*REST API › 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

- 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

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

## 1. Set up a client

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.

**Node**

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

**Python**

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

## 2. Check the key and the account

[`GET /api/v1/account`](https://docs.filmo.io/rest-api/endpoint-reference/account/get-the-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.

**cURL**

```bash
BASE="${FILMO_BASE_URL:-https://app.filmo.io}"
curl -sS "$BASE/api/v1/account" \
  -H "Authorization: Bearer $FILMO_API_KEY"
```

**Node**

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

**Python**

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

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`](https://docs.filmo.io/rest-api/endpoint-reference/contacts/list-contacts) with [`email`](https://docs.filmo.io/rest-api/endpoint-reference/contacts/list-contacts#param-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`](https://docs.filmo.io/rest-api/endpoint-reference/contacts/look-up-contacts-by-external-reference) takes 1 to 200 references and returns `notFound`, the ones to create.

### By email

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.

**cURL**

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

**Node**

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

**Python**

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

[`POST /api/v1/contacts/lookup`](https://docs.filmo.io/rest-api/endpoint-reference/contacts/look-up-contacts-by-external-reference) 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. |

**cURL**

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

**Node**

```javascript
import { 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),
    };
  });
}
```

**Python**

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

## 4. Create the contact once

Call [`POST /api/v1/contacts`](https://docs.filmo.io/rest-api/endpoint-reference/contacts/create-a-contact) 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.

**cURL**

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

**Node**

```javascript
import { 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"
}
```

**Python**

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

## 5. Update it afterwards

[`PATCH /api/v1/contacts/{contactId}`](https://docs.filmo.io/rest-api/endpoint-reference/contacts/update-a-contact) 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}`](https://docs.filmo.io/rest-api/endpoint-reference/contacts/get-a-contact), 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`](https://docs.filmo.io/rest-api/errors/idempotency_key_reused). The snippet writes `contactNumber` only when the contact has none: a number the team set in the app is never overwritten.

**cURL**

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

**Node**

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

**Python**

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

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`](https://docs.filmo.io/rest-api/endpoint-reference/contacts/list-contacts) for a window with [`updatedFrom`](https://docs.filmo.io/rest-api/endpoint-reference/contacts/list-contacts#param-updatedFrom) and [`updatedBefore`](https://docs.filmo.io/rest-api/endpoint-reference/contacts/list-contacts#param-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](https://docs.filmo.io/rest-api/guides/syncing-data); do not rebuild them here.

**cURL**

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

**Node**

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

**Python**

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

## 7. Read the opportunities

[`GET /api/v1/crm/opportunities`](https://docs.filmo.io/rest-api/endpoint-reference/crm/list-opportunities) returns only the open opportunities unless you pass [`status`](https://docs.filmo.io/rest-api/endpoint-reference/crm/list-opportunities#param-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`](https://docs.filmo.io/rest-api/endpoint-reference/crm/list-opportunities#param-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`](https://docs.filmo.io/rest-api/errors/addon_required).

**cURL**

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

**Node**

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

**Python**

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

If you also mirror projects, use their own key: `externalReference` is writable on [`POST /api/v1/projects`](https://docs.filmo.io/rest-api/endpoint-reference/projects/create-a-project) and [`PATCH /api/v1/projects/{projectId}`](https://docs.filmo.io/rest-api/endpoint-reference/projects/update-a-project), and [`GET /api/v1/projects`](https://docs.filmo.io/rest-api/endpoint-reference/projects/list-projects) returns it. Orders show [`externalReference`](https://docs.filmo.io/rest-api/objects/order#response-externalReference) on reads only; the API cannot write it.

## When a person disappears from the CRM

Do not delete the Filmo contact as a reflex. [`DELETE /api/v1/contacts/{contactId}`](https://docs.filmo.io/rest-api/endpoint-reference/contacts/delete-a-contact) is permanent and answers 409 [`contact_in_use`](https://docs.filmo.io/rest-api/errors/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}`](https://docs.filmo.io/rest-api/endpoint-reference/contacts/update-a-contact). Deleting and archiving are compared in [Archiving and deleting](https://docs.filmo.io/further-reading/archiving-and-deleting).

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

Errors are RFC 9457 problems; branch on `code`, never on the text of `detail`. The retry rules are in [Errors](https://docs.filmo.io/rest-api/guides/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].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_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

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](https://docs.filmo.io/rest-api/guides/testing)) and, in each one that should not react, filter the trigger by origin to exclude `api` and `mcp`.

## Where to go next

- [Syncing data](https://docs.filmo.io/rest-api/guides/syncing-data): the windowed pass, early stop and the sweep that confirms deletions.
- [Idempotency](https://docs.filmo.io/rest-api/guides/idempotency) and [Rate limits](https://docs.filmo.io/rest-api/guides/rate-limits): the rules behind the headers used here.
- [Records and ids](https://docs.filmo.io/concepts/records-and-ids): why identifiers are opaque.
- [Turn a web form into an opportunity](https://docs.filmo.io/rest-api/tutorials/web-form-to-opportunity): the same find-or-create for new leads.
- In the app: [Create a contact](https://www.filmo.io/help/reference/clients-and-crm/contacts/create-a-contact) and [Pipelines and stages](https://www.filmo.io/help/reference/clients-and-crm/crm/crm-pipelines-and-stages).

---

Canonical URL: https://docs.filmo.io/rest-api/tutorials/crm-contact-sync  
Last updated: 2026-10-09