Docs

How Sift works, and every endpoint it exposes.

Getting startedv1.0.0

A session-aware search API for AI agents. Wire format is snake_case; errors are always a structured { error: { type, code, message, details?, request_id } } an agent can reason about.

Base URL
https://api.siftsearch.si
Authentication
Authorization: Bearer $SIFT_KEY

Every endpoint takes a Sift API key as a bearer token. The samples below read it from a SIFT_KEY environment variable, because only a hash of your key is ever stored — there is no way to show you the real one.

The machine-readable document is at https://api.siftsearch.si/v1/openapi.json.

Search

POST/v1/answer

Answer a research goal from grounded sources

Splits one messy goal into sub-queries, searches them concurrently, merges and deduplicates the sources, packs the best passages into max_tokens, and answers from that evidence alone — as prose with citations, or as JSON matching a schema you supply, with per-field provenance.

The sub-queries share a single fetch fan-out and a single embedding batch, so a four-way decomposition costs four upstream searches rather than four whole searches. Inside a session, each sub-query is deduplicated against the run independently.

curl https://api.siftsearch.si/v1/answer \
  -H "Authorization: Bearer $SIFT_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "query": "How have EU AI Act GPAI obligations changed since August 2025, and who enforces them?",
    "max_tokens": 4000,
    "depth": "auto",
    "verify": true
  }'
Set SIFT_KEY in your environment first. No SDK required.

Request body

query
stringrequired≤ 2000 chars

The goal, in whatever shape the agent has it. It does not need to be a query.

depth
"auto" | "fast" | "standard" | "deep"default "auto"

The depth every sub-query runs at. Defaults to auto here, unlike /v1/search: a caller who has handed us an unshaped goal has no basis for picking one.

max_tokens
integer500–32000

The evidence budget. Passages are packed to fit it and that packed set is what the answer is grounded in. Defaults to the calling key’s configured context budget.

max_sub_queries
integer1–8default 4

How many sub-queries the goal may be split into. Each one is a separate upstream search; they share a single fetch fan-out, one embedding batch and one budget. 1 skips decomposition entirely.

output_type
"answer" | "structured"default "answer"

answer returns prose with citations; structured returns data matching schema.

schema
object

Required when output_type is structured. A JSON Schema whose root is type: "object". Optional properties, $defs, local $refs, anyOf, enum, pattern and the numeric and array bounds are all supported and rewritten into strict form for you. allOf, oneOf, not and if/then/else are refused with invalid_request before anything is spent.

include_passages
booleandefault false

Return the evidence alongside the answer. Off by default — the citations already identify it, and returning both roughly doubles the tokens you asked for.

include_domains
string[]
exclude_domains
string[]
source_scripts
"latin" | "any"default "latin"

Which writing systems a source may be in. latin, the default, drops sources whose title and snippet are predominantly not written in the Latin alphabet before any of them is fetched — so the answer is never synthesised from, or cited to, a page the caller cannot read. any turns the check off. Script detection, not language identification: a French or German source is Latin script and always passes.

from_date
string

Inclusive lower bound, YYYY-MM-DD. Set dates here, never inside query.

to_date
string
freshness
"any" | "last_24_hours" | "last_7_days" | "last_30_days" | "last_90_days" | "last_year"

How recent a source must be. Resolves to a date bound, and sources the content itself dates outside it are dropped before they can ground an answer. Mutually exclusive with from_date and to_date.

agent_tag
string≤ 64 chars
session_id
string≤ 64 chars

The agent run this call belongs to. Each sub-query is checked against the run’s own cache independently, so a decomposition that overlaps an earlier one only pays for the sub-queries it has not asked before. Requires the sessions scope.

deltas_only
booleandefault false

Skip sources this run has already read. Requires session_id.

verify
booleandefault false

Split the answer into claims and score each one against the evidence it was written from, returning claims[]. A claim the evidence does not actually contain scores low and carries no citation, however plausible it reads. Adds one model call, which is charged and appears in upstream_calls. Ignored for output_type: "structured", which returns per-field provenance instead.

Response · 200

request_id
stringrequired
answer
string | nullrequired

Grounded in citations and nothing else. null when the search found no evidence — never a guess from the model’s own memory.

