Frontbacked Docs

Reservations and Atomic Actions

Hold stock, appointment capacity, equipment, or seats while someone completes a workflow. Reservations use your own resource and attempt keys; they are independent of products, users, and payments.

Hold capacity

Use reservations in endpoint processes and payment events:

endpoint.post hold_places("/hold-places") {
  must($actor.id, "Sign in to continue.", 401);
  dedupe `${$actor.id}:${$request.body.attemptId}`;

  $context.result = reservation.hold(
    "workshop:morning",
    `${$actor.id}:${$request.body.attemptId}`,
    { capacity: 20, quantity: 2, expiresIn: 900 }
  );
  must($context.result.ok, "Those places are no longer available.", 409);
  return $context.result;
}

reservation.hold(resourceKey, reservationKey, options) accepts:

Argument or optionMeaning
resourceKeyYour capacity pool identifier, such as product:abc or workshop:morning.
reservationKeyA stable identifier for this hold. Reuse it when retrying.
capacityA non-negative integer or a source reference.
quantityA positive integer; defaults to 1.
expiresInLifetime in seconds, from 1 to 604800 (seven days). Use this or expiresOn.

Alternatively, pass expiresOn as a future ISO timestamp within seven days. This lets a hold match an existing booking or payment deadline exactly. Do not combine it with expiresIn.

Keys must be nonempty strings of at most 200 characters and are scoped to the site. No user ID is added automatically. Endpoint guards control who may reserve or release capacity: possession of a reservation key is not authentication. Use one resource key consistently for each pool; giving the same stock field several different resource keys creates independent pools.

Success returns:

{
  "ok": true,
  "reservation": {
    "resourceKey": "workshop:morning",
    "reservationKey": "customer:attempt",
    "quantity": 2,
    "status": "held",
    "active": true,
    "expiresOn": "2026-10-01T10:15:00.000Z"
  },
  "available": 18
}

An active matching hold returns the original reservation with replayed: true. It does not extend the deadline. Replays need not include available; use the reservation details to resume the workflow.

Unsuccessful resultMeaning
insufficient_capacityNot enough unreserved capacity. Includes available.
conflictThis reservation key already identifies a different quantity.
source_conflictThis resource key was registered with a different capacity source.
terminalThis hold has expired or been released. Use a new reservation key for a new attempt.

These return { ok: false, reason: "..." }. Invalid options or inaccessible sources throw an error. Use must(result.ok, message, status) when an unsuccessful hold should fail the endpoint or roll back an atomic block.

Reference current capacity

Instead of passing a previously read number, point to the field that owns capacity:

capacity: { post: $context.productId, field: "inventory.available" }
capacity: { userData: $actor.id, field: "bookingSlots.remaining" }

The IDs identify records, not data objects. Object paths can be nested; array positions and unsafe property names are rejected. The source must exist and contain a non-negative integer. Post references require read access to the post and an accessible field. User-data references currently support the authenticated user's own data and exclude backend-only fields.

Frontbacked reads the source while protecting the reservation check from concurrent updates. The same resource key must keep the same source. A numeric capacity remains supported: each hold uses the number supplied for that call, but Frontbacked cannot detect whether your number was calculated from stale data. Do not accept capacity directly from an untrusted request.

Capacity is the amount available before subtracting active holds. Reducing capacity below existing holds prevents new holds; it does not cancel existing reservations. Expired holds automatically stop counting against capacity. No expiry cleanup call is necessary.

Read and release

$context.hold = reservation.get($context.resourceKey, $context.reservationKey);
reservation.release($context.resourceKey, $context.reservationKey);

get returns the reservation or null. Its status is held, expired, or released; active reflects its current deadline. Inside an atomic block, reading the hold also protects it from concurrent release while that block completes.

release returns { ok: true, released, reservation } for an existing hold. Releasing an already released hold or a missing key succeeds harmlessly; a missing key is a no-op. Expired and released holds cannot be revived by hold.

Commit related changes together

Use atomic { ... } when related database operations must succeed together:

endpoint.post complete_booking("/complete-booking") {
  must($actor.id, "Sign in to continue.", 401);
  // Validate ownership and the trusted completion condition here.
  dedupe `${$actor.id}:${$request.body.completionId}`;

  atomic {
    $context.hold = reservation.get(
      $request.body.resourceKey,
      $request.body.reservationKey
    );
    must($context.hold && $context.hold.active, "This reservation is no longer active.", 409);

    // Resolve and authorize productId from trusted booking data first.
    post.increment($context.productId, {
      stock: -$context.hold.quantity,
      unitsSold: $context.hold.quantity
    });
    reservation.release($request.body.resourceKey, $request.body.reservationKey);
    return { ok: true };
  }
}

The snippet illustrates the transaction sequence; provide the ownership/completion checks and product lookup for your application before using it. Keep the stock schema minimum at zero.

Inside the block, reads see earlier writes. If an operation throws or must fails, all changes in the block roll back. Returning { ok: false } alone is a normal return, not a rollback instruction. Database transactions enclosing an endpoint or payment event may roll back the block if the containing action fails later too.

Supported operations are post reads, users.exists, the existing post create/edit/increment/delete helpers, and reservation methods. Post writes retain their existing schema validation and author semantics. Put related writes inside the block rather than queueing post writes before it. At most 128 post writes are allowed per block. Nested atomic blocks are rejected.

HTTP requests, payment creation, wallet charges, and other external effects are not allowed inside an atomic block. Complete the database action first, then perform external work with its own retry protection. Atomic blocks and reservation methods are currently available in endpoint processes and payment events, not guards, permissions, or post/profile hooks.

Atomicity prevents partial changes; dedupe prevents repeating a completed action. Use both when a caller may retry. Lock contention or database conflicts can fail an action; Frontbacked does not automatically rerun it.

Reservations do not automatically follow payment expiry or confirmation. Choose a reservation lifetime appropriate to your workflow, and explicitly consume the business capacity and release its hold when completing an authorized purchase or booking.

Wig-store example

The wig theme uses both APIs in create_cart_payment and create_product_payment. Both endpoints require an attemptId; reuse it for an unchanged request and use a new value when starting a new attempt. Each stock-controlled wig shares the resource key wig:<productId> across both checkout paths. The payment ID is the reservation key, and holds last until the latest available payment-method deadline.

Payment creation stays outside the explicit atomic block. The enclosing endpoint transaction saves the payment, holds, and dedupe response together. If a cart line cannot be reserved, no new payable payment or partial cart hold remains.

Confirmation atomically updates stock and lifetime sales, records sale snapshots, and releases the holds. Failed-payment events release holds without recording sales. Expired holds stop counting automatically. A delayed confirmation—or an older payment without holds—must acquire available capacity again; if another customer has reserved it, fulfillment stops for an order review instead of overselling. Payment settlement itself is not reversed by an FRL fulfillment error.