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.

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

Create a key in Filmo, in the API and MCP settings of the app (Ajustes → API y MCP; administrators only), with these scopes:

ScopeUsed for
account:readThe first call: timezone, currency, add-ons and the scopes of the key.
contacts:readMatching by email, looking up references, reading the contacts that changed.
contacts:writeCreating and updating contacts.
crm:readReading 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 as crm-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.

The 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 };
  }
}

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

Decide in this order, and stop at the first answer:

  1. Your table.

    If the CRM id is already linked to a Filmo id, use it. Nothing else is needed.

  2. The email.

    Ask GET /api/v1/contacts with email and compare the addresses yourself, as below. This also finds people the team created in the app before the sync existed.

  3. The reference.

    Only to decide what to create in bulk: POST /api/v1/contacts/lookup takes 1 to 200 references and returns notFound, the ones to create.

The 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[].email 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"

POST /api/v1/contacts/lookup 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 referenceWhat it meansWhat 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.
noMatchNo 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"]
}
JSON

Call POST /api/v1/contacts 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.mjs and the Python one as create.py; 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"
}
JSON

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

PATCH /api/v1/contacts/{contactId} 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/v1/contacts/{contactId}, 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).

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 echo

The 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/v1/contacts 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"

GET /api/v1/crm/opportunities 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"

If you also mirror projects, use their own key: externalReference is writable on POST /api/v1/projects and PATCH /api/v1/projects/{projectId}, and GET /api/v1/projects returns it. Orders show externalReference on reads only; the API cannot write it.

Do not delete the Filmo contact as a reflex. DELETE /api/v1/contacts/{contactId} 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/v1/contacts/{contactId}. Deleting and archiving are compared in Archiving and deleting.

ColumnWhy
CRM idThe key of the table.
Filmo contact idOpaque; never assume a format. Every later call uses it.
Fingerprint of the last state returned by FilmoEcho suppression: ignore a change that equals it.
updatedAt of that stateA cheap first check before comparing fingerprints.
Window watermark of the last full passWhere the next incremental pass starts (see the syncing guide).
Last error and its requestIdWhat you quote to support when a record keeps failing.

Errors are RFC 9457 problems; branch on code, never on the text of detail. The retry rules are in Errors. For this sync:

ResponseMeaning hereWhat to do
400 validation_errorA 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_adminThe 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_requiredThe account has no CRM add-on.Skip opportunities; keep syncing contacts.
403 insufficient_scopeThe key lacks the scope in requiredScope.Reissue the key with that scope.
403 forbiddenThe 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_foundThe 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 conflictRead errors[0].code. 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_failedThe contact changed after you read it.Read it again, merge, write again.
422 idempotency_key_reusedThe 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_limitedThe 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_errorA 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_unavailableNo 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.

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

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

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

    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.