data
object

Present for output_type: "structured". Matches the schema you supplied.

citations
object[]required

The passages the answer rests on. Slice them out of the document with GET /v1/docs/{id}, free.

provenance
object[]required

For structured: which passage each field was taken from.

claims
object[] | nullrequired

Present only when verify was requested. One entry per sentence of answer, in order.

sub_queries
object[]required

The plan that actually ran, in order.

depth
objectrequired
passages
object[]required

The evidence. Empty unless you asked for include_passages.

withheld
object[]required
usage
objectrequired
session
object

Errors

  • 400invalid_request (including a schema outside the supported subset, refused before any spend), or schema_unsatisfiable (the schema is usable but the sources did not support filling it, even after two repairs)
  • 401invalid_api_key
  • 402insufficient_credits or budget_exhausted
  • 403scope_missing or depth_ceiling_exceeded
  • 429rate_limited. Carries `Retry-After` and `details.retry_after_ms`.
  • 502upstream_unavailable

Documents

GET/v1/docs/{id}

Rehydrate a cached document

Returns the full markdown of a document Sift has already fetched, by the doc_id that appears in passages[] and withheld[].

Free. It reads a cache the workspace has already paid for. Charging for it would push agents back to hoarding page text in their context window, which is the opposite of what this API is for. It is still rate-limited by the calling key’s per-minute limit.

curl https://api.siftsearch.si/v1/docs/doc_7c21f0 \
  -H "Authorization: Bearer $SIFT_KEY"
Set SIFT_KEY in your environment first. No SDK required.

Path parameters

id
stringrequirednon-empty

Response · 200

request_id
stringrequired
doc_id
stringrequired
url
stringrequired
title
stringrequired
domain
stringrequired
content_hash
stringrequired
content_source
"fetch" | "snippet"required
fetched_at
stringrequired
as_of
string | nullrequired

When the source content says it was last true, YYYY-MM-DD, inferred from the page itself. null when no date could be read from it — never the time we fetched it.

tokens
integerrequired
markdown
stringrequired

Errors

  • 401invalid_api_key
  • 403scope_missing
  • 404not_found — no such document, or its stored content has been pruned
  • 429rate_limited

Sessions

POST/v1/sessions

Open an agent run

Creates a session and returns its id. Pass that id as session_id on every search in the run, and Sift stops charging the run twice for the same thing: a question it has already asked is answered from what that answer returned, at no charge and with no upstream call, and deltas_only withholds sources whose text the run is already holding.

Sessions expire 24 hours after their last use. Requires the sessions scope.

curl https://api.siftsearch.si/v1/sessions \
  -H "Authorization: Bearer $SIFT_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "agent_tag": "policy-analyst"
  }'
Set SIFT_KEY in your environment first. No SDK required.

Request body

agent_tag
string≤ 64 chars

Free-form label for the agent doing this run. It is what the console’s "Spend by agent" report groups on, and it is fixed for the life of the session.

Response · 200

request_id
stringrequired
id
stringrequired

Pass this as session_id on every POST /v1/search in the run.

agent_tag
string | nullrequired
created_at
stringrequired
expires_at
stringrequired
queries
integerrequired
cache_hits
integerrequired
urls_seen
integerrequired
tokens_returned
integerrequired
tokens_not_spent
integerrequired
eur_charged
numberrequired
eur_saved
numberrequired

Errors

  • 400invalid_request
  • 401invalid_api_key
  • 403scope_missing — the key does not carry the `sessions` scope
  • 429rate_limited
GET/v1/sessions/{id}

Read what a run has done

Cumulative totals for the run: how much it asked, how much came from its own cache, and what that saved. Free, and it makes no upstream call.

curl https://api.siftsearch.si/v1/sessions/sess_9f3ac2e1 \
  -H "Authorization: Bearer $SIFT_KEY"
Set SIFT_KEY in your environment first. No SDK required.

Path parameters

id
stringrequirednon-empty

Response · 200

request_id
stringrequired
id
stringrequired

Pass this as session_id on every POST /v1/search in the run.

