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, andsettlement_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#
| Pain point | Stripe payment objects | Flint orders |
|---|---|---|
| Duplicate deliveries | Stripe payment objectsDedup event.id, object ID plus type, and each sale | Flint ordersAlso at least once; dedup webhook_event_id |
| Out-of-order events | Stripe payment objectsGuards you write, which can block real changes | Flint ordersAlso unordered; one sync job per order |
| What you fetch per event | Stripe payment objectsPaymentIntent, its charge, disputes, your order row | Flint ordersGET /v1/orders/{order_id}, plus disputes if tracked |
| Paid and refunded totals | Stripe payment objectsYour derivation code | Flint ordersFlint: payment_status, refund_status, settlement_amounts |
| Deposit and balance on one sale | Stripe payment objectsTwo PaymentIntents your code sums | Flint ordersOne order: partially_paid, then paid |
| Reconciliation sweep | Stripe payment objectsList payment objects, rerun the derivation | Flint ordersList orders updated since your last sweep |
| Retry window | Stripe payment objectsUp to three days in live mode | Flint ordersUp to 9 attempts over 71 to 86 hours |
Every payment API delivers at least once#
| Delivery contract | Stripe | Adyen | Braintree | PayPal | Flint |
|---|---|---|---|---|---|
| Retries | StripeUp to three days in live mode; three times over a few hours in a sandbox | AdyenA few retries, then a retry queue for up to 30 days | BraintreeEvery hour for up to 24 hours in production | PayPalUp to 25 times over 3 days | FlintUp to 9 attempts, the last 71 to 86 hours after the first |
| Duplicates | Stripe"Occasionally" the same event, and "in some cases" two separate Event objects for one change | AdyenSame eventCode and pspReference; "use the details from the latest webhook event" | BraintreeNot stated in the webhook guides | PayPalNot stated in the webhook overview | FlintSame webhook_event_id on every retry and resend |
| Order | StripeNot guaranteed; don't use created to order events | Adyen"always check the timestamp" | Braintree"Notifications may not be delivered sequentially" | PayPalNot stated in the webhook overview | FlintNot 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.
On Hacker News, one developer lists the ordering problem:
Receiving events for the same entity in the wrong order. For instance: 'succeeded' before 'created'.
| Stripe | Flint | |
|---|---|---|
| Record of the sale | StripePayment objects at Stripe; the sale in your database | FlintThe order, which exists before any payment, with its line items and total |
| Payments, refunds, and disputes | StripeYour code turns charges, refunds, and disputes into one status | FlintEvery 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.
| Stripe | Flint | |
|---|---|---|
| Claiming an event | StripeA 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 write | FlintOne claim on webhook_event_id, in the transaction that enqueues the sync |
| Repeated deliveries | StripeA 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 events | StripeA third key per sale, since checkout.session.completed and payment_intent.succeeded describe one purchase | FlintOne 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.
| Stripe | Flint | |
|---|---|---|
| Timestamps | StripeGuards 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 dropped | FlintEvents are unordered too. Flint's guide: "Treat a webhook as a signal that something changed, not as the change itself." |
| Status changes | StripeStatus 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 refunded | FlintNo 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.
| Stripe | Flint | |
|---|---|---|
| From event to sale | StripeA path from each event type back to its PaymentIntent | FlintEach event's data.object is the resource as its GET returns it |
| Join key | Stripemetadata.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 status | StripeYour derivation code | Flintpayment_status: unpaid, partially_paid, or paid; refund_status: none, partially_refunded, or refunded |
| Totals | StripeSumming two PaymentIntents for a deposit and a balance | Flintsettlement_amounts: paid, refunded, net collected, and outstanding money |
| Disputes | StripeDispute outcomes: disputed is a boolean, and won or lost is on the Dispute | FlintDisputes carry order_id |
| Concurrency and reads | StripeLocking per payment, since parallel refetches race, and API reads for every event | FlintOne 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):
{
"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.
| Stripe | Flint | |
|---|---|---|
| Failed deliveries | StripeA 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 reported | StripePolling for events Stripe never generated, which its docs (public preview) call "irrecoverable", and a sweep over PaymentIntents, charges, refunds, and disputes that reruns your derivation | FlintA 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 drifts | Usual cause | Who pays |
|---|---|---|
| An order ships twice | A duplicate delivery, or two event types both triggering fulfillment | You: the second shipment |
| A paid order stays unpaid | The handler returned 200 and then failed, or retries ran out while the endpoint was down | You: a support ticket, or an order that never ships |
| A refund is recorded but the customer wasn't paid back | refund.failed arrived after a rank guard locked the row at refunded | You: an unhappy customer, and books that are wrong |
| Access stays on after a cancellation | An older subscription update arrived after the cancellation and overwrote it | You: service delivered for free |
| A lost dispute still shows as paid | The dispute event was never mapped back to the order | You: 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.objectas a snapshot that a retry can deliver more than a day later. - Return
2xxwithin 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 -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.
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.
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.
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.
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
}
Route: verify, claim, enqueue. A redelivery inserts nothing.
// app/api/flint/webhook/route.ts
import { Client } from "@flintpay/node";
const flint = new Client({
baseUrl: "https://api.withflintpay.com",
apiKey: process.env.FLINT_API_KEY!,
});
export async function POST(request: Request) {
const rawBody = new Uint8Array(await request.arrayBuffer());
let verified;
try {
verified = flint.verifyWebhook(rawBody, request.headers, [
process.env.FLINT_WEBHOOK_SECRET!,
]);
} catch {
return new Response(null, { status: 400 });
}
// Event types newer than the SDK arrive with known: false. Test events use
// placeholder IDs, so they prove transport only.
if (!verified.known || verified.event.test) {
return new Response(null, { status: 200 });
}
const event = verified.event;
// Orders, refunds, disputes, and order payments all carry order_id.
const orderId = (event.data.object as { order_id?: string }).order_id;
if (!orderId) return new Response(null, { status: 200 });
// One transaction: a redelivery inserts nothing. If this throws, the route
// returns 500 and Flint retries with the same webhook_event_id.
await db.transaction(async (tx) => {
const claim = await tx.query(
"insert into flint_events (webhook_event_id) values ($1) on conflict do nothing",
[event.webhook_event_id],
);
if (claim.rowCount === 1) {
await tx.query("insert into order_sync_jobs (order_id) values ($1)", [orderId]);
}
});
return new Response(null, { status: 200 });
}
Worker: read the order, fulfill once. Run one sync job per order at a time, so two reads of the same order can't finish out of order.
async function syncOrder(orderId: string) {
const order = await flint.orders.get(orderId);
await db.transaction(async (tx) => {
await tx.query(
`insert into orders (flint_order_id, payment_status, refund_status, net_collected_cents, snapshot)
values ($1, $2, $3, $4, $5)
on conflict (flint_order_id) do update set
payment_status = excluded.payment_status,
refund_status = excluded.refund_status,
net_collected_cents = excluded.net_collected_cents,
snapshot = excluded.snapshot`,
[
order.order_id,
order.payment_status,
order.refund_status,
order.settlement_amounts.net_collected_money.amount,
JSON.stringify(order),
],
);
if (order.payment_status === "paid") {
const claim = await tx.query(
"insert into fulfillments (flint_order_id) values ($1) on conflict do nothing",
[order.order_id],
);
if (claim.rowCount === 1) {
await tx.query("insert into shipping_jobs (flint_order_id) values ($1)", [order.order_id]);
}
}
});
}
Sweep: compare stored payment_status. A refund sweep is the same loop on refund_status.
for await (const order of flint.orders.listItems(
{ payment_status: ["paid"], updated_after: since, page_size: 100 },
{ deadlineMs: 60000 },
)) {
const stored = await db.orderPaymentStatus(order.order_id);
if (stored !== order.payment_status) await enqueueSync(order.order_id);
}
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/webhookstreams sandbox events with real signatures (CLI guide).POST /v1/webhook-endpoints/{webhook_endpoint_id}/test-eventswith{"event_type": "order.paid"}sends a signed example.POST /v1/webhook-deliveries/{webhook_delivery_id}/resendrepeats a real delivery with the samewebhook_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.
- Stripe, Receive Stripe events in your webhook endpoint: duplicate deliveries and two separate Event objects, no ordering guarantee,
createdin seconds, retries for up to three days in live mode and three times in a sandbox, thin events recommended, and retrieving the latest object. - Stripe, Manage webhook endpoints: listing undelivered events with
delivery_success=false, the 30-day limit, processing and processed marks, and irrecoverable event-generation failures (public preview). - Stripe, Refund and cancel payments: failed refunds, the up-to-30-day return, and the refund events.
- Stripe, How disputes work: late-win disputes.
- Stripe, Metadata: metadata doesn't copy to related objects.
- Stripe, Event types: the event names used above.
- Adyen, Handle webhook events: duplicates with the same
eventCodeandpspReference, using the latest event, and checking the timestamp for order. - Adyen, Troubleshoot webhooks: the retry queue for up to 30 days.
- Braintree, Parse webhooks: hourly retries for up to 24 hours in production and non-sequential delivery.
- PayPal, Webhooks overview: up to 25 retries over 3 days.
- Theo Browne, stripe-recommendations: the "split brain" quote.
- Hacker News, Our Stripe Billing implementation and the one webhook to rule them all: the developer comment on out-of-order events.
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.
