TypeScript / Node — SDK helper
The@signa-so/sdk package exports a thin wrapper over
standardwebhooks:
- Returns
truefor a valid signature againstSECRET. - Returns
falseon stale timestamps (>5 min skew, enforced by the reference library). - Accepts rotation overlap (
v1,<curr> v1,<prev>) — verifies if either entry passes.
JSON.parse round-trip
first. Mismatched whitespace breaks the HMAC.
TypeScript / Node — without the SDK
Python
Installstandardwebhooks:
Go
Idempotency
Usewebhook-id (the value, not the body) as your application-level
idempotency key. The same alert delivered twice (retry, redeliver,
duplicate dispatch) will carry the same webhook-id, so your business
logic (creating tickets, sending notifications, writing to your own DB)
just needs to check “have I processed this id?” — exactly-once behaviour
without a separate delivery coordination system.
Important: do NOT dedup blindly on webhook-id alone at the infrastructure layer
A common pitfall: a queue/proxy in front of your handler does
if (seenWebhookIds.has(headers['webhook-id'])) return; and silently
drops legitimate retries. Our dispatcher reuses webhook-id across
retries on purpose — that’s how application-level idempotency works —
but it means an infrastructure-layer dedup keyed only on webhook-id
will swallow a retry that your handler actually wanted to see (e.g. the
first attempt timed out before your handler committed).
If you need infrastructure-layer dedup (retry-storm protection, queue
fan-out, observability counters), key on the tuple
(webhook-id, webhook-attempt) instead. The webhook-attempt header
is the delivery attempt number — 1 for the first delivery, 2 for
the first retry, and so on.
Security note:webhook-attemptis NOT part of the signed envelope. Per the Standard Webhooks spec, onlywebhook-id,webhook-timestamp, and the body are signed. An attacker who replays a captured request can set anywebhook-attemptvalue they like. Use it only for dedup-counting and observability — never as input to a security decision.
Testing locally
Production webhook endpoints must be public HTTPS URLs, so you cannot registerhttp://localhost. To exercise your verifier end-to-end before going live:
- Run your handler locally on (say) port 4000.
- Expose it with ngrok or Cloudflare Tunnel — both give you a public HTTPS URL that forwards to your local port.
- Register the tunnel URL via
POST /v1/webhooksand store the returned secret in your local env. - Trigger a test delivery with
POST /v1/webhooks/{id}/test. The envelope shape matchesalert.created, so your verifier exercises the same path it will in production. Test deliveries are free and do not count toward auto-disable counters.
PATCH the endpoint with the production URL (Changing the URL) — the same secret keeps working.
Common pitfalls
- Verify first, parse second. Always validate the signature against the raw bytes before calling
JSON.parse. Reject 401 on a failed verification and never touch the body. - Re-serialising the body. Verify against the bytes you received,
not against
JSON.stringify(JSON.parse(body)). Whitespace matters. - Comma vs space in
webhook-signature. During rotation the header contains TWO entries separated by a SINGLE SPACE. Some libraries split on commas — make sure yours follows the spec. - Forgetting timestamp freshness. A leaked secret + stale signature is replayable. The SDK helper enforces 5-min skew automatically; if you roll your own, do the same.