Skip to content

Since 2003 · Global software, product and growth delivery

Request a free consultationSales Chat
Display settings
Reading preferences

Saved only in this browser.

Menu navigation
ServicesEnterpriseGrowthTech TalkCompanyRequest a free consultationSales Chat

Durable Webhook Receiver Design: Verified Acknowledgment, Deduplication, and Failure Handling

Executive brief

For teams evaluating API integration services

Use this guide to frame business fit, implementation effort, delivery risk, operating impact, and expected value before choosing a path.

  • Clarifies the decision, constraints, and practical outcomes.
  • Connects the topic to relevant Developers.dev expertise and delivery options.
  • Helps decision makers compare technology, operational, and adoption tradeoffs.
Read the primary guideRequest a free consultation
Durable Webhook Receiver Design: Verified Acknowledgment
Durable Webhook Receiver Design: Verified Acknowledgment

A webhook receiver looks like the easiest integration you will ever write: accept a POST, parse JSON, update a record, return 200. It is also one of the most common places where money, entitlements, and customer state quietly drift out of sync. The provider believes it delivered. Your system believes it never heard. Both are telling the truth.

This article is for a tech lead or senior developer who owns an inbound integration (payments, billing, identity, logistics, CRM, or any third-party event source) and wants a receiver that survives deploys, retries, duplicates, out-of-order delivery, and unclear inputs. We use a proposed design we call the durable receiver pipeline. It is a design we are suggesting here, not a standard and not a proprietary method any vendor has certified. It combines patterns that are individually well known: authenticate the raw bytes, commit before you acknowledge, deduplicate with a database constraint, claim work atomically, and treat an unknown outcome as unknown.

Where we make claims about specific products, we cite the vendor's own documentation, and we label code as illustrative. You will need to adapt it to your provider's contract, your database, and your legal obligations.

Key Takeaways

  1. Acknowledge only after durable commit. A 2xx means "we own this event now." Return it after signature verification and an atomic inbox write, not before.
  2. Deduplicate with a unique constraint, not a cache. Scope the key by tenant and provider. A cache with a short TTL is an optimization, not a guarantee.
  3. Make the inbox row the unit of work. Dedup record, payload, and scheduling state commit in one transaction, so there is no gap between "recorded" and "will be processed."
  4. Apply effects with target-side conditional writes. Read-compare-then-write is not compare-and-swap. If the target cannot enforce a condition, say so and serialize.
  5. Never blindly retry an ambiguous timeout on a consequential call. Reconcile against authoritative state first. Unknown means completion could not be confirmed, not success and not verified failure.
  6. Own the privacy surface. Raw payloads, event IDs, and tenant IDs can all be sensitive. Decide retention, encryption, and log redaction before launch.
  7. Use all-required go/no-go controls, not a score. A receiver that passes nine of ten controls is not 90 percent ready.

Why Webhook Receivers Fail Quietly

Key Takeaways: Delivery is at-least-once in practice, order is not guaranteed, and the provider's retry logic only protects you if your 2xx means what the provider thinks it means.

A webhook is an HTTP request your system did not initiate, carrying a claim about the outside world. Three properties make receivers fragile.

  1. The sender retries when it is unsure. If your endpoint times out, returns a non-2xx status, or the connection drops after you processed the event, the sender cannot tell the difference between "never arrived" and "arrived but the response was lost." So it sends again. Duplicates are a normal operating condition. Stripe's documentation says plainly that webhook endpoints might occasionally receive the same event more than once, and recommends logging processed event IDs.
  2. The acknowledgment is a contract. The sender treats your 2xx as "you may stop retrying." Stripe's guidance is to return a successful status code quickly, before any complex logic that could cause a timeout. That advice is correct, and it is also the trap: if you return 200 first and your only copy of the event lives in process memory, a deploy or crash erases it with no retry coming.
  3. Order is not a promise. Many providers, Stripe included in its event documentation, do not guarantee that events arrive in the order they were generated. A receiver that blindly applies "latest arrival wins" will eventually overwrite newer state with older state.

