How to Set Up Webhooks on Both Sides of the Connection
Webhooks look like the simplest integration pattern available — an HTTP POST when something happens. The complexity is entirely in the guarantees, and the guarantees are weaker than most implementations assume.
Delivery is at-least-once, so duplicates are routine. Ordering is not guaranteed, so an update can arrive before the create it depends on. And the receiver is an arbitrary endpoint that may be down, slow, or hostile. Every serious webhook implementation is a response to those three facts.
Assume duplicates, disorder, and failure
These aren't edge cases to handle later — they're the normal operating conditions, and designing for them upfront is much cheaper than debugging them.
- ›Receiving: verify the signature, respond 2xx within seconds, do the real work on a queue.
- ›Receiving: make handlers idempotent, keyed on the provider's event ID. Duplicates are guaranteed, not hypothetical.
- ›Receiving: never assume ordering. Include a timestamp or version and ignore events older than the state you already have.
- ›Sending: retry with exponential backoff, sign every payload, and give consumers a delivery log they can inspect themselves.
- ›Sending: enforce a timeout and disable endpoints that fail persistently, or one dead consumer consumes your retry capacity indefinitely.
The approaches
Receiving webhooks
// best for: Integrating Stripe, GitHub, Slack, or any third-party event source.
- Express.js
- Next.js
- Django
- Laravel
- FastAPI
- NestJS
- Vercel
The handler has one job: verify the signature, record the event, acknowledge. Everything else goes on a queue.
The reason is timeouts. Most providers give you a few seconds and treat a slow response as failure, so a handler that processes inline gets retried while it's still working — producing exactly the duplicate processing you were trying to avoid. Acknowledge fast, work asynchronously.
Signature verification needs the raw request body. Almost every framework parses JSON automatically, and re-serializing that object produces different bytes than were signed, so verification fails for reasons that look like a configuration problem. The webhook route needs raw-body access, mounted before global parsing middleware.
Use a constant-time comparison for the signature. A regular `===` on a hex digest leaks timing information.
// Ordering is not guaranteed. An 'updated' event can arrive
// before the 'created' it depends on, and a stale event can
// arrive after a newer one — overwriting good state with old.
async function apply(event: Event) {
const current = await db.getVersion(event.objectId);
if (current && current >= event.objectVersion) return; // stale, drop it
await db.upsert(event.objectId, event.data, event.objectVersion);
}
// Idempotency keyed on the provider's event id, because
// at-least-once delivery means duplicates are routine.
if (await seen(event.id)) return ack();The gotcha
Out-of-order delivery silently corrupts state. A `subscription.updated` arriving after a later `subscription.deleted` resurrects a cancelled subscription. Compare a version or timestamp on the object before applying, and drop anything older than what you already have.
Sending webhooks to your customers
// best for: Any API where customers need to react to events in your system.
- PostgreSQL
- Redis
- AWS
- Express.js
- Django
- Laravel
Sending is a delivery system, not a `fetch` call. You need durable queuing, retry with exponential backoff over hours, per-endpoint concurrency limits so one slow consumer doesn't starve the rest, and a delivery log customers can inspect.
That log is the single highest-value feature. Without it, every integration problem becomes a support ticket asking you to check whether an event was sent. With it, customers debug themselves.
Sign every payload with an HMAC over the raw body plus a timestamp, and document how to verify it. The timestamp matters: without it, a captured payload can be replayed indefinitely.
Circuit-break endpoints that fail persistently. After a day of failures, disable and notify — otherwise abandoned endpoints consume retry capacity forever.
The gotcha
Webhooks are a server-side request forgery vector. A customer registering `http://169.254.169.254/latest/meta-data/` points your server at a cloud metadata endpoint and receives the response — including credentials. Validate destinations: HTTPS only, public IPs only, and re-check after DNS resolution to catch names that resolve to private ranges.
Managed webhook infrastructure (Svix, Hookdeck, QStash)
// best for: Teams who need to send webhooks but don't want to build a delivery platform.
- Vercel
- Cloudflare
- AWS
- Railway
These handle retries, signing, delivery logs, customer-facing endpoint management, and a consumer portal. If webhooks are a feature of your product rather than your product, buying this is usually correct — you're not going to build a better retry scheduler, and the customer-facing debugging UI alone is weeks of work.
On the receiving side, Hookdeck and QStash sit in front of your endpoint and absorb the reliability problem from the other direction: they accept the provider's delivery, then feed it to you at a rate you can handle, with their own retries. That's genuinely useful when your consumer is a serverless function with a short timeout.
The trade is another vendor in a critical path, and payloads transiting a third party — which may matter for regulated data.
The gotcha
Adding a webhook proxy changes the signature situation: the payload your endpoint receives is signed by the proxy, not the original provider. You must verify against the proxy's secret, and you lose the provider's own signature unless the proxy forwards the original headers.
Common pitfalls
Processing inline before acknowledging
Slow handlers exceed the provider's timeout, get marked failed, and are retried while the first attempt is still running. Acknowledge within a couple of seconds and process on a queue.
Verifying against a parsed body
Signatures cover the exact bytes sent. JSON parsed and re-serialized differs in whitespace and key order, so verification always fails. Capture the raw body before any parsing middleware.
No replay protection
A signature valid forever means a captured payload can be replayed indefinitely. Include a timestamp in the signed content and reject anything older than a few minutes.
No visibility for consumers
Without a delivery log, every integration question becomes a support ticket. Expose attempts, response codes, and a manual replay button — it pays for itself immediately.
Questions people actually ask
Why does my signature verification keep failing?
Almost always a parsed body instead of the raw bytes. Framework JSON middleware consumes the stream and re-serializing produces different bytes. Mount the webhook route with raw-body handling before global parsers.
How do I handle duplicate webhook events?
Store processed event IDs and check before doing anything with side effects. At-least-once delivery is the norm, so duplicates are expected traffic rather than an error condition.
How long should I retry a failing endpoint?
Exponential backoff over roughly 24 hours is the common convention. After that, disable the endpoint and notify the owner — indefinite retries against a dead endpoint waste capacity and delay live deliveries.