Idempotency in Webhooks: Implementation Patterns & Failure Analysis

Idempotency is the consumer-side discipline that makes Webhook Architecture Fundamentals & Design Patterns survivable in production: it ensures that processing identical payloads multiple times yields a consistent, deterministic system state. Because distributed networks inherently rely on at-least-once delivery semantics, duplicate events are an operational certainty rather than an edge case. Network partitions, load balancer timeouts, and provider retry policies guarantee that consumers will receive identical payloads across multiple delivery attempts. Without strict idempotency controls, downstream aggregates diverge, financial reconciliation breaks, double-charging occurs, and system reliability degrades under normal operational load. This guide assumes familiarity with HTTP webhook delivery and a working datastore (Redis or PostgreSQL) for persisting deduplication state.

Idempotency-key deduplication flow Two identical webhook deliveries reach the consumer; the first writes the key and runs business logic, the second is short-circuited with a cached 200 OK. Delivery #1 key = evt_42 Delivery #2 retry, key = evt_42 SET key NX EX dedup store Business logic runs once Cached 200 OK no side effects new key → process key exists → replay
Deduplication flow: the first delivery claims the idempotency key and runs business logic; the retry finds the key already set and returns a cached 200 OK without re-executing side effects.

Idempotency Key Generation & Schema Alignment

Deterministic key generation forms the backbone of reliable deduplication. Keys must be reproducible across retries and independent of transient metadata such as delivery timestamps or retry counts. A robust strategy combines a provider-supplied event identifier with a sequence counter, cryptographic hash of the payload, or a monotonic timestamp. Aligning these identifiers with strict Event Schema Design practices ensures predictable parsing, prevents collision during schema evolution, and maintains backward compatibility across versioned payloads.

Anatomy of a derived idempotency key Three inputs — provider event id, canonical payload, and signing secret — feed an HMAC-SHA256 digest that emits a 64-character hex idempotency key. Inputs Derivation Output provider event_id immutable, per event canonical payload sorted keys, no spaces signing secret per-tenant, rotated HMAC-SHA256 keyed digest 64-hex idempotency key stable across retries Any change to payload or secret produces a different key, so tampered retries never match
Only fields that survive a retry belong in the key: the provider event id and the canonicalised payload, bound with a tenant secret so the key cannot be forged.

Implementation Pattern: Deterministic Key Generation

import hashlib
import hmac
import json

def generate_idempotency_key(
    provider_event_id: str, payload: dict, secret: str
) -> str:
    """
    Generates a deterministic, collision-resistant idempotency key.
    Combines the provider's event ID with a SHA-256 hash of the canonical payload.
    """
    # Canonicalize payload to ensure consistent hashing across retries
    canonical_payload = json.dumps(payload, sort_keys=True, separators=(",", ":"))
    hash_input = f"{provider_event_id}:{canonical_payload}".encode("utf-8")
    return hmac.new(secret.encode("utf-8"), hash_input, hashlib.sha256).hexdigest()

Key Generation Strategies:

Key Scope, Namespacing, and Collision Arithmetic

A key suppresses duplicates only inside the namespace it is compared against, which makes the namespace a design decision rather than a storage detail. Prefix every stored key with the tenant identifier and the event type — dedup:acct_912:invoice.paid:<digest> — so two tenants replaying structurally identical payloads can never suppress each other, and so a single tenant can be purged during an incident without flushing live state for everyone else. A flat namespace makes the blast radius of any mistake global: one backfill that precomputes keys for a migration will silently swallow live traffic across the entire customer base, and the only visible symptom is a fleet-wide drop in processed-event volume with no error rate to match it.

Collision resistance is arithmetic, not intuition, and the arithmetic changes the moment somebody truncates the digest to save memory. A full SHA-256 digest gives 256 bits and no practical collision risk at any webhook volume. Truncate it to 64 bits to fit a BIGINT column and the birthday bound bites much sooner than people expect: with a 72-hour window at a sustained 500 events per second you retain roughly 129.6 million keys, and the probability of at least one collision is about n²/2^65, or roughly 1 in 2,000 windows. At one window per three days that is a false-positive suppression — a legitimate event silently discarded — every 16 years per stream, which sounds tolerable until you multiply by a thousand tenants. Truncating to 128 bits (32 hex characters) puts the same figure past 10^-20 and is the lowest safe stopping point.