The result is a class of bug that never throws an exception. Dashboards stay green. Support tickets arrive weeks later: a subscription that never activated, a shipment that never closed, a refund that happened twice.

2026 Update: What Has Changed and What Has Not

Community specifications such as Standard Webhooks have pushed toward common headers (webhook-id, webhook-timestamp, webhook-signature) and signing conventions. That helps, but it is a community specification, not a universal standard, and many providers still use their own schemes. The fundamentals below do not depend on any one scheme: authenticate, persist, deduplicate, process idempotently, and reconcile. Those stay true whatever the headers are called.

PropertyWhat the provider typically doesWhat your receiver must assume
DeliveryRetries on timeout or non-2xxDuplicates will arrive, possibly long after the first copy
OrderingOften not guaranteedOlder events can arrive after newer ones
IdentityStable event ID across retries (check your provider)The ID is your dedup anchor, but only within a defined scope
AuthenticitySigns payload and often a timestampVerify raw bytes, then still authorize the tenant yourself
SchemaAdds event types and fields over timeUnknown inputs will arrive in production

How Most Teams Build Webhook Handlers, and Why It Breaks

Key Takeaways: The two common patterns, inline processing and acknowledge-then-enqueue-in-memory, each fail in a predictable way. The third, a durable inbox, trades a little latency and storage for a recoverable system.

Most first implementations land in one of three places.

Pattern A: Process inline, then return 200. It is simple and gives the strongest "processed means acknowledged" link. It breaks when processing is slow. The provider times out, retries, and now two copies run concurrently. Under load, retries multiply the load that caused the slowness.

Pattern B: Return 200 immediately, process in a background thread or in-memory queue. It is fast and it satisfies the "respond quickly" advice. It breaks on every restart. The provider has a 2xx on record and you have nothing.

Pattern C: Verify, write to a durable inbox, return 200, process from the inbox. This is the design in this article. The 2xx is truthful, because the event is stored. Processing can be slow, retried, paused, and replayed without involving the provider.

DimensionA: InlineB: Ack then in-memoryC: Durable inbox
Risk of lost eventsLow if you return non-2xx on failure; the provider retriesHigh: crash or deploy loses acknowledged eventsLow: loss requires losing the database
Duplicate processing riskHigh under timeouts and concurrent retriesHigh unless dedup is addedLow with a unique constraint plus conditional effects
Response latencyBounded by business logicVery lowLow: one verified insert
Replay after a bugNeeds the provider's redelivery toolsNot possible for lost eventsPossible from stored payloads
Operational costLowestLowModerate: storage, retention, sweeper, dashboards
FitLow volume, fast, idempotent effectsRarely defensible for consequential eventsAnything where a missed or doubled event costs money or trust

A note on vendor sample code. Stripe's webhook management guide shows a mark_as_processing then mark_as_processed sequence and tells you to implement those functions against your own data store. That is a useful skeleton. Read it as a skeleton: if the process dies between the two calls, the event is stuck in a state that means neither "done" nor "not started." A production receiver needs a lease, an owner, and a recovery path for that state, which is what the next section adds.

If your event traffic also fans out to internal consumers, the same discipline applies on the other side of the boundary. See our guides on the outbox pattern and event-driven governance and schema evolution.

A Proposed Design: The Durable Receiver Pipeline

Key Takeaways: Five gates, in order. Each gate has a clear pass condition and a clear response code. Treat the control table as mandatory, not as a score.

This is a proposed design, assembled from patterns you can verify in provider docs and database documentation. It is not a certified framework. Adapt the gates to your provider's contract.

