*API REST › 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

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

## 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`](https://docs.filmo.io/es/rest-api/errors/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

Llama a [`GET /api/v1/account`](https://docs.filmo.io/es/rest-api/endpoint-reference/account/get-the-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.

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

## 2. Validar tú mismo el envío primero

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

## 3. Buscar el contacto por 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). 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.

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

// 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 };
}
```

**Python**

```python
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

[`POST /api/v1/contacts`](https://docs.filmo.io/es/rest-api/endpoint-reference/contacts/create-a-contact) 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.

**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: web-form-7c1e4a52-contact" \
  --data @- <<'JSON'
{
  "name": "Ana Example",
  "contactType": "person",
  "role": "lead",
  "email": "ana@example.com",
  "phone": "+34 600 000 000"
}
JSON
```

**Node**

```javascript
import { 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,
    },
  });
```

**Python**

```python
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.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.

## 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/v1/crm/pipelines`](https://docs.filmo.io/es/rest-api/endpoint-reference/crm/list-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.

**cURL**

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

**Node**

```javascript
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;
}
```

**Python**

```python
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 _cached
```

## 6. Crear la oportunidad

[`POST /api/v1/crm/opportunities`](https://docs.filmo.io/es/rest-api/endpoint-reference/crm/create-an-opportunity) 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`.

**cURL**

```bash
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
```

**Node**

```javascript
import { 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),
    },
  });
}
```

**Python**

```python
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](https://docs.filmo.io/es/rest-api/guides/testing)), 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

Un 400 [`validation_error`](https://docs.filmo.io/es/rest-api/errors/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)*

```json
{
  "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.

*El handler completo*

**Node**

```javascript
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 } };
}
```

**Python**

```python
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

| 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].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_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

| 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

- [Sincronizar contactos desde tu CRM](https://docs.filmo.io/es/rest-api/tutorials/crm-contact-sync): las mismas reglas de coincidencia cuando el CRM es el propietario de los contactos.
- [De la comprobación de disponibilidad al pedido reservado](https://docs.filmo.io/es/rest-api/tutorials/availability-to-reserved-order): un flujo de reserva que comprueba el stock antes de pedir el lead.
- [Idempotencia](https://docs.filmo.io/es/rest-api/guides/idempotency), [Errores](https://docs.filmo.io/es/rest-api/guides/errors) y [Límites de peticiones](https://docs.filmo.io/es/rest-api/guides/rate-limits).
- [Pasar a producción](https://docs.filmo.io/es/rest-api/guides/going-live): la lista de comprobación antes de que visitantes reales usen el formulario.
- En la app: [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/web-form-to-opportunity  
Última actualización: 2026-10-09