Q19 of 37 · API testing
How would you test a webhook callback?
Short answer
Short answer: Stand up a receiver in the test (an HTTP server, ngrok tunnel, or webhook.site for manual exploration), trigger the source action, and assert the received payload, headers, and signature. Verify retry behaviour, signature verification, and idempotency (the same event ID may arrive twice).
Detail
Webhooks invert the normal request/response: the third party calls you. Testing them needs a receiver, signature handling, and an understanding of retry semantics.
The setup:
1. Stand up a receiver. In an automated test, a quick Express / Flask app listening on an open port:
import express from 'express';
let received: any[] = [];
const app = express();
app.use(express.json());
app.post('/hooks/stripe', (req, res) => {
received.push({ headers: req.headers, body: req.body });
res.sendStatus(200);
});
const server = app.listen(0); // random port
const port = (server.address() as any).port;
2. Make the source send to your receiver. Configure the webhook URL via the source's API:
await sourceApi.post('/webhooks', {
url: `http://localhost:${port}/hooks/stripe`,
events: ['payment_intent.succeeded'],
});
For external sources that can't reach localhost, use ngrok for a public URL during dev, or run inside a CI runner that's reachable.
3. Trigger the event and wait for delivery (with timeout):
await sourceApi.post('/payment_intents', { amount: 1000 });
await waitFor(() => received.length > 0, 5000);
4. Assert on the payload:
expect(received[0].body.type).toBe('payment_intent.succeeded');
expect(received[0].body.data.amount).toBe(1000);
The hard parts:
Signature verification. Most webhook providers sign payloads (Stripe-Signature, X-Hub-Signature, etc.). Test that:
- Valid signature → your handler accepts.
- Invalid / tampered signature → your handler rejects (401 / 400).
- Replayed signature with old timestamp → rejected (timestamp window check).
const valid = stripe.webhooks.constructEvent(rawBody, sig, secret);
// vs
expect(() => stripe.webhooks.constructEvent(rawBody, 'tampered', secret)).toThrow();
Idempotency. Webhook providers typically retry on non-2xx, sometimes deliver duplicates. Test:
- Same event ID delivered twice → handler processes once, returns 200 both times.
- A handler bug returning 500 → provider retries; handler eventually succeeds.
Retries. If your handler returns 500, providers retry with exponential backoff. Test:
- Force a 500 on first delivery; assert the provider retries.
- After N failures, the provider gives up — verify your monitoring catches this.
Out-of-order delivery. Some providers don't guarantee order. Tests should not assume "event B arrives after event A."
Tools that help:
- webhook.site for ad-hoc exploration.
- ngrok for local-dev public URLs.
- smee.io (GitHub) for proxying webhooks to localhost.
- Stripe CLI
stripe listen --forward-to localhost:3000for replaying real webhook events.