Skip to content

Which Stripe webhook event should you trust?

For Stripe Checkout and Payment Links, fulfill from checkout.session.completed, add checkout.session.async_payment_succeeded if you accept bank debits, skip sessions whose payment_status is unpaid, and record each fulfillment against the session ID. Don't also fulfill from payment_intent.succeeded: it describes the same payment, it can arrive first, and a handler that acts on both ships the order twice. On Flint, one event, order.paid, covers every way of getting paid.
Verified against official documentationReviewed Send a correction (opens in a new tab)

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 paymentFulfill fromWhy
Checkout Sessions or Payment Links, one-time paymentscheckout.session.completed, plus checkout.session.async_payment_succeeded if you accept ACH Direct Debit or other delayed methodsStripe'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 modecheckout.session.completed once, then invoice.paid for every billing periodThe 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 formpayment_intent.succeededNo Checkout Session exists, so the PaymentIntent is the top-level object.
Invoices you send outside a subscriptioninvoice.paidIt 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 objectEvents it sendsWhat they report
Checkout Sessioncheckout.session.completedThe buyer finished Checkout. payment_status says whether the money arrived.
PaymentIntentpayment_intent.created, payment_intent.succeededThe payment Checkout created for you reached succeeded.
Chargecharge.succeededOne attempt against the payment method went through.
Subscription and Invoice, in subscription modecustomer.subscription.created, invoice.created, invoice.finalized, invoice.paid, invoice.payment_succeededThe 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.completedpayment_intent.succeeded
Fires forCheckout and Payment Links onlyEvery PaymentIntent on the account: Checkout, Elements, subscription renewals, invoices
Means the money arrivedOnly when payment_status is not unpaidYes
ACH Direct Debit and other delayed methodsFires when the buyer finishes, with payment_status: unpaid. Success arrives later as checkout.session.async_payment_succeededFires later, when the funds clear
Free trial or a $0 totalFires, with payment_status: no_payment_requiredNever fires: no PaymentIntent is created
What was boughtRetrieve the session with line_items expandedFind the session by PaymentIntent ID first, then expand its line items
Your metadataThe metadata and client_reference_id you set on the sessionOnly 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:

One card payment, four calls that can each run fulfillmentResponse
BrowserStripeYour serverBuyer pays on Checkoutcheckout.session.completed (cs_...)payment_intent.succeeded (pi_...)Redirect to success_urlGET /thanks?session_id=cs_...Retry of checkout.session.completed
  1. Browser sends Buyer pays on Checkout to Stripe
  2. Stripe sends checkout.session.completed (cs_...) to Your server
  3. Stripe sends payment_intent.succeeded (pi_...) to Your server
  4. Stripe returns Redirect to success_url to Browser
  5. Browser sends GET /thanks?session_id=cs_... to Your server
  6. Stripe sends Retry of checkout.session.completed to Your server

Each one has a different cause and the same fix. Match yours by its symptom:

  1. Two event types describe one payment#

    One card payment produced two shipments, and the duplicate check on event IDs passed both times.

    checkout.session.completed and payment_intent.succeeded have different event IDs, different types, and different objects: a cs_ ID and a pi_ ID. Stripe's two duplicate checks, logged event IDs and the data.object ID with event.type, both let the pair through. Both arrive as the payment completes, so the two handlers can run at the same time.

    Confirm it

    Find the two fulfillment records. If one was keyed by the session ID and the other by a PaymentIntent ID, read the session: its payment_intent field names the same pi_ ID.

    Fix

    Fulfill from the session events only. If another job needs payment_intent.succeeded, such as a ledger, let it write the ledger and nothing else.

  2. Stripe delivered the same event again#

    The same evt_ ID appears twice in your logs, minutes or hours apart.

    A delivery that times out or gets a non-2xx answer is retried for up to three days in live mode, even when your handler did the work before it failed. Stripe also says that in some cases it generates and sends two separate Event objects, which an event ID log can't match.

    Confirm it

    Open the endpoint in Workbench and look at its Event deliveries tab. More than one attempt for one event, or two events with the same data.object ID and type, confirms it.

    Fix

    Record the fulfillment against the session ID, in the same database transaction as the work it guards, and answer with a 2xx before any slow step.

  3. The success page and the webhook both ran fulfillment#

    The duplicate fulfillments are a second or two apart, and one came from your landing page route.

    Stripe's fulfillment guide recommends triggering fulfillment from your landing page as well as the webhook, because webhooks can be delayed. Checkout waits up to 10 seconds for your webhook response before redirecting, so a slow handler and the buyer's page load overlap.

    Confirm it

    Compare the timestamps and callers on the two records. Two different entry points with one session ID is this cause.

    Fix

    Call one function from both places, keyed by the session ID, and let a unique constraint decide which call does the work.

  4. A bank debit was fulfilled at completion and again when it cleared#

    An ACH order shipped the day it was placed and shipped again days later.

    For delayed methods, checkout.session.completed fires with payment_status: unpaid, and checkout.session.async_payment_succeeded follows when the funds clear. A handler that skips the status check fulfills on both, and fulfills the first time before any money arrived.

    Confirm it

    Compare the first fulfillment's time with the checkout.session.async_payment_succeeded event's time. A fulfillment before that event acted on an unpaid session.

    Fix

    Skip sessions whose payment_status is unpaid, and key both events by the session ID.

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:

JavaScript
// 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:

  • order.paid
    The order became fully paid, whatever collected the money. Sent once per order.
    Fulfilling orders
  • checkout_session.paid
    A checkout session collected its full amount due, which can be one installment of a larger order balance.
    Session context, such as which redirect flow was paid
  • payment_intent.succeeded
    One payment succeeded, including a capture.
    Ledgers and reconciliation
  • balance_transaction.created
    A charge, refund, fee, payout, or adjustment moved your balance.
    Money movement

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
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"
  }'
JSON
{
  "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):

JSON
{
  "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:

TypeScript
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 CheckoutFlint
Events that fulfillTwo for Checkout, invoice.paid for subscriptions, payment_intent.succeeded for your own formorder.paid, whether a hosted checkout, payment link, invoice, or direct API payment collected the money
Delayed methods such as ACH debitCheck payment_status, then handle a second event when the funds clearorder.paid fires when the debit settles, and not before
What was boughtAn API request from inside the handler, with line_items expandedline_items in the event
Fulfillment keyThe session ID, with PaymentIntent IDs mapped back to itorder_id, the same on every surface
Success pageAdd the {CHECKOUT_SESSION_ID} placeholder to success_urlFlint adds order_id and checkout_session_id to success_url
Duplicate deliveriesLog event IDs, and also compare data.object ID and type for the duplicates Stripe sends as separate eventsOne order.paid per order. Retries and resends carry the same webhook_event_id
Signature checkStripe-Signature, an HMAC with your whsec_ secretA 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.