Reference

Does Stripe track inventory?

No. Stripe moves the money and stops there. A Product or Price has no quantity field, Checkout sells whatever it is asked to sell, and the API that once carried stock counts was retired years ago. The count lives in your database, and the webhook that decrements it arrives after the buyer has paid. The features that look like inventory but are not, the counter every team builds on webhooks, and the five places it breaks are below, along with what a system that cannot oversell has to do.

Verified against official documentation · last reviewed 2026-08-08 · corrections: support@withflintpay.com

The entire purchase surface of a Checkout Session is a price and a quantity:

Bash
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 areYour path
You sell through Checkout or Payment Links and the stock count lives in a spreadsheet, or nowhere yetBuild the counter properly
A tutorial told you to create SKUs or set sku.inventoryThat API is gone
You want a Payment Link to stop selling after a fixed number of salesPayment limits, and what they miss
You already decrement on webhooks and oversold during a spikeThe five race windows
You want the full list of what a stock system has to handleThe checklist
You are choosing a payments API and want stock enforced at payment timeInventory 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.

The restrictions parameter deactivates a link after a number of completed sessions, and inactive_message is what later buyers see:

Bash
# 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

Bash
# 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 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:

JavaScript
// 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:

JavaScript
// 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:

JavaScript
// 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

01

Nothing holds the unit between the cart and the charge

Two buyers checked out the last unit minutes apart, both cards were charged, and one order cannot ship.

Stripe accepts any payment for any quantity. Nothing reads a stock count when the session is created, while the buyer types a card number, or when the charge succeeds, so the first moment your system can object is after both charges have landed. The availability check before session creation narrows the window; it cannot close it, because ten buyers can pass the same check before any of them pays.

Confirm it

For one busy hour, count sessions created per SKU against the stock you had when the hour started. More sessions than stock means the guard has been luck.

Fix

Record a hold when you create the session and subtract open holds from availability, as in the sketch above. The objection moves from after the charge to before the payment form renders.

02

The decrement lands when the event arrives, not when the buyer pays

During a spike the storefront kept selling on a count that was minutes old.

checkout.session.completed is delivered asynchronously and in no guaranteed order. It runs seconds behind the charge on a good day, and if your endpoint errors or times out, Stripe retries with exponential backoff for up to three days. Your count is wrong for exactly as long as the event is late, and the event is latest when traffic is heaviest.

Confirm it

Compare charge times in the Dashboard with your decrement times for a launch hour. That gap is your overselling window.

Fix

You cannot make delivery synchronous. Enforce before payment with holds, so a late event delays bookkeeping instead of availability.

03

Read-then-write loses sales

Two handlers both read 5, both wrote 4, and the count has been wrong ever since.

Events for different sessions arrive concurrently. A handler that selects the count, subtracts in application code, and writes the result back interleaves with its twin under load, and the classic lost update follows. It never shows up in testing, because testing is one buyer.

Confirm it

Sum recorded sales per SKU against total decrements since your last physical count. A gap that grows during busy hours is this.

Fix

Decrement in one statement with the guard inside it, UPDATE stock SET quantity = quantity - $1 WHERE sku = $2 AND quantity >= $1, then check the affected row count. The database serializes what your code cannot.

04

The same sale can arrive twice

Stock drained faster than sales, and the missing units were never bought by anyone.

Delivery is at least once. Stripe retries whenever your endpoint responds slowly or with an error, and the webhook docs say plainly that endpoints might receive the same event more than once. A handler that decrements without remembering event IDs subtracts those sales twice.

Confirm it

Log processed event IDs for a week and count unique-constraint conflicts. Any duplicate that reached the decrement was a phantom sale.

Fix

Insert the event ID into a processed-events table with a unique constraint, in the same transaction as the decrement, and stop on conflict. Deduplication outside that transaction can still double-count across a crash.

05

Holds that never release sell you out with a full shelf

The site says sold out. The shelf disagrees.

Once you add holds, releasing them is your problem too. An abandoned session expires at expires_at, which defaults to 24 hours out, and the checkout.session.expired event that should trigger your release can be lost like any other delivery. Nothing else will free the units.

Confirm it

Query for holds older than your session window. Every row is stock you own and cannot sell.

Fix

All three together: create sessions with the 30 minute minimum expires_at, release on checkout.session.expired, and sweep on a schedule for the events that never came.

This machinery is the recipe's real price. None of it sells anything, and all of it has to hold up during your busiest minute of the year.

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:

Bash
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:

Bash
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:

Bash
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:

Bash
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 aloneStripe plus your databaseFlint
Where the count livesNowhereA column you maintainLevels per item, per location, derived from a movement ledger
Hold between cart and paymentNoneA holds table plus a sweep jobA claim with a required deadline that releases itself
Two buyers, one unit leftBoth payDepends on your lockingThe second claim fails before payment
A retried event or requestNothing to retry againstYour dedupe tableIdempotency keys required; targets are cumulative
Restocking a returnNoneManual, if someone remembersReturn receipts that record condition per unit
Several locationsNoneRouting logic you writeAllocation policies route deterministically
Low stock signalNoneA query you pollLevel 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