The entire purchase surface of a Checkout Session is a price and a quantity:
curl https://api.stripe.com/v1/checkout/sessions \
-u "sk_test_...:" \
-d mode=payment \
-d "line_items[0][price]=price_..." \
-d "line_items[0][quantity]=2" \
--data-urlencode "success_url=https://example.com/thanks"The 2 is what the buyer is buying, not what you have. Nothing in this request, the Product behind it, or the account it runs on stores a count for that quantity to be checked against, and the session is created just as happily for the eleventh unit of ten. The Product object's closest brush with the physical world is shippable and package_dimensions; a stock field does not exist to set.
Where to start
| Where you are | Your path |
|---|---|
| You sell through Checkout or Payment Links and the stock count lives in a spreadsheet, or nowhere yet | Build the counter properly |
| A tutorial told you to create SKUs or set sku.inventory | That API is gone |
| You want a Payment Link to stop selling after a fixed number of sales | Payment limits, and what they miss |
| Your Payment Link kept taking payments after the last unit sold | Make a Payment Link say sold out |
| You already decrement on webhooks and oversold during a spike | The five race windows |
| You want the full list of what a stock system has to handle | The checklist |
| You would rather not build any of this | Stock checked at payment, on Flint |
What Stripe gives you instead
A handful of features come up in every search for Stripe stock management. Each is real and useful. None of them is a count.
A Payment Link can stop after N payments
The restrictions parameter deactivates a link after a specified number of completed sessions, and inactive_message is what later buyers see:
# Deactivates the link after 50 completed sessions, not 50 units
curl https://api.stripe.com/v1/payment_links \
-u "sk_test_...:" \
-d "line_items[0][price]=price_..." \
-d "line_items[0][quantity]=1" \
-d "restrictions[completed_sessions][limit]=50" \
--data-urlencode "inactive_message=Sold out."The limit counts sessions, not units, so with adjustable quantity enabled, 50 sessions can carry 500 units out the door. It is a fuse for a capped launch: it burns once, restocking means raising the number by hand, and a refund does not un-complete a session.
Making a Payment Link say sold out
A Payment Link has no sold out state. It has an inactive state and a message. Set inactive_message to your sold out wording when you create the link, then deactivate the link when your own count reaches zero:
# From your webhook handler, once your own count reaches zero
curl https://api.stripe.com/v1/payment_links/plink_... \
-u "sk_test_...:" \
-d active=falseBuyers who open the link afterward see your message. The deactivation runs only after a checkout.session.completed event reaches your handler, so any payment that completes between the last unit selling and your update is an oversell you refund. Stripe's developer blog says the same about Stripe's own store: "Payment Links do not natively allow for backend inventory checks to conditionally facilitate the payment based on stock availability."
Adjustable quantity caps one buyer, not your stock
# A per-session cap, read from this request, never from stock
-d "line_items[0][adjustable_quantity][enabled]=true" \
-d "line_items[0][adjustable_quantity][minimum]=1" \
-d "line_items[0][adjustable_quantity][maximum]=10"maximum defaults to 99 and accepts up to 999999. It bounds what one session may take, and it is read from the request that created the session, never from a count. Stripe's own docs assume the count is elsewhere: they advise reserving inventory for the session's maximum, in whatever system holds inventory.
Metadata stores a number and enforces nothing
Metadata on a Product will happily hold "stock": "14", and nothing will act on it. Checkout does not consult metadata before selling, and concurrent updates overwrite each other whole, which disqualifies it as a counter. It earns its keep as a label: a SKU, a bin number, a pointer into the system that does hold the count.
The API that did track stock is gone
Stripe used to track inventory. The Orders API's SKU objects carried an inventory attribute with three types: finite with a quantity Stripe decremented, bucket with in_stock, limited, or out_of_stock, and infinite. Stripe deprecated the Orders API in October 2019, when it could not support Strong Customer Authentication, SDK support ended over the releases that followed, and the SKU reference pages no longer resolve publicly. The tutorials built on them still rank, which is why a search for Stripe inventory returns instructions for an API you cannot call. A guide that sets sku.inventory predates 2020; the pattern that replaced it is the next section. The full history of both Orders APIs, including the 2022 beta, is at Does Stripe have an Orders API?
What Stripe's own team built
In December 2024, Stripe's developer blog described the stock system behind the developer relations team's conference swag store. The store sells through Payment Links, and the count lives outside Stripe. Stripe events go to Amazon EventBridge, a Lambda function decrements a DynamoDB table with a condition expression so the count cannot go negative, and the link is disabled once a product sells out. That is four AWS services (DynamoDB, EventBridge, Lambda, and IoT Core) running next to the payment to keep one number correct.
The post is direct about what the build cannot do. The stock check runs after the payment, so when the count is already zero, "we automatically initiate a refund for the transaction," and "issuing refunds in this way can lead to losses from non-refundable transaction fees collected by Stripe." The alternative it offers is to leave Payment Links for a custom flow that checks stock before creating a PaymentIntent, which leaves the chance of running out between the check and the payment "greatly reduced." Reduced is not closed, and the rest of the work is the build below.
The counter everyone builds
With no first-party count, the standard pattern keeps stock in your own database and moves it on Stripe's events. Built properly, it has four parts. First, check your count and create the session with the shortest expiry Stripe allows:
// Your database is the source of truth; Stripe never sees this check.
const available = await db.available("mug-12-white");
if (available < qty) return soldOut();
const session = await stripe.checkout.sessions.create({
mode: "payment",
line_items: [{ price: priceId, quantity: qty }],
// 30 minutes is the minimum Stripe allows and the right value here.
// The default is 24 hours, which is how long an abandoned cart
// would pin whatever you hold for it.
expires_at: Math.floor(Date.now() / 1000) + 30 * 60,
metadata: { sku: "mug-12-white", qty: String(qty) },
success_url: "https://example.com/thanks?session_id={CHECKOUT_SESSION_ID}",
});Second, decrement when the money is in, on checkout.session.completed, with the two safeguards that make it survive production:
// Inside the checkout.session.completed handler. Raw-body and
// signature handling are their own minefield; see the webhook
// signature reference under Related.
const { sku, qty } = session.metadata;
await db.tx(async (t) => {
// Delivery is at least once: remember the event ID in the same
// transaction as the decrement, or a retry sells the unit twice.
const dedupe = await t.result(
`INSERT INTO processed_events (event_id) VALUES ($1)
ON CONFLICT DO NOTHING`,
[event.id],
);
if (dedupe.rowCount === 0) return; // already handled
// One atomic statement with the guard inside it. Never SELECT
// the count and UPDATE the result; concurrent handlers interleave.
const row = await t.oneOrNone(
`UPDATE stock SET quantity = quantity - $1
WHERE sku = $2 AND quantity >= $1
RETURNING quantity`,
[Number(qty), sku],
);
// The buyer has already paid when this branch runs.
if (!row) await markOversold(t, session);
});Third, notice what that handler cannot do. It runs after the buyer paid, so when it finds the shelf empty, the options left are a refund or a backorder email. Everything that happens between the availability check and the charge, another buyer, a slow webhook, a retry, lands in that branch. Closing it takes a hold:
// The upgrade that prevents overselling: a hold, written
// before the buyer ever sees a payment form.
// 1. At session creation, in one transaction with the availability
// check: INSERT INTO holds (session_id, sku, qty, expires_at),
// where available = on hand - open holds - paid but unshipped.
// 2. On checkout.session.completed: delete the hold and decrement,
// inside the same transaction as the event dedupe.
// 3. On checkout.session.expired: delete the hold.
// 4. On a schedule, forever: delete holds past expires_at, because
// the expired event can be lost like any other delivery.Every line of this defends the same gap: the stretch between the buyer paying and your database finding out. The five ways the gap wins anyway are below, each with the mechanical fix.
