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:
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 handle | What data.object holds | Requests to get products and quantities |
|---|---|---|
checkout.session.completed | The Checkout Session, without line_items | 1: retrieve the session with line_items expanded, or list its line items |
checkout.session.async_payment_succeeded | The same session, now paid | 1, same as above |
payment_intent.succeeded from a Checkout payment | The PaymentIntent | 2: find the session by payment_intent, then list its line items |
payment_intent.succeeded from your own Elements form | The PaymentIntent | Your own database, or 1 to expand amount_details.line_items if you sent them |
v1.checkout.session.completed as a thin event | The session's ID only | 1 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
expandparameter
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.
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:
// 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:
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:
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 set | It's stored on | You read it from |
|---|---|---|
metadata | The Checkout Session | event.data.object.metadata in checkout.session.completed |
payment_intent_data.metadata | The PaymentIntent | payment_intent.succeeded, or the retrieved PaymentIntent |
line_items[].metadata | The line item | The session retrieved with line_items expanded |
line_items[].price_data.product_data.metadata | The Product | The 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.metadataand 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[].metadataexists now, but reading it still takes the retrieve.
A SKU you set through product_data comes back like this:
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:
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:
{"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
IDinmetadata.
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.completedand the session has asuccess_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#
| Check | What correct looks like |
|---|---|
| Events | checkout.session.completed and checkout.session.async_payment_succeeded; handle async_payment_failed too |
| Paid status | Skip fulfillment while payment_status is unpaid |
| Line items | listLineItems with auto-pagination, not only the expanded list |
| Product data | line_items.data.price.product expanded when you need Product metadata |
| Cart join | client_reference_id or a metadata ID pointing at your own pending order |
| Concurrency | One fulfillment function, guarded by a unique constraint on the session ID |
| Response time | Respond 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 -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 -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:
{
"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 -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:
// 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#
| Stripe Checkout | Flint | |
|---|---|---|
| Line items in the paid event | Stripe CheckoutNo; retrieve or list them | FlintYes, in data.object.line_items |
| Requests before fulfillment | Stripe Checkout1 from the session event, 2 from payment_intent.succeeded | Flint0 |
| Large carts | Stripe CheckoutPaginate the line items endpoint (100 lines per session) | FlintEvery line is in the event (500 lines per order) |
| Your data per line | Stripe Checkoutline_items[].metadata, readable after a retrieve | Flintmetadata on the line, in the event |
| Room for your data | Stripe Checkout500 characters per value | Flint4,096 characters per value |
| Bank debit still processing | Stripe CheckoutSession completes as unpaid; check before fulfilling | Flintorder.paid doesn't fire until the money arrives |
| Events to subscribe to for fulfillment | Stripe CheckoutTwo Checkout events, or the PaymentIntent event plus lookups | Flintorder.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/webhookstreams 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-eventswith{"event_type": "order.paid"}sends a signed example order to a registered endpoint. See Testing.- The OpenAPI document types every event:
event_typeis narrowed to its one value anddata.objectreferences 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.
- Expanding responses: includable properties, events always sent in minimal form, the four-level expansion limit.
- How events work and thin event types: thin events carry the related object's ID, are recommended for new integrations, and require fetching the resource;
v1.checkout.session.completedis available as one. - Fulfill orders: the fulfillment function, retrieving the session with line items expanded, the
payment_statuscheck, concurrent calls, auto-pagination for many line items, the 10-second redirect wait, and fulfillment from the landing page. - Retrieve a Checkout Session's line items: "the first handful" of items on the session, and the 10 default and 100 maximum page size.
- Create a Checkout Session: the 100 line item maximum in payment mode, the four metadata parameters, and
client_reference_id. - List all Checkout Sessions: the
payment_intentfilter. - Metadata: the key, value, and count limits, the external database guidance, and metadata not copying between objects.
- Payment line items:
amount_details.line_itemson PaymentIntents, not included by default. - Rate limits: global and per-endpoint request limits, and expansions in concurrency limits.
- GitHub: stripe-node #1991 and stripe-php #1117.
- Stripe api-discuss: Line_item metadata set in stripe.checkout.sessions.create not returned in webhook.
Flint behavior comes from the Webhooks guide, the event payload reference, and the Orders API reference.
