One successful Checkout payment sends your endpoint several events that each read like "it worked", and Stripe's event list doesn't rank them. In April 2026 a Hacker News commenter put it plainly: "there is no guidance on which events and states are authoritative or take precedence." The answer exists, spread across three Stripe docs pages and a mailing list reply from Stripe staff, and it depends on 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 ACH Direct Debit or other delayed methods | Stripe's fulfillment guide listens for these two. The session links the payment to your cart, your customer, and the line items. |
| Checkout Sessions in subscription mode | checkout.session.completed once, then invoice.paid for every billing period | The session tells you which of your users subscribed. invoice.paid repeats on every renewal, and Stripe's billing docs provision access from it once the subscription is active. |
| PaymentIntents with Elements or your own form | payment_intent.succeeded | No Checkout Session exists, so the PaymentIntent is the top-level object. |
| Invoices you send outside a subscription | invoice.paid | It also fires when you mark an invoice paid outside Stripe, which sends no payment_intent.succeeded or charge.succeeded. |
Use one event per job. If a second event from the same payment feeds a different job, such as a ledger entry from payment_intent.succeeded, keep that handler away from fulfillment.
What one Checkout payment sends
Checkout is built from Stripe's other objects. In a reply on Stripe's api-discuss mailing list, Stripe staff explain that "Our Checkout product is built on top of our other APIs": a one-time payment "will create and use a PaymentIntent under the hood", and subscription mode creates a Subscription, which creates Invoices and PaymentIntents. Each object reports its own success, so one purchase sends a 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 arrived. |
| PaymentIntent | payment_intent.created, payment_intent.succeeded | The payment Checkout created for you reached succeeded. |
| Charge | charge.succeeded | One attempt against the payment method 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 them announce some kind of success: checkout.session.completed, charge.succeeded, invoice.paid, invoice.payment_succeeded, and payment_intent.succeeded. The same Stripe reply says which layer a Checkout integration reads: "you really would only care about the Events associated with your Checkout Session overall."
checkout.session.completed vs payment_intent.succeeded
| checkout.session.completed | payment_intent.succeeded |
|---|
| Fires for | 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 |
| ACH Direct Debit and other delayed methods | Fires when the buyer finishes, with payment_status: unpaid. Success arrives later as checkout.session.async_payment_succeeded | Fires later, when the funds clear |
| Free trial or a $0 total | Fires, with payment_status: no_payment_required | 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 and client_reference_id you set on the session | Only what you passed in payment_intent_data.metadata |
Scope settles 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. Reading the cart is where handlers lose the most time: Stripe webhook line items covers products, quantities, and metadata from either event.
Stripe webhook events arrive out of order
Stripe's webhook docs say it directly: "Stripe doesn't guarantee the delivery of events in the order that they're generated." The created timestamp doesn't help, because it is recorded in seconds and events from one payment often share it. The same page says not to use created to order events or to decide whether you processed one.
Order observed in testing isn't a contract either. On one Stack Overflow question an answer reports that payment_intent.succeeded fires first and a comment reports that checkout.session.completed does. 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, so each handler has to work alone:
- Treat the event as a signal and retrieve the object before acting. The API returns current state, so a late event can't move your records backward.
- When a handler needs a record another event creates, fetch it from Stripe instead of waiting. Stripe's docs give the example of retrieving the invoice, charge, and subscription from
invoice.paid when it arrives first. - Never infer state from sequence. Not having seen
payment_intent.succeeded yet says nothing about whether the payment succeeded.
Why a Stripe Checkout order gets fulfilled twice
Stripe's fulfillment guide warns that your fulfillment function "might be called multiple times, possibly concurrently, for the same Checkout Session." For one card payment, these calls can all reach your server, in any order:
Each one has a different cause and the same fix. Match yours by its symptom:
Fulfill once, from every entry point
The key that makes fulfillment happen once has to be yours, and it has to be the same from every entry point. For Checkout that is the session ID, enforced by the database in the same transaction as the work:
// Express. express.raw() keeps the exact bytes the signature covers.
const FULFILL_ON = new Set([
"checkout.session.completed",
"checkout.session.async_payment_succeeded",
]);
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"],
process.env.STRIPE_WEBHOOK_SECRET,
);
} catch {
return res.sendStatus(400);
}
if (FULFILL_ON.has(event.type)) await fulfillCheckout(event.data.object.id);
res.sendStatus(200);
});
// success_url is https://example.com/thanks?session_id={CHECKOUT_SESSION_ID}
app.get("/thanks", async (req, res) => {
await fulfillCheckout(String(req.query.session_id));
res.render("thanks");
});
// One function, one key, every entry point.
async function fulfillCheckout(sessionId) {
// Read current state: the event in hand can be older than the truth.
const session = await stripe.checkout.sessions.retrieve(sessionId, {
expand: ["line_items"],
});
if (session.payment_status === "unpaid") return;
await db.tx(async (t) => {
// The unique constraint on session_id stops every second run: a retry,
// the other event, or the success page racing the webhook.
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.line_items.data);
});
}
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.
Questions, answered
Should I listen to checkout.session.completed or payment_intent.succeeded?
If the payment goes through Stripe Checkout or a Payment Link, fulfill from checkout.session.completed, and add checkout.session.async_payment_succeeded if you accept ACH Direct Debit or other delayed payment methods. Stripe's fulfillment guide and its staff on the api-discuss mailing list both point Checkout users to the session events. Use payment_intent.succeeded when you confirm PaymentIntents yourself with Elements or your own form, because no Checkout Session exists there.
Should I listen to both events to be safe?
Not for the same job. Both events describe one payment, with different event IDs and different object IDs, so deduplicating by event ID lets both through and 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 doesn't guarantee that events arrive 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 events from one payment often share it, and Stripe says not to use it for ordering. Write each handler so it works whichever event lands first, and fetch the object from the API when you need its current state.
Does checkout.session.completed mean the payment succeeded?
For cards, yes. For ACH Direct Debit and other delayed methods, the event fires when the buyer finishes Checkout, with payment_status set to unpaid, and the outcome arrives later as checkout.session.async_payment_succeeded or checkout.session.async_payment_failed. A $0 session or a free trial completes with payment_status no_payment_required. Fulfill when payment_status is not unpaid.
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 said, and it knows nothing about the Checkout Session. A free trial or a $0 invoice creates no Charge at all.
Which Stripe webhook event should I use for subscriptions?
Use invoice.paid to grant and extend access. It fires on the first payment and on every renewal, and Stripe's billing docs say to retrieve the subscription and confirm its status is active before extending access. Use checkout.session.completed once, on the first purchase, to record which of your users owns the Stripe customer.
Why did my Stripe webhook fulfill the same order twice?
Four causes cover most cases: the handler fulfills from two event types that describe one payment, Stripe delivered an event again after a slow or failed response, the success page and the webhook both ran fulfillment, or a bank debit was fulfilled at completion and again when it cleared. One fix covers all four: check payment_status, and record each fulfillment against the Checkout Session ID under a unique constraint, in the same database transaction as the work.
Which Flint webhook event should I fulfill from?
order.paid. Flint sends it once per order, when the order becomes fully paid, whether a hosted checkout, a payment link, an invoice, or a direct API payment collected the money. Its data.object is the order, with the line items, so the handler needs no API call before it acts.
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. So the event feed has one event for fulfillment, at the order level, and the payment-level events serve other jobs:
Subscribe an endpoint to order.paid. New endpoints sign deliveries with a Flint JWT, so there is no shared secret to store; keep the webhook_endpoint_id, which your verifier checks as the audience. To verify with a Standard Webhooks shared secret instead, send hmac in signing_methods.
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-endpoint-fulfillment-001" \
-d '{
"url": "https://example.com/webhooks/flint",
"enabled_events": ["order.paid"],
"description": "Fulfillment"
}'
{
"data": {
"webhook_endpoint_id": "whep_1kmn0aExample",
"url": "https://example.com/webhooks/flint",
"signing_methods": ["jwt"],
"enabled_events": ["order.paid"],
"description": "Fulfillment",
"api_version": "default",
"event_sources": ["merchant"],
"status": "active",
"version": 1,
"created_at": "2026-10-28T15:02:40Z",
"updated_at": "2026-10-28T15:02:40Z"
}
}
When the order becomes fully paid, the delivery's data.object is the order itself, with what you need to act on (abbreviated here):
{
"webhook_event_id": "whev_1kmn0aExample",
"event_type": "order.paid",
"payload_version": 2,
"api_version": "2026-09-07",
"mode": "live",
"merchant_id": "mer_1kmn0aExample",
"created_at": "2026-10-28T15:04:11Z",
"test": false,
"request": null,
"data": {
"object": {
"order_id": "ord_1kmn0aExample",
"order_number": "1042",
"origin": "checkout",
"external_reference_id": "cart_1842",
"status": "closed",
"closed_reason": "paid",
"payment_status": "paid",
"checkout_session_ids": ["cs_1kmn0aExample"],
"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" }
}
],
"pricing_amounts": { "total_money": { "amount": 16400, "currency": "USD" } },
"settlement_amounts": {
"paid_money": { "amount": 16400, "currency": "USD" },
"outstanding_money": { "amount": 0, "currency": "USD" }
}
}
}
}
A bank debit that is still processing hasn't paid the order, so order.paid waits until the debit settles. An order paid in parts sends order.partially_paid when the first part arrives and order.paid only when the balance is cleared. There is no payment_status check to remember and no second event to wire up. The whole integration, webhook and success page:
import express from "express";
import { Client, SdkError, type Order } from "@flintpay/node";
const flint = new Client({ apiKey: process.env.FLINT_API_KEY! });
const app = express();
// Raw body on this route only: the signature covers the exact bytes.
app.post("/webhooks/flint", express.raw({ type: "application/json" }), async (req, res) => {
let verified;
try {
verified = await flint.verifyWebhookAsync(req.body, req.headers, {
endpointIds: [process.env.FLINT_WEBHOOK_ENDPOINT_ID!], // whep_ from the create response
});
} catch (error) {
const keysUnavailable =
error instanceof SdkError && error.code === "webhook_jwks_unavailable";
return res.sendStatus(keysUnavailable ? 503 : 400); // 503 asks Flint to retry
}
if (verified.known && verified.event.event_type === "order.paid") {
await fulfillOrder(verified.event.data.object);
}
res.sendStatus(200);
});
// Flint adds order_id and checkout_session_id to your success_url.
app.get("/thanks", async (req, res) => {
const order = await flint.orders.get(String(req.query.order_id));
if (order.payment_status === "paid") await fulfillOrder(order);
res.render("thanks", { order });
});
async function fulfillOrder(order: Order) {
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);
});
}
The same order_id reaches the handler from both entry points, so one unique constraint covers retries, resends, and the success page racing the webhook. Retries and resends also carry the same webhook_event_id. Delivery succeeds on a 2xx within 10 seconds, so move slow steps, such as buying a shipping label, to a queue.
Stripe Checkout and Flint fulfillment handlers, side by side
| Stripe Checkout | Flint |
|---|
| Events that fulfill | Two for Checkout, invoice.paid for subscriptions, payment_intent.succeeded for your own form | order.paid, whether a hosted checkout, payment link, invoice, or direct API payment collected the money |
| Delayed methods such as ACH debit | Check payment_status, then handle a second event when the funds clear | order.paid fires when the debit settles, and not before |
| What was bought | An API request from inside the handler, with line_items expanded | line_items in the event |
| Fulfillment key | The session ID, with PaymentIntent IDs mapped back to it | order_id, the same on every surface |
| Success page | Add the {CHECKOUT_SESSION_ID} placeholder to success_url | Flint adds order_id and checkout_session_id to success_url |
| Duplicate deliveries | Log event IDs, and also compare data.object ID and type for the duplicates Stripe sends as separate events | One order.paid per order. Retries and resends carry the same webhook_event_id |
| Signature check | Stripe-Signature, an HMAC with your whsec_ secret | A Flint-signed JWT checked against Flint's public keys, so there is no shared secret to store. Add hmac to signing_methods for Standard Webhooks HMAC instead |
Choosing the right event maps refunds, disputes, invoices, payouts, and subscriptions to their events, and Migrating from Stripe moves one payment flow at a time, so you can start with the handler that hurts most.
Sources
All sources accessed October 28, 2026.