Skip to content

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

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

POST <your URL>
Content-Type: application/json
User-Agent: SafeHanded-Webhooks/1
webhook-id: 01j…
webhook-timestamp: 1757160000
webhook-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.

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_… secret
const event = wh.verify(rawBody, {
'webhook-id': headers['webhook-id'],
'webhook-timestamp': headers['webhook-timestamp'],
'webhook-signature': headers['webhook-signature'],
}); // throws if invalid

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

  • 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 id in the body. Automatic retries reuse the same webhook-id, but a redelivery from the app is a new delivery with a new webhook-id (and the same event id), so webhook-id alone isn’t enough.
  • Secrets are shown once (at creation and rotation). Webhook URLs must be https on a public host.