The five gates

  1. Gate 1, authenticate the raw bytes. Verify the signature over the exact request body the provider sent, before any JSON parsing. Stripe's documentation notes that frameworks which reorder keys, strip whitespace, or re-encode the body cause signature verification to fail. Check the timestamp tolerance to limit replay. Stripe's libraries default to a five-minute tolerance, and the Standard Webhooks spec recommends a tolerance check and a constant-time comparison for symmetric signatures. Support more than one active secret so you can rotate without downtime.
  2. Gate 2, resolve the tenant from your own configuration. Derive the tenant from the endpoint or credential you registered, not from a field inside the payload. A valid signature proves the provider sent the message. It does not prove the message belongs to the tenant it claims.
  3. Gate 3, durable accept, atomically. In a single transaction: insert the inbox row keyed by tenant, provider, and event ID; store the payload hash; and set the work state to received. Dedup record, payload, and scheduling state commit together. If you later add a broker, publish from the inbox via an outbox relay so the handoff is recoverable.
  4. Gate 4, acknowledge. Return 2xx only after Gate 3 committed. A duplicate with an identical hash also gets 2xx, because the event is already safely held. Stripe says to skip an already processed event and return 200 to stop further retries.
  5. Gate 5, process, fail, or park. Workers claim rows atomically, apply effects with conditional writes, and move each row to a terminal state: done, dead (terminal failure with a reason), or quarantined (uncertain input awaiting a human or a code change).

Response policy

ConditionResponseReasoning
Unknown endpoint or tenant404Nothing was accepted; do not store a body you cannot attribute
Bad signature or stale timestamp401 or 400Not acknowledged; rate limit and alert on spikes
Valid, inbox commit succeeded2xxYou own the event now
Valid duplicate, same payload hash2xxAlready durable; stops further retries
Same event ID, different payload hashStore in a conflict table, alert, then 2xxAnomaly to investigate; keeping both copies preserves evidence
Valid but unknown event typeStore, mark quarantined, 2xxDo not lose it; replay after you add support
Database unavailable5xxNot durable, so do not acknowledge; let the provider retry

Check your provider's documentation for how it treats repeated failures. Some providers disable endpoints that keep returning errors, which is one reason to accept-and-park unknown types rather than answering them with a 4xx.

Mandatory go/no-go controls

These are all-required checks for any receiver that triggers a consequential effect. They are not a scoring model, and passing them does not certify production readiness. It means you have removed the known reasons to say no.

#ControlEvidence to showNo-go if
1Signature verified over raw bytes, with timestamp toleranceTest with a mutated body and a stale timestampBody is parsed before verification
2Tenant resolved from your config, not the payloadTest: valid signature plus a foreign tenant ID in the payload is rejected or ignoredPayload field selects the tenant
32xx only after durable commitKill the process mid-request; the provider's retry succeedsAny code path returns 2xx before commit
4Unique constraint on (tenant, provider, event ID)Schema plus concurrent-insert testDedup relies only on cache or application checks
5Payload hash stored; changed-payload reuse detectedTest with same ID, different bodySilent overwrite or silent drop
6Atomic work claim with lease and fencingTwo-worker race testTwo workers can apply the same row
7Effects applied with target-side conditional writes, or serialization limits documentedStale-event test and duplicate-effect testRead-compare-then-write with no condition at the target
8Terminal failure state with reason, alert, and replay toolPoison-event drillFailed events retry forever or vanish
9Unknown or unparseable input stored and quarantinedUnknown-type testUnknown input is dropped or crashes the worker
10Privacy owner, retention, encryption, and log redaction definedNamed owner and written retention ruleRaw payloads sit in logs or an unowned table

Implementation Sketch: Inbox, Claim, and Conditional Mutation

Key Takeaways: The unique constraint is the dedup mechanism. The inbox row is the work item. The conditional write at the target is the safety net when leases expire and two workers briefly overlap.

The following code is illustrative. It is written against PostgreSQL semantics (see the INSERT ... ON CONFLICT documentation and the SELECT locking clause documentation for FOR UPDATE SKIP LOCKED) and generic pseudo-TypeScript. It is not an official provider SDK contract. Verify exact behavior in your database version and your provider's documentation.

Inbox schema

