DEV Community

Kadokey
Kadokey

Posted on

Designing a Real-Time Inventory System for Digital-Goods Codes: Reserve, Allocate, Release

Selling digital codes — gift cards, game keys, top-up PINs — looks simple from the outside. The customer pays, a code appears on screen, everyone is happy. Under the hood, though, inventory for digital goods is weirder than inventory for physical goods: each unit is unique, each can only be sold once, and a race condition at the wrong moment means selling the same code to two people. When codes cost real money, that's an instant refund and a trust problem.

Here's the design pattern we use to keep digital-code inventory correct in real time: reserve, allocate, release.

Why quantity > 0 isn't enough

Physical-goods stores can get away with a simple counter: decrement on purchase, done. Digital codes can't, because the unit matters. You don't sell "one of 50 codes" — you sell code #37281 to Alice and code #37282 to Bob, and neither may ever be assigned twice.

The naive flow looks like this:

  1. Check that an unsold code exists for the product.
  2. Charge the customer.
  3. Mark a code as sold and email it.

The hole: between step 1 and step 3, another checkout can pick the same code. Under promo-day traffic this isn't theoretical — it's how you end up emailing two customers the same key. Marketplaces for instant digital delivery live or die on this moment, because a failed code redemption right after payment is the fastest way to lose a buyer.

Step 1: Reserve (pre-payment hold)

When a customer enters checkout, atomically claim a specific code before charging:

UPDATE inventory_codes
SET status = 'reserved',
    reserved_for_order = $1,
    reserved_until = NOW() + INTERVAL '15 minutes'
WHERE code_id = (
  SELECT code_id FROM inventory_codes
  WHERE product_id = $2 AND status = 'available'
  LIMIT 1
  FOR UPDATE SKIP LOCKED
)
RETURNING code_id;
Enter fullscreen mode Exit fullscreen mode

Two things matter here:

  • FOR UPDATE SKIP LOCKED lets concurrent checkouts skip rows locked by each other instead of queueing — this is what keeps inventory reads from serializing your entire checkout funnel under load.
  • The reservation has a TTL (15 minutes here). If the customer abandons checkout, a sweeper returns expired reservations to available. No TTL means abandoned carts silently drain your stock until someone manually cleans up.

Step 2: Allocate (post-payment commit)

Once payment succeeds, flip the reserved row to sold in the same transaction that records the order:

BEGIN;
INSERT INTO orders (...) VALUES (...);
UPDATE inventory_codes
SET status = 'sold', sold_at = NOW()
WHERE reserved_for_order = $1 AND status = 'reserved';
COMMIT;
Enter fullscreen mode Exit fullscreen mode

Keeping order creation and allocation in one transaction means you never have a paid order with no code attached. If the transaction fails, the code stays reserved until the TTL releases it — recoverable, not lost.

Step 3: Release (compensation path)

Every flow that doesn't end in a sale must release its reservation explicitly: payment failures, fraud-review rejections, customer cancellation. Plus the TTL sweeper as a backstop for anything the application missed:

UPDATE inventory_codes
SET status = 'available',
    reserved_for_order = NULL,
    reserved_until = NULL
WHERE status = 'reserved' AND reserved_until < NOW();
Enter fullscreen mode Exit fullscreen mode

Run this on a schedule (every minute is plenty) and alert if it releases an unusual volume — a spike means something upstream is broken and bleeding stock.

Stock imports and pre-orders need the same state machine

Suppliers deliver code batches as CSVs or API feeds. Import them as available rows with a batch_id, and never let an import touch reserved or sold rows. For pre-orders (codes you don't have yet), sell against a pending_supply count, then allocate FIFO from imported batches — and hold delivery notifications until the code is physically in your table, not when the supplier promises it. Stores that specialize in game keys and digital gift cards rely on this discipline to avoid selling inventory they don't actually hold.

Measure what matters

Track three numbers on a dashboard: available stock per product, reservations by age (a growing tail of old reservations means your checkout flow leaks), and allocation failure rate (sold-outs at reservation time). The first tells you when to reorder from suppliers, the second tells you your TTLs and sweeper work, the third is your most honest conversion signal.

The takeaway

Treat every code as a state machine — available → reserved → sold, with reserved → available as the only legal backward transition — and enforce each transition with a single atomic query. It's a small amount of discipline that eliminates an entire class of "we sold the same code twice" incidents, which is exactly the kind of reliability that makes a digital-goods store worth buying from twice.

Top comments (0)