Storage cost is what tempts the truncation in the first place, so size it explicitly before deciding. Those same 129.6 million keys, stored in Redis as 64-character hex strings, cost roughly 64 bytes of key plus about 90 bytes of per-entry overhead for the dictionary entry, the robj header, and the TTL slot — call it 150 bytes, or 19.4 GB of resident memory. Storing the digest as 16 raw bytes instead of 64 hex characters saves 48 bytes per key and 6.2 GB across the window, which is usually the difference between one cache node and three. Do that truncation at the encoding layer (hex to binary), never by discarding entropy.

The other half of scoping discipline is deciding what must stay out of the key. Delivery attempt counters, receipt timestamps, X-Request-Id headers, and any field a proxy rewrites are all poison, because they change between the original and the retry that the key exists to catch. The observable symptom of getting this wrong is unmistakable once you know it: the deduplication hit rate sits pinned near zero while duplicate side effects appear in the ledger at intervals that exactly match the provider’s published retry offsets — 1 minute, 5 minutes, 30 minutes. If duplicates land on the retry schedule, the key is derived from something the retry changed.

Storage Patterns & Concurrency Control

Persisting processed keys requires low-latency, highly available storage layers capable of handling high-throughput bursts without introducing serialization bottlenecks. Implement Redis SET ... NX EX (atomic set-if-not-exists with TTL) or relational UNIQUE constraints with upsert logic. When integrating with Message Ordering Guarantees, apply optimistic locking or row-level versioning to resolve race conditions between parallel worker threads and prevent phantom reads during high-throughput bursts.

Implementation Pattern: Redis Deduplication with TTL

import redis

def check_and_mark_processed(
    redis_client: redis.Redis, key: str, ttl_seconds: int = 259200
) -> bool:
    """
    Atomically checks if a key exists and sets it if not.
    Returns True if the key was newly inserted (process event).
    Returns False if the key already existed (duplicate detected).
    TTL default = 72 hours, matching most provider retry windows.
    """
    was_set = redis_client.set(key, "1", nx=True, ex=ttl_seconds)
    return bool(was_set)

Implementation Pattern: PostgreSQL Constraint Enforcement

CREATE TABLE webhook_idempotency_keys (
    idempotency_key VARCHAR(64) PRIMARY KEY,
    event_type VARCHAR(50) NOT NULL,
    processed_at TIMESTAMPTZ DEFAULT NOW(),
    payload_hash VARCHAR(64) NOT NULL
);

-- Atomic upsert: silently ignores duplicates, returns conflict status
INSERT INTO webhook_idempotency_keys (idempotency_key, event_type, payload_hash)
VALUES ($1, $2, $3)
ON CONFLICT (idempotency_key) DO NOTHING;

Concurrency Handling:

Two workers racing for one key Worker A and Worker B issue the same conditional set against the deduplication store; only the first receives OK and runs the side effect while the second receives nil and returns a cached acknowledgement. Worker A Dedup store Worker B SET evt_42 NX EX OK (claimed) SET evt_42 NX EX nil (already set) run side effect return 200 OK One atomic conditional write decides the winner, so no external lock is required
The store, not the application, arbitrates the race: whichever worker's conditional write lands first owns the side effect, and the loser degrades to an acknowledgement.

Key Lifecycle: Claimed, Completed, and the Crash In Between

The two-state model — key absent means process, key present means skip — is the version that ships first and the version that loses events in production. It has one unhandled interval: the window between claiming the key and committing the side effect. If the worker is terminated inside that window (a pod eviction, an OOM kill, a deploy rolling the node), the key is present but the work never happened. Every subsequent retry from the provider sees the key, returns 200 OK, and the event is gone. Nothing errors, nothing alerts, and the discrepancy surfaces days later when finance asks why one invoice in ten thousand never posted.

The fix is to treat the key as a lease with three states rather than a boolean. A claim writes the key with a short TTL — 60 seconds is a good default, or roughly three times the p99 handler duration — and a value of processing alongside the owning worker id. On successful commit the worker rewrites the key with the value completed and the full deduplication TTL of 72 hours. A retry arriving while the key still reads processing is genuinely concurrent with an in-flight attempt and should be answered with 409 or 503 so the provider retries later, not with 200, because a 200 here would acknowledge work that may still fail. A retry arriving after the short lease expired finds no key at all and re-claims cleanly, which is precisely the recovery you want after a crash.

