Softotic
Back to insights
24 August 202611 min readStripeNode.jsWebhooksPaymentsBackend

Stripe Webhook Duplicate Events and Out-of-Order Delivery: A Reliable Node.js Design

Stripe webhooks can be retried, duplicated and delivered out of order. This guide shows how to build a reliable Node.js handler using signature verification, a durable event inbox, idempotent processing and reconciliation.

Stripe Webhook Duplicate Events and Out-of-Order Delivery: A Reliable Node.js Design

A Stripe webhook handler should assume that the same event can arrive more than once, related events can arrive in an unexpected order, and failed deliveries can be retried for days.

The safe production pattern is:

text
Stripe
  v
Verify raw-body signature
  v
Persist Event in a durable inbox
  v
Return 2xx quickly
  v
Background worker
  v
Reconcile current Stripe state when ordering matters
  v
Apply idempotent business transition

This matters because Stripe's current documentation explicitly says webhook deliveries can be duplicated, live-mode failures can be retried for up to three days with exponential backoff, and event delivery order is not guaranteed.

Why can Stripe send duplicate webhooks?

Stripe's webhook documentation says endpoints might occasionally receive the same Event more than once. Stripe recommends logging processed Event IDs and not processing an already-recorded Event again.

The first duplicate case is straightforward:

text
evt_123
evt_123

Use the Stripe Event ID as a database uniqueness boundary.

Stripe also documents cases where two separate Event objects can represent the same underlying object and event type:

text
evt_123 -> invoice.paid -> in_456
evt_789 -> invoice.paid -> in_456

Stripe suggests using the object ID in __INLINE_CODE_0__ together with __INLINE_CODE_1__ to identify that class of duplicate.

Do not, however, make __INLINE_CODE_0__ universally unique. Some long-lived objects can legitimately emit the same type at different times.

The safer rule is:

  1. deduplicate exact deliveries by __INLINE_CODE_0__
  2. make the downstream business action idempotent
  3. add domain-specific logical dedupe only when its semantics are proven

Why retries change the architecture

Stripe says failed live-mode webhook deliveries can be retried for up to three days with exponential backoff. Manual resends can also overlap with automatic retry behaviour.

So this is unsafe:

text
webhook arrives
-> grant credits
-> email customer
-> update CRM
-> return 200

If the process crashes after granting credits but before the response completes, a retry can execute the same side effects again.

The endpoint must tolerate replay.

Stripe does not guarantee webhook order

Stripe also says it does not guarantee events arrive in the order they were generated.

A subscription flow might generate:

text
customer.subscription.created
invoice.created
invoice.paid
charge.created

but your server can see __INLINE_CODE_0__ first.

Therefore, do not build correctness around:

text
created must arrive before updated
updated must arrive before paid

When current state matters, use the Event as a trigger to reconcile with Stripe's current object.

For example:

text
subscription event received
        v
retrieve current subscription from Stripe
        v
map current Stripe state into local state

This prevents an older delayed Event from overwriting newer subscription state.

Verify the signature before storing anything

Stripe's signature guide says verification uses:

  • the original request body
  • the __INLINE_CODE_0__ header
  • the endpoint signing secret

The raw request body is important. Parsing JSON first can alter the bytes and break verification.

With Express, place the raw webhook route before global JSON parsing:

js
import express from 'express'
import Stripe from 'stripe'

const app = express()
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET

app.post(
  '/webhooks/stripe',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    let event

    try {
      event = stripe.webhooks.constructEvent(
        req.body,
        req.headers['stripe-signature'],
        webhookSecret
      )
    } catch (error) {
      return res.status(400).send('Invalid webhook signature')
    }

    const inserted = await storeStripeEvent(event)

    if (!inserted) {
      return res.sendStatus(200)
    }

    return res.sendStatus(200)
  }
)

app.use(express.json())

Keep test and live signing secrets separate and server-side.

Persist before returning __INLINE_CODE_0__

Stripe recommends returning a successful response quickly and processing events asynchronously.

A robust way to do that is a durable database inbox:

sql
create table stripe_webhook_events (
  event_id text primary key,
  event_type text not null,
  stripe_object_id text,
  payload jsonb not null,
  status text not null default 'pending',
  attempt_count integer not null default 0,
  received_at timestamptz not null default now(),
  processed_at timestamptz,
  last_error text
);