CREATE TABLE webhook_inbox (   id                bigserial PRIMARY KEY,   tenant_id         text        NOT NULL,   -- resolved from your endpoint config   provider          text        NOT NULL,   provider_event_id text        NOT NULL,   event_type        text        NOT NULL,   payload_sha256    bytea       NOT NULL,   raw_body          bytea       NOT NULL,   -- encrypt, or store an object-store reference   received_at       timestamptz NOT NULL DEFAULT now(),   state             text        NOT NULL DEFAULT 'received',                     -- received | claimed | done | dead | quarantined   attempts          int         NOT NULL DEFAULT 0,   claimed_by        text,   lease_until       timestamptz,   outcome           text,   UNIQUE (tenant_id, provider, provider_event_id) );

Receiver handler

// Illustrative sketch. Adapt to your framework, SDK, and database client. async function receiveWebhook(req) {   const raw = req.rawBody;                          // exact bytes, before JSON parsing   const endpoint = await lookupEndpoint(req.routeToken);   if (!endpoint) return respond(404);    if (!verifySignature(raw, req.headers, endpoint.activeSecrets)) return respond(401);   if (!withinTimestampTolerance(req.headers)) return respond(401);    const env = readEnvelope(raw);                    // id, type only; no business logic   const hash = sha256(raw);    try {     const result = await db.transaction(async (tx) => {       const inserted = await tx.insertInboxIfAbsent({         tenantId: endpoint.tenantId, provider: endpoint.provider,         eventId: env.id, eventType: env.type, hash, raw,         state: isKnownType(env.type) ? 'received' : 'quarantined'       });       if (inserted) return 'stored';       const existing = await tx.getInboxHash(endpoint.tenantId, endpoint.provider, env.id);       if (existing === hash) return 'duplicate';       await tx.insertConflict({ tenantId: endpoint.tenantId, eventId: env.id, hash, raw });       return 'conflict';     });     if (result === 'conflict') alertOps('same event id, different payload');     return respond(200);        // reached only after the transaction committed   } catch (err) {     return respond(503);        // not durable, so not acknowledged   } }

Notice what is absent: no business logic, no outbound calls, no parsing beyond the envelope. The handler's only job is to turn an untrusted HTTP request into a durable, attributable record.

Atomic claim with a lease

UPDATE webhook_inbox    SET state = 'claimed',        claimed_by = $1,        lease_until = now() + interval '2 minutes',        attempts = attempts + 1  WHERE id = (    SELECT id FROM webhook_inbox     WHERE state = 'received'        OR (state = 'claimed' AND lease_until < now())     ORDER BY id     FOR UPDATE SKIP LOCKED     LIMIT 1  ) RETURNING ;

The lease handles worker crashes. It also means that a slow worker whose lease expired can overlap with a second worker. A lease is not a lock, so the effect itself must be safe under overlap.

Conditional mutation with fencing

-- Local target: the effect and the inbox completion commit together. BEGIN;  UPDATE invoices    SET status = 'paid', last_event_at = $4  WHERE tenant_id = $1 AND id = $2    AND status = 'open'    AND (last_event_at IS NULL OR last_event_at < $4); -- 0 rows updated: stale or already applied. Record 'skipped_stale'; not an error.  UPDATE webhook_inbox    SET state = 'done', outcome = $5  WHERE id = $3 AND claimed_by = $6 AND state = 'claimed'; -- 0 rows updated: this worker lost its lease. ROLLBACK and stop.  COMMIT;

The condition lives in the WHERE clause of the write, so the database evaluates it atomically with the mutation. That is the difference between a conditional write and a read-compare-then-write sequence in application code, where two workers can both read "open" and both write "paid." If your target is an external system that offers no conditional update, no ETag or version check, and no idempotency key, you cannot get this guarantee from your side. Disclose that limitation, serialize writes per resource (for example, one worker per account at a time), and add reconciliation.

Terminal failure, explicitly

Decide up front which errors are retryable. A timeout calling a downstream service is retryable, with backoff. A schema validation failure on a known event type is not. After a bounded number of attempts or on any non-retryable error, move the row to dead with a reason code, raise an alert, and keep the payload for a replay tool. "Retry forever" is not a failure strategy; it is a hidden backlog.

Uncertain Input, Privacy Ownership, and the Limits of Transport Standards

Key Takeaways: Store first, interpret later. Signature schemes authenticate an HTTP delivery, nothing more. Identifiers in webhooks are data you now hold, and someone must own them.

