*API REST › Tutoriales*

# Sincronizar contactos desde tu CRM

> Replica a las personas de tu CRM como contactos de Filmo y controla los duplicados, ya que la API no puede descartarlos: busca por email o por el número de contacto que establezcas, conserva tu propia tabla de ids e ignora el eco de tus propias escrituras.

## Lo que construyes

- Del CRM a Filmo: cada persona de tu CRM se convierte en un contacto de Filmo, que se crea una vez y se actualiza después.
- De Filmo al CRM: lees los contactos que cambiaron en Filmo y las oportunidades del embudo de ventas.
- El vínculo entre ambos vive en una tabla de tu lado. Un contacto de Filmo no tiene un campo específico para el id de otro sistema; el número de contacto puede ayudarte a encontrarlo de nuevo, pero no es una clave.

> **El número de contacto no es una clave única:** Los contactos no tienen un campo dedicado para un id externo. `contactNumber` se puede escribir al crear y al actualizar (hasta 50 caracteres), pero no es único y Filmo no lo genera, así que dos contactos pueden llevar el mismo número. Úsalo como ayuda de búsqueda, guarda el emparejamiento en tu propia tabla y trata más de un contacto con el mismo número como un conflicto que resolver, no como una coincidencia.

## Antes de empezar

Crea una clave en Filmo, en los ajustes de API y MCP de la app (Ajustes → API y MCP; solo administradores), con estos scopes:

| Permiso (scope) | Se usa para |
| --- | --- |
| `account:read` | La primera llamada: zona horaria, moneda, add-ons y los scopes de la clave. |
| `contacts:read` | Buscar coincidencias por email, consultar referencias, leer los contactos que han cambiado. |
| `contacts:write` | Crear y actualizar contactos. |
| `crm:read` | Lectura de oportunidades. Necesita el add-on de CRM; sin él, las operaciones de CRM responden 402. |

