Vibe Engines
YouTube

Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path

System Design

Design Webhook Ingestion

Step 1 / 9

Learn system design by building a reliable webhook ingestion pipeline step by step.

The numbers to beatHMACover raw body~5 minreplay windowconst-timecompare

The whole design, in writing

Every step of the build above, written out: the problem each piece solves, the option that was taken and the ones that were not, the numbers, and how it fails in production.

The big idea

What is webhook ingestion?

A customer’s system fires an HTTP event at your endpoint every time something happens — a payment, a push, a status change. Sounds trivial: receive the POST, do the work. But the sender is flaky, retries on failure, and can burst; and the events are usually money, data, or state you cannot afford to lose or double-count.

Build a pipeline, not an endpoint: verify the event is authentic, acknowledge fast and buffer it in a durable queue, process idempotently, dead-letter what fails, and replay when you’ve fixed the cause. It turns an at-least-once firehose into effectively exactly-once processing.

Step 1 · The skeleton

Receive and write

The naive version: the Ingest API takes the POST and writes straight to the Target Store, synchronously, then returns. It works in a demo — and falls apart the moment the sender is untrusted, the write is slow, or anything downstream hiccups.

Senderfires eventsIngest APIverify + ack fast
New in this step: Sender, Ingest API.

Stand up the Ingest API as the single front door. For now it just receives an event and writes it to the target. Everything that follows is about making this path authentic, fast, and durable.

Why this piece earns its place

The quiet assumption here is that one public URL, one handler and one secret serve everybody. That holds exactly until the second sender arrives, and then the questions you get paged about have no answer: whose traffic just tripled, whose secret needs rotating, whose malformed payloads are filling the logs. None of that is recoverable after the fact — it has to be in the request. So carry the sender’s identity in the path or a header from day one, even with a single customer, and key everything operational off it: a secret per sender, a rate limit per sender, and a dashboard you can slice by sender. Version the path while you are at it. A sender will change its payload shape eventually, and one shared endpoint means you either break everyone at once or branch on the body — which is how one customer’s odd event becomes an outage for the rest.

What the new pieces do

Senderclient
The customer’s system (Stripe, GitHub, their own app) POSTs an event to your endpoint whenever something happens. It retries on any non-2xx, so it will re-deliver.
Ingest APIbackend
The single front door. Verifies the signature, checks the idempotency key, enqueues the event, and returns 2xx — all in milliseconds, so the sender never retries for slowness.

Step 2 · Who sent this?

Verify + replay protection

Your endpoint is public, so anyone can POST to it. And a captured event could be replayed later. Trusting the payload as-is lets an attacker forge or repeat events.

Ingest APIverify + ack fastVerifyHMAC + replay window
New in this step: Verify.

Your webhook endpoint is public. How do you know an event is genuinely from the customer and not forged or replayed?

  1. IPs are spoofable and senders’ IP ranges change; allow-lists break and don’t prove the payload wasn’t tampered with. You need to verify the content, not just the origin.

  2. TLS protects the data in transit but proves nothing about who sent it — anyone can open an HTTPS connection to your public endpoint and POST a forged event.

  3. Recompute the signature with the shared secret and constant-time-compare it, and reject events older than a few minutes. Only an authentic, fresh event passes — this is how Stripe/GitHub webhooks work.

Add a Verify step: recompute the HMAC signature over the raw body, constant-time-compare it to the header, and reject events outside a short replay window. Forged and repeated events are dropped at the door.

Why this piece earns its place

