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.

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

Permiso (scope)Se usa para
account:readLa primera llamada: zona horaria, add-ons y los scopes de la clave.
contacts:readBuscar el contacto por email.
contacts:writeCrear el contacto.
crm:readListar embudos y etapas. Solo hace falta si envías oportunidades a un embudo o a una etapa con nombre (paso opcional).
crm:writeCrear 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).

Llama a GET /api/v1/account 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.scopes tiene los scopes anteriores.

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

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

Llama a GET /api/v1/contacts 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[].email 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"

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.

POST /api/v1/contacts 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"
}
JSON

Guarda los pasos 3 y 4 juntos como contact.mjs (Python: contact.py), el paso 5 como pipelines.mjs (pipelines.py) y el paso 6 como opportunity.mjs (opportunity.py); 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.

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/v1/crm/pipelines 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"

POST /api/v1/crm/opportunities 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"
}
JSON

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 lead

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

Un 400 por el contacto (abreviado)
{
  "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 } };
}
RespuestaQué hacer
429 rate_limitedEspera 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_adminEl propietario de la clave se fue o dejó de ser administrador. Pon en cola, avisa y reemite la clave.
402 addon_requiredLa cuenta no tiene el add-on de CRM. Pon en cola y avisa; requiredAddon lo nombra.
403 insufficient_scopeEncola y avisa; requiredScope indica el scope que falta.
403 forbiddenEl propietario de la clave carece del permiso que exige la operación. Pon en cola y avisa; corrígelo en la app.
409 conflictLee errors[0].code. 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_reusedEnviaste 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_errorRepite 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_unavailableNinguna 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éPor qué
ID de envíoLa raíz de ambas claves y la identidad del lead en tus logs.
ID del contacto y ID de la oportunidad de FilmoPrueba de lo que se creó, para reintentos tras caducar las claves y para soporte.
requestId de cualquier llamada fallidaLo que indicas a soporte.
Los envíos en colaUn lead que todavía no se pudo enviar. Reenvíalos con el id de envío original.
  • 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.

  • 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

    Errores

    Los errores son documentos de problema RFC 9457. Ramifica según el code estable, 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.