Webhooks
Webhooks push events to your endpoint as they happen. Each delivery is HMAC-signed with a secret only you and the platform hold, and retried with backoff until it lands or the attempts run out.
If you would rather pull than be pushed, GET /v1/events is a cursor feed
over the same underlying trail, and it covers far more event types.
Register an endpoint
Section titled “Register an endpoint”POST /v1/organizations/org_01J…/webhooksAuthorization: Bearer sk_live_…
{ "url": "https://api.acme.com/hooks/oauth-work", "events": ["credential.issued", "credential.revoked"]}{ "endpoint_id": "we_…", "secret": "whsec_…"}The secret is shown once — store it now. It is held encrypted at rest, because it is the one value
that lets anyone forge a delivery to your endpoint.
Omit events (or pass ["*"]) to subscribe to everything. The URL is checked against the egress
policy at registration and again at delivery time, so an endpoint that later resolves somewhere it
should not is refused rather than fetched.
The payload
Section titled “The payload”{ "id": "evt_9f2c…", "type": "credential.issued", "created": 1750000000, "organization_id": "org_01J…", "data": { "vc_id": "vc_…", "type": "WorkCredential", "format": "sd-jwt-vc", "subject": "did:key:z6Mk…" }}created is Unix seconds. data varies by event type.
Headers and signature
Section titled “Headers and signature”POST /hooks/oauth-work HTTP/1.1content-type: application/jsonwebhook-id: whd_…webhook-event: credential.issuedwebhook-signature: t=1750000000,v1=8f3b2c…The signature is HMAC-SHA256, hex-encoded, over the string <timestamp>.<raw request body>, keyed
by your whsec_… secret. Verify against the raw body, before any JSON parsing — re-serializing
changes the bytes and the signature will not match.
import { createHmac, timingSafeEqual } from 'node:crypto'
function verify(rawBody: string, header: string, secret: string, toleranceSec = 300) { const parts = Object.fromEntries(header.split(',').map((p) => p.split('=') as [string, string])) const timestamp = Number(parts.t) if (!Number.isFinite(timestamp)) return false
// Reject stale deliveries so a captured request cannot be replayed later. if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSec) return false
const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex') const a = Buffer.from(expected) const b = Buffer.from(parts.v1 ?? '') return a.length === b.length && timingSafeEqual(a, b)}Compare in constant time, and reject anything that fails — an unsigned or badly-signed request is not from the platform.
webhook-id is stable across retries of the same delivery. Use it to deduplicate: delivery is
at-least-once, so a handler that already processed an id should acknowledge and do nothing.
Retries
Section titled “Retries”Your endpoint should return a 2xx promptly. Anything else — a non-2xx status, a connection
failure, a timeout — counts as a failed attempt.
| Attempt | Delay after previous |
|---|---|
| 1 | immediate |
| 2 | 10 seconds |
| 3 | 1 minute |
| 4 | 5 minutes |
| 5 | 30 minutes |
After five failed attempts the delivery is marked failed and not retried again. Retries are driven
by a per-organization dispatcher with durable alarms, so a backlog survives restarts and is not lost
if your endpoint is down for an hour.
Do the work asynchronously. Acknowledge the delivery first, then process — a handler that does slow work inline turns into a timeout, which turns into a retry, which arrives while the first attempt is still running.
Event catalogue
Section titled “Event catalogue”Webhooks fire for these event types:
| Event | Fired when |
|---|---|
api_key.created | An API key is minted, from /v1 or the console. |
member.added | A user is added to an org. |
member.removed | A user is removed from an org. |
member.role_changed | A member’s role changes. |
credential.issued | A verifiable credential is issued. |
credential.revoked | A credential’s status-list bit is flipped. |
Subscribing to * means these six, plus any added later. Every name here has an emitter behind it —
there are no reserved names that accept a subscription and then never deliver.
api_key.created carries the key’s id, name, and scopes, and never the secret:
{ "type": "api_key.created", "data": { "key_id": "key_…", "name": "ci-deploy", "scopes": ["credentials:issue"] }}It is a useful alerting signal on its own — a key minted with broad scopes is worth a look.
The audit log records far more than this — around sixty event types covering logins, SSO, SCIM,
passkeys, key rotation, delegation, session revocation, and connected accounts. If the event you
need is not in the table above, poll /v1/events rather than waiting for a webhook.
Managing endpoints
Section titled “Managing endpoints”GET /v1/organizations/:orgId/webhooks # never returns secretsDELETE /v1/organizations/:orgId/webhooks/:webhookIdRotating a secret means registering a new endpoint and deleting the old one — accept both signatures during the overlap, then drop the old.