Skip to content

Stripe Webhooks: Duplicates, Out-of-Order Events, Drift

Stripe webhooks arrive twice, out of order, and for days. The dedup, ordering, and reconciliation code teams write, how it fails, and the shorter Flint handler.
Webhooks10 min readBy Flint Pay, Product & API Team

A customer paid once and received two boxes; another paid, and your app still says unpaid. Neither threw an error: a webhook arrived twice, late, or never, and your database now disagrees with Stripe.

Tip: The short answer

  • Stripe can deliver an event more than once, in no guaranteed order, for up to three days, and your code turns them into one order status.
  • On Flint, the order is the record of the sale, and Flint computes its payment_status, refund_status, and settlement_amounts. Its webhooks are also at least once and unordered, so you still dedupe, then refetch one order.
  • You stop owning ordering guards, code that derives paid and refunded totals from payment objects, and sweeps over payment objects.

At a glance#

Duplicate deliveriesStripe payment objectsDedup event.id, object ID plus type, and each saleFlint ordersAlso at least once; dedup webhook_event_id
Out-of-order eventsStripe payment objectsGuards you write, which can block real changesFlint ordersAlso unordered; one sync job per order
What you fetch per eventStripe payment objectsPaymentIntent, its charge, disputes, your order rowFlint ordersGET /v1/orders/{order_id}, plus disputes if tracked
Paid and refunded totalsStripe payment objectsYour derivation codeFlint ordersFlint: payment_status, refund_status, settlement_amounts
Deposit and balance on one saleStripe payment objectsTwo PaymentIntents your code sumsFlint ordersOne order: partially_paid, then paid
Reconciliation sweepStripe payment objectsList payment objects, rerun the derivationFlint ordersList orders updated since your last sweep
Retry windowStripe payment objectsUp to three days in live modeFlint ordersUp to 9 attempts over 71 to 86 hours

Every payment API delivers at least once#

Webhook delivery contracts
RetriesStripeUp to three days in live mode; three times over a few hours in a sandboxAdyenA few retries, then a retry queue for up to 30 daysBraintreeEvery hour for up to 24 hours in productionPayPalUp to 25 times over 3 daysFlintUp to 9 attempts, the last 71 to 86 hours after the first
DuplicatesStripe"Occasionally" the same event, and "in some cases" two separate Event objects for one changeAdyenSame eventCode and pspReference; "use the details from the latest webhook event"BraintreeNot stated in the webhook guidesPayPalNot stated in the webhook overviewFlintSame webhook_event_id on every retry and resend
OrderStripeNot guaranteed; don't use created to order eventsAdyen"always check the timestamp"Braintree"Notifications may not be delivered sequentially"PayPalNot stated in the webhook overviewFlintNot guaranteed; fetch the resource for current state

Ordering is your code's job with every provider, and Stripe and Adyen give opposite advice on how.

Where the sale lives#

One Stripe Checkout card order produces checkout.session.completed, payment_intent.succeeded, and charge.succeeded. A refund adds refund.created, charge.refunded, and refund.updated, then refund.failed if the bank returns the money, which "can take up to 30 days." Rare "late win" disputes even change from lost to won.

Theo Browne's Stripe recommendations calls this the "split brain":

When a customer checks out, the "state of the purchase" is in Stripe. You're then expected to track the purchase in your own database via webhooks.

Theo Browne, GitHub, stripe-recommendations

On Hacker News, one developer lists the ordering problem:

Receiving events for the same entity in the wrong order. For instance: 'succeeded' before 'created'.

GorgeRonde, Hacker News
Who keeps the sale
Record of the saleStripePayment objects at Stripe; the sale in your databaseFlintThe order, which exists before any payment, with its line items and total
Payments, refunds, and disputesStripeYour code turns charges, refunds, and disputes into one statusFlintEvery payment and refund settles against the order, and Flint keeps the result on the order

Duplicates: one sale, several keys#

Stripe's advice is to log "the event IDs you've processed, and then not processing already-logged events." That one key isn't enough; Next.js and Stripe: what the tutorials leave out shows the transaction that claims and enqueues together.

Keys your deduplication needs
Claiming an eventStripeA claim in the same transaction as the work: a claim committed first loses the event when the work throws, and Stripe's processing and processed marks leave a sweep to writeFlintOne claim on webhook_event_id, in the transaction that enqueues the sync
Repeated deliveriesStripeA second key, data.object ID plus event.type, because "in some cases, two separate Event objects are generated"FlintStill at least once, with the same webhook_event_id on every retry and resend
One sale, several eventsStripeA third key per sale, since checkout.session.completed and payment_intent.succeeded describe one purchaseFlintOne fulfillment claim per order, since every payment settles against it

Ordering: guards that drop real changes#

When a refund arrives before the payment, teams add a timestamp guard and a status rank. Both drop real changes, and the row stays wrong with nothing in your logs.

