A small, reusable layer that makes third-party webhooks safe to depend on: nothing lost, nothing processed twice, everything replayable.
Role
Architecture & design
Period
2026
Company
Personal project
Persist-first
store the raw event, then return 200
Dedupe
by provider event ID, enforced by a unique key
Replay
any event or time range, on demand
#Context
Payments, messaging and telephony providers all deliver state changes through webhooks, and they all behave the same way: they retry, they deliver out of order, they send duplicates, and they time out if the handler is slow. Integrations that do the work inside the HTTP handler eventually lose or double-apply an event. This kit isolates that concern once so each new integration only writes its handler.
#Problem & constraints
Processing inside the request means a slow downstream call becomes a provider timeout and a retry storm.
Providers guarantee at-least-once delivery, so handlers see duplicates unless something removes them.
When a handler bug ships, the affected events are gone unless the raw payloads were kept.
#Architecture
Events are verified and stored before the 200; processing, retries and replay happen behind the inbox.
The HTTP endpoint does three things and nothing else: verify the provider signature against the raw body, insert the raw event into a PostgreSQL inbox table keyed by provider and event ID, and return 200. The unique key makes a duplicate delivery a no-op. A relay picks up new inbox rows and enqueues them in BullMQ; workers run the integration-specific handler with exponential backoff and jitter. A job that exhausts its attempts moves to a dead-letter queue with the error attached, and the inbox row records the status of every attempt. Because the raw payload and headers are kept, a CLI can replay a single event, everything in the dead-letter queue, or a time range — which is also how a fixed handler gets applied to events it previously failed on. Handlers are written to be idempotent, with the event ID available as the natural key.
#Key decisions & tradeoffs
01Postgres inbox before the queue
Decision
Write the event to a durable table first and enqueue from there, instead of pushing straight to Redis.
Alternatives
Enqueue directly from the HTTP handler, or process synchronously.
Why
The inbox is the system of record: it survives a queue outage, gives deduplication through a plain unique constraint, and makes replay a query.
Cost of being wrong
One extra write per event and a relay process to operate; the table needs a retention policy.
02Dead-letter queue with replay
Decision
Park failed events with their error after bounded retries, and make re-running them a first-class command.
Alternatives
Retry forever, or drop after the last attempt and rely on logs.
Why
Infinite retries hide real bugs and block the queue; dropping loses data. A visible, replayable parking lot turns an incident into a fix plus one command.
Cost of being wrong
Someone has to watch the dead-letter queue; without an alert it becomes a silent backlog.
#Results
At-least-once
delivery turned into effectively-once processing
< 1 handler
of work in the request path: verify, insert, ack
1 command
to replay an event, the DLQ or a time range
#What I'd do differently
What this design does not solve is ordering: two events for the same entity can be processed out of sequence. The next step is per-entity ordering keys in the queue and a chaos test — killed workers, duplicated and reordered deliveries — to put numbers on throughput and confirm nothing is lost.