← All articles

checkout.session.completed vs payment_intent.succeeded: Which Stripe Event to Fulfil On

Webhooker Team 13 min read
Flat illustration of Stripe events as envelopes flowing into the blue Webhooker pulse node, which forks into a shipped parcel with a check mark and an hourglass with a calendar for a delayed bank payment.

If you sell through Stripe Checkout or Payment Links, fulfil on checkout.session.completed and checkout.session.async_payment_succeeded, and only when the session’s payment_status is not unpaid. If you build your own payment form with PaymentIntents and the Payment Element, there is no Checkout Session, so fulfil on payment_intent.succeeded. Do not listen to both for the same order: they describe the same payment from two different objects, and a handler that acts on both will ship twice.

The wrong choice usually passes every card test and breaks later, in production: a SEPA Direct Debit order that ships before the money arrives, a 100% coupon that never fires payment_intent.succeeded, a subscription that fulfils once from Checkout and again from the first invoice. All Stripe behaviour below links to docs.stripe.com, checked on 9 October 2026.

The two events side by side

checkout.session.completedpayment_intent.succeeded
data.objectA Checkout SessionA PaymentIntent
Fires whenThe customer finishes CheckoutFunds for a PaymentIntent are confirmed
Means you have the moneyOnly if payment_status is paidYes
Delayed methods (SEPA Direct Debit, Bacs)Fires with payment_status: "unpaid"; outcome follows in checkout.session.async_payment_succeeded or async_payment_failedFires only when the debit succeeds, after payment_intent.processing
Orders with nothing to chargeFires, payment_status is no_payment_required or paidNever fires: no money moved
SubscriptionsFires once, when the subscription is createdFires for each invoice paid by a PaymentIntent, not only from Checkout
Line items, customer email, addressSession has customer_details; line items via expandNeither; only amount_received, customer and metadata
Your session metadataPresentNot copied over unless set in payment_intent_data.metadata
ScopeOnly payments made through CheckoutEvery PaymentIntent on the account: Checkout, Payment Element, invoices, Terminal, other apps
Checkout waits for your responseYes, up to 10 seconds before redirectingNo

People tend to miss the Scope row. payment_intent.succeeded is account-wide. If another service on the same Stripe account takes payments, or Billing charges an invoice, your Checkout fulfilment handler receives those too.

What checkout.session.completed tells you

checkout.session.completed fires when the customer submits Checkout successfully. Stripe chose its words carefully for the session’s status: "complete": “The checkout session is complete. Payment processing may still be in progress” (Checkout Session object). Whether you have been paid lives in a separate field, payment_status:

A trimmed event for a card payment looks like this:

{
  "id": "evt_1Q8...",
  "type": "checkout.session.completed",
  "data": {
    "object": {
      "id": "cs_live_a1B2...",
      "object": "checkout.session",
      "mode": "payment",
      "status": "complete",
      "payment_status": "paid",
      "payment_intent": "pi_3Q8...",
      "amount_total": 4900,
      "currency": "eur",
      "client_reference_id": "cart_8812",
      "customer_details": {
        "email": "anna@example.de",
        "address": { "country": "DE", "postal_code": "10115" }
      },
      "metadata": { "order_id": "ord_5531" }
    }
  }
}

In payment mode the session’s payment_intent is null until the customer actually pays: Checkout creates the PaymentIntent when the session is confirmed, not when you create the session. Reading payment_intent right after checkout.sessions.create and getting null is expected, not a bug.

Two things are missing from that payload on purpose. Line items are not returned by default, so you fetch them with expand: ["line_items"] or the line items endpoint. And the session in the event is a snapshot from when the event was created. Stripe’s fulfilment guide therefore has you take only the session ID from the event and retrieve the session fresh before deciding anything.

What payment_intent.succeeded tells you

payment_intent.succeeded fires when a PaymentIntent reaches succeeded, which Stripe defines as the point where you are guaranteed the funds (payment notification). It is the most direct “money arrived” signal Stripe sends, which is why so many handlers start there.

The problem is what it does not carry. The PaymentIntent knows the amount, the currency, the payment method and the customer ID if there is one. It does not know which products were in the cart, the shipping address the customer typed into Checkout, the client_reference_id, or the metadata you put on the Checkout Session. Session metadata is not copied to the PaymentIntent; you have to pass it separately as payment_intent_data.metadata when you create the session. Handlers built on payment_intent.succeeded for Checkout payments usually end up calling the list Checkout Sessions endpoint with payment_intent=pi_... to find the session again, which is the long way round to the event they could have listened to.

If you do not use Checkout at all, none of this matters. With the Payment Element and your own PaymentIntents, you created the intent, you put the order ID in its metadata, and payment_intent.succeeded (plus payment_intent.payment_failed) is the right pair.

Why payment_status is unpaid: SEPA and other delayed methods

