Skip to content

Testing webhooks

Three levels, from a unit test to a replayed live delivery.

A webhook handler has three failure modes worth testing separately: it accepts something it should reject, it rejects something it should accept, or it does the work twice. Each one is caught at a different level.

LevelCatchesNeeds
Unit testA verifier that accepts a forged signatureNothing, it is pure computation
The CLIA handler that crashes on a real payloadwajub listen and wajub trigger
KonsoleAn endpoint your server cannot actually reachA registered endpoint

Level 1: sign a payload yourself

The signature is HMAC-SHA256 over "{timestamp}.{body}", so a test can produce a valid one without any network. Write the four cases: valid, wrong secret, tampered body, old timestamp.

# The string that gets signed, for reference
TIMESTAMP=1748083260
BODY='{"id":"evt_test_1","event":"payment.succeeded","data":{}}'

printf '%s.%s' "$TIMESTAMP" "$BODY" \
| openssl dgst -sha256 -hmac "whsec_test_secret" -r \
| cut -d' ' -f1

Level 2: a real event on your laptop

Unit tests prove the verifier. They do not prove the handler survives a real payload, with the fields you did not expect and the types you did not plan for.

Two terminals
# 1
wajub listen --forward-to localhost:3000/webhooks/wajub

# 2
wajub trigger payment.succeeded
wajub trigger payment.failed
wajub trigger refund.succeeded

The CLI signs what it forwards with a secret it prints, so your verification runs for real. Export that secret and nothing else in your code changes.

The payment.succeeded trigger alone delivers five events. If your handler only knows one of them, the other four are landing in your default branch right now.

Level 3: the deployed endpoint

Two different buttons in Konsole, for two different questions.

ButtonProves
Send testWajub can reach your URL and your signature check passes
Retry a deliveryYour handler now processes a real event it previously failed on

The four assertions worth writing

These four run at level 1, in your own suite, with no network. Each one names the input that produces the condition, so none of them needs a real delivery to reproduce.

AssertionProduce it withPasses when
A forged signature is rejectedThe wrong secret, one flipped byte in the body, or no X-Wajub-Signature at allYour endpoint answers 4xx and writes nothing
A replayed delivery is rejectedA valid signature over a timestamp 400 seconds oldYour endpoint answers 4xx, because the whole window is 300 seconds
The same event twice does the work onceThe identical body and headers, posted a second timeOne order, one email, one credit
A busy handler still answers in timeYour handler with the queue already fullThe response leaves inside 10 seconds, since a slower one is retried

The first is the only thing standing between your fulfilment code and anyone who learns your URL. The third is the one that costs money when it is missing, because every delivery is retried up to five times.

What did you think of this content?