Stripe inventory management: how to prevent overselling

Stripe does not track inventory. A Product or Price has no quantity field, Checkout and Payment Links sell whatever quantity they are asked to sell, and a Checkout session stays open for 30 minutes to 24 hours with nothing holding the unit. Preventing overselling on Stripe means keeping the count in your own database, holding stock when a session starts, and releasing it when the session expires or the expiry event goes missing. On Flint, the stock check is part of the payment: a sold out item fails before the card is charged.
Verified against official documentationReviewed Send a correction (opens in a new tab)

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

cURL
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
Your Payment Link kept taking payments after the last unit soldMake a Payment Link say sold out
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 would rather not build any of thisStock 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.

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

Shell
# 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.

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:

Shell
# From your webhook handler, once your own count reaches zero
curl https://api.stripe.com/v1/payment_links/plink_... \
  -u "sk_test_...:" \
  -d active=false

Buyers 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

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

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

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

How do I show a Stripe Payment Link as sold out?

Set inactive_message on the link to your sold out wording, then deactivate the link by updating it with active=false when your own count reaches zero. Stripe can also deactivate a link after a set number of completed sessions, but that limit counts sessions, not units, and Stripe never checks a stock count for you.

How long does a Stripe Checkout session stay open?

24 hours by default. expires_at accepts anywhere from 30 minutes to 24 hours after the session is created. If you hold stock for a session, the hold has to last as long as the session can, so set the 30 minute minimum on anything with limited stock.

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.

On Flint, the stock check is part of the payment

Flint is a payments API with inventory built in. Stock is tracked per item and per location. Paying for a tracked item places a hold, commits it when the payment succeeds, and releases it when the payment fails. When the last unit is gone, the next payment fails with INVENTORY_UNAVAILABLE before the card is charged. You do not need a reservation table, expiry handler, or sweep job. A payment link for a tracked product is one request:

cURL
curl -X POST https://api.withflintpay.com/v1/payment-links \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Idempotency-Key: payment-link-ceramic-mug" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Ceramic mug",
    "line_items": [{
      "variant_id": "var_1kmn0aExample",
      "quantity": 1,
      "allow_quantity_adjustment": true,
      "max_quantity": 4
    }],
    "inventory_routing_source": {
      "type": "fixed_location",
      "location_id": "loc_1kmn0aExample"
    },
    "inactive_message": "The mugs are sold out. The next batch ships in October."
  }'

variant_id points at a catalog variant that tracks stock, and inventory_routing_source names the location the stock ships from. Every buyer who pays through the link goes through the same hold, so when two buyers reach for the last mug, one payment succeeds and the other fails before any money moves, whatever quantity each picked. For a capped drop, max_completions counts checkouts in progress as well as completed ones, so a burst of simultaneous buyers cannot pass the cap. The same hold runs when you create orders through the API or embed the checkout in your own site, so a storefront on Flint's ecommerce API cannot oversell either.

Putting stock on the shelf

Levels are never written directly. You record what happened, a delivery, a count, a damaged unit, and Flint derives the numbers and keeps the ledger:

cURL
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.

Holding stock from your own cart

If you run your own cart or point of sale, claim the stock directly when a checkout starts:

cURL
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 '{
    "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 hold carries its own deadline, up to 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. Commit the reservation after payment succeeds:

cURL
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_version": 1,
    "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_version 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. For staying in sync, inventory.level.updated fires on every quantity change and inventory.shortage.detected when claims outrun physical stock.

Stripe and Flint stock control, side by side

Stripe plus your own databaseFlint
Where the count livesA column you maintain; Stripe stores noneLevels per item, per location, derived from a movement ledger
Hold between cart and paymentA holds table, an expiry handler, and a sweep jobPlaced by the payment, or a claim with a deadline that releases itself
Two buyers, one unit leftDepends on your lockingOne payment succeeds; the other fails before the card is charged
A payment link when stock runs outDeactivated by your webhook handler, after the salePayment fails with INVENTORY_UNAVAILABLE before money moves
A retried event or requestYour dedupe tableIdempotency keys required; targets are cumulative
Restocking a returnManual, if someone remembersReturn receipts that record condition per unit
Several locationsRouting logic you writeAllocation policies route deterministically
Low stock signalA 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, Payment Links, and Locations references have every field on the requests above.

Sources