Testing a webhook endpoint usually means waiting for a provider to send a real event, which is slow, hard to repeat, and impossible to make fail in a controlled way. This generates the request locally, signed with your secret, so your endpoint sees exactly what it would in production.
Test the rejection, not just the acceptance
Enable tampering and the payload is modified *after* the signature is computed — precisely what an attacker replaying a captured request with an altered amount would produce.
Replay it. Your endpoint must reject it.
If it accepts, one of these is true:
- You are verifying a re-serialised body rather than the raw bytes.
- You parse the JSON before verifying and act on the parsed object.
- Verification runs but its result is not checked.
- Verification throws and a broad try/catch returns 200 anyway.
The last is common in webhook handlers specifically, because they are often written to always return 200 so the provider stops retrying.
Idempotency is not optional
Providers retry on timeout, on non-2xx, and sometimes on success if the response was slow. Your endpoint will receive duplicates in normal operation.
Key on the event ID, record processed IDs, and make the handler safe to run twice. Without this a retry storm becomes duplicate charges or duplicate emails.
Return fast, process later
Acknowledge with 2xx immediately and do the work asynchronously. A handler that takes thirty seconds guarantees timeouts, which guarantees retries, which multiplies the load. Providers generally expect a response within a few seconds.
Replay windows
Where the scheme includes a timestamp — Stripe, Slack, Svix — reject anything outside a few minutes. Without that check, a request captured once is replayable forever, and the signature stays valid because nothing in it expires.
What to assert in a test
- A correctly signed request returns 2xx and processes the event.
- A tampered body returns 400 and does not process.
- A request with no signature header returns 400.
- A request with an old timestamp returns 400, even with a valid signature.
- The same event ID delivered twice processes once.
Frequently asked questions
Do I need a tunnel to test locally?
Not for testing verification logic — the replay command targets localhost directly, which is faster and lets you control the failure cases. Use a tunnel or stripe listen --forward-to when you need events genuinely originating at the provider.
What status code for a signature failure?
How do I test the retry path?
Return 500 deliberately and watch the provider retry with backoff. Confirm that when your handler recovers, the event is processed exactly once — which is what the idempotency key is for.
Is the signature real?
Yes. It is computed with HMAC-SHA256 over the provider's exact signed-payload format using the secret you supply. Your verification code will accept it precisely as it would a genuine event.