Cart reservation timer API

Holding stock during checkout is the canonical duration-timer problem: the clock starts when the customer commits, has to stop while a payment provider takes its time, and must release the reservation whether or not anyone is looking at the page. This walks through the whole thing — creating the hold, pausing during authorisation, releasing on a webhook, and enforcing the deadline server-side.

What the problem actually requires

RequirementWhy the obvious approach fails
The clock starts at checkout, not at a fixed timeA date countdown has no notion of "when they arrived".
It must pause during payment authorisationOtherwise your latency eats the customer’s window.
Stock is released even if the tab is closedA browser timer only runs while someone is watching.
The customer sees the same number you enforceTwo sources of truth eventually disagree, visibly.
Releasing twice must be harmlessRetries and replays are normal; double-releasing stock is not.
The third row rules out any client-only countdown, and the second rules out a fixed deadline. Together they describe a duration timer with control actions — which is why this use case gets its own page rather than a paragraph.

Creating the hold

Four calls at checkout: create the timer, store its ID, attach the rule, start the clock. A new duration timer is scheduled and does not count down until it is started, which is what lets you reserve stock and begin the clock at exactly the right moment.

Begin checkout

// The customer commits: reserve stock and start the clock.
export async function beginCheckout(order) {
  await db.reserveStock(order.items);

  const timer = await call("/timers", {
    method: "POST",
    // Derived from the order, so a retried request returns the
    // original hold rather than granting another fifteen minutes.
    headers: { "Idempotency-Key": `hold_${order.id}` },
    body: JSON.stringify({
      name: `Cart hold ${order.id}`,
      type: "duration",
      duration_seconds: 15 * 60,
      metadata: { order_id: order.id },
      publish: true,
      expiry: { behavior: "show_message", message: "This reservation has expired" },
    }),
  });

  await db.orders.update(order.id, { timerId: timer.id });

  // One rule: tell us at zero.
  await call(`/timers/${timer.id}/webhook-rules`, {
    method: "POST",
    body: JSON.stringify({
      name: "Hold expired",
      webhook_destination_id: DESTINATION_ID,
      condition: { field: "status", operator: "equals", value: "ended" },
    }),
  });

  // Nothing counts down until it is started.
  await call(`/timers/${timer.id}/actions`, {
    method: "POST",
    body: JSON.stringify({ action: "start" }),
  });

  return timer;
}
The idempotency key is hold_{order.id}. If the request is retried after a timeout, the original timer comes back rather than a second one — so a flaky connection cannot hand someone thirty minutes. Idempotency covers why a random key defeats this.

Pausing during payment

This is the part that is genuinely awkward to build yourself, and the reason a duration timer is the right model.

Charge with the clock frozen

// Payment authorisation can take thirty seconds. That time
// should not come out of the customer's fifteen minutes.
export async function chargeOrder(order) {
  await action(order.timerId, "pause");

  try {
    const result = await paymentProvider.charge(order);

    if (result.requiresAction) {
      // 3-D Secure. Hand the remaining time back untouched.
      await action(order.timerId, "resume");
      return result;
    }

    if (result.succeeded) {
      // Done. The hold is no longer needed.
      await call(`/timers/${order.timerId}`, { method: "DELETE" });
      return result;
    }

    // Declined. Give the full window back for another card.
    await action(order.timerId, "reset");
    await action(order.timerId, "start");
    return result;
  } catch (error) {
    // Never leave a hold frozen forever because we threw.
    await action(order.timerId, "resume").catch(() => {});
    throw error;
  }
}

The three outcomes

A 3-D Secure challenge resumes with the customer's remaining time exactly as it was — they are not penalised for their bank's interstitial. A success deletes the hold. A decline resets to the full fifteen minutes and restarts, which is the fair thing to do when someone is fetching another card.

The catch block matters more than it looks. Without it, an exception during payment leaves the timer paused forever — the stock stays reserved, no webhook ever fires, and nothing tells you. Resume on the way out of any failure path.

Releasing on the webhook

At zero, the API calls you. No polling, and it happens whether or not the customer still has the tab open.

Webhook handler and release job

// The webhook handler. Fires at zero, without polling.
app.post("/webhooks/countdownshare", raw, async (req, res) => {
  if (!verify(req.body, req.headers, SECRET)) return res.sendStatus(401);

  res.sendStatus(200);   // acknowledge inside 10 seconds

  const event = JSON.parse(req.body.toString());
  if (event.data.status !== "ended") return;

  await queue.add(
    "release-hold",
    { orderId: event.timer.metadata.order_id },
    // At-least-once delivery: the same event can arrive twice.
    { jobId: event.id },
  );
});

// The job itself must be safe to run more than once.
async function releaseHold({ orderId }) {
  const order = await db.orders.find(orderId);

  // Idempotent by construction: already-released is a no-op,
  // and a paid order is never touched.
  if (!order || order.status !== "awaiting_payment") return;

  await db.transaction(async (tx) => {
    await tx.orders.update(orderId, { status: "expired" });
    await tx.releaseStock(order.items);
  });
}

Two safeguards are doing real work here. jobId: event.id deduplicates at-least-once delivery, and the status check inside the job means a race between payment succeeding and the hold expiring resolves in the customer's favour rather than cancelling a paid order.

Enforcing it at the decision point

The countdown on screen is presentation. When the customer clicks Pay, check the server.

Confirm before processing

// The countdown on screen is presentation. The decision is not.
export async function confirmOrder(order) {
  const status = await call(`/timers/${order.timerId}/status`);

  if (status.ended) {
    throw new HoldExpiredError("This reservation has expired");
  }

  return processOrder(order);
}

A customer whose laptop clock is wrong, or who left the tab open for an hour, will submit a form that looks valid to their browser. One status call before processing closes that gap — see read remaining time.

Showing the countdown

A published timer returns a responsive embed and a hosted page reading the same clock. Drop the embed into your checkout page and the number the customer sees is the number your backend will enforce, with no second implementation to keep in sync.

For a countdown styled to match your own checkout, read json_data_url through a proxy and render it yourself — the React guide has a hook that handles clock drift and throttled tabs.

Common questions

Why a duration timer rather than a fixed deadline?

The clock starts when the customer commits, not at a moment on the calendar, and it needs to stop while payment authorises. A fixed-date countdown cannot be paused — you would have to recompute and rewrite the deadline on every pause, and the customer-facing countdown would disagree with your backend during each one.

What happens if my webhook endpoint is down when a hold expires?

The delivery retries five more times over roughly fifteen hours, so a brief outage costs you nothing. For a longer one, failed deliveries stay queryable and can be replayed once you have fixed the problem. Many teams also keep a low-frequency reconciliation sweep as a backstop — hourly is plenty once webhooks are handling the timely case.

Should I delete the timer when the order is paid?

Deleting is tidy and stops the webhook firing on a completed order. It does not refund the timer against your monthly allowance — that was consumed at creation — so archiving is equally valid if you want to keep the record for support. Either way, make sure the release job checks the order status, since a delivery may already be in flight.

How do I show the countdown to the customer?

The timer returns a hosted page, a responsive embed, and an email GIF once published. The embed reads the same server clock your backend enforces against, so the number on screen and the number your API checks cannot drift apart — which is the failure customers complain about most.

Related

Build the hold in Sandbox first

Free with any account. Create a duration timer, start it, pause it, and watch the webhook arrive at zero — the whole flow without touching live stock.