Guide · Last reviewed
What is a webhook?
A webhook is not a special protocol or a piece of technology you install. It is an ordinary HTTP POST that arrives without you asking for it — and almost everything that makes webhooks hard follows from that one fact.
The short version
A webhook is an HTTP request that a service sends to a URL you gave it, when something happens that you said you cared about.
That is the whole idea. There is no webhook protocol, no webhook library you have to install, and nothing special about the request itself. If you can serve an HTTP POST, you can receive a webhook.
POST /your/endpoint HTTP/1.1
Host: api.example.com
Content-Type: application/json
Stripe-Signature: t=1750000000,v1=3f9d2a7c...
{"type":"checkout.session.completed","data":{...}}
The interesting question is never “what is a webhook”. It is “what does a correct receiver look like”, and that answer is longer than people expect.
The inversion that makes it different
When you call an API, you are in charge. You choose when to ask, you handle the response, and if the call fails you decide what to do. Control flow starts with you.
A webhook inverts every part of that:
| API call | Webhook | |
|---|---|---|
| Who starts it | You | The provider |
| When | When you need data | When something happens |
| Who retries | You | The provider |
| Failure means | Your code sees an error | The provider sees an error, and you may never know |
| Availability | Provider must be up | You must be up |
That last row is the one that catches teams out. A webhook makes your server part of someone else’s critical path. If your endpoint is down for ten minutes, an API integration just waits; a webhook integration accumulates failed deliveries that may or may not be retried, and may or may not be recoverable.
What actually arrives
Four things, and you need all of them:
The method and the URL. Almost always POST. Some providers append a path or a query string to the URL you configured, which matters if you are routing on the path.
The headers. The event type is often here rather than in the body (X-GitHub-Event, X-Event-Name), and so is the signature, the delivery id, and the retry count. Reading only the body throws away half of the request.
The raw body. Usually JSON, sometimes form-encoded, occasionally a JSON array. This is the part to be careful with: signatures are computed over the exact bytes, so anything that parses and re-serialises the body before you verify has already broken verification.
Nothing else. There is no session, no user, no context. The request carries everything, which is why the identifiers inside it — the customer id, your own metadata, the event id — are the only things connecting it back to your application.
The shape of a correct handler
Almost every provider-specific problem is a variation on the same five steps, in this order:
export async function POST(request) {
// 1. Read the raw bytes, before any parsing.
const raw = await request.text();
// 2. Verify the signature against those bytes.
if (!verify(raw, request.headers)) {
return new Response('bad signature', { status: 401 });
}
// 3. Answer immediately.
const event = JSON.parse(raw);
queue.push(event);
return new Response(null, { status: 200 });
// 4. Do the work elsewhere, idempotently, keyed on the event id.
// 5. Log everything, because you cannot ask for the request again.
}
Each step exists because of a specific failure:
Raw bytes first, because JSON.parse followed by JSON.stringify changes key order and whitespace, and the signature is over the original bytes. This single mistake accounts for more failed verifications than every other cause combined.
Verify before you act, because your URL is not a secret. It appears in dashboards, in logs, in screenshots, and in whatever config file it is pasted into. Anyone who has it can POST anything they like to it, and a handler that trusts the body will happily mark an unpaid order as paid.
Answer immediately, because providers have deadlines and most of them are short — three seconds for Slack, five for Shopify and Paddle, ten to fifteen for the rest. Being slow is treated the same as being broken, and the fix is always to acknowledge first and process afterwards.
Be idempotent, because delivery is at-least-once and never exactly-once. Retries, duplicate subscriptions, and providers that emit several events for one state change all mean the same event id can arrive more than once. Store the ids you have handled.
Log the raw request, because you cannot ask for it again. Some providers keep a delivery log you can replay from; several keep nothing at all. If the only record of a delivery is a stack trace, the delivery is gone.
Where the difficulty actually lives
Reading the list above, none of it looks hard. The difficulty is that every provider expresses these same ideas differently, and the differences are all in details that produce no useful error message when you get them wrong.
- Stripe signs
{timestamp}.{body}with a comma-separated header. Paddle signs{timestamp}:{body}with a semicolon-separated one. Calendly signs{timestamp}.{body}under a different header name than Stripe. - GitHub, Shopify, and Lemon Squeezy sign the body alone — but GitHub prefixes the digest with
sha256=, Shopify base64-encodes it, and Lemon Squeezy uses plain hex. - Twilio and Square fold the request URL into the signature, so verification depends on a value that is not in the request.
- Mailgun puts the signature inside the body and signs two other fields from the body.
- Discord and SendGrid do not use an HMAC at all; they use public-key signatures, so there is no shared secret to look for.
A wrong signature and a wrong secret produce the same failure. So does a body that was silently re-serialised by your framework. This is why looking at the actual request — the real headers, the real bytes — is usually faster than reading the documentation again.
How to see one
You need a URL the provider can reach and something that will show you what arrived. That is what this site is: create a URL, paste it into whichever dashboard configures the webhook, trigger the event, and read the delivery.
The other options are worth knowing:
- A tunnel (ngrok, Cloudflare Tunnel,
localhost.run) forwards public traffic to your machine, so you can debug in your own code with a breakpoint. Best once your handler exists and you are debugging logic. - Provider replay. Stripe, GitHub, Shopify, Clerk, Paddle, and Square all keep a delivery log you can resend from. Best for reproducing a specific failure with the identical payload.
- A capture URL. Fastest when you have not written the handler yet, when you are not sure the provider is sending anything at all, or when you need to know exactly which headers a proxy is stripping.
Those three answer different questions, and knowing which question you have saves the most time.
What a webhook is not
Not real-time streaming. A webhook is one request per event with no ordering guarantee. Events can arrive out of order, and a retry can land after a later event. If order matters, use the timestamps in the payload rather than arrival order.
Not a queue. It looks like one, which is the trap. A queue holds messages until you successfully process them; a provider retries for a bounded window and then drops the event permanently. If durability matters, the first thing your handler should do is put the event in a real queue.
Not a data source. The payload is a notification, and it can be stale by the time you read it. For anything that must be authoritative — a balance, a current subscription status — treat the webhook as a signal to fetch, and fetch it from the API.
Answers
Common questions
Is a webhook the same as an API?+
They are two directions of the same thing. With an API you make the request and wait for the answer. With a webhook the provider makes the request to you, and your response is just an acknowledgement. The protocol is identical; who initiates it is not.
Do webhooks need to be public?+
The sender has to be able to reach the URL, so yes — a `localhost` address will never work. During development you either use a tunnel such as ngrok or Cloudflare Tunnel, or point the provider at a capture URL like the ones here and read what would have arrived.
What should my webhook endpoint return?+
A 2xx status, as fast as you can, with an empty body unless the provider specifically asks for content. Do the real work after you have answered — a slow endpoint is indistinguishable from a broken one, and most providers retry the delivery.
Are webhooks secure?+
The transport is, if you use HTTPS. The authenticity is not automatic: anyone who learns your URL can POST to it. That is what signatures are for, and verifying them is the one piece of webhook handling you should never skip.
Why did my webhook arrive twice?+
Because delivery is at-least-once. A retry after a lost response, two subscriptions on the same event, or a provider that emits several events for one state change all produce duplicates. Handlers have to be idempotent; the alternative is charging someone twice.
Keep reading
Related guides
Try it on a real request
Reading about it only goes so far.
Create a URL, point a provider at it, and inspect the delivery — the real headers, the raw bytes, and a signature you can check in the browser.
No signup · No email · Ready in about a second