The replay window is the knob this step introduces, and what it really has to cover is narrower than it looks: honest clock skew between the sender’s host and yours, and nothing else. A well-behaved sender re-signs every delivery attempt, so a retry that lands an hour later arrives with a fresh timestamp and a fresh signature — it is not a stale event, and the window is not a budget for the sender’s backoff. Check that per sender before assuming it: one that signs once and reuses that signature across attempts is the exception, and it drags the window out to the sender’s whole retry horizon. That leaves the window as a pure security knob. Widen it and a captured request stays usable for that much longer; the window bounds how long a theft is worth anything, it never makes a replay harmless by itself. Tighten it and ordinary skew starts rejecting real events, which you will read in the logs as forgeries. A few minutes is the usual compromise, and you make it survivable by keeping your hosts on NTP and treating a spike in stale-timestamp rejections as a clock alarm before a security one. Rotation needs the same care: accept the old and new secret together for an overlap and try both, or a routine rotation reads exactly like an attack.

  • HMACover raw body
  • ~5 minreplay window
  • const-timecompare

What the new pieces do

Verifyservice
Recomputes the signature over the raw body and constant-time-compares it, and rejects events older than a few minutes — so only authentic, fresh events get in.

Back of the envelope

signature over raw body
re-serialized JSON breaks it
constant-time compare
no timing side-channel
reject old timestamps
stops replay attacks

Step 3 · Ack fast, work later

Queue + async worker

If the Ingest API processes the event synchronously and processing is slow — or the target is briefly down — your response is slow, so the sender times out and retries, piling on more load exactly when you’re struggling. Coupling ingestion to processing is a trap.

Durable Queuebuffer + decoupleWorkerprocess + retryTarget Storethe destination
New in this step: Durable Queue, Worker, Target Store.

Processing an event is slow and the target is sometimes down. If you do it synchronously, the sender times out and retries. What decouples them?

  1. More servers don’t fix slow synchronous processing — each request still blocks on the same downstream work, so senders still time out and retry. You need to stop blocking the response on processing.

  2. The gateway persists the event to a durable queue and returns 2xx in milliseconds; a separate worker processes at its own pace. A slow or dead worker becomes lag, not lost data — and the sender never retries for slowness.

  3. You don’t control the sender’s retry policy, and slowing retries doesn’t make your processing durable. The fix is on your side: acknowledge quickly and buffer the work.

The Ingest API writes the verified event to a Durable Queue and immediately returns 2xx. A separate Worker pulls from the queue and writes to the Target Store at its own pace. Ingestion and processing are now decoupled.

Why this piece earns its place

The moment you return 2xx before the work is done, that status code stops being a receipt and becomes a promise: the sender will never send this event again, and nothing downstream can ask it to. So the only thing allowed to happen before the ack is a write the queue has genuinely made durable — replicated or fsynced and acknowledged by the broker, not buffered in the gateway’s memory to be flushed a moment later. A gateway that dies holding a few thousand in-flight events loses them silently, and the one party who could have re-sent them has already been told they arrived. The corollary is the part people skip: when the queue is unavailable, the correct response is a failure. This is the one place in the design where deliberately failing the request is the safe move, because a non-2xx hands the event back to the sender’s retry machinery — a durable buffer you did not have to build, operate or pay for.

  • <50msack
  • durablebuffer
  • lag ≠ losson outage

What the new pieces do

Durable Queuecache
A durable log (Kafka/SQS) holding accepted events. It decouples ingestion from processing, so a slow or dead worker becomes lag, not lost data — and absorbs bursts.
Workerservice
Pulls events off the queue and does the real work (write a record, call an API). Retries transient failures with backoff; after N tries it sends the event to the DLQ.
Target Storedata
Where processed events land — a database, a data warehouse, another API. The end of the pipeline, and the thing that must never see a duplicate.

Back of the envelope

ack fast (2xx in ms)
sender never retries for slowness
say 5k events/s × a 30-min downstream outage = 9M events
the queue absorbs every one of them — this is what “lag ≠ loss” costs
9M × ~2 KB ≈ 18 GB of buffered events
size the queue for the outage you expect to survive, not for the steady state
durable queue
buffers bursts, survives worker death
async worker
processes at its own pace

Step 4 · Never process twice

Idempotent processing

Senders retry on any non-2xx, and even a successful event can be re-sent after an ambiguous timeout (you processed it but your ack was lost). Delivery is at-least-once, so the same event will arrive more than once — and processing it twice double-charges or double-writes.

