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.
Lo que construyes
Enlace a esta sección: Lo que construyes- Un manejador en tu servidor que recibe el envío de un formulario y produce un contacto (encontrado o creado) y una oportunidad.
- Reintentos seguros en cada paso, porque cada escritura lleva una
Idempotency-Keyderivada del id del envío. - Errores de validación que vuelven al visitante en el campo correcto del formulario, y fallos que nunca pierden el lead.
Solo en el servidor
La clave de API actúa como el administrador que la creó y lleva todos los permisos (scopes) que le diste. Todo lo que hay en el JavaScript de la página es público para cualquier visitante, así que el formulario envía los datos a tu servidor y solo tu servidor llama a Filmo.
Antes de empezar
Enlace a esta sección: Antes de empezar| Permiso (scope) | Se usa para |
|---|---|
account:read | La primera llamada: zona horaria, add-ons y los scopes de la clave. |
contacts:read | Buscar el contacto por email. |
contacts:write | Crear el contacto. |
crm:read | Listar embudos y etapas. Solo hace falta si envías oportunidades a un embudo o a una etapa con nombre (paso opcional). |
crm:write | Crear la oportunidad. |
- Las oportunidades y los embudos necesitan el add-on de CRM. Sin él, las operaciones de CRM responden 402
addon_required. - Genera un id por cada formulario renderizado, como un UUID en un campo oculto o en la sesión del servidor. Es el id de envío, y las claves de abajo derivan de él.
- Configura el cliente con el paso 1 del primer tutorial (los mismos dos ficheros).
1. Comprobar la clave
Enlace a esta sección: 1. Comprobar la claveLlama a GET /api una vez al desplegar o en una comprobación de estado, no por cada envío. Confirma que addons incluye lo que necesita el CRM y que apiKey. tiene los scopes anteriores.
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"])2. Validar tú mismo el envío primero
Enlace a esta sección: 2. Validar tú mismo el envío primeroComprueba los campos obligatorios, el formato del email y las longitudes en tu servidor antes de llamar a Filmo, y filtra también ahí el spam. Cada llamada que evitas cuenta: la clave tiene 120 peticiones por minuto para todo lo que hace. Los límites del cuerpo que necesitas son name hasta 200 caracteres, email hasta 200 y phone hasta 50 para el contacto, y 200 caracteres para el name de la oportunidad y para cada uno de sourceDetail, utmSource, utmMedium y utmCampaign.
3. Buscar el contacto por email
Enlace a esta sección: 3. Buscar el contacto por emailLlama a GET /api con email. La especificación no dice si el filtro coincide de forma exacta o parcial, así que compara tú mismo las direcciones de emails[]. y quédate solo con las coincidencias exactas. Si coinciden varios contactos, elige uno de forma determinista (el código prefiere uno activo y, después, el más antiguo) y deja que el equipo limpie los duplicados; el visitante no debería verlo.
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";
// Exact match on any address of the contact; the oldest active one wins, deterministically.
export async function findContact(email) {
const res = await filmo("GET", "/api/v1/contacts", { query: { email, limit: 500 } });
if (!res.ok) return { problem: res };
const hits = res.body.data
.filter((c) => c.emails.some((e) => e.email.toLowerCase() === email.toLowerCase()))
.sort((a, b) => (a.status === "active" ? 0 : 1) - (b.status === "active" ? 0 : 1)
|| Date.parse(a.createdAt) - Date.parse(b.createdAt));
return { contact: hits[0] ?? null };
}from datetime import datetime
from filmo_client import filmo
# Exact match on any address of the contact; the oldest active one wins, deterministically.
def find_contact(email):
status, body, _ = filmo("GET", "/api/v1/contacts", params={"email": email, "limit": 500})
if status != 200:
return {"problem": (status, body)}
hits = [c for c in body["data"] if any(e["email"].lower() == email.lower() for e in c["emails"])]
hits.sort(key=lambda c: (c["status"] != "active", datetime.fromisoformat(c["createdAt"].replace("Z", "+00:00"))))
return {"contact": hits[0] if hits else None}Dos envíos simultáneos
La especificación no dice que un email sea único entre contactos, y la Idempotency-Key solo deduplica repeticiones del mismo envío. Dos envíos distintos de la misma persona en el mismo momento pueden no encontrar nada y crear ambos un contacto. Serializa el buscar-o-crear por email de tu lado (un bloqueo o una cola con clave en la dirección en minúsculas) si te importa.
4. Crear el contacto si no existe
Enlace a esta sección: 4. Crear el contacto si no existePOST /api requiere solo name. Un formulario suele tener un rol de lead; los roles son client, provider, lead, debtor, creditor, student, teacher y unspecified. La clave es web-form-<submission id>-contact: si se pierde la respuesta y repites la llamada, recibes la primera respuesta con Idempotent-Replay: true y no existe un segundo contacto.
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: web-form-7c1e4a52-contact" \
--data @- <<'JSON'
{
"name": "Ana Example",
"contactType": "person",
"role": "lead",
"email": "ana@example.com",
"phone": "+34 600 000 000"
}
JSONimport { filmo } from "./filmo-client.mjs";
// submission.id: the id of THIS form submission, generated when the form was rendered.
export const createContact = (submission) =>
filmo("POST", "/api/v1/contacts", {
idempotencyKey: "web-form-" + submission.id + "-contact",
body: {
name: submission.name,
contactType: "person",
role: "lead",
email: submission.email,
phone: submission.phone,
},
});from filmo_client import filmo
# submission["id"]: the id of THIS form submission, generated when the form was rendered.
def create_contact(submission):
body = {
"name": submission["name"],
"contactType": "person",
"role": "lead",
"email": submission["email"],
"phone": submission.get("phone"),
}
body = {k: v for k, v in body.items() if v}
return filmo(
"POST", "/api/v1/contacts", body=body,
idempotency_key="web-form-" + submission["id"] + "-contact",
)Guarda los pasos 3 y 4 juntos como contact. (Python: contact.), el paso 5 como pipelines. (pipelines.) y el paso 6 como opportunity. (opportunity.); el handler del paso 7 los importa. Si el formulario también pide una empresa, crea un segundo contacto con contactType company del mismo modo y pasa su id como companyId en la oportunidad. El tutorial se limita a un contacto.
5. Elegir el embudo y la etapa (opcional)
Enlace a esta sección: 5. Elegir el embudo y la etapa (opcional)Si omites pipelineId, Filmo usa el embudo por defecto de la cuenta; si omites stageId, la primera etapa de ese embudo. Haz este paso solo cuando los leads web deban ir a un embudo o a una etapa concretos. GET /api devuelve todos los embudos activos con sus etapas ordenadas en una sola respuesta (no pagina; los archivados necesitan includeArchived). Resuelve los nombres a ids una vez y guárdalos en caché durante una hora; el manejador del paso 7 los actualiza tras un 400.
BASE="${FILMO_BASE_URL:-https://app.filmo.io}"
curl -sS "$BASE/api/v1/crm/pipelines" \
-H "Authorization: Bearer $FILMO_API_KEY"import { filmo } from "./filmo-client.mjs";
let cached = null; // { at, pipelineId, stageId }
// Optional: with no pipeline or stage name set, send neither id (Filmo uses the default pipeline and
// its first stage) and never call listPipelines, so the key does not need crm:read.
export async function target({ refresh = false } = {}) {
if (!process.env.FILMO_PIPELINE_NAME && !process.env.FILMO_STAGE_NAME) return {};
if (cached && !refresh && Date.now() - cached.at < 3_600_000) return cached;
const res = await filmo("GET", "/api/v1/crm/pipelines");
if (!res.ok) throw new Error("listPipelines " + res.status + " " + res.body?.requestId);
const pipelines = res.body.data;
const pipeline =
pipelines.find((p) => p.name === process.env.FILMO_PIPELINE_NAME) ??
pipelines.find((p) => p.isDefault);
const stage = pipeline?.stages.find((s) => s.name === process.env.FILMO_STAGE_NAME && s.type === "open");
cached = { at: Date.now(), pipelineId: pipeline?.pipelineId, stageId: stage?.stageId };
return cached;
}import os
import time
from filmo_client import filmo
_cached = None # {"at", "pipeline_id", "stage_id"}
# Optional: with no pipeline or stage name set, send neither id (Filmo uses the default pipeline and
# its first stage) and never call listPipelines, so the key does not need crm:read.
def target(refresh=False):
global _cached
if not os.environ.get("FILMO_PIPELINE_NAME") and not os.environ.get("FILMO_STAGE_NAME"):
return {"pipeline_id": None, "stage_id": None}
if _cached and not refresh and time.time() - _cached["at"] < 3600:
return _cached
status, body, _ = filmo("GET", "/api/v1/crm/pipelines")
if status != 200:
raise RuntimeError(f"listPipelines {status} {(body or {}).get('requestId')}")
pipelines = body["data"]
pipeline = next((p for p in pipelines if p["name"] == os.environ.get("FILMO_PIPELINE_NAME")), None) or next((p for p in pipelines if p["isDefault"]), None)
stage = next((s for s in (pipeline or {}).get("stages", [])
if s["name"] == os.environ.get("FILMO_STAGE_NAME") and s["type"] == "open"), None)
_cached = {
"at": time.time(),
"pipeline_id": pipeline["pipelineId"] if pipeline else None,
"stage_id": stage["stageId"] if stage else None,
}
return _cached6. Crear la oportunidad
Enlace a esta sección: 6. Crear la oportunidadPOST /api requiere solo name. Añade el contactId, pon el mensaje del visitante en description y registra de dónde vino el lead: source es un enum, y website_form es el valor para este caso (source lista los demás). Los campos utm* conservan la atribución de la campaña. La clave es web-form-<submission id>-opportunity.
BASE="${FILMO_BASE_URL:-https://app.filmo.io}"
curl -sS -X POST "$BASE/api/v1/crm/opportunities" \
-H "Authorization: Bearer $FILMO_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: web-form-7c1e4a52-opportunity" \
--data @- <<JSON
{
"name": "Website enquiry from Ana Example",
"description": "We need two cameras and a light kit for 2 to 4 November.",
"contactId": "$CONTACT_ID",
"source": "website_form",
"sourceDetail": "/rent/cameras",
"utmSource": "newsletter",
"utmMedium": "email",
"utmCampaign": "autumn-shoots"
}
JSONimport { filmo } from "./filmo-client.mjs";
import { target } from "./pipelines.mjs";
const clamp = (text, max) => (text == null ? undefined : String(text).slice(0, max));
// attempt 2 is the repeat with refreshed pipeline ids: another body needs another key.
export async function createOpportunity(submission, contactId, { attempt = 1 } = {}) {
const { pipelineId, stageId } = await target();
return filmo("POST", "/api/v1/crm/opportunities", {
idempotencyKey: "web-form-" + submission.id + "-opportunity" + (attempt > 1 ? "-" + attempt : ""),
body: {
name: clamp("Website enquiry from " + submission.name, 200),
description: submission.message,
contactId,
pipelineId,
stageId,
source: "website_form",
sourceDetail: clamp(submission.page, 200),
utmSource: clamp(submission.utm?.source, 200),
utmMedium: clamp(submission.utm?.medium, 200),
utmCampaign: clamp(submission.utm?.campaign, 200),
},
});
}from filmo_client import filmo
from pipelines import target
def clamp(text, limit):
return None if text is None else str(text)[:limit]
# attempt 2 is the repeat with refreshed pipeline ids: another body needs another key.
def create_opportunity(submission, contact_id, attempt=1):
t = target()
utm = submission.get("utm") or {}
body = {
"name": clamp("Website enquiry from " + submission["name"], 200),
"description": submission.get("message"),
"contactId": contact_id,
"pipelineId": t["pipeline_id"],
"stageId": t["stage_id"],
"source": "website_form",
"sourceDetail": clamp(submission.get("page"), 200),
"utmSource": clamp(utm.get("source"), 200),
"utmMedium": clamp(utm.get("medium"), 200),
"utmCampaign": clamp(utm.get("campaign"), 200),
}
body = {k: v for k, v in body.items() if v is not None}
return filmo(
"POST", "/api/v1/crm/opportunities", body=body,
idempotency_key="web-form-" + submission["id"] + "-opportunity" + (f"-{attempt}" if attempt > 1 else ""),
)Como ambas escrituras usan claves derivadas del mismo envío, todo el handler se puede ejecutar de nuevo desde el principio: el paso de búsqueda ya ve el contacto (o la creación se repite) y la oportunidad se repite. Las claves duran 24 horas; si reintentas después, comprueba tu propio registro de lo creado antes de volver a llamar.
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. Enumera primero los workflows de eventos activos (consulta Pruebas), avisa al administrador de que los leads web llegan por la API y filtra por origen el trigger de cualquier workflow que no deba reaccionar, para excluir api y mcp.
7. Asignar los errores al formulario y no perder nunca el lead
Enlace a esta sección: 7. Asignar los errores al formulario y no perder nunca el leadUn 400 validation_error enumera cada campo erróneo en errors[], con un path (como email) y un code estable. Asigna los paths que conozcas a los campos de tu formulario y muestra tu propio texto para cada code. El message está escrito para personas en el idioma de la petición o de la cuenta: no lo uses para la lógica.
{
"type": "https://docs.filmo.io/errors/validation_error",
"status": 400,
"code": "validation_error",
"errors": [
{ "path": "email", "code": "invalid_format", "message": "..." },
{ "path": "phone", "code": "too_big", "message": "..." }
],
"requestId": "req_..."
}Dos tipos de error necesitan un tratamiento distinto. Una ruta que corresponde a algo que ha escrito el visitante (email, phone) vuelve al formulario. Cualquier otra, incluida una ruta que fijó tu código (contactId, source), una ruta vacía para todo el cuerpo o una entrada sin path, es un fallo tuyo, no del visitante: pon el envío en cola, avísate con el requestId y di al visitante que has recibido la petición. El 400 de una escritura en el CRM no siempre indica qué campo falló, así que no puedes deducir de él un pipelineId o un stageId obsoleto. El handler vuelve a leer los embudos tras un 400 y repite la escritura una sola vez solo si los ids que enviaría han cambiado.
import { createContact, findContact } from "./contact.mjs";
import { createOpportunity } from "./opportunity.mjs";
import { target } from "./pipelines.mjs";
// API path -> form field, per call. A path that is not here is a bug on your side, not the visitor's.
const CONTACT_FIELDS = { name: "name", email: "email", phone: "phone" };
const OPPORTUNITY_FIELDS = { description: "message" };
function visitorErrors(problem, fieldMap) {
const fields = {};
let ours = false;
for (const e of problem.errors ?? []) {
if (fieldMap[e.path]) fields[fieldMap[e.path]] = e.code; // show your own text for the code
else ours = true; // includes the empty path (the whole body)
}
return { fields, ours };
}
// Never drop a lead: anything that is not the visitor's mistake goes to your outbox.
const queued = (submission, why) => {
// outbox.save(submission, why) with your own storage, then retry with the SAME submission id
return { status: 202, body: { received: true } };
};
export async function handleSubmission(submission) {
try {
return await processSubmission(submission);
} catch (error) {
// A network cut or a timeout throws before any answer arrives: the lead still goes to the outbox.
return queued(submission, error);
}
}
async function processSubmission(submission) {
let found = await findContact(submission.email);
if (found.problem) return queued(submission, found.problem);
let contact = found.contact;
if (!contact) {
const res = await createContact(submission);
if (res.status === 400) {
const { fields, ours } = visitorErrors(res.body, CONTACT_FIELDS);
return ours || !Object.keys(fields).length ? queued(submission, res.body) : { status: 400, body: { fields } };
}
if (!res.ok) return queued(submission, res.body);
contact = res.body.data;
}
let res = await createOpportunity(submission, contact.id);
if (res.status === 400) {
// The 400 of a CRM write may carry no `path`, so it cannot tell you that a cached id went stale.
// Read the pipelines again and repeat the write only if the ids you would send are different now.
const before = await target();
const after = await target({ refresh: true });
if (after.pipelineId !== before.pipelineId || after.stageId !== before.stageId) {
res = await createOpportunity(submission, contact.id, { attempt: 2 });
}
}
if (res.status === 400) {
const { fields, ours } = visitorErrors(res.body, OPPORTUNITY_FIELDS);
return ours || !Object.keys(fields).length ? queued(submission, res.body) : { status: 400, body: { fields } };
}
if (!res.ok) return queued(submission, res.body);
return { status: 201, body: { received: true } };
}from contact import create_contact, find_contact
from opportunity import create_opportunity
from pipelines import target
# API path -> form field, per call. A path that is not here is a bug on your side, not the visitor's.
CONTACT_FIELDS = {"name": "name", "email": "email", "phone": "phone"}
OPPORTUNITY_FIELDS = {"description": "message"}
def visitor_errors(problem, field_map):
fields, ours = {}, False
for e in problem.get("errors") or []:
if e["path"] in field_map:
fields[field_map[e["path"]]] = e["code"] # show your own text for the code
else:
ours = True # includes the empty path (the whole body)
return fields, ours
# Never drop a lead: anything that is not the visitor's mistake goes to your outbox.
def queued(submission, why):
# outbox.save(submission, why) with your own storage, then retry with the SAME submission id
return 202, {"received": True}
def handle_submission(submission):
try:
return _process_submission(submission)
except Exception as error: # a network cut or a timeout raises before any answer arrives
return queued(submission, error)
def _process_submission(submission):
found = find_contact(submission["email"])
if "problem" in found:
return queued(submission, found["problem"])
contact = found["contact"]
if contact is None:
status, body, _ = create_contact(submission)
if status == 400:
fields, ours = visitor_errors(body, CONTACT_FIELDS)
return queued(submission, body) if ours or not fields else (400, {"fields": fields})
if status != 201:
return queued(submission, body)
contact = body["data"]
status, body, _ = create_opportunity(submission, contact["id"])
if status == 400:
# The 400 of a CRM write may carry no path, so it cannot tell you that a cached id went stale.
# Read the pipelines again and repeat the write only if the ids you would send are different now.
before = target()
after = target(refresh=True)
if (after["pipeline_id"], after["stage_id"]) != (before["pipeline_id"], before["stage_id"]):
status, body, _ = create_opportunity(submission, contact["id"], attempt=2)
if status == 400:
fields, ours = visitor_errors(body, OPPORTUNITY_FIELDS)
return queued(submission, body) if ours or not fields else (400, {"fields": fields})
if status != 201:
return queued(submission, body)
return 201, {"received": True}Gestionar las demás respuestas
Enlace a esta sección: Gestionar las demás respuestas| Respuesta | Qué hacer |
|---|---|
429 rate_limited | Espera Retry-After segundos (también en retryAfter del cuerpo del problema) y repite la misma petición; el cliente lo hace con jitter hasta cinco veces. Si sigue fallando, pon el envío en cola y responde al visitante que lo has recibido. No mantengas abierta la petición del visitante durante minutos. |
401 api_key_owner_inactive, api_key_owner_not_admin | El propietario de la clave se fue o dejó de ser administrador. Pon en cola, avisa y reemite la clave. |
402 addon_required | La cuenta no tiene el add-on de CRM. Pon en cola y avisa; requiredAddon lo nombra. |
403 insufficient_scope | Encola y avisa; requiredScope indica el scope que falta. |
403 forbidden | El propietario de la clave carece del permiso que exige la operación. Pon en cola y avisa; corrígelo en la app. |
409 conflict | Lee errors[0].. in_progress (la misma clave sigue en ejecución) y lock_timeout (otra escritura bloqueó el recurso): espera y repite con la misma clave. invalid_state: no reintentes; encola y avisa. |
422 idempotency_key_reused | Enviaste el mismo id de envío con contenido distinto. Una clave identifica una operación; da un nuevo id de envío al contenido modificado. |
500 internal_error | Repite con las mismas claves y esperas crecientes; encola si persiste. Si la respuesta incluye Idempotent-Replay: true, se está reproduciendo el error guardado: deja de repetir y busca el contacto por email o contactNumber antes de volver a crearlo. |
503 service_unavailable | Ninguna operación de este tutorial lo documenta. Si llega, Filmo controla el interruptor que hay detrás: no lo reintentes como un fallo transitorio. Encola el envío y avisa con el requestId. |
Qué guardar por tu parte
Enlace a esta sección: Qué guardar por tu parte| Qué | Por qué |
|---|---|
| ID de envío | La raíz de ambas claves y la identidad del lead en tus logs. |
| ID del contacto y ID de la oportunidad de Filmo | Prueba de lo que se creó, para reintentos tras caducar las claves y para soporte. |
requestId de cualquier llamada fallida | Lo que indicas a soporte. |
| Los envíos en cola | Un lead que todavía no se pudo enviar. Reenvíalos con el id de envío original. |
Siguientes pasos
Enlace a esta sección: Siguientes pasos- Sincronizar contactos desde tu CRM: las mismas reglas de coincidencia cuando el CRM es el propietario de los contactos.
- De la comprobación de disponibilidad al pedido reservado: un flujo de reserva que comprueba el stock antes de pedir el lead.
- Idempotencia, Errores y Límites de peticiones.
- Pasar a producción: la lista de comprobación antes de que visitantes reales usen el formulario.
- En la app: Embudos y fases.
Páginas relacionadas
Enlace a esta sección: Páginas relacionadasTutoriales
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.
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
Errores
Los errores son documentos de problema RFC 9457. Ramifica según el
codeestable, nunca según el texto, y usa la tabla de reintentos para decidir si volver a intentarlo.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.Tutoriales
De la comprobación de disponibilidad al pedido reservado
Comprueba el stock desde tu servidor con una caché y lotes que respeten las 30 peticiones por minuto de las operaciones de disponibilidad, crea el pedido con una clave de idempotencia, luego resérvalo y gestiona
insufficient_stock.
¿Te ha resultado útil esta página?