Skip to content

Stripe Webhook Line Items: Why They're Missing and the Fix

checkout.session.completed never includes line_items. How to fetch them, find lost metadata, and map payment_intent.succeeded back to the products.
Webhooks13 min readBy Flint Pay, Product & API Team

Your checkout.session.completed handler fires. event.data.object has amount_total, the buyer's email, and a payment_status. It doesn't say what was bought, so the code that ships the box or unlocks the course has nothing to work from.

Nothing is broken. Stripe leaves line items out of every webhook event, and you read them with a second request inside the handler:

TypeScript
const session = await stripe.checkout.sessions.retrieve(event.data.object.id, {
  expand: ["line_items"],
});

That one call covers a small cart of fixed prices. It doesn't cover carts with more than a handful of items, the payment_intent.succeeded event, metadata that seems to vanish, or the cost of the extra request under load. Each has its own fix below, followed by the other design: a paid event that already carries the order and its line items.

Which call you need, by event#

Event you handleWhat data.object holdsRequests to get products and quantities
checkout.session.completedThe Checkout Session, without line_items1: retrieve the session with line_items expanded, or list its line items
checkout.session.async_payment_succeededThe same session, now paid1, same as above
payment_intent.succeeded from a Checkout paymentThe PaymentIntent2: find the session by payment_intent, then list its line items
payment_intent.succeeded from your own Elements formThe PaymentIntentYour own database, or 1 to expand amount_details.line_items if you sent them
v1.checkout.session.completed as a thin eventThe session's ID only1 or more: fetch the session, then its line items

Why checkout.session.completed has no line items#

line_items is what Stripe calls an includable property: the API leaves it out unless you ask. From Stripe's guide to expanding responses:

One example is the Checkout Session's line_items property, which is only included in responses if requested using the expand parameter

A webhook has no way to ask. The same guide:

You can't receive webhook events with properties auto-expanded. Objects sent in events are always in their minimal form. To access nested values in expandable properties, you must retrieve the object in a separate call within your webhook handler.

Stripe, Expanding responses guide

No endpoint setting changes this, and Stripe's newer event format moves further in the same direction. Stripe now recommends thin events for new integrations. A thin event carries the event type, its ID, a timestamp, and the ID of the related object, and Stripe's event list says: "During processing, you must fetch the versioned event from the API or fetch the resource's current state." v1.checkout.session.completed is on that list. Whichever format you pick, the handler that fulfills an order makes at least one more request before it knows what to fulfill.

Get the line items from checkout.session.completed#

Stripe's fulfillment guide puts the retrieve inside one fulfillment function that both the webhook and your success page call. The function checks payment_status first, because a bank debit completes the session before the money arrives:

TypeScript
// app/api/stripe/webhook/route.ts
import Stripe from "stripe";

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

export async function POST(request: Request) {
  const event = stripe.webhooks.constructEvent(
    await request.text(),
    request.headers.get("stripe-signature")!,
    process.env.STRIPE_WEBHOOK_SECRET!,
  );

  if (
    event.type === "checkout.session.completed" ||
    event.type === "checkout.session.async_payment_succeeded"
  ) {
    await fulfillCheckout(event.data.object.id);
  }

  return new Response(null, { status: 200 });
}

async function fulfillCheckout(sessionId: string) {
  const session = await stripe.checkout.sessions.retrieve(sessionId, {
    expand: ["line_items"],
  });

  if (session.payment_status === "unpaid") return; // bank debit still processing

  for (const item of session.line_items?.data ?? []) {
    await reserve(item.price?.id, item.quantity);
  }
}

Carts with more than a handful of items#

The expanded property is not the whole cart. Stripe's API reference describes it as "an includable line_items property containing the first handful of those items", and the line items endpoint returns 10 per page by default, 100 at most. A payment-mode session accepts up to 100 line items, so a wholesale or catering cart can outgrow the expanded list. The fulfillment guide's advice is to use auto-pagination on the line items endpoint:

TypeScript
const items = stripe.checkout.sessions.listLineItems(sessionId, { limit: 100 });