Idempotency Storeseen event idsWorkerprocess + retry
New in this step: Idempotency Store.

Delivery is at-least-once — the same event will arrive more than once. How do you avoid processing it twice?

  1. On first processing, record the event id and the result; on any redelivery the id is already present, so you skip it (or return the recorded result). At-least-once delivery now behaves like exactly-once.

  2. Exactly-once delivery over a network is effectively impossible; every real webhook system is at-least-once. The receiver has to make processing idempotent — you can’t push this to the sender.

  3. Comparing whole payloads is slow, brittle (fields reorder, timestamps differ), and misses legitimately-identical events. A stable id (the sender’s event id or your idempotency key) is the reliable dedupe key.

Give the Worker an Idempotency Store keyed by event id. Before processing, check the id; if it’s already there, skip it. Otherwise process, then record the id. Redeliveries become no-ops — effectively exactly-once.

Why this piece earns its place

Check the store, then process, then record: written that way there is a window between the check and the record where a second worker can run the same check. Redeliveries are exactly what fills that window, because a sender retrying after an ambiguous timeout often arrives while the first copy is still in flight, so two workers each see an absent id and each do the work. The record and the effect have to commit together — a unique constraint on the event id inside the same transaction as the write, or an insert-first claim that marks the id in-progress so the second worker waits or drops. When the effect lands somewhere you cannot transact with, write the claim first and pass the same id downstream as its idempotency key. The other decision is how long ids live. They cannot be kept forever, and the floor is the sender’s longest retry horizon: expire them sooner and a late redelivery is applied a second time with nothing anywhere recording that it happened.

  • at-least-oncedelivery
  • dedupe by idnot payload
  • ≈ exactly-onceeffect

What the new pieces do

Idempotency Storedata
Records the id of every event processed. On a redelivery the id is already there, so the event is skipped — making at-least-once delivery behave like exactly-once.

Back of the envelope

store the event id after processing
redelivery finds it and skips
key on a stable id
the sender’s event id / idempotency key
result cache optional
return the first result on replay

Step 5 · When it just won’t process

Retries + dead-letter

Some events fail every attempt — a malformed payload, a permanently-rejecting downstream, a bug. Retrying them forever blocks the queue (head-of-line blocking) and burns resources; dropping them silently loses data. Neither is acceptable.

Durable Queuebuffer + decoupleDead-Letter Queuepoison messagesWorkerprocess + retryTarget Storethe destination
New in this step: Dead-Letter Queue.

A poison event fails every retry. Retrying forever blocks the queue; dropping it loses data. What do you do with it?

  1. A permanently-failing event retried forever blocks everything behind it (head-of-line blocking) and never succeeds. You need to get it out of the main flow after a bounded number of tries.

  2. Dropping on failure loses data silently — the worst kind of bug, invisible until a report is wrong weeks later. Failed events must be captured, not discarded.

  3. Bounded retries with backoff handle transient failures; anything that exhausts them goes to a DLQ — off the main path, held for a human to inspect and later replay. Silent loss becomes a visible, recoverable backlog.

The Worker retries transient failures with backoff, but after N attempts it moves the event to a Dead-Letter Queue. The main queue keeps flowing, and the failed event is parked — visible, countable, and recoverable — instead of lost.

Why this piece earns its place

N and the backoff schedule only mean something once you know which failure you are absorbing, and the two kinds want opposite treatment. A per-event failure — an unparseable payload, a field the target refuses — fails identically on every attempt, so retries buy nothing and the event should reach the dead-letter queue almost immediately. A dependency failure is the reverse: the event is fine and the downstream is gone, so every event behind it will also exhaust its attempts, and a schedule tuned for poison messages quietly shovels the whole stream into the DLQ. What you have then is not a backlog of poison to inspect; it is your outage copied into a second queue that you now have to drain by hand, in order, on top of live traffic. So branch on the failure: retry the transient with backoff and jitter, dead-letter the permanent fast, and when the failure looks like the dependency itself, stop consuming and let lag build rather than burning attempts against something that is not listening.

  • N + backoffthen DLQ
  • nohead-of-line block
  • alert onDLQ depth