The primary key is important because two servers racing to store the same Event cannot both succeed.

A race-safe insert can use:

sql
insert into stripe_webhook_events (
  event_id,
  event_type,
  stripe_object_id,
  payload
)
values ($1, $2, $3, $4::jsonb)
on conflict (event_id) do nothing
returning event_id;

The HTTP rule becomes:

text
signature invalid
-> 4xx

verified duplicate already stored
-> 2xx

verified event stored durably
-> 2xx

verified event cannot be stored because DB is unavailable
-> error so Stripe can retry

This gives the endpoint a clean guarantee:

If it returned __INLINE_CODE_0__, your system already owns a durable copy of the Event.

Why a database inbox is useful even when you have a queue

A queue is valuable for throughput, but the durable boundary still matters.

A simple architecture can be:

text
Stripe
-> webhook
-> database inbox
-> 200
-> worker

A larger one can be:

text
Stripe
-> webhook
-> inbox
-> queue publisher
-> worker pool

Do not acknowledge the webhook before the Event is safely retained.

Worker retries can also duplicate business actions

Moving work to a worker does not create exactly-once execution.

Imagine:

text
worker grants 100 credits
-> process crashes before status='processed'
-> job retries
-> grants 100 credits again

The Event ID constraint prevents duplicate Event rows. It does not prevent duplicate business effects after a partial failure.

Protect important actions with business-level uniqueness.

For example, if one paid invoice grants one allowance:

sql
create unique index subscription_credit_once
on subscription_credits (
  stripe_invoice_id,
  credit_type
);

Useful business keys include:

ActionPossible idempotency identity
Provision one paid orderCheckout Session ID
Grant invoice allowanceInvoice ID + allowance type
Record paymentPaymentIntent ID
Record refundRefund ID
Send one paid-invoice workflowInvoice ID + notification type

The correct key comes from the business action.

Stripe API idempotency keys are different

Stripe's API idempotency documentation applies to requests your server sends to Stripe.

For example:

js
await stripe.customers.create(
  { email: customerEmail },
  { idempotencyKey: operationId }
)

That makes an outbound POST retry safer if a network failure leaves you unsure whether Stripe performed the operation.

Keep the two directions separate:

text
Your server -> Stripe
Stripe Idempotency-Key

Stripe -> your webhook
Event inbox + Event ID + business idempotency

Stripe API idempotency keys do not replace incoming webhook dedupe.

Do not use arrival order or __INLINE_CODE_0__ as your only truth

A delayed Event can be older than the state Stripe currently holds.

For mutable objects such as subscriptions, invoices and PaymentIntents, prefer reconciliation when sequence matters:

text
Event arrives
-> identify Stripe object
-> retrieve current object
-> update local projection idempotently

Use Event timestamps for diagnostics, not as your only correctness primitive.

A good subscription integration should be able to answer:

If webhook processing stopped for an hour, could we later reconstruct the correct current state?

If not, it is too dependent on perfect delivery.

When is the Event payload enough?

Fetching Stripe on every Event adds API traffic.

The payload can be enough when the operation is naturally tied to that immutable Event or one-time object, for example:

  • audit logging
  • recording a specific Refund object
  • a workflow uniquely keyed by the Event
  • append-only processing where later object state does not matter

Fetch the current Stripe object when:

  • the object changes repeatedly
  • an older payload can overwrite newer local state
  • event ordering affects correctness
  • your database needs to represent current Stripe state

Use explicit processing states

Avoid only __INLINE_CODE_0__.

Use:

text
pending
processing
processed
failed
dead_letter

Track:

  • attempt count
  • last error
  • received time
  • last attempted time
  • processed time
  • Event ID
  • Stripe object ID
  • event type

This makes failures operable instead of invisible.

A worker can route by event type:

js
async function processStripeEvent(row) {
  switch (row.event_type) {
    case 'invoice.paid':
      return reconcilePaidInvoice(row)

    case 'customer.subscription.updated':
    case 'customer.subscription.deleted':
      return reconcileSubscription(row)

    case 'payment_intent.succeeded':
      return reconcilePaymentIntent(row)

    default:
      return
  }
}

Treat this as an architecture example. Exact Stripe SDK methods and fields depend on your current API/SDK version and billing model.

