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.

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.
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.
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)
);
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.
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.
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.
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.
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.

Run in your terminal to get started locally.
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 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.
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.
expires_at or an extension call while the customer is still active.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.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.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.
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.
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.
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.