Docs For relying parties
Consuming the change feed
Follow GET /v1/changes to learn when to re-resolve: cursors of the form e<epoch>.s<seq>, at-least-once delivery, and the 410 resync.
GET /v1/changes is the public, ordered feed of lifecycle events. It tells you when to resolve again. It is a hint, never a credential: the signed assertion from /v1/resolve is the only authority, and nothing in the feed changes what you accept.
The response
{
"events": [
{
"id": "evt_5a8c1f4h7k0m3p6s9v2x5z8b1d",
"event_type": "binding.activated",
"at": "2026-10-05T11:58:12.000Z",
"subject": { "trust_domain": "agents.payments.example.com" },
"data": {
"binding_id": "bnd_9r2t5w8z1b4d7f0h3k6n9q2s5v",
"organization_id": "org_7k3m9q2x8d5f1h4j6n0p2r4t8v",
"state": "active",
"binding_version": 1,
"bundle_sha256": "q7Yd3nXk0aV2pLw9sT4uHc8BzE1mR6fJ5gN0oIxK2tA"
}
}
],
"next_cursor": "ZTNmYTg1ZjY0NTcxNzQ1NjIuczQyMQ",
"has_more": false
}
Query parameters: cursor (omit it to start from the beginning of the retained feed) and limit (default 100, at most 500). Each may be given once. Responses are Cache-Control: public, max-age=10, and the route allows 60 requests per minute per client IP (429 rate_limited beyond that).
Event types
event_type |
subject |
Meaning |
|---|---|---|
binding.activated |
trust_domain |
The first published version is active |
binding.rotated |
trust_domain |
A new version is served (rotation_kind; overlap_until for an add step) |
binding.suspended, binding.resumed |
trust_domain |
Temporarily not endorsed, or endorsed again |
binding.withdrawn |
trust_domain |
No longer endorsed (terminal) |
binding.expired |
trust_domain |
Passed expires_at without renewal (terminal) |
delegation.activated |
domain (the child) |
A delegation is in force |
delegation.withdrawn, delegation.expired, delegation.revoked |
domain (the child) |
It ended; bindings depending on it left service |
claim.state_changed |
domain |
A claim moved into or out of verified. The affected bindings also get their own events. |
data carries only public, allow-listed fields; fields that do not apply are omitted. Pending steps of claims and registrations are never published, and a production feed never names test bindings.
How to consume it
- Start without a cursor, or from the
next_cursoryou saved last time. - Treat every event as "re-resolve this subject". Delivery is at least once, so deduplicate by event
id. - Save
next_cursoronly after the page is processed. Keep going whilehas_moreis true; an empty page returns the same cursor position, so poll again later from there. - A page can hold fewer events than
limiteven whenhas_moreis true: the cursor advances past rows that are not public.
Events come in commit order. Paging by cursor never skips or repeats an event, even while new ones are being written.
Cursors
A cursor is opaque to you, but for the record it is the unpadded base64url encoding of e<epoch>.s<seq>:
<epoch>is the 16-hex-digit identity of the feed's database. Restoring the database from a backup generates a new epoch.<seq>is the position of the last event read.
Only the exact canonical value the feed returned is accepted (decoding and re-encoding must reproduce it, at most 96 characters). Anything else is 400. Do not build or edit cursors.
The 410 resync
The feed answers 410 with code: "cursor_expired" when your cursor:
- is older than the 90-day retention window,
- comes from another database epoch (the directory was restored from a backup), or
- points past the current head (a restore to an older state, or a forged cursor).
{
"code": "cursor_expired",
"message": "This cursor is older than the 90-day retention of the change feed.",
"request_id": "req_3m6p9s2v5y8b1e4h7k0n3q6t9w",
"resync": "Re-resolve every trust domain you depend on, then resume from next_cursor",
"next_cursor": "ZTNmYTg1ZjY0NTcxNzQ1NjIuczQ4OTA"
}
Do what resync says: re-resolve every trust domain you depend on, then continue from the next_cursor in the 410 body, which is the current head. The 410 is never cached.
A minimal poller
let cursor = loadSavedCursor(); // undefined on first run
const seen = new Set<string>(); // persist this alongside the cursor in a real consumer
for (;;) {
const url = new URL("https://svid.io/v1/changes");
if (cursor) url.searchParams.set("cursor", cursor);
url.searchParams.set("limit", "500");
const res = await fetch(url);
if (res.status === 410) {
const body = await res.json();
await reResolveEverything();
cursor = body.next_cursor;
saveCursor(cursor);
continue;
}
if (res.status === 429) { await sleep(60_000); continue; }
if (!res.ok) { await sleep(10_000); continue; }
const page = await res.json();
for (const e of page.events) {
if (seen.has(e.id)) continue;
seen.add(e.id);
await reResolve(e.subject.trust_domain ?? e.subject.domain);
}
cursor = page.next_cursor;
saveCursor(cursor); // only after the page is processed
if (!page.has_more) await sleep(10_000);
}
For a domain subject, re-resolve the trust domains you depend on under that domain: a claim or delegation change can affect several bindings.