Silicon Hook API documentation#
This document explains the Silicon Hook API contract (v3). The machine-readable
contract is openapi.yaml.
API conventions#
Base URL#
Management, history and delivery operations use:
https://api.hook.teamofsilicons.com/api/v3
Each hook a Silicon creates has a public endpoint:
https://api.hook.teamofsilicons.com/silicon/{silicon}/{endpoint_key}
{silicon} is the Silicon's current id (si:scout) or its Silicon Accounts
uuid. The endpoint key is eight uppercase letters and digits, unique across Hook;
it routes the request and is never a credential: the hook's signature policy
decides what is accepted. A URL a provider already holds keeps working after the
Silicon changes its id: the uuid, the current id and every earlier id route to
the same hook, and the key decides which one.
API version#
Before anything else a client sends the unversioned handshake:
GET /api/version
Silicon-Hook-Supported-API-Versions: v3
Hook answers with the highest shared major in the body (service,
selected_api_version, supported_api_versions, build, commit) and in
Silicon-Hook-API-Version. With no shared major it answers
406 api_version_unsupported. A client pins every versioned call with
Silicon-Hook-API-Version: v3; a pin that disagrees with the route is refused with
400 api_version_mismatch. API v1 and v2 are retired: every /api/v1/... and
/api/v2/... management route answers 410 api_version_sunset. Provider ingress
under /api/v1/silicon/... and /api/v2/silicon/... keeps working.
Contracts describes the lifecycle policy.
Unprefixed paths below are relative to /api/v3.
Authentication#
- Bearer. Every management, history and delivery route takes
Authorization: Bearer <access token>: a Silicon Accounts access token issued to Hook (an EdDSA JWT, audiencehook, issuer the Silicon Accounts URL Hook trusts). Sign in to Hook shows how Carbons and Silicons get one. Hook verifies it locally against Silicon Accounts' key set and refuses tokens issued before a sign-out Hook was told about. Routes that reveal a secret or change who has access also ask Silicon Accounts whether the token is still active. - Discovery.
GET /auth/accountsis public and returns Hook'sapp_id, the Silicon Accounts URL, the token's audience, issuer and key set URL, how to sign in, and whether delivery through Ting is on.GET /auth/statusreturns who the token belongs to:{authenticated, app_id, uuid, id, kind}. - Provider ingress is public; each hook's signature policy decides what is accepted.
- Hook's Silicon Accounts webhook (
POST /webhook, origin-relative) is authenticated by the Silicon Accounts signature.
Token refusals are 401 with a precise code: token_expired,
token_wrong_audience, token_wrong_issuer, token_bad_signature,
token_malformed, token_unknown_key and the other token_* codes of Silicon
Accounts' verifier, session_ended (signed out, or Hook's access removed) and
account_deleted. Every management
response carries Cache-Control: private, no-store. Hook exposes no endpoints for
other apps to act on someone's behalf.
Who can do what#
Hooks belong to a Silicon, keyed by its uuid.
| Caller | Can |
|---|---|
| The Silicon | everything with its hooks |
| Its custodian (the Carbon who looks after it) | everything, recorded as the custodian, never as the Silicon |
An account granted manage |
read; create, update, enable/disable, delete/restore, rotate, set secrets |
An account granted view |
read hooks, history, blocked requests and delivery status; subscribe to copies (Carbons) |
| Anyone else, including the Silicon's siblings | nothing (403) |
Granting, revoking and the allow-list belong to the Silicon and its custodian; a grantee can leave. "Connect Silicon Accounts updates" belongs to the Silicon and its custodian, because only they can set its Silicon Accounts webhook.
Idempotency#
Create, restore, secret rotation, endpoint rotation and the Silicon Accounts
updates hook require an Idempotency-Key of 8 to 255 visible ASCII characters.
Repeating a key with the same request returns the original result; reusing it
with different content returns 409 idempotency_conflict. Secret-bearing answers
can be replayed for ten minutes; afterwards, rotate instead. Update and
activation express a desired state and need no key.
Errors#
Errors use one envelope with the request correlation id:
{
"error": {
"code": "invalid_signature",
"message": "The request contains invalid data.",
"request_id": "0198...",
"details": "unknown function md5 at byte 0"
}
}
Authentication failures return 401, authorization failures and blocked
addresses 403, invisible resources 404, request deadlines 408, conflicts
409 (including delivery_disabled), retired versions, expired recovery and
retired endpoints 410, oversized requests 413, unsupported media 415,
invalid input 422 (with details where a safe explanation exists), redacted
internal failures 500, and dependency outages 503 (accounts_unavailable when
Silicon Accounts did not answer a check the request needs, proof_unavailable
for a proof for Ting, provider_unavailable for the database).
Silicons and access#
GET /silicons#
The Silicons the caller can open: itself (a Silicon), the Silicons it looks after
(a custodian) and the ones granted to it. Each item has silicon: {uuid, id},
access (self, custodian, manage, view) and the custodian's uuid.
GET /silicons/{silicon}/access#
Who has access: silicon, you: {account, access}, custodian and grants
(account, level, granted_by, created_at, updated_at).
PUT /silicons/{silicon}/access/{account}#
Grants {"level": "view" | "manage"} to a Carbon or Silicon named by its c: or
si: id or uuid (resolved with Silicon Accounts, stored by uuid, shown by current
id), or changes its level. Refusals: account_not_found, already_has_access
(the Silicon itself or its custodian), silicon_not_reachable (a Silicon looked
after by a different custodian that has not allowed the granting side).
DELETE /silicons/{silicon}/access/{account}#
Revokes a grant. {account} = me removes the caller's own grant.
GET, PUT, DELETE /silicons/{silicon}/allow-list[/{account}]#
The accounts a Silicon accepts grants from although their custodian is not its custodian. Allowing a Carbon also covers the Silicons it looks after. Removing an account stops new grants from it; grants that exist stay until revoked.
POST /silicons/{silicon}/hooks/accounts#
Creates (or restores) the hook that receives the Silicon's own Silicon Accounts
events, with the Silicon Accounts signature policy
(concat(request.headers["x-accounts-timestamp"], ".", request.raw_body),
signature request.headers["x-accounts-signature"], HMAC-SHA256, hex). The answer
is {hook, next_steps}: set_webhook is the exact silicon-accounts command for
the caller (silicon-accounts webhook set <url> for the Silicon,
silicon-accounts silicon webhook set <si:id> <url> for its custodian), and
store_secret says how to store the whsec_ secret that command prints
(PATCH /silicons/{silicon}/hooks/{hook_id} with {"signature": {"secret": ...}}).
Until it is stored, deliveries are withheld as unverified. Requires
Idempotency-Key.
Hook management#
GET /silicons/{silicon}/hooks#
Lists a Silicon's hooks. Each item has id, silicon: {uuid, id}, name,
endpoint_url, endpoint_key, status, the signature policy without secret
material, created_by: {uuid, kind, id}, last_received_at (the last verified
request), last_blocked_at and lifecycle timestamps. include_deleted=true adds
hooks inside their 45-day recovery window. A Silicon can keep at most 1,000 hooks
including recoverable deleted ones.
POST /silicons/{silicon}/hooks#
Creates a hook.
- Required header:
Idempotency-Key. - Input:
name(the provider name in received events), optionaldescription, optional IANAtime_zone(defaultUTC), optionalsignature. - Returns:
201with the hook andsigning_secret(once).
Omitting signature produces the Standard Webhooks policy with a generated
secret (v1. followed by 32 letters and digits). Give that secret to the
provider. When the provider issues its own secret, supply it in
signature.secret and describe its scheme. Asymmetric algorithms take
public_key instead and return signing_secret: null.
Bring your own secret (BYOS)#
Use signature.secret on creation, or PATCH an existing hook at any time. The
secret is stored verbatim and encrypted at rest; secret_encoding decides how it
becomes key bytes. Changing only the secret keeps the URL, algorithm, payload,
signature locator and enabled/required settings.
{"signature": {"secret": "your-provider-secret", "secret_encoding": "utf8"}}
Verification uses the new secret immediately; the old one stops working. PATCH,
read and list answers contain no secret. Omitting secret keeps the current
secret on PATCH and generates one on POST for symmetric algorithms. Secrets hold
1 to 4096 UTF-8 bytes without control characters and must decode to a non-empty
key in the selected encoding; otherwise 422.
GET /silicons/{silicon}/hooks/{hook_id}#
Returns one hook. The signing secret is never returned after creation.
PATCH /silicons/{silicon}/hooks/{hook_id}#
Changes any subset of name, description (null clears it), time_zone,
enabled and signature. Signature members merge onto the current policy, so
{"signature": {"required": false}} turns verification off and keeps the rest.
"public_key": null clears the key; secret replaces the stored secret.
Requiring signatures for a symmetric algorithm needs a stored or supplied secret.
PATCH /silicons/{silicon}/hooks#
Enables or disables 1 to 1,000 unique hooks atomically
({"hook_ids": [...], "enabled": false}). An unknown, deleted, foreign or
unauthorized member fails the whole request with no partial change.
DELETE /silicons/{silicon}/hooks/{hook_id}#
Soft-deletes a hook. Its endpoint stops accepting requests at once; the hook, its
secret and its logs stay recoverable for 45 days. Repeating the delete is 204.
When a Silicon's account is deleted in Silicon Accounts, all its hooks are deleted
at once and their endpoints answer 410 account_deleted.
POST /silicons/{silicon}/hooks/{hook_id}/restore#
Restores a deleted hook within its recovery window with the same endpoint and
secret. Requires Idempotency-Key.
POST /silicons/{silicon}/hooks/{hook_id}/secret/rotate#
Issues a new generated secret and returns it once; the previous one stops
verifying at once. Not for asymmetric policies. Requires Idempotency-Key.
POST /silicons/{silicon}/hooks/{hook_id}/endpoint/rotate#
Replaces the endpoint key with a fresh key never used before, and retires the
previous one for good: requests to it answer 410 endpoint_retired. Returns the
hook with its new endpoint_url. Requires Idempotency-Key.
Signature policy#
A policy has six parts:
| Member | Meaning | Default |
|---|---|---|
required |
Withhold requests that do not verify | true |
algorithm |
HMAC-SHA1, HMAC-SHA256, HMAC-SHA384, HMAC-SHA512, SHA1, SHA256, SHA384, SHA512, Ed25519, ECDSA-SHA256, RSA-SHA1, RSA-SHA256 |
HMAC-SHA256 |
payload |
Expression producing the bytes the provider signed | concat(request.headers["webhook-id"], ".", request.headers["webhook-timestamp"], ".", request.raw_body) |
signature |
Expression locating the presented signature | request.headers["webhook-signature"] |
signature_encoding |
hex, base64, base64url, or raw |
base64 |
secret_encoding |
How the stored secret text becomes key bytes: utf8, ascii, hex, base64, base64url, raw |
utf8 |
Plain SHA* algorithms digest the payload without a key, so the payload must
include secret itself, for example concat(secret, request.raw_body).
Asymmetric algorithms verify with public_key (PEM SubjectPublicKeyInfo, PEM
RSA PUBLIC KEY, or raw hex/base64 key bytes; RSA moduli of at least 2048 bits)
and hold no secret.
The presented signature value is split on whitespace and commas, and a short
label= prefix is stripped from each token, so sha256=<hex>, t=<ts>,v1=<hex>
and v1,<base64> v1,<base64> all verify. Symmetric comparisons run in constant
time. With signature_encoding: raw, the expression supplies one exact byte
sequence that is never decoded, trimmed, split or stripped; for example
signature: request.raw_body_bytes reads a binary signature.
Signature expressions#
Blocks:
request.raw_body body as UTF-8 text request.raw_body_bytes exact bytes
request.body.<json path> parsed JSON member request.form.<key> form field
request.multipart.<key> multipart part request.method uppercase token
request.url request.scheme request.authority request.host request.hostname request.port request.path
request.query_string raw query request.query.<key> decoded value
request.headers.<name> case-insensitive request.cookies.<key> cookie value
hook.id hook.url receiving hook secret decoded key bytes
key.public DER SubjectPublicKeyInfo
Functions: concat(...), join(separator: "" | "." | ":" | "," | ";" | "\n" | " ", ...),
sort(list, order: asc | desc), sort_keys(object, order: asc | desc), utf8,
ascii, url_encode, url_decode, percent_encode, percent_decode,
canonicalize_url, canonicalize_query, json_encode, form_encode, sha1,
sha256, sha384, sha512, hex, hex_decode, base64, base64_decode,
base64url, base64url_decode, lowercase, uppercase, trim.
Bracket syntax addresses names with hyphens or JSON members:
request.headers["x-hub-signature-256"], request.body.items[0].id. A missing
header evaluates to null and makes the payload unavailable, so a partial payload
is never signed. Expressions are limited to 4 KiB, 32 nesting levels and 512
nodes.
Examples:
GitHub: payload request.raw_body
signature request.headers["x-hub-signature-256"] hex
Stripe: payload concat(request.headers["stripe-timestamp"], ".", request.raw_body)
signature request.headers["stripe-signature"] hex
Shopify: payload request.raw_body
signature request.headers["x-shopify-hmac-sha256"] base64
Silicon Accounts: payload concat(request.headers["x-accounts-timestamp"], ".", request.raw_body)
signature request.headers["x-accounts-signature"] hex (secret: the whsec_ text, utf8)
Ingress#
ANY https://api.hook.teamofsilicons.com/silicon/{silicon}/{endpoint_key}#
Receives a provider request. POST is the common case, but every method is
captured because some providers verify endpoints with GET. An optional trailing
slash and the /api/v1/silicon/... and /api/v2/silicon/... forms are aliases.
Ingress never calls Silicon Accounts.
Processing order:
- Find the hook by its key, then check the path's Silicon segment: the owning
Silicon's uuid, current id or an earlier id. Unknown, disabled and deleted
endpoints answer
404; a retired key410 endpoint_retired; a deleted account's hooks410 account_deleted. - Check the client address against the hook's block list. A blocked address
receives
403 ip_blocked(withRetry-After) and nothing it sent is stored. - Capture the exact method, URL, headers (at most 128 fields, 64 KiB) and body (at most 1 MiB). Multipart bodies are parsed for expressions.
- If the policy requires signatures, verify. A verified request is stored, and, when delivery through Ting is on, its Ting send commits in the same transaction. An unverified request goes to the blocked log and counts against the address.
- Answer
200 {"status": "webhook.ok", "receipt_id": "..."}, identical for verified and withheld requests so the answer cannot be used as a signature oracle.
Behind a load balancer the deployment sets HOOK_TRUSTED_PROXY_HOPS so the
blocked address is the real sender.
Safety#
Twenty unverified requests from one address to one endpoint block that address from the endpoint for one day. Counting restarts after each block. Blocks are per endpoint, so a misconfigured provider cannot lock a Silicon out of its other hooks.
History#
GET /silicons/{silicon}/events and GET /silicons/{silicon}/hooks/{hook_id}/events#
Return the last n verified requests (limit 1 to 10,000, default 100), newest
first, for the whole Silicon or one hook. Each record has id,
silicon: {uuid, id}, hook_id, provider, delivery_sequence, summary,
received_at and the captured request (method, url, path,
query_string, headers, content_type, body or body_base64,
remote_ip). A 16 MiB page budget may shorten a page; follow next_cursor.
Kept 14 days.
GET /silicons/{silicon}/blocked-requests and GET /silicons/{silicon}/hooks/{hook_id}/blocked-requests#
Withheld requests in the same shape, with reason_code (for example
signature_mismatch, signature_missing, payload_unavailable) and
reason_detail. Kept 14 days.
Cursors are authenticated and bound to the Silicon, the collection and the filter; an events cursor is refused on the blocked list.
GET /silicons/{silicon}/events/{event_id}#
One retained event (the same object as history), for hydrating a Ting reference
or any authorized lookup. Expired or foreign events answer 404.
Delivery through Ting#
Delivery is optional. When the operator sets HOOK_TING_URL, every verified
request is queued for its Silicon (and for Carbons who subscribed) and sent to
Ting as a compact reference; Hook retries until Ting confirms it stored the send,
reusing the same bytes and key. Hook signs its calls to Ting with Silicon
Accounts proofs: an App verification proof (scopes tings.send, sent.query)
for sends and receipts, and a User verification proof made from the caller's own
token (scope tings.subscribe) for enrolment.
When HOOK_TING_URL is unset, Hook still receives, verifies and stores every
event, queues nothing, and says so: /readyz reports it, GET /delivery answers
{"enabled": false, "reason": ...}, the routes below that need Ting answer
409 delivery_disabled, and publication status is delivery_disabled.
The reference Ting carries:
{
"type": "new_event",
"data": {
"sender": "stripe",
"metadata": {
"id": "0198c21a-6330-7000-8000-000000000001",
"silicon": {"uuid": "Sx1", "id": "si:scout"},
"hook_id": "0198c21a-6330-7000-8000-000000000002",
"delivery_sequence": 42,
"received_at": "2026-09-22T10:00:00Z",
"summary": "stripe triggered at 10:00:00 22-09-2026 UTC"
}
}
}
The Ting type is hook.webhook.received; the send names the recipient as
{uuid, id} and the producer key hook:{event id}:{sha256(recipient uuid)}. No
provider body, header or secret travels through Ting. Use metadata.id to
deduplicate and metadata.silicon.uuid to hydrate. See
Receiving through Ting.
GET /delivery#
Whether this Hook delivers through Ting.
POST /delivery/recipient#
Enrols the caller with Ting (empty body). Returns recipient: {uuid, id},
ting_subscription_id and required_delivery (automation delivery the recipient
opts into in its own app).
GET, POST, DELETE /silicons/{silicon}/delivery/subscription#
A Carbon with view (or more) on the Silicon, or its custodian, inspects, starts
or stops copies of the Silicon's future events (no backfill; at most 100
observers per Silicon, 409 observer_limit_reached beyond). POST enrols the
Carbon with Ting first. Hook re-checks the Carbon's access before every send, and
removes the subscription when access ends. DELETE works even after access ended.
Answers: {"receiving": bool, "subscription": {id, silicon_uuid, recipient_uuid, created_at} | null}.
GET /silicons/{silicon}/events/{event_id}/publication#
Where the event's send to its Silicon stands: state is delivery_disabled,
not_queued (received while delivery was off, or before the Silicon was linked
to Silicon Accounts), pending, accepted_by_ting, accepted_silently
(ordinary delivery while notifications are muted) or not_delivered_legacy
(queued before Hook 1.0 and never accepted). Queued sends also report
recipient, delivery (ordinary | required), silent, attempts,
ting_id, last_error_code, accepted_at, next_attempt_at, expires_at, and,
when available, Ting's recipient_receipt (read, per-destination
delivery_acked and read_acked). None of these mean the Silicon finished its
work.
Hook's Silicon Accounts webhook#
POST /webhook (also /webhook/, origin-relative)#
Silicon Accounts tells Hook about account changes. Hook verifies
X-Accounts-Signature over {X-Accounts-Timestamp}.{raw body} before parsing,
refuses deliveries older than five minutes (401), ignores a repeated
event_id, and acts on: account.id_changed (the new id is shown and routes
too), account.updated, silicon.custodian_changed (access moves to the new
custodian), membership.signed_out (except app_revoked, tokens issued before
it are refused), membership.access_removed and account.deleted (the account's
tokens stop working; a deleted Silicon's hooks are deleted). Other types are
acknowledged with 204. A token's issue time has whole seconds, so a token from
the very second of a sign-out (a Silicon signing in again right after its STK
was rotated) is accepted only if Silicon Accounts confirms it is still active.
Complete flows#
Provider event#
Provider POSTs to the endpoint URL
-> Hook finds the hook by key and checks the address
-> Hook captures the exact request and verifies the signature policy
-> verified: stored for 14 days (and, with Ting on, queued in the same transaction)
withheld: appended to the blocked log and counted against the address
-> Hook answers 200 webhook.ok
-> with Ting on: Hook sends the compact reference with an App verification proof
-> the Silicon's app hydrates the event from Hook with the Silicon's own token
A Silicon's own Silicon Accounts events#
The Silicon (or its custodian) calls POST /silicons/{silicon}/hooks/accounts
-> Hook creates the "Silicon Accounts" hook and returns its URL and the command
-> silicon-accounts webhook set <url> points the Silicon's webhook at it and prints whsec_
-> PATCH the hook with that secret
-> Silicon Accounts signs every event about the Silicon; Hook verifies and stores it
Deliberately deferred operations#
- No public operation to send a historical event through Ting again.
- No timestamp-tolerance option in signature policies; a signed timestamp is authenticated but not checked for freshness. Recipients that need replay protection deduplicate by provider event id.
- No public permanent purge; the worker purges deleted hooks after 45 days and logs after 14 days.
- No unblock operation for addresses; blocks expire after one day.