Cards confirm in seconds, so for card payments checkout.session.completed and payment_intent.succeeded arrive at nearly the same time and both say “paid”. European checkouts are different. SEPA Direct Debit, Bacs Direct Debit in the UK and bank transfers are delayed notification methods: the customer authorises the debit, Checkout completes, and the outcome is known only days later.

For a SEPA Direct Debit order, the events look like this:

day 0   checkout.session.completed                payment_status: "unpaid"
day 0   payment_intent.processing
day 2–5 payment_intent.succeeded                  (or payment_intent.payment_failed)
day 2–5 checkout.session.async_payment_succeeded  payment_status: "paid"
        (or checkout.session.async_payment_failed)

A handler that ships on checkout.session.completed without reading payment_status ships every SEPA order on day 0, including the ones whose debit later bounces. Switching to payment_intent.succeeded does not fix it. Reading the field does: fulfil when payment_status is not unpaid, and route checkout.session.async_payment_succeeded into the same fulfilment function. Redirect-based methods such as iDEAL and Bancontact behave like cards here: the customer approves the payment at their bank, and the session completes as paid.

checkout.session.async_payment_failed deserves a handler too. It is the moment to email the customer, cancel the reservation and release stock, because nobody else will tell them the debit failed.

Subscriptions: one Checkout, many PaymentIntents

In subscription mode, Checkout creates a Customer and a Subscription, and the session’s payment_intent field stays empty; that field is only for sessions in payment mode. The first payment belongs to the subscription’s first invoice. Each later renewal is another invoice, and each invoice paid by card or debit produces its own payment_intent.succeeded. checkout.session.completed does not fire again on renewals: the customer only went through Checkout once.

So payment_intent.succeeded cannot tell a new subscription from its twelfth renewal without extra lookups, and if you listen to it next to checkout.session.completed, the first month is processed twice. Use the events that match the object you care about:

The Stripe webhooks guide has the full table of events per integration type.

Free orders never fire payment_intent.succeeded

When Checkout has nothing to charge, it does not need a successful PaymentIntent, so there is none to report. A trial that starts without a payment, a session with a billing cycle anchor and no proration, or a payment-mode session that collects no payment method because the total is zero (payment_method_collection: "if_required") all complete without a payment_intent.succeeded.

If your fulfilment listens only to payment_intent.succeeded, those customers get nothing: the giveaway, the 100% launch coupon and the free tier are exactly the orders that silently stall. checkout.session.completed fires for all of them, with payment_status set to paid or no_payment_required, and the “not unpaid” rule handles them correctly.

Which event to use: a decision table

Your integrationFulfil onAlso handle
Checkout or Payment Links, one-time, cards onlycheckout.session.completed with payment_status checkcheckout.session.expired to release reserved stock
Checkout or Payment Links with SEPA, Bacs or bank transferscheckout.session.completed and checkout.session.async_payment_succeededcheckout.session.async_payment_failed
Checkout in subscription modecheckout.session.completed to provisioninvoice.paid, invoice.payment_failed, customer.subscription.*
Payment Element or your own PaymentIntentspayment_intent.succeededpayment_intent.payment_failed, payment_intent.processing
Payments made in several ways on one accountThe event of each flow, and ignore the othersFilter by metadata so each handler sees only its own payments

charge.succeeded is not in the table on purpose. It is the older Charges-era event; a PaymentIntent creates a Charge under the hood, so it also fires, but payment_intent.succeeded replaced it as the event to build on.

A handler that fulfils exactly once

Stripe delivers events at least once and does not guarantee their order. checkout.session.async_payment_succeeded can arrive before you finished processing checkout.session.completed, a retry can deliver the same event twice, and the customer’s browser can hit your success page while the webhook is still in flight. The pattern that survives all of that is the one from Stripe’s fulfilment guide: one idempotent function keyed by the session ID, called from every entry point.

import express from "express";
import Stripe from "stripe";

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

const fulfilmentEvents = new Set([
  "checkout.session.completed",
  "checkout.session.async_payment_succeeded",
]);

app.post(
  "/webhooks/stripe",
  express.raw({ type: "application/json" }),
  async (request, response) => {
    let event;
    try {
      event = stripe.webhooks.constructEvent(
        request.body,
        request.headers["stripe-signature"],
        process.env.STRIPE_WEBHOOK_SECRET,
      );
    } catch {
      return response.sendStatus(400);
    }

    if (fulfilmentEvents.has(event.type)) {
      await queue.add("fulfil-checkout", { sessionId: event.data.object.id });
    } else if (event.type === "checkout.session.async_payment_failed") {
      await queue.add("payment-failed", { sessionId: event.data.object.id });
    }

    response.sendStatus(200);
  },
);

// The queue worker and the success page both call this.
async function fulfilCheckout(sessionId) {
  const session = await stripe.checkout.sessions.retrieve(sessionId, {
    expand: ["line_items"],
  });
  if (session.payment_status === "unpaid") return;

  const claimed = await database.query(
    `INSERT INTO fulfilments (checkout_session_id) VALUES ($1)
     ON CONFLICT (checkout_session_id) DO NOTHING
     RETURNING checkout_session_id`,
    [sessionId],
  );
  if (claimed.rowCount === 0) return;

  await shipLineItems(session.line_items.data, session.customer_details);
}

