One successful Checkout payment sends your endpoint several events that all read like "it worked", and Stripe's event list does not rank them. In April 2026 a Hacker News commenter described the result: "there is no guidance on which events and states are authoritative or take precedence." The answer does exist. It is spread across three Stripe docs pages and a mailing list reply from Stripe staff, and it changes with how you take payments.
Pick the event by integration
| How you take the payment | Fulfill from | Why |
|---|---|---|
| Checkout Sessions or Payment Links, one-time payments | checkout.session.completed, plus checkout.session.async_payment_succeeded if you accept bank debits or other delayed methods | Stripe's fulfillment guide names these two. The session is the object that links to your cart, your customer, and the line items. |
| Checkout Sessions in subscription mode | checkout.session.completed for the first purchase, invoice.paid for every billing period | The session tells you which of your users subscribed. invoice.paid is the event Stripe's billing docs use to provision access, and it repeats on every renewal. |
| PaymentIntents with Elements or your own form, no Checkout | payment_intent.succeeded | No Checkout Session exists, so the PaymentIntent is the top-level object. Stripe's payment events guide names this event for successful payments. |
| Invoices you send outside a subscription | invoice.paid | It also fires when you mark an invoice paid outside Stripe, a case that never sends payment_intent.succeeded or charge.succeeded. |
One event per job. If a second event from the same payment feeds a second job, such as a ledger entry from payment_intent.succeeded, keep that handler away from fulfillment.
What one Checkout payment sends
Checkout is built on Stripe's other APIs. In Stripe's own reply on its api-discuss mailing list, a one-time payment "will create and use a PaymentIntent under the hood", and a recurring one creates a Subscription that creates Invoices and PaymentIntents for you. Each object reports its own success, so one purchase produces one success event per layer:
| Stripe object | Events it sends | What they report |
|---|---|---|
| Checkout Session | checkout.session.completed | The buyer finished Checkout. payment_status says whether the money has arrived. |
| PaymentIntent | payment_intent.created, payment_intent.succeeded | The payment Checkout created on your behalf reached succeeded. |
| Charge | charge.succeeded | One attempt against the card went through. |
| Subscription and Invoice, in subscription mode | customer.subscription.created, invoice.created, invoice.finalized, invoice.paid, invoice.payment_succeeded | The subscription started and its first invoice was paid. |
A developer on Stack Overflow logged 14 events from a single subscription Checkout, five of which announce some kind of success: checkout.session.completed, charge.succeeded, invoice.paid, invoice.payment_succeeded, and payment_intent.succeeded. The same reply from Stripe tells Checkout users which layer to read: "you really would only care about the Events associated with your Checkout Session".
checkout.session.completed vs payment_intent.succeeded
checkout.session.completed | payment_intent.succeeded | |
|---|---|---|
| Fires for | Payments made through Checkout and Payment Links only | Every PaymentIntent on the account: Checkout, Elements, subscription renewals, invoices |
| Means the money arrived | Only when payment_status is not unpaid | Yes |
| Bank debits and other delayed methods | Fires right away with payment_status: unpaid. Success comes later as checkout.session.async_payment_succeeded | Fires later, when the funds clear |
| Free trial or a $0 total | Fires | Never fires. No PaymentIntent is created |
| What was bought | Retrieve the session with line_items expanded | Find the session by PaymentIntent ID first, then expand its line items |
| Your metadata | The metadata you set on the session | Only what you passed in payment_intent_data.metadata |
Scope decides most cases. A handler on payment_intent.succeeded runs for every PaymentIntent on the account, so the day you add subscriptions or a second product, your Checkout fulfillment code starts receiving payments it was never written for. Line items and metadata are where handlers lose the cart: Stripe webhook line items covers reading products, quantities, and metadata from either event.
Stripe webhook events arrive out of order
Stripe's webhook docs are direct about it: "Stripe doesn't guarantee the delivery of events in the order that they're generated." The created timestamp does not rescue you, because it is recorded in seconds and the events from one payment usually share it. The same docs say not to use created to determine event order.
Order observed in testing is not a contract either. On one Stack Overflow question a commenter reports that checkout.session.completed fires first and an answer reports that payment_intent.succeeded does, each from watching their own integration. On another, a developer who stores the customer on checkout.session.completed and grants access on invoice.paid asks how to make the first arrive before the second. Nothing does. Each handler has to work alone:
- Treat the event as a signal and fetch the object before acting. The API returns current state, so a late event cannot move your records backward.
- When a handler needs a record another event creates, fetch that record from Stripe instead of waiting. Stripe's docs suggest the same: retrieve the invoice, charge, and subscription from
invoice.paidif it reaches you first. - Never infer state from sequence. "I have not seen
payment_intent.succeededyet" says nothing about whether the payment succeeded.
How two listeners fulfill one order twice
Listening to both events "to be safe" is the usual route to a duplicate shipment. Stripe documents two ways to catch duplicates: log the event IDs you have processed, and, for the cases where Stripe generates two Event objects for one change, compare the ID of the object in data.object together with event.type. Neither catches this one. checkout.session.completed and payment_intent.succeeded have different event IDs, different types, and different objects, a cs_ ID and a pi_ ID. Every check passes and the fulfillment code runs twice, often concurrently, since Stripe sends both as the payment completes.
The success page is the third caller. Stripe recommends triggering fulfillment from your landing page as well as the webhook, and says the function "might be called multiple times, possibly concurrently, for the same Checkout Session." So the key that makes fulfillment happen once has to be yours, and it has to be the same key from every entry point. For Checkout that is the session ID, enforced by the database:
const event = stripe.webhooks.constructEvent(rawBody, signature, secret);
const FULFILL_ON = new Set([
"checkout.session.completed",
"checkout.session.async_payment_succeeded",
]);
if (!FULFILL_ON.has(event.type)) return res.sendStatus(200);
// Both events carry the same session, so the session ID is the key.
// Read current state: the payload in hand may be older than the truth.
const session = await stripe.checkout.sessions.retrieve(event.data.object.id);
if (session.payment_status === "unpaid") return res.sendStatus(200);
await db.tx(async (t) => {
// The unique constraint on session_id stops the second fulfillment,
// whether it comes from a retry, the other event type, or the
// success page calling the same function.
const first = await t.result(
`INSERT INTO fulfillments (session_id) VALUES ($1)
ON CONFLICT DO NOTHING`,
[session.id],
);
if (first.rowCount === 0) return;
await fulfill(t, session);
});
res.sendStatus(200);If a handler must start from payment_intent.succeeded, resolve the PaymentIntent to its session first and use the same key. Two keys for one sale is the bug.
On Flint there is one event to pick. order.paid means the order is paid, on every payment surface, with the line items in the payload. See the handler or get free API keys and run it against a sandbox.
Questions, answered
Should I listen to checkout.session.completed or payment_intent.succeeded?
If the payment goes through Stripe Checkout or a Payment Link, listen to checkout.session.completed, and add checkout.session.async_payment_succeeded if you accept bank debits or other delayed payment methods. Stripe's fulfillment guide and its staff on the api-discuss mailing list both recommend the session events for Checkout. Use payment_intent.succeeded when you confirm PaymentIntents yourself with Elements or a custom form, because no Checkout Session exists there.
Should I listen to both events to be safe?
Not for the same job. Both events describe the same payment, they carry different event IDs and different object IDs, and deduplicating by event ID lets both through, so the order is fulfilled twice. Pick one event per job. If you need the PaymentIntent event for a ledger, let it write the ledger and nothing else.
Which fires first, checkout.session.completed or payment_intent.succeeded?
Either. Stripe does not guarantee that events are delivered in the order they were generated, and developers on Stack Overflow report both orders for the same flow. The created timestamp is recorded in seconds, so two events from one payment often share it. Write each handler so it works no matter which event lands first, and fetch the object from the API when you need current state.
Does checkout.session.completed mean the payment succeeded?
For cards, yes. For bank debits and other delayed payment methods, the event fires when the buyer finishes Checkout, with payment_status set to unpaid, and the result arrives later as checkout.session.async_payment_succeeded or checkout.session.async_payment_failed. Check payment_status before you fulfill.
Do I need to handle charge.succeeded?
Not for fulfillment. A Charge is one attempt inside a PaymentIntent, so charge.succeeded repeats what payment_intent.succeeded already told you, and it knows nothing about the Checkout Session. A free trial or a $0 invoice creates no Charge at all. Keep charge events for refund and dispute handling.
Which Stripe webhook event should I use for subscriptions?
Use invoice.paid to grant and extend access: Stripe's billing docs say to provision when you receive it and the subscription status is active, and it fires on the first payment and on every renewal. Use checkout.session.completed once, on the first purchase, to record which of your users owns the Stripe customer. After that, invoice.paid is the only event you need for access.
Why did my Stripe webhook fulfill the same order twice?
There are three common causes. Stripe retried a delivery your endpoint answered too slowly. Your success page and your webhook both ran the fulfillment function. Or the handler fulfills from two event types that describe one payment. One fix covers all three: a unique constraint on the Checkout Session ID, written in the same database transaction as the fulfillment.
One event to trust on Flint
Flint is a payments API built around orders. The order exists before any payment does, with its line items, and every way of collecting money attaches to it: hosted checkout, a payment link, an invoice, or your own payment form. That gives the webhook feed one event that answers the fulfillment question. order.paid fires when the order becomes fully paid, whichever surface collected the money, and its data carries what you need to act:
{
"order_id": "ord_1kmn0aExample",
"status": "closed",
"payment_status": "paid",
"total_money": { "amount": 16400, "currency": "USD" },
"paid_money": { "amount": 16400, "currency": "USD" },
"outstanding_money": { "amount": 0, "currency": "USD" },
"order_payment_intent_ids": ["pi_1kmn0aExample"],
"line_items": [
{
"order_line_item_id": "li_1kmn0aExample",
"name": "Trail Runner 2",
"quantity": 1,
"unit_price_money": { "amount": 12800, "currency": "USD" },
"total_money": { "amount": 12800, "currency": "USD" }
},
{
"order_line_item_id": "li_1kmn0bExample",
"name": "Merino socks",
"quantity": 2,
"unit_price_money": { "amount": 1800, "currency": "USD" },
"total_money": { "amount": 3600, "currency": "USD" }
}
]
}A bank debit that is still processing has not paid the order, so order.paid has not fired, and there is no payment_status check to remember and no second event to wire up for delayed methods. The whole handler:
const { event } = flint.verifyWebhook(rawBody, headers, [
process.env.FLINT_WEBHOOK_SECRET!,
]);
if (event.event_type !== "order.paid") return new Response(null, { status: 200 });
const order = event.data;
await db.tx(async (t) => {
const first = await t.result(
`INSERT INTO fulfillments (order_id) VALUES ($1)
ON CONFLICT DO NOTHING`,
[order.order_id],
);
if (first.rowCount === 0) return;
await fulfill(t, order.line_items);
});
return new Response(null, { status: 200 });| Stripe Checkout handler | Flint order.paid handler | |
|---|---|---|
| Events to subscribe to | Two for Checkout, invoice.paid for subscriptions, payment_intent.succeeded for your own form | order.paid |
| Bank debits and other delayed methods | Check payment_status, then handle a second event when the funds clear | order.paid fires once, when the order is fully paid |
| What was bought | A second API request from inside the handler | line_items in the event |
| Fulfillment key | A session ID, or a PaymentIntent ID you map back to one | order_id, the same on every payment surface |
| Duplicate deliveries | Event IDs differ between event types, so you add your own key | webhook-id is identical on every retry and resend |
| Signature check | Stripe-Signature, verified with Stripe's library | Standard Webhooks headers, verified with the Flint SDK or any Standard Webhooks library |
Payment-level events are still there when a job calls for them: payment_intent.succeeded for a ledger, subscription.payment_succeeded for entitlements, invoice.paid for accounts receivable. Choosing webhook events maps each job to its event, Webhooks covers signatures, retries, and resends, and Migrating from Stripe moves one payment flow at a time, so you can start with the handler that hurts most.
