← Documentation

Partner API

Verify a signature

Your endpoint is a public URL. Anyone who learns it can post to it, and a receiver that acts on unverified input will happily start a re-engagement campaign because a stranger asked it to. Verifying takes about ten lines.

We use Standard Webhooks, not a scheme of our own

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.

The three headers

HeaderWhat it is
webhook-idIdentifies the event. Stable across retries — use it to deduplicate.
webhook-timestampSeconds since the epoch. Check it against a tolerance to reject replays.
webhook-signatureOne or more signatures, space-delimited, each prefixed v1,.

What is signed

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.

Node

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

Python

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 False

Why there can be more than one signature

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

Getting it wrong in the ways people actually do

  • Verifying a re-serialised body.The single most common cause of “the signature never matches”. Capture the raw bytes.
  • Forgetting to decode the secret. Everything after whsec_ is base64. Using the string as the HMAC key produces a valid-looking signature that never matches ours.
  • Comparing with ===.Use a constant-time comparison, and check lengths first — Node’s timingSafeEqual throws when they differ.
  • Skipping the timestamp check. The signature alone does not stop somebody replaying a delivery they captured last month.
  • Trusting 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.

Testing without waiting for something to happen

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.