Webhooks β Part 1: Foundations & Core Concepts
maang.io System Design Series
What is this?
Picture a busy restaurant. You order at the counter and now you need to know when your food is ready. The bad way: you walk back to the counter every thirty seconds and ask, "Ready yet? β¦ Ready yet? β¦ Ready yet?" You annoy the staff, you burn energy, and most of the time the answer is "no, not yet." The good way: they hand you one of those little buzzers that lights up and vibrates the instant your order is up. Now you sit down, do nothing, and the restaurant tells you when there's news.
That buzzer is a webhook. In the normal web request you make, you call the server ("is my order ready?"). A webhook flips the arrow around: you register a phone number β well, a URL β with the server once, and from then on the server calls you whenever something you care about happens. No repeated asking. The news comes to you.
That's why people call a webhook a "reverse API" or a "push API." A regular API is a phone number you dial. A webhook is you handing the server your phone number so it can dial you back.
The one-line idea: A webhook is an HTTP endpoint you host and register with a provider; when an event happens on their side, they make an HTTP
POSTto your URL with the event data β so you're notified the instant it happens instead of polling to find out.
We'll carry this restaurant-buzzer picture all the way through the topic. In this chapter we get the mental model and vocabulary; in Part 2 (Internals) we open up the machinery that makes delivery reliable at scale; in Part 3 (Interview) we defend the design choices under Staff/Principal pressure.
1. The problem: the pain of polling
Before webhooks, if your system needed to know about something happening in another system, you had exactly one option: poll. Ask, over and over, on a timer.
Say you've integrated Stripe and you want to know when a customer's payment succeeds. Without webhooks you'd write a loop: every 10 seconds, call GET /charges/{id} and check if the status flipped to succeeded. Multiply that by every pending payment, every customer, all day long. Three things go wrong:
- Wasted work. The overwhelming majority of polls return "nothing changed." If a payment settles once but you poll every 10 seconds for an hour, that's 359 useless calls to get 1 piece of real news. You're paying for 360 requests to learn one fact.
- Latency you can't win. Your freshness is capped by your poll interval. Poll every 10 seconds and you learn the news up to 10 seconds late β on average 5 seconds late. Poll faster to fix that and the wasted-work problem gets exponentially worse. You can trade latency for load but you can never get both.
- It doesn't scale on either side. The provider gets hammered by millions of "anything yet?" requests, and you burn CPU and rate limits making them.
A webhook dissolves all three at once. Zero wasted calls (you hear only when there's real news), near-zero latency (you hear the instant it happens), and near-zero load (one POST per actual event). The buzzer beats walking to the counter.
π‘ Rule of thumb: if you find yourself polling an external system on a short timer and most polls come back empty, you want a webhook. Polling asks "did anything happen?"; a webhook answers "here's what happened," and only when something did.
2. Core concepts & vocabulary
Let's name the parts, because the interview and the internals both depend on this vocabulary being precise.
- Producer (or provider / sender). The system where events happen and that makes the outbound call β Stripe, GitHub, Shopify, Twilio. In the analogy: the restaurant kitchen.
- Consumer (or subscriber / receiver). You β the system that hosts a URL and receives the
POST. The diner holding the buzzer. - Event. The thing that happened, with a type and a payload β
payment_intent.succeeded,push,order.created. Usually delivered as JSON in the request body. - Endpoint (the webhook URL). The publicly reachable HTTPS URL the consumer registers and hosts, e.g.
https://api.yourapp.com/webhooks/stripe. The providerPOSTs here. - Registration / subscription. The one-time setup where the consumer tells the producer "send events of type X to this URL." Done via the provider's dashboard or API.
- Delivery. A single attempt to
POSTan event to the endpoint. May succeed, fail, or time out β which is where all the interesting engineering lives (Part 2). - Signature / signing secret. A shared secret and an HMAC signature the producer attaches so the consumer can verify the request genuinely came from the producer and wasn't tampered with (Part 2, Section on security).
Two conventions make webhooks predictable in practice, and both matter enormously later:
- The consumer must respond fast with a
2xx. The producer treats a quick200 OK(or202 Accepted) as "delivered, don't retry." Anything else β a5xx, a timeout, a connection refused β means "failed, retry later." So your handler's job is: verify, acknowledge, and do the slow work asynchronously β never block the response on your business logic. (We'll hammer this in Part 2; it's the single most common mistake.) - Delivery is at-least-once, not exactly-once. Because failed deliveries are retried, the same event can legitimately arrive twice. This is not a bug β it's the fundamental contract, and it forces the consumer to be idempotent (Part 2). Hold that thought; it's the plot twist of the whole topic.
3. How you actually use it
There are two sides. On the consumer side you (a) register a URL and (b) host a handler. Registration is usually a couple of lines against the provider's API:
# One-time: tell Stripe where to send events, and which ones you care about.
curl https://api.stripe.com/v1/webhook_endpoints \
-u "sk_live_...:" \
-d "url=https://api.yourapp.com/webhooks/stripe" \
-d "enabled_events[]=payment_intent.succeeded" \
-d "enabled_events[]=payment_intent.payment_failed"
# Response includes a signing secret: whsec_... β store it, you'll need it to verify.
Then you host the endpoint. The shape that follows the two golden rules above:
// POST https://api.yourapp.com/webhooks/stripe
app.post("/webhooks/stripe", rawBody, (req, res) => {
// 1. VERIFY it's really from the provider (details in Part 2).
const event = verifySignature(req.body, req.headers["stripe-signature"], SIGNING_SECRET);
if (!event) return res.status(400).send("bad signature");
// 2. ACKNOWLEDGE immediately β hand the work to a queue, don't process inline.
queue.enqueue(event); // fast: just a durable write
res.status(200).send("ok"); // provider now marks this delivered
// 3. β¦a worker processes `event` later, idempotently (Part 2).
});
Notice what the handler does not do: it doesn't charge a card, send an email, or update ten tables before replying. It verifies, drops the event on a durable queue, and returns 200. That discipline β acknowledge fast, process later β is the difference between a webhook receiver that survives a traffic spike and one that melts. We'll see exactly why in Part 2.
On the producer side (if you're the one offering webhooks to your customers), you let them register URLs, and when an event fires you enqueue a delivery job. Building that reliably β the queue, the retry schedule, the signing, the SSRF defenses β is the whole of Part 2.
4. When to reach for it β and when not
A webhook is one of four ways two systems can stay in sync over time. Knowing which to reach for is a classic senior signal, so let's put them side by side. The axis that matters is who initiates, how often events flow, and which direction.
Here's the quick mental table:
| Approach | Direction | Connection | Best for | The catch |
|---|---|---|---|---|
| Webhook | Server β server | None held open; one POST per event |
Occasional, discrete server-to-server events (payment settled, PR merged, order shipped) | Consumer must host a public HTTPS endpoint; delivery is at-least-once |
| Polling | Client β server | New request each time | When the provider offers no push at all | Wasteful + latency-capped by interval |
| WebSocket | Both directions | One long-lived TCP connection | High-frequency, bidirectional, low-latency (chat, multiplayer, live cursors) | Stateful connections to manage at scale |
| SSE | Server β client | One long-lived HTTP stream | One-way, high-frequency serverβbrowser feeds (live scores, notifications) | One-directional; browser-oriented |
The clean way to decide:
- Webhook when the events are server-to-server, discrete, and not constant β something happens sometimes on their side and you want to know when, without holding a connection open. This is the whole "integrate with Stripe/GitHub/Twilio" world.
- WebSocket when you need a persistent, two-way, high-frequency channel β a chat app, a collaborative editor, a game. (See the sibling relationship with Asynchronous Messaging and the PublishβSubscribe Pattern β webhooks are essentially pub/sub delivered over HTTP, across organizational boundaries.)
- SSE when the server needs to stream to a browser in one direction and you don't want the weight of WebSockets.
- Polling only when the provider gives you nothing better.
π« Don't reach for a webhook when the consumer can't host a public endpoint (a mobile app, a browser tab, a machine behind a firewall) β there's nothing for the producer to POST to. That's exactly the gap SSE/WebSockets or a message queue fill. And don't use a webhook for a firehose of thousands of events per second to one consumer β at that volume a shared Kafka topic or a pub/sub stream is the right tool, not millions of individual HTTP POSTs.
β οΈ A webhook and a message queue are cousins, not rivals. Internally, a well-built producer uses a queue to deliver its webhooks (Part 2). The difference is the boundary: a queue like Kafka or Redis Pub/Sub is usually inside your trust and network domain; a webhook is how you push events across an org boundary to someone else's HTTP endpoint, over the public internet, with all the security and unreliability that implies.
5. Key takeaways
- A webhook is a "reverse API": instead of you polling a server, you register a URL once and the server
POSTs to you when an event happens β the restaurant hands you a buzzer instead of making you walk to the counter. - It kills the three pains of polling at once: wasted empty calls, latency capped by the poll interval, and load on both sides. You hear only real news, and you hear it immediately.
- Learn the vocabulary β producer, consumer, event, endpoint, registration, delivery, signing secret β and the two golden rules: acknowledge fast with a
2xxthen process asynchronously, and treat delivery as at-least-once (duplicates are normal, so you must be idempotent β Part 2). - Reach for a webhook when events are server-to-server, discrete, and occasional. Reach for WebSockets (two-way, high-frequency), SSE (one-way serverβbrowser stream), or polling (last resort) when the shape differs.
- A webhook is pub/sub over HTTP across an org boundary β a close relative of Asynchronous Messaging and the PublishβSubscribe Pattern, which is exactly why its internals (Part 2) are all about queues, retries, and signatures.
Next, Part 2 opens the machine: how a producer delivers a webhook reliably β the queue-and-worker architecture, retries with exponential backoff, why at-least-once forces idempotency, HMAC signature verification, replay and SSRF defenses β with worked numbers.