Retry internal failures internally

Once a valid Event is safely in your inbox, an accounting or CRM outage should not require Stripe to redeliver it.

Use:

text
Stripe delivery
-> durable inbox OK
-> 200

Worker
-> accounting API unavailable FAIL
-> mark failed
-> internal retry

This separates webhook transport reliability from downstream business reliability.

The same principle applies to email, CRM, ERP, licensing and other side effects. If an external call succeeds but the worker crashes before recording success, the retry path must not duplicate the action.

Options include:

  • destination-side idempotency keys
  • an outbox table
  • naturally idempotent operations
  • reconciliation before retry
  • storing the external system's returned ID

Production architecture

For a payment-heavy system:

text
Stripe
  v
Webhook ingress
  v
Signature verification
  v
stripe_webhook_events
  v
Worker
  ├─ reconcile Stripe object
  ├─ update billing projection
  └─ write outbound commands
          v
       outbox
          v
   integration workers
   ├─ email
   ├─ CRM
   └─ accounting

This avoids turning one HTTP request into a distributed transaction.

Security and observability checklist

Before production:

  • verify signatures using the raw request body
  • keep signing/API secrets server-side
  • deduplicate exact Event IDs with a database constraint
  • persist before returning __INLINE_CODE_0__
  • process complex work asynchronously
  • make important business effects idempotent
  • reconcile mutable Stripe objects when ordering matters
  • do not assume webhook delivery order
  • use Stripe API idempotency keys for appropriate outbound POST retries
  • record explicit worker failure/retry states
  • subscribe only to required event types
  • rotate webhook signing secrets appropriately
  • log Event ID, event type, object ID and processing status
  • alert on ingress __INLINE_CODE_0__, old pending events, repeated failures and dead-letter growth

Do not put sensitive payloads into unrestricted logs.

Test the failure modes

A reliable integration should test more than the happy path.

Duplicate Event

Deliver the same verified Event twice.

Expected:

text
first -> stored
second -> duplicate acknowledged
business effect -> once

Worker crash after side effect

Force a crash after an external action but before completion marking. Verify the retry does not repeat the effect.

Out-of-order Events

Process a newer subscription Event before an older one. Verify final local state still matches Stripe.

Database outage

If the Event cannot be durably stored, verify the webhook does not return success.

Poison Event

Force a repeatedly failing event and verify bounded retries plus dead-letter handling.

Traffic burst

Stripe recommends asynchronous processing partly because webhook spikes can overwhelm synchronous endpoints. Test inbox/queue throughput and worker scaling.

FAQs

Can Stripe send the same webhook twice?

Yes. Stripe says webhook endpoints can occasionally receive the same Event more than once. Record Event IDs and make downstream business actions idempotent.

Does Stripe guarantee webhook order?

No. Stripe explicitly says event delivery order is not guaranteed. Retrieve current Stripe state when sequence affects correctness.

How long does Stripe retry failed webhooks?

Stripe's current documentation says live-mode delivery can be retried for up to three days using exponential backoff.

Should a Stripe webhook finish all business logic before returning __INLINE_CODE_0__?

No. Stripe recommends returning __INLINE_CODE_0__ quickly before complex work and recommends asynchronous processing. Persist the verified Event first, then process it in a worker.

Are Stripe idempotency keys enough for webhook dedupe?

No. Stripe API idempotency keys protect outbound API request retries. Incoming webhook reliability needs your own Event and business-level idempotency.

Do I need a queue?

For non-trivial workloads, asynchronous processing is advisable. A durable database inbox plus worker is already a valid starting architecture; a separate queue can be added when scale or operational needs justify it.

Conclusion

A reliable Stripe integration does not pretend webhooks are an exactly-once, ordered message bus.

It accepts that:

text
duplicates can happen
retries can happen
order can change
workers can fail

and designs around those facts:

text
Verify
-> Persist
-> Acknowledge
-> Process asynchronously
-> Reconcile when ordering matters
-> Make important side effects idempotent

That turns payment webhooks from a fragile callback chain into a recoverable business workflow.

If your application handles subscriptions, marketplaces, invoices or payment-driven workflows, Softotic's custom software development service can design the backend reliability layer, while web application development can integrate the operational and customer-facing parts of the payment system.

Sources and references