What the new pieces do

Dead-Letter Queuecache
Where an event goes after exhausting its retries. Held for inspection and replay instead of dropped — turning silent data loss into a visible, recoverable backlog.

Back of the envelope

exponential backoff + jitter
don’t stampede a recovering downstream
cap attempts, then DLQ
poison messages leave the main flow
DLQ depth is an alarm
earliest signal of an upstream break

Step 6 · Fix it, then replay

Replay + monitoring

Events piled up in the DLQ during an outage or a bug. Once you’ve fixed the cause, you need those events processed — you can’t just tell the customer their data is gone. And you need to have known the pipeline was struggling before they told you.

On-call / Opswatches lag + DLQReplayDLQ → queue
New in this step: On-call / Ops, Replay.

A Replay worker re-enqueues dead-lettered events back onto the main queue once the fix is in — safe precisely because processing is idempotent, so anything already handled is skipped. Meanwhile Ops watches consumer lag and DLQ depth, with alerts, so a backup is caught in minutes, not weeks.

Why this piece earns its place

Replay leans on idempotency, and idempotency only promises that the same event is not applied twice. It says nothing about the order of different events, which is precisely what replay disturbs: dead-lettered events re-enter the queue behind whatever is arriving now, so an hour-old update to a record can land after the newer update that already superseded it, and the data ends up right in your logs and wrong in the target. Where events mutate shared state, apply them by the sender’s own event timestamp or version and discard anything older than what you already hold, instead of trusting arrival order. Rate-limit the replay too, because a DLQ drained flat-out competes with live ingestion for the same workers and turns a recovery into a second incident. And one note on the dashboards: lag counted in messages is not actionable on its own. Alert on projected time to drain — backlog over current rate — because that is the number that tells you whether to wait or add workers.

  • replayDLQ → queue
  • idempotentso replay is safe
  • watchlag + DLQ

What the new pieces do

On-call / Opsclient
You. Watches consumer lag and the dead-letter queue, and triggers a replay once a bad batch is fixed. On a locked-down deployment this dashboard is how you know the pipeline broke.
Replayservice
Once the underlying bug is fixed, re-enqueues the dead-lettered events so they flow through again. Idempotency makes replay safe — already-processed ids are skipped.

Back of the envelope

replay re-enqueues from the DLQ
idempotency skips the already-done
monitor consumer lag
the earliest sign of a slow/stuck worker
alert on DLQ depth
know before the customer tells you

You did it

You just designed webhook ingestion at scale.

SenderOn-call / OpsIngest APIVerifyDurable QueueDead-Letter QueueIdempotency StoreWorkerTarget StoreReplay
The finished design, end to end. · swipe to pan the diagram

Everything you assembled, in order

  • An Ingest API is the single front door — verify, then ack fast.
  • HMAC signature over the raw body + a replay window reject forged and repeated events.
  • A durable queue decouples ingestion from processing: an outage is lag, not loss.
  • An idempotency store dedupes redeliveries, making at-least-once behave like exactly-once.
  • Bounded retries with backoff, then a dead-letter queue for poison messages.
  • A replay worker recovers the DLQ once fixed — safe because processing is idempotent.
  • Consumer-lag and DLQ-depth monitoring catch trouble before the customer does.

Where an interviewer pokes next

