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.

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/wh_7Yh2MpL4tRb3nP8sZcXv/rotate-secret \
  -H "Authorization: sk.kZ3qP8mWvL2xR7tB5nY4hC6dF9jS1aG0eU3i…"

The new secret takes effect immediately, so deploy it before you rotate rather than after.

What did you think of this content?