Tutorials
Sync contacts from your CRM
Mirror the people in your CRM as Filmo contacts and keep duplicates in check, since the API cannot rule them out: match by email or by the contact number you set, keep your own table of ids, and ignore the echo of your own writes.
What you build
Link to this section: What you build- CRM to Filmo: every person in your CRM becomes one Filmo contact, created once and updated afterwards.
- Filmo to CRM: you read the contacts that changed in Filmo and the opportunities of the sales pipeline.
- The link between the two lives in a table on your side. A Filmo contact has no dedicated field for the id of another system; the contact number can help you find it again, but it is not a key.
The contact number is not a unique key
Contacts have no dedicated external-id field. contactNumber is writable on create and update (up to 50 characters), but it is not unique and Filmo does not generate it, so two contacts can carry the same number. Use it as a lookup aid, keep the pairing in your own table, and treat more than one contact with the same number as a conflict to resolve, not as a match.
Before you start
Link to this section: Before you startCreate a key in Filmo, in the API and MCP settings of the app (Ajustes → API y MCP; administrators only), with these scopes:
| Scope | Used for |
|---|---|
account:read | The first call: timezone, currency, add-ons and the scopes of the key. |
contacts:read | Matching by email, looking up references, reading the contacts that changed. |
contacts:write | Creating and updating contacts. |
crm:read | Reading opportunities. It needs the CRM add-on; without it the CRM operations answer 402. |
- Every write the key makes is attributed to the administrator who created it. Use a dedicated administrator account (Authentication).
- Agree a prefix for the contact numbers you write with whoever administers the account. The examples use
crm-followed by your CRM id, such ascrm-c-1042. Nothing enforces uniqueness, so the prefix keeps yours apart from numbers the team assigns in the app, and you check for duplicates yourself before and after you write. - The key allows 120 requests per minute. A new contact costs about two calls (the email check and the create), three if you look up the number one by one, so plan on roughly 40 to 60 new contacts per minute during an initial load.
1. Set up a client
Link to this section: 1. Set up a clientThe snippets in this tutorial call a small client that adds the base URL, the key, the optional headers and a retry on 429 that waits for Retry-After. Save it next to your sync code. The key stays in the environment; it never appears in code.
// filmo-client.mjs: one place for the base URL, the key and the 429 backoff.
const BASE = process.env.FILMO_BASE_URL ?? "https://app.filmo.io";
export async function filmo(method, path, { query, body, idempotencyKey, ifMatch, retries = 5 } = {}) {
const url = new URL(path, BASE);
for (const [name, value] of Object.entries(query ?? {})) {
if (value != null) url.searchParams.set(name, String(value));
}
const headers = { Authorization: `Bearer ${process.env.FILMO_API_KEY}` };
if (body) headers["Content-Type"] = "application/json";
if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
if (ifMatch) headers["If-Match"] = ifMatch;
for (let attempt = 0; ; attempt++) {
const res = await fetch(url, { method, headers, body: body ? JSON.stringify(body) : undefined });
if (res.status === 429 && attempt < retries) {
// Wait what the server says, plus a little jitter, then repeat the same request.
const seconds = Number(res.headers.get("Retry-After") ?? 1);
await new Promise((resolve) => setTimeout(resolve, (seconds + Math.random()) * 1000));
continue;
}
const isJson = (res.headers.get("Content-Type") ?? "").includes("json");
return { status: res.status, ok: res.ok, headers: res.headers, body: isJson ? await res.json() : null };
}
}# filmo_client.py: one place for the base URL, the key and the 429 backoff.
import os
import random
import time
import requests
BASE = os.environ.get("FILMO_BASE_URL", "https://app.filmo.io")
def filmo(method, path, params=None, body=None, idempotency_key=None, if_match=None, retries=5):
headers = {"Authorization": f"Bearer {os.environ['FILMO_API_KEY']}"}
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key
if if_match:
headers["If-Match"] = if_match
for attempt in range(retries + 1):
res = requests.request(
method, BASE + path, params=params, json=body, headers=headers, timeout=30
)
if res.status_code == 429 and attempt < retries:
# Wait what the server says, plus a little jitter, then repeat the same request.
time.sleep(float(res.headers.get("Retry-After", 1)) + random.random())
continue
break
is_json = "json" in res.headers.get("Content-Type", "")
return res.status_code, (res.json() if is_json else None), res.headers2. Check the key and the account
Link to this section: 2. Check the key and the accountGET /api is the first call of any integration. Check that addons lists what you need before you rely on opportunities, and keep timezone: bare days in filters are read in it.
BASE="${FILMO_BASE_URL:-https://app.filmo.io}"
curl -sS "$BASE/api/v1/account" \
-H "Authorization: Bearer $FILMO_API_KEY"import { filmo } from "./filmo-client.mjs";
const res = await filmo("GET", "/api/v1/account");
if (!res.ok) throw new Error("getAccount " + res.status + " " + res.body?.code);
const { timezone, currency, addons, trial, apiKey } = res.body.data;
console.log({ timezone, currency, addons, trial: trial.active, scopes: apiKey.scopes });from filmo_client import filmo
status, body, _ = filmo("GET", "/api/v1/account")
if status != 200:
raise RuntimeError(f"getAccount {status} {(body or {}).get('code')}")
account = body["data"]
print(account["timezone"], account["currency"], account["addons"],
account["trial"]["active"], account["apiKey"]["scopes"])3. Match a person to a contact
Link to this section: 3. Match a person to a contactDecide in this order, and stop at the first answer:
Your table.
If the CRM id is already linked to a Filmo id, use it. Nothing else is needed.
The email.
Ask
GET /apiwith/v1 /contacts emailand compare the addresses yourself, as below. This also finds people the team created in the app before the sync existed.The reference.
Only to decide what to create in bulk:
POST /apitakes 1 to 200 references and returns/v1 /contacts /lookup notFound, the ones to create.
By email
Link to this section: By emailThe email filter matches against all the addresses of a contact, and the spec does not say whether it matches exactly or in part. Read emails[]. in each result and keep only the exact matches. If more than one contact matches, the account has duplicates: send the person to a human to review instead of choosing one.
BASE="${FILMO_BASE_URL:-https://app.filmo.io}"
curl -sS -G "$BASE/api/v1/contacts" \
-H "Authorization: Bearer $FILMO_API_KEY" \
--data-urlencode "email=ana@example.com" \
--data-urlencode "limit=500"import { filmo } from "./filmo-client.mjs";
const sameEmail = (contact, email) =>
contact.emails.some((e) => e.email.toLowerCase() === email.toLowerCase());
// The filter's matching rules are not specified, so compare the addresses yourself.
export async function findByEmail(email) {
const res = await filmo("GET", "/api/v1/contacts", { query: { email, limit: 500 } });
if (!res.ok) throw new Error("listContacts " + res.status + " " + res.body?.requestId);
return res.body.data.filter((contact) => sameEmail(contact, email));
}from filmo_client import filmo
def same_email(contact, email):
return any(e["email"].lower() == email.lower() for e in contact["emails"])
# The filter's matching rules are not specified, so compare the addresses yourself.
def find_by_email(email):
status, body, _ = filmo("GET", "/api/v1/contacts", params={"email": email, "limit": 500})
if status != 200:
raise RuntimeError(f"listContacts {status} {(body or {}).get('requestId')}")
return [c for c in body["data"] if same_email(c, email)]By reference, in bulk
Link to this section: By reference, in bulkPOST /api writes nothing. It compares each reference only with contactNumber, as an exact match: there is no trimming and no case or tax-ID normalisation, so send the reference exactly as you wrote it. It returns { contacts: [{ id, name, contactNumber }], notFound } and no pairs, so you pair a contact to a reference when its contactNumber equals that reference. If several contacts share a number, all of them are returned, in no guaranteed order, with no warning. The code writes your id into the contact number on create and treats more than one match as a conflict.
| Result for a reference | What it means | What to do |
|---|---|---|
Paired (filmoId set) | Exactly one contact carries that contact number. | Link it and update it if needed. |
conflict (several ids) | More than one contact carries that number. | Send it to a human to merge or renumber; never pick one and never create. |
noMatch | No contact matched. | Check the email (step above) before creating: the person may already exist under another number. |
BASE="${FILMO_BASE_URL:-https://app.filmo.io}"
curl -sS -X POST "$BASE/api/v1/contacts/lookup" \
-H "Authorization: Bearer $FILMO_API_KEY" \
-H "Content-Type: application/json" \
--data @- <<'JSON'
{
"references": ["crm-c-1042", "crm-c-1043", "crm-c-1044"]
}
JSONimport { filmo } from "./filmo-client.mjs";
// crmIds: 1 to 200 ids of your CRM. The contact number we write is "crm-" + id.
export async function lookup(crmIds) {
const references = crmIds.map((id) => "crm-" + id);
const res = await filmo("POST", "/api/v1/contacts/lookup", { body: { references } });
if (!res.ok) throw new Error("lookupContacts " + res.status + " " + res.body?.requestId);
const { contacts, notFound } = res.body.data;
// The answer does not pair references with contacts: pair them by contactNumber yourself.
// A contact number is not unique, so several contacts can come back for one reference.
return references.map((reference) => {
const matches = contacts.filter((c) => c.contactNumber === reference);
return {
reference,
filmoId: matches.length === 1 ? matches[0].id : null,
conflict: matches.length > 1 ? matches.map((c) => c.id) : null, // for a human to resolve
noMatch: notFound.includes(reference),
};
});
}from filmo_client import filmo
# crm_ids: 1 to 200 ids of your CRM. The contact number we write is "crm-" + id.
def lookup(crm_ids):
references = ["crm-" + i for i in crm_ids]
status, body, _ = filmo("POST", "/api/v1/contacts/lookup", body={"references": references})
if status != 200:
raise RuntimeError(f"lookupContacts {status} {(body or {}).get('requestId')}")
data = body["data"]
# The answer does not pair references with contacts: pair them by contactNumber yourself.
# A contact number is not unique, so several contacts can come back for one reference.
results = []
for ref in references:
matches = [c["id"] for c in data["contacts"] if c["contactNumber"] == ref]
results.append({
"reference": ref,
"filmo_id": matches[0] if len(matches) == 1 else None,
"conflict": matches if len(matches) > 1 else None, # for a human to resolve
"no_match": ref in data["notFound"],
})
return results4. Create the contact once
Link to this section: 4. Create the contact onceCall POST /api with the fields your CRM owns, including the contact number. Only name is required; the body is checked against an allow-list of fields, and a field outside it is rejected as unknown_field. Send an Idempotency-Key built from the CRM id: a retry after a timeout returns the first response instead of creating a second contact.
Check for duplicates before you write: a lookup of the number you are about to use should come back in notFound. Store the link as soon as the response arrives: the Filmo id, the updatedAt and a fingerprint of the contact **as the API returned it**. The next steps need all three. Save the Node snippet as create. and the Python one as create.; later snippets import them.
BASE="${FILMO_BASE_URL:-https://app.filmo.io}"
curl -sS -X POST "$BASE/api/v1/contacts" \
-H "Authorization: Bearer $FILMO_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: crm-contact-create-c-1042" \
--data @- <<'JSON'
{
"name": "Ana Example",
"contactType": "person",
"role": "client",
"email": "ana@example.com",
"phone": "+34 600 000 000",
"contactNumber": "crm-c-1042"
}
JSONimport { createHash } from "node:crypto";
import { filmo } from "./filmo-client.mjs";
// A stand-in for your database: CRM id -> { filmoId, hash, updatedAt }.
export const links = new Map();
const primary = (list, key) => (list.find((x) => x.isPrimary) ?? list[0])?.[key] ?? "";
// The fingerprint of a Filmo contact, from what the API returned (never from what you sent).
export const hashOf = (dto) =>
createHash("sha256")
.update(JSON.stringify([
dto.name,
dto.contactType,
primary(dto.emails, "email").toLowerCase(),
primary(dto.phoneNumbers, "number"),
dto.contactNumber ?? "",
]))
.digest("hex");
export const link = (crmId, dto) =>
links.set(crmId, { filmoId: dto.id, hash: hashOf(dto), updatedAt: dto.updatedAt });
export async function createInFilmo(person) {
const res = await filmo("POST", "/api/v1/contacts", {
idempotencyKey: "crm-contact-create-" + person.id,
body: {
name: person.name,
contactType: person.type, // "person" or "company"
role: "client",
email: person.email,
phone: person.phone,
contactNumber: "crm-" + person.id,
},
});
if (res.ok) link(person.id, res.body.data);
return res; // 400 and 422 are for the caller to route, see "Handle errors"
}import hashlib
import json
from filmo_client import filmo
# A stand-in for your database: CRM id -> {"filmo_id", "hash", "updated_at"}.
links = {}
def primary(items, key):
chosen = next((x for x in items if x["isPrimary"]), items[0] if items else None)
return chosen[key] if chosen else ""
# The fingerprint of a Filmo contact, from what the API returned (never from what you sent).
def hash_of(dto):
fields = [
dto["name"],
dto["contactType"],
primary(dto["emails"], "email").lower(),
primary(dto["phoneNumbers"], "number"),
dto["contactNumber"] or "",
]
return hashlib.sha256(json.dumps(fields).encode()).hexdigest()
def link(crm_id, dto):
links[crm_id] = {"filmo_id": dto["id"], "hash": hash_of(dto), "updated_at": dto["updatedAt"]}
def create_in_filmo(person):
body = {
"name": person["name"],
"contactType": person["type"], # "person" or "company"
"role": "client",
"email": person["email"],
"phone": person.get("phone"),
"contactNumber": "crm-" + person["id"],
}
body = {k: v for k, v in body.items() if v is not None}
status, payload, _ = filmo(
"POST", "/api/v1/contacts", body=body,
idempotency_key="crm-contact-create-" + person["id"],
)
if status == 201:
link(person["id"], payload["data"])
return status, payload # 400 and 422 are for the caller to route, see "Handle errors"A key lasts 24 hours
Idempotency-Key deduplicates for 24 hours. After that, the same call creates a second contact, which is why your table and the match step exist (Idempotency).
5. Update it afterwards
Link to this section: 5. Update it afterwardsPATCH /api changes only the fields you send, with one exception: emails, phoneNumbers, websites and addresses replace the whole list. If you send emails without the addresses people added in Filmo, those addresses are erased. So read the contact with GET /api, merge, and write conditionally with the ETag as If-Match. A 412 means the contact changed after you read it: read again.
Build the Idempotency-Key from the CRM id, the version of the CRM record and a short hash of the body you send. A retry of the same change reuses the key; a later change, or a body that differs because you merged again after a 412, gets a new one. Sending the same key with a different body is rejected with 422 idempotency_key_reused. The snippet writes contactNumber only when the contact has none: a number the team set in the app is never overwritten.
BASE="${FILMO_BASE_URL:-https://app.filmo.io}"
# 1. Read the contact: the response carries its ETag header and the current lists.
curl -sS -i "$BASE/api/v1/contacts/$CONTACT_ID" \
-H "Authorization: Bearer $FILMO_API_KEY"
# 2. Write only what changed, conditional on the ETag header you just read (set ETAG to its value).
curl -sS -X PATCH "$BASE/api/v1/contacts/$CONTACT_ID" \
-H "Authorization: Bearer $FILMO_API_KEY" \
-H "Content-Type: application/json" \
-H "If-Match: $ETAG" \
-H "Idempotency-Key: crm-contact-update-c-1042-v7" \
--data @- <<'JSON'
{
"name": "Ana Example Rentals"
}
JSON
# Add "contactNumber": "crm-c-1042" only when the contact has none yet (see the text).import { createHash } from "node:crypto";
import { filmo } from "./filmo-client.mjs";
import { link } from "./create.mjs";
// person.version: the version or updated-at of the CRM record. The key also carries a hash of
// the body: a retry after a timeout whose body changed in the meantime gets a new key, and the same key with another body is a 422.
export async function updateInFilmo(person, filmoId) {
for (let attempt = 0; attempt < 3; attempt++) {
const current = await filmo("GET", "/api/v1/contacts/" + filmoId);
if (current.status === 404) return current; // deleted or outside your tenancy (an archived contact answers 200)
const dto = current.body.data;
const patch = { name: person.name };
// Never overwrite a contact number: the team may have set its own. Write ours only when empty.
if (!dto.contactNumber) patch.contactNumber = "crm-" + person.id;
if (!dto.emails.some((e) => e.email.toLowerCase() === person.email.toLowerCase())) {
// emails REPLACES the whole list: keep the addresses people added in Filmo.
patch.emails = [
{ email: person.email, isPrimary: true },
...dto.emails.map((e) => ({ email: e.email, label: e.label ?? undefined, isPrimary: false })),
];
}
const res = await filmo("PATCH", "/api/v1/contacts/" + filmoId, {
body: patch,
ifMatch: current.headers.get("ETag"),
idempotencyKey:
"crm-contact-update-" + person.id + "-v" + person.version + "-" +
createHash("sha256").update(JSON.stringify(patch)).digest("hex").slice(0, 12),
});
if (res.status === 412) continue; // it changed after you read it: read again
if (res.ok) link(person.id, res.body.data);
return res;
}
throw new Error("contact " + filmoId + " keeps changing; try again on the next pass");
}import hashlib
import json
from create import link
from filmo_client import filmo
# person["version"]: the version or updated-at of the CRM record. The key also carries a hash of
# the body: a retry after a timeout whose body changed in the meantime gets a new key, and the same key with another body is a 422.
def update_in_filmo(person, filmo_id):
for _ in range(3):
status, payload, headers = filmo("GET", "/api/v1/contacts/" + filmo_id)
if status == 404:
return status, payload # deleted or outside your tenancy (an archived contact answers 200)
dto = payload["data"]
patch = {"name": person["name"]}
# Never overwrite a contact number: the team may have set its own. Write ours only when empty.
if not dto["contactNumber"]:
patch["contactNumber"] = "crm-" + person["id"]
if not any(e["email"].lower() == person["email"].lower() for e in dto["emails"]):
# emails REPLACES the whole list: keep the addresses people added in Filmo.
patch["emails"] = [{"email": person["email"], "isPrimary": True}] + [
{k: v for k, v in
{"email": e["email"], "label": e["label"], "isPrimary": False}.items()
if v is not None}
for e in dto["emails"]
]
status, payload, _ = filmo(
"PATCH", "/api/v1/contacts/" + filmo_id, body=patch,
if_match=headers["ETag"],
idempotency_key="crm-contact-update-" + person["id"] + "-v" + str(person["version"]) + "-"
+ hashlib.sha256(json.dumps(patch, sort_keys=True).encode()).hexdigest()[:12],
)
if status == 412:
continue # it changed after you read it: read again
if status == 200:
link(person["id"], payload["data"])
return status, payload
raise RuntimeError(f"contact {filmo_id} keeps changing; try again on the next pass")6. Read what changed in Filmo and ignore your own echo
Link to this section: 6. Read what changed in Filmo and ignore your own echoThe API does not expose where a change came from, and everything the key writes is attributed to its administrator. Your own create or update therefore appears again as a change. To tell the echoes from real edits, compare the fingerprint of each contact you read with the one you stored after your last write: if they are equal, nothing changed that you care about. Fingerprint the response, not the request: the server may normalise values, such as how a phone number is split.
To find the contacts that changed, ask GET /api for a window with updatedFrom and updatedBefore, sorted by updatedAt, one page per window. Choosing the window, splitting it when hasMore is true and never following the cursor are covered in Syncing data; do not rebuild them here.
BASE="${FILMO_BASE_URL:-https://app.filmo.io}"
# One window, one page: [A, B) with A and B as UTC instants.
curl -sS -G "$BASE/api/v1/contacts" \
-H "Authorization: Bearer $FILMO_API_KEY" \
--data-urlencode "updatedFrom=2026-10-09T08:00:00Z" \
--data-urlencode "updatedBefore=2026-10-09T08:15:00Z" \
--data-urlencode "sort=updatedAt" \
--data-urlencode "limit=500"import { filmo } from "./filmo-client.mjs";
import { hashOf, links } from "./create.mjs";
// Contacts changed in Filmo inside [from, before), minus the echoes of your own writes.
export async function changedContacts(from, before) {
const res = await filmo("GET", "/api/v1/contacts", {
query: { updatedFrom: from, updatedBefore: before, sort: "updatedAt", limit: 500 },
});
if (!res.ok) throw new Error("listContacts " + res.status + " " + res.body?.requestId);
if (res.body.pagination.hasMore) throw new Error("window too large: split it, do not follow the cursor");
const crmIdOf = new Map([...links].map(([crmId, l]) => [l.filmoId, crmId]));
const changes = [];
for (const dto of res.body.data) {
const crmId = crmIdOf.get(dto.id) ?? null;
if (crmId && hashOf(dto) === links.get(crmId).hash) continue; // your own write coming back
changes.push({ crmId, dto });
// After you apply a change to the CRM, store its fingerprint so the CRM does not send it back:
// links.get(crmId).hash = hashOf(dto);
}
return changes;
}from create import hash_of, links
from filmo_client import filmo
# Contacts changed in Filmo inside [from, before), minus the echoes of your own writes.
def changed_contacts(start, before):
status, body, _ = filmo("GET", "/api/v1/contacts", params={
"updatedFrom": start, "updatedBefore": before, "sort": "updatedAt", "limit": 500,
})
if status != 200:
raise RuntimeError(f"listContacts {status} {(body or {}).get('requestId')}")
if body["pagination"]["hasMore"]:
raise RuntimeError("window too large: split it, do not follow the cursor")
crm_id_of = {link["filmo_id"]: crm_id for crm_id, link in links.items()}
changes = []
for dto in body["data"]:
crm_id = crm_id_of.get(dto["id"])
if crm_id and hash_of(dto) == links[crm_id]["hash"]:
continue # your own write coming back
changes.append({"crm_id": crm_id, "dto": dto})
# After you apply a change to the CRM, store its fingerprint so the CRM does not send it back:
# links[crm_id]["hash"] = hash_of(dto)
return changes7. Read the opportunities
Link to this section: 7. Read the opportunitiesGET /api returns only the open opportunities unless you pass status with won, lost or all. Always send status=all, or a deal that is won or lost disappears from the list and your CRM never learns it closed. The list has no filter by update date; it can be sorted by updatedAt, so read it newest first and stop when you reach your watermark, as the syncing guide describes.
An opportunity carries contactName and companyName, not the id of the contact. To attach opportunities to a person you have linked, ask for them with contactId, one call per contact. The filter covers people only: for a contact of type company, list the opportunities and match them by companyName. That call, and listing pipelines, need the CRM add-on: without it the answer is 402 addon_required.
BASE="${FILMO_BASE_URL:-https://app.filmo.io}"
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"
# The opportunities of one mapped contact:
curl -sS -G "$BASE/api/v1/crm/opportunities" \
-H "Authorization: Bearer $FILMO_API_KEY" \
--data-urlencode "status=all" \
--data-urlencode "contactId=$CONTACT_ID"import { filmo } from "./filmo-client.mjs";
// Opportunities updated since a watermark: newest first, stop when you pass it, dedupe by id.
export async function changedOpportunities(since) {
const found = new Map();
let cursor;
for (;;) {
const res = await filmo("GET", "/api/v1/crm/opportunities", {
query: { status: "all", sort: "-updatedAt", limit: 500, cursor },
});
if (res.status === 402) return []; // no CRM add-on on this account
if (!res.ok) throw new Error("listOpportunities " + res.status + " " + res.body?.requestId);
const page = res.body.data;
for (const o of page) {
if (Date.parse(o.updatedAt) >= Date.parse(since)) found.set(o.opportunityId, o);
}
const last = page.at(-1);
if (!res.body.pagination.hasMore || !last || Date.parse(last.updatedAt) < Date.parse(since)) {
return [...found.values()];
}
cursor = res.body.pagination.nextCursor;
}
}from datetime import datetime
from filmo_client import filmo
def parse(instant):
return datetime.fromisoformat(instant.replace("Z", "+00:00"))
# Opportunities updated since a watermark: newest first, stop when you pass it, dedupe by id.
def changed_opportunities(since):
found, cursor = {}, None
while True:
status, body, _ = filmo("GET", "/api/v1/crm/opportunities", params={
"status": "all", "sort": "-updatedAt", "limit": 500, "cursor": cursor,
})
if status == 402:
return [] # no CRM add-on on this account
if status != 200:
raise RuntimeError(f"listOpportunities {status} {(body or {}).get('requestId')}")
page = body["data"]
for o in page:
if parse(o["updatedAt"]) >= parse(since):
found[o["opportunityId"]] = o
if not body["pagination"]["hasMore"] or not page or parse(page[-1]["updatedAt"]) < parse(since):
return list(found.values())
cursor = body["pagination"]["nextCursor"]Projects and orders
Link to this section: Projects and ordersIf you also mirror projects, use their own key: externalReference is writable on POST /api and PATCH /api, and GET /api returns it. Orders show externalReference on reads only; the API cannot write it.
When a person disappears from the CRM
Link to this section: When a person disappears from the CRMDo not delete the Filmo contact as a reflex. DELETE /api is permanent and answers 409 contact_in_use when orders, quotes or other records still use the contact. Mark the link as closed in your table and, if the team agrees, set the contact's status to inactive with PATCH /api. Deleting and archiving are compared in Archiving and deleting.
What to store on your side
Link to this section: What to store on your side| Column | Why |
|---|---|
| CRM id | The key of the table. |
| Filmo contact id | Opaque; never assume a format. Every later call uses it. |
| Fingerprint of the last state returned by Filmo | Echo suppression: ignore a change that equals it. |
updatedAt of that state | A cheap first check before comparing fingerprints. |
| Window watermark of the last full pass | Where the next incremental pass starts (see the syncing guide). |
Last error and its requestId | What you quote to support when a record keeps failing. |
Handle errors
Link to this section: Handle errorsErrors are RFC 9457 problems; branch on code, never on the text of detail. The retry rules are in Errors. For this sync:
| Response | Meaning here | What to do |
|---|---|---|
400 validation_error | A field of the contact was rejected. errors[] lists every offending path and code. | Log the record as rejected with the paths and move on; retrying the same body fails again. |
401 api_key_owner_inactive, api_key_owner_not_admin | The key is fine; its owner left or stopped being an administrator. | Stop the sync, alert, and reissue the key from an administrator account. |
402 addon_required | The account has no CRM add-on. | Skip opportunities; keep syncing contacts. |
403 insufficient_scope | The key lacks the scope in requiredScope. | Reissue the key with that scope. |
403 forbidden | The owner of the key lacks the permission the operation requires. | Fix the owner's permissions in the app; reissuing the key does not help. |
404 not_found | The linked contact no longer exists or is outside the key's tenancy. | Mark the link as missing and run the match step again. Do not recreate it blindly. |
409 conflict | Read errors[0].. in_progress: another request with the same key is still running. lock_timeout: another write held the contact for more than five seconds. invalid_state: the state of the contact forbids the operation. | For in_progress and lock_timeout, wait and repeat the same request with the same key. For invalid_state, do not retry: read the contact and change the request. |
412 precondition_failed | The contact changed after you read it. | Read it again, merge, write again. |
422 idempotency_key_reused | The same key was used within 24 hours with a different body, so the contact was probably created already. | Find it by email or reference and update it; do not create. |
429 rate_limited | The key used its 120 requests per minute. | Wait Retry-After seconds (the problem body repeats it in retryAfter); the client above does it. |
500 internal_error | A failure on Filmo's side. | Repeat the same request with the same Idempotency-Key, with growing waits. If the answer carries Idempotent-Replay: true, Filmo is replaying a stored error: do not keep repeating. Look the contact up by email or contactNumber before creating another, and quote the requestId to support. |
503 service_unavailable | No operation of this tutorial documents it. Where the API answers it, a feature is switched off for the account, and Filmo controls that switch. | Do not retry it as a transient failure. Skip the record, keep the rest of the sync running and contact Filmo support with the requestId. |
Automations
Link to this section: AutomationsWrites through the API fire the account's event workflows, like the same change made in the app. They run after the response, on a best-effort basis, and you cannot suppress them per request. An initial load is a long run of writes, so list the active event workflows first (see Testing) and, in each one that should not react, filter the trigger by origin to exclude api and mcp.
Where to go next
Link to this section: Where to go next- Syncing data: the windowed pass, early stop and the sweep that confirms deletions.
- Idempotency and Rate limits: the rules behind the headers used here.
- Records and ids: why identifiers are opaque.
- Turn a web form into an opportunity: the same find-or-create for new leads.
- In the app: Create a contact and Pipelines and stages.
Related pages
Link to this section: Related pagesGuides
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.
Tutorials
Turn a web form into an opportunity
Your server receives the form, finds or creates the contact by email, and opens an opportunity in the pipeline, with a key derived from the submission so that no retry creates a second lead.
Guides
Idempotency and safe retries
Send an
Idempotency-Keyheader with every write and reuse the same value when you retry that operation: Filmo returns the stored result of the first attempt instead of writing twice.Guides
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.Core concepts
Records and ids
Every record has an opaque id that Filmo assigns. Keep your own ids in a mapping table on your side, and use a reference field only where the API has one.
Was this page helpful?