*REST API › Tutorials*

# From availability check to reserved order

> Check stock from your server with a cache and batches that respect the 30 requests per minute of the availability operations, create the order with an idempotency key, then reserve it and handle `insufficient_stock`.

## What you build

- A server-side availability check for a booking site or a customer portal of your own, which survives traffic because it caches and batches.
- A fallback to “request a quote” whenever the quota is exhausted, so the visitor never sees an error.
- A booking step that creates an order, reserves it and tells the customer what happens when stock runs short.

## Before you start

| Scope | Used for |
| --- | --- |
| `account:read` | The first call: timezone, currency and the rate limits of the key. |
| `availability:read` | `POST /api/v1/availability/check`. |
| `items:read` | `GET /api/v1/items`, to map your catalog to Filmo item ids once. Optional if you already store the ids. |
| `contacts:read`, `contacts:write` | Finding or creating the customer's contact. |
| `orders:write` | `POST /api/v1/orders` and `POST /api/v1/orders/{orderId}/reserve`. |
| `orders:read` | `GET /api/v1/orders/{orderId}`, to read an order after a conflict or a lost response. Optional. |

- You need the Filmo id of the customer's contact. Get it with the find-or-create of steps 3 and 4 in [Turn a web form into an opportunity](https://docs.filmo.io/rest-api/tutorials/web-form-to-opportunity).
- You need the Filmo id of each item (and variant) you sell. Read them once with [`GET /api/v1/items`](https://docs.filmo.io/rest-api/endpoint-reference/items/list-items) and store them; identifiers are opaque.
- Set up the client from the first tutorial's step 1 (the same two files).

## 1. Know your limits

| Limit | Value |
| --- | --- |
| Availability queries | `POST /api/v1/availability/check`, `POST /api/v1/staff/availability` and `POST /api/v1/vehicles/availability` share one extra quota: at most 30 requests per minute per key. They also count against the 120 per minute for all routes, so they leave the rest of the 120 for everything else. |
| Items per availability query | 1 to 100 (`items`), all over the same date range. |
| Lines per order | 1 to 100, with 1 to 9999 units per line. |
| Every other call | 120 requests per minute per key. |

Thirty a minute is one availability query every two seconds, for the whole site. A page that calls Filmo once per visitor, or once per item, uses it up in moments. The rest of this tutorial exists to avoid that. The headers `RateLimit-Remaining` and `RateLimit-Reset` show what is left, and a 429 [`rate_limited`](https://docs.filmo.io/rest-api/errors/rate_limited) carries `Retry-After` ([Rate limits](https://docs.filmo.io/rest-api/guides/rate-limits)).

## 2. Keep the key on your server

The browser asks your server, and your server asks Filmo. There is one key for the site, held in the server's environment: a key in page code would be readable by every visitor, and would also give each of them their own way to use up your 30 per minute.

**cURL**

```bash
BASE="${FILMO_BASE_URL:-https://app.filmo.io}"
curl -sS "$BASE/api/v1/account" \
  -H "Authorization: Bearer $FILMO_API_KEY"
```

**Node**

```javascript
import { filmo } from "./filmo-client.mjs";

const res = await filmo("GET", "/api/v1/account");
if (!res.ok) throw new Error("getAccount " + res.status + " " + res.body?.code);

const { timezone, currency, addons, trial, apiKey } = res.body.data;
console.log({ timezone, currency, addons, trial: trial.active, scopes: apiKey.scopes });
```

**Python**

```python
from filmo_client import filmo

status, body, _ = filmo("GET", "/api/v1/account")
if status != 200:
    raise RuntimeError(f"getAccount {status} {(body or {}).get('code')}")

account = body["data"]
print(account["timezone"], account["currency"], account["addons"],
      account["trial"]["active"], account["apiKey"]["scopes"])
```

## 3. Check availability with a cache and batches

[`POST /api/v1/availability/check`](https://docs.filmo.io/rest-api/endpoint-reference/availability/check-item-availability) takes one date range and up to 100 items, each with the quantity you intend to book (1 if you omit it). Dates are ISO 8601 instants with an offset; build them from the visitor's choice in the account's `timezone`. It returns one entry per item, in the order you sent them: `available`, a `status` (`ok`, `partial` or `none`), the `shortfall`, the business days that are short (`conflictingPeriods`), or an `error` with code `not_found` for an item that does not exist.

- **Batch.** Put every item of a page for the same dates into one query, up to 100. Do not call once per item.
- **Cache for a short time.** Key the cache by dates, item, variant and quantity, with a TTL of about a minute. The TTL is your trade-off between quota and stale answers; the code below uses 60 seconds. Ask only for the entries that are missing.
- **Never show internals.** The response lists the orders that hold the stock (`commitments`) with their numbers. They are other customers' orders: strip them before anything reaches the browser.

**cURL**

```bash
BASE="${FILMO_BASE_URL:-https://app.filmo.io}"
curl -sS -X POST "$BASE/api/v1/availability/check" \
  -H "Authorization: Bearer $FILMO_API_KEY" \
  -H "Content-Type: application/json" \
  --data @- <<'JSON'
{
  "startDate": "2026-11-02T09:00:00+01:00",
  "endDate": "2026-11-04T18:00:00+01:00",
  "items": [
    { "itemId": "ITEM_ID_1", "quantity": 2 },
    { "itemId": "ITEM_ID_2" }
  ]
}
JSON
```

**Node**

```javascript
import { filmo } from "./filmo-client.mjs";

const TTL_MS = 60_000;
const cache = new Map(); // "start|end|item|variant|quantity" -> { at, entry }
const keyOf = (start, end, i) => [start, end, i.itemId, i.variantId ?? "", i.quantity ?? 1].join("|");

// What the visitor may see: no order numbers, no stock figures of the business.
function toPublic(item, entry) {
  if ("error" in entry) return { itemId: item.itemId, state: "unknown" };
  if (entry.status === "ok") return { itemId: item.itemId, state: "available" };
  return {
    itemId: item.itemId,
    state: entry.status === "partial" ? "partial" : "unavailable",
    maxQuantity: Math.max(0, (item.quantity ?? 1) - entry.shortfall),
  };
}

// items: [{ itemId, quantity, variantId? }] for ONE date range.
// Returns { mode: "live", results } or { mode: "quote" } when you should not promise anything.
export async function checkRange(start, end, items) {
  const entries = new Array(items.length);
  const missing = [];
  items.forEach((item, index) => {
    const hit = cache.get(keyOf(start, end, item));
    if (hit && Date.now() - hit.at < TTL_MS) entries[index] = hit.entry;
    else missing.push(index);
  });

  try {
    for (let from = 0; from < missing.length; from += 100) {
      const slice = missing.slice(from, from + 100); // at most 100 items per query
      const res = await filmo("POST", "/api/v1/availability/check", {
        retries: 0, // never make a visitor wait out a 429
        body: { startDate: start, endDate: end, items: slice.map((index) => items[index]) },
      });
      if (!res.ok) return { mode: "quote", reason: res.status };
      slice.forEach((index, n) => {
        // One entry per requested item, in request order.
        cache.set(keyOf(start, end, items[index]), { at: Date.now(), entry: res.body.data[n] });
        entries[index] = res.body.data[n];
      });
    }
  } catch {
    return { mode: "quote", reason: "network" };
  }
  return { mode: "live", results: entries.map((entry, i) => toPublic(items[i], entry)) };
}
```

**Python**

```python
import time

from filmo_client import filmo

TTL = 60  # seconds
cache = {}  # "start|end|item|variant|quantity" -> (stored_at, entry)


def key_of(start, end, item):
    return "|".join([start, end, item["itemId"], item.get("variantId", ""), str(item.get("quantity", 1))])


# What the visitor may see: no order numbers, no stock figures of the business.
def to_public(item, entry):
    if "error" in entry:
        return {"itemId": item["itemId"], "state": "unknown"}
    if entry["status"] == "ok":
        return {"itemId": item["itemId"], "state": "available"}
    return {
        "itemId": item["itemId"],
        "state": "partial" if entry["status"] == "partial" else "unavailable",
        "maxQuantity": max(0, item.get("quantity", 1) - entry["shortfall"]),
    }


# items: [{"itemId", "quantity", "variantId"?}] for ONE date range.
# Returns {"mode": "live", "results": [...]} or {"mode": "quote"} when you should not promise anything.
def check_range(start, end, items):
    entries = [None] * len(items)
    missing = []
    for index, item in enumerate(items):
        hit = cache.get(key_of(start, end, item))
        if hit and time.monotonic() - hit[0] < TTL:
            entries[index] = hit[1]
        else:
            missing.append(index)

    try:
        for offset in range(0, len(missing), 100):  # at most 100 items per query
            chunk = missing[offset:offset + 100]
            status, body, _ = filmo(
                "POST", "/api/v1/availability/check",
                body={"startDate": start, "endDate": end, "items": [items[i] for i in chunk]},
                retries=0,  # never make a visitor wait out a 429
            )
            if status != 200:
                return {"mode": "quote", "reason": status}
            for n, index in enumerate(chunk):
                # One entry per requested item, in request order.
                cache[key_of(start, end, items[index])] = (time.monotonic(), body["data"][n])
                entries[index] = body["data"][n]
    except Exception:
        return {"mode": "quote", "reason": "network"}
    return {"mode": "live", "results": [to_public(items[i], e) for i, e in enumerate(entries)]}
```

*A response (abridged)*

```json
{
  "data": [
    {
      "itemId": "ITEM_ID_1",
      "variantId": null,
      "itemName": "Cinema camera body",
      "quantity": 2,
      "available": false,
      "status": "partial",
      "totalStock": 3,
      "reserved": 2,
      "minRemaining": 1,
      "shortfall": 1,
      "conflictingPeriods": ["2026-11-03"],
      "commitments": [ { "orderId": "...", "orderNumber": "...", "quantity": 2, "startDate": "...", "endDate": "..." } ],
      "checkedAt": "2026-10-09T09:12:00Z"
    },
    { "itemId": "ITEM_ID_2", "variantId": null, "error": { "code": "not_found", "message": "..." } }
  ]
}
```

A cached answer can be a minute old, so the check informs the visitor; it does not reserve anything. Two visitors can see the same last unit as available. The reservation in step 6 is what decides, and step 7 handles the loser.

## 4. Fall back to a quote on 429

The code above asks the client not to retry (`retries` set to 0) and returns `mode: "quote"` on a 429, on any other failure and on a network error. When that happens, show the same page with “Request a quote” instead of a stock label, and capture the request as a lead with the web form flow. The visitor never sees an error and the lead is not lost. Optionally, read `RateLimit-Remaining` from the response headers and switch to the quote mode before the quota runs out.

## 5. Create the order

[`POST /api/v1/orders`](https://docs.filmo.io/rest-api/endpoint-reference/orders/create-an-order) creates a draft order with the requested material. It is the validated equivalent of a request from a form: every identifier is checked against the account of the key before anything is written, and a single wrong one rejects the whole request with 400 and creates nothing.

> **The order is born at zero:** The create operation accepts no amounts of any kind: no prices, discounts, insurance or deposits, and no status, serial units, accessories or notes. The order's totals are zero until the team prices it in Filmo. Show the customer your own price or “the team will confirm the price”; do not read a price from the new order. The order also takes no reference of your own ([`externalReference`](https://docs.filmo.io/rest-api/objects/order#response-externalReference) is returned on reads but the API cannot write it), so keep your booking id and the Filmo order id together.

Send the `Idempotency-Key` `booking-<your booking id>-order`. If your server times out, repeat the call with the same key and body within 24 hours and you receive the first response, with `Idempotent-Replay: true`, instead of a second order. A different body with the same key is rejected with 422.

**cURL**

```bash
BASE="${FILMO_BASE_URL:-https://app.filmo.io}"
curl -sS -X POST "$BASE/api/v1/orders" \
  -H "Authorization: Bearer $FILMO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: booking-1042-order" \
  --data @- <<JSON
{
  "contactId": "$CONTACT_ID",
  "startDate": "2026-11-02T09:00:00+01:00",
  "endDate": "2026-11-04T18:00:00+01:00",
  "lines": [
    { "itemId": "ITEM_ID_1", "quantity": 2 },
    { "itemId": "ITEM_ID_2", "quantity": 1 }
  ]
}
JSON
```

**Node**

```javascript
// order.mjs
import { filmo } from "./filmo-client.mjs";

// booking: { id, orderId?, contactId, start, end, lines: [{ itemId, quantity, variantId? }] }
// booking.id is YOUR id of the booking; both keys derive from it.
export async function createBookingOrder(booking) {
  if (booking.orderId) return { ok: true }; // created before: skip straight to the reserve
  const res = await filmo("POST", "/api/v1/orders", {
    idempotencyKey: "booking-" + booking.id + "-order",
    body: { contactId: booking.contactId, startDate: booking.start, endDate: booking.end, lines: booking.lines },
  });
  if (!res.ok) {
    return { ok: false, outcome: res.status === 400 ? "invalid" : "retry-later", problem: res.body };
  }
  booking.orderId = res.body.data.id; // save it now, before reserving
  return { ok: true };
}
```

**Python**

```python
# order.py
from filmo_client import filmo


# booking: {"id", "order_id"?, "contact_id", "start", "end", "lines": [{"itemId", "quantity", "variantId"?}]}
# booking["id"] is YOUR id of the booking; both keys derive from it.
def create_booking_order(booking):
    if booking.get("order_id"):
        return {"ok": True}  # created before: skip straight to the reserve
    status, body, _ = filmo(
        "POST", "/api/v1/orders",
        body={
            "contactId": booking["contact_id"],
            "startDate": booking["start"],
            "endDate": booking["end"],
            "lines": booking["lines"],
        },
        idempotency_key="booking-" + booking["id"] + "-order",
    )
    if status != 201:
        return {"ok": False, "outcome": "invalid" if status == 400 else "retry-later", "problem": body}
    booking["order_id"] = body["data"]["id"]  # save it now, before reserving
    return {"ok": True}
```

## 6. Reserve it

[`POST /api/v1/orders/{orderId}/reserve`](https://docs.filmo.io/rest-api/endpoint-reference/orders/reserve-an-order) takes no body. It moves the order to `reserved`, checks the stock of every line, deducts it and creates the delivery and return tasks, so the team's schedule changes with this call; `meta.tasksCreated` says how many tasks were created. Reserving an order that is already reserved is not an error: it changes nothing and answers 200, so repeating it after a timeout is safe. It answers 404 if the order does not exist or is archived. The reservation can be undone: [`POST /api/v1/orders/{orderId}/unreserve`](https://docs.filmo.io/rest-api/endpoint-reference/orders/unreserve-an-order) returns the order to draft and frees the stock, and the order can be reserved again; cancelling it cannot be undone.

Decide whether to reserve at all. An order that is created at zero and reserved straight away takes stock before anyone has priced it. If your business reviews bookings first, stop after step 5 and let the team reserve the draft in Filmo.

Automations: creating and reserving fire the account's event workflows (order created, reserved, status changed, and the tasks the reservation creates). They run after the response, on a best-effort basis, and you cannot suppress them per request. List the active event workflows first (see [Testing](https://docs.filmo.io/rest-api/guides/testing)) and filter the trigger of any workflow that should not react by origin to exclude `api` and `mcp`.

**cURL**

```bash
BASE="${FILMO_BASE_URL:-https://app.filmo.io}"
curl -sS -X POST "$BASE/api/v1/orders/$ORDER_ID/reserve" \
  -H "Authorization: Bearer $FILMO_API_KEY" \
  -H "Idempotency-Key: booking-1042-reserve"
```

**Node**

```javascript
// order.mjs, continued
export async function reserveBookingOrder(booking) {
  const res = await filmo("POST", "/api/v1/orders/" + booking.orderId + "/reserve", {
    idempotencyKey: "booking-" + booking.id + "-reserve",
  });
  if (res.ok) return { outcome: "reserved", tasksCreated: res.body.meta.tasksCreated };
  if (res.status === 409 && res.body?.code === "insufficient_stock") {
    // Nothing was reserved; the order is still a draft.
    const short = res.body.errors.map(({ path, itemId, requested, available, shortfall }) => ({
      path, itemId, requested, available, shortfall,
    }));
    return { outcome: "short", short };
  }
  return { outcome: "retry-later", problem: res.body };
}

export async function book(booking) {
  const created = await createBookingOrder(booking);
  if (!created.ok) return created;
  return { orderId: booking.orderId, ...(await reserveBookingOrder(booking)) };
}
```

**Python**

```python
# order.py, continued
def reserve_booking_order(booking):
    status, body, _ = filmo(
        "POST", "/api/v1/orders/" + booking["order_id"] + "/reserve",
        idempotency_key="booking-" + booking["id"] + "-reserve",
    )
    if status == 200:
        return {"outcome": "reserved", "tasks_created": body["meta"]["tasksCreated"]}
    if status == 409 and (body or {}).get("code") == "insufficient_stock":
        # Nothing was reserved; the order is still a draft.
        short = [
            {k: e.get(k) for k in ("path", "itemId", "requested", "available", "shortfall")}
            for e in body["errors"]
        ]
        return {"outcome": "short", "short": short}
    return {"outcome": "retry-later", "problem": body}


def book(booking):
    created = create_booking_order(booking)
    if not created["ok"]:
        return created
    return {"order_id": booking["order_id"], **reserve_booking_order(booking)}
```

## 7. Handle insufficient stock

If a line cannot be served, [`POST /api/v1/orders/{orderId}/reserve`](https://docs.filmo.io/rest-api/endpoint-reference/orders/reserve-an-order) answers 409 [`insufficient_stock`](https://docs.filmo.io/rest-api/errors/insufficient_stock). Nothing is reserved and the order stays a draft. `errors[]` has one entry per item that is short, with the `itemId`, the units `requested`, the units `available` for the order's dates and the `shortfall`. A `path` of `spaces` instead of `items` means a space is short. The same 409 status also carries [`conflict`](https://docs.filmo.io/rest-api/errors/conflict) for other causes, so branch on `code` first and then on `errors[0].code` of the conflict: `invalid_state` (the order is not a draft, a serial number is taken), `in_progress` (a request with the same key is still running) or `lock_timeout` (another write held the order for more than five seconds).

*The 409 body (abridged)*

```json
{
  "type": "https://docs.filmo.io/errors/insufficient_stock",
  "status": 409,
  "code": "insufficient_stock",
  "errors": [
    { "path": "items", "itemId": "ITEM_ID_1", "requested": 2, "available": 1, "shortfall": 1 }
  ],
  "requestId": "req_..."
}
```

What next is a business decision. The API cannot edit the lines of an order ([`PATCH /api/v1/orders/{orderId}`](https://docs.filmo.io/rest-api/endpoint-reference/orders/update-the-header-of-an-order) changes the customer, project and dates only), so you cannot reduce quantities on the same order. Your options:

- Keep the draft, tell the customer what is available (`available` per item), and let the team adjust it in Filmo. This loses nothing.
- Offer different dates or smaller quantities and start a new booking with a new booking id, which creates a new order. The first draft stays; archive it with the team, or cancel it with [`POST /api/v1/orders/{orderId}/cancel`](https://docs.filmo.io/rest-api/endpoint-reference/orders/cancel-an-order), which cannot be undone through the API.
- Ask the customer to request a quote for the missing units.

> **Info:** Check availability again after a conflict or a short result: the cache you used for the page is by now older than the stock.

## What to store on your side

| What | Why |
| --- | --- |
| Your booking id | The root of both idempotency keys. |
| The Filmo order id, as soon as the create returns | Retries skip the create and go straight to the reserve, even after the 24 hours of the key. |
| The outcome: reserved, short, invalid or retry later | What the customer was told, and what the team must follow up. |
| `requestId` of failed calls | What you quote to support. |

## Handle errors

| Response | What to do |
| --- | --- |
| 429 `rate_limited` on availability | Do not retry for the visitor. Fall back to the quote. `RateLimit-*` says which quota ran out. |
| 429 `rate_limited` on create or reserve | Wait `Retry-After` (also `retryAfter` in the problem body) and repeat with the same key. |
| 400 `validation_error` | Read `errors[].path`: `contactId` or `lines[0].itemId` with `not_found` means an id that does not belong to the account; `endDate` with `must_be_after` means the dates are the wrong way round. Nothing was written. |
| 409 `insufficient_stock` | Step 7. |
| 409 `conflict` | Read `errors[0].code`. `in_progress` and `lock_timeout`: wait and repeat with the same key; do not send a second request in parallel. `invalid_state`: do not retry; read the order with `GET /api/v1/orders/{orderId}` to see its status. |
| 422 `idempotency_key_reused` | The same booking id was sent with different content. Use a new booking id for a changed booking. |
| 403 `insufficient_scope`, 403 `forbidden` | Configuration of the key (`requiredScope` names the missing scope) or the key's owner lacks the permission; fix it in the app. Queue the booking and alert. |
| 500 `internal_error` | Repeat with the same key and growing waits. If the answer carries `Idempotent-Replay: true`, the stored error is being replayed: stop repeating and look the order up with `GET /api/v1/orders` before creating another. |
| 503 `service_unavailable` | No operation of this tutorial documents it. If it arrives, Filmo controls the switch behind it: do not retry it as a transient failure. Queue the booking and alert with the `requestId`. |

## Where to go next

- [Rate limits](https://docs.filmo.io/rest-api/guides/rate-limits) and [Idempotency](https://docs.filmo.io/rest-api/guides/idempotency).
- [Turn a web form into an opportunity](https://docs.filmo.io/rest-api/tutorials/web-form-to-opportunity): find or create the customer, and capture quote requests.
- [Errors](https://docs.filmo.io/rest-api/guides/errors): the retry rules for every status.
- [Going live](https://docs.filmo.io/rest-api/guides/going-live).
- In the app: [Reserve and release stock](https://www.filmo.io/help/reference/orders/prices-and-availability/how-equipment-availability-works).

---

Canonical URL: https://docs.filmo.io/rest-api/tutorials/availability-to-reserved-order  
Last updated: 2026-10-09