Lifecycle of one idempotency key An idempotency key moves from unseen to claimed under a short lease, then to completed with a long TTL; a worker crash lets the lease expire and returns the key to the unseen state for a clean retry. One key, three states and a recovery path Unseen no key present Claimed, lease 60s worker owns event Completed TTL raised to 72h Lease expired key vanished Duplicate arrives cached 200, no work SET NX EX 60 commit, then mark worker killed next retry re-claims A short lease is what makes a crash recoverable instead of silently dropping the event
The lease TTL is the whole trick: a key written for 72 hours before the work commits turns every crash into a lost event, while a 60-second lease turns it into an ordinary retry.

Sizing the lease is a two-sided constraint. Too short and a slow-but-healthy handler loses its claim mid-flight, letting a concurrent retry start a second copy of the same work — the symptom is paired side effects separated by exactly the lease duration. Too long and genuine crash recovery stalls: the event cannot be retried until the lease drains, so a 15-minute lease on a payment webhook means a 15-minute hole in the ledger after every pod eviction. Measure the handler’s p99 duration, multiply by three, and round up to the nearest 15 seconds; for a handler with a p99 of 900 ms that yields a 15-second lease, comfortably above the noise and well inside any provider’s first retry interval. Handlers that legitimately run for minutes should heartbeat the lease — extend the TTL every third of its length from the worker — rather than being granted a long one up front.

Leases also need a sweeper, because expiry alone tells you nothing. Emit a counter every time a claim is made and every time one is promoted to completed; the difference over a rolling hour is the number of events that entered the critical window and never came out. In a healthy system running a few hundred events per second that number is zero for hours at a time and jumps to single digits during a deploy. Alert when it exceeds five in fifteen minutes, because a sustained gap means handlers are dying inside the window and the deduplication layer is now a source of loss rather than protection. The same counter is the fastest way to detect a partial outage in a downstream dependency: work that hangs past the lease shows up here long before the request timeout metric moves.

One subtlety catches teams that store the claim in Redis and the business state in a relational database: the two commit independently, so there is no arrangement of the write order that makes them atomic. Claiming first risks the lost-event mode above; writing the business row first risks a duplicate if the claim write fails. The lease resolves it in one direction only — claim first, keep it short — but if the side effect is irreversible the correct answer is to stop splitting the stores and put the deduplication row in the same transaction as the state it protects. That is exactly the argument for a relational deduplication table, covered in detail in storing idempotency keys in Postgres.

Implementation Pathways & Validation Workflows

Deploy a middleware interception layer that validates signatures, queries idempotency stores, and short-circuits duplicates with a 200 OK response before executing business logic. For comprehensive architectural guidance, reference How to design idempotent webhook consumers to establish standardized retry handling, acknowledgment protocols, and graceful degradation pathways.

Implementation Pattern: FastAPI Ingress Interceptor

import hashlib
import hmac
import os

import redis
from fastapi import APIRouter, Request, Response
from fastapi.responses import JSONResponse

router = APIRouter()
SECRET = os.environ["WEBHOOK_SECRET"].encode("utf-8")
redis_client = redis.Redis.from_url(os.environ["REDIS_URL"])

LEASE_SECONDS = 60
DEDUP_TTL_SECONDS = 259200  # 72h, matching the provider retry horizon


