Signature verification
Proving a delivery came from Wajub, and was not replayed.
Your webhook URL is public. Anyone who finds it can post a well-formed payment.succeeded to it
and watch you ship an order nobody paid for. The signature is what separates a real delivery from
that, and it is the only thing that does.
Two headers carry the proof.
| Header | What it is |
|---|---|
X-Wajub-Signature | v1= followed by a hex HMAC-SHA256 |
X-Wajub-Timestamp | Unix seconds, part of what was signed |
What is signed
Not the body alone. The timestamp and the body, joined by a single dot.
signed = "{timestamp}.{raw_body}"
digest = hex(hmac_sha256(signed, endpoint_secret))
header = "v1=" + digestThe timestamp is inside the HMAC on purpose: it means a captured delivery cannot be replayed a day later, because moving it in time breaks the signature. Comparing the timestamp to now is the second half of that protection, and the SDKs do it for you with a 300 second window.
With an SDK
The secret belongs to the client, not to the call.
# What Wajub sends you
POST /webhooks/wajub HTTP/1.1
X-Wajub-Signature: v1=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
X-Wajub-Timestamp: 1748083260
X-Wajub-Event: payment.succeeded
X-Wajub-Delivery-Id: whd_7Yh2MpL4tRb3nP8sZcXv
Content-Type: application/json
{"id":"evt_…","event":"payment.succeeded","data":{…}}The secret is not a fourth argument
constructEvent takes the payload, the signature, the timestamp, and then the tolerance in
seconds. Passing your secret in that fourth slot does not authenticate anything: in Node and
Python it lands where a number was expected and replay protection stops behaving, and in PHP it
raises a TypeError outright. The secret goes into the client you build, once.
Go is the one exception to the default: its fourth argument has no default value, so pass 0 and
the SDK applies the same 300 second window.
Without an SDK
Three steps, in this order: check the prefix, recompute the digest, compare in constant time. Only then look at the timestamp.
# Reproduce a signature locally from a captured delivery
TIMESTAMP=1748083260
BODY=$(cat delivery.json)
printf '%s.%s' "$TIMESTAMP" "$BODY" \
| openssl dgst -sha256 -hmac "$WAJUB_WEBHOOK_SECRET" -hexThe four ways this goes wrong
The body was parsed before you hashed it
This is the cause of nearly every signature that will not verify. Your framework parses the JSON, you re-serialise it to hash it, and the bytes come back in a different order or with different spacing. The digest is over the exact bytes we sent.
| Framework | What to use |
|---|---|
| Express | express.raw({ type: 'application/json' }) on that route only |
| Flask | request.get_data(), never request.json |
| Laravel | $request->getContent() |
| Go | io.ReadAll(r.Body) before any decoder |
| Next.js route handler | await request.text() |
The comparison threw instead of returning false
Node's crypto.timingSafeEqual raises a RangeError when the two buffers differ in length, and a
forged signature is usually a different length. Without a length check first, your handler crashes
on exactly the request it was meant to reject. hash_equals and compare_digest handle this
themselves.
The event type was read from the wrong field
The field is `event`
The envelope carries event, not type. Several SDK READMEs and the Node WebhookEvent
TypeScript type still say type, so the mistake compiles cleanly and fails at runtime: your
switch falls through and nothing is fulfilled. Read event.event, or route on the
X-Wajub-Event header.
The clock drifted
Verification allows 300 seconds between the signed timestamp and your server's clock. A container whose clock has drifted past that rejects every delivery with a valid signature. If everything started failing at once and nothing changed in your code, check NTP before you check the secret.
The secret
Each endpoint has its own, starting with whsec_, shown once when the endpoint is created and
readable afterwards in Konsole. Rotating it is a single call.
curl -X POST https://api.wajub.com/webhooks/wh_7Yh2MpL4tRb3nP8sZcXv/rotate-secret \
-H "Authorization: sk.kZ3qP8mWvL2xR7tB5nY4hC6dF9jS1aG0eU3i…"The new secret takes effect immediately, so deploy it before you rotate rather than after.