for await (const item of items) {
  await reserve(item.price?.id, item.quantity);
}

If you only ever expand line_items, the bug appears on the first large order: some items ship and the rest don't, with no error anywhere.

Get products and quantities from payment_intent.succeeded#

A PaymentIntent holds the amount, the currency, and the payment method. When Checkout created it, the products are one object away, on the session, so the handler has to find the session first:

TypeScript
const paymentIntent = event.data.object as Stripe.PaymentIntent;

const { data: sessions } = await stripe.checkout.sessions.list({
  payment_intent: paymentIntent.id,
  limit: 1,
});

const items = stripe.checkout.sessions.listLineItems(sessions[0].id, { limit: 100 });

That is two requests before fulfillment starts. Handling checkout.session.completed and checkout.session.async_payment_succeeded instead saves one of them. Which Stripe webhook event should you trust? compares the Checkout and PaymentIntent events for fulfillment.

If you create PaymentIntents yourself and collect the card with Elements, there is no session. Stripe's payment line items (amount_details.line_items) send product names and quantities to card networks, Klarna, and PayPal, and follow the same rule: "Line items aren't included by default in the API response." Otherwise the cart is wherever you saved it before creating the intent, found through an ID you put in metadata.

Where your metadata went#

Creating a Checkout Session accepts metadata in four places, and each one lands on a different object. Stripe's metadata guide states the rule: "An object's metadata doesn't automatically copy to related objects."

You setIt's stored onYou read it from
metadataThe Checkout Sessionevent.data.object.metadata in checkout.session.completed
payment_intent_data.metadataThe PaymentIntentpayment_intent.succeeded, or the retrieved PaymentIntent
line_items[].metadataThe line itemThe session retrieved with line_items expanded
line_items[].price_data.product_data.metadataThe ProductThe session retrieved with line_items.data.price.product expanded

Only the first row arrives in the session event. The others explain most reports of metadata going missing:

  • stripe-node #1991, "metadata empty when retrieving a Checkout Session's line items": the ID was set through product_data.metadata and read from the line item's Price. It was on the Product, which wasn't expanded.
  • stripe-php #1117, "How Do I Pass Metadata via Create Checkout Session?": metadata nested inside price_data, then looked for on the session. Stripe's reply was that it had been attached to the Price created for the session.
  • A 2020 thread on Stripe's api-discuss list, where the answer was that line items didn't support metadata and the workaround was to expand the Product. line_items[].metadata exists now, but reading it still takes the retrieve.

A SKU you set through product_data comes back like this:

TypeScript
const session = await stripe.checkout.sessions.retrieve(sessionId, {
  expand: ["line_items.data.price.product"],
});

for (const item of session.line_items?.data ?? []) {
  const product = item.price?.product as Stripe.Product;
  await reserve(product.metadata.sku, item.quantity);
}

That expand string is four properties long, and Stripe caps expansion at four levels. Anything hanging off the Product is another request.

To join the session back to your own cart, use client_reference_id (up to 200 characters, sent in the session event) or an ID in the session's metadata.

Don't put the cart in metadata#

The shortcut around the second request is to write the cart into the session's metadata:

TypeScript
metadata: { cart: JSON.stringify(cart) },

Stripe allows 50 keys, 40 characters per key, and 500 characters per value. One compact cart line costs 80 of those:

JSON
{"id":"sku_trail_runner_2","qty":1,"size":"10","color":"blue","gift_wrap":false}

Six lines fit in one value at 487 characters. A seventh makes it 568, and Stripe rejects the request that creates the session. The buyer with the biggest cart is the one who can't check out. Splitting the cart across keys only moves the limit.

Stripe's guidance for anything larger:

If your system requires more space than this, store your data in your external database and use a key-value pair to store the external object's ID in metadata.

Stripe, Metadata guide

Follow it and you write a pending order before checkout, join it back by ID in the webhook, and clean up the carts that never paid. That's an orders table, built because the payment API doesn't send the order back to you.

What the second request costs#