Handling uncertain input

Production will send you things your code has never seen: a new event type, a new optional field, a null where you expected a string, an enum value added last week. Decide the policy before it happens.

  1. Unknown event type: store, quarantine, acknowledge. After you ship support, flip the rows back to received and let the pipeline process them.
  2. Unknown field on a known type: ignore the field, process the event. Do not let a strict schema turn a harmless addition into an outage.
  3. Missing required field: terminal failure for that row with a reason code. Do not guess a default for a consequential effect.
  4. Ambiguous semantics (for example, two event objects that describe the same real-world change): Stripe documents that in some cases two separate Event objects are generated, and suggests using the ID of the object in data.object together with the event type to spot those duplicates. Build that secondary dedup into the effect layer, not into the receiver.
  5. Out-of-order delivery: compare a monotonic marker supplied by the provider (a version, sequence, or event timestamp, if documented) inside the conditional write, as in the SQL above. If the provider offers no such marker, fetch current state from the provider's API before acting, and treat the webhook as a hint.

Scope your standards to their transport

HMAC-style signatures, whether Stripe's Stripe-Signature header or the headers in the Standard Webhooks specification, authenticate an HTTP delivery from a party that holds the shared secret (or private key, for asymmetric variants). They do not:

  1. authenticate a message after you copy it onto a queue or into a data lake;
  2. prove which of your tenants the event belongs to;
  3. guarantee exactly-once delivery or ordering;
  4. make the event ID safe to use globally. Standard Webhooks describes the webhook-id as an identifier that stays the same across retries and is often used as an idempotency key. That is a statement about retries of one event from one sender, not a promise of uniqueness across providers, environments, or tenants.

Likewise, HTTP-level idempotency conventions apply to a request and a window defined by whoever implements them. An idempotency key on an outbound call to a provider does not make your inbound handling idempotent. You need both, and they solve different halves of the problem. For the outbound half, see Stripe's documentation on idempotent requests as one concrete vendor contract, and our broader guide to idempotency in distributed systems.

Identifiers can be sensitive data

Teams often treat event IDs, customer IDs, and tenant IDs as harmless plumbing. They are not automatically harmless. A provider's customer ID may be linkable to a person. A tenant ID may reveal a customer relationship. A payload hash of a low-entropy body may be guessable. Practical rules:

  1. Keep identifiers out of URL paths, query strings, and metric labels, which are widely logged and high-cardinality.
  2. Redact or tokenize identifiers in application logs; log the inbox row ID instead.
  3. Encrypt raw bodies at rest, or store them in an access-controlled object store with the inbox holding a reference.
  4. Name a privacy owner for the inbox table: someone accountable for retention, access reviews, and handling deletion requests.
  5. Set a retention rule. "Forever, because we might replay" is a decision, and it has legal consequences that depend on your jurisdiction, your contracts, and the data involved. Whether and how GDPR, CCPA, or sector rules apply to your inbox is a question for your counsel, not something this article or a vendor's certification answers for you. Design for purge: be able to remove or irreversibly redact a payload while keeping the minimum record you are permitted to keep.

For multi-tenant platforms, the isolation choices you make for your primary data apply to the inbox as well. Our multi-tenant database architecture guide covers the trade-offs, and the API security guide covers the surrounding zero-trust controls.

Ambiguous Timeouts, Tenant-Safe Operation Identity, and Legitimate Later Operations

Key Takeaways: Receivers usually trigger outbound calls. When one of those calls times out, you do not know what happened. Reconcile against the authoritative system before you retry anything consequential.

A webhook handler rarely stops at a database write. It calls a billing API, provisions an account, sends a shipment request, or issues a refund. Those calls can time out, and a timeout is the most dangerous response because it carries no information about whether the remote side acted.

The rule: unknown is its own state

An unknown result means completion could not be confirmed. It is not success and it is not verified failure. If you model it as failure and retry, you may double-execute. If you model it as success, you may never execute. Give it a first-class state, show it in operator tooling, and block any dependent step until it resolves.