agent_tag
string | nullrequired
created_at
stringrequired
expires_at
stringrequired
queries
integerrequired
cache_hits
integerrequired
urls_seen
integerrequired
tokens_returned
integerrequired
tokens_not_spent
integerrequired
eur_charged
numberrequired
eur_saved
numberrequired

Errors

  • 401invalid_api_key
  • 403scope_missing
  • 404not_found — unknown or expired
  • 429rate_limited

Provenance

POST/v1/replay/{request_id}

Reproduce a request’s passages from stored snapshots

Returns exactly the passages a previous request returned, re-sliced from immutable snapshots of the pages as they read at the time. No upstream call and no charge — it reads content the workspace has already paid for, and it does not depend on the live web or on the document cache, either of which may have moved on.

Requires the calling key to have the search scope, and requires the *original* request to have been made with a key that had audit snapshots enabled. A request that was never snapshotted returns snapshot_unavailable, which is deliberately distinct from not_found: the id was right, the policy was not.

It is a POST because it is an audit action rather than a cacheable read, and because it is logged against the workspace like any other call.

curl https://api.siftsearch.si/v1/replay/req_2f81c04b \
  -X POST \
  -H "Authorization: Bearer $SIFT_KEY"
Set SIFT_KEY in your environment first. No SDK required.

Path parameters

request_id
stringrequired≤ 128 chars

Response · 200

request_id
stringrequired

This replay’s own id. Replays are logged like any other call.

replay_of
stringrequired
captured_at
stringrequired

When the content behind these passages was pinned.

expires_at
string | nullrequired

When it ages out. Snapshots are kept 90 days from last use.

passages
object[]required

Byte-identical to the original response, re-sliced from the snapshot and checked against the hash recorded at the time.

unavailable
object[]required

Passages that could not be reproduced, and why. Reported rather than silently dropped.

usage
objectrequired

Errors

  • 401invalid_api_key
  • 403scope_missing
  • 404not_found — no such request in this workspace. snapshot_unavailable — the request exists but nothing was snapshotted for it.
  • 429rate_limited

Research

GET/v1/research

List your research tickets

For an agent that lost a ticket id — the other half of a resumable handle. Newest first, with a keyset cursor rather than a page number, because the table takes concurrent inserts and an offset paginator would silently skip and repeat rows.

The rows are trimmed: no answer, no data, no sources. Any one of them is a cheap GET /v1/research/{id} away, and a page of twenty-five full reports would return far more than was asked for.

curl https://api.siftsearch.si/v1/research \
  -H "Authorization: Bearer $SIFT_KEY"
Set SIFT_KEY in your environment first. No SDK required.

Query parameters

status
"pending" | "running" | "completed" | "failed"
limit
integer1–100default 25
cursor
string

Response · 200

request_id
stringrequired
data
object[]required
has_more
booleanrequired
next_cursor
string | nullrequired

Errors

  • 400invalid_request — an unparseable `cursor`, or a `limit` outside 1–100.
  • 401invalid_api_key
  • 403scope_missing
  • 429rate_limited. Carries `Retry-After` and `details.retry_after_ms`.
  • 500internal_error
POST/v1/research

Start an async research task and get a ticket back

Research takes two to twenty minutes, so this returns immediately with a handle rather than holding a connection open. Read the handle with GET /v1/research/{id}, or supply a webhook_url and never poll at all.

Credits are reserved, not charged. The full price for the reasoning depth is held against the workspace when the ticket is created, so a concurrent request cannot spend it twice, and it is charged on success or released in full on any failure — the research provider does not bill for errors, so neither do we.

Send an Idempotency-Key header to make retrying safe: a replayed key returns the original ticket with a 200 instead of ordering a second research.

curl https://api.siftsearch.si/v1/research \
  -H "Authorization: Bearer $SIFT_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "query": "Compare the GPAI transparency obligations of the EU AI Act with the UK approach",
    "reasoning_depth": "M"
  }'
Set SIFT_KEY in your environment first. No SDK required.

Request body

query
stringrequired≤ 2000 chars

The question or goal to research. Whole sentences work better than keywords.

reasoning_depth
"S" | "M" | "L" | "XL"default "M"

