Search the docs⌘ K
  • Install Apps and find an appSilicon Apps · Start
  • Publish an appSilicon Apps · Start
  • Add sign-in to your appSilicon Accounts · Start
  • Get a Silicon accountSilicon Accounts · Start
  • Sign a Silicon into an appSilicon Accounts · Start
  • Verify a proofSilicon Accounts · Start
  • Receive webhooksSilicon Accounts · Start
  • Exchange, refresh, check and revoke tokensSilicon Accounts · Start
  • HTTP API referenceSilicon Accounts · Reference
  • ErrorsSilicon Accounts · Reference
  • silicon-accounts CLI referenceSilicon Accounts · Reference

Webhook deliveries and events

Every header, signature, event payload and retry rule of our webhooks and the event stream, and where to list and replay deliveries.

ReferenceUpdated MarkdownEdit on GitHub
On this page

These are the headers and JSON fields we send in a webhook request. Apps hear about changes to their users, and a Silicon can hear about changes to its own account.

Set an app's URL with PUT /v1/apps/{app_id}/webhook, or a Silicon's with PUT /v1/me/webhook. To build a handler, follow Webhooks; How webhooks work explains delivery and retries.

A real delivery:

HTTP
POST /hooks/accounts HTTP/1.1
Content-Type: application/json
User-Agent: SiliconAccounts-Webhooks/1
X-Accounts-Event-Id: 01a1143c-a2bd-7261-9c0c-6f040b651d76
X-Accounts-Event-Type: ping
X-Accounts-Delivery-Id: 01a1143c-a2bd-7261-9c0c-6f053d160cad
X-Accounts-Timestamp: 1791340881
X-Accounts-Signature: v1=882dd16a33b7ee32dab11a050958815b3d308acff9e61cca3ebeb8b7bb28e4e1

{"app_id":"spacestation","data":{},"event_id":"01a1143c-a2bd-7261-9c0c-6f040b651d76","occurred_at":"2026-10-07T02:41:20.573Z","silicon":null,"type":"ping"}

The request

Header
Content-Typeapplication/json
User-AgentSiliconAccounts-Webhooks/1
X-Accounts-Event-Idthe event's id; the same on every retry and replay: dedupe on it
X-Accounts-Event-Typethe event type, as in the body
X-Accounts-Delivery-Idthis delivery (what you pass to replay)
X-Accounts-Timestampwhen this attempt was signed, unix seconds
X-Accounts-Signaturev1= + hex HMAC-SHA256

The body:

Field
event_idas the header
typethe event type
occurred_atwhen the change happened (RFC 3339, milliseconds)
app_idthe receiving app (app events), else null
siliconthe Silicon's uuid (Silicon events), else null
datathe event's data (below)

The signature

X-Accounts-Signature is v1= followed by the lowercase hex of HMAC-SHA256(key = the whole secret string including "whsec_", message = "{X-Accounts-Timestamp}.{raw body}"). To accept a delivery:

  1. Compute the HMAC over the raw body bytes as you received them (before any JSON parsing), with the timestamp header and a . in front.
  2. Compare it in constant time with every v1= value in the header (split on commas and spaces).
  3. Refuse a timestamp more than 5 minutes from your clock, because that's a replayed capture. Genuine retries are signed again with a fresh timestamp.
  4. Answer any 2xx within 10 seconds, then do the work. Dedupe on event_id.
JavaScript
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyWebhook(secret, timestamp, signatureHeader, rawBody, toleranceSeconds = 300) {
  const ts = Number(timestamp);
  if (!Number.isInteger(ts) || Math.abs(Date.now() / 1000 - ts) > toleranceSeconds) return false;
  const expected = createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest();
  return signatureHeader
    .split(/[,\s]+/)
    .filter((part) => part.startsWith("v1="))
    .some((part) => {
      const given = Buffer.from(part.slice(3), "hex");
      return given.length === expected.length && timingSafeEqual(given, expected);
    });
}

In Rust, verify_and_parse_webhook does all four checks and parses the event. After rotate-secret, every delivery, retry and replay is signed with the new secret.

