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
- 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.
- 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.
- 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."
- 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.
- 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.
- Own the privacy surface. Raw payloads, event IDs, and tenant IDs can all be sensitive. Decide retention, encryption, and log redaction before launch.
- 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.
- 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.
- 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.
- 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.
| Property | What the provider typically does | What your receiver must assume |
|---|---|---|
| Delivery | Retries on timeout or non-2xx | Duplicates will arrive, possibly long after the first copy |
| Ordering | Often not guaranteed | Older events can arrive after newer ones |
| Identity | Stable event ID across retries (check your provider) | The ID is your dedup anchor, but only within a defined scope |
| Authenticity | Signs payload and often a timestamp | Verify raw bytes, then still authorize the tenant yourself |
| Schema | Adds event types and fields over time | Unknown 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.
| Dimension | A: Inline | B: Ack then in-memory | C: Durable inbox |
|---|---|---|---|
| Risk of lost events | Low if you return non-2xx on failure; the provider retries | High: crash or deploy loses acknowledged events | Low: loss requires losing the database |
| Duplicate processing risk | High under timeouts and concurrent retries | High unless dedup is added | Low with a unique constraint plus conditional effects |
| Response latency | Bounded by business logic | Very low | Low: one verified insert |
| Replay after a bug | Needs the provider's redelivery tools | Not possible for lost events | Possible from stored payloads |
| Operational cost | Lowest | Low | Moderate: storage, retention, sweeper, dashboards |
| Fit | Low volume, fast, idempotent effects | Rarely defensible for consequential events | Anything 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
- 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.
- 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.
- 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. - 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.
- 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), orquarantined(uncertain input awaiting a human or a code change).
Response policy
| Condition | Response | Reasoning |
|---|---|---|
| Unknown endpoint or tenant | 404 | Nothing was accepted; do not store a body you cannot attribute |
| Bad signature or stale timestamp | 401 or 400 | Not acknowledged; rate limit and alert on spikes |
| Valid, inbox commit succeeded | 2xx | You own the event now |
| Valid duplicate, same payload hash | 2xx | Already durable; stops further retries |
| Same event ID, different payload hash | Store in a conflict table, alert, then 2xx | Anomaly to investigate; keeping both copies preserves evidence |
| Valid but unknown event type | Store, mark quarantined, 2xx | Do not lose it; replay after you add support |
| Database unavailable | 5xx | Not 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.
| # | Control | Evidence to show | No-go if |
|---|---|---|---|
| 1 | Signature verified over raw bytes, with timestamp tolerance | Test with a mutated body and a stale timestamp | Body is parsed before verification |
| 2 | Tenant resolved from your config, not the payload | Test: valid signature plus a foreign tenant ID in the payload is rejected or ignored | Payload field selects the tenant |
| 3 | 2xx only after durable commit | Kill the process mid-request; the provider's retry succeeds | Any code path returns 2xx before commit |
| 4 | Unique constraint on (tenant, provider, event ID) | Schema plus concurrent-insert test | Dedup relies only on cache or application checks |
| 5 | Payload hash stored; changed-payload reuse detected | Test with same ID, different body | Silent overwrite or silent drop |
| 6 | Atomic work claim with lease and fencing | Two-worker race test | Two workers can apply the same row |
| 7 | Effects applied with target-side conditional writes, or serialization limits documented | Stale-event test and duplicate-effect test | Read-compare-then-write with no condition at the target |
| 8 | Terminal failure state with reason, alert, and replay tool | Poison-event drill | Failed events retry forever or vanish |
| 9 | Unknown or unparseable input stored and quarantined | Unknown-type test | Unknown input is dropped or crashes the worker |
| 10 | Privacy owner, retention, encryption, and log redaction defined | Named owner and written retention rule | Raw 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.
- Unknown event type: store, quarantine, acknowledge. After you ship support, flip the rows back to
receivedand let the pipeline process them. - 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.
- Missing required field: terminal failure for that row with a reason code. Do not guess a default for a consequential effect.
- 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.objecttogether with the event type to spot those duplicates. Build that secondary dedup into the effect layer, not into the receiver. - 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:
- authenticate a message after you copy it onto a queue or into a data lake;
- prove which of your tenants the event belongs to;
- guarantee exactly-once delivery or ordering;
- make the event ID safe to use globally. Standard Webhooks describes the
webhook-idas 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:
- Keep identifiers out of URL paths, query strings, and metric labels, which are widely logged and high-cardinality.
- Redact or tokenize identifiers in application logs; log the inbox row ID instead.
- Encrypt raw bodies at rest, or store them in an access-controlled object store with the inbox holding a reference.
- Name a privacy owner for the inbox table: someone accountable for retention, access reviews, and handling deletion requests.
- 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
- Set the operation to
unknown. Do not retry yet. - 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.
- If the remote side shows the effect: record
succeededwith the remote reference. - 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.
- 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.
- 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).
- 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 ReviewWhy 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
| Cost | What it buys | Mitigation |
|---|---|---|
| One extra durable write on the request path | A truthful 2xx | Keep the handler minimal; measure p95 insert latency |
| Storage growth from raw payloads | Replay and forensics | Retention policy, compression, object-store offload |
| Database used as a work queue | Atomic dedup plus scheduling with no dual-write gap | Fine for moderate volume; at higher volume, relay from the inbox to a broker via an outbox |
| Extra states and tooling | Recoverable failures | Small replay CLI and one dashboard before launch |
| Privacy surface of stored bodies | Debuggability | Encryption, 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:
| Measurement | Why 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 tenant | Shows retry behavior and cache or constraint efficacy |
Count and age of dead and quarantined rows | Unowned failures accumulate here |
Count and age of unknown outbound operations | Direct measure of unresolved money or state risk |
| Reconciliation mismatches against provider records | The only independent check that you did not lose or double events |
A lower-risk rollout path
- Shadow inbox. Keep the current handler. Add the verified inbox write beside it and compare counts against the provider's event log.
- Cut over one low-consequence event type. Move its processing to the inbox worker. Run duplicate and kill-process tests.
- Add conditional effects and the terminal-failure tooling. Drill a poison event and a replay.
- Migrate consequential event types once the go/no-go table is fully green for the earlier ones.
- 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.
- 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.
- 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.
- Replace in-code read-compare-write with conditional writes. Where the target cannot enforce a condition, write down the limitation and serialize per resource.
- Introduce an
unknownstate for outbound mutations and a reconcile-before-retry rule. Bind each operation to a tenant-scoped identity and an immutable payload hash. - 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?
- Use a monotonic marker from the provider (version, sequence, or event time) inside the conditional write, so older events cannot overwrite newer state.
- If no reliable marker exists, treat the webhook as a hint and fetch the current object from the provider's API before acting.
- 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 UsAPI Integration Services
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.
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 by the Experts team. Verified by our SEO team. Validate legal, security, data, budget, and operational requirements with the relevant stakeholders before rollout.