@router.post("/webhooks")
async def receive(request: Request) -> Response:
    raw_body = await request.body()  # raw bytes: never re-serialise before signing
    signature = request.headers.get("x-webhook-signature", "")
    key = request.headers.get("x-idempotency-key", "")

    # 1. Verify HMAC-SHA256 over the exact bytes, before any store access
    expected = hmac.new(SECRET, raw_body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(signature, expected):
        return JSONResponse({"error": "invalid signature"}, status_code=401)

    # 2. Claim the key as a short lease, not as a permanent marker
    claimed = redis_client.set(key, "processing", nx=True, ex=LEASE_SECONDS)
    if not claimed:
        state = redis_client.get(key)
        if state == b"processing":
            # Concurrent in-flight attempt: ask the provider to come back later
            return JSONResponse({"status": "in_progress"}, status_code=409)
        # Already finished: 200 stops the retry chain without redoing the work
        return JSONResponse({"status": "already_processed"}, status_code=200)

    # 3. Only now run business logic, then promote the lease to a full TTL
    await handle_event(raw_body)
    redis_client.set(key, "completed", ex=DEDUP_TTL_SECONDS)
    return JSONResponse({"status": "processed"}, status_code=200)


async def handle_event(raw_body: bytes) -> None:
    """Domain handler: parse, validate, and apply the side effect exactly once."""
    ...

Validation Workflow Requirements:

Choosing and Sizing the Deduplication Store

The store is chosen by what happens when it is wrong, not by what it costs when it is right. A deduplication layer sits on the hot path of every delivery, so its latency is added to every request; but it is also the only thing standing between an at-least-once transport and a double-applied side effect, so its durability sets the correctness ceiling of the whole integration. Those two properties pull in opposite directions, and the honest way to pick is to decide first whether a lost key is an inconvenience or an incident.

Store p99 claim latency Durability of a claim Practical ceiling Pick it when
Redis, single node, AOF everysec 0.3–0.8 ms in-AZ Up to 1 s of writes lost on crash ~20 GB of keys per node High volume, reversible side effects
Redis Cluster, key-prefixed slots 0.5–1.2 ms (one extra hop) Async replica: failover drops recent writes Scales linearly with shards Above ~2,000 events/s sustained
PostgreSQL unique index 1.5–4 ms with fsync Survives crash; commits with the side effect 50–100M rows before partitioning Money movement, irreversible writes
DynamoDB conditional put 6–12 ms Quorum-durable, multi-AZ Effectively unbounded Cross-region consumers, spiky traffic
In-process LRU cache Sub-microsecond Lost on restart, per-process only One worker’s memory A front-line filter only, never alone

The single most common production failure here is not the store falling over but the store quietly evicting. A Redis instance shared with a session cache and configured with maxmemory-policy allkeys-lru will start discarding deduplication keys the moment the session workload grows, and it will do so without a single error on either side. The symptom is a deduplication hit rate that decays from a steady 2% to near zero across an hour while the evicted_keys counter climbs; the consequence is duplicate side effects for every provider retry that arrives after its key was evicted. Deduplication keys belong on an instance with maxmemory-policy noeviction — or at minimum volatile-ttl on a dedicated database — so that memory pressure produces a write error you can alert on rather than a silent correctness regression.

Choosing a deduplication store A decision tree branching first on whether the side effect is reversible and then on sustained event rate, leading to a relational, sharded, or single-node store. Is the side effect reversible? Postgres unique index inside the write txn Add daily range partitions and drop, never DELETE Sustained rate above 2,000 events per second? Redis Cluster, one slot per tenant prefix Single Redis node AOF everysec no, it moves money yes, safe to redo over 50M rows yes no Reversibility decides durability first, throughput only decides the shape of the cache
Throughput is the second question, never the first: a store fast enough for the traffic but too weak to survive failover simply relocates the duplicate problem to your next incident.

Redis failover deserves its own paragraph because it produces a duplicate burst that looks like an application bug. Replication is asynchronous, so a primary that dies takes with it any claims written in the last few hundred milliseconds; the promoted replica has no record of them, the provider retries, and those events run a second time. At 500 events per second a 300 ms replication lag exposes roughly 150 events per failover. If that is acceptable — notification fan-out, cache invalidation, search reindexing — Redis is the right tool and the occasional duplicate is absorbed by downstream idempotency. If it is not acceptable, no amount of Redis tuning fixes it: WAIT 1 100 makes each claim wait for one replica ack and costs a full round trip on every event, at which point a relational unique index is both cheaper and stronger.

The relational option has its own long-run failure mode, which is bloat rather than loss. A deduplication table accumulating 129.6 million rows over a 72-hour window and cleaned with a nightly DELETE ... WHERE processed_at < now() - interval '72 hours' generates dead tuples faster than autovacuum reclaims them on a busy table. The observable pattern is an insert p99 that drifts from 2 ms to 40 ms over several weeks while table size grows even though row count is flat. Use daily range partitions and DROP TABLE the expired partition instead: reclaiming space becomes a metadata operation, the index stays shallow, and the p99 stays where it started.

Rolling Out a Deduplication Layer Without Losing Events

Turning on deduplication is a change to what your system silently discards, which makes it one of the few changes where a naive deploy can lose data faster than no deploy at all. Sequence it in three stages, and treat the store’s warm-up period as a first-class part of the plan.

Stage one is observe-only. Ship the key derivation and the store write, but do not suppress anything: every delivery runs its side effect as before, while the middleware records whether the key already existed. Leave it there for at least one full TTL window plus a margin — 96 hours for a 72-hour TTL — so the store is fully warm and the numbers describe steady state rather than an empty cache. What you are looking for is the baseline duplicate rate, which for most providers lands between 0.1% and 2% of deliveries. A rate at zero means the key is unstable across retries and the derivation is wrong; a rate above 10% usually means the consumer is timing out and the provider is retrying healthy work, which is a timeout problem that deduplication would merely paper over.

Stage two enables suppression for one event type on one internal tenant, then widens by event type rather than by traffic percentage. Percentage-based rollout is the wrong axis here, because a 10% sample splits a resource’s event stream across two code paths and you learn nothing about whether the key held for a given resource. Watch three numbers as you widen: the suppression count (should match the observe-only duplicate count for that event type, within noise), the claim-to-completion gap described above (should stay at zero), and the downstream side-effect volume (should fall by exactly the duplicate rate, not more).

Stage three is the rollback plan, and it has one non-obvious rule: roll back by disabling suppression, never by disabling the store writes. If you stop writing keys, the store goes cold, and when you re-enable it every duplicate that arrives inside the next 72 hours passes straight through — you have converted a suppression bug into a duplicate-side-effect incident during the exact window when you are already debugging. Keep the writes on, flip suppression to log-only, and the system degrades to the behaviour it had before the feature existed, with the store still warm for the moment you re-enable.

Two operational guardrails belong in the same change. First, ship the feature flag as an event-type allowlist, not a boolean, so a single misbehaving event type can be excluded without disabling the layer for everything else. Second, make the store’s failure mode explicit and deliberate: on a claim timeout, decide in advance whether to fail open (process, risking a duplicate) or fail closed (return 503, risking a delayed event). Fail open is right for reversible work, fail closed is right for money, and the worst answer is leaving it to whatever the client library’s default exception path happens to do — which is usually a 500, which the provider retries, which produces exactly the duplicate storm you were trying to prevent.

Security Controls & Replay Mitigation

Idempotency stores must be hardened against unauthorized key injection and replay attacks. Enforce strict HMAC-SHA256 signature verification prior to key lookup. Implement bounded TTL expiration on deduplication caches to limit storage costs while neutralizing replay attempts within acceptable operational windows.

Security Controls:

Replay Window Constraints: Align TTL expiration with the maximum documented provider retry window (typically 72 hours). Events arriving outside this window should be treated as new deliveries, triggering soft-delete reconciliation jobs rather than hard rejections. The trade-off between persistent, per-event keys and bounded time-based dedup caches is examined in depth in Idempotency keys vs deduplication windows, which covers when a sliding window is sufficient versus when you need durable key storage.

Deduplication TTL against the provider retry horizon Retry attempts landing inside the 72-hour TTL band are suppressed, while an attempt arriving after expiry is treated as a fresh event and runs again. Provider retry window against deduplication TTL attempt 1 retry 2 retry 3 late retry Dedup TTL = 72h keys retained, duplicates suppressed after expiry treated as new event 0h 24h 48h 72h 96h runs again Size the TTL at or above the provider maximum retry horizon
The TTL is a security control as much as a cost control: shrink it below the provider retry horizon and a legitimate late retry re-executes the side effect.

Operational Monitoring & Failure Simulation

Track idempotency hit rates, cache eviction metrics, and duplicate processing latency. Integrate chaos engineering workflows to simulate network partitions and forced provider retries. Validate that fallback mechanisms gracefully handle storage outages without compromising data integrity or triggering cascading failures.

Monitoring Metrics:

Hit rate alone is a weak signal because it moves for two opposite reasons, so page on the derivative rather than the level. A hit rate that jumps from 1% to 15% within a few minutes means the provider is retrying work you already acknowledged — usually because handler latency crossed the provider’s read timeout — and the correct response is to shed load or shorten the handler, not to celebrate the deduplication layer doing its job. A hit rate that falls toward zero while delivery volume holds steady means keys are no longer matching: an eviction, a namespace change, a deploy that altered the derivation, or a provider that started including a new field in the payload. Both are actionable; the absolute number in between is not.

The alert set that has proven worth waking someone for is short. Alert when the claim-to-completion gap exceeds five in fifteen minutes, because that is silent event loss. Alert when the store’s error rate on claims exceeds 0.1% for five minutes, because the fail-open or fail-closed branch is now carrying production traffic and you should know which one you chose. Alert when evicted_keys on the deduplication instance is non-zero at all, since a correctly provisioned store never evicts. Everything else — hit rate, lookup latency, key count — belongs on a dashboard, not in a pager rotation, and is most useful as context once one of those three has fired.

Give the dashboard one panel that nothing else provides: duplicate suppressions broken down by event type and by age of the original delivery. Age is the diagnostic. Suppressions landing a few seconds after the original are ordinary consumer timeouts. Suppressions landing on the provider’s documented retry offsets are ordinary retry behaviour. Suppressions arriving 40 or 60 hours after the original almost always mean the provider recovered a stalled delivery queue and is draining it, which is the one case where you want to confirm the TTL is still covering the gap before the backlog arrives.

Explicit Troubleshooting Steps & Failure Mode Analysis

Failure Mode Impact Diagnostic Steps Mitigation & Resolution
Duplicate Delivery Double-charging, corrupted aggregates Check provider retry logs; verify x-idempotency-key header propagation across retries. Enforce strict key validation before business logic execution; return 200 OK immediately on match.
Storage Outage Fallback to non-idempotent processing, state drift Monitor Redis/DB connection pool exhaustion; check circuit breaker state transitions. Deploy circuit breaker with local in-memory LRU cache; trigger async reconciliation job post-recovery.
Key Collision False positive deduplication, dropped legitimate events Audit hash distribution; verify namespace isolation by tenant/event_type. Use cryptographically strong hashes (SHA-256); implement collision detection alerts; namespace keys.
TTL Expiration Late retry treated as new event, duplicate processing Compare event timestamps against cache eviction logs; identify provider retry window mismatches. Align TTL with maximum provider retry window (72h); implement soft-delete reconciliation for late arrivals.

Testing Workflows:

  1. Replay Simulation Harness: Inject historical payloads with identical signatures and keys to validate middleware short-circuiting.
  2. Parallel Worker Load Testing: Spawn concurrent consumers processing synthetic duplicates to verify distributed mutex behavior and lock contention thresholds.
  3. Network Partition Chaos Experiments: Intentionally sever idempotency store connections mid-flight to validate fallback logic, local cache promotion, and post-partition reconciliation accuracy.

Deduplication Debugging Checklist

Work through these checks when duplicates slip past the guard or legitimate events are wrongly rejected:

Frequently Asked Questions

Should the consumer or the provider generate the idempotency key?

Prefer the provider's event id when one exists, because it is the only value guaranteed to be identical across the original delivery and every retry of it. Derive your own key only as a fallback, and derive it from retry-stable payload fields exclusively. A consumer-generated key based on receipt time or a request id changes on every attempt and provides no protection at all.

What status code should a duplicate delivery receive?

Return 200 for an event that has already completed, so the provider stops retrying. Return 409 or 503 only while an attempt is still in flight under an unexpired lease, which tells the provider to come back later rather than acknowledging work that might still fail. Never return 4xx for a completed duplicate, because most providers treat it as a permanent failure and may disable the endpoint.

Is a database unique constraint enough on its own?

It is enough for correctness if the constraint insert and the side effect commit in the same transaction, which is why relational stores are the right answer for irreversible work. It is not enough for performance at high volume, where a Redis claim in front of the constraint absorbs the bulk of the duplicate traffic before it reaches the database. Treat the cache as an optimisation and the constraint as the authority.

How long should keys be retained?

At or slightly above the provider's maximum documented retry horizon, which is commonly 72 hours. Retaining for less means a legitimate late retry re-executes the side effect; retaining for much more multiplies storage cost without adding protection. If the provider publishes no horizon, measure the oldest retry you have ever observed and add 50 percent of margin.

Can two workers both pass the check if they receive the duplicate at the same instant?

Only if the check and the write are separate operations. A read-then-write sequence has a window between the two calls in which both workers see an absent key, and at high concurrency that window is hit routinely. Use one atomic conditional operation so the store itself decides the winner and the loser learns it lost from the return value.

What happens to deduplication during a Redis failover?

Replication is asynchronous, so claims written in the last few hundred milliseconds before the primary died are absent from the promoted replica and their events will run a second time when retried. At 500 events per second and 300 ms of lag that is roughly 150 exposed events per failover. If that is unacceptable, the answer is a durable store rather than Redis tuning, because waiting for replica acknowledgement costs a round trip on every single event.

Does deduplication replace signature verification?

No, and the order between them matters. Verify the signature first so an attacker cannot write attacker-chosen keys into your store, which would let them suppress legitimate events they can predict. Deduplication answers 'have I seen this before', authentication answers 'is this genuine', and only the second one is a security boundary.