- Toda escritura que hace la clave se atribuye al administrador que la creó. Usa una cuenta de administrador dedicada ([Autenticación](https://docs.filmo.io/es/rest-api/guides/authentication)).
- Acuerda con quien administre la cuenta un prefijo para los números de contacto que escribas. Los ejemplos usan `crm-` seguido de tu id de CRM, como `crm-c-1042`. Nada impone la unicidad, así que el prefijo separa los tuyos de los números que asigna el equipo en la app, y tú mismo compruebas los duplicados antes y después de escribir.
- La clave permite 120 peticiones por minuto. Un contacto nuevo cuesta unas dos llamadas (la comprobación del email y la creación), tres si buscas el número uno a uno, así que calcula unos 40 a 60 contactos nuevos por minuto durante una carga inicial.

## 1. Configurar un cliente

Los fragmentos de este tutorial llaman a un cliente pequeño que añade la URL base, la clave, las cabeceras opcionales y un reintento ante 429 que espera `Retry-After`. Guárdalo junto a tu código de sincronización. La clave se queda en el entorno; nunca aparece en el código.

**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. Comprobar la clave y la cuenta

[`GET /api/v1/account`](https://docs.filmo.io/es/rest-api/endpoint-reference/account/get-the-account) es la primera llamada de cualquier integración. Comprueba que `addons` lista lo que necesitas antes de depender de las oportunidades, y guarda `timezone`: las fechas sin hora de los filtros se leen en ella.

**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. Asociar una persona a un contacto

Decide en este orden y para en la primera respuesta:

1. **Tu tabla.** Si el id del CRM ya está vinculado a un id de Filmo, úsalo. No hace falta nada más.
2. **El email.** Llama a [`GET /api/v1/contacts`](https://docs.filmo.io/es/rest-api/endpoint-reference/contacts/list-contacts) con [`email`](https://docs.filmo.io/es/rest-api/endpoint-reference/contacts/list-contacts#param-email) y compara tú mismo las direcciones, como se muestra abajo. Así también encuentras a las personas que el equipo creó en la app antes de que existiera la sincronización.
3. **La referencia.** Solo para decidir qué crear en bloque: [`POST /api/v1/contacts/lookup`](https://docs.filmo.io/es/rest-api/endpoint-reference/contacts/look-up-contacts-by-external-reference) acepta de 1 a 200 referencias y devuelve `notFound`, las que hay que crear.

### Por email

El filtro de email coincide con todas las direcciones de un contacto, y la especificación no dice si coincide de forma exacta o parcial. Lee `emails[].email` en cada resultado y quédate solo con las coincidencias exactas. Si coincide más de un contacto, la cuenta tiene duplicados: deriva a la persona a un humano para que lo revise en lugar de elegir uno.

**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)]
```

### Por referencia, en masa

[`POST /api/v1/contacts/lookup`](https://docs.filmo.io/es/rest-api/endpoint-reference/contacts/look-up-contacts-by-external-reference) no escribe nada. Compara cada referencia solo con `contactNumber`, como coincidencia exacta: no se recortan espacios ni se normalizan mayúsculas ni identificadores fiscales, así que envía la referencia exactamente como la escribiste. Devuelve `{ contacts: [{ id, name, contactNumber }], notFound }` y no pares, así que emparejas un contacto con una referencia cuando su `contactNumber` es igual a esa referencia. Si varios contactos comparten un número, se devuelven todos, en un orden no garantizado, sin aviso. El código escribe tu id en el número de contacto al crear y trata más de una coincidencia como un conflicto.

| Resultado para una referencia | Qué significa | Qué hacer |
| --- | --- | --- |
| Emparejado (`filmoId` definido) | Exactamente un contacto lleva ese número de contacto. | Vincúlalo y actualízalo si hace falta. |
| `conflict` (varios ids) | Más de un contacto tiene ese número. | Pásalo a una persona para que lo fusione o renumere; nunca elijas uno y nunca crees. |
| `noMatch` | Ningún contacto coincide. | Comprueba el email (paso anterior) antes de crear: puede que la persona ya exista con otro número. |

**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. Crear el contacto una sola vez

Llama a [`POST /api/v1/contacts`](https://docs.filmo.io/es/rest-api/endpoint-reference/contacts/create-a-contact) con los campos que gestiona tu CRM, incluido el número de contacto. Solo `name` es obligatorio; el cuerpo se comprueba contra una lista de campos permitidos, y un campo que no esté en ella se rechaza como `unknown_field`. Envía una `Idempotency-Key` construida a partir del id del CRM: un reintento tras un timeout devuelve la primera respuesta en lugar de crear un segundo contacto.

Comprueba si hay duplicados antes de escribir: una búsqueda del número que vas a usar debería salir en `notFound`. Guarda el vínculo en cuanto llegue la respuesta: el `id` de Filmo, el `updatedAt` y una huella del contacto **tal como lo devolvió la API**. Los pasos siguientes necesitan los tres. Guarda el fragmento de Node como `create.mjs` y el de Python como `create.py`; los fragmentos posteriores los importan.

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

> **Una clave dura 24 horas:** `Idempotency-Key` deduplica durante 24 horas. Pasado ese tiempo, la misma llamada crea un segundo contacto, y por eso existen tu tabla y el paso de coincidencia ([Idempotencia](https://docs.filmo.io/es/rest-api/guides/idempotency)).

## 5. Actualizarlo después

[`PATCH /api/v1/contacts/{contactId}`](https://docs.filmo.io/es/rest-api/endpoint-reference/contacts/update-a-contact) cambia solo los campos que envías, con una excepción: `emails`, `phoneNumbers`, `websites` y `addresses` sustituyen toda la lista. Si envías `emails` sin las direcciones que la gente añadió en Filmo, esas direcciones se borran. Así que lee el contacto con [`GET /api/v1/contacts/{contactId}`](https://docs.filmo.io/es/rest-api/endpoint-reference/contacts/get-a-contact), fusiona y escribe de forma condicional con el `ETag` como `If-Match`. Un 412 significa que el contacto cambió después de que lo leyeras: vuelve a leer.

Construye la `Idempotency-Key` a partir del id del CRM, la versión del registro del CRM y un hash corto del cuerpo que envías. Un reintento del mismo cambio reutiliza la clave; un cambio posterior, o un cuerpo distinto porque volviste a fusionar tras un 412, recibe una nueva. Enviar la misma clave con un cuerpo distinto se rechaza con 422 [`idempotency_key_reused`](https://docs.filmo.io/es/rest-api/errors/idempotency_key_reused). El fragmento escribe `contactNumber` solo cuando el contacto no tiene ninguno: un número que el equipo puso en la app nunca se sobrescribe.

**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. Leer lo que ha cambiado en Filmo e ignorar tu propio eco

La API no expone de dónde viene un cambio, y todo lo que escribe la clave se atribuye a su administrador. Por eso tu propia creación o actualización aparece de nuevo como un cambio. Para distinguir los ecos de las ediciones reales, compara la huella de cada contacto que leas con la que guardaste tras tu última escritura: si son iguales, no cambió nada que te importe. Calcula la huella de la respuesta, no de la petición: el servidor puede normalizar valores, como la forma de dividir un número de teléfono.

Para encontrar los contactos que han cambiado, pide a [`GET /api/v1/contacts`](https://docs.filmo.io/es/rest-api/endpoint-reference/contacts/list-contacts) una ventana con [`updatedFrom`](https://docs.filmo.io/es/rest-api/endpoint-reference/contacts/list-contacts#param-updatedFrom) y [`updatedBefore`](https://docs.filmo.io/es/rest-api/endpoint-reference/contacts/list-contacts#param-updatedBefore), ordenada por `updatedAt`, una página por ventana. Cómo elegir la ventana, dividirla cuando `hasMore` es true y no seguir nunca el cursor se explica en [Sincronizar datos](https://docs.filmo.io/es/rest-api/guides/syncing-data); no las reconstruyas aquí.

**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. Leer las oportunidades

[`GET /api/v1/crm/opportunities`](https://docs.filmo.io/es/rest-api/endpoint-reference/crm/list-opportunities) devuelve solo las oportunidades abiertas a menos que pases [`status`](https://docs.filmo.io/es/rest-api/endpoint-reference/crm/list-opportunities#param-status) con `won`, `lost` o `all`. Envía siempre `status=all`, o un trato ganado o perdido desaparece de la lista y tu CRM nunca se entera de que se cerró. La lista no tiene filtro por fecha de actualización; se puede ordenar por `updatedAt`, así que léela empezando por lo más reciente y detente al llegar a tu marca de agua, como describe la guía de sincronización.

Una oportunidad lleva `contactName` y `companyName`, no el id del contacto. Para asociar oportunidades a una persona que has vinculado, pídelas con [`contactId`](https://docs.filmo.io/es/rest-api/endpoint-reference/crm/list-opportunities#param-contactId), una llamada por contacto. El filtro cubre solo personas: para un contacto de tipo `company`, lista las oportunidades y emparéjalas por `companyName`. Esa llamada, y listar embudos, necesitan el add-on de CRM: sin él la respuesta es 402 [`addon_required`](https://docs.filmo.io/es/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"]
```

## Proyectos y pedidos

Si también replicas proyectos, usa su propia clave: `externalReference` es escribible en [`POST /api/v1/projects`](https://docs.filmo.io/es/rest-api/endpoint-reference/projects/create-a-project) y [`PATCH /api/v1/projects/{projectId}`](https://docs.filmo.io/es/rest-api/endpoint-reference/projects/update-a-project), y [`GET /api/v1/projects`](https://docs.filmo.io/es/rest-api/endpoint-reference/projects/list-projects) lo devuelve. Los pedidos muestran [`externalReference`](https://docs.filmo.io/es/rest-api/objects/order#response-externalReference) solo en lecturas; la API no puede escribirlo.

## Cuando una persona desaparece del CRM

No elimines el contacto de Filmo por reflejo. [`DELETE /api/v1/contacts/{contactId}`](https://docs.filmo.io/es/rest-api/endpoint-reference/contacts/delete-a-contact) es permanente y responde 409 [`contact_in_use`](https://docs.filmo.io/es/rest-api/errors/contact_in_use) cuando pedidos, presupuestos u otros registros siguen usando el contacto. Marca el vínculo como cerrado en tu tabla y, si el equipo está de acuerdo, pon el `status` del contacto en `inactive` con [`PATCH /api/v1/contacts/{contactId}`](https://docs.filmo.io/es/rest-api/endpoint-reference/contacts/update-a-contact). Eliminar y archivar se comparan en [Archivar y eliminar](https://docs.filmo.io/es/further-reading/archiving-and-deleting).

## Qué guardar por tu parte

| Columna | Por qué |
| --- | --- |
| Id del CRM | La clave de la tabla. |
| ID del contacto de Filmo | Opaco; no asumas nunca un formato. Todas las llamadas posteriores lo usan. |
| Huella del último estado devuelto por Filmo | Supresión de eco: ignora un cambio que sea igual a él. |
| `updatedAt` de ese estado | Una primera comprobación barata antes de comparar huellas. |
| Marca de agua de ventana de la última pasada completa | Dónde empieza la siguiente pasada incremental (consulta la guía de sincronización). |
| Último error y su `requestId` | Lo que indicas a soporte cuando un registro sigue fallando. |

## Gestionar errores

Los errores son problemas RFC 9457; ramifica según `code`, nunca según el texto de `detail`. Las reglas de reintento están en [Errores](https://docs.filmo.io/es/rest-api/guides/errors). Para esta sincronización:

| Respuesta | Significado aquí | Qué hacer |
| --- | --- | --- |
| 400 `validation_error` | Se rechazó un campo del contacto. `errors[]` enumera cada `path` y `code` erróneo. | Registra el registro como rechazado con las rutas y sigue adelante; reintentar el mismo cuerpo vuelve a fallar. |
| 401 `api_key_owner_inactive`, `api_key_owner_not_admin` | La clave está bien; su propietario se fue o dejó de ser administrador. | Detén la sincronización, avisa y vuelve a emitir la clave desde una cuenta de administrador. |
| 402 `addon_required` | La cuenta no tiene el add-on de CRM. | Omite las oportunidades; sigue sincronizando los contactos. |
| 403 `insufficient_scope` | A la clave le falta el scope indicado en `requiredScope`. | Vuelve a emitir la clave con ese permiso (scope). |
| 403 `forbidden` | El propietario de la clave carece del permiso que exige la operación. | Corrige los permisos del propietario en la app; volver a emitir la clave no ayuda. |
| 404 `not_found` | El contacto vinculado ya no existe o está fuera del aislamiento de la clave. | Marca el vínculo como perdido y ejecuta de nuevo el paso de correspondencia. No lo vuelvas a crear a ciegas. |
| 409 `conflict` | Lee `errors[0].code`. `in_progress`: otra petición con la misma clave sigue en ejecución. `lock_timeout`: otra escritura retuvo el contacto durante más de cinco segundos. `invalid_state`: el estado del contacto prohíbe la operación. | Para `in_progress` y `lock_timeout`, espera y repite la misma petición con la misma clave. Para `invalid_state`, no reintentes: lee el contacto y cambia la petición. |
| 412 `precondition_failed` | El contacto cambió después de que lo leyeras. | Vuelve a leerlo, fusiona y escribe de nuevo. |
| 422 `idempotency_key_reused` | Se usó la misma clave en menos de 24 horas con un cuerpo distinto, así que lo más probable es que el contacto ya se haya creado. | Búscalo por email o referencia y actualízalo; no lo crees. |
| 429 `rate_limited` | La clave usó sus 120 peticiones por minuto. | Espera `Retry-After` segundos (el cuerpo del problema repite el valor en `retryAfter`); el cliente de arriba lo hace. |
| 500 `internal_error` | Un fallo por parte de Filmo. | Repite la misma petición con la misma `Idempotency-Key`, con esperas crecientes. Si la respuesta incluye `Idempotent-Replay: true`, Filmo está reproduciendo un error guardado: no sigas repitiendo. Busca el contacto por email o `contactNumber` antes de crear otro, y cita el `requestId` a soporte. |
| 503 `service_unavailable` | Ninguna operación de este tutorial lo documenta. Cuando la API lo responde, una función está desactivada para la cuenta, y Filmo controla ese interruptor. | No lo reintentes como un fallo transitorio. Omite el registro, deja que el resto de la sincronización siga en marcha y contacta con el soporte de Filmo con el `requestId`. |

## Automatizaciones

Las escrituras a través de la API activan los workflows de eventos de la cuenta, igual que el mismo cambio hecho en la app. Se ejecutan después de la respuesta, en la medida de lo posible, y no puedes suprimirlos por petición. Una carga inicial es una larga serie de escrituras, así que enumera primero los workflows de eventos activos (consulta [Pruebas](https://docs.filmo.io/es/rest-api/guides/testing)) y, en cada uno que no deba reaccionar, filtra el trigger por origen para excluir `api` y `mcp`.

## Siguientes pasos

- [Sincronizar datos](https://docs.filmo.io/es/rest-api/guides/syncing-data): el recorrido por ventanas, la parada anticipada y el barrido que confirma las eliminaciones.
- [Idempotencia](https://docs.filmo.io/es/rest-api/guides/idempotency) y [Límites de peticiones](https://docs.filmo.io/es/rest-api/guides/rate-limits): las reglas que hay detrás de las cabeceras que se usan aquí.
- [Registros e ids](https://docs.filmo.io/es/concepts/records-and-ids): por qué los identificadores son opacos.
- [Convertir un formulario web en una oportunidad](https://docs.filmo.io/es/rest-api/tutorials/web-form-to-opportunity): el mismo buscar o crear para nuevos leads.
- En la app: [Crear un contacto](https://www.filmo.io/es/help/reference/clientes-y-crm/contactos/crear-un-contacto) y [Embudos y fases](https://www.filmo.io/es/help/reference/clientes-y-crm/crm/embudos-y-etapas-del-crm).

---

URL canónica: https://docs.filmo.io/es/rest-api/tutorials/crm-contact-sync  
Última actualización: 2026-10-09