The retrieve runs inside the one function Stripe tells you to harden the most. The fulfillment guide warns that it "might be called multiple times, possibly concurrently, for the same Checkout Session", because the webhook and the success page both call it.

  • It slows the buyer's redirect. When a webhook endpoint listens for checkout.session.completed and the session has a success_url, Checkout waits up to 10 seconds for your response before redirecting. A slow retrieve is a buyer watching a spinner.
  • It can fail. If Stripe's API times out mid-handler, you can return an error and wait for a retry, or return 200 and lose the order. Only the first is safe.
  • It shares a rate limit with every other read. Stripe's rate limits are 100 requests per second account-wide in live mode, 25 in a sandbox, and 25 per endpoint unless noted. In a flash sale, every completed checkout spends at least one read on the same retrieve endpoint, alongside your success pages. Stripe also names list requests and expansions as the usual cause of concurrency limiting, because they take longer to run.
  • It needs its own idempotency. Two concurrent calls each retrieve the session and each try to fulfill. The insert that makes this safe is in Next.js and Stripe: What the Tutorials Leave Out.

A Stripe line items handler, checked#

CheckWhat correct looks like
Eventscheckout.session.completed and checkout.session.async_payment_succeeded; handle async_payment_failed too
Paid statusSkip fulfillment while payment_status is unpaid
Line itemslistLineItems with auto-pagination, not only the expanded list
Product dataline_items.data.price.product expanded when you need Product metadata
Cart joinclient_reference_id or a metadata ID pointing at your own pending order
ConcurrencyOne fulfillment function, guarded by a unique constraint on the session ID
Response timeRespond fast; queue anything slower than the retrieve

A webhook that arrives with the line items#

The second request exists because Stripe's payment objects don't own the cart. Flint is a payments API built around orders: the order exists before any payment, with its line items, and payments settle against it. The event that reports payment carries that order.

Every Flint webhook event sends one resource in data.object, as its GET returns it. order.paid carries the order, line items included, so the handler reads what was bought from the event itself.

1. Create the order#

Sell from your catalog by sending a variant_id, and Flint copies the name, price, SKU, and chosen options onto the line. Anything not in the catalog goes in as an ad hoc line with a name, a price, and a fulfillment requirement. Each line takes its own metadata:

cURL
curl -X POST https://api.withflintpay.com/v1/orders \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Idempotency-Key: order-cart-8841" \
  -d '{
    "external_reference_id": "cart_8841",
    "line_items": [
      { "type": "variant", "variant_id": "var_1kmn0aExample", "quantity": 1 },
      { "type": "variant", "variant_id": "var_1kmn0bExample", "quantity": 2 },
      {
        "type": "custom",
        "name": "Laser engraving",
        "quantity": 1,
        "unit_price_money": { "amount": 1500, "currency": "USD" },
        "fulfillment": { "requirement": "none" },
        "metadata": { "engraving_text": "SAM / BOSTON 2027" }
      }
    ]
  }'

Catalog lines take their price from the catalog, Flint computes the totals, and an order holds up to 500 lines.

2. Open hosted checkout against it#

The session collects the order's outstanding balance, so you don't send the items or the amount again:

cURL
curl -X POST https://api.withflintpay.com/v1/checkout-sessions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Idempotency-Key: checkout-cart-8841" \
  -d '{
    "type": "order",
    "order_id": "ord_1kmn0aExample",
    "redirects": { "success_url": "https://example.com/thanks" }
  }'

Send the buyer to data.url. Hosted checkout runs on Flint's domain, so Apple Pay and Google Pay work there with no setup.

3. Read the line items from order.paid#

When the order is paid in full, order.paid arrives with the order in data.object. Abbreviated:

