Webhook Testing
A webhook is an HTTP callback: when an event happens in one system, the provider, it sends an HTTP request, usually a JSON POST, to a URL that another system, the consumer, registered in advance. Example: when a card payment succeeds, the payment provider posts a payment.succeeded event to https://shop.example/webhooks/payments, and the shop marks order 1042 as paid and emails one receipt.
Webhook testing checks both sides. The provider must send the documented event, signed, and retry failed deliveries. The consumer, usually the tester's focus, must reject forged requests, acknowledge real ones and let each event take effect only once, even when it arrives late, twice or out of order.
Why it matters
Webhooks run with nobody watching. A lost or doubly processed event usually shows no error on any screen: the paid order stays "pending", stock is reduced twice or the customer gets two receipts. The defect often surfaces days later, when it is hard to reproduce.
A webhook endpoint is also a public URL. Anyone who finds it can post a fake "payment succeeded" event, so signature verification is a security control, and an easy one to get wrong. Test it with forged and altered deliveries, only on systems you own or have written permission to test.
Callbacks, acknowledgements and retries
In the REST lessons the client asked and the server answered. A webhook reverses the roles: the provider's server is the client, and the consumer's endpoint receives the request. The consumer acknowledges a delivery by answering quickly with a 2xx status; any other status (3xx, 4xx, 5xx), a timeout or a refused connection is usually a failed delivery.
Many providers then retry, with growing gaps between attempts, for a limited period; some disable an endpoint that keeps failing, and some never retry and offer manual redelivery instead. Rules, timeout and schedule differ per provider, so its webhook documentation is your test basis.
Retries mean delivery is "at least once": if the consumer did the work but its response was lost or too slow, the same event arrives again. Each event usually carries an id that stays the same on every retry (some providers add a separate id per attempt), and that id is how a duplicate is recognised. Order is usually not guaranteed either: a retried payment.succeeded can arrive after the payment.refunded that followed it.
Idempotency: every event takes effect once
Processing is idempotent when handling an event twice has the same effect as handling it once, as with idempotent HTTP methods. The usual design stores the event id in a table with a unique constraint, a database rule that refuses a second row with the same value. If the id is already there, the consumer does nothing and still answers 2xx; an error status would only make the provider retry an event that was already handled. Ask what happens when the id is stored but the work then fails: the retry must not be skipped as a duplicate, or the event is lost.
Two copies can also arrive at once on two server instances. A "check, then insert" in code can let both through; the database's unique constraint stops the second. Against reordering, the consumer compares the event with the current state, so a refunded order never moves back to paid.
- Payment provider Sends and retries
- Shop webhook endpoint Verifies, records, acknowledges
- Shop database Unique key on the event id
- 1 From Payment provider To Shop webhook endpoint: Attempt 1: POST the signed event
id: evt_7f3a91 - 2 Shop webhook endpoint internally: Verify signature and timestamp Pass
- 3 From Shop webhook endpoint To Shop database: Insert the event id, mark order 1042 PAID One receipt is queued
- 4 From Shop webhook endpoint To Payment provider: Response lost or later than the timeout reply The provider records a failed delivery Fail
- 5 Payment provider internally: Wait, then retry Growing gaps between attempts Caution
- 6 From Payment provider To Shop webhook endpoint: Attempt 2: the same event again Same event id, new timestamp and signature
id: evt_7f3a91 - 7 Shop webhook endpoint internally: Verify signature and timestamp Pass
- 8 From Shop webhook endpoint To Shop database: Insert the same event id
- 9 From Shop database To Shop webhook endpoint: Already stored: the unique key rejects it reply No second receipt, no change to the order
- 10 From Shop webhook endpoint To Payment provider: 200 OK: the duplicate is acknowledged reply Pass
Outcome: Delivered twice, processed once: order 1042 is paid and the customer gets exactly one receipt. Pass
Signature verification
Many providers sign each delivery with an HMAC (RFC 2104): a hash computed with a secret key, so only a holder of the key can produce the right value, and changing one byte of the message changes the value completely. Usually it is HMAC-SHA256, with a secret only the provider and consumer know, over the exact raw body, often with a timestamp (Unix time: seconds since 1 January 1970 UTC) in front. The consumer recomputes the HMAC over the bytes it received and compares. A match shows that the sender knew the secret and the body was not changed; rejecting old timestamps limits the replay of a captured request. Providers usually sign each attempt afresh, so a retry hours later carries a new timestamp. Verify against the raw bytes: a handler that re-serialises the JSON first rejects valid deliveries with unusual spacing or key order. The scheme below is illustrative: each provider documents its own headers and format.
secret shared value from the provider settings (test mode)
timestamp 1790000000, sent in the Pay-Timestamp header
input "1790000000." + raw body bytes, exactly as received
signature hex( HMAC-SHA256(secret, input) ), sent in Pay-Signature
consumer recomputes over the raw bytes it received, compares with the
header, and rejects a timestamp outside the tolerance windowTesting with a capture endpoint
A capture endpoint, often called a request bin, is a temporary public URL that records every request it receives. Register it as the webhook URL in the provider's test mode and trigger an event to see the real delivery: method, headers, raw body and timing. That tests the provider side before the consumer exists: compare the payload with the documentation and verify its signature with the test secret.
The capture then becomes test data for the consumer. Replay it, that is, send the same method, headers and raw body again, for example with cURL: unchanged and within the timestamp window to test duplicates, after the window to test that stale deliveries are rejected, and with one byte changed to test rejection. Inside the window only the thing you changed is wrong; outside it the old timestamp alone causes the rejection. Sign your own variants with an HMAC tool. To see retries, make delivery fail on purpose (stop the consumer, have it return 503, or point the provider at an endpoint that returns a 5xx status) and read the attempts in the provider's delivery log.
- A capture endpoint usually answers
200to everything: it proves what the provider sent, not that your consumer works. - Use test mode, test secrets and synthetic data only: captures are stored on a server for a while. Have any leaked secret rotated.
Try it: Webhook Tester Try it: Webhook Inspector Try it: Webhook Payload Formatter Try it: API Status Code Simulator
Worked example: tests for a payment webhook
Requirement: "On a valid payment.succeeded event, mark the order as paid and send one receipt. Deliveries are signed with HMAC-SHA256 over the timestamp and body; timestamps older than 300 seconds are rejected. The provider retries any non-2xx response and any response slower than 10 seconds." A captured delivery:
- W1: a valid new delivery →
2xxwithin 10 seconds, order 1042 PAID, one receipt. - W2: the same delivery replayed, and W3: two copies at once →
2xx, one receipt, one stored event id. - W4: amount changed to "1.00" without re-signing, a missing signature or another secret →
4xx, order unchanged, rejection logged. - W5: valid signature, timestamp 300 and 301 seconds old → accepted, rejected (2-value boundary value analysis: "older than 300" leaves 300 in the accepted partition). The age grows in transit, so ask how it is measured and rounded.
- W6:
payment.refundedhandled before the retriedpayment.succeededarrives → stays REFUNDED. - W7:
503to the first attempt → the provider retries, the retry succeeds, one receipt.
POST /webhooks/payments HTTP/1.1
Host: shop.example
Content-Type: application/json
Pay-Timestamp: 1790000000
Pay-Signature: sha256=<hex HMAC-SHA256 of "1790000000." + raw body>
{
"id": "evt_7f3a91",
"type": "payment.succeeded",
"data": { "order_id": 1042, "amount": "190.00", "currency": "SAR" }
}Common mistakes
- Testing one clean delivery, never a duplicate, a parallel copy, an out-of-order event or a failed first attempt.
- Expecting an error status for a duplicate: the provider would retry it. A duplicate gets a
2xxand is ignored. - Ignoring response time: slow work before answering (emails, PDFs, other services) makes the provider time out and retry a handled event. Verify, record, acknowledge, then process.
- Testing only that a valid signature passes, never that altered, unsigned, stale or wrongly signed deliveries fail.
- Sending real customer data or a live secret to a public capture endpoint, or pasting it into a bug report.
The QA perspective
Start when the integration is planned. Get the provider's webhook documentation (event types, payload schema, signature scheme, timeout, retries, ordering) and ask the developers: which id is the idempotency key? What does the handler answer while a downstream service is down? Who is alerted when retries run out?
In test cases and bug reports record the event id, attempt time, headers with secrets masked, raw body, the consumer's status and response time, and the resulting state: rows, emails, stock. Signature bugs often reproduce only with the exact bytes, so attach the raw capture.
Practice exercise
Design the tests for a shipment webhook
A delivery company sends shipment.delivered webhooks to your shop, documented below. The shop must mark the order delivered and send the customer one notification. Write the test cases and say how you would run each.
Webhooks: shipment events
- POST, JSON body; every event has a unique "id"; retries reuse the same id.
- Header Ship-Signature: hex HMAC-SHA256 of timestamp + "." + raw body,
timestamp in Ship-Timestamp (Unix seconds); older than 300 s is rejected.
Every attempt, retries included, is signed with a fresh timestamp.
- No 2xx within 10 s = failure. Up to 8 retries over 24 hours.
- Events can arrive more than once and in any order.- List the checks you can make on a captured delivery.
- Write consumer cases for duplicates, ordering, signatures and retries, with the expected response and state.
- Note for each case whether it needs the real provider in test mode, a replayed or edited capture, or a newly signed request.
- Write at least two questions for the developers.
Show the model answer
Captured delivery in test mode: POST, Content-Type: application/json, both headers, documented body fields and a signature valid under the test secret.
Consumer: (1) valid new event → 2xx within 10 s, DELIVERED, one notification; (2) replayed → 2xx, no second notification; (3) two parallel copies → one notification; (4) one body byte changed → 4xx, no change; (5) no signature or a wrong secret → 4xx; (6) timestamp 300 s and 301 s old → accepted, rejected (2-value BVA); (7) shipment.in_transit after shipment.delivered → stays DELIVERED; (8) 503 on the first attempt, and (9) a handler slower than 10 s → retried, still one notification.
Real provider: 1, 8, 9 (read its delivery log). Replayed capture: 2, 3. Edited capture: 4, and 5 without the header. Send 2 to 5 within 300 s of the capture, or the stale timestamp, not the condition under test, causes the rejection. Newly signed with the HMAC tool and a chosen timestamp: 5 with a wrong secret, 6, 7.
Questions: How is the timestamp age measured and rounded, and is exactly 300 seconds accepted? Which id is the idempotency key, and is it under a unique constraint? Is the notification sent before or after the 2xx?
Frequently asked questions
What is the difference between a webhook and polling an API?
With polling, the consumer calls the provider's API again and again to ask whether anything changed. With a webhook, the provider calls the consumer when the event happens. Webhooks cut delay and wasted calls but need a public endpoint, signature checks and duplicate handling; many integrations also read the API to catch up after missed events.
Which status code should a webhook endpoint return?
A 2xx, usually 200 or 204, as soon as the delivery is verified and safely recorded, including for a duplicate it has already handled. A 4xx for a delivery it rejects, such as one with a bad signature. Check the provider's documentation: many providers retry every non-2xx response, so a 4xx may not stop the retries.
Can a provider send webhooks to my localhost?
No. The provider's servers cannot reach a machine inside your private network. Capture the delivery on a public capture endpoint and replay it to your local consumer, use a tunnel your team approves, or use a command-line tool that forwards test events if the provider offers one.
If the endpoint uses HTTPS, is the signature still needed?
Yes. HTTPS protects the request in transit, but anyone can send a request to a public HTTPS URL. The signature shows that the sender knows the shared secret and that the body was not altered, which HTTPS alone does not tell the consumer.
References
- RFC 9110: HTTP SemanticsThe meaning of HTTP methods, header fields, status codes and content negotiation, independent of the wire version.
- RFC 2104: HMAC: Keyed-Hashing for Message AuthenticationThe HMAC construction: how a secret key and a hash function produce a message authentication code.
- ISTQB Certified Tester Foundation Level (CTFL) v4.0The foundation syllabus of software testing: test levels and types, test design techniques, test management and tools.
Practise with Mutqan tools
Get a unique URL, send webhooks to it and inspect method, headers, body and timing in real time.
Inspect captured webhook deliveries in depth: headers, signatures, JSON body, timing and replay data.
Verify webhook signatures (HMAC-SHA256/SHA1, Stripe, GitHub, Shopify, Slack styles) with your secret — locally.
Generate HMAC signatures (SHA-256/384/512/SHA-1) for webhook and API request signing.
Format, prettify and annotate webhook payloads from Stripe, GitHub, Shopify, Slack, Twilio and more.
Get a public endpoint that returns any HTTP status code, with optional delay and custom JSON body, to test client error handling.
Go deeper
Your progress on this topic
Not started
Mark the topic complete when you have studied it and done the exercise.
Sign in to track your progress
Mark topics complete, continue where you left off and unlock specialisation exams.