How each handler copes with order
TimestampsStripeGuards that tie: created is in seconds, and Stripe says "Don't use created to determine event order." With <=, the second event in a second is droppedFlintEvents are unordered too. Flint's guide: "Treat a webhook as a signal that something changed, not as the change itself."
Status changesStripeStatus ranks that block money moving backwards: a failed refund returns refunded to paid, a lost dispute can become a late win, and a partial refund isn't refundedFlintNo guards: fetch the order on every event and write what the API returns, with one sync job per order at a time

Deriving the sale from payment objects#

Stripe's docs say to "retrieve the API resource from the Stripe API to access the latest and up-to-date object definition," and the thin events it recommends for new integrations carry only IDs. The refetch replaces the guards; the derivation stays yours.

Who derives the sale
From event to saleStripeA path from each event type back to its PaymentIntentFlintEach event's data.object is the resource as its GET returns it
Join keyStripemetadata.order_id exists only if you set it, and metadata "doesn't automatically copy to related objects" (where it lands)FlintRefunds and payment intents carry order_id
Paid and refunded statusStripeYour derivation codeFlintpayment_status: unpaid, partially_paid, or paid; refund_status: none, partially_refunded, or refunded
TotalsStripeSumming two PaymentIntents for a deposit and a balanceFlintsettlement_amounts: paid, refunded, net collected, and outstanding money
DisputesStripeDispute outcomes: disputed is a boolean, and won or lost is on the DisputeFlintDisputes carry order_id
Concurrency and readsStripeLocking per payment, since parallel refetches race, and API reads for every eventFlintOne sync job per order at a time, and one order read per event

After a deposit, a balance payment, and one partial refund, the order reads like this (abbreviated):

JSON
{
  "order_id": "ord_1kmn0aExample",
  "status": "closed",
  "closed_reason": "paid",
  "payment_status": "paid",
  "refund_status": "partially_refunded",
  "fulfillment_status": "fulfilled",
  "payment_intent_ids": ["pi_1kmn0aExample", "pi_1kmn0bExample"],
  "refund_ids": ["ref_1kmn0aExample"],
  "settlement_amounts": {
    "paid_money": { "amount": 20000, "currency": "USD" },
    "refunded_money": { "amount": 4500, "currency": "USD" },
    "net_collected_money": { "amount": 15500, "currency": "USD" },
    "outstanding_money": { "amount": 0, "currency": "USD" }
  }
}

Flint summed both payments, applied the refund, and set payment_status to paid only when the balance settled. order.paid fires when the order fully settles and closes, from checkout, a payment link, an invoice, or the API; a bank debit still processing hasn't paid it.

Reconciliation: catching what never arrived#

The last layer reprocesses events that failed delivery, then sweeps for state no event reported.

What reconciliation covers
Failed deliveriesStripeA missed-event job: Stripe "only returns events created in the last 30 days"FlintGET /v1/webhook-events?delivery_status=failed to alert on, then resend or create deliveries within the 14-day payload retention; after that, read current orders
State no event reportedStripePolling for events Stripe never generated, which its docs (public preview) call "irrecoverable", and a sweep over PaymentIntents, charges, refunds, and disputes that reruns your derivationFlintA sweep over orders updated since your last run, comparing stored payment_status; GET /v1/orders filters by payment_status, refund_status, fulfillment_status, and external_reference_id

What drift costs#

What driftsUsual causeWho pays
An order ships twiceA duplicate delivery, or two event types both triggering fulfillmentYou: the second shipment
A paid order stays unpaidThe handler returned 200 and then failed, or retries ran out while the endpoint was downYou: a support ticket, or an order that never ships
A refund is recorded but the customer wasn't paid backrefund.failed arrived after a rank guard locked the row at refundedYou: an unhappy customer, and books that are wrong
Access stays on after a cancellationAn older subscription update arrived after the cancellation and overwrote itYou: service delivered for free
A lost dispute still shows as paidThe dispute event was never mapped back to the orderYou: revenue you report but don't have

See also Stripe as your source of truth and Which Stripe webhook event should you trust?.

The handler, side by side#

Flint's Webhooks guide still asks your handler to:

  • Deduplicate on webhook_event_id.
  • If you mirror state locally, "fetch the current resource on every event and write what the API returns" (delivery reference).
  • Treat data.object as a snapshot that a retry can deliver more than a day later.
  • Return 2xx within 10 seconds, after the event and its work are recorded durably.

The verifier below uses a shared secret, so create the endpoint with hmac and store the response's secret as FLINT_WEBHOOK_SECRET; new endpoints default to JWT, verified with Flint's public JWKS. Refund events are included because a second partial refund that leaves an order partially_refunded sends refund.succeeded but no order event.

