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 Python, PHP
and Ruby it raises an error on every delivery (a TypeError, a NoMethodError in Ruby), so every
webhook fails. In Node.js before 2.0.0 it silently switched replay protection off; from 2.0.0 a
tolerance that is not a positive number is ignored and the 300 second window stays. Go, Java and C#
refuse it when the code compiles. 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. Node SDK releases before 2.0.0 typed WebhookEvent
with 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.
Sync lifecycle deliveries
The onboarding and connection events a Sync platform receives, account.connected among them,
are signed the same way, with the same endpoint secret, under headers of their own.
| Header | What it is |
|---|---|
X-Sync-Signature-V1 | v1= followed by the hex HMAC-SHA256 of {timestamp}.{raw_body} |
X-Sync-Timestamp | Unix seconds, part of what was signed |
Verify them like any other delivery, with the same 300 second window: pass X-Sync-Signature-V1
and X-Sync-Timestamp to constructEvent where you would pass the X-Wajub- headers, or to the
verify function of Without an SDK. Refuse a delivery whose timestamp is
outside the window, even when its signature matches. constructEvent returns the body as it is,
and these deliveries have a flat body rather than the usual envelope, described in
the event catalogue. Then record each
X-Idempotency-Key you have processed, and ignore a delivery whose key you have already seen.
X-Sync-Event: account.connected
X-Sync-Timestamp: 1748083260
X-Sync-Signature-V1: v1=5d41f2a8c3e07b9a1f64d2e8b0c7a95e3f18d6b4a2c9e07f1b3d5a8c6e2f4b90
X-Sync-Signature: 2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae
X-Idempotency-Key: 9b74c9897bac770ffc029102a200c5de35d0f7b1a8e7c3b6d4f2a1e0c9b8a7f6`X-Sync-Signature` will be retired
These deliveries still carry X-Sync-Signature, the HMAC-SHA256 of the raw body alone, in
hexadecimal without v1=. It does not sign the time, so a captured delivery still verifies days
later. It is kept for the integrations that verify it today and will be retired later: verify
X-Sync-Signature-V1 and X-Sync-Timestamp instead, and stop reading it.
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/9d3f6a2e-4b1c-4e8a-b7d2-5c9e1f0a3b64/rotate-secret \
-H "Authorization: sk.xxxxxxxxxxxxxxxx…"Wajub generates the new secret, so you cannot deploy it ahead of time. It takes effect at once: every attempt sent from then on is signed with it, retries of older events included, since an attempt is signed when it is sent. Switch without losing a delivery this way:
- Change your handler to accept a signature made with either of two secrets, and deploy it with the current secret alone.
- Rotate, and add the new secret, shown once, as the second one.
- Once nothing has been refused for a while, remove the old secret.
A delivery refused during the switch is not lost: it is retried, signed with the new secret.