A webhook works like this: you give a provider a URL, the provider sends an HTTP POST to that URL when something happens, and your server answers with a status code. That is the entire mechanism. There is no protocol beyond HTTP, no persistent connection, no SDK required.
Every explanation says roughly that, and then stops. What people actually get stuck on is everything the summary skips: what is in the request, what your response means to the sender, and what happens in the minutes after your endpoint returns a 500. So this walks through one real exchange from both ends.
The whole thing, in one exchange
Here is an actual webhook. Not a diagram, the bytes. GitHub sending a push event to a server:
POST /hooks/github HTTP/1.1
Host: api.yourapp.com
Content-Type: application/json
Content-Length: 8214
User-Agent: GitHub-Hookshot/a1b2c3d
X-GitHub-Event: push
X-GitHub-Delivery: 8c7a1f30-9b2e-11f0-8d4a-3f1e6c2b7a91
X-Hub-Signature-256: sha256=6f3a...c81b
{"ref":"refs/heads/main","commits":[...],"repository":{...}}
And your side of it:
HTTP/1.1 200 OK
Content-Length: 0
That is a complete webhook. An ordinary POST with a JSON body and some custom headers, answered by an ordinary status code with an empty body. If you have ever written an HTTP handler, you already know how to receive one.
The interesting parts are the three custom headers, because they carry everything the JSON body does not:
X-GitHub-Eventsays which event this is, so one endpoint can serve many event types.X-GitHub-Deliveryis a unique id for this delivery. It stays the same across retries of the same event, which makes it the value you dedupe on.X-Hub-Signature-256is an HMAC of the raw body, keyed with a secret only you and GitHub know. Without checking it, anyone who learns your URL can send you a fake push event.
Every provider does the same three things with different names. Stripe calls its signature header Stripe-Signature and puts the event id inside the JSON. Shopify uses X-Shopify-Topic and X-Shopify-Hmac-Sha256. The pattern is identical; only the spelling changes.
Are webhooks GET or POST requests?
A webhook is a POST request, essentially always. The event data has to travel somewhere, and it travels in the request body — which is exactly what GET does not have. If you are looking at a webhook that appears to be a GET, one of three things is happening.
A verification handshake. Several providers send a one-time GET before they will start delivering anything, to prove you control the URL. Meta’s platforms send GET /your-endpoint?hub.mode=subscribe&hub.challenge=1158201444&hub.verify_token=... and expect you to echo the hub.challenge value back as a plain-text body. Answer it once and every subsequent request is a POST. Slack does the equivalent with a JSON url_verification challenge over POST.
A misconfigured sender. Some low-code and automation tools let you pick the HTTP method when you configure an outgoing “webhook”, and someone picked GET. The payload then arrives as query-string parameters, which is workable for two or three scalar fields and falls apart the moment you need nested data. It also cannot be signed the way a body can: an HMAC over a URL is not a standard any provider implements, so you lose signature verification entirely.
It is not a webhook. A URL you poll on a schedule is a GET, but that is polling, not a webhook — the distinction is direction, and we cover it in webhooks vs APIs.
Practically, this means your endpoint should accept POST and reject everything else with 405 Method Not Allowed, with one exception: if your provider uses a GET handshake, handle GET on that route too, and handle it only as the challenge. Never let a GET trigger real processing. Query strings land in access logs, proxy logs and browser history in a way request bodies do not, so anything that arrives by GET should be treated as public.
Step 1: registration, and what you are really agreeing to
Before any of that, you told the provider where to send events. Usually you paste a URL into a dashboard and tick which event types you want.
Two things happen at that moment, and both are easy to miss.
You created a public endpoint. It has to be reachable from the provider’s servers, which means reachable from the internet, which means reachable by anyone who guesses or finds the URL. Providers require HTTPS for this reason, but TLS only proves the connection is private, not that the sender is who they claim. That is what the signature is for.
You also got a secret. The provider shows it once, usually a random string, and uses it as the HMAC key for every request. If you skipped that step, or stored the secret and never wrote the verification code, your endpoint currently trusts anyone. The full security model is worth reading before you go to production, and finding the signing secret is a small task people lose an afternoon to on Stripe specifically.
Step 2: something happens, and a request is queued
A user pushes a commit. A card gets charged. An order ships.
The provider does not usually send your webhook from inside that operation. It writes an event to its own queue and a separate worker delivers it, because if delivery were synchronous, every slow customer endpoint would slow down the provider’s own product. This is why events sometimes arrive a second or two after the thing they describe, and why they can arrive out of order: two events queued a millisecond apart may be picked up by different workers and finish in either order.
That detail matters more than it sounds. If you build logic that assumes order.created always lands before order.updated, it will work in testing and break in production, because nothing in the system guarantees it.
Step 3: the POST arrives at your server
Now your handler runs. In order, before anything else:
Read the raw bytes. Not the parsed JSON. The signature was computed over the exact body that was sent, and if your framework parses the JSON and you re-serialize it to check the signature, key order and whitespace shift and the HMAC will not match. This is the single most common webhook bug in existence, and it produces an error message that says nothing about parsing.
Verify the signature. Recompute the HMAC over those bytes with your secret and compare it in constant time. If it does not match, return 401 and stop. Do not log the payload as trusted, do not parse it, do not act on it.
Check the timestamp, if there is one. A valid signature proves the request was genuine at some point. It does not prove it is genuine now. Anyone who captured a signed request can send it again verbatim, and it will verify forever unless you reject requests whose timestamp is too old. That is why a signature alone does not stop replays.
Then answer, and only then do the work. More on why in the next section.
If verification is failing and you cannot see why, the causes are surprisingly few: six of them account for nearly everything.
Step 4: your response, which means more than you think
Your status code is not a formality. It is the only signal the provider has about whether to send this event again.
- 2xx means received. The provider marks the delivery successful and moves on. It will not send this event again.
- Anything else, or no response at all, means failed. The event goes back into the provider’s retry schedule.
Two consequences follow, and they are the reason experienced teams structure handlers the way they do.
First, answer fast. Providers set a timeout, often just a few seconds, and a response that arrives after it counts as a failure even if your code succeeded. So if you charge a card, update three tables, and call two internal services before returning 200, a slow day for any of those turns into a webhook the provider believes failed and will send again.
The fix is always the same shape: verify, persist the event somewhere durable, return 200 immediately, and do the real work in a background job.
POST arrives
-> verify signature (fast, in-request)
-> write event to a queue (fast, in-request)
-> return 200 (done, sender is happy)
-> worker picks it up later (slow work happens here)
Second, 2xx is a lie you are allowed to tell. Returning 200 says “I have this”, not “I finished processing it”. That is correct and intentional. Once the event is stored durably, you own it, and whether your worker succeeds is your problem to retry, not the provider’s.
What happens when you fail
Say your server was down for ten minutes. Here is what the provider does, roughly, because the specifics differ by vendor:
It retries on a growing delay. Not every second, which would hammer an already struggling endpoint, but on exponential backoff: a few seconds, then longer, then longer still, usually over a window of hours or days. Stripe keeps trying for up to three days in live mode. GitHub lets you redeliver manually from the UI instead.
Two things you should know about that window:
Retries create duplicates. If your handler processed the event and then the response was lost on the way back, the provider saw a failure and sends it again. You will get the same event twice, and unless your handler is idempotent, the second copy charges the card a second time. This is not an edge case; it is normal operation.
When retries run out, the event is gone. The provider stops trying and, from their side, the matter is closed. There is no automatic catch-up. If you were down for the whole retry window, those events simply never arrived, and nothing tells you which ones you missed. That silence is the sharpest edge of the whole model, and it is why serious pipelines keep a dead letter queue and a periodic reconciliation job against the provider’s API.
Receiving one on your laptop
A provider cannot reach localhost, so local development needs a tunnel that gives your machine a public URL and forwards requests to it. Some providers ship their own: Stripe’s CLI does it with stripe listen --forward-to localhost:4242/webhook, and prints a signing secret specific to that session. Otherwise a general tunnelling tool works for any provider.
Two things trip people up here. The CLI secret is not your dashboard secret, so verification will fail if you mix them. And a tunnel URL usually changes every time you restart it, which means re-registering it with the provider unless you use a reserved subdomain.
Where a gateway fits
Everything above is per-provider work: read the raw body, verify this provider’s scheme, answer fast, store durably, retry, dedupe, keep history. It is the same work in every service that receives events, and every copy is a place it can drift.
A webhook gateway is that layer written once. Webhooker gives each source its own EU-hosted ingest URL, verifies the provider’s signature before accepting anything, stores the event in a PostgreSQL queue, answers in single-digit milliseconds, then delivers to your service with retries, a circuit breaker, a dead letter queue and replay. Your handler stops being a hardened public endpoint and becomes an internal service consuming events that already passed every check.
Whether that is worth buying or building is a fair question with real numbers on both sides, which we work through in build vs buy. If you want to watch the exchange happen, you can get an ingest URL on the free plan and point a provider at it, or read the docs first.
Frequently asked questions
What does a webhook actually look like?
An HTTP POST with a JSON body and a few custom headers, sent to a URL you registered. The headers usually carry the event type, a unique delivery id, and a signature over the body. Your server answers with a status code and typically an empty body. There is nothing else to it at the protocol level; it is the same HTTP your app already speaks.
Do webhooks use GET or POST?
POST, essentially always, because the event data travels in the request body. A few providers send a one-time GET to verify you control the URL before they start delivering, such as Meta’s hub.challenge handshake, but the events themselves arrive as POSTs.
What should my webhook endpoint return?
A 2xx status, as fast as possible, with no body needed. Return it after you have verified the signature and stored the event, but before you do the actual processing. Anything other than 2xx, or a response slower than the provider’s timeout, is read as a failure and puts the event into their retry schedule.
What are the downsides of webhooks?
You run a public endpoint that anyone can POST to, so every request has to be authenticated. Delivery is at-least-once, so duplicates arrive and handlers must be idempotent. Events can land out of order. If you exhaust the provider’s retry window, events are lost with no notification. And failures are silent: a broken endpoint looks exactly like a quiet period until someone notices missing data.
How is a webhook different from an API?
Direction. With an API your code makes the request when it wants data; with a webhook the provider makes the request to you when something happens. That reversal also moves the availability requirement onto your side, which is the part that changes how you build. We cover the trade in webhooks vs APIs.
Can I test a webhook without a provider?
Yes. Send yourself a POST with curl shaped like the provider’s real request, including the headers, to check your routing and parsing. To test signature verification properly you need a correctly computed HMAC, which is easier with the provider’s own CLI or test-event feature than by hand.