Getting the boxes right is the easy half. These are the questions that separate a candidate who drew the diagram from one who has run the thing. Answer each one out loud before you open it.

  1. Why acknowledge the webhook before processing it?

    Senders retry on any non-2xx and time out if you’re slow, so synchronous processing turns a slow downstream into a retry storm. Acking fast (persist to a durable queue, return 2xx in milliseconds) decouples the sender from your processing speed: a slow or dead worker becomes consumer lag you can drain later, not lost events or a hammered endpoint. The rule is "durably accept, then process" — never make the sender wait on your work.

  2. Webhooks are at-least-once — how do you get exactly-once effects?

    You can’t make delivery exactly-once over a network, but you can make processing idempotent, which gives the same result. Record each event’s id (or an idempotency key) when you process it; on any redelivery the id is already present, so you skip it or return the recorded result. The retries and redeliveries still happen — they just become harmless no-ops. Exactly-once is a processing property, not a delivery one.

  3. What goes in the dead-letter queue and what do you do with it?

    Events that exhaust their retries — poison messages from a malformed payload, a permanently-failing downstream, or a bug. They’re moved off the main queue (so they don’t block healthy events) and parked in the DLQ, which you alert on. Once you fix the root cause, a replay worker re-enqueues them; idempotency ensures anything already processed is skipped. The DLQ turns silent data loss into a visible, recoverable backlog.

  4. How do you scale ingestion to millions of events a day?

    Partition the queue by a key (customer id or event id) and run many workers in parallel, one or more per partition — throughput scales with partitions. Keep the Ingest API stateless behind a load balancer so it scales horizontally, and make the idempotency store fast (a key-value store) since it’s hit on every event. Watch consumer lag as your saturation signal: rising lag means add workers or partitions. The queue is what lets ingestion and processing scale independently.

  5. Why verify the signature over the raw body specifically?

    Because the signature is computed over the exact bytes the sender signed. If you parse the JSON and re-serialize it, key order, whitespace, and number formatting can change, so your recomputed signature won’t match even for a legitimate event. So you must capture the raw request body before any parsing and verify against that. Pair it with a constant-time comparison (to avoid a timing side-channel) and a timestamp/replay window, and only authentic, fresh events get in.

Check yourself — the answers, and why

Seven steps in, these are the calls you should be able to make cold. Pick one, then read why.

  1. You acknowledge a webhook fast (before processing) because…

    Persist to a durable queue and return 2xx in ms; a slow/dead worker becomes lag, not loss.

  2. Webhook delivery is at-least-once, so processing must be…

    Record processed ids; redeliveries find the id and become harmless no-ops.

  3. An event that fails every retry should…

    Bounded retries handle transient failures; the DLQ contains poison messages, off the main flow.

  4. A durable queue between the API and the worker means an outage is…

    Decoupling ingestion from processing turns a worker outage into a latency blip, not loss.

  5. You verify the HMAC signature over the raw body because…

    Sign the exact bytes; parse-then-reserialize can reorder keys and whitespace, failing a valid event.

How you’d open this design in an interview

Before any boxes: agree what it must do, pin the qualities that shape everything, then build — naming each trade-off as you make it. The walkthrough above is that exact order.

What it must do

Agree on these before drawing a single box.

  • Receive: accept a customer’s webhook POST at a public endpoint whenever something happens.
  • Verify: reject forged or replayed events before doing any work.
  • Ack fast: return 2xx in milliseconds so the sender never retries for slowness.
  • Process once: apply a redelivered event exactly once — no double-charge, no double-write.
  • Recover: dead-letter what won’t process, replay it after a fix, and watch the lag.

The qualities that shape everything

Each one names the mechanism that buys it.

Only authentic, fresh events get in
Recompute an HMAC signature over the raw body and constant-time-compare it, and reject events outside a short replay window.
Sender never retries for slowness
Ack fast: the gateway persists the event to a durable queue and returns 2xx in milliseconds; a separate worker processes async.
At-least-once behaves like exactly-once
An idempotency store keyed by event id records what’s been processed, so a redelivery finds the id and becomes a no-op.
Poison events don’t block the queue or vanish
Bounded retries with exponential backoff, then move the event to a dead-letter queue — off the main flow, held for inspection.
Failed events are recoverable
A replay worker re-enqueues dead-lettered events once the bug is fixed, made safe by idempotency skipping the already-done.
Know it broke before the customer does
Ops monitors consumer lag and DLQ depth with alerts, so a backup is caught in minutes, not weeks.