cURL
curl -X POST https://api.withflintpay.com/v1/webhook-endpoints \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Idempotency-Key: webhook-order-sync-001" \
  -d '{
    "url": "https://example.com/api/flint/webhook",
    "enabled_events": [
      "order.partially_paid",
      "order.paid",
      "order.partially_refunded",
      "order.refunded",
      "refund.succeeded",
      "refund.partially_succeeded",
      "refund.failed",
      "dispute.created",
      "dispute.won",
      "dispute.lost",
      "dispute.prevented",
      "dispute.inquiry_closed"
    ],
    "signing_methods": ["hmac"]
  }'

Layer 1: dedup table. The claim commits before the work, so a throw loses the event.

TypeScript
const claim = await db.query(
  "insert into stripe_events (id) values ($1) on conflict do nothing",
  [event.id],
);
if (claim.rowCount === 0) return new Response(null, { status: 200 });

await handleEvent(event); // fulfill, update the order row, send email
return new Response(null, { status: 200 });

Layer 2: ordering guards. Ties and backwards money moves are dropped.

TypeScript
const RANK = { pending: 0, paid: 1, refunded: 2 } as const;

const row = await db.orderByPaymentIntent(paymentIntentId);
if (event.created < row.last_event_created) return; // older event, skip
if (RANK[nextStatus] < RANK[row.status]) return;     // never move backwards
await db.updateOrderStatus(row.id, nextStatus, event.created);

Layer 3: refetch and derive. Every event type needs its own path back.

TypeScript
async function syncPayment(paymentIntentId: string) {
  const pi = await stripe.paymentIntents.retrieve(paymentIntentId, {
    expand: ["latest_charge"],
  });
  const charge = pi.latest_charge as Stripe.Charge | null;

  await db.query(
    `update orders
        set stripe_status = $2, amount_refunded = $3, disputed = $4
      where id = $1`,
    [pi.metadata.order_id, pi.status, charge?.amount_refunded ?? 0, charge?.disputed ?? false],
  );
}

switch (event.type) {
  case "payment_intent.succeeded":
    await syncPayment(event.data.object.id);
    break;
  case "charge.refunded":
  case "charge.dispute.closed":
  case "refund.failed":
    await syncPayment(event.data.object.payment_intent as string);
    break;
}

Layer 4: reconciliation. Missed events from the last 30 days only.

TypeScript
const missed = stripe.events.list({
  ending_before: lastDeliveredEventId,
  types: ["payment_intent.succeeded", "charge.refunded", "refund.failed", "charge.dispute.closed"],
  delivery_success: false,
});

for await (const event of missed) {
  await processEvent(event); // the same dedup and sync as the webhook route
}

To track disputes, read GET /v1/disputes?order_id= in the same job; each dispute ends at exactly one of won, lost, prevented, or inquiry_closed. For subscriptions, the subscription billing guide says to "fetch the subscription and reconcile your state against its current status" before granting or revoking access.

Try it in the sandbox#

  • flint listen --forward-to http://localhost:3000/api/flint/webhook streams sandbox events with real signatures (CLI guide).
  • POST /v1/webhook-endpoints/{webhook_endpoint_id}/test-events with {"event_type": "order.paid"} sends a signed example.
  • POST /v1/webhook-deliveries/{webhook_delivery_id}/resend repeats a real delivery with the same webhook_event_id, to test deduplication.
  • Accept your first payment creates a sandbox order to refund and sync.

Sign up for a free sandbox. Get sandbox keys, forward signed events with flint listen, and resend a delivery to test deduplication. You can also run flint signup in your terminal. Read the CLI guide.

Live card payments cost 3.79% + 35¢ with no monthly fee. Migrating from Stripe maps each Stripe object to Flint, and Statuses and lifecycles lists every status your sync will see.

FAQ#

Why does Stripe send the same webhook event twice?#

Stripe delivers at least once, so a retry repeats an event you may have processed, and "in some cases, two separate Event objects are generated and sent" for one change. Deduplicate on the event ID and on the data.object ID with the event type.

Does Stripe send webhook events in order?#

No. Stripe "doesn't guarantee the delivery of events in the order that they're generated" and says not to use created for ordering, so fetch the object and act on its current state.

How long does Stripe retry a failed webhook?#

Up to three days in live mode and three times over a few hours in a sandbox. After that, delivery_success=false lists missed events created in the last 30 days.

How do I make a webhook handler idempotent?#

Insert the event ID with a unique constraint and enqueue the work in the same transaction, then return 2xx. Add a second unique constraint on the business action, such as one fulfillment per order.

Are Flint webhooks delivered in order?#

No. Flint webhooks are at least once and unordered, but one GET /v1/orders/{order_id} returns payment_status, refund_status, and settlement_amounts already computed, so your handler writes that instead of assembling it.

Sources#

Everything attributed to Stripe, Adyen, Braintree, and PayPal above comes from their own documentation, and the developer quotes come from the linked public pages, checked on 2026-10-28.

Flint behavior comes from the Webhooks guide, the Webhook delivery reference, the event payload guide, Statuses and lifecycles, and the Orders and Disputes API references.