Webhooks
Webhooks push request lifecycle events to your endpoint as they happen, so your systems can react —
notify, log, or trigger a workflow. Subscribe under Settings → API → Webhooks (or POST /webhooks).
Events
Section titled “Events”request.created, request.opened, request.submitted, request.revealed, request.completed,
request.expired, request.revoked, request.reported, credential.rotation_recommended,
member.disabled, org_recovery.used, plus ping from “Send test”.
Each event is derived from your organisation’s audit chain, so a delivery exists exactly when the
audited change committed. credential.rotation_recommended fires on completion when you enable the
post-handover rotation signal (Policy → Handover assurance).
Delivery shape
Section titled “Delivery shape”POST <your URL>Content-Type: application/jsonUser-Agent: SafeHanded-Webhooks/1webhook-id: 01j…webhook-timestamp: 1757160000webhook-signature: v1,<base64>X-SafeHanded-Event: request.submitted
{ "id": "evt_1042", "object": "event", "type": "request.submitted", "occurred_at": "…", "organisation_id": "…", "client_id": "…", "seq": 1042, "data": { "request": { …request resource… }, "actor": { "type": "subject" } } }Payloads never contain envelope material, link tokens, codes or key material.
Verifying the signature
Section titled “Verifying the signature”Deliveries carry the Standard Webhooks headers, so the simplest option is an off-the-shelf library:
const { Webhook } = require('standardwebhooks');const wh = new Webhook(secret); // your whsec_… secretconst event = wh.verify(rawBody, { 'webhook-id': headers['webhook-id'], 'webhook-timestamp': headers['webhook-timestamp'], 'webhook-signature': headers['webhook-signature'],}); // throws if invalidOr verify by hand over the raw body (the HMAC key is the decoded base64 body of the whsec_…
secret):
$key = base64_decode(substr($secret, strlen('whsec_')));$expected = 'v1,'.base64_encode(hash_hmac('sha256', "$id.$timestamp.$rawBody", $key, true));
// The header can carry several space-separated signatures (e.g. while a secret is rotated): accept// if any one matches, always with a constant-time comparison. Reject stale timestamps (replays).$ok = abs(time() - (int) $timestamp) <= 300;$ok = $ok && array_filter(explode(' ', $signatureHeader), fn ($sig) => hash_equals($expected, $sig)) !== [];Always verify over the exact bytes you received, before parsing the JSON, and reject the delivery if the check fails.
Retries and de-duplication
Section titled “Retries and de-duplication”- Answer any 2xx within 10 seconds.
- Anything else (or a timeout) retries after 1 m, 5 m, 30 m, 2 h, 6 h, 12 h and 24 h — eight attempts. After that the delivery is marked failed and audited; the webhook shows “failing since” until a delivery succeeds.
- Deliveries are at-least-once — de-duplicate on the event
idin the body. Automatic retries reuse the samewebhook-id, but a redelivery from the app is a new delivery with a newwebhook-id(and the same eventid), sowebhook-idalone isn’t enough. - Secrets are shown once (at creation and rotation). Webhook URLs must be https on a public host.