Skip to content

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.

POST /v1/organizations/org_01J…/webhooks
Authorization: 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.

{
"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.

POST /hooks/oauth-work HTTP/1.1
content-type: application/json
webhook-id: whd_…
webhook-event: credential.issued
webhook-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.

Your endpoint should return a 2xx promptly. Anything else — a non-2xx status, a connection failure, a timeout — counts as a failed attempt.

AttemptDelay after previous
1immediate
210 seconds
31 minute
45 minutes
530 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.

Webhooks fire for these event types:

EventFired when
api_key.createdAn API key is minted, from /v1 or the console.
member.addedA user is added to an org.
member.removedA user is removed from an org.
member.role_changedA member’s role changes.
credential.issuedA verifiable credential is issued.
credential.revokedA 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.

GET /v1/organizations/:orgId/webhooks # never returns secrets
DELETE /v1/organizations/:orgId/webhooks/:webhookId

Rotating a secret means registering a new endpoint and deleting the old one — accept both signatures during the overlap, then drop the old.