What Is a Webhook? The Same HTTP Request, With the Arrow Turned Around
A webhook is not a new protocol. It is the ordinary HTTP POST you already send, with the direction reversed: their server becomes the client, and yours becomes the API. Once you see it that way, everything else follows — why you register a URL, why your 200 means received and not done, why a missing 200 turns into retries, why retries turn into duplicates, and why the event id and an HMAC signature are the two lines that make a receiver production-ready.
"Webhook" sounds like a technology you have to go and learn. It isn't. There is no webhook protocol, no webhook port, no webhook library you must adopt. A webhook is the same HTTP request you already send every day, with the arrow turned around.
That single sentence is the whole idea. Everything else in this article — registration, the 200, retries, duplicates, signatures — is a consequence of turning the arrow around, and each one falls out of the previous one.
Without a webhook, you poll
Say a payment provider will eventually tell you a charge succeeded. Without a webhook, the only tool you have is asking:
while True:
r = httpx.get(f"https://api.provider.dev/charges/{charge_id}")
if r.json()["status"] == "succeeded":
fulfil_order(charge_id)
break
time.sleep(10)
Every ten seconds, you call their API. Anything new? No. No. No. Still no.
Two things are wrong here, and they pull in opposite directions. First, you are spending hundreds of requests to receive one yes — burning their rate limit and your own connection pool on answers you already knew. Second, despite all that traffic, the yes is still late: on average you learn about the event half a polling interval after it happened. Poll faster and the waste grows; poll slower and the latency grows. There is no setting that fixes both, because the design is wrong, not the number.
The reversal
A webhook removes the question. Instead of you calling their API, they call yours.
The roles swap, and that is the part worth sitting with: their server becomes the client, and your server becomes the API. The bytes on the wire are unremarkable — an ordinary HTTP POST with a JSON body. Nothing new is being spoken. Only the direction changed.
POST /webhooks/payments HTTP/1.1
Host: your-app.dev
Content-Type: application/json
X-Signature: sha256=8f4e...
{"id": "evt_9f21c", "type": "charge.succeeded", "amount": 4200}
Zero requests while nothing happens. One request the instant something does.
The precondition: you register a URL
A reversed call has a requirement the polling version never had: they cannot call you until they know where you live. So before any of this works, you register one URL in their dashboard or via their API.
That URL is the hook — the thing their system hangs your code on. Register it once and you are done. It is worth naming clearly because most of the "how do I set up a webhook" confusion is really just this: the setup is a URL, and there is nothing else to install.
Your 200 means received, not done
Now their request arrives at your endpoint and you answer with a status code. Here is the first thing that bites people:
200 does not mean the work finished. It means the message arrived.
That distinction matters because senders time out — typically in a few seconds. If you verify the signature, charge something, generate a PDF, email the customer, and then return 200, you are running a multi-second job inside someone else's request timeout. The right shape is: validate, write the event somewhere durable, hand the real work to a queue, and answer in milliseconds.
@app.post("/webhooks/payments")
async def receive(request: Request, bg: BackgroundTasks):
body = await request.body()
event = json.loads(body)
bg.add_task(process_event, event) # the real work, later
return {"ok": True} # 200, right now
No 200 means a retry
Suppose you don't answer — your process is restarting, or the job you inlined blew past their timeout. From the sender's side, something important is missing: they cannot tell a slow server from a dead one. Both look identical from the outside — a request went out, no response came back.
Given that ambiguity, the only safe behaviour is to assume the message was lost and send it again. So they do, on a backoff: after a minute, after five, after an hour, often for a day or more. A retry is not a special feature they built; it is the direct price of a missing 200.
Retries mean duplicates
Follow that one more step. If they resend whenever a response is missing, then a 200 that got lost on the way back also triggers a resend — and now you have processed the same event twice.
This is not a bug in their system. It is the delivery guarantee they are offering, stated honestly: at-least-once, never exactly-once. Every serious webhook provider says so in their docs, because the alternative — exactly-once across an unreliable network — is not something a sender can promise on its own.
Which means duplicate delivery is not an edge case to be surprised by. It is normal traffic, and your receiver has to be built for it.
The event id kills the duplicate
The fix is already in the payload. Every well-designed webhook body carries an event id — evt_9f21c above — and it is stable across retries: the same event redelivered carries the same id.
So the receiver becomes: have I seen this id before? Then skip it.
async def process_event(event: dict):
inserted = await db.execute(
"INSERT INTO seen_events (id) VALUES ($1) ON CONFLICT DO NOTHING",
event["id"],
)
if inserted.rowcount == 0:
return # already handled — a retry, not new work
await fulfil(event)
Let the database's unique constraint be the referee rather than a SELECT followed by an INSERT — the two-step version has a race exactly when it matters, which is when two copies of the same event arrive at once. With this in place, at-least-once delivery stops being a problem: a second copy changes nothing, which is what idempotent means.
A different problem: your URL is public
Duplicates are about repetition. There is a second, unrelated problem: your endpoint sits on the open internet, so anyone can POST to it. Nothing about receiving a request proves who sent it, and "it looked like a payment event" is not authentication.
The standard answer is a shared secret and a hash. The sender takes the exact bytes of the body, computes HMAC-SHA256(secret, body), and puts the result in a header. You recompute the same hash over the same bytes with the same secret and compare. Match means it really came from them, and the body was not altered on the way. Mismatch means reject — 401, no processing.
import hmac, hashlib
def verify(body: bytes, header: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header)
Two details do real work here. Hash the raw bytes, not a re-serialised dict — json.dumps of a parsed body will not reproduce the sender's whitespace or key order, and the hash will not match. And compare with compare_digest, not ==, so the comparison does not leak the correct value through its timing.
Their side is four ordinary lines
It is easy to imagine the sender as something elaborate. It is not:
body = json.dumps({"id": event_id, "type": "charge.succeeded", "amount": 4200}).encode()
sig = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
httpx.post(customer_url, content=body, headers={"X-Signature": f"sha256={sig}"})
Build the body, sign it, POST it — code you could have written yourself. There is no magic in a webhook, which is the point: the whole mechanism is HTTP plus a few disciplines about what you do when HTTP does what HTTP does.
The same shape shows up in AI serving
This is not only a payments pattern. It is how you talk to anything that takes longer than a request should wait — which describes most of the AI stack.
A batch inference job, a fine-tune, an image or video generation, a long agent run: none of them fit inside a synchronous HTTP call. So the APIs hand you a job id and offer the same two options. You can poll GET /jobs/{id} every few seconds — and hit exactly the tradeoff from the top of this article, now with a job that might run for twenty minutes and a poll loop that spends thousands of requests on "status": "running". Or you pass a callback URL and let them POST the result when it is ready.
Everything you just read transfers unchanged. The completion callback needs a 200 fast, because the sender's timeout does not care that your handler wants to write embeddings to a vector store — queue it. The callback will be retried, so the same generation result will land twice, so you deduplicate on the job id or you will bill a customer twice for one render. And the endpoint is public, so an unsigned "your fine-tune finished, here are the weights at this URL" is an invitation. The vocabulary changes from charge.succeeded to job.completed; the failure modes are identical.
Worth flagging one asymmetry: streaming token output over SSE or WebSocket is a different mechanism, not a webhook — a connection your client opened and holds. Webhooks are for the case where nobody is waiting on a connection at all, which is exactly the batch and background case.
Polling vs webhooks
| Polling | Webhook | |
|---|---|---|
| Who calls whom | You call their API | They call your API |
| Requests while idle | Constant, all wasted | Zero |
| Latency of the answer | Up to one polling interval | Near-instant |
| Setup | None — just call | Register a public URL once |
| Needs a public endpoint | No | Yes (plus a tunnel in local dev) |
| Delivery guarantee | You control it — ask again | At-least-once, with retries |
| Duplicates | Not really a concern | Expected — dedupe on event id |
| Authenticity | You know who you called | Must verify an HMAC signature |
| Failure mode | Rate limits, wasted spend, lag | Missed events while you are down |
The verdict
Reach for a webhook whenever the other side knows something before you do and you would otherwise be asking on a timer — payments, CI results, deploys, batch jobs, model runs, third-party state changes. It is the correct default for event notification between services.
Keep polling when you cannot expose a public endpoint, when you genuinely need a snapshot at a moment of your choosing rather than theirs, or as a reconciliation sweep alongside webhooks — a nightly "did I miss any events while I was down?" pass is the standard belt-and-braces setup, because a webhook you never received leaves no trace in your system.
And whichever you pick, remember the five things the reversal costs you, because a receiver that ignores them works in staging and pages you in production:
- Register the URL — that URL is the hook.
200means received, not done. Answer in milliseconds, queue the work.- No
200means retries, because they cannot tell slow from dead. - Retries mean duplicates. At-least-once, never exactly-once.
- The event id kills the duplicate; the HMAC signature proves it was really them.
References and further reading
The pattern itself — polling vs. being called
- Gregor Hohpe & Bobby Woolf, Enterprise Integration Patterns (Addison-Wesley, 2003) — Polling Consumer and Event-Driven Consumer are precisely the two halves of the reversal in this article, written up two decades before the word "webhook" was common.
- Michael T. Nygard, Release It!, 2nd ed. (Pragmatic Bookshelf, 2018) — on timeouts and why a hung dependency is indistinguishable from a dead one, which is the reason retries exist at all.
The HTTP mechanics — the request, the status code
- IETF, RFC 9110: HTTP Semantics — what a
POSTand a2xxactually promise. Section 15.3 is the source for "the request was received and processed", not "the downstream work completed". - Standard Webhooks — an open specification for webhook payloads, ids, timestamps and signature headers; useful as a checklist when you are the sender.
Delivery, retries and duplicates
- Martin Kleppmann, Designing Data-Intensive Applications (O'Reilly, 2017) — chapters 8 and 11 on at-least-once delivery, why exactly-once is not something a sender can offer alone, and idempotence as the receiver-side answer.
- Stripe: Webhook builder and best practices — the reference implementation of everything here: registered endpoints, event ids, the retry schedule, and the explicit advice to return
2xximmediately and process asynchronously. - GitHub: Webhook deliveries and redelivery — a second vendor's take, with a delivery log you can inspect and replay while you are debugging.
Signatures
- IETF, RFC 2104: HMAC — Keyed-Hashing for Message Authentication — the construction behind
HMAC-SHA256, and why a plain hash of the body would not do the job. - GitHub: Validating webhook deliveries — the raw-bytes and constant-time-comparison details, spelled out in code.
If a reference you'd expect is missing, say so in the comments and I'll add it.
Watch the reel: the short version walks the whole chain on a single page, and the full episode builds both sides of the request — their sender and your FastAPI receiver — in code.