Tutorials
Sync your inventory with another system
You read the catalog, the physical units and the lines of the orders that commit them, load everything once, then poll for changes and reconcile weekly. Filmo does not send webhooks, so polling is how you stay current.
This tutorial shows only the calls that are specific to inventory. The windowed pass, the safety margin, the reconciliation sweep and the reasons behind them are defined in Syncing data; read that guide first and use this page as its worked example.
What you read
Link to this section: What you read| Data | Operation | How you keep it current |
|---|---|---|
| Catalog items | GET /api | Windowed pass on updatedAt; a detail read with GET /api for fields the list omits |
| Physical units | GET /api | Windowed pass on updatedAt; the unit says which order has prepared, picked up, sold or got it back in currentOrderId; a reservation is not there, read it in serials |
| Orders (the header) | GET /api | Windowed pass on updatedAt |
| Order lines | GET /api | Re-read per order: a line has no updatedAt of its own, and a change to the lines alone does not move the order's |
serializedItemsCount tells you how many units an item has, but an item carries no stock level for a date. To ask what is free between two dates, use POST /api, which has its own quota of 30 requests per minute; see Availability to reserved order.
Prerequisites
Link to this section: Prerequisites- An API key with
account:read,items:readandorders:read. Create it in the API and MCP settings of the app (Ajustes → API y MCP); only administrators can. A read-only key is enough for the whole tutorial. - Node 22 or later, or Python 3 with
requests, and the key in theFILMO_API_KEYenvironment variable. - The idea of the windowed pass from Syncing data: ask for a half-open window of
updatedAt, one page, and split the window ifpagination.ishasMore true. Never follow the cursor to decide what changed.
Step 1. Set up the client and read the account
Link to this section: Step 1. Set up the client and read the accountSave the client once; every later snippet imports it. It waits out a rate_limited response for as many seconds as Retry-After says, and it returns the server's own time from the Date header, which the windows need. The cURL snippets in the later steps reuse the FILMO_URL variable defined in the cURL tab.
# Export FILMO_API_KEY first: a key with the scopes listed above.
FILMO_URL="${FILMO_BASE_URL:-https://app.filmo.io}"
// filmo.mjs: a small client. Node 22 or later, no dependencies.
export const API = (process.env.FILMO_BASE_URL ?? "https://app.filmo.io") + "/api/v1";
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// GET with query parameters. Waits out a 429 as Retry-After says.
export async function get(path, query = {}, headers = {}) {
const url = new URL(API + path);
for (const [key, value] of Object.entries(query)) url.searchParams.set(key, String(value));
for (;;) {
const res = await fetch(url, {
headers: { Authorization: ["Bearer", process.env.FILMO_API_KEY].join(" "), ...headers },
});
if (res.status === 429) {
await sleep(Number(res.headers.get("Retry-After") ?? 1) * 1000);
continue;
}
if (res.status === 304) return { status: 304, etag: res.headers.get("ETag") };
if (!res.ok) {
throw Object.assign(new Error(res.status + " " + (await res.text())), { status: res.status });
}
return {
...(await res.json()),
status: res.status,
etag: res.headers.get("ETag"),
serverTime: new Date(res.headers.get("Date")), // the server's clock, not yours
};
}
}# filmo.py: a small client. Python 3 and requests.
import os
import time
from email.utils import parsedate_to_datetime
import requests
API = os.environ.get("FILMO_BASE_URL", "https://app.filmo.io") + "/api/v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['FILMO_API_KEY']}"
def get(path, headers=None, **query):
"""GET with query parameters. Waits out a 429 as Retry-After says."""
while True:
res = session.get(API + path, params=query, headers=headers)
if res.status_code == 429:
time.sleep(int(res.headers.get("Retry-After", "1")))
continue
if res.status_code == 304:
return {"status": 304, "etag": res.headers.get("ETag")}
res.raise_for_status()
body = res.json()
body["status"] = res.status_code
body["etag"] = res.headers.get("ETag")
body["serverTime"] = parsedate_to_datetime(res.headers["Date"]) # the server's clock
return bodyThen read the account with GET /api. timezone is the business timezone and rateLimits reports the quotas of your key; the global one is 120 requests per minute.
# Export FILMO_API_KEY first: a key with the scopes listed above.
FILMO_URL="${FILMO_BASE_URL:-https://app.filmo.io}"
curl -sS "$FILMO_URL/api/v1/account" \
-H "Authorization: Bearer $FILMO_API_KEY"import { get } from "./filmo.mjs";
const { data: account } = await get("/account");
console.log(account.timezone, account.currency, account.rateLimits.global.perMinute);from filmo import get
account = get("/account")["data"]
print(account["timezone"], account["currency"], account["rateLimits"]["global"]["perMinute"])Step 2. Load the catalog
Link to this section: Step 2. Load the catalogThe first load reads every item. Pick a start earlier than your first record (set START in the helper to the month of your first record), fix the end B at the server's time minus the safety margin, and walk the range in 30-day windows with updatedFrom and updatedBefore, sorted by sort updatedAt and with limit 500. Each window is one page; the helper splits an overflowing window in half and deduplicates by id.
FILMO_URL="${FILMO_BASE_URL:-https://app.filmo.io}"
# One window [updatedFrom, updatedBefore), one page. If pagination.hasMore is true, split the window.
curl -sS -G "$FILMO_URL/api/v1/items" \
-H "Authorization: Bearer $FILMO_API_KEY" \
--data-urlencode "updatedFrom=2026-09-01T00:00:00.000Z" \
--data-urlencode "updatedBefore=2026-10-01T00:00:00.000Z" \
--data-urlencode "sort=updatedAt" \
--data-urlencode "limit=500"// window.mjs: the windowed read of the syncing guide, one page per query.
import { get } from "./filmo.mjs";
const iso = (ms) => new Date(ms).toISOString();
export const START = Date.parse("2020-01-01T00:00:00Z"); // move it to the month of your first record
// B: the server's time minus the safety margin of the guide (2 minutes and 1 second). The time is the
// Date header of a response, never your own clock.
export async function upperBound() {
const { serverTime } = await get("/account");
return serverTime.getTime() - (2 * 60 * 1000 + 1000);
}
// Every record whose updatedAt (or createdAt) falls in [from, to), in epoch milliseconds.
// It never follows the cursor: a window that overflows one page is split in half.
export async function windowed(path, from, to, { field = "updated", query = {} } = {}) {
const page = await get(path, {
...query,
[field + "From"]: iso(from),
[field + "Before"]: iso(to),
sort: field + "At",
limit: 500,
});
if (!page.pagination.hasMore) return page.data;
if (to - from <= 1) throw new Error("More than 500 records share one millisecond: see the guide");
const mid = Math.floor((from + to) / 2);
const merged = new Map();
for (const half of [
await windowed(path, from, mid, { field, query }),
await windowed(path, mid, to, { field, query }),
]) {
for (const record of half) merged.set(record.id, record); // deduplicate by id
}
return [...merged.values()];
}
// Walks [start, end) in 30-day windows. Returns a Map of id to record.
export async function readRange(path, start, end, options) {
const step = 30 * 24 * 3600 * 1000;
const records = new Map();
for (let from = start; from < end; from += step) {
for (const record of await windowed(path, from, Math.min(from + step, end), options)) {
records.set(record.id, record);
}
}
return records;
}# window.py: the windowed read of the syncing guide, one page per query.
from datetime import datetime, timezone
from filmo import get
START = int(datetime(2020, 1, 1, tzinfo=timezone.utc).timestamp() * 1000) # move it to the month of your first record
def upper_bound():
"""B: the server's time minus the safety margin of the guide (2 minutes and 1 second).
The time is the Date header of a response, never your own clock."""
return int(get("/account")["serverTime"].timestamp() * 1000) - (2 * 60 * 1000 + 1000)
def iso(ms):
stamp = datetime.fromtimestamp(ms / 1000, tz=timezone.utc)
return stamp.isoformat(timespec="milliseconds").replace("+00:00", "Z")
def windowed(path, start, end, field="updated", **query):
"""Every record whose updatedAt (or createdAt) falls in [start, end), in epoch ms.
It never follows the cursor: a window that overflows one page is split in half."""
page = get(
path,
**query,
**{field + "From": iso(start), field + "Before": iso(end), "sort": field + "At", "limit": 500},
)
if not page["pagination"]["hasMore"]:
return page["data"]
if end - start <= 1:
raise RuntimeError("More than 500 records share one millisecond: see the guide")
mid = (start + end) // 2
merged = {r["id"]: r for r in windowed(path, start, mid, field, **query)}
merged.update({r["id"]: r for r in windowed(path, mid, end, field, **query)})
return list(merged.values())
def read_range(path, start, end, field="updated", **query):
"""Walks [start, end) in 30-day windows. Returns a dict of id to record."""
step = 30 * 24 * 3600 * 1000
records = {}
for begin in range(start, end, step):
for record in windowed(path, begin, min(begin + step, end), field, **query):
records[record["id"]] = record
return recordsFILMO_URL="${FILMO_BASE_URL:-https://app.filmo.io}"
# One window [updatedFrom, updatedBefore), one page. If pagination.hasMore is true, split the window.
curl -sS -G "$FILMO_URL/api/v1/items" \
-H "Authorization: Bearer $FILMO_API_KEY" \
--data-urlencode "updatedFrom=2026-09-01T00:00:00.000Z" \
--data-urlencode "updatedBefore=2026-10-01T00:00:00.000Z" \
--data-urlencode "sort=updatedAt" \
--data-urlencode "limit=500"import { readRange, START, upperBound } from "./window.mjs";
const B = await upperBound(); // the end of this load; save it as the watermark when every list is read
const items = await readRange("/items", START, B);
console.log(items.size + " items");from window import START, read_range, upper_bound
B = upper_bound() # the end of this load; save it as the watermark when every list is read
items = read_range("/items", START, B)
print(len(items), "items")The list returns the fields you need to mirror a catalog: name, sku, ean, stockCalculationMethod (bulk or serialized), collectionId, variants and salePrice (a decimal string). Replacement value, accessories and alternatives are in the detail only; read them with GET /api for the items that need them, and budget one call per item.
Step 3. Load the physical units
Link to this section: Step 3. Load the physical unitsGET /api lists every unit, in every status, unless you filter with status; the values the filter accepts are available, prepared, picked_up, returned, broken, archived and sold. Do not filter in the sync: you want to see a unit change status, and an archived or sold unit must not look deleted. inventoryType separates your own stock (regular) from stock brought in for a job (temporary). The unit carries the order that has prepared, picked up, sold or got it back, so you can answer "where is serial number X" for those states without reading lines; a unit that is only reserved shows its order in the serials of the order line.
# The units of one catalog item, with where each one is committed right now.
curl -sS -G "$FILMO_URL/api/v1/serialized-items" \
-H "Authorization: Bearer $FILMO_API_KEY" \
--data-urlencode "itemId=$ITEM_ID" \
--data-urlencode "limit=500"import { readRange, START, upperBound } from "./window.mjs";
const B = await upperBound();
const units = await readRange("/serialized-items", START, B);
for (const unit of units.values()) {
// unit.currentOrderId is the order that has prepared, picked up, sold or got the unit back.
// It is null otherwise, also when the unit is only reserved on an order.
console.log(unit.code, unit.status, unit.itemId, unit.currentOrderId);
}from window import START, read_range, upper_bound
B = upper_bound()
units = read_range("/serialized-items", START, B)
for unit in units.values():
# unit["currentOrderId"] is the order that has prepared, picked up, sold or got the unit back.
# It is None otherwise, also when the unit is only reserved on an order.
print(unit["code"], unit["status"], unit["itemId"], unit["currentOrderId"])Step 4. Load the orders and re-read their lines
Link to this section: Step 4. Load the orders and re-read their linesRead the orders with the same helper, then read the lines of each order with GET /api. Pass includeDelivery set to true: without it, lines added during delivery are left out and your stock picture is short. Lines belong to the order and are only reachable through it; there is no list of lines on its own.
# The lines of one order, including the ones added during delivery.
curl -sS -G "$FILMO_URL/api/v1/orders/$ORDER_ID/lines" \
-H "Authorization: Bearer $FILMO_API_KEY" \
--data-urlencode "includeDelivery=true" \
--data-urlencode "limit=500"// orders.mjs
import { get } from "./filmo.mjs";
// Lines are only reachable through their order, and they have no updatedAt.
export async function readLines(orderId) {
const lines = [];
let cursor;
do {
const page = await get("/orders/" + orderId + "/lines", {
includeDelivery: true,
limit: 500,
...(cursor && { cursor }),
});
lines.push(...page.data);
cursor = page.pagination.hasMore ? page.pagination.nextCursor : undefined;
} while (cursor);
return lines;
}# orders.py
from filmo import get
def read_lines(order_id):
"""Lines are only reachable through their order, and they have no updatedAt."""
lines, cursor = [], None
while True:
query = {"includeDelivery": "true", "limit": 500}
if cursor:
query["cursor"] = cursor
page = get("/orders/" + order_id + "/lines", **query)
lines += page["data"]
if not page["pagination"]["hasMore"]:
return lines
cursor = page["pagination"]["nextCursor"]import { readRange, START, upperBound } from "./window.mjs";
import { readLines } from "./orders.mjs";
const B = await upperBound();
const orders = await readRange("/orders", START, B);
for (const order of orders.values()) {
try {
const lines = await readLines(order.id);
// Replace the lines you hold for order.id with these.
for (const line of lines) console.log(order.orderNumber, line.itemId, line.quantity, line.serials.length);
} catch (error) {
if (error.status !== 404) throw error; // 404: archived or deleted since the list was read
}
}import requests
from orders import read_lines
from window import START, read_range, upper_bound
B = upper_bound()
orders = read_range("/orders", START, B)
for order in orders.values():
try:
for line in read_lines(order["id"]):
# Replace the lines you hold for this order with these.
print(order["orderNumber"], line["itemId"], line["quantity"], len(line["serials"]))
except requests.HTTPError as error:
if error.response.status_code != 404: # 404: archived or deleted since the list was read
raiseFor inventory, the useful line fields are itemId, variantId, quantity, serials (the specific units assigned, empty if none yet), status, addedDuringDelivery, and the period in startDate and endDate, which are null when the line inherits the order period. Lines of type section and type_group are presentation groupings with no resource; skip them.
Lines have no updatedAt
id identifies a line, but nothing tells you when it changed, and a change to the lines alone does not move the updatedAt of the order. Saving the order with new dates, reserving, unreserving, cancelling and editing the order without dates do move it; a bulk return of lines moves it only when every active line ends in a final state. So the order appearing in a window is not enough: re-read the lines of every order that appears in a window and, on a schedule (once a day is the least; hourly if stock must be current), the lines of the orders you hold as still open (any status other than checked_in or cancelled; archived orders cannot be read). A full save of an order writes its lines after the order is committed, so a line read right after the order changes can still be the old one: the next re-read fixes it. An order with more than 500 lines needs the cursor: read all its pages in one go, and if the order's updatedAt moved while you read, read them again.
Step 5. Keep it current
Link to this section: Step 5. Keep it currentEvery few minutes, run one pass over [A, B): A is the watermark you saved, B is the server's time minus the margin. The watermark moves to B only when every list was read; if the pass is interrupted, the next one repeats the same window. Records that change while you read fall into a later window. The updatedAt of a record comes from the application clock and is not monotonic at commit: some writes stamp it one second earlier, and work after the commit can take longer. So keep the margin at two minutes or more, start each window with updatedFrom (inclusive) and deduplicate by id. Filmo gives no guaranteed upper bound for the delay, so the weekly reconciliation stays in place. The guide explains the algorithm and why the cursor cannot give you a guarantee.
// poll.mjs: one incremental pass. Run it every few minutes.
import { get } from "./filmo.mjs";
import { readRange } from "./window.mjs";
import { readLines } from "./orders.mjs";
const MARGIN = 2 * 60 * 1000 + 1000; // the guide's safety margin: 2 minutes and 1 second
export async function pollOnce(state) {
// state = { watermark } in epoch ms, kept in your database or a file.
const { serverTime } = await get("/account");
const A = state.watermark;
const B = serverTime.getTime() - MARGIN;
if (B <= A) return state;
const items = await readRange("/items", A, B);
const units = await readRange("/serialized-items", A, B);
const orders = await readRange("/orders", A, B);
// ...upsert items and units by id here...
// Lines have no updatedAt: re-read the lines of every order that appears in the window.
for (const order of orders.values()) {
const lines = await readLines(order.id);
// ...replace the lines you hold for order.id...
}
return { watermark: B }; // moves only when every list above was read
}# poll.py: one incremental pass. Run it every few minutes.
from filmo import get
from window import read_range
from orders import read_lines
MARGIN = 2 * 60 * 1000 + 1000 # the guide's safety margin: 2 minutes and 1 second
def poll_once(state):
"""state = {"watermark": epoch ms}, kept in your database or a file."""
server_time = get("/account")["serverTime"]
a = state["watermark"]
b = int(server_time.timestamp() * 1000) - MARGIN
if b <= a:
return state
items = read_range("/items", a, b)
units = read_range("/serialized-items", a, b)
orders = read_range("/orders", a, b)
# ...upsert items and units by id here...
# Lines have no updatedAt: re-read the lines of every order that appears in the window.
for order in orders.values():
lines = read_lines(order["id"])
# ...replace the lines you hold for this order...
return {"watermark": b} # moves only when every list above was readAn order that is archived answers 404 on its GET and disappears from the list; so does a deleted one. When not_found comes back for an order you hold, mark it as archived or deleted and check it again at the next sweep rather than deleting it on your side. See Archiving and deleting.
Step 6. Reconcile once a week
Link to this section: Step 6. Reconcile once a weekDeleting is final and leaves no trace: deleting an item also deletes its serialized units, and its GET answers 404 from then on. A pass cannot tell you about a deletion, and a sweep that follows the cursor cannot decide one, because a record skipped by a concurrent change looks the same as a deleted one. So the sweep lists windows of createdFrom and createdBefore (every list in this tutorial has them), compares what comes back with what you hold, and confirms every missing id with its own GET. Only a 404 counts. The sweep also compares updatedAt and re-reads the records that differ, which recovers any update the passes missed.
# A week later: list a createdAt window...
curl -sS -G "$FILMO_URL/api/v1/items" \
-H "Authorization: Bearer $FILMO_API_KEY" \
--data-urlencode "createdFrom=2026-09-01T00:00:00.000Z" \
--data-urlencode "createdBefore=2026-10-01T00:00:00.000Z" \
--data-urlencode "sort=createdAt" \
--data-urlencode "limit=500"
# ...and confirm every id you hold that is missing from it. Only a 404 counts.
curl -sS -o /dev/null -w "%{http_code}\n" "$FILMO_URL/api/v1/items/$MISSING_ITEM_ID" \
-H "Authorization: Bearer $FILMO_API_KEY"// sweep.mjs: weekly reconciliation of one list.
import { get } from "./filmo.mjs";
import { readRange } from "./window.mjs";
// held: Map of id to the updatedAt you stored. Returns what to re-read and what is gone.
export async function sweep(path, held, start, end) {
const live = await readRange(path, start, end, { field: "created" });
// Updates the early passes may have skipped: the stored updatedAt differs.
const stale = [...live].filter(([id, record]) => held.get(id) !== record.updatedAt).map(([id]) => id);
// A missing id is not a deletion until its own GET answers 404.
const gone = [];
for (const id of held.keys()) {
if (live.has(id)) continue;
try {
await get(path + "/" + id); // still there: the sweep skipped it, do not delete
} catch (error) {
if (error.status !== 404) throw error;
gone.push(id); // for an order, 404 means archived or deleted
}
}
return { stale, gone };
}# sweep.py: weekly reconciliation of one list.
import requests
from filmo import get
from window import read_range
def sweep(path, held, start, end):
"""held: dict of id to the updatedAt you stored. Returns what to re-read and what is gone."""
live = read_range(path, start, end, field="created")
# Updates the early passes may have skipped: the stored updatedAt differs.
stale = [i for i, record in live.items() if held.get(i) != record["updatedAt"]]
# A missing id is not a deletion until its own GET answers 404.
gone = []
for record_id in held:
if record_id in live:
continue
try:
get(path + "/" + record_id) # still there: the sweep skipped it, do not delete
except requests.HTTPError as error:
if error.response.status_code != 404:
raise
gone.append(record_id) # for an order, 404 means archived or deleted
return {"stale": stale, "gone": gone}GET /api and GET /api sweep the same way; confirm missing units with GET /api and missing orders with GET /api. GET /api and GET /api return every record they can see. GET /api does not: it never returns archived orders, and no parameter includes them, so an order that the team archives drops out of every pass and every sweep. That is why a missing order is confirmed with its GET and a 404 is recorded as “archived or deleted”, not as a deletion. The delivery lines of an order are handled above.
What to store on your side
Link to this section: What to store on your side| Store | Why |
|---|---|
Filmo id next to your own id, for items, units and orders | Identifiers are opaque; never derive one from another. The table is your only mapping. |
The updatedAt you last saw, per record | The weekly sweep compares it to find updates a pass missed. |
| The watermark B of the last complete pass | The next pass starts there. It moves only after a full pass. |
| The lines of each order, replaced as a set | A line has no updatedAt, so you replace the whole set whenever you re-read it. |
| A flag for archived or deleted records, not a delete | A 404 on an order means archived or deleted; the sweep decides later. |
Call budget against 120 requests per minute
Link to this section: Call budget against 120 requests per minuteYour key allows 120 requests per minute across the whole API, and every authenticated response counts, including a 404 or a 429. Count the calls before you start. The helper walks the range in fixed 30-day windows. For a list read in W windows of which S overflow one page, the cost is W + 2S calls: each overflow costs the failed call plus two halves. W follows from your start date, not from the number of records: a range of 24 months is 24 windows. The initial load is the three lists, one call for the server's time, and one lines call per order (more only for orders with over 500 lines):
calls = 1 + (W_items + 2*S_items) + (W_units + 2*S_units) + (W_orders + 2*S_orders) + O
minutes = calls / 120Worked example. An account has 2,400 items, 9,000 units and 3,000 orders, and its records go back 24 months, so each list takes 24 windows of 30 days. Suppose 1 window of items overflows (the month the catalog was imported), 2 of units and 3 of orders. That is 1 + (24 + 2) + (24 + 4) + (24 + 6) + 3,000 = 3,085 calls, or 25.7 minutes at the full 120 per minute. Leaving a fifth of the quota to other integrations (96 per minute), it takes 32.1 minutes. The lines dominate: 3,000 of the 3,085 calls.
In steady state a pass costs one call for the time plus one per list, so 4 per pass; an hourly pass is 96 calls a day. Add one lines call for each order re-read. If 120 orders change in a day and you re-read the 450 you hold as open once a day, the day costs 96 + 120 + 450 = 666 calls, 5.6 minutes of quota out of the 1,440 minutes in a day (0.4%). The weekly sweep costs about the same list calls as the load (84 in the example) plus one GET per missing id.
Spread the initial load: run it at a quiet hour and let the client sleep when it receives rate_limited.
Error handling
Link to this section: Error handling| Response | What it means here | What to do |
|---|---|---|
401 api_key_owner_inactive or api_key_owner_not_admin | The person who created the key left or stopped being an administrator. | Stop the job, alert someone, and reissue the key from an account that is still an administrator. |
403 insufficient_scope | The key lacks a scope; requiredScope names it. | Reissue the key with items:read and orders:read. |
404 not_found | On an order: archived or deleted. On an item or unit: deleted. Also outside your account. | Flag the record and confirm it at the weekly sweep. Do not delete on your side on the first 404 from a list read. |
429 rate_limited | You spent the 120 requests of the minute. | Wait for Retry-After seconds, then continue the same window. |
400 validation_error | A parameter is malformed; errors[] lists each one. | Fix the request. errors[] names the field. Check on your side that updatedFrom is before updatedBefore: the list is not documented to reject an inverted window. |
| 500, 502 or 504 | A transient failure. Reads are safe to repeat. | Retry the same window with exponential backoff. Do not move the watermark. |
Where to go next
Link to this section: Where to go next- Syncing data: the algorithm, the proofs and the lists that hide records by default.
- Filtering and sorting and Pagination: the
updated*andcreated*filters and why the cursor is an offset. - Rate limits: the headers that tell you how much quota is left.
- Availability to reserved order: answer "is it free on these dates" for a booking site.
- Going live: a checklist before the sync runs in production.
Related pages
Link to this section: Related pagesGuides
Syncing data
Filmo v1 does not send webhooks, so you keep another system in step by polling: read what changed in half-open time windows, and run a separate sweep to find what was deleted. Do not follow the pagination cursor to decide what changed or what disappeared.
Guides
Pagination
Lists return one page at a time. Ask for a page with
limit, and whilepagination.ishasMore truecall again with thenextCursoryou were given.Guides
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.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.
Was this page helpful?