How hard the research agent works, and the only field that moves the price: S €0.276 (2–5 min) · M €0.552 (3–7 min) · L €1.656 (5–10 min) · XL €2.760 (10–20 min). Defaults to `M`, which is not the upstream default — L would make omitting one field cost €1.66. A key can cap this with its research ceiling, and a request above the ceiling is refused before anything is spent.

mode
"auto" | "answer" | "investigate" | "research"default "auto"

answer for a question with a definite answer, investigate for one subject examined from every angle, research for a themed report across many subjects, auto to let the agent choose. Does not change the price.

output_type
"answer" | "structured"default "answer"

answer returns prose with sources; structured returns data matching schema.

schema
object

Required when output_type is structured. A JSON Schema whose root is type: "object". Passed to the research provider as given — unlike /v1/answer, which rewrites your schema for constrained decoding, this is a different engine with a different supported subset, so we do not pretend to know what it will accept.

include_domains
string[]
exclude_domains
string[]
from_date
string

Inclusive lower bound, YYYY-MM-DD. Set dates here, never inside query.

to_date
string
agent_tag
string≤ 64 chars
session_id
string≤ 64 chars

The agent run this research belongs to. Cost attribution only — a research is neither served from nor added to the run’s query cache, and deltas_only does not apply. What it does is bring research spend under the run’s per-run cap. Requires the sessions scope.

webhook_url
string≤ 2048 chars

An https endpoint to POST the result to, so you never have to poll. Signed with your workspace’s webhook secret as Sift-Signature: t=<unix>,v1=<hex hmac-sha256 of "t.body">. Retried on a 5xx, a 408, a 425, a 429 or a network failure — eight attempts over about forty minutes — and abandoned immediately on any other 4xx, which will not start working. Deduplicate on the Sift-Event-Id header: delivery is at-least-once.

Response · 200

request_id
stringrequired
id
stringrequired
status
"pending" | "running" | "completed" | "failed"required
stage
"starting" | "researching" | "finalising" | "done"required
query
stringrequired
mode
"auto" | "answer" | "investigate" | "research"required
reasoning_depth
"S" | "M" | "L" | "XL"required
output_type
"answer" | "structured"required
created_at
stringrequired
started_at
string | nullrequired
completed_at
string | nullrequired
output_available
booleanrequired

False once the stored answer has aged out of retention. The ticket and its cost survive; the text does not.

error
object | nullrequired

Why the research failed. A ticket-state vocabulary, not an API error code — it arrives on a 200, describing the resource rather than refusing your request. A failed research is charged nothing.

usage
objectrequired
webhook
object | nullrequired
progress
objectrequired

Where the ticket is, as far as we can honestly tell. The research provider exposes no progress signal and no partial output — the result appears all at once — so this reports what we measured: how long it has been running, the provider’s own coarse status, and the expected window for this reasoning depth. There is no state in which a research is half-answered.

poll_after_ms
integer | nullrequired

How long to wait before reading this ticket again — aimed just after our own next check upstream, so every read returns something new. Null once the ticket is finished. Also sent as Retry-After.

answer
string | nullrequired
data
object

The structured result, when output_type is structured.

sources
object[]required

Errors

  • 400invalid_request — `output_type: "structured"` without a `schema`, a `schema` whose root is not an object, a malformed date, or a `webhook_url` that is not https, carries credentials, or points at a private, loopback or link-local address.
  • 401invalid_api_key
  • 402insufficient_credits or budget_exhausted. Available balance excludes credits already held by research in flight, so `details.reserved_eur` distinguishes "you have none" from "yours are committed".
  • 403scope_missing (the key has no `research` scope, or no `sessions` scope for a `session_id`) or depth_ceiling_exceeded (the `reasoning_depth` is above the key’s research ceiling)
  • 404not_found — the `session_id` does not belong to this workspace, or has expired.
  • 429rate_limited. Carries `Retry-After` and `details.retry_after_ms`.
  • 500internal_error
  • 502upstream_unavailable. The research was never started and the reservation has been released in full.
GET/v1/research/{id}

Read a research ticket

Free, and safe to call as often as you like — but read poll_after_ms (also sent as Retry-After) and honour it. It is aimed at the moment just after our own next check upstream, so polling harder returns an identical body.

The same shape a webhook delivers, so there is one thing to parse either way. A research that failed is a 200 carrying status: "failed" and an error block: the read succeeded, and the failure is the ticket’s state rather than a refusal of your request.

