Flint blog

Integration

Stripe Webhook Line Items: What Did They Buy?

checkout.session.completed arrives without line items. Get product, quantity, and metadata with a second request, or receive them inside the event.

7 min readBy Flint Pay, Product & API Team

Your checkout.session.completed handler fires. event.data.object has an amount_total, a customer email, and a payment status. It does not 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:

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

That call covers a small cart of fixed prices. Product metadata, payment_intent.succeeded, and a cart that outgrows a metadata value each add another request or another table.

Why checkout.session.completed has no line items#

line_items is one of the properties Stripe calls includable. 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

Webhooks have no way to ask for it. 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.

No endpoint setting changes this. Every handler that fulfills an order makes the second call.

Get the line items: retrieve the session with expand#

Stripe's fulfillment guide puts the retrieve inside the function that does the work, and checks payment_status before acting on it:

TypeScript
// app/api/stripe/webhook/route.ts
async function fulfill(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);
  }
}

The expanded property is a partial list. Stripe's API reference describes it as "containing the first handful of those items", and the line items endpoint returns 10 per page unless you ask for more, up to 100. A payment-mode session can hold 100 line items, so a wholesale cart needs the list endpoint with auto-pagination:

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

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

From payment_intent.succeeded to product and quantity#

A PaymentIntent that Checkout created holds the amount, the currency, and the payment method. The products are one object away, on the session, so a payment_intent.succeeded handler looks the session up first:

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

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

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

That is two requests before fulfillment starts. Listening for checkout.session.completed and checkout.session.async_payment_succeeded saves one of them.

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

Why your metadata is missing in the webhook#

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 is 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 that has gone 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 item.price.metadata. It is on the Product, and the Product was not expanded.
  • stripe-php #1117, "How Do I Pass Metadata via Create Checkout Session?": metadata placed inside price_data, then looked for on the returned session.
  • A thread on Stripe's api-discuss list from 2020, where Stripe's reply was that line items did not support metadata and the answer was to expand the Product. Stripe's API reference has since added line_items[].metadata. Reading it still takes the retrieve.

Your own SKU 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. Anything hanging off the Product is another request.

One more object to keep straight: the session and your own cart. client_reference_id, or an ID in the session's metadata, is the join between them. Developers have been asking how to make that join since Checkout launched (stripe-node #563).

The 500-character metadata limit#

The shortcut around the second request is to put the cart in the session's metadata:

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

Stripe's limits are 50 keys, 40 characters per key, and 500 characters per value. A compact cart line costs about 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. The seventh line makes it 568, and Stripe rejects the request that creates the session. The buyer with the biggest cart is the one who cannot check out.

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.

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 is an orders table, designed around a payment API that does not send it back to you.

The second request runs inside a handler that runs twice#

The retrieve is a network call inside the one function Stripe tells you to harden the most. From the fulfillment guide, your function

might be called multiple times, possibly concurrently, for the same Checkout Session.

The webhook calls it. The success page calls it too, because Stripe recommends triggering fulfillment from both. Checkout waits up to 10 seconds for your webhook response before it redirects the buyer, so a slow retrieve is a buyer watching a spinner. A failed retrieve leaves two options: return an error and wait for Stripe's retry, or return 200 and lose the order.

The idempotent insert that makes concurrent calls safe is in Next.js and Stripe: What the Tutorials Leave Out.

A webhook that arrives with the line items#

Flint is a payments API built around orders. The order exists before the payment does, with its line items, and the event that reports payment carries them.

Create the order:

Bash
curl -X POST https://api.withflintpay.com/v1/orders \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -d '{
    "external_reference_id": "cart_8841",
    "line_items": [
      { "name": "Trail Runner 2", "quantity": 1,
        "unit_price_money": { "amount": 12800, "currency": "USD" } },
      { "name": "Merino socks", "quantity": 2,
        "unit_price_money": { "amount": 1800, "currency": "USD" } }
    ]
  }'

Open hosted checkout against it. The session collects the order's outstanding balance, so you do not send the amount or the items again:

Bash
curl -X POST https://api.withflintpay.com/v1/checkout-sessions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -d '{
    "order_id": "ord_1kmn0aExample",
    "redirects": { "success_redirect_url": "https://example.com/thanks" }
  }'

When the buyer pays, order.paid arrives with this in data:

JSON
{
  "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" },
      "subtotal_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" },
      "subtotal_money": { "amount": 3600, "currency": "USD" },
      "total_money": { "amount": 3600, "currency": "USD" }
    }
  ]
}

Your handler reads data.line_items and fulfills, with no second request inside the webhook.

Sell from your Flint catalog by passing a variant_id on the line, and each item in the event also carries product_id, variant_id, and the buyer's selected_options. The size and color are fields on the line item, so the cart never has to fit inside a metadata value. When you do want your own keys on the order, order metadata values hold 4,096 characters.

order.paid fires once the order is fully paid, whether the buyer used hosted checkout, your own payment form, or an invoice, so one handler covers all three. A bank debit that is still processing has not paid the order yet, which means there is no payment_status check to remember. Choosing webhook events covers which event fits which job, and Why Flint is orders-first walks through the order model end to end.

Get free API keys and run Accept your first payment to see order.paid land in your own handler. 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.

Sources#

Everything attributed to Stripe above comes from their documentation and public issue trackers, checked on 2026-09-18.

Next step

Get the line items in the webhook

Create an order, open checkout against it, and read what was bought from order.paid. Test keys are free and run against an isolated sandbox.