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

You needUse
The records that changed since your last runOn lists that have them, updatedFrom (on or after) and updatedBefore (before), with sort=updatedAt and limit=500.
The same, on lists with no updated* filterssort=-updatedAt, follow the cursor, and stop at your watermark minus an overlap.
To notice a deletionA reconciliation sweep over createdAt windows, with a detail GET to confirm every missing id.
The time on Filmo's sideThe Date header of any response. Never your own clock.
To be told that something changedA 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.

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.

Your system
API
  1. First load: the full-pass parameters of each list (overlay.syncDefaults), so nothing hidden by default is missed.

  2. Incremental pass, lists with updated* filters

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

  4. pagination.hasMore: true → split the window in two and repeat

  5. Every sub-window read: the watermark becomes B. If the pass is interrupted, it does not move. Deduplicate by id.

  6. Lists without updated* filters but sortable by updatedAt

  7. sort=-updatedAt, follow the cursor

    Stop at the watermark minus an overlap; deduplicate by id.

  8. Reconciliation sweep (deletions and skipped updates)

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

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

Keeping a copy in sync

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.

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

Timestamps have millisecond precision, in the filters and in the responses. The four boundaries of a date filter behave as follows.

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

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.

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

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.

ListHidden by defaultParameter for a full pass
GET /api/v1/crm/opportunitiesWon and lost opportunitiesstatus set to all
GET /api/v1/orders/{orderId}/linesLines added during deliveryincludeDelivery set to true
GET /api/v1/treasury/accountsArchived accountsincludeArchived set to true (ignored when you pass status)
GET /api/v1/crm/pipelinesArchived pipelines (the list is not paginated)includeArchived set to true
GET /api/v1/task-typesArchived task types (the list is not paginated)includeArchived set to true
GET /api/v1/projectsArchived projectsarchived 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.

OperationCreated filtersUpdated filtersSorts by updatedAtHidden by defaultIncremental strategyDeletion sweepDetail GET
GET /api/v1/contactsYesYesYes—Windows of updatedAtWindows of createdAtYes
GET /api/v1/itemsYesYesYes—Windows of updatedAtWindows of createdAtYes
GET /api/v1/ordersYesYesYes—Windows of updatedAtWindows of createdAtYes
GET /api/v1/orders/{orderId}/linesNoNoNoincludeDelivery=trueFull sweepCursor, two sweeps in a rowNo
GET /api/v1/invoicesYesYesYes—Windows of updatedAtWindows of createdAtYes
GET /api/v1/quotesYesYesYes—Windows of updatedAtWindows of createdAtYes
GET /api/v1/projectsYesYesYesarchived=false · archived=trueWindows of updatedAtWindows of createdAtYes
GET /api/v1/invoices/{invoiceId}/paymentsNoNoYes—Descending by updatedAt, stop at the watermarkCursor, two sweeps in a rowNo
GET /api/v1/invoices/{invoiceId}/linesNoNoNo—Full sweepCursor, two sweeps in a rowNo
GET /api/v1/quotes/{quoteId}/linesNoNoNo—Full sweepCursor, two sweeps in a rowNo
GET /api/v1/serialized-itemsYesYesYes—Windows of updatedAtWindows of createdAtYes
GET /api/v1/spacesYesYesYes—Windows of updatedAtWindows of createdAtYes
GET /api/v1/repair-ticketsYesYesYes—Windows of updatedAtWindows of createdAtYes
GET /api/v1/vehiclesYesYesYes—Windows of updatedAtWindows of createdAtYes
GET /api/v1/staffYesYesYes—Windows of updatedAtWindows of createdAtYes
GET /api/v1/subrentsYesYesYes—Windows of updatedAtWindows of createdAtYes
GET /api/v1/purchase-ordersYesYesYes—Windows of updatedAtWindows of createdAtYes
GET /api/v1/contractsYesYesYes—Windows of updatedAtWindows of createdAtYes
GET /api/v1/kitsNoNoYes—Descending by updatedAt, stop at the watermarkCursor, two sweeps in a rowYes
GET /api/v1/collectionsYesYesYes—Windows of updatedAtWindows of createdAtNo
GET /api/v1/servicesNoNoYes—Descending by updatedAt, stop at the watermarkCursor, two sweeps in a rowYes
GET /api/v1/internal-servicesNoNoYes—Descending by updatedAt, stop at the watermarkCursor, two sweeps in a rowYes
GET /api/v1/availability/unavailabilitiesNoNoNo—Full sweepCursor, two sweeps in a rowNo
GET /api/v1/incidentsYesNoYes—Descending by updatedAt, stop at the watermarkWindows of createdAtYes
GET /api/v1/notesYesYesYes—Windows of updatedAtWindows of createdAtNo
GET /api/v1/tasksYesYesYes—Windows of updatedAtWindows of createdAtYes
GET /api/v1/crm/opportunitiesNoNoYesstatus=allDescending by updatedAt, stop at the watermarkCursor, two sweeps in a rowYes
GET /api/v1/supplier-invoicesYesYesYes—Windows of updatedAtWindows of createdAtYes
GET /api/v1/treasury/accountsNoNoYesincludeArchived=trueDescending by updatedAt, stop at the watermarkCursor, two sweeps in a rowYes
GET /api/v1/treasury/paymentsNoNoYes—Descending by updatedAt, stop at the watermarkCursor, two sweeps in a rowYes
GET /api/v1/credit-notesYesYesYes—Windows of updatedAtWindows of createdAtYes
GET /api/v1/proformasYesYesYes—Windows of updatedAtWindows of createdAtYes
GET /api/v1/workflowsYesYesYes—Windows of updatedAtWindows of createdAtNo
GET /api/v1/payment-methodsNoNoYes—Descending by updatedAt, stop at the watermarkCursor, two sweeps in a rowNo
GET /api/v1/usersYesYesYes—Windows of updatedAtWindows of createdAtNo

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.