curl https://api.siftsearch.si/v1/research/rsch_7c21f0a1 \
  -H "Authorization: Bearer $SIFT_KEY"
Set SIFT_KEY in your environment first. No SDK required.

Path parameters

id
stringrequired≤ 64 chars

Response · 200

request_id
stringrequired
id
stringrequired
status
"pending" | "running" | "completed" | "failed"required
stage
"starting" | "researching" | "finalising" | "done"required
query
stringrequired
mode
"auto" | "answer" | "investigate" | "research"required
reasoning_depth
"S" | "M" | "L" | "XL"required
output_type
"answer" | "structured"required
created_at
stringrequired
started_at
string | nullrequired
completed_at
string | nullrequired
output_available
booleanrequired

False once the stored answer has aged out of retention. The ticket and its cost survive; the text does not.

error
object | nullrequired

Why the research failed. A ticket-state vocabulary, not an API error code — it arrives on a 200, describing the resource rather than refusing your request. A failed research is charged nothing.

usage
objectrequired
webhook
object | nullrequired
progress
objectrequired

Where the ticket is, as far as we can honestly tell. The research provider exposes no progress signal and no partial output — the result appears all at once — so this reports what we measured: how long it has been running, the provider’s own coarse status, and the expected window for this reasoning depth. There is no state in which a research is half-answered.

poll_after_ms
integer | nullrequired

How long to wait before reading this ticket again — aimed just after our own next check upstream, so every read returns something new. Null once the ticket is finished. Also sent as Retry-After.

answer
string | nullrequired
data
object

The structured result, when output_type is structured.

sources
object[]required

Errors

  • 401invalid_api_key
  • 403scope_missing
  • 404not_found. Unknown ids, another workspace’s ids and tickets deleted by retention are indistinguishable, deliberately.
  • 429rate_limited. Carries `Retry-After` and `details.retry_after_ms`.
  • 500internal_error

Watches

GET/v1/watches

List your watches

Newest first, with a keyset cursor rather than a page number, because the table takes concurrent inserts and an offset paginator would silently skip and repeat rows.

curl https://api.siftsearch.si/v1/watches \
  -H "Authorization: Bearer $SIFT_KEY"
Set SIFT_KEY in your environment first. No SDK required.

Query parameters

status
"active" | "paused"
limit
integer1–100default 25
cursor
string

Response · 200

request_id
stringrequired
data
object[]required
has_more
booleanrequired
next_cursor
string | nullrequired

Errors

  • 400invalid_request — an unparseable `cursor`, or a `limit` outside 1–100.
  • 401invalid_api_key
  • 403scope_missing — the key has no `watch` scope
  • 429rate_limited. Carries `Retry-After` and `details.retry_after_ms`.
  • 500internal_error
POST/v1/watches

Create a scheduled query that reports only what changed

A watch runs your query on a schedule and reports the delta since its last run: sources that are new, sources whose content moved, and sources that dropped out. Its first run sets a baseline, so everything is new; a later run over pages that have not changed returns no passages at all, which is the point.

Change is a similarity, not a hash. Live pages carry navigation, related-article rails and relative timestamps that differ on every fetch, so comparing content hashes would report every source as changed every time. A source counts as changed when enough of its text has actually moved.

Every run is billed like the search it is, against the key that created the watch — its scopes, depth ceiling and monthly budget govern the schedule for as long as it exists. Runs that cannot be afforded fail rather than overdraw, and five consecutive failures pause the watch with a reason.

Send an Idempotency-Key header to make retrying safe: a replayed key returns the original watch with a 200 instead of creating a second schedule.

curl https://api.siftsearch.si/v1/watches \
  -H "Authorization: Bearer $SIFT_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "EU AI Act GPAI obligations",
    "query": "changes to EU AI Act GPAI obligations",
    "cadence": "daily",
    "depth": "standard",
    "max_tokens": 4000
  }'
Set SIFT_KEY in your environment first. No SDK required.

Request body

name
stringrequired≤ 120 chars

What this watch is for. Also its default agent_tag, so spend is attributable.

query
stringrequired≤ 2000 chars
cadence
"hourly" | "every_6_hours" | "daily" | "weekly"required

How often the watch runs. A run refetches its sources rather than reading the document cache — a watch that was served yesterday’s bytes would report that nothing ever changes — so a shorter cadence costs proportionally more.

depth
"auto" | "standard" | "deep"default "standard"

fast is not available: it fetches no pages, so it cannot tell that one changed. auto is floored at standard.

max_tokens
integer500–32000

The context budget for each run’s passages. Defaults to the key’s.

max_results
integer1–100
include_domains
string[]
exclude_domains
string[]
source_scripts
"latin" | "any"default "latin"
freshness
"any" | "last_24_hours" | "last_7_days" | "last_30_days" | "last_90_days" | "last_year"

Applied to every run. Mutually exclusive with from_date/to_date.

from_date
string
to_date
string
agent_tag
string≤ 64 chars

Defaults to name. Drives the per-agent spend report.

webhook_url
string

Where to POST each run. HTTPS only, signed with the workspace secret, and never sent to a private, loopback or link-local address.

notify_on
"change" | "every_run"default "change"

change posts only when something actually moved — the point of a watch. every_run posts every time, as a heartbeat.

Response · 200

request_id
stringrequired
id
stringrequired
name
stringrequired
query
stringrequired
cadence
"hourly" | "every_6_hours" | "daily" | "weekly"required
status
"active" | "paused"required
agent_tag
string | nullrequired
params
objectrequired
webhook_url
string | nullrequired
notify_on
"change" | "every_run"required
next_run_at
string | nullrequired

Null when the watch is paused. Carries a small jitter so a cohort of watches does not fire on the same minute.

last_run_at
string | nullrequired
run_count
integerrequired
consecutive_failures
integerrequired

Five in a row pauses the watch, with paused_reason saying why.

paused_reason
string | nullrequired
created_at
stringrequired

Errors

  • 400invalid_request — `freshness` together with `from_date`/`to_date`, a malformed date, or a `webhook_url` that is not https, carries credentials, or points at a private, loopback or link-local address.
  • 401invalid_api_key
  • 402insufficient_credits or budget_exhausted
  • 403scope_missing — the key has no `watch` scope
  • 429rate_limited. Carries `Retry-After` and `details.retry_after_ms`.
  • 500internal_error
GET/v1/watches/{id}

Read one watch

Returns the watch with its current schedule, its status and its run counters — including consecutive_failures, which is what pauses a watch once it reaches five.

Free, and does not touch the schedule: reading a watch never advances next_run_at and never triggers a run. Use POST /v1/watches/{id}/run for that.

curl https://api.siftsearch.si/v1/watches/wch_7c21f0a1 \
  -H "Authorization: Bearer $SIFT_KEY"
Set SIFT_KEY in your environment first. No SDK required.

Path parameters

id
stringrequired≤ 64 chars

Response · 200

request_id
stringrequired
id
stringrequired
name
stringrequired
query
stringrequired
cadence
"hourly" | "every_6_hours" | "daily" | "weekly"required
status
"active" | "paused"required
agent_tag
string | nullrequired
params
objectrequired
webhook_url
string | nullrequired
notify_on
"change" | "every_run"required
next_run_at
string | nullrequired

Null when the watch is paused. Carries a small jitter so a cohort of watches does not fire on the same minute.

last_run_at
string | nullrequired
run_count
integerrequired
consecutive_failures
integerrequired

Five in a row pauses the watch, with paused_reason saying why.

paused_reason
string | nullrequired
created_at
stringrequired

Errors

  • 401invalid_api_key
  • 403scope_missing — the key has no `watch` scope
  • 404not_found — no such watch in this workspace
  • 429rate_limited. Carries `Retry-After` and `details.retry_after_ms`.
  • 500internal_error
PATCH/v1/watches/{id}

Pause, resume, rename or re-schedule a watch

Resuming an active watch is a no-op; resuming a paused one clears paused_reason, resets the failure count and arms the next run. Changing the cadence takes effect from the next run — the one already scheduled keeps its time.

The query and the search parameters are deliberately not editable: a watch’s whole value is that its runs are comparable, and a delta across a changed query would be a diff between two different questions. Create a new watch instead.