The trade-offs you say out loud

Senior signal isn’t the boxes — it’s naming what you gave up and why it was the right price.

Verify an HMAC signature over the raw body over allow-listing the source IP

IPs are spoofable and senders’ ranges change, and TLS proves nothing about who sent it. Recompute the HMAC with the shared secret, constant-time-compare, and reject stale timestamps so only authentic, fresh events pass — and verify the raw bytes, since re-serialized JSON won’t match.

Ack fast + a durable queue over processing synchronously

Synchronous processing turns a slow downstream into a retry storm — the sender times out and re-fires exactly when you’re struggling. Persist to a durable queue, return 2xx in ms, and drain at the worker’s pace: an outage becomes consumer lag, not lost data.

Idempotency keyed by event id over comparing full payloads or demanding exactly-once delivery

Exactly-once delivery over a network is effectively impossible, and payload compares are slow and brittle (fields reorder, timestamps differ). Record the stable event id and skip redeliveries — at-least-once now behaves like exactly-once.

Bounded retries, then a dead-letter queue over retrying a poison event forever

A permanently-failing event retried forever blocks everything behind it (head-of-line blocking), and dropping it loses data silently. Cap attempts with backoff, then park it in a DLQ — silent loss becomes a visible, replayable backlog.

The answer, out loud

