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
Enlace a esta sección: 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
Enlace a esta sección: Antes de empezarCrea 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).
- 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, comocrm-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
Enlace a esta sección: 1. Configurar un clienteLos 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 };
}
}# filmo_client.py: one place for the base URL, the key and the 429 backoff.
import os
import random
import time
import requests
BASE = os.environ.get("FILMO_BASE_URL", "https://app.filmo.io")
def filmo(method, path, params=None, body=None, idempotency_key=None, if_match=None, retries=5):
headers = {"Authorization": f"Bearer {os.environ['FILMO_API_KEY']}"}
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key
if if_match:
headers["If-Match"] = if_match
for attempt in range(retries + 1):
res = requests.request(
method, BASE + path, params=params, json=body, headers=headers, timeout=30
)
if res.status_code == 429 and attempt < retries:
# Wait what the server says, plus a little jitter, then repeat the same request.
time.sleep(float(res.headers.get("Retry-After", 1)) + random.random())
continue
break
is_json = "json" in res.headers.get("Content-Type", "")
return res.status_code, (res.json() if is_json else None), res.headers2. Comprobar la clave y la cuenta
Enlace a esta sección: 2. Comprobar la clave y la cuentaGET /api 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"import { filmo } from "./filmo-client.mjs";
const res = await filmo("GET", "/api/v1/account");
if (!res.ok) throw new Error("getAccount " + res.status + " " + res.body?.code);
const { timezone, currency, addons, trial, apiKey } = res.body.data;
console.log({ timezone, currency, addons, trial: trial.active, scopes: apiKey.scopes });from filmo_client import filmo
status, body, _ = filmo("GET", "/api/v1/account")
if status != 200:
raise RuntimeError(f"getAccount {status} {(body or {}).get('code')}")
account = body["data"]
print(account["timezone"], account["currency"], account["addons"],
account["trial"]["active"], account["apiKey"]["scopes"])3. Asociar una persona a un contacto
Enlace a esta sección: 3. Asociar una persona a un contactoDecide en este orden y para en la primera respuesta:
Tu tabla.
Si el id del CRM ya está vinculado a un id de Filmo, úsalo. No hace falta nada más.
El email.
Llama a
GET /apicon/v1 /contacts emaily 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.La referencia.
Solo para decidir qué crear en bloque:
POST /apiacepta de 1 a 200 referencias y devuelve/v1 /contacts /lookup notFound, las que hay que crear.
Por email
Enlace a esta sección: Por emailEl 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[]. 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"import { filmo } from "./filmo-client.mjs";
const sameEmail = (contact, email) =>
contact.emails.some((e) => e.email.toLowerCase() === email.toLowerCase());
// The filter's matching rules are not specified, so compare the addresses yourself.
export async function findByEmail(email) {
const res = await filmo("GET", "/api/v1/contacts", { query: { email, limit: 500 } });
if (!res.ok) throw new Error("listContacts " + res.status + " " + res.body?.requestId);
return res.body.data.filter((contact) => sameEmail(contact, email));
}from filmo_client import filmo
def same_email(contact, email):
return any(e["email"].lower() == email.lower() for e in contact["emails"])
# The filter's matching rules are not specified, so compare the addresses yourself.
def find_by_email(email):
status, body, _ = filmo("GET", "/api/v1/contacts", params={"email": email, "limit": 500})
if status != 200:
raise RuntimeError(f"listContacts {status} {(body or {}).get('requestId')}")
return [c for c in body["data"] if same_email(c, email)]Por referencia, en masa
Enlace a esta sección: Por referencia, en masaPOST /api 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. |
BASE="${FILMO_BASE_URL:-https://app.filmo.io}"
curl -sS -X POST "$BASE/api/v1/contacts/lookup" \
-H "Authorization: Bearer $FILMO_API_KEY" \
-H "Content-Type: application/json" \
--data @- <<'JSON'
{
"references": ["crm-c-1042", "crm-c-1043", "crm-c-1044"]
}
JSONimport { filmo } from "./filmo-client.mjs";
// crmIds: 1 to 200 ids of your CRM. The contact number we write is "crm-" + id.
export async function lookup(crmIds) {
const references = crmIds.map((id) => "crm-" + id);
const res = await filmo("POST", "/api/v1/contacts/lookup", { body: { references } });
if (!res.ok) throw new Error("lookupContacts " + res.status + " " + res.body?.requestId);
const { contacts, notFound } = res.body.data;
// The answer does not pair references with contacts: pair them by contactNumber yourself.
// A contact number is not unique, so several contacts can come back for one reference.
return references.map((reference) => {
const matches = contacts.filter((c) => c.contactNumber === reference);
return {
reference,
filmoId: matches.length === 1 ? matches[0].id : null,
conflict: matches.length > 1 ? matches.map((c) => c.id) : null, // for a human to resolve
noMatch: notFound.includes(reference),
};
});
}from filmo_client import filmo
# crm_ids: 1 to 200 ids of your CRM. The contact number we write is "crm-" + id.
def lookup(crm_ids):
references = ["crm-" + i for i in crm_ids]
status, body, _ = filmo("POST", "/api/v1/contacts/lookup", body={"references": references})
if status != 200:
raise RuntimeError(f"lookupContacts {status} {(body or {}).get('requestId')}")
data = body["data"]
# The answer does not pair references with contacts: pair them by contactNumber yourself.
# A contact number is not unique, so several contacts can come back for one reference.
results = []
for ref in references:
matches = [c["id"] for c in data["contacts"] if c["contactNumber"] == ref]
results.append({
"reference": ref,
"filmo_id": matches[0] if len(matches) == 1 else None,
"conflict": matches if len(matches) > 1 else None, # for a human to resolve
"no_match": ref in data["notFound"],
})
return results4. Crear el contacto una sola vez
Enlace a esta sección: 4. Crear el contacto una sola vezLlama a POST /api 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. y el de Python como create.; 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"
}
JSONimport { createHash } from "node:crypto";
import { filmo } from "./filmo-client.mjs";
// A stand-in for your database: CRM id -> { filmoId, hash, updatedAt }.
export const links = new Map();
const primary = (list, key) => (list.find((x) => x.isPrimary) ?? list[0])?.[key] ?? "";
// The fingerprint of a Filmo contact, from what the API returned (never from what you sent).
export const hashOf = (dto) =>
createHash("sha256")
.update(JSON.stringify([
dto.name,
dto.contactType,
primary(dto.emails, "email").toLowerCase(),
primary(dto.phoneNumbers, "number"),
dto.contactNumber ?? "",
]))
.digest("hex");
export const link = (crmId, dto) =>
links.set(crmId, { filmoId: dto.id, hash: hashOf(dto), updatedAt: dto.updatedAt });
export async function createInFilmo(person) {
const res = await filmo("POST", "/api/v1/contacts", {
idempotencyKey: "crm-contact-create-" + person.id,
body: {
name: person.name,
contactType: person.type, // "person" or "company"
role: "client",
email: person.email,
phone: person.phone,
contactNumber: "crm-" + person.id,
},
});
if (res.ok) link(person.id, res.body.data);
return res; // 400 and 422 are for the caller to route, see "Handle errors"
}import hashlib
import json
from filmo_client import filmo
# A stand-in for your database: CRM id -> {"filmo_id", "hash", "updated_at"}.
links = {}
def primary(items, key):
chosen = next((x for x in items if x["isPrimary"]), items[0] if items else None)
return chosen[key] if chosen else ""
# The fingerprint of a Filmo contact, from what the API returned (never from what you sent).
def hash_of(dto):
fields = [
dto["name"],
dto["contactType"],
primary(dto["emails"], "email").lower(),
primary(dto["phoneNumbers"], "number"),
dto["contactNumber"] or "",
]
return hashlib.sha256(json.dumps(fields).encode()).hexdigest()
def link(crm_id, dto):
links[crm_id] = {"filmo_id": dto["id"], "hash": hash_of(dto), "updated_at": dto["updatedAt"]}
def create_in_filmo(person):
body = {
"name": person["name"],
"contactType": person["type"], # "person" or "company"
"role": "client",
"email": person["email"],
"phone": person.get("phone"),
"contactNumber": "crm-" + person["id"],
}
body = {k: v for k, v in body.items() if v is not None}
status, payload, _ = filmo(
"POST", "/api/v1/contacts", body=body,
idempotency_key="crm-contact-create-" + person["id"],
)
if status == 201:
link(person["id"], payload["data"])
return status, payload # 400 and 422 are for the caller to route, see "Handle errors"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).
5. Actualizarlo después
Enlace a esta sección: 5. Actualizarlo despuésPATCH /api 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, 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).import { createHash } from "node:crypto";
import { filmo } from "./filmo-client.mjs";
import { link } from "./create.mjs";
// person.version: the version or updated-at of the CRM record. The key also carries a hash of
// the body: a retry after a timeout whose body changed in the meantime gets a new key, and the same key with another body is a 422.
export async function updateInFilmo(person, filmoId) {
for (let attempt = 0; attempt < 3; attempt++) {
const current = await filmo("GET", "/api/v1/contacts/" + filmoId);
if (current.status === 404) return current; // deleted or outside your tenancy (an archived contact answers 200)
const dto = current.body.data;
const patch = { name: person.name };
// Never overwrite a contact number: the team may have set its own. Write ours only when empty.
if (!dto.contactNumber) patch.contactNumber = "crm-" + person.id;
if (!dto.emails.some((e) => e.email.toLowerCase() === person.email.toLowerCase())) {
// emails REPLACES the whole list: keep the addresses people added in Filmo.
patch.emails = [
{ email: person.email, isPrimary: true },
...dto.emails.map((e) => ({ email: e.email, label: e.label ?? undefined, isPrimary: false })),
];
}
const res = await filmo("PATCH", "/api/v1/contacts/" + filmoId, {
body: patch,
ifMatch: current.headers.get("ETag"),
idempotencyKey:
"crm-contact-update-" + person.id + "-v" + person.version + "-" +
createHash("sha256").update(JSON.stringify(patch)).digest("hex").slice(0, 12),
});
if (res.status === 412) continue; // it changed after you read it: read again
if (res.ok) link(person.id, res.body.data);
return res;
}
throw new Error("contact " + filmoId + " keeps changing; try again on the next pass");
}import hashlib
import json
from create import link
from filmo_client import filmo
# person["version"]: the version or updated-at of the CRM record. The key also carries a hash of
# the body: a retry after a timeout whose body changed in the meantime gets a new key, and the same key with another body is a 422.
def update_in_filmo(person, filmo_id):
for _ in range(3):
status, payload, headers = filmo("GET", "/api/v1/contacts/" + filmo_id)
if status == 404:
return status, payload # deleted or outside your tenancy (an archived contact answers 200)
dto = payload["data"]
patch = {"name": person["name"]}
# Never overwrite a contact number: the team may have set its own. Write ours only when empty.
if not dto["contactNumber"]:
patch["contactNumber"] = "crm-" + person["id"]
if not any(e["email"].lower() == person["email"].lower() for e in dto["emails"]):
# emails REPLACES the whole list: keep the addresses people added in Filmo.
patch["emails"] = [{"email": person["email"], "isPrimary": True}] + [
{k: v for k, v in
{"email": e["email"], "label": e["label"], "isPrimary": False}.items()
if v is not None}
for e in dto["emails"]
]
status, payload, _ = filmo(
"PATCH", "/api/v1/contacts/" + filmo_id, body=patch,
if_match=headers["ETag"],
idempotency_key="crm-contact-update-" + person["id"] + "-v" + str(person["version"]) + "-"
+ hashlib.sha256(json.dumps(patch, sort_keys=True).encode()).hexdigest()[:12],
)
if status == 412:
continue # it changed after you read it: read again
if status == 200:
link(person["id"], payload["data"])
return status, payload
raise RuntimeError(f"contact {filmo_id} keeps changing; try again on the next pass")6. 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 ecoLa 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 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"import { filmo } from "./filmo-client.mjs";
import { hashOf, links } from "./create.mjs";
// Contacts changed in Filmo inside [from, before), minus the echoes of your own writes.
export async function changedContacts(from, before) {
const res = await filmo("GET", "/api/v1/contacts", {
query: { updatedFrom: from, updatedBefore: before, sort: "updatedAt", limit: 500 },
});
if (!res.ok) throw new Error("listContacts " + res.status + " " + res.body?.requestId);
if (res.body.pagination.hasMore) throw new Error("window too large: split it, do not follow the cursor");
const crmIdOf = new Map([...links].map(([crmId, l]) => [l.filmoId, crmId]));
const changes = [];
for (const dto of res.body.data) {
const crmId = crmIdOf.get(dto.id) ?? null;
if (crmId && hashOf(dto) === links.get(crmId).hash) continue; // your own write coming back
changes.push({ crmId, dto });
// After you apply a change to the CRM, store its fingerprint so the CRM does not send it back:
// links.get(crmId).hash = hashOf(dto);
}
return changes;
}from create import hash_of, links
from filmo_client import filmo
# Contacts changed in Filmo inside [from, before), minus the echoes of your own writes.
def changed_contacts(start, before):
status, body, _ = filmo("GET", "/api/v1/contacts", params={
"updatedFrom": start, "updatedBefore": before, "sort": "updatedAt", "limit": 500,
})
if status != 200:
raise RuntimeError(f"listContacts {status} {(body or {}).get('requestId')}")
if body["pagination"]["hasMore"]:
raise RuntimeError("window too large: split it, do not follow the cursor")
crm_id_of = {link["filmo_id"]: crm_id for crm_id, link in links.items()}
changes = []
for dto in body["data"]:
crm_id = crm_id_of.get(dto["id"])
if crm_id and hash_of(dto) == links[crm_id]["hash"]:
continue # your own write coming back
changes.append({"crm_id": crm_id, "dto": dto})
# After you apply a change to the CRM, store its fingerprint so the CRM does not send it back:
# links[crm_id]["hash"] = hash_of(dto)
return changes7. Leer las oportunidades
Enlace a esta sección: 7. Leer las oportunidadesGET /api 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"import { filmo } from "./filmo-client.mjs";
// Opportunities updated since a watermark: newest first, stop when you pass it, dedupe by id.
export async function changedOpportunities(since) {
const found = new Map();
let cursor;
for (;;) {
const res = await filmo("GET", "/api/v1/crm/opportunities", {
query: { status: "all", sort: "-updatedAt", limit: 500, cursor },
});
if (res.status === 402) return []; // no CRM add-on on this account
if (!res.ok) throw new Error("listOpportunities " + res.status + " " + res.body?.requestId);
const page = res.body.data;
for (const o of page) {
if (Date.parse(o.updatedAt) >= Date.parse(since)) found.set(o.opportunityId, o);
}
const last = page.at(-1);
if (!res.body.pagination.hasMore || !last || Date.parse(last.updatedAt) < Date.parse(since)) {
return [...found.values()];
}
cursor = res.body.pagination.nextCursor;
}
}from datetime import datetime
from filmo_client import filmo
def parse(instant):
return datetime.fromisoformat(instant.replace("Z", "+00:00"))
# Opportunities updated since a watermark: newest first, stop when you pass it, dedupe by id.
def changed_opportunities(since):
found, cursor = {}, None
while True:
status, body, _ = filmo("GET", "/api/v1/crm/opportunities", params={
"status": "all", "sort": "-updatedAt", "limit": 500, "cursor": cursor,
})
if status == 402:
return [] # no CRM add-on on this account
if status != 200:
raise RuntimeError(f"listOpportunities {status} {(body or {}).get('requestId')}")
page = body["data"]
for o in page:
if parse(o["updatedAt"]) >= parse(since):
found[o["opportunityId"]] = o
if not body["pagination"]["hasMore"] or not page or parse(page[-1]["updatedAt"]) < parse(since):
return list(found.values())
cursor = body["pagination"]["nextCursor"]Proyectos y pedidos
Enlace a esta sección: Proyectos y pedidosSi también replicas proyectos, usa su propia clave: externalReference es escribible en POST /api y PATCH /api, y GET /api lo devuelve. Los pedidos muestran externalReference solo en lecturas; la API no puede escribirlo.
Cuando una persona desaparece del CRM
Enlace a esta sección: Cuando una persona desaparece del CRMNo elimines el contacto de Filmo por reflejo. DELETE /api 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. Eliminar y archivar se comparan en Archivar y eliminar.
Qué guardar por tu parte
Enlace a esta sección: 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
Enlace a esta sección: Gestionar erroresLos 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:
| 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].. 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
Enlace a esta sección: AutomatizacionesLas 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.
Siguientes pasos
Enlace a esta sección: Siguientes pasos- Sincronizar datos: el recorrido por ventanas, la parada anticipada y el barrido que confirma las eliminaciones.
- Idempotencia y Límites de peticiones: las reglas que hay detrás de las cabeceras que se usan aquí.
- Registros e ids: por qué los identificadores son opacos.
- Convertir un formulario web en una oportunidad: el mismo buscar o crear para nuevos leads.
- En la app: Crear un contacto y Embudos y fases.
Páginas relacionadas
Enlace a esta sección: Páginas relacionadasGuí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-Keycon 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.
¿Te ha resultado útil esta página?