curl https://api.siftsearch.si/v1/watches/wch_7c21f0a1 \
  -X PATCH \
  -H "Authorization: Bearer $SIFT_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "status": "paused"
  }'
Set SIFT_KEY in your environment first. No SDK required.

Path parameters

id
stringrequired≤ 64 chars

Request body

name
string≤ 120 chars
cadence
"hourly" | "every_6_hours" | "daily" | "weekly"
status
"active" | "paused"

Pause or resume. Resuming re-arms the schedule and clears paused_reason.

webhook_url
string | null
notify_on
"change" | "every_run"

Response · 200

request_id
stringrequired
id
stringrequired
name
stringrequired
query
stringrequired
cadence
"hourly" | "every_6_hours" | "daily" | "weekly"required
status
"active" | "paused"required
agent_tag
string | nullrequired
params
objectrequired
webhook_url
string | nullrequired
notify_on
"change" | "every_run"required
next_run_at
string | nullrequired

Null when the watch is paused. Carries a small jitter so a cohort of watches does not fire on the same minute.

last_run_at
string | nullrequired
run_count
integerrequired
consecutive_failures
integerrequired

Five in a row pauses the watch, with paused_reason saying why.

paused_reason
string | nullrequired
created_at
stringrequired

Errors

  • 400invalid_request
  • 401invalid_api_key
  • 403scope_missing — the key has no `watch` scope
  • 404not_found — no such watch in this workspace
  • 429rate_limited. Carries `Retry-After` and `details.retry_after_ms`.
  • 500internal_error
DELETE/v1/watches/{id}

Delete a watch and its run history

Removes the schedule and every run it recorded. The billing history stays: request_log and the credit ledger are an audit trail, and a deleted schedule does not take its receipts with it.

curl https://api.siftsearch.si/v1/watches/wch_7c21f0a1 \
  -X DELETE \
  -H "Authorization: Bearer $SIFT_KEY"
Set SIFT_KEY in your environment first. No SDK required.

Path parameters

id
stringrequired≤ 64 chars

Response · 200

request_id
stringrequired
id
stringrequired
deleted
truerequired

Errors

  • 401invalid_api_key
  • 403scope_missing — the key has no `watch` scope
  • 404not_found — no such watch in this workspace
  • 429rate_limited. Carries `Retry-After` and `details.retry_after_ms`.
  • 500internal_error
POST/v1/watches/{id}/run

Run a watch now, without waiting for its schedule

Queues an extra run immediately. It is billed exactly like a scheduled one and produces the same delta against the last completed run.

This is also how a watch whose job chain was interrupted is re-armed, and how you take a first look without waiting out a weekly cadence. If a run is already armed this brings that one forward rather than adding a second — the run is the unit of work, and triggering it twice is harmless. Returns 202 with the run; poll GET /v1/watches/{id}/runs for the result.

curl https://api.siftsearch.si/v1/watches/wch_7c21f0a1/run \
  -X POST \
  -H "Authorization: Bearer $SIFT_KEY"
Set SIFT_KEY in your environment first. No SDK required.

Path parameters

id
stringrequired≤ 64 chars

Response · 202

request_id
stringrequired
watch_id
stringrequired
run
objectrequired

Errors

  • 401invalid_api_key
  • 403scope_missing — the key has no `watch` scope
  • 404not_found — no such watch in this workspace
  • 429rate_limited. Carries `Retry-After` and `details.retry_after_ms`.
  • 500internal_error
GET/v1/watches/{id}/runs

List a watch’s runs

Newest first. Summaries only — the per-source delta and the passages live on the run detail, so a poll stays cheap.

curl https://api.siftsearch.si/v1/watches/wch_7c21f0a1/runs \
  -H "Authorization: Bearer $SIFT_KEY"
Set SIFT_KEY in your environment first. No SDK required.

Path parameters

id
stringrequired≤ 64 chars

Query parameters

limit
integer1–100default 25
cursor
string

Response · 200

request_id
stringrequired
watch_id
stringrequired
data
object[]required
has_more
booleanrequired
next_cursor
string | nullrequired