Operation identity, bound to an immutable payload

Before any consequential outbound call, create a durable operation record with a tenant-scoped identity and a hash of the exact approved payload.

CREATE TABLE outbound_operation (   tenant_id      text  NOT NULL,   operation_id   text  NOT NULL,   -- identity of one business intent   payload_sha256 bytea NOT NULL,   -- immutable once approved   state          text  NOT NULL,   -- pending | in_flight | succeeded                                    -- | failed_terminal | unknown   remote_ref     text,   updated_at     timestamptz NOT NULL DEFAULT now(),   PRIMARY KEY (tenant_id, operation_id) );  -- Atomic claim: only one worker moves pending to in_flight, -- and only for the exact approved payload. UPDATE outbound_operation    SET state = 'in_flight', updated_at = now()  WHERE tenant_id = $1 AND operation_id = $2    AND payload_sha256 = $3 AND state = 'pending' RETURNING operation_id; -- 0 rows: another worker owns it, the payload differs, or it is not pending. -- A payload mismatch for an existing operation_id is a rejected, alerted -- conflict, never a silent overwrite.

Enforce immutability at the database level (no UPDATE privilege on the hash column, or a trigger that rejects changes), not just by convention.

On timeout: reconcile, then decide

  1. Set the operation to unknown. Do not retry yet.
  2. Query the authoritative system for the operation's real state. Use a lookup that does not depend on the call you are unsure about: a search by your idempotency key or metadata, or a read of the resource.
  3. If the remote side shows the effect: record succeeded with the remote reference.
  4. If the remote side shows no effect and supports idempotent retries under the same key: retry with the same key and the same payload, inside the provider's documented key lifetime.
  5. If the remote side cannot be queried, or the idempotency window may have expired: escalate to a human or a bounded reconciliation job. Do not guess.

Whether a given provider supports an idempotency key, how long it honors one, and what it does when the same key arrives with different parameters is provider-specific. Verify it in that provider's documentation; do not assume. If the provider offers no key and no lookup, you are in the "disclose the limitation" case, and the safest design is a single-writer queue per resource plus manual reconciliation for unknowns.

Tenant-safe keys, and legitimate later operations

Two mistakes sit on opposite sides of the same line.

  1. Keys too broad. If the key is just the provider's event ID, two tenants, two environments, or a replayed sandbox event can collide. Scope every key by tenant (and provider, and environment).
  2. Keys too narrow or too sticky. If you derive the operation identity only from the resource (say, refund:order-123), a legitimate second refund on the same order is rejected as a duplicate. Include a business-intent discriminator, such as an approved refund request ID, so a genuinely new operation gets a new identity while a retry of the same intent reuses the old one.

Reject changed-payload reuse of an existing key, and treat a new approved intent as a new operation. Both rules need to be explicit in code, because the failure for each is silent.

Is your webhook layer acknowledging events it cannot prove it kept?

A receiver audit against the ten go/no-go controls above usually surfaces the gaps in a day, not a quarter.

Talk to engineers who build and run integration backends, not just write about them.

Request an Integration Review

Why This Fails in the Real World: Common Failure Patterns and Trade-Offs

Key Takeaways: Capable teams fail here because each decision looked reasonable alone. The failures live in the gaps between components and between teams, not in any single line of code. The scenarios below are illustrative composites, not customer results.

Failure pattern 1: The acknowledged event that existed only in memory (illustrative)

A team follows vendor advice to return 200 quickly. The handler verifies the signature, pushes the event onto an in-process queue, and responds. A rolling deploy replaces pods every release. Events sitting in memory during the shutdown window are gone, and the provider has a 2xx on record for each. Nothing errors. The gap shows up as a reconciliation mismatch some time later.

Why intelligent teams still fail: "Respond quickly" and "respond after you are durable" were treated as the same instruction. No one owned the definition of what a 2xx means in this system. The fix is a written acknowledgment contract (control 3) and a kill-the-process test in CI.

Failure pattern 2: Deduplication with a shorter memory than the sender's retries (illustrative)

