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.

DataOperationHow you keep it current
Catalog itemsGET /api/v1/itemsWindowed pass on updatedAt; a detail read with GET /api/v1/items/{itemId} for fields the list omits
Physical unitsGET /api/v1/serialized-itemsWindowed 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/ordersWindowed pass on updatedAt
Order linesGET /api/v1/orders/{orderId}/linesRe-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/v1/availability/check, which has its own quota of 30 requests per minute; see Availability to reserved order.

  • An API key with account:read, items:read and 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: 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

Link to this section: 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 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}"

Then read the account with GET /api/v1/account. 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"

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

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/v1/items/{itemId} for the items that need them, and budget one call per item.

GET /api/v1/serialized-items 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"

Step 4. Load the orders and re-read their lines

Link to this section: 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. 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"
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
  }
}

For 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.

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 (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
}

An 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.

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

GET /api/v1/serialized-items and GET /api/v1/orders sweep the same way; confirm missing units with GET /api/v1/serialized-items/{serializedItemId} and missing orders with GET /api/v1/orders/{orderId}. GET /api/v1/items and GET /api/v1/serialized-items return every record they can see. GET /api/v1/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.

StoreWhy
Filmo id next to your own id, for items, units and ordersIdentifiers are opaque; never derive one from another. The table is your only mapping.
The updatedAt you last saw, per recordThe weekly sweep compares it to find updates a pass missed.
The watermark B of the last complete passThe next pass starts there. It moves only after a full pass.
The lines of each order, replaced as a setA line has no updatedAt, so you replace the whole set whenever you re-read it.
A flag for archived or deleted records, not a deleteA 404 on an order means archived or deleted; the sweep decides later.

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

ResponseWhat it means hereWhat to do
401 api_key_owner_inactive or api_key_owner_not_adminThe 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_scopeThe key lacks a scope; requiredScope names it.Reissue the key with items:read and orders:read.
404 not_foundOn 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_limitedYou spent the 120 requests of the minute.Wait for Retry-After seconds, then continue the same window.
400 validation_errorA 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 504A transient failure. Reads are safe to repeat.Retry the same window with exponential backoff. Do not move the watermark.
  • Guides

    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 while pagination.hasMore is true call again with the nextCursor you 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.