Errors

  • 401invalid_api_key
  • 403scope_missing — the key has no `watch` scope
  • 404not_found — no such watch in this workspace
  • 429rate_limited. Carries `Retry-After` and `details.retry_after_ms`.
  • 500internal_error
GET/v1/watches/{id}/runs/{run_id}

Read one run: what changed, and the passages that changed

The delta and the passages packed from the new and changed sources only. unchanged sources are omitted unless include_unchanged is set — resending what has not moved is the token spend a watch exists to avoid — and the counts in delta still account for them.

Passages are rebuilt from the live document store, so their char_start/char_end always slice back to the text they name. A run whose documents have since been pruned returns its delta with no passages rather than offsets that no longer hold.

curl https://api.siftsearch.si/v1/watches/wch_7c21f0a1/runs/wrun_3f81c04b \
  -H "Authorization: Bearer $SIFT_KEY"
Set SIFT_KEY in your environment first. No SDK required.

Path parameters

id
stringrequired≤ 64 chars
run_id
stringrequired≤ 64 chars

Query parameters

include_unchanged
boolean | nulldefault false

Response · 200

request_id
stringrequired
watch_id
stringrequired
run
objectrequired
sources
object[]required

What each source did since the last run. unchanged sources are omitted unless you ask for them — resending what has not moved is the token spend a watch exists to avoid, and the counts in delta still account for them.

passages
object[]required

Passages from the new and changed sources only, packed to the watch’s budget.

Errors

  • 401invalid_api_key
  • 403scope_missing — the key has no `watch` scope
  • 404not_found — no such watch or run in this workspace
  • 429rate_limited. Carries `Retry-After` and `details.retry_after_ms`.
  • 500internal_error

MCP

POST/v1/mcp

Model Context Protocol endpoint

A Streamable HTTP MCP endpoint at /v1/mcp, exposing sift_search, sift_answer and sift_read_doc, plus cached documents as sift://doc/{doc_id} resources. Authenticate with the same Authorization: Bearer sk_… header as every other endpoint — there is no OAuth flow.

The body is a single JSON-RPC 2.0 request; batches are refused. A tool call is billed, rate-limited and logged exactly as the REST endpoint it wraps, so POST /v1/search and sift_search cost the same and appear side by side in the request log.

curl https://api.siftsearch.si/v1/mcp \
  -H "Authorization: Bearer $SIFT_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "tools/call",
    "params": {
      "name": "sift_search",
      "arguments": {
        "query": "changes to EU AI Act GPAI obligations",
        "max_tokens": 2000
      }
    }
  }'
Set SIFT_KEY in your environment first. No SDK required.

Errors

  • 400Malformed JSON-RPC, or a batch.
  • 401Missing or invalid API key.
  • 405GET and DELETE — this endpoint serves no server-initiated stream.
Error codesStructured refusals an agent can branch on.
invalid_request
400 · invalid_request
The request body or parameters failed validation. `details.issues` names the offending fields.
not_found
404 · invalid_request
No such resource in this workspace.
schema_unsatisfiable
400 · invalid_request
The JSON Schema supplied for structured output is outside the supported subset.
snapshot_unavailable
404 · invalid_request
The stored evidence for this request has expired or was never captured.
invalid_api_key
401 · auth
The key is missing, malformed or revoked.
scope_missing
403 · auth
The key is valid but lacks the scope this endpoint needs. `details` names it.
depth_ceiling_exceeded
403 · policy
The requested depth is above the key’s ceiling. Requested depth is refused rather than silently downgraded.
insufficient_credits
402 · budget
The workspace balance cannot cover this call.
budget_exhausted
402 · budget
A configured budget cap was reached. `details` carries what was spent and the cap.
rate_limited
429 · rate_limit
The key’s per-minute limit was exceeded. `retry-after` says how long to wait.
upstream_unavailable
502 · upstream
The upstream search provider failed. Nothing was charged.
internal_error
500 · internal
Something broke on our side. Quote the request_id.

Every error body is the same shape, whatever went wrong. Quote the request_id in a support ticket.

{
  "error": {
    "type": "budget",
    "code": "budget_exhausted",
    "message": "Per-run budget of €0.75 reached.",
    "details": { "spent_eur": 0.75, "cap_eur": 0.75 },
    "request_id": "req_2f81c04b"
  }
}