ListDate filtersSorts by updatedAt
GET /api/v1/kitsNoneYes, one field only
GET /api/v1/servicesNoneYes, one field only
GET /api/v1/internal-servicesNoneYes
GET /api/v1/crm/opportunitiesNoneYes
GET /api/v1/treasury/paymentsNoneYes, one field only
GET /api/v1/payment-methodsNoneYes
GET /api/v1/treasury/accountsNoneYes, one field only
GET /api/v1/invoices/{invoiceId}/paymentsNoneYes, one field only
GET /api/v1/incidentscreated* onlyYes, 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.

GET /api/v1/orders/{orderId}/lines, GET /api/v1/invoices/{invoiceId}/lines and GET /api/v1/quotes/{quoteId}/lines 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 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

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

ChangeDoes the parent's updatedAt move?
Order lines changed on their ownNo.
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 datesYes.
Bulk return of order linesOnly when every active line ends in a final state.
A payment, an invoice validation or a quote status change, on the orderYes, with an updatedAt one second in the past.
Invoice lines, payments, validation or voiding, on the invoiceYes. The lines are stored inside the invoice.
A quote status changeYes, on the quote and on its active version.
Editing a quote draftNo: only the version changes, not the quote.
Linking or unlinking an order and a projectYes. 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 contactYes.
Variants, physical properties, prices, accessories and alternatives of an itemYes.
Assigning or removing a tagFor 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.

Archiving an order takes it out of the API. After PUT /api/v1/orders/{orderId}/archive (which answers 204), the order's GET responds 404, exactly as for a deleted order, the order disappears from GET /api/v1/orders, and it cannot be changed. There is no parameter that includes archived orders. Restoring it with PUT /api/v1/orders/{orderId}/archive 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 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 set to true.

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

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.

ListDetail GETRule 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/accountsYesRead every id by cursor, then confirm each missing one with its detail GET. Only a 404 counts.
GET /api/v1/payment-methodsNoThe id must be missing from two consecutive sweeps.
GET /api/v1/invoices/{invoiceId}/paymentsNoRead 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 GET

GET /api/v1/users, GET /api/v1/collections and GET /api/v1/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 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"

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 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 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 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 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}, keyed by originSystem and originExternalId.
  • An Idempotency-Key deduplicates for 24 hours only (Idempotency). After that, a retried create makes a second contact: check your mapping table first.

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

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

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

AspectBehaviour
What you setThe 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 youIdempotency-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.
SignatureNone. The request is not signed.
RetriesNone 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.
Destinationhttp 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.
Limits15 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.

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.

EntityEvents
Ordersorder.created, order.reserved, order.cancelled, order.returned, order.archived, order.unarchived, order.completed, order.delivered, order.statusChanged
Quotesquote.created, quote.updated, quote.statusUpdated, quote.versionCreated
Invoicesinvoice.created, invoice.updated, invoice.validated, invoice.voided, invoice.paid, invoice.partiallyPaid
Paymentspayment.created, payment.linked
OthersContacts, tasks, items, projects, opportunities, incidents, purchases, contracts and credit notes have events of their own, such as contact.created, contact.updated and contact.deleted.
FieldContent
nameThe event name.
entityType, entityId, entityNameThe record the event is about.
actionCodeThe action that produced the event.
originWhere the change came from: neo, workflow, api, mcp or integration. It can be null.
actorWho made it: kind, id and name. For a change made through the API, the actor is the administrator who owns the key.
entityThe record, read after the change was committed. For a deletion, a snapshot of what was deleted.
changesA 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.

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

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

  • Guides

    Pagination

    Lists return one page at a time. Ask for a page with limit, and while pagination.hasMore is true call again with the nextCursor you were given.

  • Guides

    Filtering and sorting

    Most lists accept sort and the created* and updated* time filters, many accept list-valued filters such as statuses, and some accept q for 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-Key header 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 the If-Match header 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.