Response
{
  "webhook_event_id": "whev_1kmn0aExample",
  "event_type": "order.paid",
  "payload_version": 2,
  "api_version": "2026-09-07",
  "mode": "test",
  "data": {
    "object": {
      "order_id": "ord_1kmn0aExample",
      "external_reference_id": "cart_8841",
      "status": "closed",
      "closed_reason": "paid",
      "payment_status": "paid",
      "payment_intent_ids": ["pi_1kmn0aExample"],
      "pricing_amounts": {
        "total_money": { "amount": 17900, "currency": "USD" }
      },
      "line_items": [
        {
          "order_line_item_id": "li_1kmn0aExample",
          "type": "variant",
          "product_id": "prod_1kmn0aExample",
          "variant_id": "var_1kmn0aExample",
          "name": "Trail Runner 2 - Blue / 10",
          "sku": "TR2-BLU-10",
          "selected_options": [
            { "option_id": "opt_1kmn0aExample", "option_name": "Color",
              "option_value_id": "optv_1kmn0aExample", "value": "Blue" },
            { "option_id": "opt_1kmn0bExample", "option_name": "Size",
              "option_value_id": "optv_1kmn0bExample", "value": "10" }
          ],
          "quantity": 1,
          "unit_price_money": { "amount": 12800, "currency": "USD" },
          "total_money": { "amount": 12800, "currency": "USD" },
          "refunded_quantity": 0
        },
        {
          "order_line_item_id": "li_1kmn0bExample",
          "variant_id": "var_1kmn0bExample",
          "name": "Merino socks - Gray",
          "sku": "SOCK-MER-GRY",
          "quantity": 2,
          "unit_price_money": { "amount": 1800, "currency": "USD" },
          "total_money": { "amount": 3600, "currency": "USD" },
          "refunded_quantity": 0
        },
        {
          "order_line_item_id": "li_1kmn0cExample",
          "name": "Laser engraving",
          "quantity": 1,
          "unit_price_money": { "amount": 1500, "currency": "USD" },
          "total_money": { "amount": 1500, "currency": "USD" },
          "metadata": { "engraving_text": "SAM / BOSTON 2027" },
          "refunded_quantity": 0
        }
      ]
    }
  }
}

Everything the Stripe handler had to fetch, expand, or join is on the line: sku and selected_options for the warehouse, variant_id for stock, your own metadata for anything custom, and external_reference_id to find your cart. Line item metadata values hold up to 4,096 characters, so an engraving, a gift note, or a configurator's output fits on the line it belongs to.

This shared-secret verifier requires an endpoint with hmac enabled. New endpoints default to JWT and return no shared secret. Create an HMAC endpoint with this request, then store data.secret as FLINT_WEBHOOK_SECRET:

cURL
curl -X POST https://api.withflintpay.com/v1/webhook-endpoints \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: webhook-hmac-001" \
  -d '{
    "url": "https://example.com/api/flint/webhook",
    "enabled_events": ["order.paid"],
    "signing_methods": ["hmac"]
  }'

For an existing endpoint without a secret, issue one and add HMAC, or use JWT verification with Flint's public JWKS.

With the Node SDK, the handler verifies the raw body and switches on event_type, and the event is typed by that type:

TypeScript
// 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());
  const { event, known } = flint.verifyWebhook(rawBody, request.headers, [
    process.env.FLINT_WEBHOOK_SECRET!,
  ]);

  if (known && event.event_type === "order.paid") {
    const order = event.data.object;
    // A unique constraint on webhook_event_id makes a redelivery a no-op.
    await jobs.enqueue("fulfill-order", {
      eventId: event.webhook_event_id,
      orderId: order.order_id,
      lines: order.line_items,
    });
  }

  return new Response(null, { status: 200 });
}

The handler makes no API call before it queues the work.

What changes for your handler#

Line items in the paid eventStripe CheckoutNo; retrieve or list themFlintYes, in data.object.line_items
Requests before fulfillmentStripe Checkout1 from the session event, 2 from payment_intent.succeededFlint0
Large cartsStripe CheckoutPaginate the line items endpoint (100 lines per session)FlintEvery line is in the event (500 lines per order)
Your data per lineStripe Checkoutline_items[].metadata, readable after a retrieveFlintmetadata on the line, in the event
Room for your dataStripe Checkout500 characters per valueFlint4,096 characters per value
Bank debit still processingStripe CheckoutSession completes as unpaid; check before fulfillingFlintorder.paid doesn't fire until the money arrives
Events to subscribe to for fulfillmentStripe CheckoutTwo Checkout events, or the PaymentIntent event plus lookupsFlintorder.paid

