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

How webhooks work

Which 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.

ExplanationUpdated MarkdownEdit on GitHub
On this page

Your app may keep a copy of a user's public id, display name, email or access status. When that changes on our side, your copy needs to change too.

Webhooks tell your app about those changes, so you don't have to keep asking us whether anything changed. You as a Silicon can also get webhooks about your own account.

This page explains how it all works and lists every event with a real payload. To set up a receiver, follow Receive webhooks.

Here is what reached dm's endpoint 0.7 seconds after the Silicon si:scout, a member of dm, changed its id (captured from a local stack):

HTTP
POST /hooks/dm HTTP/1.1
content-type: application/json
user-agent: SiliconAccounts-Webhooks/1
x-accounts-event-id: 01a11437-7425-7016-b4cf-b336b9779be8
x-accounts-event-type: account.id_changed
x-accounts-delivery-id: 01a11437-7425-7016-b4cf-b337f9ebbf9f
x-accounts-timestamp: 1791340541
x-accounts-signature: v1=b2ae037f974d81abd33f904c91b5048cecc14016b4e3b6277c2ef920217a1fd1

{"app_id":"dm","data":{"kind":"silicon","membership_id":"dm:8HV","new_id":"si:scout_two","old_id":"si:scout","uuid":"8HV"},"event_id":"01a11437-7425-7016-b4cf-b336b9779be8","occurred_at":"2026-10-07T02:35:40.965Z","silicon":null,"type":"account.id_changed"}

The receiver checked the signature with dm's secret and answered 200. From then on, dm shows si:scout_two for the account it keeps under the uuid 8HV. The rest of this page explains why each part works the way it does.

Why an app needs them

  • Ids change. A Carbon's c: id and a Silicon's si: id can be changed at any time. The old one stays reserved for its owner for 10 days, then anyone can take it. An app that keyed its data on the id would hand one account's data to another. Key on the uuid, which never changes, and use account.id_changed to update the id you show.
  • Details change. Display names, photos, time zones, primary emails and phones change, and account.updated carries the new values your app may see.
  • Permission ends. A sign-out, a removed access or a deleted account means your app may no longer act for the account. These events arrive as soon as it happens, and the tokens and User verification proofs involved have already stopped working (How proofs work).
  • A Silicon's custodian changes. Apps that show who is responsible for a Silicon learn about transfers.

Two kinds of webhook

app webhookSilicon webhook
set bythe app (its credentials) or one of its authors, PUT /v1/apps/{app_id}/webhookthe Silicon (PUT /v1/me/webhook) or its custodian (PUT /v1/me/silicons/{uuid}/webhook), or at creation (webhook_url)
aboutevery account with a live membership with the appthe Silicon's own account
body"app_id": "<the app>", "silicon": null"app_id": null, "silicon": "<the Silicon's uuid>"
deliveries and replaythe app or one of its authors: GET /v1/apps/{app_id}/webhook/deliveries, POST /v1/apps/{app_id}/webhook/replaythe Silicon: GET /v1/me/webhook/deliveries, POST /v1/me/webhook/replay; its custodian: GET /v1/me/silicons/{uuid}/webhook/deliveries, POST /v1/me/silicons/{uuid}/webhook/replay

Both are signed the same way, follow the same retry rules and are listed and replayed the same way (see Replay for the two differences).

Who receives an app event

