Webhook delivery is one of those quiet parts of modern infrastructure that only gets attention when something goes wrong. A payment processor retries a notification six times, a customer is charged twice, and suddenly everyone is asking why the system is not idempotent. In 2026, as more teams stitch together AI agents, event-driven microservices, and third-party SaaS integrations, the difference between at-least-once and exactly-once delivery has stopped being a theoretical concern. It is the difference between a clean ledger and a support ticket backlog.
This guide walks through how to design idempotent webhook systems in three popular backend languages: Node.js, Go, and Python. Instead of chasing theoretical guarantees, we will focus on the pragmatic patterns that make duplicate delivery harmless.
Why Idempotency Is the Real Webhook Contract
REST promises a clean request-response cycle. Webhooks invert that: the server initiates the call to your endpoint, often without much ceremony. Providers like Stripe, GitHub, Shopify, and Twilio will retry on any 5xx, timeout, or network blip. The HTTP semantics of your endpoint rarely matter — what matters is whether processing the same event twice produces the same outcome.
Idempotency is what turns a flaky network into a non-event. Three ingredients usually get you there:
- A unique event identifier supplied by the sender.
- A durable store of processed event IDs.
- A transactional boundary that records the ID and applies the side effect together.
Get those right, and your webhook receiver becomes safe to call a thousand times for the same logical event.
The Anatomy of a Safe Webhook Receiver
Before comparing languages, it helps to agree on the structure. A well-designed receiver does five things, in order:
- Verifies the request signature using a shared secret or asymmetric key.
- Parses the payload and extracts a stable event ID.
- Checks the ID against a deduplication table — usually within the same database transaction as the side effect.
- Applies the business logic only if the ID is new.
- Acknowledges with a 2xx response promptly, before any slow downstream work.
That last point is often missed. If you do heavy processing synchronously, a slow handler will be retried by the sender, compounding duplicates. Push the heavy work onto a queue and let the webhook handler return fast.
Implementing Idempotent Webhooks in Node.js
Node.js is the default for most webhook receivers because Express and Fastify make it trivial to accept JSON. The interesting part is the deduplication layer. A common pattern is to use PostgreSQL with an INSERT ... ON CONFLICT DO NOTHING query that both records the event ID and checks for prior delivery in one shot.
Signature Verification and Event ID Extraction
Most providers send a header like Stripe-Signature or X-Hub-Signature-256. Verify it with the raw request body, not the parsed JSON, because any whitespace change will invalidate the HMAC. Once verified, pull the event ID — typically id, event_id, or delivery_id depending on the sender.
Atomic Deduplication with PostgreSQL
Wrap your side effect and your dedupe insert in the same transaction. A simple schema looks like:
event_idas the primary key.received_atfor observability.statusto record processing outcome.
Attempt the insert first. If it returns zero rows, the event was already processed — return 200 immediately. If it succeeds, perform your business work inside the same transaction, then commit. This gives you exactly-once semantics for any side effect that lives in the database.
Acknowledging Before Side Effects
For work that lives outside the database — sending emails, calling third-party APIs, updating search indexes — record the event ID, commit, and enqueue a background job. The webhook returns 200 the moment the ID is durably stored, ensuring the sender will not retry even if the downstream job fails.
Implementing Idempotent Webhooks in Go
Go shines for webhook receivers that need predictable performance under load. Its standard library, especially net/http and crypto/hmac, gives you everything you need without pulling in a framework.
Strict Typing for Event Payloads
Define a struct per webhook source. This catches schema drift at compile time and makes it obvious which fields contain the unique identifier. For providers that omit an id, generate one from a hash of the canonical payload — but treat that as a fallback, because sender-supplied IDs are always preferable.
Using a Unique Index in PostgreSQL or DynamoDB
In Go, the deduplication pattern often uses database/sql with a prepared statement. With DynamoDB, you can lean on ConditionExpression: "attribute_not_exists(event_id)" to fail fast on duplicates. Either way, the database, not the application, is the source of truth for whether an event is new.
Concurrent Processing with Worker Pools
Because Go handles concurrency well, it is tempting to fan out webhook processing immediately. Resist the urge. The handler should still enqueue work to a channel or external queue, and a fixed pool of workers should drain it. Limiting concurrency protects downstream services from bursty retries during a provider outage.
Implementing Idempotent Webhooks in Python
Python is everywhere in data pipelines, AI workflows, and internal tooling — all places where webhook idempotency matters. The same principles apply, but the ecosystem leans on different primitives.
FastAPI for Async Webhook Receivers
FastAPI’s async handlers are ideal for I/O-heavy webhook flows. Verify the signature, parse with Pydantic for type safety, and store the event ID in PostgreSQL using asyncpg or in Redis using SET ... NX EX for a fast check before consulting the database of record.
Deduplication with Redis as a Fast Filter
Redis is a popular first line of defense. Use SET key value NX EX 86400 to atomically claim an event ID with a 24-hour TTL. If the command returns false, the event is a duplicate. For durable dedupe, follow up by writing the ID to PostgreSQL inside your business transaction. Redis handles the hot path, Postgres owns the audit trail.
Side Effects in Celery or Dramatiq
Long-running tasks belong in a background worker. Celery and Dramatiq both support acks_late, which only acknowledges a task after it succeeds — meaning the queue, not the webhook sender, drives retries. Combined with idempotent task bodies that re-check the event ID, this gives you a second layer of safety.
Cross-Language Patterns That Actually Matter
Across Node, Go, and Python, the same architectural decisions show up. These are the ones that decide whether your system survives a real-world retry storm:
- Trust the database, not the application, for dedupe. Application-level checks race.
INSERT ... ON CONFLICTand conditional writes do not. - Return 2xx as soon as the event is durably recorded. Anything slower invites retries.
- Separate ingestion from processing. The webhook handler is a router, not a worker.
- Reconcile asynchronously. Even with perfect idempotency, run periodic jobs that compare your ledger against the provider’s and patch gaps.
- Make event IDs visible in logs and metrics. When something goes wrong, you will be glad you can search by it.
Testing Idempotency Before It Hurts
Most teams test the happy path and call it a day. For webhook systems, the unhappy paths matter more. Write tests that replay the same payload ten times in parallel and assert that the side effect happens once. Run a chaos test that kills the receiver mid-processing and verifies recovery. Include provider retry simulations — most SaaS dashboards expose a “resend” button that lets you trigger a duplicate on demand.
A useful trick is to wrap your webhook receiver in a small harness that records every received event ID and lets you re-inject them. With that, idempotency becomes a property you can prove in CI rather than a hope you hold during incidents.
Conclusion
Exactly-once delivery is not a protocol feature; it is an outcome you earn. By extracting a stable identifier from every payload, storing it durably, and folding it into the same transaction as the side effect, webhook receivers in Node.js, Go, and Python can absorb the inevitable retries without flinching. The rest is plumbing: fast acknowledgements, background queues, and the discipline to treat the database as the authority on what has already happened. Build those habits in, and your integrations will look calm even when the network is not.
