Booking hold expiry API

A held seat, table, or appointment slot is inventory that somebody else wants. Holding it too briefly loses the booking; holding it after the customer has wandered off loses a different one. This walks through a ten-minute hold that releases itself — including the warning that recovers abandoned checkouts, and the concurrency detail that stops a stale release taking a seat from the customer who has since claimed it.

Why this is not quite a cart hold

The timer mechanics are identical to a cart reservation. What differs is the inventory model, and it changes the release logic.

Cart holdBooking hold
InventoryA quantity, usually fungibleA specific seat, table, or slot
ContentionRare until stock is lowConstant — everyone wants row A
On releaseReturn quantity to a poolVerify who still holds it, then free it
Risk of a stale releaseLowHigh — the seat may already be someone else’s
The bottom row is the one that produces real bugs. A delivery that arrives late, or a replay, can try to release a seat that a different customer has already booked. The handler has to check who holds it, not just that it is held.

Placing the hold

Lock and claim the seat first, then create the timer. Doing it the other way round leaves an orphaned timer whenever the seat turns out to be gone.

holdSeat

// A customer selects a seat. Hold it for ten minutes.
export async function holdSeat(seat, session) {
  await db.transaction(async (tx) => {
    const current = await tx.seats.find(seat.id, { lock: true });
    if (current.status !== "available") throw new SeatTakenError();
    await tx.seats.update(seat.id, { status: "held", sessionId: session.id });
  });

  const timer = await call("/timers", {
    method: "POST",
    headers: { "Idempotency-Key": `hold_${seat.id}_${session.id}` },
    body: JSON.stringify({
      name: `Seat ${seat.label} — session ${session.id}`,
      type: "duration",
      duration_seconds: 10 * 60,
      metadata: { seat_id: seat.id, session_id: session.id },
      publish: true,
      expiry: { behavior: "show_message", message: "This seat has been released" },
    }),
  });

  await db.seats.update(seat.id, { timerId: timer.id });

  await attachRule(timer.id, "Two minutes left", {
    field: "remaining_seconds", operator: "less_than_or_equal", value: 120,
  });
  await attachRule(timer.id, "Hold expired", {
    field: "status", operator: "equals", value: "ended",
  });

  await action(timer.id, "start");
  return timer;
}

The idempotency key includes both the seat and the session, so a retry by the same customer returns their existing hold, while a genuinely different customer holding the same seat later gets a new timer.

The warning and the release

handleHoldEvent

async function handleHoldEvent(event) {
  const { seat_id, session_id } = event.timer.metadata;

  if (event.rule.name === "Two minutes left") {
    // Nudge before losing the seat — this converts.
    return notifySession(session_id, "Your seat is released in 2 minutes");
  }

  // "Hold expired": release, but only if this session still holds it.
  await db.transaction(async (tx) => {
    const seat = await tx.seats.find(seat_id, { lock: true });

    // Three ways this is already resolved:
    //   - the booking was confirmed
    //   - the customer released it manually
    //   - another session already picked it up
    if (!seat || seat.status !== "held" || seat.sessionId !== session_id) {
      return;
    }

    await tx.seats.update(seat_id, {
      status: "available",
      sessionId: null,
      timerId: null,
    });
  });

  await notifySession(session_id, "Your seat hold has expired");
}

The three-part guard

Before releasing, the handler checks the seat exists, is still held, and is held by this session. Each condition catches a real case: the booking was confirmed, the customer released it manually, or a stale delivery arrived after someone else took the seat. Skipping the session check is how a late webhook cancels a stranger's booking.

The two-minute warning is worth including for commercial reasons rather than technical ones. A nudge before a hold lapses recovers a meaningful share of checkouts that would otherwise be abandoned silently — and it is one extra rule on a timer that already exists.

Extending a hold

extendHold

// "Give me five more minutes." A reset, not a new timer —
// resets are free, creation is metered.
export async function extendHold(seat) {
  const status = await call(`/timers/${seat.timerId}/status`);
  if (status.ended) throw new HoldExpiredError();

  // reset returns to the full duration and stops, so start again.
  await action(seat.timerId, "reset");
  await action(seat.timerId, "start");
}

// If a different window is wanted, PATCH duration_seconds first:
//   PATCH /timers/{id}  { "duration_seconds": 300 }
// then reset and start. Updates are free too.

reset returns the timer to its full duration and stops it, so start follows. Both actions are free and unlimited — only creating a timer is metered — so extending costs nothing, while deleting and recreating would consume another from your allowance. See control duration timers.

Confirming the booking

confirmBooking

// Confirming the booking. Check the clock, not the browser.
export async function confirmBooking(seat, session, payment) {
  const status = await call(`/timers/${seat.timerId}/status`);
  if (status.ended) throw new HoldExpiredError("Your seat hold expired");

  // Freeze the clock while the payment provider takes its time.
  await action(seat.timerId, "pause");

  try {
    const result = await charge(payment);
    if (!result.succeeded) {
      await action(seat.timerId, "resume");
      return result;
    }

    await db.seats.update(seat.id, { status: "booked" });
    await call(`/timers/${seat.timerId}`, { method: "DELETE" });
    return result;
  } catch (error) {
    // Never leave the hold frozen because we threw.
    await action(seat.timerId, "resume").catch(() => {});
    throw error;
  }
}

Two things happen here. The status read enforces the deadline server-side, so a customer whose page has been open for twenty minutes cannot confirm a lapsed hold. And the pause means the payment provider's latency does not come out of their ten minutes.

The catch that resumes on the way out matters. Without it, an exception during payment leaves the hold paused indefinitely — the seat stays locked, no webhook ever fires, and nothing surfaces the problem until someone asks why row A has been unavailable for three days.

Showing the hold

A published timer gives you a responsive embed reading the same clock your backend enforces, so the countdown on the seat-selection page and the answer your confirm endpoint gives cannot drift apart. For a countdown styled to match your booking flow, read the status endpoint through a proxy — the React guide has a hook that handles clock drift and throttled tabs.

Common questions

How is this different from a cart reservation?

Mechanically it is the same duration-timer pattern. The differences are in the surrounding logic: seats are a fixed inventory where two customers can want the identical row, so the database lock matters more, and the release has to verify that this session still holds the seat rather than releasing whatever is there.

What if the customer closes the tab?

The seat is still released. That is the whole point of a server-side deadline — the webhook fires at zero regardless of whether anyone has the page open, so an abandoned checkout does not lock inventory until someone notices.

Can I extend a hold without creating a new timer?

Yes, and you should. reset returns the timer to its full duration; start runs it again. Both are free, while creating a timer counts against your monthly allowance. If you want a different window, PATCH duration_seconds first — that is free too.

What happens if two holds expire at the same moment?

They are independent timers producing independent deliveries, so there is no contention between them. The contention is in your own release logic, which is why the handler takes a row lock and verifies the session ID before releasing — otherwise a stale delivery could release a seat that a different customer has since taken.

Related

Try a two-minute hold

Sandbox is free with any account. Create a duration timer, start it, and watch both the warning and the release arrive.