Skip to content

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.

HeaderWhat it is
X-Wajub-Signaturev1= followed by a hex HMAC-SHA256
X-Wajub-TimestampUnix seconds, part of what was signed

What is signed

Not the body alone. The timestamp and the body, joined by a single dot.

The signed string
signed  = "{timestamp}.{raw_body}"
digest  = hex(hmac_sha256(signed, endpoint_secret))
header  = "v1=" + digest

The 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":{…}}

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" -hex

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

FrameworkWhat to use
Expressexpress.raw({ type: 'application/json' }) on that route only
Flaskrequest.get_data(), never request.json
Laravel$request->getContent()
Goio.ReadAll(r.Body) before any decoder
Next.js route handlerawait 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 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.

HeaderWhat it is
X-Sync-Signature-V1v1= followed by the hex HMAC-SHA256 of {timestamp}.{raw_body}
X-Sync-TimestampUnix 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.

The headers of a Sync lifecycle delivery
X-Sync-Event: account.connected
X-Sync-Timestamp: 1748083260
X-Sync-Signature-V1: v1=5d41f2a8c3e07b9a1f64d2e8b0c7a95e3f18d6b4a2c9e07f1b3d5a8c6e2f4b90
X-Sync-Signature: 2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae
X-Idempotency-Key: 9b74c9897bac770ffc029102a200c5de35d0f7b1a8e7c3b6d4f2a1e0c9b8a7f6

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.

Rotate an endpoint secret
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:

  1. Change your handler to accept a signature made with either of two secrets, and deploy it with the current secret alone.
  2. Rotate, and add the new secret, shown once, as the second one.
  3. 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.

What did you think of this content?