Partner API
Standard Webhooks is the format Stripe-adjacent tooling, Zapier, Twilio and Supabase already converge on. That means you can use an off-the-shelf library instead of hand-writing a verifier against our prose — and a hand-written verifier is where mistakes like comparing strings non-constant-time creep in.
| Header | What it is |
|---|---|
webhook-id | Identifies the event. Stable across retries — use it to deduplicate. |
webhook-timestamp | Seconds since the epoch. Check it against a tolerance to reject replays. |
webhook-signature | One or more signatures, space-delimited, each prefixed v1,. |
The three parts joined by full stops:
{webhook-id}.{webhook-timestamp}.{raw request body}Then HMAC-SHA256 with your endpoint secret, base64-encoded. The secret is shown to you when you create the endpoint and starts with whsec_; the part after that prefix is base64 and must be decoded to bytes before you use it as the key.
Sign the raw body bytes, exactly as received. Parsing the JSON and re-serialising it will change the whitespace and the signature will not match. In Express that means express.raw() on this route, not express.json().
Because the id and timestamp are inside the signature, neither can be swapped by someone replaying a captured request.
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verify({ secret, id, timestamp, signatureHeader, body }) {
// Reject anything too old to be a live delivery.
const age = Math.abs(Date.now() / 1000 - Number(timestamp))
if (!Number.isFinite(age) || age > 300) return false
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64')
const expected = createHmac('sha256', key)
.update(`${id}.${timestamp}.${body}`)
.digest('base64')
// The header may carry SEVERAL signatures during a rotation. Accept any match.
return signatureHeader.split(' ').some((entry) => {
const sent = entry.startsWith('v1,') ? entry.slice(3) : entry
const a = Buffer.from(sent)
const b = Buffer.from(expected)
// Compare in constant time, and only when lengths agree —
// timingSafeEqual throws on a length mismatch.
return a.length === b.length && timingSafeEqual(a, b)
})
}import base64, hashlib, hmac, time
def verify(secret, id_, timestamp, signature_header, body: bytes) -> bool:
if abs(time.time() - int(timestamp)) > 300:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{id_}.{timestamp}.".encode() + body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
for entry in signature_header.split(" "):
sent = entry[3:] if entry.startswith("v1,") else entry
if hmac.compare_digest(sent, expected):
return True
return Falsewebhook-signature carries a space-delimited list, and that is what makes secret rotation possible without downtime. During a grace window we sign with both the new secret and the old one, and you accept whichever you know. You update your end whenever you like rather than during a coordinated cutover, and nothing is dropped in between.
So iterate the list. A verifier that reads only the first entry will start failing halfway through a rotation, which is a confusing thing to debug months after you wrote it.
whsec_ is base64. Using the string as the HMAC key produces a valid-looking signature that never matches ours.===.Use a constant-time comparison, and check lengths first — Node’s timingSafeEqual throws when they differ.x-launchsite-event. That header is a convenience for platforms that route without parsing JSON. It is not covered by the signature — route on it if you like, but decide what to do from the verified body.Endpoint registration will include a test-fire button. It sends a real, properly signed delivery with "type": "test" so you can confirm your verifier works before trusting the integration.
Route on type and ignore test in your production path. It is deliberately not a real event name so that a receiver cannot mistake a test for the real thing and start a re-engagement campaign against a client who is doing fine.