A few things in there are deliberate.

Testing both paths

stripe listen --forward-to localhost:3000/webhooks/stripe forwards sandbox events to your machine, and stripe trigger checkout.session.completed sends a quick card-shaped event. Fixtures do not exercise the delayed path, though. For that, complete a real sandbox Checkout with SEPA Direct Debit enabled and a test IBAN such as AT611904300234573201: you will see checkout.session.completed with payment_status: "unpaid" first and checkout.session.async_payment_succeeded shortly after. The how to test webhooks guide covers replaying captured payloads and sending deliberately broken ones.

Routing Stripe Checkout webhook events with a gateway

On a busy Stripe account the event stream mixes several flows: Checkout orders, subscription invoices, payments from another product, refunds. Teams usually end up with one large handler and a growing switch, or with several Stripe endpoints that each verify signatures and each have to be up whenever Stripe sends.

A webhook gateway such as Webhooker sits in front instead. Stripe sends everything to one ingest URL (https://app.webhooker.eu/in/<token>); Webhooker verifies the Stripe-Signature with your whsec_ secret, stores the event and answers 200 straight away, so Checkout’s 10-second wait never depends on your fulfilment service. Filters on the body then decide which destination gets what: type in checkout.session.completed, checkout.session.async_payment_succeeded, checkout.session.async_payment_failed goes to the fulfilment service, invoice.* to billing, everything to analytics.

Each destination gets retries with exponential backoff, a dead-letter queue and replay, plus a stable X-Webhooker-Event-Id header to deduplicate on. Checkout events include customer names, emails and addresses, which makes them personal data under GDPR; Webhooker processes and stores them in the EU. The free plan covers 10,000 events a month.

Frequently asked questions

Should I use checkout.session.completed or payment_intent.succeeded?

Use checkout.session.completed for Checkout. For Stripe Checkout and Payment Links, use checkout.session.completed plus checkout.session.async_payment_succeeded, and fulfil only when payment_status is not unpaid. The session carries line items, customer details and your metadata. Use payment_intent.succeeded when you create PaymentIntents yourself, for example with the Payment Element, and there is no Checkout Session.

Does checkout.session.completed mean the payment succeeded?

Not always. checkout.session.completed means the customer finished Checkout. For cards and redirect methods like iDEAL the session is paid at that point. For delayed methods such as SEPA Direct Debit, Bacs Direct Debit or bank transfers it is unpaid, and the result arrives later as checkout.session.async_payment_succeeded or checkout.session.async_payment_failed.

Do both events fire for the same Checkout payment?

Yes, in payment mode with an amount due, Checkout creates a PaymentIntent, so both checkout.session.completed and payment_intent.succeeded are sent. They are not guaranteed to arrive in a particular order. Pick one as your fulfilment trigger, or you will fulfil the order twice.

Why is my checkout.session.completed payment_status unpaid?

checkout.session.completed arrives with payment_status: "unpaid" when the customer paid with a delayed notification method, most often SEPA Direct Debit in Europe. The funds are not confirmed yet. Keep the order pending and fulfil when checkout.session.async_payment_succeeded arrives; handle checkout.session.async_payment_failed to cancel it.

Why doesn’t payment_intent.succeeded fire for some Checkout orders?

payment_intent.succeeded only fires when money moves. Free trials, sessions with nothing due and zero-total orders complete without a successful PaymentIntent. checkout.session.completed still fires for them with payment_status set to paid or no_payment_required.

How do I get the line items from a Stripe webhook?

A checkout.session.completed event does not include line items. Retrieve the Checkout Session with expand: ["line_items"], or call the list line items endpoint for the session and paginate if there are many. A PaymentIntent has no line items at all, which is another reason to fulfil Checkout orders from the session event.

What is checkout.session.async_payment_succeeded?

checkout.session.async_payment_succeeded is the event Stripe sends when a delayed payment method, such as SEPA Direct Debit, Bacs Direct Debit or a bank transfer, finally succeeds after Checkout completed. The session’s payment_status changes from unpaid to paid, and that is the moment to fulfil the order. Its counterpart, checkout.session.async_payment_failed, tells you the debit failed.

Does checkout.session.completed fire on subscription renewals?

No. checkout.session.completed fires once, when the customer completes Checkout and the subscription is created. Renewals are invoices, so use invoice.paid to extend access each period and invoice.payment_failed to start dunning.

Why is payment_intent null on my Checkout Session?

In payment mode, Checkout creates the PaymentIntent only when the customer confirms the payment, so the field is null on a freshly created session. In subscription and setup mode it stays null for good, because the payment belongs to the subscription’s invoice or there is no payment at all.