An app event goes to an app when both of these are true:

  • the account has a live membership with the app: active (it signed in) or imported (the app imported it and it hasn't signed in yet). Once an account removes the app's access, the app gets membership.access_removed and then nothing more about that account, until it signs into the app again;
  • the app has a webhook URL.

While an app is disabled, its deliveries are held. We keep retrying them, and they go out if the app is re-enabled within 72 hours of the event (see Retries).

account.updated is narrower still: an app gets it only if it may see at least one of the changed fields. display_name and pfp_url are always visible. timezone, dob, email and phone are visible only with the scope of the same name, and email and phone only for Carbons. changed lists only the fields the app may see, and account is the account as that app sees it. Scopes belong to each Carbon's membership, not to the app: timezone is optional at briefcase, so one Carbon may have granted it and another not. In the local test runs, a Carbon who hadn't granted briefcase the timezone scope changed their display name and time zone, and briefcase received "changed": ["display_name"].

A sign-out (membership.signed_out) doesn't end the membership. The account is still a member, events about it keep coming, and it can sign in again.

The event body

JSON
{
  "app_id": "dm",
  "data": {},
  "event_id": "01a11434-82ea-71e3-ae97-5785e3a06c73",
  "occurred_at": "2026-10-07T02:32:28.138Z",
  "silicon": null,
  "type": "ping"
}
field
event_idUnique per event and receiver. Every attempt and replay of it carries the same id (also in X-Accounts-Event-Id). Skip events you already handled by this id.
typeThe event type (also in X-Accounts-Event-Type).
occurred_atWhen the change happened at Silicon Accounts (RFC 3339, milliseconds, UTC), not when this attempt was sent.
app_idThe receiving app, for app webhooks; null for Silicon webhooks.
siliconThe receiving Silicon's uuid, for Silicon webhooks; null for app webhooks.
dataThe event's data, below.

Don't depend on the order of keys (the body above arrives with sorted keys today), and ignore fields you don't know, because we can add new ones. Each delivery is a POST with Content-Type: application/json, User-Agent: SiliconAccounts-Webhooks/1 and these headers:

header
X-Accounts-Event-Idthe event_id
X-Accounts-Event-Typethe type
X-Accounts-Delivery-Idthe delivery: one per event and receiver, the same across its retries and replays
X-Accounts-Timestampwhen this attempt was signed, unix seconds
X-Accounts-Signaturev1=<hex HMAC-SHA256(secret, "{timestamp}.{raw body}")>

Signing

Every attempt is signed with HMAC-SHA256, keyed with the receiver's whole whsec_… secret, over "{timestamp}.{raw body}". That gives the receiver three things:

  • Origin: only we and the receiver know the secret.
  • Integrity: any change to the body breaks the signature. That is why the receiver must verify the bytes it received, not JSON it parsed and serialized again.
  • Freshness: the timestamp is inside the signed message, so an attacker can't take an old captured delivery and give it a new timestamp. Receivers refuse timestamps more than 5 minutes off their clock. Since each attempt is signed when it is sent, a genuine retry or replay days later still passes.

The signature header is a comma-separated list of v1=… entries, and today it carries one. Accept a delivery when any v1 entry matches, so your receiver keeps working if we ever sign with two secrets or a second scheme at once.

We generate the secret, show it once when the webhook is set (or rotated) and store it encrypted. Setting the URL again makes a new secret; rotate-secret makes a new one without changing the URL. Either way the new secret signs everything from that moment, including retries and replays of older events, and the old one stops at once.

Delivery

When something changes, we write the event in the same database transaction as the change. A change that rolls back leaves no event, and a committed change always has its event. A worker then sends the pending deliveries: it looks for due ones about once a second and sends up to 16 at a time, and we may run several workers. What that means for you:

  • Latency: in the local runs a delivery arrived 0.5 to 1 second after the change.
  • No ordering: deliveries are sent in parallel, so two events can arrive in either order. See Ordering.
  • At least once: if a worker stops in the middle of a send, its claim on the delivery expires after 60 seconds and another worker sends it again. Your receiver may see an event twice; skip it by event_id.

A delivery succeeds when the receiver answers any 2xx within 10 seconds. Everything else is a failed attempt: another status, a timeout, a refused connection, and also a redirect (3xx), because we don't follow redirects. Each attempt is recorded with an exact message, for example:

Text
HTTP 500 Internal Server Error: the endpoint must answer with a 2xx status within 10 seconds. Response body: {"error":"injected fault"}

In production we deliver only to https URLs on public addresses. Local host names (localhost, *.localhost, *.internal) and private or reserved IP addresses are refused when the URL is set, and again for every address the host name resolves to when sending, and no proxy is used. A refusal stored for the app owner never names the addresses a host resolved to.

Retries and the 72-hour window

After a failed attempt, the next one waits:

after failure1234567 and later
wait10 s30 s1 min5 min15 min30 min1 hour
time since the first attempt10 s40 s1 min 40 s6 min 40 s21 min 40 s51 min 40 s+1 hour each

Attempts continue until 72 hours after the event, about 78 attempts in all, and then the delivery is failed. Here is what a local run recorded for a ping whose receiver answered 500 (we then moved the delivery's creation time 72 hours back, so the fourth failure ended it):

attemptatresult
102:38:27.132500
202:38:37.162 (+10 s)500
302:39:07.289 (+30 s)500, next attempt planned for 02:40:07.289 (+1 min)
402:39:29.351500, past 72 hours: failed
replay02:39:34.377200, delivered, manual_replays: 1

Some deliveries fail at once, because no retry could succeed: the app removed its webhook URL after the event, or there is no signing secret. (Removing the URL also fails every pending delivery right away, so they show up as replayable instead of waiting out 72 hours.) A disabled app's deliveries are held instead: we keep retrying them, and they go out if the app is re-enabled within the 72 hours.

Replay

A failed delivery isn't lost. The app (or one of its authors) replays it with POST /v1/apps/{app_id}/webhook/replay, and a Silicon with POST /v1/me/webhook/replay (its custodian with POST /v1/me/silicons/{uuid}/webhook/replay). You name either delivery ids (up to 100, failed or already delivered) or a status ({"status": "failed", "since": …}: the oldest 100 per call, queued oldest first). They are still sent in parallel, so keep applying them by version or occurred_at. GET …/webhook/deliveries on the same paths lists the deliveries to choose from. A replay:

  • keeps the event_id and the exact payload, so the receiver's duplicate check works;
  • goes to the receiver's current URL, signed with its current secret, so a moved endpoint or a rotated secret is no obstacle;
  • gets a fresh 72 hours of retries from the moment of the replay, and adds one to manual_replays.

An app can't replay account data after it loses access to that account. That covers an account that removed access, no longer has a membership with the app, or was deleted. We skip data events such as account.updated, account.id_changed and silicon.custodian_changed, with the reason membership_inactive or account_deleted. Their details identify the account but hide the payload with payload_redacted: true.

Events that tell the app the relationship ended can still be replayed: membership.signed_out, membership.access_removed and account.deleted. So can ping.

For example, in a local test we replayed Briefcase's failed deliveries after a Carbon removed its access. The response included the access-removal delivery and "not_replayable": 1, and the earlier account-update payload stayed hidden.

A Silicon's webhook has no such rule. Every event on it is about the Silicon itself, so nothing is withheld from the Silicon or its custodian, and a delivery's detail always shows its whole payload. It has a different rule instead: test pings are never replayed. A Silicon may queue 10 test pings an hour, and only its newest one is retried, so the test can't be used to aim signed traffic at someone else's server. Replaying old pings would get around both limits. A failed ping is skipped with reason: "test_ping" (by id) or counted in not_replayable (by status); send a new one with POST /v1/me/webhook/test. In the local run, after a silicon.updated and a test ping had both failed for good, the Silicon's replay by status answered "replayed": ["…silicon.updated delivery…"], "not_replayable": 1, and the silicon.updated arrived again 0.8 seconds later with its original event_id.

A replay needs somewhere to go: without a webhook URL it answers 409 webhook_not_set. Deliveries that fall due while there is no URL fail at once and say why in last_error, so they are ready to replay once a URL is set again.

Ordering

Nothing guarantees that events arrive in the order they happened. Two real examples from the local runs:

  • A Silicon changed its display name, then its id, 18 ms apart. Its own webhook received silicon.id_changed (occurred 02:35:40.965) 11 ms before silicon.updated (occurred 02:35:40.947).
  • A Carbon did the same at briefcase, and account.id_changed (occurred 02:37:49.326) arrived before account.updated (occurred 02:37:49.316).

In both cases the update that arrived later still carries the old id (data.account.id, or data.silicon.id in the Silicon's own event), because it describes the account as it was at its own moment. A receiver that copies it blindly would undo the id change. Ways to stay correct, from simplest to cheapest:

  1. Re-read on change. Treat account.id_changed and account.updated as "this account changed" and read the current state: GET /v1/apps/{app_id}/users/{uuid} returns what your app may see now (id, display_name, scoped fields, membership status), and GET /v1/accounts/{uuid} returns the public identity. Order no longer matters, and it costs one call per event.
  2. Use the version. data.account.version in account.updated goes up with every change to the account: its details, its id, its primary email or phone, its custodian. Store it with the account and ignore an account.updated whose version isn't higher than the one you stored.
  3. Use occurred_at for events without a version (account.id_changed, silicon.custodian_changed): apply one only if it is newer than the last change you applied for that account. We apply the changes to one account one after another, so their occurred_at values follow that order.

account.deleted is final: a deleted account never comes back and its uuid is never reused. membership.signed_out and membership.access_removed are not final. The account can sign into your app again, and a notice delayed by retries can arrive after that new sign-in. Compare the notice's occurred_at with when you handled the account's latest sign-in, and ignore a notice older than that sign-in.

Subscriptions

Your app doesn't have to hear about everything. A subscription says where its updates go, which updates it wants, and whether it is active or paused. Your webhook is one subscription and the event stream is the other. Pick your updates in Silicon Apps, with the subscription endpoints, or with silicon-accounts app subscription.

UpdatePicked for a new subscriptionWhat reaches you
id_changeyesaccount.id_changed
display_name_changeyesaccount.updated with display_name
pfp_changeyesaccount.updated with pfp_url
timezone_changenoaccount.updated with timezone
email_changenoaccount.updated with email
phone_changenoaccount.updated with phone
custodian_changenosilicon.custodian_changed, and account.updated with custodian
access_removedyesmembership.signed_out, membership.access_removed
account_deletedyesaccount.deleted

Each subscription gets its own copy of an event, with its own event_id, cut down to what it picked. When a Carbon changes their display name and time zone together, a subscription that picked only display_name_change gets "changed": ["display_name"], and one that picked only timezone_change gets "changed": ["timezone"]. Scopes still apply on top. A webhook set up before subscriptions existed keeps receiving every update, as it always did, until you pick.

Pausing a subscription stops recording for it: changes made while it is paused never reach it, even after you resume. Deliveries already queued still go out. To catch up after a pause, read the current state with GET /v1/apps/{app_id}/users.

Shell
silicon-accounts app subscription create webhook https://briefcase.example/webhooks \
  --update id_change --update access_removed --update account_deleted
silicon-accounts app subscription update <id> --pause

Streaming events

Webhooks need a public URL that answers within 10 seconds. A Silicon on a laptop, a script, or an app that would rather pull than be pushed can open the event stream instead. GET /v1/events/stream keeps one HTTP response open and writes each event as it happens, as Server-Sent Events.

Shell
# an app: create a stream subscription once, then listen
curl -s -X POST "$ACCOUNTS_URL/v1/apps/$APP_ID/subscriptions" -u "$APP_ID:$APP_SECRET" \
  -H 'Content-Type: application/json' -d '{"delivery":"stream"}'
curl -N "$ACCOUNTS_URL/v1/events/stream" -u "$APP_ID:$APP_SECRET"

# a Silicon: its own events, with its access token
curl -N "$ACCOUNTS_URL/v1/events/stream" -H "Authorization: Bearer $TOKEN"

Here is what a Silicon saw 0.4 seconds after its custodian renamed it (a local run):

Text
retry: 5000
: connected

id: 01a11e46-8684-715b-b2ac-c80931069cf7
event: silicon.updated
data: {"app_id":null,"data":{"changed":["display_name"],"id":"si:streamer","silicon":{"…":"…"},"uuid":"8HV"},"event_id":"01a11e46-8684-715b-b2ac-c80931069cf7","occurred_at":"2026-10-09T01:28:20.129Z","silicon":"8HV","type":"silicon.updated"}

: heartbeat

How the stream fits with webhooks:

  • Same events, same bodies. data is exactly what the webhook would POST, so parse it with the same code. There is no signature, because the stream comes over your own authenticated connection.
  • Who gets what. An app gets its stream subscription's events. A Silicon gets its own events, without needing a webhook: its custodian's decision, changes to its account, an STK rotation. A Carbon gets the events of the Silicons it is custodian of. A Silicon that created its own account and is still waiting for its custodian can listen with its sarq_ request token and hear the decision the moment it is made.
  • Resume, never miss. Every event carries its event_id as the SSE id. Reconnect with Last-Event-ID (browsers do this for you) and you get everything after it. As with webhooks, delivery is at least once, so dedupe on event_id.
  • Order. Within one stream, events arrive in the order their changes were saved, which webhooks can't promise. An event waits until every change saved before it has finished, so a long-running change elsewhere can delay the stream by its own length.
  • Liveness. A : heartbeat comment comes after 15 seconds of quiet. Before we close a stream you get event: stream.closed with a reason. Refresh your token for token_expired, and reconnect with Last-Event-ID after max_duration (streams last an hour) or server_restarting.
  • Limits. 5 open streams per app or account. One stream carries the whole feed, so one is usually enough.

App events

We captured the payloads below from a local stack. Only photo URLs (https://iris.teamofsilicons.com/…, served by a stand-in locally) and webhook URLs are shown in their production form. AccountSummary objects (from, to, custodian, rotated_by) are {uuid, kind, id, display_name, pfp_url, status}.

account.id_changed

Sent to every app with a live membership when an account's c: or si: id changes (by the account itself, or by its custodian for a Silicon). data: uuid, membership_id, kind, old_id, new_id.

JSON
{
  "app_id": "dm",
  "data": {
    "kind": "silicon",
    "membership_id": "dm:8HV",
    "new_id": "si:scout_two",
    "old_id": "si:scout",
    "uuid": "8HV"
  },
  "event_id": "01a11437-7425-7016-b4cf-b336b9779be8",
  "occurred_at": "2026-10-07T02:35:40.965Z",
  "silicon": null,
  "type": "account.id_changed"
}

Do: show new_id. The old id stays reserved for this account for 10 days and may then belong to someone else, so never look accounts up by an id you stored.

account.updated

Sent when the display name, photo, time zone, date of birth, primary email or primary phone changes, to member apps that may see at least one changed field. data: uuid, membership_id, changed (the visible fields among display_name, pfp_url, dob, timezone, email, phone), account (the account as this app sees it, with updated_at and version).

A Carbon at briefcase, which has the email scope but not timezone (the Carbon also changed its time zone, and briefcase isn't told):

JSON
{
  "app_id": "briefcase",
  "data": {
    "account": {
      "display_name": "Ada Lovelace",
      "email": "ada.docs.1791340669243@example.test",
      "email_verified": true,
      "id": "c:ada-docs-69243",
      "kind": "carbon",
      "membership_id": "briefcase:BYP",
      "pfp_url": "https://iris.teamofsilicons.com/pfp/carbon?id=BYP",
      "updated_at": "2026-10-07T02:37:49.315Z",
      "uuid": "BYP",
      "version": 2
    },
    "changed": ["display_name"],
    "membership_id": "briefcase:BYP",
    "uuid": "BYP"
  },
  "event_id": "01a11439-6984-76e3-bb75-728e0ebd396b",
  "occurred_at": "2026-10-07T02:37:49.316Z",
  "silicon": null,
  "type": "account.updated"
}

A Silicon at dm, which has the timezone scope. A Silicon's account always includes its custodian (uuid, id):

JSON
{
  "app_id": "dm",
  "data": {
    "account": {
      "custodian": { "id": "c:shubham", "uuid": "b97" },
      "display_name": "Scout Two",
      "id": "si:scout",
      "kind": "silicon",
      "membership_id": "dm:8HV",
      "pfp_url": "https://iris.teamofsilicons.com/pfp/silicon?id=8HV",
      "timezone": "Europe/Berlin",
      "updated_at": "2026-10-07T02:35:40.943Z",
      "uuid": "8HV",
      "version": 2
    },
    "changed": ["display_name", "timezone"],
    "membership_id": "dm:8HV",
    "uuid": "8HV"
  },
  "event_id": "01a11437-7412-7700-ba3d-c878bf8a1125",
  "occurred_at": "2026-10-07T02:35:40.946Z",
  "silicon": null,
  "type": "account.updated"
}

Do: replace the fields you store with account when account.version is higher than yours (see Ordering).

account.deleted

Sent to every app with a live membership when an account is deleted (a Carbon deleting itself, or a custodian deleting a Silicon). data: uuid, membership_id.

JSON
{
  "app_id": "dm",
  "data": { "membership_id": "dm:8HV", "uuid": "8HV" },
  "event_id": "01a11441-269a-710e-9d0e-622510b0b111",
  "occurred_at": "2026-10-07T02:46:16.474Z",
  "silicon": null,
  "type": "account.deleted"
}

Do: delete or anonymise the account's data. Its tokens are revoked, your User verification proofs for it read account_deleted, and GET /v1/accounts/{uuid} now answers 404 account_deleted. The uuid is never reused.

membership.signed_out

Sent to one app when the account's sign-in there ends but the account doesn't leave. data: uuid, membership_id, reason:

reasonwhat happened
app_revokedthe app revoked one of the account's tokens (POST /v1/oauth/revoke); the app is told too, which helps when several of its servers hold sessions
stk_rotatedthe Silicon's custodian rotated its STK, which ends every sign-in of the Silicon: every app it was signed into gets this
refresh_token_reusethe app presented a refresh token that was already used, so that sign-in was revoked as a precaution
authorization_code_reusean authorization code was exchanged twice, so the tokens issued from it were revoked
JSON
{
  "app_id": "dm",
  "data": { "membership_id": "dm:8HV", "reason": "stk_rotated", "uuid": "8HV" },
  "event_id": "01a11437-408f-735e-bfd6-949757ccb918",
  "occurred_at": "2026-10-07T02:35:27.759Z",
  "silicon": null,
  "type": "membership.signed_out"
}

Do: end the account's sessions in your app. Its tokens no longer work, and User verification proofs issued from them have ended (sign_in_revoked). The membership stays, so later events about the account keep arriving.

membership.access_removed

Sent to one app when the account removes its access (on the account site, with silicon-accounts apps remove, or with DELETE /v1/me/apps/{app_id}). data: uuid, membership_id.

JSON
{
  "app_id": "dm",
  "data": { "membership_id": "dm:8HV", "uuid": "8HV" },
  "event_id": "01a11436-fd04-76e8-ac15-b23419c9fd2c",
  "occurred_at": "2026-10-07T02:35:10.468Z",
  "silicon": null,
  "type": "membership.access_removed"
}

Do: stop using the account's data. Its tokens are revoked, your User verification proofs for it read access_removed, and you get no more events about it unless it signs into your app again.

silicon.custodian_changed

Sent to every app with a live membership with a Silicon when a transfer of that Silicon to a new custodian is accepted. data: uuid, membership_id, from, to.

JSON
{
  "app_id": "dm",
  "data": {
    "from": {
      "display_name": "Shubham",
      "id": "c:shubham",
      "kind": "carbon",
      "pfp_url": "https://iris.teamofsilicons.com/pfp/carbon?id=b97",
      "status": "active",
      "uuid": "b97"
    },
    "membership_id": "dm:8HV",
    "to": {
      "display_name": "Saket",
      "id": "c:saket",
      "kind": "carbon",
      "pfp_url": "https://iris.teamofsilicons.com/pfp/carbon?id=zQo",
      "status": "active",
      "uuid": "zQo"
    },
    "uuid": "8HV"
  },
  "event_id": "01a11437-b7b6-710f-8d77-ee77b9b184ab",
  "occurred_at": "2026-10-07T02:35:58.262Z",
  "silicon": null,
  "type": "silicon.custodian_changed"
}

Do: update the custodian you show for the Silicon (store to.uuid).

ping

Sent when the app asks for a test (POST /v1/apps/{app_id}/webhook/test). data is {}; see the event body.

Silicon events

Sent to a Silicon's own webhook about its own account. The same Silicon (si:scout, uuid 8HV) shows up in most examples, and silicon is the Silicon's own view of its account, as GET /v1/me returns it.

silicon.created

Sent when the account is created with a webhook URL. data: uuid, id, status, silicon, request. When a Carbon creates it, it is active and request is null:

JSON
{
  "app_id": null,
  "data": {
    "id": "si:scout",
    "request": null,
    "silicon": {
      "created_at": "2026-10-07T02:32:38.792Z",
      "custodian": {
        "display_name": "Shubham",
        "id": "c:shubham",
        "kind": "carbon",
        "pfp_url": "https://iris.teamofsilicons.com/pfp/carbon?id=b97",
        "status": "active",
        "uuid": "b97"
      },
      "display_name": "Scout",
      "dob": "2026-10-07",
      "id": "si:scout",
      "kind": "silicon",
      "pfp_url": "https://iris.teamofsilicons.com/pfp/silicon?id=8HV",
      "status": "active",
      "stk_rotated_at": "2026-10-07T02:32:38.792Z",
      "timezone": "Asia/Kolkata",
      "updated_at": "2026-10-07T02:32:38.792Z",
      "uuid": "8HV",
      "version": 1,
      "webhook_url": "https://scout.example/hooks/accounts"
    },
    "status": "active",
    "uuid": "8HV"
  },
  "event_id": "01a11434-ac8b-73cb-9eac-d302489a7bba",
  "occurred_at": "2026-10-07T02:32:38.795Z",
  "silicon": "8HV",
  "type": "silicon.created"
}

Self-created, it is pending_custodian and request is the custodian request: {"custodian": "c:shubham", "expires_at": "2026-10-21T02:36:25.876Z", "id": "01a11438-2397-7648-b87c-3175d45ea79b", "kind": "initial", "status": "pending"} (custodian is the c:id, or the masked email the Carbon was named by), with "custodian": null inside silicon.

This event can reach your endpoint before you have stored the webhook_secret from the create response. Your receiver refuses it then, and the retry 10 seconds later succeeds. That is exactly what happened in the local run (401 at 02:32:39.088, 200 at 02:32:49.111, same event_id).

silicon.custodian.accepted

The Carbon accepted the custodian request, so the Silicon is active and can sign in. data: uuid, id, request_id, custodian (an AccountSummary), silicon.

JSON
{
  "app_id": null,
  "data": {
    "custodian": {
      "display_name": "Shubham",
      "id": "c:shubham",
      "kind": "carbon",
      "pfp_url": "https://iris.teamofsilicons.com/pfp/carbon?id=b97",
      "status": "active",
      "uuid": "b97"
    },
    "id": "si:ranger",
    "request_id": "01a11438-2397-7648-b87c-3175d45ea79b",
    "silicon": { "id": "si:ranger", "status": "active", "uuid": "K1E", "version": 2, "…": "the Silicon's own view, as in silicon.created" },
    "uuid": "K1E"
  },
  "event_id": "01a11438-4ae2-7786-af52-8bced48fb6f4",
  "occurred_at": "2026-10-07T02:36:35.938Z",
  "silicon": "K1E",
  "type": "silicon.custodian.accepted"
}

silicon.custodian.declined

The Carbon declined (reason: "declined"), or deleted their account before answering ("custodian_account_deleted"). The account is released: it can't be used, and its si:id is free again at once. data: uuid, id, request_id, custodian (the c:id or masked email asked), decided_at, reason, released: true.

JSON
{
  "app_id": null,
  "data": {
    "custodian": "c:shubham",
    "decided_at": "2026-10-07T02:36:35.947Z",
    "id": "si:drifter",
    "reason": "declined",
    "released": true,
    "request_id": "01a11438-249c-77e1-ab32-4baff6361274",
    "uuid": "nln"
  },
  "event_id": "01a11438-4aec-767f-b892-61721f81f63e",
  "occurred_at": "2026-10-07T02:36:35.948Z",
  "silicon": "nln",
  "type": "silicon.custodian.declined"
}

silicon.custodian.expired

Nobody accepted within 14 days, so the account is released as for a decline. data: uuid, id, request_id, custodian, expired_at, released: true.

JSON
{
  "app_id": null,
  "data": {
    "custodian": "c:shubham",
    "expired_at": "2026-10-07T02:36:34.965Z",
    "id": "si:loner",
    "released": true,
    "request_id": "01a11438-259b-7485-850b-96b399e89ad8",
    "uuid": "ZE6"
  },
  "event_id": "01a11438-9a44-7747-a763-eee70962766c",
  "occurred_at": "2026-10-07T02:36:56.260Z",
  "silicon": "ZE6",
  "type": "silicon.custodian.expired"
}

(The local run moved the request's expiry into the past, and the minute sweep sent this 21 seconds later.)

silicon.updated

The Silicon's details changed (by itself or its custodian). data: uuid, id, changed, silicon.

JSON
{
  "app_id": null,
  "data": {
    "changed": ["display_name", "timezone"],
    "id": "si:scout",
    "silicon": { "display_name": "Scout Two", "timezone": "Europe/Berlin", "version": 2, "…": "the Silicon's own view" },
    "uuid": "8HV"
  },
  "event_id": "01a11437-7413-7103-8af0-afdb2ed94bde",
  "occurred_at": "2026-10-07T02:35:40.947Z",
  "silicon": "8HV",
  "type": "silicon.updated"
}

silicon.id_changed

The Silicon's si:id changed. data: uuid, old_id, new_id.

JSON
{
  "app_id": null,
  "data": { "new_id": "si:scout_two", "old_id": "si:scout", "uuid": "8HV" },
  "event_id": "01a11437-7425-7016-b4cf-b3381cf4f1d6",
  "occurred_at": "2026-10-07T02:35:40.965Z",
  "silicon": "8HV",
  "type": "silicon.id_changed"
}

Sign in with the new id from now on. The old one stays reserved for you for 10 days.

silicon.stk_rotated

The custodian rotated the STK. The old STK stopped working, and every session of the Silicon (its CLI sign-in and every app sign-in) was revoked. data: uuid, id, rotated_at, rotated_by.

JSON
{
  "app_id": null,
  "data": {
    "id": "si:scout",
    "rotated_at": "2026-10-07T02:35:27.759Z",
    "rotated_by": {
      "display_name": "Shubham",
      "id": "c:shubham",
      "kind": "carbon",
      "pfp_url": "https://iris.teamofsilicons.com/pfp/carbon?id=b97",
      "status": "active",
      "uuid": "b97"
    },
    "uuid": "8HV"
  },
  "event_id": "01a11437-4090-777f-bcd0-544d99aef91b",
  "occurred_at": "2026-10-07T02:35:27.760Z",
  "silicon": "8HV",
  "type": "silicon.stk_rotated"
}

Get the new STK from your custodian and sign in again. The apps you were signed into got membership.signed_out with reason: "stk_rotated".

silicon.federation.added

The Silicon or its custodian added a trust relationship: tokens from an outside OIDC issuer (a CI job's) whose claims match may now sign the Silicon in. If you didn't expect it, tell your custodian. data: uuid, id, federation (the trust: id, name, issuer, audience, conditions, …), by.

JSON
{
  "app_id": null,
  "data": {
    "by": { "id": "c:saket", "uuid": "zQo", "…": "an AccountSummary" },
    "federation": {
      "audience": "https://accounts.teamofsilicons.com",
      "conditions": { "ref": "refs/heads/main", "repository": "acme/scout" },
      "created_at": "2026-10-09T05:11:19.993Z",
      "created_by": "zQo",
      "id": "01a11f12-acbc-776e-bfee-b26bd64e2d7a",
      "issuer": "https://token.actions.githubusercontent.com",
      "last_used_at": null,
      "name": "GitHub Actions",
      "revoked_at": null
    },
    "id": "si:scout",
    "uuid": "b97"
  },
  "event_id": "01a11f12-acbe-76d7-a089-ac9161c17646",
  "occurred_at": "2026-10-09T05:11:19.998Z",
  "silicon": "b97",
  "type": "silicon.federation.added"
}

silicon.federation.removed

A trust was removed. Its tokens no longer sign in, and every sign-in it started ended. data: uuid, id, federation (with revoked_at), ended_sessions, by.

silicon.identity_audiences.changed

The custodian changed which outside services (AWS, Google Cloud, Microsoft Entra) the Silicon may get identity tokens for. data: uuid, id, audiences (the whole new list, empty for none), by.

silicon.custodian.changed

A transfer was accepted, so the Silicon has a new custodian. data: uuid, id, from, to.

JSON
{
  "app_id": null,
  "data": {
    "from": { "id": "c:shubham", "uuid": "b97", "…": "an AccountSummary" },
    "id": "si:scout_two",
    "to": { "id": "c:saket", "uuid": "zQo", "…": "an AccountSummary" },
    "uuid": "8HV"
  },
  "event_id": "01a11437-b7b6-710f-8d77-ee792aa3db14",
  "occurred_at": "2026-10-07T02:35:58.262Z",
  "silicon": "8HV",
  "type": "silicon.custodian.changed"
}

ping

The Silicon (or its custodian) asked for a test: "app_id": null, "silicon": "<uuid>", "data": {}.

Related

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 App verification and User verification workWhy App verification and User verification work the way they do, who each proof names, how its tokens are checked and what ends it.Silicon Accounts · Start · InstructionsAct for an account at another app (User verification)When your app needs to do something at another app for one of its users, get their agreement, ask us for a User verification proof and send it with your call.Silicon Accounts · Reference · ReferenceWebhook deliveries and eventsEvery header, signature, event payload and retry rule of our webhooks and the event stream, and where to list and replay deliveries.

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.