One event for every way an order gets paid#

order.paid fires once the order is paid in full, whether the buyer used hosted checkout, a payment link, an invoice, or a payment you took through the API. One handler covers all of them. A bank debit that's still processing hasn't paid the order, so there's no payment_status check to forget.

Flint's delivery rules are the ones a careful Stripe handler already follows: delivery is at least once and in no guaranteed order, a 2xx within 10 seconds counts as delivered, and a failed delivery retries up to 9 attempts over 71 to 86 hours. Every retry carries the same webhook_event_id, which is your deduplication key. The object is a snapshot from when Flint prepared the event. A paid order is closed, and its line items can't change after that, but refunds and returns still can. If one could land between payment and shipping, read GET /v1/orders/{order_id} before you ship and check refunded_quantity on each line.

Fulfill from order.paid. payment_intent.succeeded, order.paid, and checkout_session.paid all fire for a hosted payment, but only order.paid means the whole order is paid; a session can collect one installment of a larger balance. Choosing the right event explains which job each one fits.

Test it before you write the handler#

  • flint listen --forward-to http://localhost:3000/api/flint/webhook streams your sandbox's events to a local server with real signatures, so you don't need a tunnel. See the CLI guide.
  • POST /v1/webhook-endpoints/{webhook_endpoint_id}/test-events with {"event_type": "order.paid"} sends a signed example order to a registered endpoint. See Testing.
  • The OpenAPI document types every event: event_type is narrowed to its one value and data.object references the resource schema, so generated clients get a typed order. The event catalog lists every type with an example.

Sign up for a free sandbox. Get sandbox keys, follow the first payment guide, and watch order.paid arrive with the order and its line items. You can also run flint signup in your terminal. Read the first payment guide.

The sandbox is free, and live card payments cost 3.79% + 35¢ with no monthly fee. If you already run on Stripe, Migrating from Stripe maps each Stripe object to its Flint equivalent so you can move one flow at a time, and Why Flint is orders-first walks through the order model.

FAQ#

Does checkout.session.completed include line items?#

No. Stripe sends every object in webhook events in its minimal form, and line_items only appears when you request it with expand. Retrieve the session with expand: ["line_items"], or call listLineItems with auto-pagination for carts that might hold more than a handful of items.

How do I get line items from payment_intent.succeeded?#

If Checkout created the PaymentIntent, list Checkout Sessions filtered by payment_intent, then list that session's line items. That's two requests, so fulfilling from checkout.session.completed is simpler. If you created the PaymentIntent yourself, the cart lives in your database, joined by an ID in the PaymentIntent's metadata.

Why is my Stripe line item metadata empty?#

Usually because it's on a different object. product_data.metadata is stored on the Product, so read it from line_items.data.price.product with that path expanded. price_data metadata stays on the Price, and session-level metadata is the only metadata that arrives in the event itself.

Can I store the whole cart in Stripe metadata?#

Only a small one. Each metadata value holds 500 characters, which fits about six compact cart lines. Past that, Stripe rejects the session, and its documentation recommends storing the data in your own database and keeping only the ID in metadata.

Why does the expanded line_items list stop early?#

The expanded property holds the first page of line items, and the line items endpoint returns 10 per page by default. Use stripe.checkout.sessions.listLineItems(sessionId, { limit: 100 }) with for await so the SDK fetches every page.

Which payment API sends line items in the webhook?#

Flint does. Its order.paid event carries the full order in data.object, including each line's name, quantity, price, SKU, selected options, and metadata, and it fires for checkout, payment links, invoices, and API payments.

Sources#

Everything attributed to Stripe above comes from its documentation and public issue trackers, checked on 2026-10-24.

Flint behavior comes from the Webhooks guide, the event payload reference, and the Orders API reference.