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 |
| 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 are choosing a payments API and want stock enforced at payment time | Inventory on Flint |
What Stripe gives you instead
Four 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.
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?
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.
