Key takeaways
- Every production webhook system is at-least-once. Exactly-once delivery is a distributed-systems fiction.
- Idempotency starts with a stable event ID, gets enforced by an atomic dedupe store, and gets verified by side-effect design downstream.
- A 24–72 hour TTL on your dedupe cache covers ~99% of real-world retry storms. Don’t store forever — storage grows unbounded and you lose the ability to replay.
- Return the right HTTP codes.
2xxmeans “stop retrying.”5xxmeans “try again.” Mixing them up is the most common bug in this whole space. - Test duplicate delivery in CI. Ship every integration test twice on every run. If the second pass diverges, you have a bug in production.
Webhook bugs don’t page you on Tuesday afternoon. They page you at 2am, when your consumer is looping on a retry, a partner’s system is melting under your accidental load, and your on-call is trying to figure out whether the duplicate charges in Stripe are your bug or their replay.
The list below is the one we run internally before any new webhook source or consumer goes live. It’s not novel. Most of it has been written before, in better blog posts. But nobody has the full checklist in one place, so here it is.
Why Webhook Idempotency Matters
Webhook idempotency means your system can safely receive the same webhook event more than once without creating duplicate or incorrect results.
This matters because webhooks are not guaranteed to arrive exactly once. A webhook may be retried, delayed, delivered out of order, or sent multiple times after a timeout. If your system is not designed for that, one event can accidentally create duplicate contacts, duplicate tasks, duplicate CRM notes, repeated messages, incorrect pipeline updates, or broken reporting.
For CRM software, webhook idempotency is especially important because CRM integrations often touch customer records, lead statuses, sales activity, workflow automation, and reporting dashboards. A duplicate webhook is not just a technical issue. It can affect how sales, marketing, support, and operations teams work.
1. Assume at-least-once delivery
There is no webhook provider that guarantees exactly-once delivery, because in a distributed system it’s impossible. What they guarantee is at-least-once, which means you should expect the same event to arrive one, two, or ten times.
Every claim of “exactly-once” in this space is one of two things: at-least-once with idempotent handlers downstream (honest), or a marketing statement (dishonest). Build as if you’ll see every event multiple times, and the rest of the list falls out naturally.
2. Every event needs a stable ID
Idempotency starts at the event itself, not at your handler. The event must carry a unique, stable identifier that you can dedupe on. Not a hash of the payload (those collide). A dedicated ID field the producer generates once and re-sends verbatim on retry.
If you’re the producer, UUIDv4 or UUIDv7 is fine. UUIDv7 sorts chronologically, which is a nice property for dedupe-table indexes.
3. Use the provider’s ID when it exists
Most major providers already give you one. Stripe’s id field. GitHub’s X-GitHub-Delivery header. Linear’s delivery_id. Use those. Don’t hash the payload. Don’t generate your own on receive — you want the ID to be stable across the producer’s retries, and the producer is the only one that can guarantee that.
If the provider doesn’t send an ID, file an issue with them. Then fall back to hashing (timestamp, event_type, subject_id) to get something close to stable — but know that it’ll occasionally collide on legitimate near-simultaneous events, so treat your dedupe as best-effort.
4. Dedupe on receive, not on process
Before any business logic runs, check whether you’ve seen this event ID. The check happens in the HTTP request handler, not in the downstream worker. Reason: if dedupe happens in the worker, a retry that lands between “received” and “enqueued” produces two queued jobs, and you’ve already lost the battle.
# Python / Flask - dedupe at the handler boundary
@app.post("/webhooks/stripe")
def stripe_webhook():
event_id = request.headers.get("Stripe-Signature-Id") or request.json["id"]
if dedupe_store.seen_recently(event_id):
return "", 200 # already processed, return success
queue.enqueue("process_stripe_event", request.json)
dedupe_store.mark_seen(event_id, ttl=86400 * 3)
return "", 200
5. Store dedupe state atomically
The dedupe store write and the enqueue need to be atomic — or at least, they need to happen in the right order so a crash between them is safe.
In Redis: SET key 1 NX EX 259200 gives you a conditional write with TTL in one round-trip. In Postgres:
INSERT INTO webhook_dedupe (event_id, received_at)
VALUES ($1, NOW())
ON CONFLICT (event_id) DO NOTHING
RETURNING xmax;
The RETURNING xmax trick tells you whether the row was new (xmax = 0) or a duplicate (xmax != 0), in a single round-trip. If the row was new, continue to processing. If it was a duplicate, return 200 and do nothing.
6. Give your dedupe cache a TTL
Don’t store dedupe keys forever. Storage grows unbounded, and you lose the ability to deliberately replay an event for recovery.
For most providers, 24–72 hours covers the retry-storm window. Stripe gives up after 3 days. GitHub retries for up to 24 hours. Pick your TTL based on the longest window of any upstream you accept events from, and add a day of safety margin.
7. Make side-effects conditional
Even with perfect dedupe, retries can happen inside your own pipeline — a worker crashes mid-job and the queue redelivers. So every side-effect your handler performs should be conditional on prior state.
Bad:
UPDATE deals SET status = 'won' WHERE id = $1;
Good:
UPDATE deals
SET status = 'won', won_at = NOW()
WHERE id = $1 AND status = 'negotiation';
The good version is a compare-and-swap. If the deal already moved to won (because a retry already processed), the update affects zero rows and you know to skip the downstream notifications. This pattern is critical in any AI-native CRM where automated agents are updating deal stages alongside human reps.
8. Propagate idempotency keys to downstream APIs
If your webhook handler calls out to another API — Stripe to issue a refund, Resend to send an email, Twilio to send an SMS — most modern APIs accept an Idempotency-Key header. Use the upstream event ID as the key, or derive it deterministically.
// Go - retry-safe charge creation
req, _ := http.NewRequest("POST", "https://api.stripe.com/v1/charges", body)
req.Header.Set("Idempotency-Key", eventID) // stable across our retries
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := httpClient.Do(req)
A retry of the handler will send the same key. Stripe returns the original response — no duplicate charge.
9. Use the outbox pattern for multi-system writes
When your handler does more than one write — local DB + external API + message queue — you can’t make all of them atomic. The fix is the transactional outbox:
- In the same database transaction as your business-logic write, insert a row into an
outboxtable describing the side-effect (“send this email,” “call this API”). - A separate worker polls the outbox and performs the side-effects, marking each row done.
- If a side-effect fails, the outbox row retries — naturally at-least-once.
Combined with downstream idempotency keys (item 8), the whole chain is safe under failure.
10. Return the right HTTP status codes
2xx— “got it, don’t retry.” Use this even when you’ve deduped and done nothing.4xx— “this event is malformed, don’t ever retry.” Use when the payload is invalid, the signature doesn’t verify, or the referenced entity doesn’t exist and never will.5xx— “transient failure, please retry.” Use when your downstream failed, your DB is unreachable, or you want the provider to try again in a minute.
The most common bug here: returning 500 when you’ve actually processed the event but a downstream notification failed. The provider retries, your dedupe catches it, and your logs fill with spurious retries that look like real failures. Keep the retry signal honest.
11. Test duplicate delivery explicitly
Every integration test in your webhook suite should run the test event twice, back-to-back. If the result after two deliveries is different from the result after one, you have an idempotency bug.
// Node / Jest
test('deal-won webhook is idempotent', async () => {
const event = buildDealWonEvent({ dealId: 'deal_123' });
await postWebhook('/webhooks/deals', event);
await postWebhook('/webhooks/deals', event); // same event, again
const deal = await db.deals.find('deal_123');
const notifications = await db.notifications.where({ deal_id: 'deal_123' });
expect(deal.status).toBe('won');
expect(notifications).toHaveLength(1); // not 2
});
Most idempotency bugs we’ve seen would have been caught by a one-line change to the test harness.
12. Monitor dedupe rate and handler percentiles
Two metrics tell you more about webhook health than anything else:
- Dedupe hit rate — the percentage of incoming events that you’ve seen before. In steady state, this should be a low single-digit percentage. A spike means your upstream is retrying more than usual — often the first sign of a downstream problem affecting that upstream.
- Handler p99 runtime — if your p99 is climbing, retries are compounding somewhere. Handlers should be bounded by their slowest downstream call; if they’re slower than that, something’s wrong.
Alert on the 95th percentile of both, over 10-minute windows. That’s the fire alarm, not the dashboard.
Common CRM Webhook Problems Idempotency Helps Prevent
CRM integrations often depend on webhooks to move data between forms, dialers, calendars, payment tools, marketing platforms, AI agents, and internal systems. Without idempotency, one failed or retried webhook can create real operational problems.
Webhook idempotency helps prevent:
- Duplicate lead creation in the CRM database
- Duplicate tasks assigned to sales reps
- Repeated SMS or email follow-ups
- Duplicate notes on a customer record
- Incorrect CRM lead management status changes
- Multiple workflow automation triggers from the same event
- Duplicate calendar or appointment activity
- Broken CRM dashboard reporting
- Conflicting pipeline updates
- Duplicate API calls to third-party systems
- Confusion between sales, support, and operations teams
The safest assumption is that every webhook may be delivered more than once. A reliable CRM integration should be built so repeated delivery does not create repeated business actions.
How to Design an Idempotent Webhook Workflow
An idempotent webhook workflow starts by giving every event a reliable unique identifier. That identifier should be stored when the webhook is processed, so the system can recognize duplicate events later.
A basic webhook idempotency pattern looks like this:
- Receive the webhook event.
- Validate the webhook signature or authentication.
- Extract the unique event ID.
- Check whether that event ID has already been processed.
- If it has already been processed, return a safe success response.
- If it has not been processed, run the approved workflow.
- Store the event ID, timestamp, result, and related CRM record.
- Return a success response to the sending system.
This pattern helps protect CRM automation workflows from retries, duplicate deliveries, and timeout-related errors.
For CRM API and webhook integrations, idempotency should be treated as part of the workflow design, not something added later after duplicate records appear.
Why Webhook Idempotency Matters for CRM Automation
CRM automation often depends on a chain of events. A form submission creates a lead. A lead triggers a workflow. A workflow sends a message. A message response updates a status. A status change creates a task. A manager dashboard updates based on the activity.
When that chain works correctly, automation saves time. When duplicate webhooks enter the chain, automation can multiply mistakes.
For example, one duplicated webhook could cause:
- The same lead to be created twice
- Two sales reps to receive the same task
- The same customer to receive duplicate follow-up messages
- A deal to move to the wrong pipeline stage
- A CRM dashboard to overcount activity
- An AI agent to summarize incomplete or duplicated activity history
This is why webhook reliability is a CRM operations issue, not just a developer concern. If your CRM software depends on integrations, API calls, automation triggers, or AI workflows, idempotency helps keep the CRM database clean and the workflow predictable.
Webhook Idempotency Checklist for CRM Integrations
Use this checklist when building or reviewing a CRM webhook integration:
- Does every webhook event include a unique event ID?
- Are processed event IDs stored in the database?
- Can the system safely ignore duplicate event IDs?
- Does the workflow return a safe success response for duplicate retries?
- Are webhook signatures or authentication checked before processing?
- Are timestamps stored for each webhook event?
- Can the system handle events that arrive out of order?
- Does the workflow check whether a CRM record already exists before creating a new one?
- Are status updates conditional instead of blindly overwritten?
- Are failed webhook attempts logged?
- Are retry attempts visible to admins or developers?
- Are duplicate events connected to the same original CRM record?
- Are alerts triggered when webhook failures or retries spike?
- Is there a manual recovery process for failed events?
- Are webhook logs searchable by event ID, contact ID, phone number, email, or CRM record ID?
A webhook integration is not complete just because data moves from one system to another. It should also protect the CRM from duplicate, delayed, failed, and repeated events.
How This Applies to Conduyt CRM Workflows
Conduyt is built for teams that rely on CRM integrations, REST API access, automation triggers, MCP tools, and AI-native CRM workflows. In that kind of environment, webhook reliability matters because customer records may be updated by multiple systems.
A reliable CRM workflow should make it clear when an event was received, whether it was processed, which CRM record it affected, and whether the same event was already handled.
For teams using Conduyt as the operating layer for sales, marketing, support, and operations, webhook idempotency helps protect:
- CRM lead management
- Contact and company records
- Pipeline activity
- Workflow automation
- CRM dashboard reporting
- AI agent activity
- Custom CRM development projects
- Third-party CRM integrations
As teams connect more tools to their CRM, idempotency becomes one of the foundations of clean data and reliable automation.
Closing
Idempotency isn’t a library you install. It’s a discipline you enforce on every handler you write. The checklist above is roughly the order we run it in code review: event ID → dedupe store → conditional side-effects → downstream keys → tests. Skip any step and you ship a bug that only shows up under retry pressure, which is the worst time to find a bug.
If you’re designing a webhook system today and any item on this list is unchecked, it’s worth fifteen minutes to write down what your team would do if you caught a duplicate processed tomorrow at 2am. If you’re building on a CRM that handles webhooks natively, check whether these patterns are built in or whether you’re responsible for them yourself. Conduyt’s automation framework handles dedupe and idempotency at the platform level, so your integration code can focus on business logic rather than retry plumbing. For teams evaluating CRM platforms, our pricing page covers what’s included.
FAQ
What is webhook idempotency?
Webhook idempotency means a system can safely receive the same webhook event more than once without creating duplicate or incorrect results.
Why do webhooks get duplicated?
Webhooks can be duplicated because of retries, timeouts, network failures, sender-side errors, receiver-side errors, or systems that resend events when they do not receive a clear success response.
Why is idempotency important for CRM integrations?
Idempotency is important for CRM integrations because duplicate webhook events can create duplicate leads, repeated tasks, duplicate notes, incorrect status changes, broken reporting, and unreliable CRM automation.
What is an idempotency key?
An idempotency key is a unique value used to identify a specific event or request. The receiving system stores that key so it can recognize and safely ignore duplicate attempts.
Should every webhook have an event ID?
Yes. Every webhook should include a unique event ID or another reliable idempotency key. Without it, the receiving system has a harder time detecting duplicates safely.
How do you prevent duplicate CRM records from webhooks?
To prevent duplicate CRM records, store processed event IDs, check whether the target record already exists, use stable identifiers, validate payloads, and make create/update workflows conditional.
Is webhook idempotency only a developer issue?
No. Developers implement webhook idempotency, but the impact is operational. Without it, sales, marketing, support, and operations teams may deal with duplicate records, repeated messages, and unreliable CRM reporting.
What is webhook idempotency, and why does it matter for CRM integrations?
Webhook idempotency means the receiving system can process the same webhook payload multiple times without producing different side effects. It matters for CRM integrations because webhook delivery networks (HubSpot, Stripe, Twilio, Conduyt itself) retry failed deliveries – sometimes aggressively. Without idempotency, a retried payload can create duplicate contacts, double-charge customers, or run a workflow twice. Idempotency turns webhook retries from a risk into a feature: the sender doesn’t need to know whether the receiver succeeded, and the receiver can safely accept a duplicate without changing state.
How do you implement webhook idempotency in practice?
The standard pattern is a content-derived idempotency key stored in a fast key-value cache. The webhook receiver: (1) extracts a stable idempotency key from the payload – either the sender’s event ID, a hash of the payload, or both; (2) checks the cache for that key with a TTL of 24-72 hours; (3) if the key exists, returns the cached response without re-processing; (4) if the key doesn’t exist, processes the webhook, then writes the result back to the cache before responding. Redis is the most common backing store. The 12-item checklist above details each step plus failure-mode handling.
Why do CRM webhooks fail in production, and what are the most common idempotency bugs?
CRM webhooks fail in production for predictable reasons: receiver timeouts during heavy load (sender retries), receiver crashes mid-processing (sender retries), network partitions, and sender bugs that double-emit the same event. The most common idempotency bugs are: (1) using a wall-clock timestamp as the idempotency key (different on retry, so retries aren’t recognized); (2) keying on the wrong field – the sender’s internal event ID can change across retries, but the business-level event ID rarely does; (3) writing the cache entry AFTER the side effect completes (creates a window where a retry can succeed twice); (4) cache TTLs shorter than the sender’s retry window, so late retries get re-processed. The checklist above catches each.
Related: Browse all Conduyt integrations | Connect via MCP