*REST API › 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](https://docs.filmo.io/rest-api/guides/syncing-data); read that guide first and use this page as its worked example.

## What you read

| Data | Operation | How you keep it current |
| --- | --- | --- |
| Catalog items | `GET /api/v1/items` | Windowed pass on `updatedAt`; a detail read with `GET /api/v1/items/{itemId}` for fields the list omits |
| Physical units | `GET /api/v1/serialized-items` | 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/v1/orders` | Windowed pass on `updatedAt` |
| Order lines | `GET /api/v1/orders/{orderId}/lines` | 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`](https://docs.filmo.io/rest-api/objects/item#response-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/v1/availability/check`](https://docs.filmo.io/rest-api/endpoint-reference/availability/check-item-availability), which has its own quota of 30 requests per minute; see [Availability to reserved order](https://docs.filmo.io/rest-api/tutorials/availability-to-reserved-order).

## Prerequisites

- An API key with [`account:read`](https://docs.filmo.io/rest-api/guides/scopes#account:read), [`items:read`](https://docs.filmo.io/rest-api/guides/scopes#items:read) and [`orders:read`](https://docs.filmo.io/rest-api/guides/scopes#orders: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 the `FILMO_API_KEY` environment variable.
- The idea of the windowed pass from [Syncing data](https://docs.filmo.io/rest-api/guides/syncing-data): ask for a half-open window of `updatedAt`, one page, and split the window if `pagination.hasMore` is `true`. Never follow the cursor to decide what changed.

## Step 1. Set up the client and read the account

Save the client once; every later snippet imports it. It waits out a [`rate_limited`](https://docs.filmo.io/rest-api/errors/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.

*The client*

**cURL**

```bash
# Export FILMO_API_KEY first: a key with the scopes listed above.
FILMO_URL="${FILMO_BASE_URL:-https://app.filmo.io}"

```

**Node**

```javascript
// 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
    };
  }
}
```

**Python**

```python
# 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 body
```

Then read the account with [`GET /api/v1/account`](https://docs.filmo.io/rest-api/endpoint-reference/account/get-the-account). [`timezone`](https://docs.filmo.io/rest-api/objects/account#response-timezone) is the business timezone and [`rateLimits`](https://docs.filmo.io/rest-api/objects/account#response-rateLimits) reports the quotas of your key; the global one is 120 requests per minute.

**cURL**

```bash
# 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"
```

**Node**

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

const { data: account } = await get("/account");
console.log(account.timezone, account.currency, account.rateLimits.global.perMinute);
```

**Python**

```python
from filmo import get

account = get("/account")["data"]
print(account["timezone"], account["currency"], account["rateLimits"]["global"]["perMinute"])
```

## Step 2. Load the catalog

The 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`](https://docs.filmo.io/rest-api/endpoint-reference/items/list-items#param-updatedFrom) and [`updatedBefore`](https://docs.filmo.io/rest-api/endpoint-reference/items/list-items#param-updatedBefore), sorted by [`sort`](https://docs.filmo.io/rest-api/endpoint-reference/items/list-items#param-sort) `updatedAt` and with [`limit`](https://docs.filmo.io/rest-api/endpoint-reference/items/list-items#param-limit) 500. Each window is one page; the helper splits an overflowing window in half and deduplicates by `id`.

*The windowed read*

**cURL**

```bash
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"
```

**Node**

```javascript
// 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;
}
```

**Python**

```python
# 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 records
```

*Load all the items*

**cURL**

```bash
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"
```

**Node**

```javascript
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");
```

**Python**

```python
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`](https://docs.filmo.io/rest-api/objects/item#response-name), [`sku`](https://docs.filmo.io/rest-api/objects/item#response-sku), [`ean`](https://docs.filmo.io/rest-api/objects/item#response-ean), [`stockCalculationMethod`](https://docs.filmo.io/rest-api/objects/item#response-stockCalculationMethod) (`bulk` or `serialized`), [`collectionId`](https://docs.filmo.io/rest-api/objects/item#response-collectionId), [`variants`](https://docs.filmo.io/rest-api/objects/item#response-variants) and [`salePrice`](https://docs.filmo.io/rest-api/objects/item#response-salePrice) (a decimal string). Replacement value, accessories and alternatives are in the detail only; read them with [`GET /api/v1/items/{itemId}`](https://docs.filmo.io/rest-api/endpoint-reference/items/get-an-item) for the items that need them, and budget one call per item.

## Step 3. Load the physical units

[`GET /api/v1/serialized-items`](https://docs.filmo.io/rest-api/endpoint-reference/items/list-serialized-items) lists every unit, in every status, unless you filter with [`status`](https://docs.filmo.io/rest-api/endpoint-reference/items/list-serialized-items#param-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`](https://docs.filmo.io/rest-api/endpoint-reference/items/list-serialized-items#param-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.

**cURL**

```bash
# 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"
```

**Node**

```javascript
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);
}
```

**Python**

```python
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

Read the orders with the same helper, then read the lines of each order with [`GET /api/v1/orders/{orderId}/lines`](https://docs.filmo.io/rest-api/endpoint-reference/orders/list-the-lines-of-an-order). Pass [`includeDelivery`](https://docs.filmo.io/rest-api/endpoint-reference/orders/list-the-lines-of-an-order#param-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.

*Read the lines of an order*

**cURL**

```bash
# 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"
```

**Node**

```javascript
// 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;
}
```

**Python**

```python
# 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"]
```

*Load the orders and their lines*

**Node**

```javascript
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
  }
}
```

**Python**

```python
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
            raise
```

For inventory, the useful line fields are [`itemId`](https://docs.filmo.io/rest-api/objects/order-line#response-itemId), [`variantId`](https://docs.filmo.io/rest-api/objects/order-line#response-variantId), [`quantity`](https://docs.filmo.io/rest-api/objects/order-line#response-quantity), [`serials`](https://docs.filmo.io/rest-api/objects/order-line#response-serials) (the specific units assigned, empty if none yet), [`status`](https://docs.filmo.io/rest-api/objects/order-line#response-status), [`addedDuringDelivery`](https://docs.filmo.io/rest-api/objects/order-line#response-addedDuringDelivery), and the period in [`startDate`](https://docs.filmo.io/rest-api/objects/order-line#response-startDate) and [`endDate`](https://docs.filmo.io/rest-api/objects/order-line#response-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`](https://docs.filmo.io/rest-api/objects/order-line#response-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

Every 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`](https://docs.filmo.io/rest-api/endpoint-reference/items/list-items#param-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.

**Node**

```javascript
// 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
}
```

**Python**

```python
# 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 read
```

An order that is archived answers 404 on its GET and disappears from the list; so does a deleted one. When [`not_found`](https://docs.filmo.io/rest-api/errors/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](https://docs.filmo.io/further-reading/archiving-and-deleting).

## Step 6. Reconcile once a week

Deleting 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`](https://docs.filmo.io/rest-api/endpoint-reference/items/list-items#param-createdFrom) and [`createdBefore`](https://docs.filmo.io/rest-api/endpoint-reference/items/list-items#param-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.

**cURL**

```bash
# 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"
```

**Node**

```javascript
// 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 };
}
```

**Python**

```python
# 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/v1/serialized-items`](https://docs.filmo.io/rest-api/endpoint-reference/items/list-serialized-items) and [`GET /api/v1/orders`](https://docs.filmo.io/rest-api/endpoint-reference/orders/list-orders) sweep the same way; confirm missing units with [`GET /api/v1/serialized-items/{serializedItemId}`](https://docs.filmo.io/rest-api/endpoint-reference/items/get-a-serialized-item) and missing orders with [`GET /api/v1/orders/{orderId}`](https://docs.filmo.io/rest-api/endpoint-reference/orders/get-an-order). [`GET /api/v1/items`](https://docs.filmo.io/rest-api/endpoint-reference/items/list-items) and [`GET /api/v1/serialized-items`](https://docs.filmo.io/rest-api/endpoint-reference/items/list-serialized-items) return every record they can see. [`GET /api/v1/orders`](https://docs.filmo.io/rest-api/endpoint-reference/orders/list-orders) 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

| 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

Your 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):

*Initial load*

```text
calls = 1 + (W_items + 2*S_items) + (W_units + 2*S_units) + (W_orders + 2*S_orders) + O
minutes = calls / 120
```

Worked 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`](https://docs.filmo.io/rest-api/errors/rate_limited).

## 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

- [Syncing data](https://docs.filmo.io/rest-api/guides/syncing-data): the algorithm, the proofs and the lists that hide records by default.
- [Filtering and sorting](https://docs.filmo.io/rest-api/guides/filtering-and-sorting) and [Pagination](https://docs.filmo.io/rest-api/guides/pagination): the `updated*` and `created*` filters and why the cursor is an offset.
- [Rate limits](https://docs.filmo.io/rest-api/guides/rate-limits): the headers that tell you how much quota is left.
- [Availability to reserved order](https://docs.filmo.io/rest-api/tutorials/availability-to-reserved-order): answer "is it free on these dates" for a booking site.
- [Going live](https://docs.filmo.io/rest-api/guides/going-live): a checklist before the sync runs in production.

---

Canonical URL: https://docs.filmo.io/rest-api/tutorials/inventory-sync  
Last updated: 2026-10-09