Webhooks Explained for Analysts: How to Specify, Test, and Debug Them
Written by Ahmed at Analyst Engineering, a Senior Technical Business Analyst with 10+ years in banking and payments delivery.
Key takeaways
- A webhook is an HTTP request a provider sends to a URL your system exposes when an event happens, which reverses the usual API direction: the provider decides when to call you.
- Webhook delivery guarantees differ per provider and must be written into requirements: Stripe retries failed deliveries for up to three days in live mode, while GitHub advises you to redeliver missed deliveries yourself.
- Every webhook consumer must handle four realities: duplicate deliveries, out-of-order events, forged requests, and missed events, so requirements need idempotency by event ID, state checks, signature verification, and reconciliation.
- Providers expect a fast 2xx acknowledgement: GitHub asks for a response within 10 seconds, and Stripe advises returning 2xx before any complex logic, which means processing belongs on a queue after the acknowledgement.
- Webhooks can be tested without building a server: webhook.site shows raw deliveries, and the Stripe CLI forwards, triggers, and resends real signed sandbox events.
A webhook is an HTTP request a provider sends to your system when something happens: a payment succeeds, a refund completes, a pull request opens. It reverses the usual API direction, which is exactly what makes it fast and exactly what makes it tricky. Webhooks arrive late, twice, out of order, or not at all, and every one of those behaviors needs a requirement and a test.
Webhooks work like this: you register an HTTPS URL with the provider and choose which events you want; when an event occurs, the provider sends a POST to that URL with a JSON body describing it and a signature header proving it came from them; your endpoint verifies the signature, acknowledges with a 2xx quickly, and processes the event afterwards. If the provider gets no timely 2xx, it may retry. Analysts must specify how the consumer handles duplicates, ordering, forged requests, and missed events, because the provider’s guarantees do not cover them.
APIs for Analysts, intermediate track. Builds on Part 1, what is an API and Part 3, how to analyze an API. Full learning path: APIs for Analysts.
In payment integrations, the webhook is often where the business outcome actually arrives: the API response says “accepted”, and the webhook later says “settled” or “failed”. That makes webhook requirements some of the highest-stakes lines in a specification, and some of the most often missing. How to document them for consumers is covered in API Documentation from Scratch.
What is a webhook, and how is it different from polling?
A webhook is a push; polling is a pull. Both answer “has something changed?”, with very different trade-offs.
| Polling | Webhook | |
|---|---|---|
| Who calls whom | Your system calls the provider repeatedly | The provider calls your system once per event |
| Speed of knowing | As slow as your polling interval | Near real time |
| API calls used | Many, mostly returning “no change” | One per event |
| Your system must expose | Nothing | A public HTTPS endpoint |
| Failure modes | Rate limits, wasted calls | Duplicates, out of order, missed deliveries, forged requests |
| Who controls timing | You | The provider |
Mature integrations use both: webhooks for speed, and a periodic API check or reconciliation for the events that never arrived. The general pattern is laid out in integration patterns.
What does a webhook delivery look like?
A webhook is just an HTTP request, so it has the same anatomy as any API call. A simplified Stripe delivery:
POST /webhooks/stripe HTTP/1.1
Host: payments.example.com
Content-Type: application/json
Stripe-Signature: t=1789480000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e7...
{
"id": "evt_1Q...",
"type": "payment_intent.succeeded",
"created": 1789480000,
"data": {
"object": {
"id": "pi_3Q...",
"object": "payment_intent",
"amount": 2000,
"currency": "eur",
"status": "succeeded",
"metadata": { "order_ref": "ORD-88213" }
}
}
}
The parts analysts care about:
- Event ID (
evt_...): the key for detecting duplicates. - Event type: which business event happened, and which handler runs.
- The object: the state of the resource when the event was created, not necessarily its state now.
- Your reference (
metadata.order_ref): how you match the event to your own records. If you did not send it on the original API call, it is not in the webhook. - Signature header: proof of origin. Stripe’s
Stripe-Signaturecarries a timestamptand an HMAC SHA-256 signaturev1.
GitHub’s deliveries follow the same shape with different names: the event type arrives in the X-GitHub-Event header, each delivery has a unique X-GitHub-Delivery identifier, and the signature is in X-Hub-Signature-256.
How do providers differ on retries, ordering, and duplicates?
This is the most important section for requirements, because “the provider handles delivery” means different things per provider. Two well-documented examples:
| Behavior | Stripe | GitHub |
|---|---|---|
| Expected acknowledgement | Return 2xx quickly, before complex logic | Respond with 2xx within 10 seconds |
| Automatic retries | Up to three days with exponential backoff in live mode; three times over a few hours in a sandbox | Documentation advises redelivering missed deliveries yourself once your server is back up |
| Manual redelivery | Dashboard resend up to 15 days; CLI stripe events resend up to 30 days | Redeliver from the webhook’s recent deliveries |
| Ordering | Not guaranteed; do not rely on event order | Treat as not guaranteed |
| Duplicates | Endpoints may occasionally receive the same event more than once | Use X-GitHub-Delivery to detect replays |
| Signature | Stripe-Signature, HMAC SHA-256, timestamp included | X-Hub-Signature-256, HMAC SHA-256 with your secret |
Read that table as a requirements finding. If your integration depends on a GitHub webhook and your service is down for an hour, nothing arrives later by itself; your requirements need a recovery process. With Stripe, three days of retries cover most outages, but your sandbox testing will not show that, because sandbox retries are much shorter. This is exactly the kind of difference API analysis should surface before the design is fixed.
What should an analyst specify for a webhook consumer?
Ten requirements. A webhook integration missing any of them has a known production failure waiting.
| # | Requirement | Example wording |
|---|---|---|
| 1 | Event catalogue | Consume payment_intent.succeeded, payment_intent.payment_failed, charge.refunded. Ignore all others with 2xx. |
| 2 | Payload mapping | Map data.object.metadata.order_ref to the order; map amount from minor units. |
| 3 | Endpoint per environment | One registered URL and signing secret per environment; sandbox events never reach production. |
| 4 | Signature verification | Reject any delivery whose signature fails verification, or whose timestamp is older than 5 minutes, with no side effects. |
| 5 | Acknowledgement contract | Return 2xx within 2 seconds after verification and persistence; process asynchronously. |
| 6 | Idempotent processing | Record processed event IDs; a duplicate event produces no second business action. |
| 7 | Out-of-order handling | Never move an order backwards; on doubt, retrieve the current object from the API before acting. |
| 8 | Missed event recovery | Hourly reconciliation compares open orders against the provider’s API and repairs gaps. |
| 9 | Monitoring | Alert when signature failures exceed a threshold or no events arrive for 30 minutes during business hours. |
| 10 | Secret rotation | Signing secrets rotated per security policy with an overlap window. |
A few notes on the ones people get wrong:
- Requirement 4’s 5-minute tolerance mirrors the default in Stripe’s official libraries, which reject signatures whose timestamp is more than 5 minutes old to limit replay attacks. The timestamp is inside the signed content, so it cannot be altered without breaking the signature.
- Requirement 5 is why processing belongs on a queue. Stripe explicitly advises returning a
2xxbefore complex logic, such as updating an invoice in an accounting system, because slow handlers cause timeouts and timeouts cause retries and duplicates. - Requirement 7 matters because the payload is a snapshot. If a
charge.refundedevent arrives beforepayment_intent.succeeded, a naive handler marks the order refunded and then overwrites it back to paid. - Requirement 8 is the one nobody writes. Webhooks are an optimization, not a guarantee, and reconciliation design is what makes the combined system trustworthy.
If your own API sends webhooks to consumers, the same list applies in reverse: publish your event catalogue, retry policy, signature scheme, and ordering guarantees in the documentation, as Part 4 describes. The Standard Webhooks specification is a useful public reference for consistent header names and signing.
How do you test webhooks without building a server?
Start by seeing real deliveries, then test the consumer’s behavior against the ten requirements.
Capture real payloads
webhook.site gives you a unique public URL and displays every request sent to it: method, headers, and body. Register it as the endpoint in a provider’s sandbox, trigger an event, and you have a genuine payload for mapping and test data.
The Stripe CLI does more, because it works with signed sandbox events end to end:
stripe login
# Stream sandbox events to your terminal and forward them to a local endpoint
stripe listen --forward-to localhost:4242/webhook
# In a second terminal: fire a real sandbox event
stripe trigger payment_intent.succeeded
# Resend an existing event to an endpoint, to test duplicate handling
stripe events resend evt_1Q... --webhook-endpoint=we_1Q...
stripe listen prints the signing secret for the session, starting with whsec_, which the consumer under test uses to verify signatures.
Test cases for a webhook consumer
| ID | Scenario | How to produce it | Expected |
|---|---|---|---|
| WH-01 | Valid signed event | stripe trigger payment_intent.succeeded | 2xx; order marked paid once |
| WH-02 | Invalid signature | Replay a captured payload from Bruno with one character of v1 changed | Rejected (400); no side effect; security log entry |
| WH-03 | Missing signature header | Same, header removed | Rejected; no side effect |
| WH-04 | Stale timestamp | Replay a genuine delivery captured more than 5 minutes ago | Rejected; no side effect |
| WH-05 | Duplicate delivery | stripe events resend for an already processed event | 2xx; no second business action |
| WH-06 | Out of order | Deliver the refund event before the success event (resend in reverse order) | Order ends in the correct final state |
| WH-07 | Unknown event type | Trigger an event not in the catalogue | 2xx; ignored; no error alert |
| WH-08 | Consumer down | Stop the consumer, trigger an event, restart | Event recovered by retry or reconciliation; alert raised |
| WH-09 | Slow processing | Downstream dependency delayed | Acknowledgement still inside the time limit; processing completes later |
| WH-10 | Wrong environment | A sandbox event sent to the production endpoint | Rejected by signature (different secret) |
WH-02 and WH-04 are where an API client earns its place: capture one genuine delivery, save it as a Bruno request pointing at the consumer’s test URL, and edit the signature or wait past the tolerance. Because you are sending the request, you control every header. The broader approach to proving duplicates are harmless is in idempotency testing.
How do you debug a webhook that did not arrive?
Work from the provider’s side inward.
- Check the provider’s delivery log. Stripe shows each endpoint’s event deliveries in Workbench with status codes and next retry time; GitHub lists recent deliveries per webhook with request, response, and a redeliver button.
- No delivery attempt at all? The event type is not subscribed, the endpoint is registered in a different environment or account, or the event never happened.
- Attempt with a connection error? The URL is not publicly reachable, DNS is wrong, or a firewall blocks the provider.
- Attempt with
4xx? Usually signature verification failing: the wrong signing secret for that environment, or a framework modifying the raw body before verification. Stripe’s documentation is explicit that the raw request body must be used. - Attempt with timeout? The handler is doing the work before acknowledging. Move processing behind a queue.
- Delivered with
2xxbut nothing happened? The problem is in your processing: check the queue, the logs by event ID, and whether the event was wrongly treated as a duplicate.
Finding the event ID in your own logs is the same skill as tracing any transaction, covered in reading production logs.
The APIs for Analysts learning path
Beginner: What is an API · API glossary · JSON for analysts · HTTP status codes · Your first collection · Why did my API request fail? · Reading an API contract
Intermediate: Analyze an API · Document an API · API test cases · Chaining and scripts · Webhooks (you are here) · GraphQL
Advanced: POCs and demos · API design review · Versioning and breaking changes · API security testing · API tests in CI
The takeaway
A webhook is an HTTP request the provider sends when an event happens, which makes integrations fast and introduces four realities: duplicates, out-of-order events, forged requests, and missed deliveries. Providers differ on retries, so write their actual behavior into requirements, then specify signature verification with a timestamp tolerance, a fast acknowledgement with processing on a queue, idempotency by event ID, state-aware handling of order, and reconciliation for anything that never arrives. Test all of it with webhook.site, the Stripe CLI, and replayed deliveries from your API client.
For documenting webhooks your own API sends, see API Documentation from Scratch, and for the test design behind the cases above, API Testing and QA Mastery for BAs. Specifying a webhook integration right now? Book a 1:1 Tech BA Coaching Call and we will pressure-test the requirements together.
Ahmed is a Senior Technical Business Analyst with 10+ years in banking and payments. He builds practical guides and tools for analysts at The Tech BA Toolkit.
Tags: Webhooks, Integration, API Testing, Stripe, Systems Analysis
About the author
Analyst Engineering is written by Ahmed, a Senior Technical Business Analyst with 10+ years of banking and payments delivery experience: ISO 20022 and SWIFT messaging, payments API integration, Kafka event validation, and production support. Every article comes from real delivery work, and each one is reviewed and updated as tools and standards change.
Related articles
- Synchronous vs Asynchronous: The Choice That Defines a System Synchronous and asynchronous communication differ in whether the caller waits, and that choice shapes coupling, latency, resilience, and the customer experience.
- Integration Patterns Every Systems Analyst Should Know The integration patterns that wire systems together: request-response, messaging, publish-subscribe, request-reply, batch file transfer, and webhooks. With payments examples.
- Idempotency Testing: Proving Duplicate Requests Are Safe How to test idempotency in APIs and event consumers: idempotency keys, duplicate requests, redelivered events, and the race conditions that cause double processing.
- How to Analyze an API: The Analyst's Method Before Anyone Writes Integration Code A method for analyzing an API before integration: capability mapping, field-level data mapping, failure behavior, limits, versioning, and a fit-gap worksheet.
Free account
Practice on the Labs, keep your progress
A free account, no password: an email link signs you in. It saves your steps and self-assessments on the Labs, shows your missions on a dashboard, unlocks the solutions, and, if you tick the box, sends you new missions and articles when they ship.
Your email is used to sign you in. Nothing else, unless you ask. Privacy.