The team caches processed event IDs in a fast key-value store with a short expiry, which matches the example in the Standard Webhooks spec (save the IDs for a few minutes). That is fine as a first-line filter. But the sender's retry schedule can stretch well beyond the cache lifetime, and manual redeliveries from a provider dashboard can happen days later. The same event is processed twice, and the second pass also lands on a cache that was flushed during a failover.

Why intelligent teams still fail: a cache was used for a correctness job. The durable unique constraint is the guarantee; the cache only saves a database round trip. Also check that the key includes the tenant, so a shared or replayed ID cannot suppress another tenant's event.

Failure pattern 3: The blind retry after a timeout (illustrative)

A refund call times out. The job framework retries three times with backoff. The first call had actually succeeded; the provider's response was lost. Depending on whether the key was stable and honored, the customer receives zero, one, or several refunds, and the logs show "timeout, retry, success" with no hint of the earlier effect.

Why intelligent teams still fail: the retry policy lived in a generic job library that knows nothing about consequence. Retrying a read is free. Retrying a mutation is a decision. The operation record, the unknown state, and reconcile-before-retry make the decision explicit.

Failure pattern 4: Newest-arrival-wins overwrites newer state

Events for the same object arrive out of order. The worker applies each as it comes. An older "pending" event is processed after a newer "paid" event and flips the record back. Add a monotonic-marker condition to the write, or re-fetch current state.

Trade-offs you accept with the durable receiver

CostWhat it buysMitigation
One extra durable write on the request pathA truthful 2xxKeep the handler minimal; measure p95 insert latency
Storage growth from raw payloadsReplay and forensicsRetention policy, compression, object-store offload
Database used as a work queueAtomic dedup plus scheduling with no dual-write gapFine for moderate volume; at higher volume, relay from the inbox to a broker via an outbox
Extra states and toolingRecoverable failuresSmall replay CLI and one dashboard before launch
Privacy surface of stored bodiesDebuggabilityEncryption, access review, purge path, named owner

Measurements to take from your own system

We are not publishing benchmark numbers for this design, because they would depend entirely on your provider, schema, and hardware. Instead, record these from your own environment and set thresholds your team agrees on:

MeasurementWhy it matters
p95 and p99 handler latency (verify plus insert)Confirms you stay inside the provider's response window
Inbox lag (oldest received row age)Early signal of worker stalls
Duplicate rate by provider and tenantShows retry behavior and cache or constraint efficacy
Count and age of dead and quarantined rowsUnowned failures accumulate here
Count and age of unknown outbound operationsDirect measure of unresolved money or state risk
Reconciliation mismatches against provider recordsThe only independent check that you did not lose or double events

A lower-risk rollout path

  1. Shadow inbox. Keep the current handler. Add the verified inbox write beside it and compare counts against the provider's event log.
  2. Cut over one low-consequence event type. Move its processing to the inbox worker. Run duplicate and kill-process tests.
  3. Add conditional effects and the terminal-failure tooling. Drill a poison event and a replay.
  4. Migrate consequential event types once the go/no-go table is fully green for the earlier ones.
  5. Schedule a recurring reconciliation job as an independent check, not a one-time launch task.

Teams that do not have the bandwidth to build and run this alongside a product roadmap often bring in extra engineers for the integration layer. Developers.dev offers API integration services and backend development, plus staff augmentation if you want engineers embedded in your own repository and review process.

Conclusion: Decisions to Make This Week

The core idea is small: a 2xx should mean "we durably own this event," and everything after that should be recoverable. These are the concrete actions we would take, in order.

  1. Audit every path that returns 2xx. Trace each one to the point where the event is committed. Any 2xx that can occur before commit is a no-go, so fix it first.
  2. Add the unique constraint and the payload hash. Scope the key by tenant, provider, and event ID. Decide and test what happens when the same ID arrives with a different body.
  3. Replace in-code read-compare-write with conditional writes. Where the target cannot enforce a condition, write down the limitation and serialize per resource.
  4. Introduce an unknown state for outbound mutations and a reconcile-before-retry rule. Bind each operation to a tenant-scoped identity and an immutable payload hash.
  5. Name a privacy owner and a retention rule for the inbox, and confirm the legal implications with your own counsel. Vendor statements and compliance-support features do not replace your own obligations.

