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
Link to this section: 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
Link to this section: Before you start| Scope | Used for |
|---|---|
account:read | The first call: timezone, currency and the rate limits of the key. |
availability:read | POST /api. |
items:read | GET /api, 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 and POST /api. |
orders:read | GET /api, 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.
- You need the Filmo id of each item (and variant) you sell. Read them once with
GET /apiand store them; identifiers are opaque./v1 /items - Set up the client from the first tutorial's step 1 (the same two files).
1. Know your limits
Link to this section: 1. Know your limits| Limit | Value |
|---|---|
| Availability queries | POST /api, POST /api and POST /api 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 carries Retry-After (Rate limits).
2. Keep the key on your server
Link to this section: 2. Keep the key on your serverThe 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.
BASE="${FILMO_BASE_URL:-https://app.filmo.io}"
curl -sS "$BASE/api/v1/account" \
-H "Authorization: Bearer $FILMO_API_KEY"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 });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
Link to this section: 3. Check availability with a cache and batchesPOST /api 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.
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" }
]
}
JSONimport { 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)) };
}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)]}{
"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
Link to this section: 4. Fall back to a quote on 429The 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
Link to this section: 5. Create the orderPOST /api 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 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.
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// 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 };
}# 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
Link to this section: 6. Reserve itPOST /api 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. 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 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) and filter the trigger of any workflow that should not react by origin to exclude api and mcp.
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"// 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)) };
}# 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
Link to this section: 7. Handle insufficient stockIf a line cannot be served, POST /api answers 409 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 for other causes, so branch on code first and then on errors[0]. 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).
{
"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 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 (
availableper 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, which cannot be undone through the API./v1 /orders /{orderId} /cancel - Ask the customer to request a quote for the missing units.
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
Link to this section: 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
Link to this section: 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[].: contactId or lines[0]. 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].. 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 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 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
Link to this section: Where to go next- Rate limits and Idempotency.
- Turn a web form into an opportunity: find or create the customer, and capture quote requests.
- Errors: the retry rules for every status.
- Going live.
- In the app: Reserve and release stock.
Related pages
Link to this section: Related pagesGuides
Rate limits
Each API key has 120 requests per minute, and the three availability checks share their own quota of 30 per minute. Past a limit the API answers 429 with
Retry-After.Guides
Idempotency and safe retries
Send an
Idempotency-Keyheader with every write and reuse the same value when you retry that operation: Filmo returns the stored result of the first attempt instead of writing twice.Tutorials
Turn a web form into an opportunity
Your server receives the form, finds or creates the contact by email, and opens an opportunity in the pipeline, with a key derived from the submission so that no retry creates a second lead.
Guides
Errors
Errors are RFC 9457 problem documents. Branch on the stable
code, never on the text, and use the retry table to decide whether to try again.Guides
Going live checklist
Go through these eight checks before your integration runs against production data. Each one points to the guide that explains it.
Was this page helpful?