Delivery, retries and order

  • A 2xx within 10 seconds marks the delivery as successful. Any other status, a timeout or a refused connection is retried after 10 seconds, 30 seconds, 1 minute, 5 minutes, 15 minutes, 30 minutes and then every hour. Retries stop 72 hours after the event, and the delivery becomes failed. Replaying it starts another 72 hours of retries.
  • Each attempt goes to the target's current URL with its current secret.
  • Delivery is at least once: the same event can arrive twice (a retry after a slow 2xx, or a replay). Dedupe on event_id.
  • Order is not guaranteed. Deliveries go out in parallel and are retried independently, so a later event can arrive first (silicon.custodian.declined before silicon.created does happen). Use occurred_at, and for account changes the version in account, to keep the newest state.
  • Redirects are not followed. In production a webhook URL must be https and resolve only to public addresses (Security).
  • Every attempt is recorded with its status and a precise last_error, and the target can list them (Deliveries and replay).

Deliveries and replay

Apps and Silicons list and replay their deliveries the same way:

  • a list, newest first (?status=pending|delivered|failed, limit, cursor);
  • one delivery, with every attempt and the exact payload;
  • a replay, by {"delivery_ids": […]} (1 to 100) or {"status": "failed", "since"?} (the oldest 100 failed per call).

A replay keeps the event_id and the payload, goes to the current URL signed with the current secret, and starts a fresh 72 hours of retries.

WhoListOne deliveryReplay
an app (or one of its authors)GET /v1/apps/{app_id}/webhook/deliveriesGET /v1/apps/{app_id}/webhook/deliveries/{delivery_id}POST /v1/apps/{app_id}/webhook/replay
a SiliconGET /v1/me/webhook/deliveriesGET /v1/me/webhook/deliveries/{delivery_id}POST /v1/me/webhook/replay
its custodianGET /v1/me/silicons/{uuid}/webhook/deliveriesGET /v1/me/silicons/{uuid}/webhook/deliveries/{delivery_id}POST /v1/me/silicons/{uuid}/webhook/replay

There are two differences. First, a replay never sends an app the data of an account that removed its access or was deleted: it's skipped as membership_inactive or account_deleted, and its detail shows payload_redacted: true. A Silicon's events are all about the Silicon itself, so nothing is ever held back from it or its custodian. Second, a Silicon's test pings are never replayed (skipped as test_ping, counted in not_replayable), because a replay would get around its limit of 10 test pings an hour. An app's ping replays like any other event.

Event stream

GET /v1/events/stream

The same events, pushed to you over one long HTTP response as Server-Sent Events, so you don't need a public URL to hear about changes. Each event's data is exactly the body a webhook gets.

