Nov 12, 202612 min read

Inventory Reservation Backend for Ecommerce Checkout

Hold stock while a customer pays without overselling: an available-stock check in one Postgres UPDATE, per-cart reservations that are safe to retry, holds that expire, and release on payment failure through Pub/Sub with a cron sweep behind it.

Illustration for Inventory Reservation Backend for Ecommerce Checkout

An online store that takes payment after the cart is confirmed has a window, often several minutes, between the customer clicking pay and the payment provider confirming. If stock is only decremented when payment succeeds, two customers can pay for the last unit and one order has to be cancelled and refunded after the fact. If it is decremented when checkout starts, abandoned checkouts and declined cards slowly drain sellable stock to zero.

The usual answer is a reservation: a hold on specific units that becomes a sale when payment succeeds and goes back on sale when payment fails or times out. This guide designs that reservation backend in Postgres, first as a list of AWS pieces built by hand and then in Go with Encore, with the SQL tested against 20 concurrent checkouts for 5 units.

Inventory reservations at a glance

JobPatternHand-built on AWSWith Encore in your AWS account
Available stockon_hand and reserved columns, conditional UPDATERDS for PostgreSQLEncore database on RDS
One hold per cartUnique cart_id, INSERT ... ON CONFLICT DO NOTHINGSame tableSame table
Sale on payment successConditional commit while status = 'held'API endpointPrivate endpoint
Release on payment failureEvent from the payment webhookSNS topic and SQS queuePub/Sub topic and subscription
Release abandoned holdsSweep of expired holdsScheduled Lambda or ECS taskCron job, Encore platform managed

How do you build it by hand on AWS?

Overselling happens when two checkouts read the same available stock and both proceed. The design below keeps held units in a separate column, claims them with a conditional update, and releases them from a payment-failed webhook or from a sweep of expired holds.

  1. Tables. RDS Postgres holds stock, one reservation per cart, and the items on that reservation. Available stock is on_hand - reserved, and a table constraint refuses any update that would oversell even if application code has a bug:
CREATE TABLE stock ( sku TEXT PRIMARY KEY, on_hand INT NOT NULL CHECK (on_hand >= 0), reserved INT NOT NULL DEFAULT 0 CHECK (reserved >= 0), CHECK (reserved <= on_hand) ); CREATE TABLE reservations ( id BIGSERIAL PRIMARY KEY, cart_id TEXT NOT NULL UNIQUE, items_fingerprint TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'held' CHECK (status IN ('held', 'committed', 'released')), expires_at TIMESTAMPTZ NOT NULL ); CREATE TABLE reservation_items ( reservation_id BIGINT NOT NULL REFERENCES reservations (id), sku TEXT NOT NULL REFERENCES stock (sku), qty INT NOT NULL CHECK (qty > 0), PRIMARY KEY (reservation_id, sku) );
  1. Reserve in one transaction. Insert the reservation with ON CONFLICT (cart_id) DO NOTHING so a retried checkout with the same items returns the existing hold instead of taking stock twice. A retry that sends a different SKU or quantity is rejected, rather than silently keeping the first hold. Then, for each item, claim units with a conditional update, locking SKUs in a fixed order so two carts that share products cannot deadlock, following the explicit locking docs:
UPDATE stock SET reserved = reserved + $2 WHERE sku = $1 AND on_hand - reserved >= $2;

Under Read Committed, a second checkout updating the same row waits for the first to commit, then Postgres re-evaluates the WHERE clause against the updated row, so the check sees units the first checkout took. A failed item rolls the whole transaction back.

  1. Commit after payment. A successful payment runs a conditional update that moves the reservation from held to committed only while expires_at > now(), then decrements both on_hand and reserved. A payment that lands after the hold expired gets no row and should reserve again or refund, instead of quietly selling units that already went back on sale.

  2. Release on payment failure. The payment webhook handler verifies the provider's signature and publishes a failure event to SNS. Payment providers emit events for this, for example Stripe's payment_intent.payment_failed and checkout.session.expired in its event types reference. A worker on an SQS queue with a dead-letter queue sets status = 'released' only while the row is still held, then returns those units from reserved. The status check makes a redelivered message a no-op.

  3. Sweep expired holds. A scheduled task every few minutes covers checkouts where the browser closed and no event ever arrives. It claims expired rows with FOR UPDATE SKIP LOCKED so two overlapping runs cannot release the same hold, then runs the same release as the webhook worker:

SELECT id FROM reservations WHERE status = 'held' AND expires_at < now() ORDER BY expires_at LIMIT 500 FOR UPDATE SKIP LOCKED;

The API, the worker and the sweep each need database credentials and a network path to RDS, and the webhook handler needs publish rights on the topic.

How do you build it with Encore?

Encore

Encore turns infrastructure declared in Go or TypeScript code into resources in your own cloud account. The inventory database, the payment-failed topic and the sweep schedule are declared next to the code that uses them, and deploying creates RDS, SNS and SQS in your own AWS account, or the GCP equivalents, as listed in the AWS infrastructure docs.

Application code passes through Encore and infrastructure guardrails to resources provisioned in your own AWS or GCP account.
Encore provisions the infrastructure your application needs in your own AWS or GCP account.

Set up the app

Get started
brew install encoredev/tap/encore &&
encore app create
Copy

Run in your terminal to get started locally.

~
$ encore app create shop --example=hello-world$ cd shop && mkdir -p inventory/migrations payments

The migration creates the tables used above, plus an index on expiring holds:

-- inventory/migrations/1_inventory.up.sql CREATE TABLE stock ( sku TEXT PRIMARY KEY, on_hand INT NOT NULL CHECK (on_hand >= 0), reserved INT NOT NULL DEFAULT 0 CHECK (reserved >= 0), CHECK (reserved <= on_hand) ); CREATE TABLE reservations ( id BIGSERIAL PRIMARY KEY, cart_id TEXT NOT NULL UNIQUE, items_fingerprint TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'held' CHECK (status IN ('held', 'committed', 'released')), expires_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE reservation_items ( reservation_id BIGINT NOT NULL REFERENCES reservations (id), sku TEXT NOT NULL REFERENCES stock (sku), qty INT NOT NULL CHECK (qty > 0), PRIMARY KEY (reservation_id, sku) ); CREATE INDEX reservations_expiring ON reservations (expires_at) WHERE status = 'held';

Reserve, commit and release

Reserve uses the same conditional UPDATE and SKU lock ordering described above.

// inventory/inventory.go package inventory import ( "context" "encoding/json" "errors" "sort" "time" "encore.dev/beta/errs" "encore.dev/storage/sqldb" ) var db = sqldb.NewDatabase("inventory", sqldb.DatabaseConfig{ Migrations: "./migrations", }) const holdFor = 15 * time.Minute type Item struct { SKU string `json:"sku"` Qty int `json:"qty"` } type ReserveParams struct { CartID string `json:"cart_id"` Items []Item `json:"items"` } type Reservation struct { ID int64 `json:"id"` Status string `json:"status"` ExpiresAt time.Time `json:"expires_at"` } // Reserve holds stock for every item in the cart, or for none of them. // Calling it again for the same cart and the same items returns the // existing reservation. A different item list for that cart is rejected. // //encore:api private method=POST path=/reservations func Reserve(ctx context.Context, p *ReserveParams) (*Reservation, error) { if p.CartID == "" || len(p.Items) == 0 { return nil, &errs.Error{Code: errs.InvalidArgument, Message: "cart_id and items are required"} } for _, it := range p.Items { if it.Qty <= 0 { return nil, &errs.Error{Code: errs.InvalidArgument, Message: "qty must be positive"} } } // Lock rows in a fixed order so two carts with the same SKUs // cannot deadlock each other. items := append([]Item(nil), p.Items...) sort.Slice(items, func(i, j int) bool { return items[i].SKU < items[j].SKU }) fp := itemsFingerprint(items) tx, err := db.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback() var r Reservation err = tx.QueryRow(ctx, ` INSERT INTO reservations (cart_id, items_fingerprint, expires_at) VALUES ($1, $2, now() + $3 * interval '1 second') ON CONFLICT (cart_id) DO NOTHING RETURNING id, status, expires_at `, p.CartID, fp, int(holdFor.Seconds())).Scan(&r.ID, &r.Status, &r.ExpiresAt) if errors.Is(err, sqldb.ErrNoRows) { // Retry of a cart that already has a reservation. var existing string err = tx.QueryRow(ctx, ` SELECT id, status, expires_at, items_fingerprint FROM reservations WHERE cart_id = $1 FOR SHARE `, p.CartID).Scan(&r.ID, &r.Status, &r.ExpiresAt, &existing) if err != nil { return nil, err } if existing != fp { return nil, &errs.Error{Code: errs.FailedPrecondition, Message: "cart already reserved with different items"} } return &r, nil } else if err != nil { return nil, err } for _, it := range items { res, err := tx.Exec(ctx, ` UPDATE stock SET reserved = reserved + $2 WHERE sku = $1 AND on_hand - reserved >= $2 `, it.SKU, it.Qty) if err != nil { return nil, err } if res.RowsAffected() == 0 { return nil, &errs.Error{Code: errs.FailedPrecondition, Message: "not enough stock for " + it.SKU} } if _, err := tx.Exec(ctx, ` INSERT INTO reservation_items (reservation_id, sku, qty) VALUES ($1, $2, $3) `, r.ID, it.SKU, it.Qty); err != nil { return nil, err } } return &r, tx.Commit() } func itemsFingerprint(items []Item) string { b, err := json.Marshal(items) if err != nil { return "" } return string(b) } // Commit turns a held reservation into a sale after payment succeeds. // It fails if the hold already expired or was released. // //encore:api private method=POST path=/reservations/:id/commit func Commit(ctx context.Context, id int64) error { tx, err := db.Begin(ctx) if err != nil { return err } defer tx.Rollback() var status string err = tx.QueryRow(ctx, ` UPDATE reservations SET status = 'committed' WHERE id = $1 AND status = 'held' AND expires_at > now() RETURNING status `, id).Scan(&status) if errors.Is(err, sqldb.ErrNoRows) { return &errs.Error{Code: errs.FailedPrecondition, Message: "reservation is no longer held"} } else if err != nil { return err } if _, err := tx.Exec(ctx, ` UPDATE stock s SET on_hand = s.on_hand - i.qty, reserved = s.reserved - i.qty FROM reservation_items i WHERE i.reservation_id = $1 AND s.sku = i.sku `, id); err != nil { return err } return tx.Commit() } // release returns held stock. The status check makes it safe to call // more than once for the same reservation. func release(ctx context.Context, id int64) error { tx, err := db.Begin(ctx) if err != nil { return err } defer tx.Rollback() res, err := tx.Exec(ctx, ` UPDATE reservations SET status = 'released' WHERE id = $1 AND status = 'held' `, id) if err != nil { return err } if res.RowsAffected() == 0 { return nil } if _, err := tx.Exec(ctx, ` UPDATE stock s SET reserved = s.reserved - i.qty FROM reservation_items i WHERE i.reservation_id = $1 AND s.sku = i.sku `, id); err != nil { return err } return tx.Commit() }

The unique cart_id makes Reserve safe to retry: a second call hits ON CONFLICT DO NOTHING and returns the existing reservation. A cart that changes after a successful reservation should release it first or use a new cart ID.

Commit only succeeds while the hold is still held and unexpired. A late payment gets FailedPrecondition, and the caller decides whether to reserve again or refund.

Release on payment failure and expiry

The payments service owns the topic, and its webhook handler verifies the provider's signature and publishes when a payment fails:

// payments/payments.go package payments import "encore.dev/pubsub" type PaymentFailedEvent struct { ReservationID int64 `json:"reservation_id"` PaymentID string `json:"payment_id"` Reason string `json:"reason"` } // Published by the payment provider's webhook handler in this service. var PaymentFailed = pubsub.NewTopic[*PaymentFailedEvent]("payment-failed", pubsub.TopicConfig{ DeliveryGuarantee: pubsub.AtLeastOnce, })

The inventory service subscribes and runs the same release as the sweep. The Pub/Sub docs note that delivery is at least once, and the status = 'held' condition turns a redelivered event, or one that races the sweep, into a no-op, matching the webhook path above:

// inventory/release.go package inventory import ( "context" "encore.app/payments" "encore.dev/cron" "encore.dev/pubsub" ) // Release the hold as soon as the payment fails. var _ = pubsub.NewSubscription(payments.PaymentFailed, "release-hold", pubsub.SubscriptionConfig[*payments.PaymentFailedEvent]{ Handler: func(ctx context.Context, e *payments.PaymentFailedEvent) error { return release(ctx, e.ReservationID) }, }, ) // Sweep holds whose checkout was abandoned and never sent an event. var _ = cron.NewJob("release-expired-holds", cron.JobConfig{ Title: "Release expired stock holds", Every: 5 * cron.Minute, Endpoint: ReleaseExpired, }) //encore:api private func ReleaseExpired(ctx context.Context) error { rows, err := db.Query(ctx, ` SELECT id FROM reservations WHERE status = 'held' AND expires_at < now() ORDER BY expires_at LIMIT 500 `) if err != nil { return err } var ids []int64 for rows.Next() { var id int64 if err := rows.Scan(&id); err != nil { rows.Close() return err } ids = append(ids, id) } rows.Close() if err := rows.Err(); err != nil { return err } for _, id := range ids { if err := release(ctx, id); err != nil { return err } } return nil }

The database docs cover migrations and transactions, and the cron job docs cover schedules. encore run starts Postgres locally and runs the topic in process, so Reserve, Commit and a published failure event can be tried from the local development dashboard. Cron jobs do not run locally or in preview environments, so call ReleaseExpired by hand during development. In deployed environments the sweep is Encore platform managed and runs every five minutes.

When a coding agent later adds back-in-stock emails, it can publish a new event from release and subscribe to it in its own service, and the next deploy creates the topic and subscription from that code change.

Best fit: Ecommerce teams running checkout in their own AWS or GCP account who want stock holds, payment events and the expiry sweep declared in one Go codebase.

Things to decide early

  • Hold length. Fifteen minutes suits card payments, while bank redirects and buy-now-pay-later flows can take longer, and their holds need either a longer expires_at or an extension call while the customer is still active.
  • Late payment success. Decide whether an expired hold is re-reserved when stock allows or refunded. Log each case, since a rising count means the hold is too short.
  • Hot SKUs. Every checkout for one product updates the same stock row, so a flash sale serializes on that row. For limited drops, split stock across several rows per SKU or put a queue in front of checkout.
  • Physical counts. Warehouse corrections change on_hand, and the CHECK (reserved <= on_hand) constraint rejects a correction that would go below current holds. That rejection is where someone decides which orders to cancel.

Frequently asked questions

How do you prevent overselling during checkout?

Reserve stock with a single conditional UPDATE such as UPDATE stock SET reserved = reserved + $2 WHERE sku = $1 AND on_hand - reserved >= $2, and treat zero affected rows as out of stock. Under Read Committed, Postgres makes a concurrent updater wait and then re-checks the WHERE clause against the new row, so two checkouts cannot both take the last unit.

Should inventory be decremented when the order is placed or when payment succeeds?

Hold it when checkout starts and decrement on_hand only when payment succeeds. A reserved column tracks held units, so available stock is on_hand minus reserved, and a failed or abandoned payment returns the units by lowering reserved instead of touching on_hand.

How long should a stock reservation last?

Long enough for the payment step to finish, often 10 to 15 minutes for card payments. Store an expires_at on each reservation, refuse to commit one that has expired, and run a scheduled sweep that releases expired holds so abandoned checkouts do not lock stock forever.

How do you release reserved stock when a payment fails?

Have the payment webhook publish a payment-failed event with the reservation ID, and release the hold in a subscriber. Make the release conditional on status = 'held' so a redelivered event or a race with the expiry sweep releases the units once.

Deploy to your cloud on AWS and Google Cloud
without managing infrastructure

Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders