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.
This guide gives the algorithm, the parameters each list needs and complete code in Node, Python and cURL. It assumes you know how lists paginate (Pagination) and how requests are limited (Rate limits).
What the API gives you
Link to this section: What the API gives you| You need | Use |
|---|---|
| The records that changed since your last run | On lists that have them, updatedFrom (on or after) and updatedBefore (before), with sort=updatedAt and limit=500. |
The same, on lists with no updated* filters | sort=-updatedAt, follow the cursor, and stop at your watermark minus an overlap. |
| To notice a deletion | A reconciliation sweep over createdAt windows, with a detail GET to confirm every missing id. |
| The time on Filmo's side | The Date header of any response. Never your own clock. |
| To be told that something changed | A workflow with an event trigger and an HTTP request step that calls your endpoint. It is a hint, not the data: keep the polling pass. See Push, below. |
Why following the cursor loses records
Link to this section: Why following the cursor loses recordsThe cursor encodes an internal offset. That is enough for a screen that shows a list once, and not enough for a sync that runs while people keep working in Filmo.
Say you read records in ascending updatedAt order, 500 at a time, and record 17 is updated while you read. It moves to the end of the order, every record after it moves up one position, and the record that sat at position 501 is now at position 500: an offset you already consumed. You never receive it. Its updatedAt lies between values you already read, below the watermark you are about to store, so no overlap of seconds or minutes brings it back. It stays lost until it changes again. Deleting a record you already read produces the same skip.
A cursor is also strict about what it came from. It is bound to the limit and the sort of the query that produced it, its offset must be a multiple of limit, and it is at most 200 characters long. Never build or edit one, and never change limit or sort while you follow it.
The algorithm below avoids the cursor: every query is one page of a time window, so no offset is carried from one query to the next.
The incremental pass
Link to this section: The incremental passFirst load: the full-pass parameters of each list (
overlay.), so nothing hidden by default is missed.syncDefaults Incremental pass, lists with
updated*filtersGET ...?updatedFrom=A&updatedBefore=B&sort=updatedAt&limit=500 A half-open window [A, B). B is the server time (the
Dateheader of the last response) minus a safety margin, never the client clock. One page, never the cursor.pagination.→ split the window in two and repeathasMore: true Every sub-window read: the watermark becomes B. If the pass is interrupted, it does not move. Deduplicate by
id.Lists without
updated*filters but sortable byupdatedAtsort=-updatedAt, follow the cursorStop at the watermark minus an overlap; deduplicate by
id.Reconciliation sweep (deletions and skipped updates)
Read every id in windows of
createdAt(a cursor with two sweeps where the list has no date filters)Compare each
updatedAtwith the one you stored and re-read the ones that differ.GETthe detail of every id missing from the sweepOnly a
404counts as a deletion. Where there is no detail read, the id must be missing from two sweeps in a row.
This works on every list that has updatedFrom and updatedBefore. Windows are half-open, [A, B): A is inclusive and B is exclusive, so consecutive windows neither overlap nor leave a gap.
Fix B from the server clock
B is the time of Filmo's servers minus a safety margin. Read the time from the
Dateheader of the last response, never from your own clock: a clock that runs ahead would push B past records that have not become visible yet. Use a margin of 2 minutes plus 1 second (the precision ofDate). The callout below explains why even that margin is not a guarantee.Ask for the window [A, B)
updatedFrom=A,updatedBefore=B,sort=updatedAtandlimit=500. Read one page only. A is your watermark; on the first run it is the earliest date you care about.Split when the page is full
If
pagination.ishasMore true, split the window in two halves and repeat step 2 on each. Never followpagination..nextCursor Advance the watermark
When every sub-window of [A, B) has been read, the watermark becomes B. If the pass is interrupted, the watermark does not move and the next run reads the same window again.
Deduplicate by id
A record updated during the pass can show up in two sub-windows. That is a duplicate, not a gap: keep the copy with the later
updatedAtand upsert by id in your database.
Why it does not lose records: each query is a single page with no offset. A record that changes while you read leaves its window for an updatedAt at or after B, and the next pass collects it.
updatedAt is not a safe watermark on its own
Filmo stamps updatedAt with the clock of the application, before the transaction commits, and some writes stamp it one second in the past (recording a payment, validating an invoice, changing a quote's status). A transaction can take seconds to commit and work after the commit has no upper bound, so a change can become visible with an updatedAt below a watermark you already stored. The margin of 2 minutes plus 1 second makes that rare, and Filmo does not guarantee a bound. Keep the margin configurable, and run the reconciliation sweep below periodically: it is what catches the record a pass skipped.
Filter precision
Link to this section: Filter precisionTimestamps have millisecond precision, in the filters and in the responses. The four boundaries of a date filter behave as follows.
| Parameter | Comparison | A bare day (YYYY-MM-DD) means |
|---|---|---|
updatedFrom | >= (on or after) | 00:00:00.000 of that day |
updatedAfter | > (strictly after) | 23:59:59.999 of that day, so the whole day is excluded |
updatedBefore | < (strictly before) | 00:00:00.000 of that day |
updatedTo | <= (on or before) | 23:59:59.999 of that day |
A bare day is read in the timezone of the account. A sync should not use bare days: pass an ISO 8601 instant with an offset, such as the Z instants of the examples. The created* filters follow the same rules.
When more than 500 records share one instant
Link to this section: When more than 500 records share one instantA bulk import can give more than 500 records the same updatedAt. Halving cannot separate them. When a window of 1 millisecond still reports pagination., follow the cursor inside that window only, and read it again until two consecutive reads return the same ids with the same updatedAt. The code below does this, and gives up after five attempts without advancing the watermark.
BASE="${FILMO_BASE_URL:-https://app.filmo.io}"
# Server time: the Date header of any response (here, a one-row read of the same list).
curl -sS -D headers.txt -o /dev/null -G "$BASE/api/v1/orders" \
-H "Authorization: Bearer $FILMO_API_KEY" \
--data-urlencode "limit=1"
SERVER_DATE=$(grep -i '^date:' headers.txt | cut -d' ' -f2- | tr -d '\r')
# B = server time minus 2 minutes and 1 second (GNU date; use gdate on macOS).
B=$(date -u -d "$SERVER_DATE - 121 seconds" +%Y-%m-%dT%H:%M:%S.000Z)
A=2026-10-01T00:00:00.000Z # your stored watermark
# The window [A, B): one page, sorted by updatedAt.
curl -sS -G "$BASE/api/v1/orders" \
-H "Authorization: Bearer $FILMO_API_KEY" \
--data-urlencode "updatedFrom=$A" \
--data-urlencode "updatedBefore=$B" \
--data-urlencode "sort=updatedAt" \
--data-urlencode "limit=500" \
| jq '{records: (.data | length), hasMore: .pagination.hasMore}'
# If hasMore is true, run the same request again on [A, M) and [M, B),
# where M is the midpoint. Never pass the cursor. Store B only when
# every sub-window has been read.
// filmo-sync.mjs: Node 22+, ESM, no dependencies.
import { readFile, rename, writeFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const BASE = process.env.FILMO_BASE_URL ?? "https://app.filmo.io";
const KEY = process.env.FILMO_API_KEY;
const INITIAL_FROM = process.env.FILMO_SYNC_FROM ?? "2000-01-01T00:00:00Z";
export const LIMIT = 500;
export const SAFETY_MS = 2 * 60_000 + 1_000; // 2 minutes + the 1 s precision of `Date`
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export const iso = (ms) => new Date(ms).toISOString();
/** GET /api/v1<path>. Waits out a 429. Returns { status, body, date } for the statuses in `ok`. */
export async function api(path, params = {}, ok = [200]) {
const url = new URL(`/api/v1${path}`, BASE);
for (const [name, value] of Object.entries(params)) url.searchParams.set(name, String(value));
for (;;) {
const res = await fetch(url, {
headers: { Authorization: `Bearer ${KEY}`, Accept: "application/json" },
});
if (res.status === 429) {
await sleep(Number(res.headers.get("Retry-After") ?? "1") * 1000);
continue;
}
const body = await res.json().catch(() => null);
if (!ok.includes(res.status)) {
throw new Error(`${res.status} ${body?.code ?? ""} (${res.headers.get("X-Request-Id")})`);
}
const date = Date.parse(res.headers.get("Date") ?? "");
if (Number.isNaN(date)) throw new Error("the response has no Date header");
return { status: res.status, body, date };
}
}
/** Merge two id -> record maps, keeping the copy with the later updatedAt. */
function merge(into, from) {
for (const [id, row] of from) {
const known = into.get(id);
if (!known || Date.parse(row.updatedAt) >= Date.parse(known.updatedAt)) into.set(id, row);
}
return into;
}
/**
* Read the half-open window [a, b) (epoch ms) of a list: one page per query,
* split in halves while pagination.hasMore is true. `field` is "updated" for
* the incremental pass and "created" for the deletion sweep. Returns Map id -> record.
*/
export async function syncWindow(listPath, params, a, b, field = "updated") {
const query = {
...params,
[`${field}From`]: iso(a),
[`${field}Before`]: iso(b),
sort: `${field}At`,
limit: LIMIT,
};
const { body } = await api(listPath, query);
const rows = new Map(body.data.map((row) => [row.id, row]));
if (!body.pagination.hasMore) return rows;
if (b - a > 1) {
const mid = a + Math.floor((b - a) / 2);
const left = await syncWindow(listPath, params, a, mid, field);
return merge(left, await syncWindow(listPath, params, mid, b, field));
}
return readOneMillisecond(listPath, query);
}
/** More than LIMIT records share one millisecond: follow the cursor, repeat until two reads agree. */
async function readOneMillisecond(listPath, query) {
let previous = null;
for (let attempt = 0; attempt < 5; attempt++) {
const rows = new Map();
let cursor;
do {
const { body } = await api(listPath, cursor ? { ...query, cursor } : query);
for (const row of body.data) rows.set(row.id, row);
cursor = body.pagination.hasMore ? body.pagination.nextCursor : undefined;
} while (cursor);
const signature = [...rows.values()].map((row) => `${row.id}@${row.updatedAt}`).sort().join("|");
if (signature === previous) return rows;
previous = signature;
}
throw new Error("more than 500 records share one millisecond and keep changing; retry later");
}
/** A watermark store is { load(): Promise<string | null>, save(watermark: string): Promise<void> }. */
export function jsonFileStore(file) {
return {
async load() {
try {
return JSON.parse(await readFile(file, "utf8")).watermark;
} catch (error) {
if (error.code === "ENOENT") return null;
throw error;
}
},
async save(watermark) {
await writeFile(`${file}.tmp`, JSON.stringify({ watermark }));
await rename(`${file}.tmp`, file); // never leave a half-written file
},
};
}
/**
* One incremental pass. `variants` are the query parameters of a full pass
* (see the table of hidden records): [{}] for orders, [{}, { archived: "true" }] for projects.
* `apply` must upsert by id: after a crash the same window is read again.
*/
export async function incrementalPass(listPath, variants, store, apply) {
const probe = await api(listPath, { ...variants[0], limit: 1 });
const b = probe.date - SAFETY_MS; // server time minus the margin, never your own clock
const a = Date.parse((await store.load()) ?? INITIAL_FROM);
if (b <= a) return;
const rows = new Map();
for (const variant of variants) merge(rows, await syncWindow(listPath, variant, a, b));
await apply([...rows.values()]);
await store.save(iso(b)); // the watermark moves only after every sub-window was read
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
await incrementalPass("/orders", [{}], jsonFileStore("watermark-orders.json"), async (rows) => {
console.log(`${rows.length} orders changed`); // upsert them by id in your database here
});
}
# filmo_sync.py: Python 3.11+, requests.
import json
import os
import time
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
import requests
BASE = os.environ.get("FILMO_BASE_URL", "https://app.filmo.io")
INITIAL_FROM = os.environ.get("FILMO_SYNC_FROM", "2000-01-01T00:00:00Z")
LIMIT = 500
SAFETY_MS = 2 * 60_000 + 1_000 # 2 minutes + the 1 s precision of `Date`
session = requests.Session()
session.headers.update(
{"Authorization": f"Bearer {os.environ['FILMO_API_KEY']}", "Accept": "application/json"}
)
def iso(ms):
stamp = datetime.fromtimestamp(ms / 1000, tz=timezone.utc)
return stamp.isoformat(timespec="milliseconds").replace("+00:00", "Z")
def to_ms(text):
return round(datetime.fromisoformat(text).timestamp() * 1000)
def api(path, params=None, ok=(200,)):
"""GET /api/v1<path>. Waits out a 429. Returns (status, body, server_time_ms)."""
while True:
res = session.get(f"{BASE}/api/v1{path}", params=params, timeout=30)
if res.status_code == 429:
time.sleep(int(res.headers.get("Retry-After", "1")))
continue
try:
body = res.json()
except ValueError:
body = None
if res.status_code not in ok:
code = body.get("code") if isinstance(body, dict) else ""
raise RuntimeError(f"{res.status_code} {code} ({res.headers.get('X-Request-Id')})")
date = res.headers.get("Date")
if not date:
raise RuntimeError("the response has no Date header")
return res.status_code, body, round(parsedate_to_datetime(date).timestamp() * 1000)
def merge(into, other):
"""Merge two id -> record dicts, keeping the copy with the later updatedAt."""
for id_, row in other.items():
known = into.get(id_)
if known is None or to_ms(row["updatedAt"]) >= to_ms(known["updatedAt"]):
into[id_] = row
return into
def sync_window(list_path, params, a, b, field="updated"):
"""Read the half-open window [a, b) (epoch ms) of a list: one page per query,
split in halves while pagination.hasMore is true. `field` is "updated" for the
incremental pass and "created" for the deletion sweep. Returns {id: record}."""
query = {
**params,
f"{field}From": iso(a),
f"{field}Before": iso(b),
"sort": f"{field}At",
"limit": LIMIT,
}
_, body, _ = api(list_path, query)
rows = {row["id"]: row for row in body["data"]}
if not body["pagination"]["hasMore"]:
return rows
if b - a > 1:
mid = a + (b - a) // 2
left = sync_window(list_path, params, a, mid, field)
return merge(left, sync_window(list_path, params, mid, b, field))
return read_one_millisecond(list_path, query)
def read_one_millisecond(list_path, query):
"""More than LIMIT records share one millisecond: follow the cursor, repeat until two reads agree."""
previous = None
for _ in range(5):
rows, cursor = {}, None
while True:
_, body, _ = api(list_path, {**query, "cursor": cursor} if cursor else query)
rows.update({row["id"]: row for row in body["data"]})
if not body["pagination"]["hasMore"]:
break
cursor = body["pagination"]["nextCursor"]
signature = sorted((row["id"], row["updatedAt"]) for row in rows.values())
if signature == previous:
return rows
previous = signature
raise RuntimeError("more than 500 records share one millisecond and keep changing; retry later")
class JsonFileStore:
"""A watermark store is any object with load() -> str | None and save(watermark: str)."""
def __init__(self, path):
self.path = path
def load(self):
try:
with open(self.path) as handle:
return json.load(handle)["watermark"]
except FileNotFoundError:
return None
def save(self, watermark):
with open(f"{self.path}.tmp", "w") as handle:
json.dump({"watermark": watermark}, handle)
os.replace(f"{self.path}.tmp", self.path) # never leave a half-written file
def incremental_pass(list_path, variants, store, apply):
"""One incremental pass. `variants` are the query parameters of a full pass
(see the table of hidden records): [{}] for orders, [{}, {"archived": "true"}] for projects.
`apply` must upsert by id: after a crash the same window is read again."""
_, _, server_now = api(list_path, {**variants[0], "limit": 1})
b = server_now - SAFETY_MS # server time minus the margin, never your own clock
a = to_ms(store.load() or INITIAL_FROM)
if b <= a:
return
rows = {}
for variant in variants:
merge(rows, sync_window(list_path, variant, a, b))
apply(list(rows.values()))
store.save(iso(b)) # the watermark moves only after every sub-window was read
if __name__ == "__main__":
incremental_pass(
"/orders",
[{}],
JsonFileStore("watermark-orders.json"),
lambda rows: print(f"{len(rows)} orders changed"), # upsert them by id in your database here
)
The watermark store is an interface with two methods, load and save. The examples keep it in a JSON file that is written through a temporary file and a rename; in production use a database row, in the same transaction as your upserts if you can. apply receives every record the pass found and must upsert by id, because after a crash the same window is read again.
The first run and the request budget
Link to this section: The first run and the request budgetThe first run is the same pass with a distant A, set with FILMO_SYNC_FROM in the examples. Expect roughly one request for every 250 to 500 records, plus the requests that discover empty windows. That is an estimate, not a contract. A key is limited to 120 requests per minute (Rate limits), and the code waits out a 429 using Retry-After. Plan the initial load against that budget.
Records that lists hide by default
Link to this section: Records that lists hide by defaultSome lists do not return everything unless you ask. A sync that leaves the parameter out never sees the hidden records: an opportunity that is won or lost vanishes from the list, and the other system never learns that it closed. Use these parameters in every sync pass, sweep and example.
| List | Hidden by default | Parameter for a full pass |
|---|---|---|
GET /api | Won and lost opportunities | status set to all |
GET /api | Lines added during delivery | includeDelivery set to true |
GET /api | Archived accounts | includeArchived set to true (ignored when you pass status) |
GET /api | Archived pipelines (the list is not paginated) | includeArchived set to true |
GET /api | Archived task types (the list is not paginated) | includeArchived set to true |
GET /api | Archived projects | archived set to true returns only the archived ones, so a full pass is two passes: one without the parameter and one with it |
Orders are different: an archived order is not hidden by a parameter, it is invisible to the API altogether. See Archived is not deleted, below.
The table below is generated from the specification. For every list it shows which created* and updated* filters exist, whether it sorts by updatedAt, which records it hides by default, which incremental strategy and deletion sweep apply, and whether there is a detail GET.
| Operation | Created filters | Updated filters | Sorts by updatedAt | Hidden by default | Incremental strategy | Deletion sweep | Detail GET |
|---|---|---|---|---|---|---|---|
GET /api | Yes | Yes | Yes | — | Windows of updatedAt | Windows of createdAt | Yes |
GET /api | Yes | Yes | Yes | — | Windows of updatedAt | Windows of createdAt | Yes |
GET /api | Yes | Yes | Yes | — | Windows of updatedAt | Windows of createdAt | Yes |
GET /api | No | No | No | includeDelivery=true | Full sweep | Cursor, two sweeps in a row | No |
GET /api | Yes | Yes | Yes | — | Windows of updatedAt | Windows of createdAt | Yes |
GET /api | Yes | Yes | Yes | — | Windows of updatedAt | Windows of createdAt | Yes |
GET /api | Yes | Yes | Yes | archived=false · archived=true | Windows of updatedAt | Windows of createdAt | Yes |
GET /api | No | No | Yes | — | Descending by updatedAt, stop at the watermark | Cursor, two sweeps in a row | No |
GET /api | No | No | No | — | Full sweep | Cursor, two sweeps in a row | No |
GET /api | No | No | No | — | Full sweep | Cursor, two sweeps in a row | No |
GET /api | Yes | Yes | Yes | — | Windows of updatedAt | Windows of createdAt | Yes |
GET /api | Yes | Yes | Yes | — | Windows of updatedAt | Windows of createdAt | Yes |
GET /api | Yes | Yes | Yes | — | Windows of updatedAt | Windows of createdAt | Yes |
GET /api | Yes | Yes | Yes | — | Windows of updatedAt | Windows of createdAt | Yes |
GET /api | Yes | Yes | Yes | — | Windows of updatedAt | Windows of createdAt | Yes |
GET /api | Yes | Yes | Yes | — | Windows of updatedAt | Windows of createdAt | Yes |
GET /api | Yes | Yes | Yes | — | Windows of updatedAt | Windows of createdAt | Yes |
GET /api | Yes | Yes | Yes | — | Windows of updatedAt | Windows of createdAt | Yes |
GET /api | No | No | Yes | — | Descending by updatedAt, stop at the watermark | Cursor, two sweeps in a row | Yes |
GET /api | Yes | Yes | Yes | — | Windows of updatedAt | Windows of createdAt | No |
GET /api | No | No | Yes | — | Descending by updatedAt, stop at the watermark | Cursor, two sweeps in a row | Yes |
GET /api | No | No | Yes | — | Descending by updatedAt, stop at the watermark | Cursor, two sweeps in a row | Yes |
GET /api | No | No | No | — | Full sweep | Cursor, two sweeps in a row | No |
GET /api | Yes | No | Yes | — | Descending by updatedAt, stop at the watermark | Windows of createdAt | Yes |
GET /api | Yes | Yes | Yes | — | Windows of updatedAt | Windows of createdAt | No |
GET /api | Yes | Yes | Yes | — | Windows of updatedAt | Windows of createdAt | Yes |
GET /api | No | No | Yes | status=all | Descending by updatedAt, stop at the watermark | Cursor, two sweeps in a row | Yes |
GET /api | Yes | Yes | Yes | — | Windows of updatedAt | Windows of createdAt | Yes |
GET /api | No | No | Yes | includeArchived=true | Descending by updatedAt, stop at the watermark | Cursor, two sweeps in a row | Yes |
GET /api | No | No | Yes | — | Descending by updatedAt, stop at the watermark | Cursor, two sweeps in a row | Yes |
GET /api | Yes | Yes | Yes | — | Windows of updatedAt | Windows of createdAt | Yes |
GET /api | Yes | Yes | Yes | — | Windows of updatedAt | Windows of createdAt | Yes |
GET /api | Yes | Yes | Yes | — | Windows of updatedAt | Windows of createdAt | No |
GET /api | No | No | Yes | — | Descending by updatedAt, stop at the watermark | Cursor, two sweeps in a row | No |
GET /api | Yes | Yes | Yes | — | Windows of updatedAt | Windows of createdAt | No |
Lists with no updated filters
Link to this section: Lists with no updated filtersSome lists cannot be windowed by update time but can be sorted by it. They sort by updatedAt and do not filter on it. For them, read in descending order, follow the cursor, and stop at the first record older than your watermark minus an overlap (an hour in the examples). Deduplicate by id.
| List | Date filters | Sorts by updatedAt |
|---|---|---|
GET /api | None | Yes, one field only |
GET /api | None | Yes, one field only |
GET /api | None | Yes |
GET /api | None | Yes |
GET /api | None | Yes, one field only |
GET /api | None | Yes |
GET /api | None | Yes, one field only |
GET /api | None | Yes, one field only |
GET /api | created* only | Yes, one field only |
Descending order is safe where ascending order is not. A record updated during the pass jumps to the front and pushes the others back, which produces a duplicate at a page boundary, not a gap. A concurrent deletion can still make the scan skip one record, and what you lose then is an update. The reconciliation sweep recovers it by comparing updatedAt, which costs much less than a full re-read.
Two details of these lists. Opportunities are identified by opportunityId and have no id field, and their cursor is specific to the CRM service: treat it as opaque like any other. updatedAt and updatedAt can be null, and a null must never stop the scan.
BASE="${FILMO_BASE_URL:-https://app.filmo.io}"
# First page, newest update first. status=all keeps won and lost opportunities in.
curl -sS -G "$BASE/api/v1/crm/opportunities" \
-H "Authorization: Bearer $FILMO_API_KEY" \
--data-urlencode "status=all" \
--data-urlencode "sort=-updatedAt" \
--data-urlencode "limit=500" \
| jq '{newest: .data[0].updatedAt, hasMore: .pagination.hasMore, next: .pagination.nextCursor}'
# Read the next page with --data-urlencode "cursor=<next>" (same sort and limit)
# until you meet a record whose updatedAt is older than your watermark minus the overlap.
// early-stop.mjs: for lists with no updatedFrom/updatedBefore that sort by updatedAt.
import { api, iso, jsonFileStore, LIMIT, SAFETY_MS } from "./filmo-sync.mjs";
const OVERLAP_MS = 60 * 60_000; // re-read the last hour on every pass
/** `idKey` is the field that holds the id: "id" for most records, "opportunityId" for opportunities. */
export async function earlyStopPass(listPath, params, idKey, store, apply) {
const probe = await api(listPath, { ...params, limit: 1 });
const passStart = probe.date - SAFETY_MS;
const saved = await store.load();
const stopAt = saved ? Date.parse(saved) - OVERLAP_MS : -Infinity;
const rows = new Map();
let cursor;
scan: for (;;) {
const { body } = await api(listPath, {
...params,
sort: "-updatedAt",
limit: LIMIT,
...(cursor && { cursor }),
});
for (const row of body.data) {
// updatedAt can be null on a few lists: a null never stops the scan
if (row.updatedAt && Date.parse(row.updatedAt) < stopAt) break scan;
rows.set(row[idKey], row);
}
if (!body.pagination.hasMore) break;
cursor = body.pagination.nextCursor;
}
await apply([...rows.values()]);
await store.save(iso(passStart));
}
// Opportunities: status=all, otherwise won and lost ones disappear from the list.
await earlyStopPass(
"/crm/opportunities",
{ status: "all" },
"opportunityId",
jsonFileStore("watermark-opportunities.json"),
async (rows) => console.log(`${rows.length} opportunities changed`),
);
# early_stop.py: for lists with no updatedFrom/updatedBefore that sort by updatedAt.
from filmo_sync import LIMIT, SAFETY_MS, JsonFileStore, api, iso, to_ms
OVERLAP_MS = 60 * 60_000 # re-read the last hour on every pass
def early_stop_pass(list_path, params, id_key, store, apply):
"""`id_key` is the field that holds the id: "id" for most records, "opportunityId" for opportunities."""
_, _, server_now = api(list_path, {**params, "limit": 1})
pass_start = server_now - SAFETY_MS
saved = store.load()
stop_at = to_ms(saved) - OVERLAP_MS if saved else float("-inf")
rows, cursor, done = {}, None, False
while not done:
query = {**params, "sort": "-updatedAt", "limit": LIMIT}
if cursor:
query["cursor"] = cursor
_, body, _ = api(list_path, query)
for row in body["data"]:
# updatedAt can be null on a few lists: a null never stops the scan
if row["updatedAt"] and to_ms(row["updatedAt"]) < stop_at:
done = True
break
rows[row[id_key]] = row
if not body["pagination"]["hasMore"]:
break
cursor = body["pagination"]["nextCursor"]
apply(list(rows.values()))
store.save(iso(pass_start))
if __name__ == "__main__":
# Opportunities: status=all, otherwise won and lost ones disappear from the list.
early_stop_pass(
"/crm/opportunities",
{"status": "all"},
"opportunityId",
JsonFileStore("watermark-opportunities.json"),
lambda rows: print(f"{len(rows)} opportunities changed"),
)
Lists that cannot be read incrementally
Link to this section: Lists that cannot be read incrementallyGET /apitakes a required resource type and a date window, and has no sort or update filter. Re-read the windows you care about on every run and replace what you hold for them./v1 /availability /unavailabilities - Several lists return everything in one response and have no
cursor. Among them,GET /api,/v1 /tags GET /api,/v1 /task-types GET /api,/v1 /crm /pipelines GET /apiand/v1 /custom-fields GET /apiare small catalogues that do not sort and do not filter by/v1 /project-types created*orupdated*: read them whole on every run and replace what you hold. Anupdated*filter on them answers 400validation_error.GET /api(which needs the contact id),/v1 /contacts /{contactId} /related-contacts GET /api(which needs a start and an end date) and/v1 /planner /services GET /api(which needs a resource type) also answer without pagination; read them whole as well./v1 /planner /resources - The line lists carry no
updatedAt. See the next section.
Order and document lines
Link to this section: Order and document linesGET /api, GET /api and GET /api return the lines of one parent document, and the lines carry neither updatedAt nor createdAt, so they cannot be windowed. Read the lines of a parent again whenever you read the parent, and replace the set you hold for it with what you read.
- Ask for
limit=500. Ifpagination.ishasMore false, the page is a consistent snapshot of the lines and no cursor is involved. If it istrue, follow the cursor and read the lines again until two reads agree. - For orders, pass
includeDeliveryset totrue, so the lines added during delivery are included. - A change to the lines does not always move the
updatedAtof the parent (see the next section), so re-read the lines of the parents you sweep, not only of those that changed. - Budget for it: reading lines costs at least one request per parent. For 10,000 orders that is 10,000 requests, about 84 minutes at 120 requests per minute. Do it on the first run and spread later re-reads across the sweeps.
When a child changes and the parent does not
Link to this section: When a child changes and the parent does notDo not assume that any change to a child moves the updatedAt of its parent. This is what moves it today.
| Change | Does the parent's updatedAt move? |
|---|---|
| Order lines changed on their own | No. |
| A full order save (PATCH with dates) | Yes, in several steps. The lines are written after the order is saved, so they can change after the order's updatedAt. |
| Reserve, cancel, unreserve; PATCH without dates | Yes. |
| Bulk return of order lines | Only when every active line ends in a final state. |
| A payment, an invoice validation or a quote status change, on the order | Yes, with an updatedAt one second in the past. |
| Invoice lines, payments, validation or voiding, on the invoice | Yes. The lines are stored inside the invoice. |
| A quote status change | Yes, on the quote and on its active version. |
| Editing a quote draft | No: only the version changes, not the quote. |
| Linking or unlinking an order and a project | Yes. When you create an order with a projectId, the link is made outside the creation transaction, and if it fails the project does not move. |
| Addresses and related contacts of a contact | Yes. |
| Variants, physical properties, prices, accessories and alternatives of an item | Yes. |
| Assigning or removing a tag | For some entity types only. It moves items, spaces, orders, projects, invoices, credit notes, proformas, staff, vehicles and services, and does not move contacts, serialized items, purchase orders, incidents, repair tickets, opportunities or contracts. |
So the incremental pass finds a changed parent only when its updatedAt moved. Re-read the child lists (lines, payments) of the parents you sweep, and for the entity types where a tag does not move the parent, read the tags again in the reconciliation.
The updated* filters of the quote list consider the later of the quote's update and the update of its version. The updatedAt that the quote shows, and the one sort=updatedAt uses, is the quote's alone. A quote whose draft was edited can therefore be returned in a window with an updatedAt below the start of that window. Never filter quotes on the client by updatedAt alone, and do not compare updatedAt to decide whether a quote changed: when the filter returns a quote, read its detail and its lines.
Archived is not deleted
Link to this section: Archived is not deletedArchiving an order takes it out of the API. After PUT /api (which answers 204), the order's GET responds 404, exactly as for a deleted order, the order disappears from GET /api, and it cannot be changed. There is no parameter that includes archived orders. Restoring it with PUT /api and archived: false (or in the app) brings it back. Only a draft or cancelled order can be archived. The status of an order lists archived among its values, but Filmo never produces it, and you cannot observe it either. See also Archiving and deleting.
- On a 404 for an order you hold, mark it "archived or deleted" in your system. Do not delete it on your side yet, and check it again in the next sweep. An archived order vanishes from the incremental pass without a trace.
- Archiving and restoring both move
updatedAt. A restored order reappears in the lists with the time of the restore, so the incremental pass returns it. The sweep also does: it compares ids overcreatedAtwindows. - Do not rely on a repeated archive being a no-op: archiving again moves
updatedAtand fires the event again. - Projects behave differently. An archived project keeps answering its GET (with
archived: true), archiving and restoring move itsupdatedAt, and the list shows archived projects only witharchivedset totrue.
Detecting deletions
Link to this section: Detecting deletionsA DELETE is physical and final: Filmo keeps no tombstone, and afterwards the record's GET responds 404. There is no deletedSince or includeDeleted filter and no feed of deleted records, so you find deletions by comparing what you hold with what Filmo returns. Deleting a contact also deletes its tasks, notes and files.
Orders and projects have no DELETE in v1. An order is cancelled or archived, and a project is archived or has its orders unlinked. Workflow events exist for the deletion of contacts, tasks, items, incidents and opportunities, and none for spaces, vehicles, staff or tags. They are best-effort, so use them as a hint and keep the sweep.
Never decide a deletion from the cursor
A record that the cursor skips looks exactly like a deleted one. If you delete on your side whatever a cursor read did not return, you will eventually delete a live order, invoice or contact in your ERP. Only a 404 from the detail GET, or the double sweep described below, is evidence.
Read with windows, not the cursor
On lists that have
createdFromandcreatedBefore(half-open, like theupdated*filters), run the same window method as the incremental pass overcreatedAt:sort=createdAt, one page per query, split in halves whilepagination.ishasMore true.Confirm every missing id with its detail GET
For each id you hold in the window that the sweep did not return, call the detail GET. Only a 404 counts. For orders, a 404 means archived or deleted. A 200 means the sweep skipped a live record: upsert it.
Without a detail GET, require two sweeps
Where a list has no detail GET, an id must be missing from two consecutive sweeps before you treat it as deleted.
Recover skipped updates
Compare the
updatedAtof every id the sweep returns with the one you stored, and read again the records that differ. This recovers the updates that early stop may have skipped.Use the parameters of a full pass
Pass the parameters from the table of hidden records, so that a record hidden by default does not look deleted.
Keep the end of a sweep behind the same safety margin as the incremental pass. A sweep over all history is the most expensive job in this guide, so slice it: sweep a few weeks of createdAt per run, recent windows more often than old ones.
Lists with no created filters
Link to this section: Lists with no created filtersOn lists with neither created* nor updated* filters the cursor is the only way to enumerate, so what it returns is a list of candidates, never a verdict.
| List | Detail GET | Rule for deleting on your side |
|---|---|---|
GET /api, GET /api, GET /api, GET /api, GET /api, GET /api | Yes | Read every id by cursor, then confirm each missing one with its detail GET. Only a 404 counts. |
GET /api | No | The id must be missing from two consecutive sweeps. |
GET /api | No | Read the payments of each invoice in one page (limit=500) and replace the set you hold. |
Lists with created filters and no detail GET
Link to this section: Lists with created filters and no detail GETGET /api, GET /api and GET /api filter by created* and updated*, but have no detail GET to confirm a deletion. Do a windowed sweep over createdAt, and treat an id as gone only when it is missing from two consecutive sweeps. GET /api cannot be swept this way: it is read per entity, with a required related document type and id, and there is no account-wide list of notes.
BASE="${FILMO_BASE_URL:-https://app.filmo.io}"
# The ids Filmo reports for one createdAt window [FROM, TO). Same halving as the
# incremental pass when hasMore is true; shown here for a window that fits one page.
curl -sS -G "$BASE/api/v1/orders" \
-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" \
| jq -r '.data[] | [.id, .updatedAt] | @tsv' > remote.tsv
# For every id you hold in that window that is NOT in remote.tsv, ask the detail GET.
# 404 on an order means archived or deleted. 200 means the sweep skipped a live record.
curl -sS -o /dev/null -w "%{http_code}\n" "$BASE/api/v1/orders/$ORDER_ID" \
-H "Authorization: Bearer $FILMO_API_KEY"
// reconcile.mjs: deletion sweep. Usage: node reconcile.mjs 2026-09-01T00:00:00Z 2026-10-01T00:00:00Z
import { readFile } from "node:fs/promises";
import { api, LIMIT, SAFETY_MS, syncWindow } from "./filmo-sync.mjs";
/**
* Sweep one createdAt window [a, b) (epoch ms) of a list that has createdFrom/createdBefore.
* `local` is Map id -> { createdAt, updatedAt } from your database.
* Returns the records to upsert and the ids whose detail GET answered 404.
*/
export async function sweepWindow({ listPath, detailPath, variants, local, a, b }) {
const remote = new Map();
for (const variant of variants) {
for (const [id, row] of await syncWindow(listPath, variant, a, b, "created")) remote.set(id, row);
}
const upsert = []; // new records and records whose updatedAt differs from yours
for (const [id, row] of remote) {
const mine = local.get(id);
if (!mine || Date.parse(mine.updatedAt) !== Date.parse(row.updatedAt)) upsert.push(row);
}
const gone = []; // missing from the sweep AND 404 on the detail GET
for (const [id, mine] of local) {
const created = Date.parse(mine.createdAt);
if (created < a || created >= b || remote.has(id)) continue;
const { status, body } = await api(`${detailPath}/${encodeURIComponent(id)}`, {}, [200, 404]);
if (status === 404) gone.push(id);
else upsert.push(body.data); // the sweep skipped a live record
}
return { upsert, gone };
}
/** Lists with no created filters: every id by cursor. A list of candidates, never a verdict. */
export async function readIds(listPath, params, idKey = "id") {
const ids = new Set();
let cursor;
do {
const { body } = await api(listPath, { ...params, limit: LIMIT, ...(cursor && { cursor }) });
for (const row of body.data) ids.add(row[idKey]);
cursor = body.pagination.hasMore ? body.pagination.nextCursor : undefined;
} while (cursor);
return ids;
}
/** With a detail GET only a 404 counts. Without one, the id must be missing in two sweeps in a row. */
export async function confirmGone(candidates, previousCandidates, detailPath) {
const gone = [];
for (const id of candidates) {
if (detailPath) {
const { status } = await api(`${detailPath}/${encodeURIComponent(id)}`, {}, [200, 404]);
if (status === 404) gone.push(id);
} else if (previousCandidates.has(id)) {
gone.push(id);
}
}
return gone;
}
if (process.argv[2] && process.argv[3]) {
const local = new Map(Object.entries(JSON.parse(await readFile("local-orders.json", "utf8"))));
const a = Date.parse(process.argv[2]);
const probe = await api("/orders", { limit: 1 });
const b = Math.min(Date.parse(process.argv[3]), probe.date - SAFETY_MS); // server time, not your clock
const { upsert, gone } = await sweepWindow({
listPath: "/orders",
detailPath: "/orders",
variants: [{}],
local,
a,
b,
});
// For orders a 404 means archived or deleted: mark them, do not delete.
console.log(JSON.stringify({ upsert: upsert.map((row) => row.id), archivedOrDeleted: gone }, null, 2));
}
# reconcile.py: deletion sweep.
# Usage: python3 reconcile.py 2026-09-01T00:00:00Z 2026-10-01T00:00:00Z
import json
import sys
from urllib.parse import quote
from filmo_sync import LIMIT, SAFETY_MS, api, sync_window, to_ms
def sweep_window(list_path, detail_path, variants, local, a, b):
"""Sweep one createdAt window [a, b) (epoch ms) of a list that has createdFrom/createdBefore.
`local` is {id: {"createdAt": ..., "updatedAt": ...}} from your database.
Returns the records to upsert and the ids whose detail GET answered 404."""
remote = {}
for variant in variants:
remote.update(sync_window(list_path, variant, a, b, "created"))
upsert = [] # new records and records whose updatedAt differs from yours
for id_, row in remote.items():
mine = local.get(id_)
if mine is None or to_ms(mine["updatedAt"]) != to_ms(row["updatedAt"]):
upsert.append(row)
gone = [] # missing from the sweep AND 404 on the detail GET
for id_, mine in local.items():
created = to_ms(mine["createdAt"])
if created < a or created >= b or id_ in remote:
continue
status, body, _ = api(f"{detail_path}/{quote(id_, safe='')}", None, (200, 404))
if status == 404:
gone.append(id_)
else:
upsert.append(body["data"]) # the sweep skipped a live record
return upsert, gone
def read_ids(list_path, params, id_key="id"):
"""Lists with no created filters: every id by cursor. A list of candidates, never a verdict."""
ids, cursor = set(), None
while True:
query = {**params, "limit": LIMIT}
if cursor:
query["cursor"] = cursor
_, body, _ = api(list_path, query)
ids.update(row[id_key] for row in body["data"])
if not body["pagination"]["hasMore"]:
return ids
cursor = body["pagination"]["nextCursor"]
def confirm_gone(candidates, previous_candidates, detail_path):
"""With a detail GET only a 404 counts. Without one, the id must be missing in two sweeps in a row."""
gone = []
for id_ in candidates:
if detail_path:
status, _, _ = api(f"{detail_path}/{quote(id_, safe='')}", None, (200, 404))
if status == 404:
gone.append(id_)
elif id_ in previous_candidates:
gone.append(id_)
return gone
if __name__ == "__main__" and len(sys.argv) == 3:
with open("local-orders.json") as handle:
local = json.load(handle)
_, _, server_now = api("/orders", {"limit": 1})
a = to_ms(sys.argv[1])
b = min(to_ms(sys.argv[2]), server_now - SAFETY_MS) # server time, not your clock
upsert, gone = sweep_window("/orders", "/orders", [{}], local, a, b)
# For orders a 404 means archived or deleted: mark them, do not delete.
print(json.dumps({"upsert": [row["id"] for row in upsert], "archivedOrDeleted": gone}, indent=2))
The scripts read your local state from local-orders., a stand-in for your database: an object that maps each id to the createdAt and updatedAt you stored. The sweep prints the records to upsert and the ids whose detail GET answered 404. For orders, mark those as archived or deleted and leave the decision for the next sweep.
Contacts and your own ids
Link to this section: Contacts and your own idsContacts have no field for your own id. The closest is contactNumber, a reference you can set when you create or update a contact (up to 50 characters, and it may be empty). It is not unique: two contacts can carry the same number.
POST /api takes between 1 and 200 references and compares each one only with contactNumber, by exact equality, with no trimming and no normalisation of case or tax ids. It answers with contacts[] (id, name and contactNumber) and notFound[]. It does not pair each reference with its contact: match them yourself by contactNumber. When several contacts share a number it returns all of them, without a warning, in no guaranteed order. A reference you repeat and that is not found appears repeated in notFound.
- Keep a mapping table on your side: your id on one column, the Filmo id on the other.
- Use
POST /apito decide which references to create (/v1 /contacts /lookup notFound). When it returns more than one contact for a reference, do not pick one: treat it as a conflict for a person to resolve. - Match by email with
emailwhencontactNumberis not enough. - Other records are better placed. Projects accept and return an
externalReference. Orders return anexternalReferencetoo, but the API does not let you write it. An invoice issued in another system is mirrored withPUT /api, keyed by/v1 /invoices /external /{originSystem} /{originExternalId} originSystemandoriginExternalId. - An
Idempotency-Keydeduplicates for 24 hours only (Idempotency). After that, a retried create makes a second contact: check your mapping table first.
Two-way sync and echoes
Link to this section: Two-way sync and echoesNo object says who or what made a change. What a key writes is attributed to the administrator who owns it. Filmo's activity log records that the change came from the API, but the API does not expose that. In a two-way sync your own write comes back as a change in the next pass, and the two systems bounce the update between them.
- For every record you write, store a hash of the last state you wrote: the fields you sync, in a fixed order.
- When a pass returns a record, hash the same fields. If the hash equals the stored one, ignore the change.
- Before writing, compare with what Filmo already holds and skip writes that change nothing.
- Send
If-Matchwith theETagof your last read, so a write on a record that changed in between is rejected (Concurrency).
Bulk writes and automations
Link to this section: Bulk writes and automationsA write through the API takes the same path as a change made in the app, so it fires the account's event workflows. Those can send emails or call third parties. You cannot suppress that for one request: there is no header or flag for it. Events fire after the response and on a best-effort basis. Most business writes fire one (orders, invoices, payments, quotes, contacts, tasks, projects, opportunities). Some do not, such as tag assignments and edits of item prices or physical properties.
Before an initial load or an import, list the active workflows that start from an event or from an entity: GET /api with isActive set to true and triggerTypes set to event,entity. It needs workflows:read and an administrator. Then either pause those workflows in the app, or edit their trigger to filter by origin and exclude api (and mcp), so they ignore what your key writes.
Push: reacting to changes
Link to this section: Push: reacting to changesFilmo does not send webhooks and has no subscriptions in the API. You build push inside Filmo: a workflow whose trigger is an event and whose step is an HTTP request to your endpoint. See Workflows for what workflows are. The event is a hint to run a pass now, never the data: your pass stays the source of truth and the watermark logic does not change.
The HTTP request step
Link to this section: The HTTP request step| Aspect | Behaviour |
|---|---|
| What you set | The method (GET, POST, PUT, PATCH or DELETE), the URL, the headers and a JSON body. The URL and the body can use the fields of the trigger. |
| Sent for you | Idempotency-Key: <runId>:<position> on mutating methods, User-Agent: filmo-workflows, and Content-Type: application when a non-GET request has a body. Headers you set are applied last and can override these. |
| Signature | None. The request is not signed. |
| Retries | None inside the step: any response that is not 2xx fails the step, and the failure carries only the status code, not the body of the response. The run is attempted up to 3 times, waiting 30 seconds and then doubling between attempts, and a step that already succeeded is not repeated. |
| Destination | http or https only. Loopback, private addresses and cloud metadata addresses are refused, so the endpoint must be reachable from the public internet. Up to 3 redirects are followed, and each one is checked again. |
| Limits | 15 seconds to answer. The response is read up to 5 MB. |
The idempotency key is built from the run and the position of the step, so a retried step sends the same value. Use it to ignore a delivery you have already handled.
Events and their payload
Link to this section: Events and their payloadThe trigger names start with filmo., for example filmo.. These are the ones that matter most for a sync. The full list, which has well over a hundred events, is in the workflow editor.
| Entity | Events |
|---|---|
| Orders | order., order., order., order., order., order., order., order., order. |
| Quotes | quote., quote., quote., quote. |
| Invoices | invoice., invoice., invoice., invoice., invoice., invoice. |
| Payments | payment., payment. |
| Others | Contacts, tasks, items, projects, opportunities, incidents, purchases, contracts and credit notes have events of their own, such as contact., contact. and contact.. |
| Field | Content |
|---|---|
name | The event name. |
entityType, entityId, entityName | The record the event is about. |
actionCode | The action that produced the event. |
origin | Where the change came from: neo, workflow, api, mcp or integration. It can be null. |
actor | Who made it: kind, id and name. For a change made through the API, the actor is the administrator who owns the key. |
entity | The record, read after the change was committed. For a deletion, a snapshot of what was deleted. |
changes | A map of the fields that changed, as booleans. It is {} when the event does not compute a difference. |
Events fire after the response of the change and on a best-effort basis. A delivery that fails is not retried by the event, only by the run, and a run that exhausts its attempts is lost. Keep the scheduled pass, so that a missed event costs time and not data.
Secure the receiver
Link to this section: Secure the receiverBecause the step does not sign its requests, authenticate the receiver yourself. Put a long random secret in a header that you set in the step, compare it in constant time, and use a URL with a random segment that you give to nobody else. Answer fast with a 2xx and do the work afterwards. The receiver below does exactly that: it checks the secret, ignores a delivery whose Idempotency-Key it has seen, and runs the incremental pass of this guide, never more than one at a time.
// Run: FILMO_HOOK_SECRET=<a long random value> FILMO_API_KEY=... node receiver.mjs
import { timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
import { incrementalPass, jsonFileStore } from "./filmo-sync.mjs";
const SECRET = Buffer.from(process.env.FILMO_HOOK_SECRET ?? "");
const PATH = process.env.FILMO_HOOK_PATH ?? "/hooks/filmo/replace-with-a-random-segment";
const seen = new Set(); // Idempotency-Key values already handled: use a database in production
let running = false;
let again = false;
function authorized(header) {
const given = Buffer.from(header ?? "");
return SECRET.length > 0 && given.length === SECRET.length && timingSafeEqual(given, SECRET);
}
/** Run one pass now, or ask the pass that is running to go round once more. */
async function runPass() {
if (running) {
again = true;
return;
}
running = true;
try {
do {
again = false;
await incrementalPass("/orders", [{}], jsonFileStore("watermark-orders.json"), async (rows) => {
console.log(rows.length + " orders changed"); // upsert them by id in your database here
});
} while (again);
} catch (error) {
console.error("pass failed:", error.message);
} finally {
running = false;
}
}
createServer((req, res) => {
if (req.method !== "POST" || req.url !== PATH) return void res.writeHead(404).end();
if (!authorized(req.headers["x-filmo-secret"])) return void res.writeHead(401).end();
req.resume(); // the body is ignored: it only says that something changed
req.on("end", () => {
const key = req.headers["idempotency-key"];
if (!key || !seen.has(key)) {
if (key) seen.add(key);
void runPass();
}
res.writeHead(204).end();
});
}).listen(Number(process.env.PORT ?? 8080));
In the workflow, add the header X-Filmo-Secret to the HTTP request step with the same value as FILMO_HOOK_SECRET, and keep the secret out of the body.
A reaction that writes to Filmo and an event that starts a reaction can trigger each other without end. Apply these rules:
- Filter the trigger of every workflow that calls your endpoint by
origin, to excludeapiandmcp, so your own writes do not come back as events. - Ignore changes that match the last state you wrote (the hash described above).
- Write only when the data differs from what Filmo already holds.
- Cap the writes per record per minute. When a record passes the cap, stop writing to it and alert a person.
- Make every reaction idempotent: one
Idempotency-Keyper business operation.
A workflow that calls the API with a key is not covered by the loop guard
Filmo protects a chain of workflows: a workflow already in the lineage is excluded, and the chain stops at a depth of 10. A write that a workflow makes with an API key goes through the public API, so it most likely arrives as a new event of origin api that carries no lineage, and the guard would not recognise it. Filmo has not confirmed this, so do not rely on the guard. If such a workflow can start itself, filter its trigger by origin, and apply the rules above.
The same pass over MCP
Link to this section: The same pass over MCPA list tool takes the same window as dateFilters: { updated: { gte: A, lt: B } }, together with sort: "updatedAt" and limit: 500, for example in filmo_orders_list. The key under dateFilters is the field name without At (created, updated), and an instant with an offset is applied as given by gte and lt. The result carries the same pagination., so split the window exactly as above. A tool result does not carry the HTTP Date header: take B from a clock you keep synchronised and use a margin at least as wide as the one above. For a scheduled sync, REST is the better fit; MCP is for assistants.
Related pages
Link to this section: Related pagesGuides
Pagination
Lists return one page at a time. Ask for a page with
limit, and whilepagination.ishasMore truecall again with thenextCursoryou were given.Guides
Filtering and sorting
Most lists accept
sortand thecreated*andupdated*time filters, many accept list-valued filters such asstatuses, and some acceptqfor free-text search. The examples use the orders list.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.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.Guides
Concurrency and optimistic locking
Every detail read returns an
ETag. Send it back in theIf-Matchheader of a write and Filmo rejects the write with 412 if the record changed since you read it, so two integrations cannot silently overwrite each other.Guides
Multi-account integrations
Give each customer account its own credential, store it with that account's timezone and currency, and treat a failing credential as the state of one connection, not of your whole product. OAuth is available for a server you run, and API keys are simpler for server-to-server work.
Guides
Zapier, Make and n8n
Zapier, Make and n8n can all call the Filmo API with their generic HTTP step. Send the key as a bearer header, page through lists with the cursor, wait when you get a 429 and give every write its own idempotency key.
Further reading
Archiving and deleting
Filmo has several ways to take a record out of use, and they are not interchangeable. Some can be undone and some cannot; check this page before you call a destructive operation.
Introduction
Workflows
A workflow is an automation you build in the Filmo app: a trigger starts a run and the steps do the work, one after another. From the API and MCP you can list the workflows of an account and start a run.
Was this page helpful?