Run the ten go/no-go controls as a review checklist. Every row must pass for a receiver that triggers a consequential effect.

About Developers.dev

Developers.dev is an offshore software development and staff augmentation company founded in 2007, headquartered in Indore, India, with 1000+ in-house IT professionals serving clients mainly in the USA, EMEA, and Australia. The company states that it holds CMMI Level 5, SOC 2, and ISO 27001 accreditations among others. Certificate scope and audit periods vary, so ask any vendor, including us, for the current certificates and their scope during due diligence. A vendor's certification does not by itself make your integration compliant with a law or contract that applies to you.

Reviewed by the Developers.dev Expert Team.

Frequently Asked Questions

When should a webhook receiver return a 2xx response?

Only after three things have happened: the signature (and timestamp tolerance) was verified over the raw body, the tenant was resolved from your own configuration, and the event was written durably to your inbox in a committed transaction. A duplicate with an identical payload hash can also get a 2xx, because the event is already stored. If storage is unavailable, return a 5xx so the provider retries.

Is verifying the HMAC signature enough to trust a webhook?

No. A signature authenticates the HTTP delivery from the holder of the secret. It does not tell you which of your tenants the event belongs to, whether it is a replay (that needs a timestamp check), or whether you have already processed it. You still need tenant resolution from your own config, dedup, and conditional effects.

Can I deduplicate webhooks with a cache like Redis?

A cache is useful as a first-line filter, but it should not be the guarantee. Cache entries expire or get flushed, while a sender's retries and manual redeliveries can arrive much later. Use a database unique constraint on tenant, provider, and event ID as the source of truth, and treat the cache as an optimization.

What should I do with webhook events I do not recognize?

Store them, mark them quarantined, and acknowledge them. Dropping them loses data you may need after you add support, and answering with a 4xx can cause some providers to treat your endpoint as unhealthy. Check your provider's documentation for its endpoint-disabling behavior. After shipping support, move the rows back to the received state to process them.

How do I handle out-of-order webhook events?

  1. Use a monotonic marker from the provider (version, sequence, or event time) inside the conditional write, so older events cannot overwrite newer state.
  2. If no reliable marker exists, treat the webhook as a hint and fetch the current object from the provider's API before acting.
  3. Record skipped-stale outcomes so you can see how often it happens.

What if a downstream API call times out while handling a webhook?

Do not blindly retry a consequential call. Mark the operation as unknown, query the authoritative system for the real state, and decide from that. Retry under the same idempotency key and payload only if the provider supports it and the key is still within its documented lifetime. Otherwise escalate for manual or bounded reconciliation.

How long should I keep raw webhook payloads?

It depends on your replay needs, contracts, and legal obligations, so there is no universal number. Define a retention period, encrypt payloads at rest, name an owner, and build a purge or redaction path. Ask your counsel how privacy laws apply to the identifiers and personal data in your payloads.

Webhooks drive your revenue and your support queue. Who owns them?

If your receiver acknowledges before it persists, deduplicates with a cache, or retries mutations after a timeout, the risk is already in production.

Work with Developers.dev engineers to harden your integration layer: durable inbox, tenant-safe idempotency, reconciliation, and replay tooling.

Contact Us
Related service

This guide is designed for engineering leaders who want to plan a practical implementation. Use the related Developers.dev path to compare delivery options, implementation fit, risk, and practical next steps.

Read the primary guideRequest a free consultation
Editorial review

Reviewed by the Experts team

This guide is reviewed for clarity, technical and operational relevance, service alignment, and a useful next step. Verified by our SEO team for clear search presentation.

Reviewed byDevelopers.dev Experts Team
Reviewed2026-10-16
FocusAPI Integration Services
SEO verified byDevelopers.dev SEO Team
SEO verified2026-10-16

Reviewed by the Experts team. Verified by our SEO team. Validate legal, security, data, budget, and operational requirements with the relevant stakeholders before rollout.