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.

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

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:readLa primera llamada: zona horaria, moneda, add-ons y los scopes de la clave.
contacts:readBuscar coincidencias por email, consultar referencias, leer los contactos que han cambiado.
contacts:writeCrear y actualizar contactos.
crm:readLectura 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).
  • 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.

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.

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

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

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 con 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 acepta de 1 a 200 referencias y devuelve notFound, las que hay que crear.

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.

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 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 referenciaQué significaQué 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.
noMatchNingún contacto coincide.Comprueba el email (paso anterior) antes de crear: puede que la persona ya exista con otro número.
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

Llama a POST /api/v1/contacts 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.

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

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

PATCH /api/v1/contacts/{contactId} 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}, 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. El fragmento escribe contactNumber solo cuando el contacto no tiene ninguno: un número que el equipo puso en la app nunca se sobrescribe.

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

Enlace a esta sección: 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 una ventana con updatedFrom y 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; no las reconstruyas aquí.

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 devuelve solo las oportunidades abiertas a menos que pases 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, 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.

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"

Si también replicas proyectos, usa su propia clave: externalReference es escribible en POST /api/v1/projects y PATCH /api/v1/projects/{projectId}, y GET /api/v1/projects lo devuelve. Los pedidos muestran externalReference solo en lecturas; la API no puede escribirlo.

No elimines el contacto de Filmo por reflejo. DELETE /api/v1/contacts/{contactId} es permanente y responde 409 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}. Eliminar y archivar se comparan en Archivar y eliminar.

ColumnaPor qué
Id del CRMLa clave de la tabla.
ID del contacto de FilmoOpaco; no asumas nunca un formato. Todas las llamadas posteriores lo usan.
Huella del último estado devuelto por FilmoSupresión de eco: ignora un cambio que sea igual a él.
updatedAt de ese estadoUna primera comprobación barata antes de comparar huellas.
Marca de agua de ventana de la última pasada completaDónde empieza la siguiente pasada incremental (consulta la guía de sincronización).
Último error y su requestIdLo que indicas a soporte cuando un registro sigue fallando.

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. Para esta sincronización:

RespuestaSignificado aquíQué hacer
400 validation_errorSe 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_adminLa 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_requiredLa cuenta no tiene el add-on de CRM.Omite las oportunidades; sigue sincronizando los contactos.
403 insufficient_scopeA la clave le falta el scope indicado en requiredScope.Vuelve a emitir la clave con ese permiso (scope).
403 forbiddenEl 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_foundEl 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 conflictLee 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_failedEl contacto cambió después de que lo leyeras.Vuelve a leerlo, fusiona y escribe de nuevo.
422 idempotency_key_reusedSe 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_limitedLa 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_errorUn 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_unavailableNinguna 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.

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) y, en cada uno que no deba reaccionar, filtra el trigger por origen para excluir api y mcp.

  • Guías

    Sincronizar datos

    Filmo v1 no envía webhooks, así que mantienes otro sistema sincronizado consultando periódicamente: lee lo que cambió en ventanas de tiempo semiabiertas y ejecuta un barrido aparte para encontrar lo que se eliminó. No sigas el cursor de paginación para decidir qué cambió o qué desapareció.

  • Tutoriales

    Convertir un formulario web en una oportunidad

    Tu servidor recibe el formulario, busca o crea el contacto por correo electrónico y abre una oportunidad en el embudo, con una clave derivada del envío para que ningún reintento cree un segundo lead.

  • Guías

    Idempotencia y reintentos seguros

    Envía una cabecera Idempotency-Key con cada escritura y reutiliza el mismo valor cuando reintentes esa operación: Filmo devuelve el resultado almacenado del primer intento en lugar de escribir dos veces.

  • Guías

    Límites de peticiones

    Cada clave de API tiene 120 peticiones por minuto, y las tres comprobaciones de disponibilidad comparten su propia cuota de 30 por minuto. Al pasar un límite, la API responde 429 con Retry-After.

  • Conceptos básicos

    Registros e ids

    Cada registro tiene un id opaco que asigna Filmo. Guarda tus propios ids en una tabla de correspondencias de tu lado, y usa un campo de referencia solo donde la API lo tenga.