Signature verification
Proving a delivery came from Wajub, and was not replayed.
Your webhook URL is public, so anyone can post a well-formed payment.succeeded to it. Verifying the
signature is the only way to tell a real delivery from a forged one, and two headers carry it.
| 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 a tolerance, never
your secret: the secret goes into the client you build, once. The tolerance is in seconds, or a
time.Duration in Go, a Duration in Java and a TimeSpan in C#. Go and Java require the
argument: pass 0 in Go, or null in Java, for the default 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. A switch on type 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-Idempotency-Key: 9b74c9897bac770ffc029102a200c5de35d0f7b1a8e7c3b6d4f2a1e0c9b8a7f6The 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.