*REST API › 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](https://docs.filmo.io/rest-api/guides/pagination)) and how requests are limited ([Rate limits](https://docs.filmo.io/rest-api/guides/rate-limits)).

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

The 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

**Keeping a copy in sync**
- First load: the full-pass parameters of each list (overlay.syncDefaults), so nothing hidden by default is missed.
- Incremental pass, lists with updated* filters
- Your system → API: GET ...?updatedFrom=A&updatedBefore=B&sort=updatedAt&limit=500 A half-open window [A, B). B is the server time (the Date header of the last response) minus a safety margin, never the client clock. One page, never the cursor.
- API → Your system: pagination.hasMore: true → split the window in two and repeat
- 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 by updatedAt
- Your system → API: sort=-updatedAt, follow the cursor Stop at the watermark minus an overlap; deduplicate by id.
- Reconciliation sweep (deletions and skipped updates)
- Your system → API: Read every id in windows of createdAt (a cursor with two sweeps where the list has no date filters) Compare each updatedAt with the one you stored and re-read the ones that differ.
- Your system → API: GET the detail of every id missing from the sweep Only a 404 counts 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`](https://docs.filmo.io/rest-api/endpoint-reference/orders/list-orders#param-updatedFrom) and [`updatedBefore`](https://docs.filmo.io/rest-api/endpoint-reference/orders/list-orders#param-updatedBefore). Windows are half-open, [A, B): A is inclusive and B is exclusive, so consecutive windows neither overlap nor leave a gap.

1. **Fix B from the server clock** B is the time of Filmo's servers minus a safety margin. Read the time from the `Date` header 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 of `Date`). The callout below explains why even that margin is not a guarantee.
2. **Ask for the window [A, B)** [`updatedFrom`](https://docs.filmo.io/rest-api/endpoint-reference/orders/list-orders#param-updatedFrom)=A, [`updatedBefore`](https://docs.filmo.io/rest-api/endpoint-reference/orders/list-orders#param-updatedBefore)=B, `sort=updatedAt` and `limit=500`. Read one page only. A is your watermark; on the first run it is the earliest date you care about.
3. **Split when the page is full** If `pagination.hasMore` is `true`, split the window in two halves and repeat step 2 on each. Never follow `pagination.nextCursor`.
4. **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.
5. **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 `updatedAt` and 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

Timestamps 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

A bulk import can give more than 500 records the same `updatedAt`. Halving cannot separate them. When a window of 1 millisecond still reports `pagination.hasMore`, 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.

*One window, and the whole incremental pass*

**cURL**

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

```

**Node**

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

```

**Python**

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

The 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](https://docs.filmo.io/rest-api/guides/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

Some 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/v1/crm/opportunities` | Won and lost opportunities | `status` set to `all` |
| `GET /api/v1/orders/{orderId}/lines` | Lines added during delivery | `includeDelivery` set to `true` |
| `GET /api/v1/treasury/accounts` | Archived accounts | `includeArchived` set to `true` (ignored when you pass `status`) |
| `GET /api/v1/crm/pipelines` | Archived pipelines (the list is not paginated) | `includeArchived` set to `true` |
| `GET /api/v1/task-types` | Archived task types (the list is not paginated) | `includeArchived` set to `true` |
| `GET /api/v1/projects` | 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/v1/contacts` | Yes | Yes | Yes | — | Windows of updatedAt | Windows of createdAt | Yes |
| `GET /api/v1/items` | Yes | Yes | Yes | — | Windows of updatedAt | Windows of createdAt | Yes |
| `GET /api/v1/orders` | Yes | Yes | Yes | — | Windows of updatedAt | Windows of createdAt | Yes |
| `GET /api/v1/orders/{orderId}/lines` | No | No | No | `includeDelivery=true` | Full sweep | Cursor, two sweeps in a row | No |
| `GET /api/v1/invoices` | Yes | Yes | Yes | — | Windows of updatedAt | Windows of createdAt | Yes |
| `GET /api/v1/quotes` | Yes | Yes | Yes | — | Windows of updatedAt | Windows of createdAt | Yes |
| `GET /api/v1/projects` | Yes | Yes | Yes | `archived=false` · `archived=true` | Windows of updatedAt | Windows of createdAt | Yes |
| `GET /api/v1/invoices/{invoiceId}/payments` | No | No | Yes | — | Descending by updatedAt, stop at the watermark | Cursor, two sweeps in a row | No |
| `GET /api/v1/invoices/{invoiceId}/lines` | No | No | No | — | Full sweep | Cursor, two sweeps in a row | No |
| `GET /api/v1/quotes/{quoteId}/lines` | No | No | No | — | Full sweep | Cursor, two sweeps in a row | No |
| `GET /api/v1/serialized-items` | Yes | Yes | Yes | — | Windows of updatedAt | Windows of createdAt | Yes |
| `GET /api/v1/spaces` | Yes | Yes | Yes | — | Windows of updatedAt | Windows of createdAt | Yes |
| `GET /api/v1/repair-tickets` | Yes | Yes | Yes | — | Windows of updatedAt | Windows of createdAt | Yes |
| `GET /api/v1/vehicles` | Yes | Yes | Yes | — | Windows of updatedAt | Windows of createdAt | Yes |
| `GET /api/v1/staff` | Yes | Yes | Yes | — | Windows of updatedAt | Windows of createdAt | Yes |
| `GET /api/v1/subrents` | Yes | Yes | Yes | — | Windows of updatedAt | Windows of createdAt | Yes |
| `GET /api/v1/purchase-orders` | Yes | Yes | Yes | — | Windows of updatedAt | Windows of createdAt | Yes |
| `GET /api/v1/contracts` | Yes | Yes | Yes | — | Windows of updatedAt | Windows of createdAt | Yes |
| `GET /api/v1/kits` | No | No | Yes | — | Descending by updatedAt, stop at the watermark | Cursor, two sweeps in a row | Yes |
| `GET /api/v1/collections` | Yes | Yes | Yes | — | Windows of updatedAt | Windows of createdAt | No |
| `GET /api/v1/services` | No | No | Yes | — | Descending by updatedAt, stop at the watermark | Cursor, two sweeps in a row | Yes |
| `GET /api/v1/internal-services` | No | No | Yes | — | Descending by updatedAt, stop at the watermark | Cursor, two sweeps in a row | Yes |
| `GET /api/v1/availability/unavailabilities` | No | No | No | — | Full sweep | Cursor, two sweeps in a row | No |
| `GET /api/v1/incidents` | Yes | No | Yes | — | Descending by updatedAt, stop at the watermark | Windows of createdAt | Yes |
| `GET /api/v1/notes` | Yes | Yes | Yes | — | Windows of updatedAt | Windows of createdAt | No |
| `GET /api/v1/tasks` | Yes | Yes | Yes | — | Windows of updatedAt | Windows of createdAt | Yes |
| `GET /api/v1/crm/opportunities` | No | No | Yes | `status=all` | Descending by updatedAt, stop at the watermark | Cursor, two sweeps in a row | Yes |
| `GET /api/v1/supplier-invoices` | Yes | Yes | Yes | — | Windows of updatedAt | Windows of createdAt | Yes |
| `GET /api/v1/treasury/accounts` | No | No | Yes | `includeArchived=true` | Descending by updatedAt, stop at the watermark | Cursor, two sweeps in a row | Yes |
| `GET /api/v1/treasury/payments` | No | No | Yes | — | Descending by updatedAt, stop at the watermark | Cursor, two sweeps in a row | Yes |
| `GET /api/v1/credit-notes` | Yes | Yes | Yes | — | Windows of updatedAt | Windows of createdAt | Yes |
| `GET /api/v1/proformas` | Yes | Yes | Yes | — | Windows of updatedAt | Windows of createdAt | Yes |
| `GET /api/v1/workflows` | Yes | Yes | Yes | — | Windows of updatedAt | Windows of createdAt | No |
| `GET /api/v1/payment-methods` | No | No | Yes | — | Descending by updatedAt, stop at the watermark | Cursor, two sweeps in a row | No |
| `GET /api/v1/users` | Yes | Yes | Yes | — | Windows of updatedAt | Windows of createdAt | No |

## Lists with no updated filters

Some 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/v1/kits` | None | Yes, one field only |
| `GET /api/v1/services` | None | Yes, one field only |
| `GET /api/v1/internal-services` | None | Yes |
| `GET /api/v1/crm/opportunities` | None | Yes |
| `GET /api/v1/treasury/payments` | None | Yes, one field only |
| `GET /api/v1/payment-methods` | None | Yes |
| `GET /api/v1/treasury/accounts` | None | Yes, one field only |
| `GET /api/v1/invoices/{invoiceId}/payments` | None | Yes, one field only |
| `GET /api/v1/incidents` | `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`](https://docs.filmo.io/rest-api/objects/service#response-updatedAt) and [`updatedAt`](https://docs.filmo.io/rest-api/objects/payment-method#response-updatedAt) can be `null`, and a `null` must never stop the scan.

*Early stop, descending by updatedAt*

**cURL**

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

```

**Node**

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

```

**Python**

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

- [`GET /api/v1/availability/unavailabilities`](https://docs.filmo.io/rest-api/endpoint-reference/availability/list-the-unavailabilities-of-a-resource) takes 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.
- Several lists return everything in one response and have no `cursor`. Among them, [`GET /api/v1/tags`](https://docs.filmo.io/rest-api/endpoint-reference/tags/list-tags), [`GET /api/v1/task-types`](https://docs.filmo.io/rest-api/endpoint-reference/task-types/list-task-types), [`GET /api/v1/crm/pipelines`](https://docs.filmo.io/rest-api/endpoint-reference/crm/list-pipelines), [`GET /api/v1/custom-fields`](https://docs.filmo.io/rest-api/endpoint-reference/custom-fields/list-custom-field-definitions) and [`GET /api/v1/project-types`](https://docs.filmo.io/rest-api/endpoint-reference/projects/list-project-types) are small catalogues that do not sort and do not filter by `created*` or `updated*`: read them whole on every run and replace what you hold. An `updated*` filter on them answers 400 [`validation_error`](https://docs.filmo.io/rest-api/errors/validation_error). [`GET /api/v1/contacts/{contactId}/related-contacts`](https://docs.filmo.io/rest-api/endpoint-reference/contacts/list-the-contacts-related-to-a-contact) (which needs the contact id), [`GET /api/v1/planner/services`](https://docs.filmo.io/rest-api/endpoint-reference/planner/list-the-services-to-cover) (which needs a start and an end date) and [`GET /api/v1/planner/resources`](https://docs.filmo.io/rest-api/endpoint-reference/planner/list-the-assignable-resources) (which needs a resource type) also answer without pagination; read them whole as well.
- The line lists carry no `updatedAt`. See the next section.

## Order and document lines

[`GET /api/v1/orders/{orderId}/lines`](https://docs.filmo.io/rest-api/endpoint-reference/orders/list-the-lines-of-an-order), [`GET /api/v1/invoices/{invoiceId}/lines`](https://docs.filmo.io/rest-api/endpoint-reference/invoices/list-the-lines-of-an-invoice) and [`GET /api/v1/quotes/{quoteId}/lines`](https://docs.filmo.io/rest-api/endpoint-reference/quotes/list-the-lines-of-a-quote) 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`. If `pagination.hasMore` is `false`, the page is a consistent snapshot of the lines and no cursor is involved. If it is `true`, follow the cursor and read the lines again until two reads agree.
- For orders, pass [`includeDelivery`](https://docs.filmo.io/rest-api/endpoint-reference/orders/list-the-lines-of-an-order#param-includeDelivery) set to `true`, so the lines added during delivery are included.
- A change to the lines does not always move the `updatedAt` of 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

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

### Quotes

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

Archiving an order takes it out of the API. After [`PUT /api/v1/orders/{orderId}/archive`](https://docs.filmo.io/rest-api/endpoint-reference/orders/archive-or-restore-an-order) (which answers 204), the order's GET responds 404, exactly as for a deleted order, the order disappears from [`GET /api/v1/orders`](https://docs.filmo.io/rest-api/endpoint-reference/orders/list-orders), and it cannot be changed. There is no parameter that includes archived orders. Restoring it with [`PUT /api/v1/orders/{orderId}/archive`](https://docs.filmo.io/rest-api/endpoint-reference/orders/archive-or-restore-an-order) 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](https://docs.filmo.io/further-reading/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 over `createdAt` windows.
- Do not rely on a repeated archive being a no-op: archiving again moves `updatedAt` and fires the event again.
- Projects behave differently. An archived project keeps answering its GET (with `archived: true`), archiving and restoring move its `updatedAt`, and the list shows archived projects only with [`archived`](https://docs.filmo.io/rest-api/endpoint-reference/projects/list-projects#param-archived) set to `true`.

## Detecting deletions

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

1. **Read with windows, not the cursor** On lists that have [`createdFrom`](https://docs.filmo.io/rest-api/endpoint-reference/orders/list-orders#param-createdFrom) and [`createdBefore`](https://docs.filmo.io/rest-api/endpoint-reference/orders/list-orders#param-createdBefore) (half-open, like the `updated*` filters), run the same window method as the incremental pass over `createdAt`: `sort=createdAt`, one page per query, split in halves while `pagination.hasMore` is `true`.
2. **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.
3. **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.
4. **Recover skipped updates** Compare the `updatedAt` of 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.
5. **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

On 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/v1/kits`, `GET /api/v1/services`, `GET /api/v1/internal-services`, `GET /api/v1/crm/opportunities`, `GET /api/v1/treasury/payments`, `GET /api/v1/treasury/accounts` | Yes | Read every id by cursor, then confirm each missing one with its detail GET. Only a 404 counts. |
| `GET /api/v1/payment-methods` | No | The id must be missing from two consecutive sweeps. |
| `GET /api/v1/invoices/{invoiceId}/payments` | 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

[`GET /api/v1/users`](https://docs.filmo.io/rest-api/endpoint-reference/users/list-users), [`GET /api/v1/collections`](https://docs.filmo.io/rest-api/endpoint-reference/collections/list-collections) and [`GET /api/v1/workflows`](https://docs.filmo.io/rest-api/endpoint-reference/workflows/list-workflows) 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/v1/notes`](https://docs.filmo.io/rest-api/endpoint-reference/notes/list-the-notes-of-an-entity) 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.

*Reconciliation sweep*

**cURL**

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

```

**Node**

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

```

**Python**

```python
# 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.json`, 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

Contacts 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/v1/contacts/lookup`](https://docs.filmo.io/rest-api/endpoint-reference/contacts/look-up-contacts-by-external-reference) 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 /api/v1/contacts/lookup`](https://docs.filmo.io/rest-api/endpoint-reference/contacts/look-up-contacts-by-external-reference) to decide which references to create (`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 [`email`](https://docs.filmo.io/rest-api/endpoint-reference/contacts/list-contacts#param-email) when `contactNumber` is not enough.
- Other records are better placed. Projects accept and return an `externalReference`. Orders return an `externalReference` too, but the API does not let you write it. An invoice issued in another system is mirrored with [`PUT /api/v1/invoices/external/{originSystem}/{originExternalId}`](https://docs.filmo.io/rest-api/endpoint-reference/invoices/create-or-update-the-mirror-of-an-externally-issued-invoice), keyed by `originSystem` and `originExternalId`.
- An `Idempotency-Key` deduplicates for 24 hours only ([Idempotency](https://docs.filmo.io/rest-api/guides/idempotency)). After that, a retried create makes a second contact: check your mapping table first.

## Two-way sync and echoes

No 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-Match` with the `ETag` of your last read, so a write on a record that changed in between is rejected ([Concurrency](https://docs.filmo.io/rest-api/guides/concurrency)).

## Bulk writes and automations

A 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/v1/workflows`](https://docs.filmo.io/rest-api/endpoint-reference/workflows/list-workflows) with [`isActive`](https://docs.filmo.io/rest-api/endpoint-reference/workflows/list-workflows#param-isActive) set to `true` and [`triggerTypes`](https://docs.filmo.io/rest-api/endpoint-reference/workflows/list-workflows#param-triggerTypes) set to `event,entity`. It needs [`workflows:read`](https://docs.filmo.io/rest-api/guides/scopes#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

Filmo 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](https://docs.filmo.io/workflows/overview) 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

| 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/1.0`, and `Content-Type: application/json` 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

The trigger names start with `filmo.`, for example `filmo.quote.statusUpdated`. 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.created`, `order.reserved`, `order.cancelled`, `order.returned`, `order.archived`, `order.unarchived`, `order.completed`, `order.delivered`, `order.statusChanged` |
| Quotes | `quote.created`, `quote.updated`, `quote.statusUpdated`, `quote.versionCreated` |
| Invoices | `invoice.created`, `invoice.updated`, `invoice.validated`, `invoice.voided`, `invoice.paid`, `invoice.partiallyPaid` |
| Payments | `payment.created`, `payment.linked` |
| Others | Contacts, tasks, items, projects, opportunities, incidents, purchases, contracts and credit notes have events of their own, such as `contact.created`, `contact.updated` and `contact.deleted`. |

| 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

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

*receiver.mjs: Node 22+, ESM, next to filmo-sync.mjs*

```javascript
// 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 exclude `api` and `mcp`, 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-Key` per 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

A 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`](https://docs.filmo.io/mcp/tools/orders/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.hasMore`, 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.

---

Canonical URL: https://docs.filmo.io/rest-api/guides/syncing-data  
Last updated: 2026-10-09