What a strong answer to “Design Webhook Ingestion at Scale” sounds like, first question to last trade-off. It is about 7 minutes of talking; the whiteboard and the interviewer fill the rest of the 45. Read it aloud once, then close the page and give it yourself.

  1. 0–4 min

    What makes this hard

    Let me start by naming what makes this hard, because the endpoint itself is trivial. The thing I don’t control is the sender — when it fires, how hard it bursts, how long it waits before deciding I failed, how many times it re-sends. What I do control is everything after the socket, so I’d frame the design as making my side indifferent to the sender’s behaviour: authentic events only, a response fast enough that the sender never forms an opinion about my speed, and processing that gives the same result whether an event turns up once or five times. The payloads are usually money or state, so my bar is no loss and no double-application.

  2. 4–9 min

    The shape I’d draw

    The shape is a pipeline with one public front door. I’d draw an ingest API that every sender posts to, and behind it the thing that actually does the work, and I want to be clear from the start that those are two processes with a durable boundary between them, not one request handler. The naive version has the API receive the post and write straight to the target store, and I’ll start there because it’s honest about where the difficulty is — the handler is maybe ten percent of this. The rest is what happens when the sender misbehaves, and it will, because re-firing on anything that isn’t a 2xx is what a well-behaved sender is supposed to do.

    Built in step 1: Receive and write
  3. 9–15 min

    Proving who sent it

    First thing I’d add is verification, because the endpoint is public and anything on the internet can post to it. I wouldn’t allow-list source IPs — they’re spoofable and senders change their ranges — and TLS tells me the bytes weren’t altered in transit, not who sent them. What I want is an HMAC over the body with the shared secret, recomputed on my side and compared in constant time. Two details, because they’re where this goes wrong in practice: I verify the raw bytes before anything parses them, since re-serializing JSON reorders keys and breaks a valid signature, and I reject events whose timestamp falls outside a short window, so a captured request can’t be re-fired at me next week.

    Built in step 2: Verify + replay protection
  4. 15–23 min

    Ack fast, work later

    Now the part I think is the real answer here. I would not process the event inside the request. If processing is synchronous then a slow target makes my response slow, the sender times out and re-fires, so load doubles at exactly the moment I’m struggling. Instead the ingest API writes the verified event to a durable queue and returns 2xx in milliseconds, and a separate worker drains that queue at its own pace. That converts the worst case from lost events into consumer lag: if the worker dies, senders keep succeeding, the backlog grows, and it drains when the worker returns. I’d size the queue for the outage I expect to survive — five thousand events a second across a thirty-minute downstream outage is nine million events, roughly eighteen gigabytes, and that’s the number worth putting on the board.

    Built in step 3: Queue + async worker
  5. 23–30 min

    Making redeliveries harmless

    Because I’m acking before processing, and because the sender re-sends on any ambiguity, delivery is at-least-once and I should stop trying to fix that. Exactly-once delivery across a network isn’t something I can buy; an exactly-once effect is something I can build. So the worker keeps an idempotency store keyed on the sender’s event id — a stable id, not a comparison of payloads, because fields reorder and timestamps shift and two genuinely different events can look identical. Before doing the work it checks the id, and if it’s already there the event is a no-op. That one store is what makes every retry in the system safe: the sender’s retries, my worker’s own retries, and later the replay.

    Built in step 4: Idempotent processing
  6. 30–36 min

    The events that never succeed

    Some events will never succeed — a payload that doesn’t parse, a target that permanently refuses it, a bug on my side. I’d retry with exponential backoff and jitter so I’m not stampeding something that’s trying to recover, but I’d cap the attempts, and past the cap the event moves to a dead-letter queue. That’s the compromise between two bad options: retrying forever means one event blocks everything behind it, and dropping it loses data silently — the failure I’d least like to explain, because nobody notices until a report is wrong weeks later. In the DLQ it’s off the main path but countable, and I’d alert on its depth.

    Built in step 5: Retries + dead-letter
  7. 36–42 min

    Recovering, and knowing

    A dead-letter queue nobody drains is just a graveyard, so I’d pair it with a replay worker that re-enqueues those events once the fix is out. This is where the idempotency store pays for itself a second time: I can replay a batch without auditing which of them already went through, because anything applied is skipped. Operationally I’d watch two numbers. Consumer lag is my earliest sign that the worker is falling behind or wedged, and DLQ depth is usually the first sign that something upstream changed shape. Both get alerts. On a deployment where I can’t see the box directly, those two dashboards are the whole difference between finding out in minutes and hearing it from the customer.

    Built in step 6: Replay + monitoring
  8. 42–45 min

    Where I’d go next

    So, in one breath: verify at the door, ack fast onto a durable queue, process idempotently, bounded retries then dead-letter, replay once fixed, and watch lag and DLQ depth. The trade-off running through all of it is that I’ve chosen availability of ingestion over immediacy of processing: I accept an event before I know it can be processed, so I’ve taken on a backlog to manage instead of a failure the sender would have handled for me. With more time I’d work through ordering per entity, and how I’d partition the queue so one loud customer can’t starve everybody else.

What this teaches

Learn system design by building a reliable webhook ingestion pipeline step by step. An interactive guide covering signature verification and replay protection, fast acknowledgement with a durable queue, idempotent processing, retries with a dead-letter queue, replay, and monitoring consumer lag — the backbone of every integration a forward deployed engineer builds.

Key takeaways

  • An Ingest API is the single front door — verify, then ack fast.
  • HMAC signature over the raw body + a replay window reject forged and repeated events.
  • A durable queue decouples ingestion from processing: an outage is lag, not loss.
  • An idempotency store dedupes redeliveries, making at-least-once behave like exactly-once.
  • Bounded retries with backoff, then a dead-letter queue for poison messages.
  • A replay worker recovers the DLQ once fixed — safe because processing is idempotent.
  • Consumer-lag and DLQ-depth monitoring catch trouble before the customer does.

Concepts covered

  • What is webhook ingestion?
  • Receive and write
  • Verify + replay protection
  • Queue + async worker
  • Idempotent processing
  • Retries + dead-letter
  • Replay + monitoring
built to survive a flaky sender, not memorized — verify, ack fast, dedupe, dead-letter, replay.
Finished this one? 0 / 65 System Designs done
NextDesign Multi-Tenant Customer Isolation Next in “Become a Forward Deployed Engineer” · step 40 of 63

Explore the topic

See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.

More System Designs