---
title: "What Is a Webhook? The Same HTTP Request, With the Arrow Turned Around"
description: "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."
keywords: "what is a webhook, webhook vs polling, webhook retries, at-least-once delivery, webhook idempotency, event id deduplication, HMAC SHA256 signature, verify webhook signature, FastAPI webhook receiver, webhook 200 response, async job callbacks"
created_at: "2026-08-24T10:45:00"
post_type: "anonym_post"
content_type: "technical_article"
---

![Banner](./banner.webp)

<div style="display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: center; padding: 1rem 1.25rem; margin: 1.5rem 0 2rem; background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%); border: 1px solid #e2e8f0; border-radius: 12px;">
  <span style="font-size: 0.95rem; font-weight: 600; color: #475569; margin-right: 0.25rem;">Prefer to watch?</span>
  <a href="https://youtu.be/nrANHdKhUYU" target="_blank" rel="noopener noreferrer" style="display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.55rem 1rem; border-radius: 8px; background: #ff0000; color: #ffffff; font-size: 0.875rem; font-weight: 600; text-decoration: none;">▶ The full episode</a>
  <a href="https://youtube.com/shorts/BpjGpASQa_4" target="_blank" rel="noopener noreferrer" style="display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.55rem 1rem; border-radius: 8px; background: #0f172a; color: #ffffff; font-size: 0.875rem; font-weight: 600; text-decoration: none;">⚡ The short version</a>
  <a href="https://t.me/SoftwareEngineerBlog" target="_blank" rel="noopener noreferrer" style="display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.55rem 1rem; border-radius: 8px; background: #229ed9; color: #ffffff; font-size: 0.875rem; font-weight: 600; text-decoration: none;">✈ Telegram</a>
</div>

"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*:

```python
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.

```http
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**.

```python
@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.

```python
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.

```python
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:

```python
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

<table>
  <thead>
    <tr>
      <th>&nbsp;</th>
      <th>Polling</th>
      <th>Webhook</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Who calls whom</strong></td>
      <td>You call their API</td>
      <td>They call your API</td>
    </tr>
    <tr>
      <td><strong>Requests while idle</strong></td>
      <td>Constant, all wasted</td>
      <td>Zero</td>
    </tr>
    <tr>
      <td><strong>Latency of the answer</strong></td>
      <td>Up to one polling interval</td>
      <td>Near-instant</td>
    </tr>
    <tr>
      <td><strong>Setup</strong></td>
      <td>None — just call</td>
      <td>Register a public URL once</td>
    </tr>
    <tr>
      <td><strong>Needs a public endpoint</strong></td>
      <td>No</td>
      <td>Yes (plus a tunnel in local dev)</td>
    </tr>
    <tr>
      <td><strong>Delivery guarantee</strong></td>
      <td>You control it — ask again</td>
      <td>At-least-once, with retries</td>
    </tr>
    <tr>
      <td><strong>Duplicates</strong></td>
      <td>Not really a concern</td>
      <td>Expected — dedupe on event id</td>
    </tr>
    <tr>
      <td><strong>Authenticity</strong></td>
      <td>You know who you called</td>
      <td>Must verify an HMAC signature</td>
    </tr>
    <tr>
      <td><strong>Failure mode</strong></td>
      <td>Rate limits, wasted spend, lag</td>
      <td>Missed events while you are down</td>
    </tr>
  </tbody>
</table>

---

## 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:

1. Register the URL — that URL *is* the hook.
2. `200` means received, not done. Answer in milliseconds, queue the work.
3. No `200` means retries, because they cannot tell slow from dead.
4. Retries mean duplicates. At-least-once, never exactly-once.
5. 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](https://www.rfc-editor.org/rfc/rfc9110.html) — what a `POST` and a `2xx` actually promise. Section 15.3 is the source for "the request was received and processed", not "the downstream work completed".
- [Standard Webhooks](https://www.standardwebhooks.com/) — 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](https://docs.stripe.com/webhooks) — the reference implementation of everything here: registered endpoints, event ids, the retry schedule, and the explicit advice to return `2xx` immediately and process asynchronously.
- [GitHub: Webhook deliveries and redelivery](https://docs.github.com/en/webhooks/using-webhooks/handling-webhook-deliveries) — 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](https://www.rfc-editor.org/rfc/rfc2104) — the construction behind `HMAC-SHA256`, and why a plain hash of the body would not do the job.
- [GitHub: Validating webhook deliveries](https://docs.github.com/en/webhooks/using-webhooks/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](https://youtube.com/shorts/BpjGpASQa_4) walks the whole chain on a single page, and the [full episode](https://youtu.be/nrANHdKhUYU) builds both sides of the request — their sender and your FastAPI receiver — in code.
