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.
| Level | Catches | Needs |
|---|---|---|
| Unit test | A verifier that accepts a forged signature | Nothing, it is pure computation |
| The CLI | A handler that crashes on a real payload | wajub listen and wajub trigger |
| Konsole | An endpoint your server cannot actually reach | A 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' ' -f1Sign the raw bytes, in the test too
If your test serialises the object again before signing, it is testing a different body from the one it posts, and it passes for the wrong reason. Build the body string once, sign that string, post that string.
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.
# 1
wajub listen --forward-to localhost:3000/webhooks/wajub
# 2
wajub trigger payment.succeeded
wajub trigger payment.failed
wajub trigger refund.succeededThe 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.
| Button | Proves |
|---|---|
| Send test | Wajub can reach your URL and your signature check passes |
| Retry a delivery | Your handler now processes a real event it previously failed on |
Send test does not prove your handler works
Its payload carries the type in type, not in event, and it has no api_version, no
pending_webhooks and no request. A handler written against it fails in production. Use it for
connectivity, and wajub trigger for behaviour.
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.
| Assertion | Produce it with | Passes when |
|---|---|---|
| A forged signature is rejected | The wrong secret, one flipped byte in the body, or no X-Wajub-Signature at all | Your endpoint answers 4xx and writes nothing |
| A replayed delivery is rejected | A valid signature over a timestamp 400 seconds old | Your endpoint answers 4xx, because the whole window is 300 seconds |
| The same event twice does the work once | The identical body and headers, posted a second time | One order, one email, one credit |
| A busy handler still answers in time | Your handler with the queue already full | The 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.