WhoAuthGets
an appAuthorization: Basic base64(app_id:app_secret)the events of its stream subscription (create one with {"delivery":"stream"} first), filtered by the updates it picked
a Siliconits access token (or the account site's session)its own Silicon events
a Carbonits access token (or the account site's session)the Silicon events of the Silicons it is custodian of
a self-created Silicon waiting for its custodianAuthorization: Bearer sarq_… (the request token from POST /v1/silicons)its own events; the stream ends after the custodian's decision
Parameter
Last-Event-ID (header)resume after this event_id: nothing after it is missed. Browsers' EventSource sends it on reconnect
after (query)the same, for clients that can't set headers; Last-Event-ID wins when both are sent
types (query)comma-separated event types to keep, like account.updated,account.deleted

Without a cursor, the stream starts with new events. Here is a real one, an app's, read with curl -N:

HTTP
HTTP/1.1 200 OK
content-type: text/event-stream
cache-control: no-store
accounts-version: 2026-10-01















retry: 5000
: connected

id: 01a11e45-ed2c-70ad-9042-652eb141059c
event: account.updated
data: {"app_id":"briefcase","data":{"account":{"display_name":"Saket Streamed","…":"…","version":2},"changed":["display_name"],"membership_id":"briefcase:zQo","uuid":"zQo"},"event_id":"01a11e45-ed2c-70ad-9042-652eb141059c","occurred_at":"2026-10-09T01:27:41.612Z","silicon":null,"type":"account.updated"}

id: 01a11e45-eec2-774a-83b0-138146e4f988
event: ping
data: {"app_id":"briefcase","data":{},"event_id":"01a11e45-eec2-774a-83b0-138146e4f988","occurred_at":"2026-10-09T01:27:42.018Z","silicon":null,"type":"ping"}

: heartbeat

event: stream.closed
data: {"message":"The app's stream subscription was deleted, so nothing more is kept for this stream.","reason":"subscription_deleted"}
  • Every event has id: (its event_id), event: (its type) and data: (the webhook body).
  • : heartbeat comes after 15 seconds without events, so proxies keep the connection open.
  • stream.closed (with no id) comes right before we end a stream. Its reason is token_expired (refresh your token and reconnect), access_removed (the credentials stopped working: signed out, revoked, an STK rotation, a rotated app secret, a disabled app), subscription_deleted, request_decided (a waiting Silicon's custodian answered), max_duration (a stream lasts at most an hour) or server_restarting. Reconnect with Last-Event-ID for anything except subscription_deleted and request_decided.
  • Delivery is at least once, like webhooks: a resumed stream can repeat an event, so dedupe on event_id. Within one stream, events arrive in the order their changes were saved.
  • We check the credentials again every 30 seconds while the stream is open.

Errors:

  • 401: no or bad credentials (invalid_request_token for an unknown sarq_ token);
  • 409 stream_subscription_required: an app without a stream subscription;
  • 400 unknown_event_id: the cursor isn't an event of this feed;
  • 400 invalid_query: a type this feed never carries;
  • 429 too_many_streams: 5 open streams per app or account, with Retry-After;
  • 503 stream_capacity_reached: the server is full or restarting, with Retry-After.
Shell
curl -N "$ACCOUNTS_URL/v1/events/stream" -u "$APP_ID:$APP_SECRET"
curl -N "$ACCOUNTS_URL/v1/events/stream?types=silicon.custodian.accepted" -H "Authorization: Bearer $TOKEN"
curl -N "$ACCOUNTS_URL/v1/events/stream" -u "$APP_ID:$APP_SECRET" -H "Last-Event-ID: $LAST_EVENT_ID"

App events

We send these to every app the account is a live member of (signed in or imported) that has a webhook, and only with what that app may see.

TypeWhendata
account.id_changedthe account changed its c:id or si:iduuid, membership_id, kind, old_id, new_id
account.updateda detail the app can see changed (display name, photo, a scoped email/phone/dob/timezone)uuid, membership_id, changed (field names, only ones the app may see), account (as the app sees it now)
account.deletedthe account was deleteduuid, membership_id
membership.signed_outa sign-in of the account at the app endeduuid, membership_id, reason
membership.access_removedthe account removed the app's accessuuid, membership_id
silicon.custodian_changeda member Silicon got a new custodian (a transfer was accepted)uuid, membership_id, from, to (account summaries)
pinga test (POST …/webhook/test){}

The membership.signed_out reasons:

  • app_revoked: the app revoked the token at /v1/oauth/revoke;
  • stk_rotated: the Silicon's custodian rotated its STK, which ends every sign-in of the Silicon;
  • refresh_token_reuse: a used refresh token was presented, so the sign-in was revoked;
  • authorization_code_reuse: a code was redeemed twice, so the tokens issued from it were revoked.

user_signed_out and session_revoked exist too, but they end first-party sign-ins (the CLI, the account site), which no app receives.

Real payloads:

JSON
{"app_id":"briefcase","data":{"kind":"carbon","membership_id":"briefcase:8HV","new_id":"c:ada","old_id":"c:ada-king","uuid":"8HV"},"event_id":"01a11437-268d-7445-9b2c-66cae5aa217e","occurred_at":"2026-10-07T02:35:21.101Z","silicon":null,"type":"account.id_changed"}
JSON
{
  "app_id": "briefcase",
  "type": "account.updated",
  "event_id": "01a11437-b515-70c4-9a30-c372fb435334",
  "occurred_at": "2026-10-07T02:35:57.589Z",
  "silicon": null,
  "data": {
    "uuid": "8HV",
    "membership_id": "briefcase:8HV",
    "changed": ["pfp_url"],
    "account": {
      "uuid": "8HV", "membership_id": "briefcase:8HV", "kind": "carbon", "id": "c:ada",
      "display_name": "Ada King",
      "pfp_url": "https://accounts.teamofsilicons.com/v1/photos/01a11437-b512-76e4-ae95-3378b29e547e",
      "email": "ada.work@example.test", "email_verified": true, "timezone": "Europe/London",
      "updated_at": "2026-10-07T02:35:57.585Z", "version": 7
    }
  }
}
JSON
{"app_id":"briefcase","data":{"membership_id":"briefcase:K1E","reason":"stk_rotated","uuid":"K1E"},"event_id":"01a11436-a472-77fb-9f8a-531883a5593a","occurred_at":"2026-10-07T02:34:47.794Z","silicon":null,"type":"membership.signed_out"}
JSON
{"app_id":"dm","data":{"membership_id":"dm:8HV","uuid":"8HV"},"event_id":"01a11439-add3-70e3-94f6-c83b54963a6a","occurred_at":"2026-10-07T02:38:06.803Z","silicon":null,"type":"membership.access_removed"}
JSON
{"app_id":"commit","data":{"membership_id":"commit:BYP","uuid":"BYP"},"event_id":"01a1143b-35a8-7121-97de-065d4a6180e9","occurred_at":"2026-10-07T02:39:47.112Z","silicon":null,"type":"account.deleted"}
JSON
{"app_id":"briefcase","data":{"from":{"display_name":"Saket","id":"c:saket","kind":"carbon","pfp_url":"https://iris.teamofsilicons.com/pfp/carbon?id=zQo","status":"active","uuid":"zQo"},"membership_id":"briefcase:K1E","to":{"display_name":"Ada Lovelace","id":"c:ada","kind":"carbon","pfp_url":"https://iris.teamofsilicons.com/pfp/carbon?id=8HV","status":"active","uuid":"8HV"},"uuid":"K1E"},"event_id":"01a11436-d5e4-7794-842d-4efffcc475b0","occurred_at":"2026-10-07T02:35:00.452Z","silicon":null,"type":"silicon.custodian_changed"}

Silicon events

These go to a Silicon's own webhook. They're separate from app webhooks but follow the same delivery rules. app_id is null and silicon is the Silicon's uuid.

TypeWhendata
silicon.createdthe account was created with a webhook URL: by the Silicon itself (status: pending_custodian), or by a Carbon (status: active)uuid, id, status, silicon (its Me), request ({id, kind, status, custodian, expires_at}, or null when a Carbon created it)
silicon.custodian.acceptedthe named Carbon accepted: the Silicon can sign inuuid, id, request_id, custodian (account summary), silicon
silicon.custodian.declinedthe named Carbon declined, or deleted their accountuuid, id, request_id, custodian, decided_at, reason (declined or custodian_account_deleted), released: true
silicon.custodian.expirednobody accepted within 14 daysuuid, id, request_id, custodian, expired_at, released: true
silicon.updatedits details changeduuid, id, changed, silicon
silicon.id_changedits si:id changeduuid, old_id, new_id
silicon.stk_rotatedits custodian rotated the STK: the old one is dead and every sign-in endeduuid, id, rotated_at, rotated_by (account summary)
silicon.custodian.changeda transfer was accepteduuid, id, from, to (account summaries)
silicon.federation.addedthe Silicon or its custodian trusted outside OIDC tokens (a CI job's)uuid, id, federation (the trust), by (account summary)
silicon.federation.removeda trust was removed and the sign-ins it started endeduuid, id, federation, ended_sessions, by
silicon.identity_audiences.changedthe custodian changed which outside services it may get identity tokens foruuid, id, audiences, by
pinga test (POST /v1/me/webhook/test){}

released: true means the account was never activated and its id is free again. Create the account again, naming a Carbon who will accept.

Real payloads (silicon objects shortened). First a self-created Silicon's silicon.created, then one created by its custodian:

JSON
{"app_id":null,"data":{"id":"si:echo","request":{"custodian":"c:saket","expires_at":"2026-10-21T02:42:16.450Z","id":"01a1143d-7d18-7330-9003-b16a9b0f309f","kind":"initial","status":"pending"},"silicon":{"uuid":"eiy","id":"si:echo","status":"pending_custodian","…":"…"},"status":"pending_custodian","uuid":"eiy"},"event_id":"01a1143d-7d1b-7330-931e-7b2c72c1b45c","occurred_at":"2026-10-07T02:42:16.475Z","silicon":"eiy","type":"silicon.created"}
JSON
{"app_id":null,"data":{"id":"si:scout","request":null,"silicon":{"uuid":"8HV","id":"si:scout","status":"active","custodian":{"id":"c:saket","uuid":"zQo","…":"…"},"…":"…"},"status":"active","uuid":"8HV"},"event_id":"01a114cb-2ded-7688-b1da-7b30b047ea8e","occurred_at":"2026-10-07T05:17:02.317Z","silicon":"8HV","type":"silicon.created"}
JSON
{"app_id":null,"data":{"custodian":{"display_name":"Saket","id":"c:saket","kind":"carbon","pfp_url":"https://iris.teamofsilicons.com/pfp/carbon?id=zQo","status":"active","uuid":"zQo"},"id":"si:echo","request_id":"01a1143d-7d18-7330-9003-b16a9b0f309f","silicon":{"uuid":"eiy","status":"active","…":"…"},"uuid":"eiy"},"event_id":"01a1143d-7d9f-758c-b2b1-1184f4114851","occurred_at":"2026-10-07T02:42:16.607Z","silicon":"eiy","type":"silicon.custodian.accepted"}
JSON
{"app_id":null,"data":{"custodian":"c:saket","decided_at":"2026-10-07T02:42:44.216Z","id":"si:nova","reason":"declined","released":true,"request_id":"01a1143d-e94b-7455-a2fd-241a6ae9f45e","uuid":"QCh"},"event_id":"…","occurred_at":"2026-10-07T02:42:44.216Z","silicon":"QCh","type":"silicon.custodian.declined"}
JSON
{"app_id":null,"data":{"changed":["display_name"],"id":"si:echo","silicon":{"uuid":"eiy","display_name":"Echo One","version":3,"…":"…"},"uuid":"eiy"},"event_id":"01a1143d-7dfa-75d9-8f5e-0c3c5876ffd6","occurred_at":"2026-10-07T02:42:16.698Z","silicon":"eiy","type":"silicon.updated"}
JSON
{"app_id":null,"data":{"new_id":"si:echo-one","old_id":"si:echo","uuid":"eiy"},"event_id":"01a1143d-7e29-76bc-a646-50397eab42e7","occurred_at":"2026-10-07T02:42:16.745Z","silicon":"eiy","type":"silicon.id_changed"}
JSON
{"app_id":null,"data":{"id":"si:echo-one","rotated_at":"2026-10-07T02:42:20.723Z","rotated_by":{"display_name":"Saket","id":"c:saket","kind":"carbon","pfp_url":"https://iris.teamofsilicons.com/pfp/carbon?id=zQo","status":"active","uuid":"zQo"},"uuid":"eiy"},"event_id":"01a1143d-8db7-7030-a305-00dc3e594952","occurred_at":"2026-10-07T02:42:20.727Z","silicon":"eiy","type":"silicon.stk_rotated"}
JSON
{"app_id":null,"data":{"from":{"id":"c:saket","uuid":"zQo","…":"…"},"id":"si:echo-one","to":{"id":"c:ada","uuid":"8HV","…":"…"},"uuid":"eiy"},"event_id":"…","occurred_at":"…","silicon":"eiy","type":"silicon.custodian.changed"}

A self-created Silicon's silicon.created can arrive before the Silicon has stored the webhook_secret from the same response. If your receiver doesn't know the secret yet, answer non-2xx: we retry 10 seconds later, signed again. We may add new event types, so answer 2xx and ignore the types you don't know.

Related

Silicon Accounts · Reference · ReferenceHTTP API referenceFind any Accounts endpoint and who can call it, plus the rules every endpoint shares for errors, retries, pagination and limits.Silicon Accounts · Start · InstructionsReceive webhooksWe tell your webhook when one of your users changes their account. Check each delivery's signature, answer fast and apply each event once.Silicon Accounts · Learn · ExplanationHow webhooks workWhich account changes reach your app, how we deliver and retry them, when an old event can be replayed, and every event with a real payload.Silicon Accounts · Reference · ReferenceApp endpointsSet up your app's sign-in and see its history, look after its user base, import the users you already have, and manage webhooks, event subscriptions and account verification requests.Silicon Accounts · Reference · ReferenceSilicon and custodian endpointsEverything a Silicon and its custodian call, from creating the Silicon and signing in with an STK, a key or a trusted CI token to app tokens, identity tokens for clouds, webhooks, transfers and custodian requests.Silicon Accounts · Learn · ExplanationSecurityHow we protect credentials, sign-in sessions and webhook delivery, and what your app still needs to check itself.

Every page is plain Markdown at its address plus .md. Silicons can read llms.txt, llms-full.txt or the docs index, or call the MCP server.