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
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 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 actually holds inventory.
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 actually 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.
The five places the counter lies
What a working stock system has to do
The recipe grows toward the same list every time. A stock system that holds up in the busiest hour has to:
- Hold stock before money moves, with a deadline that enforces itself rather than trusting a sweep job.
- Give a claim everything it asked for or nothing, across items and locations, so a split cart cannot half succeed.
- Survive retries, so the same command applied twice changes nothing.
- Tell apart what is on the shelf, what carts are holding, what paid orders own, and what is damaged or set aside, because one number cannot answer both the storefront's question and the stockroom's.
- Keep a ledger of every movement, so any count can be explained.
- Restock from what physically came back and its condition, not from a refund's side effect.
- Say when demand has outrun stock instead of going quietly negative.
All of it can be built next to Stripe, and teams do build it. It is the traps above turned into infrastructure, plus the audit trail, maintained through every schema change and every traffic spike from here on.
Questions, answered
Does Stripe track inventory?
No. Products and Prices carry no stock quantity, Checkout and Payment Links do not read or decrement a count, and no Stripe object records how many units you have. Stock lives in a system you run, and Stripe learns nothing about it.
Can Stripe Checkout limit how many of an item are sold?
Not against stock. Adjustable quantity caps how many units one session may take, and a Payment Link can deactivate after a set number of completed sessions. Both are configuration values; neither consults a count, so overselling across sessions stays possible.
What happened to the Stripe Orders API and SKU inventory?
Stripe deprecated the Orders API in October 2019 because it could not support Strong Customer Authentication, and SDK support ended afterward. SKU objects and their inventory types went with it, with no replacement inside Stripe, so a tutorial that sets sku.inventory describes an API you can no longer call.
How do I prevent overselling with Stripe?
Keep the count in your own database, hold stock when a checkout starts, decrement with a single guarded update on checkout.session.completed, deduplicate by event ID, and release holds on expiry with a sweep for missed events. Stripe enforces none of it, so the guarantee is exactly as strong as the weakest of those parts.
Can I store stock quantities in Stripe metadata?
You can write a number there, and nothing will read or enforce it. Checkout does not consult metadata before selling, and concurrent updates overwrite each other whole, which disqualifies it as a counter. It works as a label pointing at the system that holds the real count.
Does Stripe restock inventory when I refund a payment?
No. A refund moves money only. Whether the unit returns to sellable stock is a separate decision your inventory system records, ideally based on the condition the item came back in rather than on the refund itself.
Can Stripe sync inventory with Shopify, Square, or my warehouse system?
There is nothing to sync. Stripe holds no count, so platforms that process payments through Stripe keep stock on their own side. Connecting a storefront to a warehouse means integrating those two systems directly; Stripe carries the payment either way.
Does Stripe send low stock alerts?
No. There is no stock for Stripe to watch. Alerts have to come from the system that holds the count, either as a threshold you check yourself or as an event from an inventory API that fires when demand outruns stock.
Inventory on Flint
Flint is a payments API where stock is part of the payment. Levels are tracked per item, per location, and stock is held by claims with deadlines rather than a counter you decrement and hope about. A claim takes everything it asked for or fails before any money moves. Stocking a shelf is recording what happened, never writing a number directly:
curl -X POST https://api.withflintpay.com/v1/inventory-adjustments \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: receiving-1842" \
-H "Content-Type: application/json" \
-d '{
"reason": "received_stock",
"occurred_at": "2026-08-08T14:18:00Z",
"source_system": { "type": "wms", "external_source_id": "east-coast-wms" },
"lines": [{
"inventory_item_id": "invi_1kmn0aExample",
"location_id": "loc_1kmn0aExample",
"on_hand_quantity_delta": 25
}]
}'
The Idempotency-Key header is required on every command that moves quantity, so the retry that already landed changes nothing, and the response carries the resulting levels, so there is no follow-up read. When a checkout starts, claim the stock:
curl -X POST https://api.withflintpay.com/v1/inventory-reservations \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: hold-cart-1842" \
-H "Content-Type: application/json" \
-d '{
"type": "standalone",
"owner": { "type": "merchant", "key": "cart_1842", "expires_at": "2026-08-08T14:33:00Z" },
"inventory_routing_source": { "type": "fixed_location", "location_id": "loc_1kmn0aExample" },
"demands": [{
"demand_key": "cart_line_1",
"inventory_item_id": "invi_1kmn0aExample",
"quantity": 2,
"splitting_behavior": "single_location"
}]
}'
The deadline is required and can be at most 15 minutes out, there is one active reservation per owner.key, and the claim is atomic: all of its demands land or none do. When two buyers reach for the last unit, the second claim fails with INVENTORY_INSUFFICIENT before a payment form renders, not after two charges. When the buyer reaches the payment step, extend the deadline once:
curl -X POST https://api.withflintpay.com/v1/inventory-reservations/invr_1kmn0aExample/start-payment-window \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: pay-window-cart-1842" \
-H "Content-Type: application/json" \
-d '{
"expected_inventory_reservation_revision": 2,
"payment_window_duration_seconds": 1800
}'
And when the payment succeeds, commit:
curl -X POST https://api.withflintpay.com/v1/inventory-reservations/invr_1kmn0aExample/commit \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: commit-cart-1842" \
-H "Content-Type: application/json" \
-d '{
"expected_inventory_reservation_revision": 3,
"lines": [{
"inventory_reservation_line_id": "invrln_1kmn0aExample",
"target_committed_quantity": 2
}]
}'
target_committed_quantity is cumulative, how much should be committed by now, so a retried commit succeeds and changes nothing, and expected_inventory_reservation_revision rejects a stale writer instead of silently overwriting. Those two parameters are the dedupe table and the guarded UPDATE from the recipe above, with nothing to build. When a deadline passes without payment, the hold releases itself and inventory.reservation.hold_expired says so; the sweep job has no equivalent here because there is nothing left for it to do.
Selling through Flint orders removes the orchestration too: paying an order places the hold, success commits it, failure releases it, and when stock cannot be served the payment fails with INVENTORY_UNAVAILABLE before money moves. For staying in sync, inventory.level.updated fires on every quantity change and inventory.shortage.detected when claims outrun physical stock.
| Stripe alone | Stripe plus your database | Flint |
|---|
| Where the count lives | Nowhere | A column you maintain | Levels per item, per location, derived from a movement ledger |
| Hold between cart and payment | None | A holds table plus a sweep job | A claim with a required deadline that releases itself |
| Two buyers, one unit left | Both pay | Depends on your locking | The second claim fails before payment |
| A retried event or request | Nothing to retry against | Your dedupe table | Idempotency keys required; targets are cumulative |
| Restocking a return | None | Manual, if someone remembers | Return receipts that record condition per unit |
| Several locations | None | Routing logic you write | Allocation policies route deterministically |
| Low stock signal | None | A query you poll | Level update and shortage detection events |
The inventory guide follows one unit from receiving through a paid order to shipment, and the Inventory and Locations references have every field on the requests above. The recipe on this page maps onto them call for call; the parts that disappear are the ones that were defending the gap.
Sources