# Funnelchat API · v0 — documentación completa para desarrolladores y agentes > Archivo generado desde las instantáneas de las especificaciones de cada dominio y los textos del portal. Alcanza para construir una integración sin otra fuente. Si algo de acá contradice una respuesta real de la API, manda la respuesta real: la v0 cambia sin aviso. Instantáneas usadas: Conversations 68cce1b4e 2026-10-22 16:30 (-03:00); Communities 6f32f0b4 (árbol con cambios sin commitear) 2026-10-22 09:46 (-03:00); Accounts 300cc8435 2026-10-22 09:46 (-03:00). # Introducción Con la API de Funnelchat armás **tu propio Funnelchat sobre WhatsApp**: tus flujos, tus tiempos, tu lógica. Nosotros nos ocupamos de la parte difícil. Estado: **v0 en desarrollo**. ## Qué es y qué no es **Es** un conjunto de piezas para hacer desde tu código lo que se hace en WhatsApp: mandar y recibir mensajes, saber si llegaron, manejar grupos y números, y guardar los datos de tus contactos. Las combinás a tu manera: si no te gustan los tiempos de espera de un flow, armás el tuyo con las piezas y decidís vos cuándo y qué responder. **No es** una forma de manejar Funnelchat por control remoto. Si ya usás nuestros flows, campañas o difusiones, también podés consultarlos desde la API ([Funciones de Funnelchat](/docs/funciones-de-funnelchat/)), pero no los necesitás para armar lo tuyo. ## Qué hace nuestra capa por vos - **Un formato para todos los números.** Oficiales de Meta o por QR, la misma llamada. Qué puede hacer cada número lo dice su ficha (`capabilities`): [Números](/docs/piezas/numeros/#que-puede). - **Las reglas de WhatsApp, aplicadas.** La ventana de 24 horas, los frenos contra spam y el ritmo por número, antes de que Meta rechace un mensaje o marque tu número. No se pueden apagar: [Reglas de WhatsApp](/docs/reglas-de-whatsapp/). - **Entrega, colas y reintentos.** Las sesiones, los números y la entrega los opera Funnelchat: no mantenés servidores de WhatsApp. - **Todo lo que pasa, avisado.** Webhooks firmados, con reintentos e historial, para mensajes, estados, grupos y números. ## Las piezas | Pieza | Hoy | |---|---| | [Mensajes](/docs/piezas/mensajes/) | Recibir y enviar texto, archivos por URL, botones, listas, plantillas, formularios de WhatsApp, tarjetas, reacciones y reenvíos: **disponible**, con `conversations:send` y las reglas de WhatsApp. | | [Estados de mensaje](/docs/piezas/estados-de-mensaje/) | En cola, enviado, entregado, leído y falló: **disponible**. | | [Grupos](/docs/piezas/grupos/) | Leer grupos y participantes, y los avisos de lo que pasa adentro: **disponible**. | | [Números](/docs/piezas/numeros/) | Estado, salud, conexión por QR y qué puede hacer cada número (`capabilities`): **disponible**. | | [Contactos y datos](/docs/piezas/contactos-y-datos/) | Contactos, etiquetas, campos y notas: **disponible**. | | [Eventos](/docs/webhooks/eventos/) | Webhooks firmados con reintentos e historial: **disponible**. | ## Por dónde seguir - [Primeros pasos](/docs/primeros-pasos/): la URL base, la clave, un ejemplo de lista y de paginación, los errores comunes y los límites de llamadas. - [Guía rápida](/docs/guia-rapida/): clave, primera llamada, suscripción a un evento y verificación de la firma, en cuatro pasos. - [Armá tu propio flujo](/docs/armar-tu-flujo/): el principio en la práctica, de un mensaje que entra a una respuesta con tus tiempos. - [Autenticación y permisos](/docs/autenticacion/): el formato de la clave, los permisos, qué está abierto a claves y los errores. - [Webhooks](/docs/webhooks/) y el [catálogo de eventos](/docs/webhooks/eventos/): cómo suscribirte, la firma con código probado, los reintentos y qué eventos existen. - [Referencia](/docs/referencia/): cada endpoint, con las piezas primero y prueba en vivo con tu clave. - [llms.txt](/llms.txt) y [llms-full.txt](/llms-full.txt): todo esto en Markdown, para que tu IA programe contra la API. - [Procedencia](/docs/procedencia/): de qué commit y a qué hora salió cada especificación. ## Las áreas La API tiene **una sola URL base**, `https://api.funnelchat.com`, y está organizada en tres áreas, cada una con su propia especificación. Los eventos de las tres se suscriben en un solo lugar, `/api/v1/webhooks`. Para lo básico —la URL, la clave, un ejemplo de lista y los errores comunes— empezá por [Primeros pasos](/docs/primeros-pasos/). | Área | Qué cubre | Instantánea | Operaciones | Eventos construidos | |---|---|---|---|---| | Conversations | Contactos, etiquetas, campos personalizados, conversaciones, mensajes, flujos, plantillas, tableros y webhooks de mensajes. | 68cce1b4e · 2026-10-22 16:30 (-03:00) | 152 | 14 | | Communities | Campañas de grupos de WhatsApp, sus grupos y participantes, envíos programados a grupos, estadísticas y webhooks de grupos. | 6f32f0b4 (árbol con cambios sin commitear) · 2026-10-22 09:46 (-03:00) | 53 | 22 | | Accounts | La cuenta, sus números de WhatsApp (devices), sus alertas operativas y sus dominios propios. | 300cc8435 · 2026-10-22 09:46 (-03:00) | 68 | 12 | ## Cómo armar la URL La URL de una llamada es **la URL base + la ruta completa** que muestra el portal. Las rutas del portal son completas: todas empiezan con `/api/v1/` y se pegan a la URL base tal cual, sin agregar ni quitar nada. | Área | Ruta completa (ejemplo) | URL completa | Lo mismo en su especificación: `servers` + `paths` | |---|---|---|---| | Conversations | `GET /api/v1/contacts` | `https://api.funnelchat.com/api/v1/contacts` | `https://api.funnelchat.com/api/v1` + `/contacts` | | Communities | `GET /api/v1/webhooks` | `https://api.funnelchat.com/api/v1/webhooks` | `https://api.funnelchat.com/api` + `/v1/webhooks` | | Accounts | `GET /api/v1/me` | `https://api.funnelchat.com/api/v1/me` | `https://api.funnelchat.com/api/v1` + `/me` | - **No combines la URL base del portal con el `servers` de una especificación.** Saldría `…/api/api/v1/…` o `…/api/v1/api/v1/…`, que responden `404`. - **Las especificaciones parten la misma URL en otro lugar:** su `servers` ya incluye parte de la ruta y sus `paths` son relativos a eso (última columna). Si generás un cliente desde una especificación, usá su `servers` con sus `paths`; si escribís las llamadas a mano, usá la URL base + la ruta completa. Las dos formas dan la misma URL. ## Antes de integrar > [!WARNING] Lo que tenés que saber de la v0 > - **La v0 cambia sin aviso.** No es una API escrita aparte: la clave entra por las rutas que ya usa el producto, así que rutas, campos y respuestas pueden cambiar sin aviso previo. Generá tu cliente desde la especificación y tolerá campos nuevos. _(D7)_ > - **Hay una sola URL base: `https://api.funnelchat.com`**, igual para las tres áreas. La URL de cada llamada es la URL base + la ruta completa (`/api/v1/…`): ver [Cómo armar la URL](#url). Los hosts internos de cada área ya no se publican y las claves van a dejar de funcionar contra ellos. > - **Algunas respuestas exponen identificadores internos** (`user_id`, `owner_id`, `device_id` en conversations). Se aceptan en v0 y no son estables: no los guardes como identificadores de tu cuenta. De esos, los eventos de mensajes traen solo `device_id`: el número por el que entró o salió el mensaje, el mismo con el que mandás. _(enmienda C1/D5 del 04-10)_ > - **Revocar una clave corta también los webhooks atados a ella**: los que creó o a los que les cambió la URL. Dejan de recibir eventos en el acto, pasan a `status: disabled` con `disabled_reason: key_revoked` y no se pueden volver a habilitar; para seguir, creá una suscripción nueva. Las creadas desde el panel que ninguna clave redirigió no cambian. Ver [Webhooks](/docs/webhooks/). _(enmienda C3 del 04-10, E18, E19)_ > - **La revisión de seguridad del portero cubre hasta la v0.3.** La revisión única que pide el contrato se hizo el 11-09 sobre la v0.3 y corrigió tres fallos; los cambios posteriores (desde la lista `operate` y el guard explícito de la v0.4) todavía no se revisaron. El portero es la capa que valida la clave, el permiso y, en accounts y communities, el tope diario en cada llamada. _(C1, D15)_ > - La especificación de Communities (instantánea 6f32f0b4 (árbol con cambios sin commitear)) ya no dice que las claves estén pendientes. Confirmalo con una llamada real antes de depender de eso. En communities, una clave lee; enviar y operar no habilitan ninguna operación en v0. _(Q32, enmienda C1 del 04-10)_ > - **Los webhooks son un solo sistema**, en `/api/v1/webhooks`, con los permisos `webhooks:read` y `webhooks:write` más el permiso de lectura del área de cada evento. El sistema anterior de accounts (`/api/v1/webhooks/endpoints…`) sigue andando, solo para los eventos de la cuenta y sus números. Ver [Webhooks](/docs/webhooks/) y [el sistema anterior](/docs/webhooks/#accounts). > - **Enviar pide el permiso `conversations:send`** y pasa por las reglas de WhatsApp: la ventana de 24 horas, los frenos contra spam y el ritmo por número ([Reglas de WhatsApp](/docs/reglas-de-whatsapp/)). Un envío aceptado vuelve en cola (`queued`): qué pasó después lo cuenta [Estados de mensaje](/docs/piezas/estados-de-mensaje/). _(Q19, que cierra D21)_ > - **Todavía no registramos bajas.** Si un contacto te pide que no le escriban más, respetalo vos en tu sistema: la API no frena los envíos a ese contacto. Ver [Bajas](/docs/reglas-de-whatsapp/#bajas). _(Q9)_ > - **No hay sandbox: todo es real.** Lo que hagas con una clave actúa sobre los datos y los números reales de tu cuenta. _(D17)_ > [!NOTE] Sobre las instantáneas de esta versión > Las instantáneas salieron de árboles de trabajo con cambios sin commitear (Communities `6f32f0b4`). El SHA indica la base del árbol, no el contenido exacto que se documentó. Ver [Procedencia](/docs/procedencia/). ## Conocido en v0, se corrige en las specs de los dominios Diferencias entre lo que dicen las especificaciones y lo que responde la API hoy. Ya están identificadas; mientras se corrigen, esto es lo que conviene hacer. - **`POST /api/v1/contacts/update-or-create` (conversations) responde `201` o `200`.** `201` cuando crea el contacto y `200` cuando ya existía, con el mismo `id`; la especificación documenta solo `200`. Busca el contacto por teléfono, con o sin `+` (`+5491100000099` y `5491100000099` dieron el mismo contacto). **Mientras tanto:** tratá `200` y `201` como éxito. En los dos casos el cuerpo es el contacto, sin envoltorio `data`. - **Las especificaciones no listan todos los `403` que pueden llegar.** El portero puede responder `403` en cualquier ruta (`permission_denied` o `route_not_available`, con `error` y `message`). Algunas rutas no documentan `403`, como `GET /api/v1/contacts` en conversations y `GET /api/v1/groups` en communities, y donde está documentado el esquema trae solo `message`. **Mientras tanto:** esperá un `403` en cualquier ruta y decidí por `error` cuando viene. Ver [Errores](/docs/autenticacion/#errores). - **No hay una ruta para que una clave consulte qué permisos tiene.** `GET /api/v1/me` (accounts) pide `accounts:read` y, según su especificación, devuelve los datos de la cuenta, no los permisos de la clave. Las rutas de claves responden `403 route_not_available` a cualquier clave. **Mientras tanto:** guardá la lista de permisos al crear la clave. Ver [Qué permisos tiene tu clave](/docs/autenticacion/#mis-permisos). _Observado con llamadas reales el 04-10 entre las 19:40 y las 20:00 (-03:00), contra conversations `beb716dd7` y accounts `1d6501b4`, y en las especificaciones de esa hora y de las instantáneas de [Procedencia](/docs/procedencia/). La paginación de `GET /api/v1/contacts`, que figuraba acá, se volvió a probar el 05-10: con clave siempre pagina y `links` conserva `perPage` y los filtros, como dice su especificación._ # Primeros pasos Lo que es común a toda la API, sin importar el área (accounts, conversations o communities): una sola URL, una sola forma de autenticarte y los mismos errores. ## La URL base ```text https://api.funnelchat.com ``` Es la **única** dirección pública de la API. La URL de una llamada es la URL base + la ruta completa, que empieza con `/api/v1/…`: por ejemplo, `https://api.funnelchat.com/api/v1/contacts`. Cada llamada llega al mismo lugar y se reparte sola al área dueña de la ruta; vos no elegís el área. - **Las referencias siguen siendo una por área**, pero todas usan esta URL base. - **Los hosts internos de cada área dejan de publicarse** y las claves van a dejar de funcionar contra ellos. Usá siempre `https://api.funnelchat.com`. Una clave usada contra un host interno, cuando se encienda esa exigencia, recibe `403 use_public_endpoint`: **pendiente de fecha**. - Las URL que arma la API en sus respuestas, como los enlaces de paginación, también salen con `https://api.funnelchat.com`. ## Autenticarte con tu clave Cada llamada lleva la clave completa en la cabecera `Authorization`, tal como se mostró al crearla, incluido el `|` del principio: ```http Authorization: Bearer |fc_live_... ``` La clave se crea en la pantalla **Desarrolladores** de tu cuenta. Los permisos, cómo guardarla y qué pasa al revocarla están en [Autenticación y permisos](/docs/autenticacion/); el paso a paso con la primera llamada, en la [Guía rápida](/docs/guia-rapida/). ## Un ejemplo de lista Esta llamada lista tus primeros cinco contactos (área conversations). Con una clave la respuesta siempre viene paginada: ```bash curl "https://api.funnelchat.com/api/v1/contacts?perPage=5" \ -H "Authorization: Bearer |fc_live_..." \ -H "Accept: application/json" ``` Responde `200` con `data`, `links`, `meta` (según la especificación). Pide el permiso `conversations:read`. ## Un ejemplo de paginación La respuesta trae `data` con los elementos de la página, `links` con los enlaces a otras páginas y `meta` con los totales. Recortada a lo que importa para paginar (es la de la [Guía rápida](/docs/guia-rapida/#respuesta)): ```json { "data": [ … ], "links": { "first": "https://api.funnelchat.com/api/v1/contacts?perPage=5&page=1", "last": "https://api.funnelchat.com/api/v1/contacts?perPage=5&page=88", "prev": null, "next": "https://api.funnelchat.com/api/v1/contacts?perPage=5&page=2" }, "meta": { "current_page": 1, "last_page": 88, "per_page": 5, "total": 436 } } ``` Para pedir la página siguiente, seguí `links.next` tal cual: conserva `perPage` y los filtros de tu consulta. Pará cuando `links.next` sea `null`. ```bash curl "https://api.funnelchat.com/api/v1/contacts?perPage=5&page=2" \ -H "Authorization: Bearer |fc_live_..." \ -H "Accept: application/json" ``` `perPage` y `page` valen para esta ruta; cada ruta dice en su referencia qué parámetros acepta. ## Errores comunes Cuando el portero o la entrada de la API rechazan una llamada, el cuerpo trae `error` (un código estable) y `message` (para personas). Decidí por el código HTTP y por `error`, no por `message`. Como toda respuesta, el error trae la cabecera [`X-Request-Id`](#request-id). | Código | `error` | Qué pasó | Qué hacer | |---|---|---|---| | `401` | `unauthenticated` | Falta la clave, no existe o fue revocada. | Revisá la cabecera `Authorization` y que la clave siga activa. | | `403` | `permission_denied` | La clave no tiene el permiso que pide la ruta; `message` dice cuál. | Creá una clave con ese permiso. | | `403` | `route_not_available` | La ruta no está disponible para claves en el piloto. | No hay permiso que pedir: ninguna clave la habilita. | | `404` | `route_not_found` | La ruta no existe: se contesta sin llegar a ningún área. | Revisá la ruta y que empiece con `/api/v1/`. | | `429` | `daily_cap_exceeded` | Superaste el tope diario de llamadas de accounts o de communities. Trae `Retry-After` con los segundos que faltan para que se reinicie. | Esperá lo que indica `Retry-After`. | | `502` | `origin_unreachable` | La entrada de la API no pudo comunicarse con el área que atiende la ruta. | Reintentá con espera, cada vez más larga. Si sigue, reportalo citando el `X-Request-Id`. | La lista completa, con los demás códigos (`422`, `429 rate_limited`, `500 edge_misconfigured`, `503 gate_unavailable`, límites por minuto de una ruta), está en [Autenticación y permisos](/docs/autenticacion/#errores). ## El id del pedido: `X-Request-Id` Cada respuesta de la API trae la cabecera `X-Request-Id`, en éxito y en error: también en los rechazos del portero (`401`, `403`, `429`, `503`) y en los errores de la entrada (`404 route_not_found`, `500 edge_misconfigured`, `502 origin_unreachable`). ```http X-Request-Id: 3f2b8c1e-9a4d-4e7b-8c21-5d6f0a9b1c3e ``` - **Es un UUID** distinto para cada pedido. - **Es lo que hay que citar al reportar un problema**, junto con la hora aproximada de la llamada: con ese id soporte encuentra la llamada. Guardalo en tus logs junto con el código de respuesta. - **No se puede consultar por el id desde la API.** No hay ninguna ruta que devuelva una llamada a partir de su id: sirve para que soporte la encuentre, no para buscarla vos. ## Los límites de llamadas - **accounts y communities:** hasta **10.000 llamadas por día por clave** en cada una. Al superarlo, la API responde `429 daily_cap_exceeded` con `Retry-After`. - **conversations no tiene tope diario.** Lo que se limita son los envíos a WhatsApp, por el número que manda y no por la clave: hasta 60 mensajes por segundo en un número de WhatsApp Cloud, 15 en coexistencia y sin límite por QR; además, 10 por minuto al mismo contacto en lo que sale por WhatsApp Cloud. No hay un límite por minuto por clave ni por cuenta. Ver [Ritmo por número](/docs/reglas-de-whatsapp/#ritmo). > [!NOTE] Pendiente de confirmar > Este portal no documenta todavía cómo se cuenta el día (a qué hora se reinicia) ni si el tope se levanta a pedido: ni las instantáneas ni el contrato de la URL única lo dicen. Hasta entonces, usá `Retry-After` para saber cuánto esperar. # Guía rápida De cero a recibir tu primer evento firmado, en cuatro pasos. Necesitás una cuenta de Funnelchat con acceso a la beta privada de la API y un servidor con una URL `https` pública para recibir los webhooks. ## 1. Creá una clave de API Entrá a la pantalla **Desarrolladores** de tu cuenta, poné una etiqueta para reconocerla y elegí los permisos. Para esta guía alcanzan `conversations:read` (leer contactos y elegir eventos de conversations) y `webhooks:write` (administrar la suscripción). La pantalla está disponible para el titular de la cuenta que esté en la beta privada de la API. > [!WARNING] La clave se ve una sola vez > Copiala apenas la crees: tiene la forma `|fc_live_…` y no se puede volver a consultar. Las claves no vencen: si se filtra, revocala desde la misma pantalla. Si la guardás en un archivo `.env`, ponela entre comillas. El `|` es parte de la clave: sin comillas, al cargar el archivo con `source` la shell lo toma como una tubería y la variable queda vacía. ```bash FC_API_KEY='|fc_live_…' ``` También se puede crear por API, con tu sesión web de Funnelchat. Una clave nunca puede crear, listar ni revocar claves: esa ruta le responde `403 route_not_available`, y por eso no aparece en la referencia. ```bash curl -X POST "https://api.funnelchat.com/api/v1/me/api-keys" \ -H "Authorization: Bearer " \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ -d '{ "label": "Mi integración", "abilities": ["conversations:read", "webhooks:write"] }' ``` `label` es el nombre de la clave (hasta 120 caracteres) y `abilities`, la lista de permisos. La respuesta trae la clave completa una sola vez. ## 2. Hacé tu primera llamada La API tiene una sola URL base, y la URL de una llamada es esa URL base + la ruta completa, que siempre empieza con `/api/v1/` (ver [Primeros pasos](/docs/primeros-pasos/) y [Cómo armar la URL](/docs/#url)). Esta llamada lista tus primeros cinco contactos en conversations: ```bash curl "https://api.funnelchat.com/api/v1/contacts?perPage=5" \ -H "Authorization: Bearer |fc_live_..." \ -H "Accept: application/json" ``` Responde `200` con `data`, `links`, `meta` (según la especificación). Pide el permiso `conversations:read`. Si la clave no existe o fue revocada, recibís `401`; si le falta el permiso, `403 permission_denied`. Todos los códigos están en [Autenticación y permisos](/docs/autenticacion/#errores). ### La respuesta La respuesta viene paginada: `data` trae los contactos, `links` los enlaces a otras páginas y `meta` los totales. Esta es una respuesta real de esta llamada (05-10), recortada a uno de los cinco contactos y a tres de los quince elementos de `meta.links`: ```json { "data": [ { "id": 102578466, "name": "Ana Pérez", "email": "", "phone": "5491100000000", "whatsapp_id": "", "whatsapp_username": null, "has_phone": true, "country_code": "", "status": 1, "image": "", "notes": null, "created_at": "2026-10-04T21:23:45.000000Z", "active_conversation": 0, "favorite": 0, "unread_messages": 0, "device_id": null, "adviser_id": null, "paused_at": null, "mac_date": null, "ai_assistant_name": null } ], "links": { "first": "https://api.funnelchat.com/api/v1/contacts?perPage=5&page=1", "last": "https://api.funnelchat.com/api/v1/contacts?perPage=5&page=88", "prev": null, "next": "https://api.funnelchat.com/api/v1/contacts?perPage=5&page=2" }, "meta": { "current_page": 1, "from": 1, "last_page": 88, "links": [ { "url": null, "label": "« Anterior", "page": null, "active": false }, { "url": "https://api.funnelchat.com/api/v1/contacts?perPage=5&page=1", "label": "1", "page": 1, "active": true }, { "url": "https://api.funnelchat.com/api/v1/contacts?perPage=5&page=2", "label": "Siguiente »", "page": 2, "active": false } ], "path": "https://api.funnelchat.com/api/v1/contacts", "per_page": 5, "to": 5, "total": 436 } } ``` - **Con una clave siempre viene paginada.** Sin `perPage`, trae 25 contactos por página. - **Para la página siguiente, seguí `links.next`:** conserva `perPage` y los filtros de tu consulta. Pará cuando `links.next` sea `null`. - **El teléfono viene sin `+`** (`phone: "5491100000000"`), aunque los eventos lo traen con `+`. Para buscar un contacto por el teléfono de un evento, quitale el `+` antes: ver [El teléfono en los eventos y en la API](/docs/webhooks/#telefonos). > [!NOTE] Identificadores internos > Las respuestas de conversations pueden traer `user_id`, `owner_id` o `device_id`. En v0 se aceptan, pero no son estables: no los uses como identificadores de tu cuenta. ## 3. Suscribite a `message.received` Las suscripciones a webhooks se administran en un solo lugar, `/api/v1/webhooks`, para los eventos de las tres áreas. `message.received` —un contacto le escribió a un número de WhatsApp de tu cuenta— es del área de conversations, así que la clave necesita `webhooks:write` y además `conversations:read`: ```bash curl -X POST "https://api.funnelchat.com/api/v1/webhooks" \ -H "Authorization: Bearer |fc_live_..." \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ -d '{ "url": "https://tu-servidor.example/hooks/funnelchat", "events": [ "message.received" ], "description": "Mi integración" }' ``` Responde `201` con la suscripción: `id`, `url`, `description`, `events`, `filters`, `status`, `disabled_reason`, `last_delivery_at`, `last_success_at`, `secret_rotated_at`, `created_at`, `updated_at`, `migration`, `secret`, `secret_notice`. Pide el permiso `webhooks:write` y, para `message.received`, también `conversations:read`: cada evento pide el permiso de lectura de su área. > [!WARNING] El secreto se ve una sola vez > La respuesta trae `secret`: guardalo en tu servidor, es con lo que se firma cada entrega. No se puede volver a consultar; si lo perdés, generá otro desde el panel de Funnelchat (la clave no puede rotarlo). - `url` es la dirección pública de tu receptor; usá `https`. - `events` acepta de 1 a 50 tipos del [catálogo](/docs/webhooks/eventos/), de cualquiera de las tres áreas; no hay un comodín para «todos». Elegí eventos con `status: "active"` en `GET /api/v1/webhooks/events`: los planificados se aceptan, pero no llegan hasta que se empiecen a emitir ([detalle](/docs/webhooks/eventos/#suscribirse-planificados)). - **Guardá el `id` de la respuesta.** Identifica tu suscripción: las suscripciones son de la cuenta y `GET /api/v1/webhooks` también muestra las de otras integraciones. Si cambia tu URL, actualizala con `PUT` en lugar de crear otra ([detalle](/docs/webhooks/#tus-suscripciones)). - Para probar sin esperar un mensaje real, `POST https://api.funnelchat.com/api/v1/webhooks/{id}/test` manda un evento `webhook.test` firmado igual que uno real y te responde si tu servidor contestó `2xx` (`succeeded`). `{id}` es el `id` de la respuesta del alta. ## 4. Verificá la firma Cada entrega es un `POST` con el sobre `{id, type, version, occurred_at, data}` y la cabecera `X-Funnelchat-Signature: t=,v1=`. `v1` es el HMAC-SHA256, en hexadecimal, de `t + "." + cuerpo crudo`, con el secreto completo de la suscripción tal como se mostró. Verificala sobre el cuerpo crudo, antes de parsear el JSON: **Node.js** (`snippets/verificar-firma.js`): ```js // Verifica la cabecera X-Funnelchat-Signature de una entrega de webhook (Node.js 16+). // cuerpoCrudo: el cuerpo tal como llegó (Buffer o string), sin parsear ni volver a serializar. // secreto: el secreto completo de la suscripción, tal como se mostró al crearla o rotarla. const crypto = require('node:crypto'); function verificarFirma(cuerpoCrudo, cabecera, secreto, toleranciaSegundos = 300) { if (typeof cabecera !== 'string' || typeof secreto !== 'string' || secreto === '') return false; let t = null; const firmas = []; for (const parte of cabecera.split(',')) { const i = parte.indexOf('='); if (i === -1) continue; const clave = parte.slice(0, i).trim(); const valor = parte.slice(i + 1).trim(); if (clave === 't') t = valor; if (clave === 'v1') firmas.push(valor); } if (t === null || !/^\d+$/.test(t) || firmas.length === 0) return false; // Rechazá entregas viejas o del futuro (protección contra repeticiones). if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > toleranciaSegundos) return false; const esperada = crypto .createHmac('sha256', secreto) .update(`${t}.`) .update(cuerpoCrudo) .digest('hex'); const a = Buffer.from(esperada, 'utf8'); return firmas.some((v1) => { const b = Buffer.from(v1, 'utf8'); return a.length === b.length && crypto.timingSafeEqual(a, b); }); } module.exports = { verificarFirma }; // Uso con Express: leé el cuerpo crudo, verificá, y recién después parseá. // app.post('/hooks/funnelchat', express.raw({ type: 'application/json' }), (req, res) => { // if (!verificarFirma(req.body, req.get('X-Funnelchat-Signature'), process.env.FUNNELCHAT_WEBHOOK_SECRET)) { // return res.sendStatus(400); // } // const evento = JSON.parse(req.body.toString('utf8')); // tomá type e id de acá, no de las cabeceras // res.sendStatus(200); // respondé rápido; procesá después // }); ``` **Python** (`snippets/verificar_firma.py`): ```python # Verifica la cabecera X-Funnelchat-Signature de una entrega de webhook (Python 3.8+). # cuerpo_crudo: los bytes del cuerpo tal como llegaron, sin parsear ni volver a serializar. # secreto: el secreto completo de la suscripción, tal como se mostró al crearla o rotarla. import hashlib import hmac import time def verificar_firma(cuerpo_crudo: bytes, cabecera: str, secreto: str, tolerancia_segundos: int = 300) -> bool: if not isinstance(cabecera, str) or not isinstance(secreto, str) or not secreto: return False t = None firmas = [] for parte in cabecera.split(","): clave, separador, valor = parte.partition("=") if not separador: continue clave, valor = clave.strip(), valor.strip() if clave == "t": t = valor elif clave == "v1": firmas.append(valor) if t is None or not t.isdigit() or not firmas: return False # Rechazá entregas viejas o del futuro (protección contra repeticiones). if abs(int(time.time()) - int(t)) > tolerancia_segundos: return False esperada = hmac.new(secreto.encode("utf-8"), t.encode("ascii") + b"." + cuerpo_crudo, hashlib.sha256).hexdigest() return any(hmac.compare_digest(esperada, v1) for v1 in firmas) # Uso con Flask: leé el cuerpo crudo, verificá, y recién después parseá. # @app.post("/hooks/funnelchat") # def recibir(): # cuerpo = request.get_data() # bytes crudos # if not verificar_firma(cuerpo, request.headers.get("X-Funnelchat-Signature", ""), os.environ["FUNNELCHAT_WEBHOOK_SECRET"]): # return "", 400 # evento = json.loads(cuerpo) # tomá type e id de acá, no de las cabeceras # return "", 200 # respondé rápido; procesá después ``` **PHP** (`snippets/verificar-firma.php`): ```php $toleranciaSegundos) { return false; } $esperada = hash_hmac('sha256', $t.'.'.$cuerpoCrudo, $secreto); foreach ($firmas as $v1) { if (hash_equals($esperada, $v1)) { return true; } } return false; } // Uso: // $cuerpo = file_get_contents('php://input'); // if (! verificarFirma($cuerpo, $_SERVER['HTTP_X_FUNNELCHAT_SIGNATURE'] ?? '', (string) getenv('FUNNELCHAT_WEBHOOK_SECRET'))) { // http_response_code(400); // exit; // } // $evento = json_decode($cuerpo, true); // tomá type e id de acá, no de las cabeceras // http_response_code(200); // respondé rápido; procesá después ``` Último resultado de `tests/test_firmas.py` (2026-10-22 20:53 (+00:00)): pasó — Python 3.12.15, Node v24.21.0, PHP 8.3.6. Después: respondé `2xx` enseguida y procesá en segundo plano, y descartá los eventos repetidos por su `id`. El detalle está en [Webhooks](/docs/webhooks/). ## Y después - Mirá qué otros eventos podés recibir en el [catálogo](/docs/webhooks/eventos/). - Explorá las rutas de los tres dominios y probalas con tu clave en la [referencia](/docs/referencia/). - Si programás con una IA, pasale [llms-full.txt](/llms-full.txt): tiene todo esto más un resumen de cada endpoint. # Armá tu propio flujo Un ejemplo completo: alguien le escribe «Quiero info» a tu número, esperás diez minutos, revisás tu CRM y le respondés. Los tiempos y las condiciones son tuyos: la API te da las piezas y Funnelchat se ocupa de WhatsApp. Lo que necesitás: - **Una clave** con `webhooks:write` (administrar la suscripción), `conversations:read` (leer contactos y la ventana, y recibir los eventos de mensajes), `conversations:write` (guardar datos del contacto) y `conversations:send` (responder). Ver [Permisos](/docs/autenticacion/#permisos). - **Un servidor** con una URL `https` pública para recibir los eventos. ## 1. Suscribite a los eventos El flujo usa dos eventos: `message.received` (te escribieron) y `message.status_changed` (cambió el estado de un mensaje). Te suscribís con tu URL y los dos tipos, en la suscripción única de `/api/v1/webhooks`: ```bash curl -X POST "https://api.funnelchat.com/api/v1/webhooks" \ -H "Authorization: Bearer |fc_live_..." \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ -d '{ "url": "https://tu-servidor.example/whatsapp", "events": ["message.received", "message.status_changed"], "description": "Mi flujo propio" }' ``` - Responde `201` con la suscripción (`id`, `status`…) y el `secret` **una sola vez**: guardalo completo, es con lo que verificás la firma de cada entrega. - Pide `webhooks:write` y, por ser eventos de mensajes, también `conversations:read`. - `message.status_changed` se pide siempre por su nombre, como acá. - Suscripciones, firma y reintentos, en detalle: [Cómo funcionan los webhooks](/docs/webhooks/). ## 2. Recibí el mensaje Cada mensaje entrante llega a tu URL como un `POST` firmado con `X-Funnelchat-Signature`. Este es el ejemplo de `message.received` que publica la especificación: ```json { "id": "evt_000000000000000000000001", "type": "message.received", "version": 1, "occurred_at": "2026-10-01T12:00:00Z", "data": { "message": { "id": 1001, "type": "text", "text": "Hola, quiero más información", "media": null }, "contact": { "id": 2001, "name": "Contacto de ejemplo", "phone": "+5491100000000" }, "receiver": { "phone": "+5491100000001" }, "device_id": 4521 } } ``` Verificá la firma sobre el cuerpo crudo, respondé `2xx` enseguida y hacé el resto en segundo plano. Descartá los repetidos por `id`: un reintento trae el mismo. Los snippets de verificación, probados, están en [Cómo funcionan los webhooks](/docs/webhooks/). ## 3. Tu lógica, tus tiempos Esto es lo que antes te imponían nuestros flows: ahora es tuyo. - **Guardá el estado de la conversación** donde quieras: en tu base o en un campo personalizado del contacto en Funnelchat (Asignar un campo personalizado a contactos (`POST /api/v1/contacts/attach-custom-field`)). - **Programá la espera** con tu propia cola o tu cron: diez minutos, un día o «cuando el CRM diga». - **Decidí qué responder**: consultá tu CRM, tu catálogo o tu IA. ## 4. Sabé desde qué número responder Para responder necesitás el `device_id` del número que recibió el mensaje: el evento lo trae en `data.device_id` (y su teléfono en `receiver.phone`). Respondé por ese mismo número. Si querés su nombre o qué puede mandar, está en Listar números (`GET /api/v1/devices`) (permiso `accounts:read`). ## 5. Antes de responder, mirá la ventana Con un número de WhatsApp Cloud (el oficial de Meta) solo podés mandar mensajes que no son plantilla si el contacto te escribió en las últimas 24 horas; si no, va una plantilla aprobada. Ver la ventana de 24 horas de un contacto (`GET /api/v1/contacts/{contact}/meta-window`) te lo dice: ```bash curl "https://api.funnelchat.com/api/v1/contacts/2001/meta-window?device_id=3" \ -H "Authorization: Bearer |fc_live_..." \ -H "Accept: application/json" ``` Responde `is_open` (`true` o `false`) y `meta_device_id`. Si te olvidás de mirar, no se manda nada que rompa la regla: te frenamos con un error claro (paso 7). ## 6. Respondé **Ventana abierta: texto libre**, con Enviar un mensaje a un contacto (`POST /api/v1/contacts/{contact}/message`). `device_id` y `contact_id` van en el cuerpo: la especificación pide el contacto también ahí. ```bash curl -X POST "https://api.funnelchat.com/api/v1/contacts/2001/message" \ -H "Authorization: Bearer |fc_live_..." \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ -d '{ "device_id": 3, "contact_id": 2001, "text": "¡Hola! Te paso la info…", "client_reference": "consulta-2001-1" }' ``` Responde `200` con `message`: el mensaje registrado, con su `id` y `status: "queued"`. Guardá el `id`: es el `message_id` de los avisos del paso 8. `client_reference` es opcional y es tuya: vuelve en esos avisos, para relacionarlos con tu envío aunque la llamada no te haya respondido. Con `file` (la URL de un archivo) mandás una imagen, un video, un documento o un audio, y con `options`, botones o una lista: ver [Mensajes](/docs/piezas/mensajes/). **Ventana cerrada: una plantilla aprobada**, con Enviar una plantilla (`POST /api/v1/devices/{device}/templates/{template}/send-template`). En la ruta van el número (`3`) y el `id` de la plantilla (`45`), que sale de Listar plantillas (`GET /api/v1/templates`). ```bash curl -X POST "https://api.funnelchat.com/api/v1/devices/3/templates/45/send-template" \ -H "Authorization: Bearer |fc_live_..." \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ -d '{ "phone": "5491100000000", "contact_id": 2001, "template_variables": { "body": ["Lucía"] }, "client_reference": "consulta-2001-1" }' ``` `phone` y `contact_id` son obligatorios; `template_variables.body` completa las variables del cuerpo de la plantilla, en orden. Responde `200` con el mensaje en cola, igual que el texto. ## 7. Si te frenamos Si un envío rompe una regla de WhatsApp, no sale ni queda registrado: te devolvemos un error y no se lo mandamos a Meta. ```http HTTP/1.1 422 Unprocessable Content Content-Type: application/json { "error": "window_closed", "message": "" } ``` | Respuesta | Qué pasó | Qué hacer | |---|---|---| | `422 window_closed` | Un mensaje que no es plantilla con la ventana cerrada, en un número de WhatsApp Cloud. | Mandá una plantilla aprobada. | | `422 antiblock_duplicate_content` | El mismo contenido repetido al mismo contacto. | No repitas el texto: cambialo o esperá. | | `422 antiblock_unanswered_limit` | Demasiados mensajes seguidos a alguien que no contesta. | Esperá a que el contacto responda. | | `429 rate_limited` | Superaste el ritmo de tu número: 60 mensajes por segundo en un número de WhatsApp Cloud, 15 en coexistencia y sin límite por QR; además, 10 por minuto al mismo contacto en lo que sale por WhatsApp Cloud. Se cuenta por número, no por clave. Trae `Retry-After`. | Esperá lo que indica `Retry-After` y reintentá. | | `422 recipient_unsupported` | El contacto es un grupo, una lista de difusión o un canal. | Por la API solo se les escribe a personas. | Decidí por el código HTTP y por `error`; `message` es para personas. Los límites exactos están en [Reglas de WhatsApp](/docs/reglas-de-whatsapp/), y el ritmo de cada tipo de número, en [Ritmo por número](/docs/reglas-de-whatsapp/#ritmo). ## 8. Enterate de qué pasó Cada cambio de estado de un mensaje llega como `message.status_changed`: ```json { "id": "evt_…", "type": "message.status_changed", "version": 1, "occurred_at": "2026-10-15T14:02:11Z", "data": { "message_id": 1002, "contact_id": 2001, "device_id": 4521, "status": "read", "previous_status": "delivered", "occurred_at": "2026-10-15T14:02:10Z", "client_reference": "consulta-2001-1" } } ``` `sent` → `delivered` → `read`, o `failed` con `error` (`code`, `title` y `reason`). Llega un aviso por cambio, nunca uno que retroceda. `message_id` es el `id` que te devolvió el envío. Si preferís preguntar en lugar de esperar el aviso, Ver un mensaje (`GET /api/v1/contacts/{contact}/messages/{message}`) te da el estado de un mensaje por su `id`. Todo el detalle, en [Estados de mensaje](/docs/piezas/estados-de-mensaje/). > [!NOTE] Con estas piezas armás cualquier flujo > Secuencias, recordatorios, un bot propio o tu IA: nada te obliga a usar nuestros flows. Si ya los usás, siguen ahí, en [Funciones de Funnelchat](/docs/funciones-de-funnelchat/). ## Probalo acá La ventana de 24 horas de un contacto, con tu clave. Es una lectura: no manda nada. En el portal, esta sección trae la prueba en vivo de `GET /api/v1/contacts/{contact}/meta-window`: la misma de su página en la referencia, con tu clave. # Autenticación y permisos ## La clave Cada llamada lleva la clave completa en la cabecera `Authorization`, tal como se mostró al crearla, incluido el `|` del principio: ```http Authorization: Bearer |fc_live_... ``` - **Una clave, una cuenta.** La clave accede solo a los datos de la cuenta que la creó. No hay claves para varias cuentas. _(D9)_ - **Sin OAuth.** No hay intercambio de tokens ni refresco: la clave se manda tal cual en cada llamada. _(D8)_ - **No vence.** Guardala como una contraseña, fuera del código y de los repositorios. Si se filtra, revocala desde la pantalla Desarrolladores: según el contrato, deja de funcionar en todos los dominios desde la llamada siguiente. _(C1)_ - **Lleva un `|`.** En un archivo `.env`, escribila entre comillas: `FC_API_KEY='|fc_live_…'`. Sin comillas, si cargás el archivo con `source`, la shell toma el `|` como una tubería y la variable queda vacía. - **Revocarla corta también los webhooks atados a ella.** Una suscripción queda atada a la clave que la creó o a la última clave que le cambió la URL. Cuando esa clave se revoca, deja de recibir eventos en el acto y pasa a `status: disabled` con `disabled_reason: "key_revoked"`; no se puede volver a habilitar, así que hay que crear una nueva. Las suscripciones creadas desde el panel que ninguna clave redirigió son de la cuenta y no cambian. Ningún campo de la suscripción dice a qué clave está atada: llevá vos el registro de qué clave usó cada integración. _(enmienda C3 del 04-10, E18, E19, E40)_ - **Tope diario de llamadas: 10.000 por día por clave en accounts y en communities**, cada área por su lado. Al superarlo, `429 daily_cap_exceeded`. Si el tope se puede levantar a pedido es algo que el contrato anterior decía (D16, C6) y que el de la URL única no repite: **pendiente de confirmar**. _(D16, C6; URL única, Q6; Q20 del 14-10)_ - **conversations no tiene tope diario.** Lo que se limita son los envíos a WhatsApp, por el número que manda y no por la clave: ver [Ritmo por número](/docs/reglas-de-whatsapp/#ritmo). _(Q20 del 14-10)_ - **Cada llamada queda registrada** (clave, ruta, método, código de respuesta, hora y duración) para entender cómo se usa el piloto. No se guarda el contenido. _(C2, D12)_ Todas las claves son `fc_live_`: no hay claves de prueba ni sandbox. Lo que hagas con una clave actúa sobre datos y números reales. _(C1, D17)_ ## Permisos Cada ruta pide un permiso y los permisos se eligen al crear la clave. Son de la forma `dominio:grado`: leer, escribir y, solo donde el dominio produce salida a WhatsApp, enviar u operar. _(D10)_ | Permiso | Qué habilita | Estado en v0 | Rutas en las instantáneas | |---|---|---|---| | `conversations:read` | Leer contactos, conversaciones, mensajes, etiquetas, flujos, plantillas, tableros… | Abierto (etapa 1). | 75 | | `conversations:write` | Escribir sin enviar a WhatsApp: contactos, etiquetas, campos, tableros, notas, tickets, carpetas. | Abierto (etapa 1). La superficie amplificada sigue cerrada (`route_not_available`). | 69 | | `conversations:send` | Enviar mensajes 1:1 por WhatsApp. | Abierto, con las [reglas de WhatsApp](/docs/reglas-de-whatsapp/): ritmo por número, ventana de 24 horas y antibloqueo. | 8 | | `communities:read` | Consultar campañas, grupos, participantes, envíos y estadísticas. | Abierto. | 48 | | `communities:write` | Escritura sin egress a WhatsApp. | En v0 solo administra las suscripciones propias de communities, que no llegan por la URL base: las suscripciones se administran con `webhooks:write`. | 5 | | `communities:send` | Envíos a grupos que la cuenta ya administra. | Denegado a claves en v0. | 0 | | `communities:operate` | Acciones sobre la sesión de WhatsApp (sincronizar, importar, renovar link, reintentar). | Denegado a claves en v0. | 0 | | `accounts:read` | Leer la cuenta, sus números (devices), alertas y dominios propios. | Según la especificación de accounts. | 31 | | `accounts:write` | Modificar de forma reversible. | Según la especificación de accounts. | 25 | | `accounts:operate` | Las operaciones irreversibles de accounts (reset, limpieza, purga de cola y borrados). | Según la especificación de accounts; no figura en D10. | 5 | | `webhooks:read` | Leer las suscripciones de webhooks y sus entregas. | Abierto. Además, la clave necesita el permiso de lectura del área de cada evento de la suscripción. | 3 | | `webhooks:write` | Crear, modificar, probar y borrar suscripciones de webhooks. Incluye `webhooks:read`. | Abierto. Para elegir un evento, la clave necesita además el permiso de lectura de su área (`conversations:read`, `communities:read` o `accounts:read`). | 4 | | `whalink:read` | Leer links de wha-link. | wha-link no forma parte de este portal todavía. | — | | `whalink:write` | Escribir en wha-link. | wha-link no forma parte de este portal todavía. | — | La última columna cuenta las operaciones de cada permiso en las instantáneas de hoy; en la [referencia](/docs/referencia/) cada operación muestra el suyo. > [!NOTE] Los nombres pueden cambiar > Los permisos de v0 usan los nombres de D10 y se van a unificar con los roles de workspaces (Q20). El contrato llama `whalink` al dominio de links; la pantalla de claves puede mostrarlo como `wha-link`. ## Qué permisos tiene tu clave En v0, una clave no puede consultar quién es ni qué permisos tiene. Es una limitación conocida: - `GET /api/v1/me` (accounts) pide `accounts:read`. Con una clave sin ese permiso responde `403 permission_denied`, y con el permiso, según su especificación, devuelve los datos de la cuenta, no los permisos de la clave. - Las rutas de claves (`/api/v1/me/api-keys`) responden `403 route_not_available` a cualquier clave. **Qué hacer:** guardá junto a la clave la lista de permisos que elegiste al crearla. Si una llamada responde `403 permission_denied`, `message` dice cuál falta, por ejemplo: «Esta API key no tiene el permiso `accounts:read`.» ## Qué está abierto a claves La API se abre por radio de daño, no por dominio ni por ruta. _(D21)_ | Superficie | Qué incluye | Daño posible por llamada | Estado | |---|---|---|---| | A | Lectura y escritura sin envío a WhatsApp: contactos, etiquetas, campos, tableros, notas, tickets, listas, carpetas | Datos basura en la cuenta | **Abierta** (etapa 1) | | B | Envío 1:1 | Un mensaje | **Abierta**, con el permiso `conversations:send` y las [reglas de WhatsApp](/docs/reglas-de-whatsapp/) (ventana de 24 horas, frenos contra spam y ritmo por número) _(Q19)_ | | C | Envío amplificado: masivos, arrancar flujos, disparadores, etiqueta que dispara un flujo | N mensajes, sin tope en el pedido | Fuera del piloto | **El envío 1:1 pide `conversations:send`**, un permiso aparte de `conversations:write`: elegilo al crear la clave solo si tu integración manda mensajes. **Ninguna clave habilita la superficie amplificada**, donde una llamada produce N mensajes. En conversations son estas diez rutas, y responden `403 route_not_available` con cualquier permiso: _(C1)_ - `broadcasts/{id}/execute` y sus dos reintentos - `contacts/{contact}/start-flow` - `flows/triggers/{uuid}/listen` - `contacts/add-tags` y `contacts/remove-tags` - `contacts/tags/bulk-operation` - `contacts-sync` - `contacts/import/analyze` `write` no habilita envío, ni siquiera por adentro: agregarle una etiqueta a un contacto puede disparar un flujo que manda mensajes, y por eso esas rutas quedan afuera. **En communities**, en v0, una clave lee: Escribir solo administra las suscripciones propias de communities, que no llegan por la URL base (las suscripciones se administran con `webhooks:write`), y Enviar y Operar no habilitan ninguna operación. La especificación de Communities (instantánea 6f32f0b4 (árbol con cambios sin commitear)) ya no dice que las claves estén pendientes. Confirmalo con una llamada real antes de depender de eso. _(enmienda C1 del 04-10)_ **Webhooks:** `webhooks:read` y `webhooks:write` administran las suscripciones de `/api/v1/webhooks` y no se implican con los de `accounts`. Para elegir un evento, la clave necesita además el permiso de lectura de su área; ver [Webhooks](/docs/webhooks/). ## Errores Cuando el portero o la entrada de la API rechazan una llamada, el cuerpo trae `error` (un código estable) y `message` (para personas): ```json { "error": "permission_denied", "message": "Esta API key no tiene el permiso `conversations:send`." } ``` | Código | `error` | Qué pasó | Qué hacer | |---|---|---|---| | `401` | `unauthenticated` | Falta la clave, no existe o fue revocada. Según el contrato de la URL única es igual en las tres áreas. De las instantáneas de hoy, solo la de communities lo documenta, con la cabecera `WWW-Authenticate: Bearer`; las de conversations y accounts declaran para el `401` un cuerpo con solo `message`. **Pendiente de confirmar** en conversations y accounts. | Revisá la cabecera `Authorization` y que la clave siga activa. | | `403` | `permission_denied` | La clave no tiene el permiso que pide la ruta: «Esta API key no tiene el permiso `…`». | Creá una clave con ese permiso. | | `403` | `route_not_available` | La ruta no está disponible para claves en el piloto: superficie amplificada (por ejemplo `contacts/add-tags`) o una ruta que no figura en la referencia. | No hay permiso que pedir: ninguna clave la habilita. | | `403` | — (solo `message`) | Lo rechazó la autorización propia del dominio, no el portero. | Leé `message`. | | `404` | `route_not_found` | La ruta no existe en la API pública: se contesta sin llegar a ninguna área. | Revisá la ruta y que empiece con `/api/v1/`. | | `422` | — | Error de validación: `message` y `errors`, con la lista de problemas por campo. | Corregí los campos indicados. | | `429` | `daily_cap_exceeded` | Se superó el tope diario de llamadas de accounts o de communities (10.000 por día por clave en cada una). conversations no tiene tope diario. Trae `Retry-After` con los segundos que faltan para que se reinicie. | Esperá lo que indica `Retry-After`. | | `429` | `rate_limited` | Un envío superó el ritmo del número por el que sale: 60 mensajes por segundo en un número oficial, 15 en coexistencia y sin límite por QR; además, 10 por minuto al mismo contacto en lo que sale por la Cloud API. Se cuenta por número, no por clave. No lo devuelve el portero sino las [reglas de WhatsApp](/docs/reglas-de-whatsapp/#ritmo). Trae `Retry-After`. | Esperá lo que indica `Retry-After` y reintentá. | | `429` | — | Límite por minuto de una ruta concreta. | Bajá el ritmo y reintentá. | | `500` | `edge_misconfigured` | La entrada de la API no está configurada para llegar al área de la ruta y no reenvía la llamada. Es un problema de nuestro lado. | Reportalo citando el `X-Request-Id`. | | `502` | `origin_unreachable` | La entrada de la API no pudo comunicarse con el área que atiende la ruta. | Reintentá con espera, cada vez más larga. Si sigue, reportalo citando el `X-Request-Id`. | | `503` | `gate_unavailable` | El portero no pudo evaluar la llamada y, por seguridad, no la deja pasar. Trae `Retry-After`. | Reintentá en unos segundos. _(C6 P7)_ | `503 gate_unavailable` y `403 permission_denied` son distintos a propósito: el primero es «no pude evaluar, reintentá»; el segundo, «evalué y no». `500 edge_misconfigured` y `502 origin_unreachable` los responde la entrada de la API, antes de llegar al área, con el mismo `message`: «La API no está disponible en este momento.». No traen `Retry-After`. Ante un `502` no hay garantía de que el área no haya recibido la llamada: si era una escritura, comprobá que no se aplicó antes de repetirla. - **Cada respuesta trae `X-Request-Id`**, en éxito y en error, incluidos todos los de esta tabla. Es un UUID distinto para cada pedido. Al reportar un problema, citalo junto con la hora aproximada de la llamada: con ese id soporte encuentra la llamada. No se puede consultar por el id desde la API. Ver [El id del pedido](/docs/primeros-pasos/#request-id). _(1019)_ - **Decidí por el código HTTP y por `error`, no por `message`.** `message` es para personas: su texto y su idioma cambian según el dominio. - **Esperá un `403` en cualquier ruta.** Las especificaciones de los dominios no lo documentan en todas, y donde lo documentan el esquema trae solo `message`, sin `error` ([conocido en v0](/docs/#conocido)). # Mensajes Todo lo que podés mandar y recibir en una conversación 1:1, con la misma llamada para un número oficial de Meta y para uno por QR. | Pieza | Estado | |---|---| | Recibir mensajes (`message.received`) y leer el historial | **Disponible** | | Enviar texto, archivos, botones, listas, plantillas, formularios de WhatsApp, tarjetas de contacto, reacciones y reenvíos | **Disponible**, con el permiso `conversations:send` y las [reglas de WhatsApp](/docs/reglas-de-whatsapp/) | | Saber qué pasó con cada envío (`message.status_changed` y la consulta del mensaje) | **Disponible**: [Estados de mensaje](/docs/piezas/estados-de-mensaje/) | | Ubicación, nota de voz, encuestas e invitaciones a eventos | Todavía no hay una ruta para mandarlos | ## Qué podés mandar | Tipo | Cómo | En `capabilities.messages` | |---|---|---| | Texto | Enviar un mensaje a un contacto (`POST /api/v1/contacts/{contact}/message`), con `text` | `text` | | Imagen, video, documento, audio o sticker | La misma ruta, con `file`: la URL pública del archivo ([abajo](#archivo)) | `media` | | Botones o lista | La misma ruta, con `options` (de 1 a 10, cada una con `id` y `label`) | `buttons`, `lists` | | Plantilla aprobada | Enviar una plantilla (`POST /api/v1/devices/{device}/templates/{template}/send-template`) | `templates` | | Formulario de WhatsApp | Enviar un formulario (`POST /api/v1/contacts/{contact}/message/form`), solo por un número de WhatsApp Cloud | — | | Tarjeta de contacto | Enviar una tarjeta de contacto (`POST /api/v1/contacts/{contact}/message/contact-card`) | `contact_card` | | Reacción | Reaccionar a un mensaje (`PUT /api/v1/contacts/{contact}/messages/{message}/reaction`) | `reactions` | | Reenvío | Reenviar un mensaje (`POST /api/v1/contacts/{contact}/messages/{message}/forward`) | — | **Qué puede cada número lo dice su ficha.** Listar números (`GET /api/v1/devices`) trae en cada uno `capabilities.messages`, con un valor verdadero o falso por tipo: miralo antes de mandar en lugar de suponerlo por el tipo de número ([Números](/docs/piezas/numeros/#que-puede)). La ficha también tiene `location`, `voice_note`, `polls` y `events`, pero hoy la API no tiene una ruta para mandarlos. - **Todo envío pide `conversations:send`** y pasa por las [reglas de WhatsApp](/docs/reglas-de-whatsapp/): la ventana de 24 horas en los números de WhatsApp Cloud, los frenos contra spam y el [ritmo por número](/docs/reglas-de-whatsapp/#ritmo) (hasta 60 mensajes por segundo en un número de WhatsApp Cloud, 15 en coexistencia y sin límite por QR). - **Por la API solo se les escribe a personas:** si el contacto es un grupo, una lista de difusión o un canal, responde `422 recipient_unsupported`. - **Recomendamos el número oficial** ([por qué](/docs/reglas-de-whatsapp/#numero-oficial)). ### Un texto ```bash curl -X POST "https://api.funnelchat.com/api/v1/contacts/2001/message" \ -H "Authorization: Bearer |fc_live_..." \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ -d '{ "device_id": 3, "contact_id": 2001, "text": "¡Hola! Te paso la info…", "client_reference": "pedido-1001" }' ``` - `device_id` es el número desde el que sale (Listar números (`GET /api/v1/devices`)). `contact_id` va también en el cuerpo: la especificación lo pide ahí, además de en la ruta. - **Responde `200` con `message`**: el mensaje ya registrado, con su `id` y `status: "queued"`. La llamada no espera a WhatsApp: el mensaje sale enseguida, desde la cola. **En cola no es enviado**, y un mensaje `queued` no se reintenta: reenviarlo lo duplicaría. Qué pasó después te lo cuenta [Estados de mensaje](/docs/piezas/estados-de-mensaje/). - **`client_reference`** (opcional, de 1 a 64 caracteres visibles, sin espacios) es tu propia referencia del envío: vuelve en el historial del contacto y en `message.status_changed`. No es única ni secreta (la ven todas las claves y todas las suscripciones de la cuenta): relacionala siempre junto con `message_id` y `contact_id`, y no pongas datos personales. - Si una regla de WhatsApp frena el envío, responde `422` o `429` con su código y no se registra nada ([Reglas de WhatsApp](/docs/reglas-de-whatsapp/#errores)). - Un rechazo por el límite de contactos activos (MAC) es distinto: queda en el historial del contacto un mensaje con estado de error que lleva tu `client_reference`. Si tu cuenta está en bloqueo (Ver el consumo de MACs (`GET /api/v1/mac/status`) responde `state` = `enforcing`) y el contacto es inactivo (`is_mac_inactive` en `true`), el envío responde `429` con `mac_lockout_24h`. No trae `Retry-After`: vuelve a funcionar cuando el contacto deja de ser inactivo, al sumar capacidad o al empezar un ciclo nuevo con cupo. A los contactos que ya son activos en el ciclo les seguís escribiendo con normalidad. ### Un archivo por URL No subís el archivo a Funnelchat: en `file` mandás la URL pública del archivo y WhatsApp o el proveedor lo descarga desde ahí. ```bash curl -X POST "https://api.funnelchat.com/api/v1/contacts/2001/message" \ -H "Authorization: Bearer |fc_live_..." \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ -d '{ "device_id": 3, "contact_id": 2001, "file": "https://cdn.example.com/facturas/2026-10.pdf", "text": "Te paso la factura" }' ``` - **El tipo sale de la extensión** de la URL: `.jpg`, `.jpeg` y `.png` son imagen; `.webp`, sticker; `.ogg`, `.oga`, `.mp3`, `.m4a`, `.aac`, `.opus` y `.wav`, audio; `.gif` y `.mp4`, video; `.doc`, `.docx` y `.pdf`, documento. Si la URL no termina en una de esas (por ejemplo una URL firmada sin extensión), mandá `file_type` (`image`, `video`, `audio`, `document` o `sticker`); si no, responde `422`. Una extensión de otro formato, como `.xlsx`, sin `file_type` responde `415`. - **`file_name`** es el nombre con el que el contacto ve un documento, con su extensión (`factura.pdf`). Es obligatorio en un documento cuya URL no termina en una extensión de la lista. - **La URL tiene que durar al menos una hora:** un envío con clave puede esperar en cola hasta 54 minutos, y Funnelchat no la abre al recibir tu llamada. Un archivo inaccesible, vencido o fuera de los límites termina en un mensaje `failed` con el error del proveedor. - **No pongas secretos en la URL.** La URL completa, con su consulta, queda en el historial del contacto, la ven los usuarios de tu cuenta y se copia al reenviar. Usá URLs firmadas de corta duración (cerca de una hora) y para un solo archivo. - **Tamaños máximos de WhatsApp:** imagen 5 MB, video y audio 16 MB, documento 100 MB, sticker 100 KB (500 KB si es animado). Los formatos aceptados de cada tipo están en la página del endpoint. ### Una plantilla Fuera de la ventana de 24 horas, un número de WhatsApp Cloud solo puede mandar plantillas aprobadas por Meta. Elegí una en Listar plantillas (`GET /api/v1/templates`) (su `status` dice si está aprobada) y mandala con su `id` en la ruta: ```bash curl -X POST "https://api.funnelchat.com/api/v1/devices/3/templates/45/send-template" \ -H "Authorization: Bearer |fc_live_..." \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ -d '{ "phone": "5491100000000", "contact_id": 2001, "template_variables": { "body": ["Lucía"] }, "client_reference": "recordatorio-1001" }' ``` - Responde `200` con `message`, en cola, igual que un texto. - Si una variable se completa con un campo personalizado que no existe en tu cuenta, responde `422 template_unresolved_placeholder` con `placeholders` y `count`, y no manda nada. Si la plantilla no existe en tu cuenta, `404 template_not_found`. - Cuando Meta aprueba, rechaza o pausa una plantilla, te llega `template.status_changed` ([catálogo](/docs/webhooks/eventos/)). ### Un formulario de WhatsApp Enviar un formulario (`POST /api/v1/contacts/{contact}/message/form`) le manda al contacto un formulario ya publicado, con `form_id` y el número de WhatsApp Cloud por el que sale (`device_id`); `text` y `button_text` son opcionales. Solo funciona dentro de la ventana de 24 horas. Responde `200` con el mensaje en cola, y lo que el contacto contestó te llega en el evento `whatsapp_form.completed`, con `answers`. Sus errores propios (`form_not_found`, `form_not_published`, `form_not_on_number`, `number_not_eligible`…) están en la página del endpoint. Crear y publicar formularios todavía no se puede por la URL base: se hace desde Funnelchat. ## Qué recibís - **Cada mensaje entrante** llega a tu servidor como `message.received`, firmado: el mensaje (`id`, `type`, `text`, `media`), el contacto (`id`, `name`, `phone`), el teléfono del número que lo recibió (`receiver.phone`) y su `device_id`. Paso a paso: [Recibir mensajes](/docs/guias/recibir-mensajes/). - **El historial de un contacto** está en Listar los mensajes de un contacto (`GET /api/v1/contacts/{contactId}/messages`), con paginación por cursor y `updated_since` para traer solo lo nuevo. Paso a paso: [Leer conversaciones](/docs/guias/leer-conversaciones/). - **Un mensaje por su id**, con su estado y tu `client_reference`: Ver un mensaje (`GET /api/v1/contacts/{contact}/messages/{message}`). - **Qué pasó con cada mensaje que mandaste** (enviado, entregado, leído, falló): [Estados de mensaje](/docs/piezas/estados-de-mensaje/). ## Probalo acá > [!WARNING] Esto manda un mensaje real > Sale por WhatsApp desde tu número y pasa por las mismas reglas que tu código. En el portal, esta sección trae la prueba en vivo de `POST /api/v1/contacts/{contact}/message`: la misma de su página en la referencia, con tu clave. # Estados de mensaje Sabé qué pasó con cada mensaje que mandaste: los de tu clave y también los del chat, las automatizaciones y los asistentes de IA. Hay dos caminos, que dan lo mismo: el aviso `message.status_changed`, que te llega solo, y Ver un mensaje (`GET /api/v1/contacts/{contact}/messages/{message}`), que consultás cuando querés. ## Los estados | `status` | Qué significa | |---|---| | `queued` | Registrado y en cola: está por salir. **En cola no es enviado.** Solo aparece al consultar el mensaje; no genera aviso. | | `sent` | El proveedor aceptó el mensaje. **Aceptado no es entregado.** | | `delivered` | Llegó al teléfono del contacto. | | `read` | El contacto lo abrió (si tiene activadas las confirmaciones de lectura). | | `failed` | No salió o no se pudo entregar. Viene con `error` ([abajo](#fallas)). | | `null` | El resultado no se conoce: el proveedor no confirmó ni rechazó el envío, o el envío se interrumpió. Puede haber llegado y no va a cambiar de estado. | Lo normal es `queued` → `sent` → `delivered` → `read`. **Cada estado se informa una sola vez por mensaje** y nunca uno que retroceda: si llegan varios avisos juntos se informa el más avanzado, y un aviso atrasado que no cambia el estado no genera evento. ## Cuando falla `error` trae `code`, `title` y `reason`, solo cuando `status` es `failed`: - **Lo rechazó el proveedor:** `code` es su código numérico y `title` su título (cualquiera de los dos puede ser `null`); `reason` es `null`. - **Lo frenó Funnelchat antes de que saliera** (solo en mensajes enviados con clave): `reason` trae el motivo, `code` es `null` y `title` es un texto llano en el idioma de la cuenta. No salió: reintentar es un intento nuevo y no lo duplica. | `error.reason` | Qué hacer | |---|---| | `device_unavailable` | Reconectá el número en Funnelchat y mandá de nuevo. | | `insufficient_balance` | Recargá tu billetera de WhatsApp. | | `window_closed` | Mandá una plantilla aprobada. | | `recipient_unreachable` | Revisá el teléfono del contacto. | | `template_not_found`, `template_unresolved_placeholder` | Corregí la plantilla o sus variables. | | `not_queued`, `not_sent` | Volvé a intentarlo. | **Más de una hora en `queued`:** un mensaje cuyo envío no empezó dentro de la hora ya no sale. Pasada la hora sin resultado, su `status` es `null`, o `failed` con `reason: "not_sent"` si su envío llega tarde (y entonces sí te llega su aviso). ## El aviso: `message.status_changed` ```json { "id": "evt_000000000000000000000014", "type": "message.status_changed", "version": 1, "occurred_at": "2026-10-01T12:00:00Z", "data": { "message_id": 1001, "contact_id": 2001, "device_id": 4521, "status": "failed", "previous_status": "sent", "error": { "code": 131047, "title": "Re-engagement message", "reason": null }, "occurred_at": "2026-10-01T12:00:00Z", "client_reference": "pedido-1001" } } ``` | Campo | Qué es | |---|---| | `message_id` | El id del mensaje: el mismo `id` que devuelve el envío y el historial del contacto (`GET /api/v1/contacts/{contactId}/messages`). | | `contact_id`, `device_id` | El contacto y el número por el que salió (el mismo `device_id` con el que mandás). | | `status` | `sent`, `delivered`, `read` o `failed`. | | `previous_status` | El estado anterior. Es `null` si es el primer aviso del mensaje, si repite el estado que ya tenía o si no se pudo leer. | | `error` | Solo cuando `status` es `failed`: `code`, `title` y `reason` ([arriba](#fallas)). | | `occurred_at` | Cuándo registró Funnelchat el estado, en UTC. | | `client_reference` | La referencia que mandaste al enviar, o `null`. Sirve para relacionar el aviso con tu envío aunque la llamada de envío no haya respondido. | - **Qué mensajes lo emiten:** los que se mandan a un contacto desde el chat, una automatización o un asistente de IA, incluidos los de tu clave. **No se emite** para mensajes de difusiones ni para mensajes recibidos. - **Un mensaje que no llegó a salir no tiene avisos**, salvo uno enviado con clave: ese recibe un único aviso `failed`, con `previous_status` en `null`, su `error.reason` y su `client_reference`. Un mensaje con resultado desconocido (`status` `null`) no recibe aviso. - **En cuentas con mucho tráfico** se descartan los eventos que superan el límite de entrega por minuto de la cuenta. Si necesitás el estado de un mensaje concreto, consultalo. - **El `id` del evento es el mismo en todos los reintentos de una entrega**: si te llega dos veces, descartá el repetido por `id`. ## Cómo suscribirte Igual que a cualquier evento, con una suscripción que tenga tu URL y `message.status_changed` en `events` ([Webhooks](/docs/webhooks/)). **Pedilo de forma explícita**: es el único evento de mensajes que hay que nombrar siempre. Para elegirlo, la clave necesita `webhooks:write` y además `conversations:read`, el permiso de lectura de su área. El paso a paso está en [Armá tu propio flujo](/docs/armar-tu-flujo/#suscribirte). ## El estado en el historial El historial de un contacto trae el estado de cada mensaje en `status`, pero la especificación lo publica como un número, sin el nombre de cada valor. Para el estado con nombre, consultá Ver un mensaje (`GET /api/v1/contacts/{contact}/messages/{message}`) con el `id`, o usá el aviso. ## Probalo acá El historial de un contacto, con tu clave. Es una lectura: no manda nada. En el portal, esta sección trae la prueba en vivo de `GET /api/v1/contacts/{contactId}/messages`: la misma de su página en la referencia, con tu clave. # Grupos Tu propia gestión de comunidades, sin pasar por nuestras campañas. | Pieza | Estado | |---|---| | Leer grupos, participantes, entradas y salidas y los mensajes enviados a un grupo | **Disponible** | | Avisos de lo que pasa adentro: mensajes, reacciones, grupo lleno y solicitudes para unirse | **Disponible** | ## Lo que podés leer hoy - Listar grupos (`GET /api/v1/groups`): los grupos de tu cuenta, con filtros por tipo, estado, número o sin campaña. - Listar los participantes de un grupo (`GET /api/v1/groups/participants`) y sus entradas y salidas (`GET /api/v1/groups/{group}/participant-events`). - Los mensajes enviados a un grupo (`GET /api/v1/groups/{group}/messages`). Los avisos —`group_participant.joined`, `group_participant.left`, `group.message_received`, `poll.voted`, entre otros— están en el [catálogo de eventos](/docs/webhooks/eventos/) y se suscriben, como todos, en `/api/v1/webhooks` ([abajo](#avisos)). Paso a paso: [Seguir grupos](/docs/guias/seguir-grupos/). ## Avisos de lo que pasa adentro Además de las entradas y salidas, te llegan `group.message_received` (alguien escribió en el grupo), `group.reaction_added` (alguien reaccionó a un mensaje), `group.full` (el grupo se llenó) y `group_join_request.created` y `group_join_request.revoked` (alguien pidió unirse a un grupo que pide aprobación, o retiró el pedido). Cada uno trae el grupo (`uid`, `name`) y, cuando corresponde, la persona (`identity`, `identity_type`, `name`). Se suscriben en `/api/v1/webhooks`, con `webhooks:write` y además `communities:read`, el permiso de lectura de su área ([Webhooks](/docs/webhooks/)). Paso a paso: [Seguir grupos](/docs/guias/seguir-grupos/). ## Probalo acá Los grupos de tu cuenta, con tu clave. Es una lectura: no cambia nada. En el portal, esta sección trae la prueba en vivo de `GET /api/v1/groups`: la misma de su página en la referencia, con tu clave. # Números Tus números de WhatsApp, oficiales de Meta o por QR, con la misma API. **Recomendamos el oficial** ([por qué](/docs/reglas-de-whatsapp/#numero-oficial)). | Pieza | Estado | |---|---| | Listar tus números, ver uno, su estado de conexión y su salud | **Disponible** | | Conectar por QR, reiniciar o limpiar la sesión, eliminar un número | **Disponible** | | Qué puede hacer cada número (`capabilities`) | **Disponible** | ## Los dos tipos de número | | Número oficial de Meta | Número por QR | |---|---|---| | Cómo se conecta | Una vez, desde el panel de Funnelchat, con la cuenta de WhatsApp Business de Meta | Escaneando un código QR, como un WhatsApp Web | | Ventana de 24 horas | Sí: fuera de la ventana, solo plantillas ([reglas](/docs/reglas-de-whatsapp/#ventana)) | No tiene | | Plantillas | Sí | No | | Grupos | En beta: invita a la gente | Agrega a la gente directo | La llamada es la misma para los dos. Qué puede hacer cada número en concreto lo dice su ficha: [abajo](#que-puede). ## Lo que podés hacer hoy - Listar números (`GET /api/v1/devices`): tus números con su proveedor (`provider`), si están conectados y activos, su salud y qué puede hacer cada uno (`capabilities`). Te da el `device_id` que piden las demás llamadas, por ejemplo para enviar. `is_coexistence` dice si el número está en coexistencia (el mismo teléfono en WhatsApp Cloud y en la app WhatsApp Business del celular). - Ver el estado de conexión (`GET /api/v1/devices/{device}/status`) y la salud de un número (`GET /api/v1/devices/{device}/health`). - Reiniciar la sesión (`PUT /api/v1/devices/{device}/reset`) o limpiarla (`POST /api/v1/devices/{device}/clean`): después hay que volver a escanear el QR. Son irreversibles y piden `accounts:operate`. - Eliminar un número (`DELETE /api/v1/devices/{device}`). Cuando un número se conecta o se desconecta, te llegan `device.connected` y `device.disconnected` ([catálogo](/docs/webhooks/eventos/)). Un número oficial todavía no emite `device.disconnected`. **El número oficial se conecta una vez desde el panel de Funnelchat.** ## Qué puede hacer cada número La ficha de cada número (Listar números (`GET /api/v1/devices`) y Ver un número (`GET /api/v1/devices/{device}`)) trae `capabilities`: lo que la API puede hacer con ese número, según su proveedor. Miralo antes de mandar, en lugar de suponerlo por el tipo de número. ```json { "capabilities": { "messages": { "text": true, "media": true, "buttons": true, "lists": true, "templates": true, "location": false, "voice_note": false, "polls": false, "events": false, "contact_card": true, "reactions": true }, "groups": { "create": true, "add_participants": "invite", "remove_participants": true, "admins": false, "photo": false, "settings": false, "invite_link": true, "send": true, "join_requests": "list_and_decide" }, "statuses": ["sent", "delivered", "read", "failed"] } } ``` - `messages`: un verdadero o falso por tipo de mensaje. `location`, `voice_note`, `polls` y `events` figuran, pero hoy la API no tiene una ruta para mandarlos ([Mensajes](/docs/piezas/mensajes/#enviar)). - `groups`: qué puede hacer con sus grupos. `add_participants` vale `"direct"` (agrega a la gente directo) o `"invite"` (la invita); `join_requests`, `"list"` (ve las solicitudes para unirse) o `"list_and_decide"` (además, las aprueba o rechaza). - `statuses`: los estados de mensaje que informa ([Estados de mensaje](/docs/piezas/estados-de-mensaje/)). Los valores del ejemplo son ilustrativos: los de cada número salen de su ficha. ## Probalo acá Tus números, con tu clave. Es una lectura: no cambia nada. En el portal, esta sección trae la prueba en vivo de `GET /api/v1/devices`: la misma de su página en la referencia, con tu clave. # Contactos y datos Contactos, etiquetas, campos personalizados y notas, para guardar lo que tu flujo necesita sin armar otra base. Todo **disponible** con `conversations:read` (leer) y `conversations:write` (escribir). | Pieza | Para qué | |---|---| | Contactos | Listar (`GET /api/v1/contacts`), ver (`GET /api/v1/contacts/{contact}`), crear (`POST /api/v1/contacts`) o actualizar (`PUT /api/v1/contacts/{contact}`) un contacto, o crearlo si no existe (`POST /api/v1/contacts/update-or-create`). | | Campos personalizados | El estado de tu flujo, guardado en el contacto: crear un campo (`POST /api/v1/custom-fields`) y asignarle un valor a un contacto (`POST /api/v1/contacts/attach-custom-field`). | | Etiquetas | Crear (`POST /api/v1/tags`) y listar (`GET /api/v1/tags`) etiquetas, y ver los contactos de una (`GET /api/v1/tags/{tag}/contacts`). | | Notas | Agregarle una nota a un contacto (`POST /api/v1/contacts/{contact}/notes`) y leerlas (`GET /api/v1/contacts/{contact}/notes`). | - **Ponerle o quitarle una etiqueta a un contacto no está abierto a claves** (`403 route_not_available`): una etiqueta puede disparar un flow que manda mensajes. Para marcar a un contacto desde tu código, usá un campo personalizado. Ver [Qué está abierto a claves](/docs/autenticacion/#etapa-1). - **Te enterás de los cambios** con `contact.created`, `contact.tag_added`, `contact.tag_removed` y `contact.stage_changed` ([catálogo](/docs/webhooks/eventos/)). - **El teléfono de un contacto viene sin `+`** en la API y con `+` en los eventos ([detalle](/docs/webhooks/#telefonos)). Paso a paso: [Sincronizar contactos](/docs/guias/sincronizar-contactos/). ## Probalo acá Tus contactos, con tu clave. Es una lectura: no cambia nada. En el portal, esta sección trae la prueba en vivo de `GET /api/v1/contacts`: la misma de su página en la referencia, con tu clave. # Cómo funcionan Los webhooks avisan a tu sistema, con un `POST` firmado, cuando algo pasa en tu cuenta. Registrás una URL `https`, elegís los tipos de evento y listo: no hace falta consultar la API en bucle. ## Dónde te suscribís **Hay un solo sistema de suscripciones, en `/api/v1/webhooks`.** Cada suscripción tiene una URL, un secreto y un historial de entregas, y puede recibir eventos de las tres áreas: mensajes, conversaciones y contactos (conversations); grupos, participantes, envíos programados y campañas (communities); y números y cuenta (accounts). El sobre, las cabeceras y la firma son los mismos para todos los eventos. Según su especificación (instantánea 300cc8435), la atiende accounts. Las rutas de la tabla son completas: la URL es la URL base + la ruta ([cómo armar la URL](/docs/#url)). | Ruta | Para qué | Permiso | |---|---|---| | `GET /api/v1/webhooks` | Listar webhooks | `webhooks:read` | | `POST /api/v1/webhooks` | Crear un webhook | `webhooks:write` | | `GET /api/v1/webhooks/events` | Listar tipos de evento | `accounts:read` | | `GET /api/v1/webhooks/{id}` | Ver un webhook | `webhooks:read` | | `PUT /api/v1/webhooks/{id}` | Actualizar un webhook | `webhooks:write` | | `DELETE /api/v1/webhooks/{id}` | Eliminar un webhook | `webhooks:write` | | `GET /api/v1/webhooks/{id}/deliveries` | Listar entregas de un webhook | `webhooks:read` | | `POST /api/v1/webhooks/{id}/test` | Enviar un evento de prueba | `webhooks:write` | Generada de la instantánea de Accounts, el área a la que la URL base manda `/api/v1/webhooks`. Rutas completas: la URL es la URL base + la ruta, sin agregar nada. **Permisos.** Listar, ver y leer el historial de entregas piden `webhooks:read`; crear, editar, borrar y probar, `webhooks:write`, que incluye `webhooks:read`. `webhooks:*` y `accounts:*` no se incluyen entre sí. Además, con una clave, cada tipo de evento pide el permiso de lectura de su área (`conversations:read`, `communities:read` o `accounts:read`; el de escritura o envío de esa misma área también sirve): - Al crear o editar, si a la clave le falta el permiso del área de algún tipo de `events`, la respuesta es `422`. - Para ver, editar, borrar, probar o leer las entregas de una suscripción, la clave necesita el permiso de lectura del área de cada tipo que la suscripción tiene ahora; si le falta alguno, responde `403` con `error: "permission_denied"`, el mensaje nombra el permiso que falta y no se cambia, borra ni envía nada. - `GET /api/v1/webhooks` lista solo las suscripciones que la clave puede leer. El catálogo (`GET /api/v1/webhooks/events`) pide `accounts:read`. La clave tiene que ser del titular de la cuenta. > [!NOTE] El sistema anterior de accounts > `/api/v1/webhooks/endpoints…` es el sistema anterior: sigue funcionando con los permisos `accounts:*`, pero solo para los eventos de la cuenta y sus números. Para una integración nueva usá `/api/v1/webhooks`. Ver [El sistema anterior de accounts](#accounts). ## Alta de una suscripción ```json { "url": "https://tu-servidor.example/hooks/funnelchat", "description": "Mi integración", "events": ["message.received"] } ``` - `url`: la dirección pública de tu receptor, hasta 2048 caracteres. Usá `https`. Una dirección interna no recibe entregas (la prueba responde `destination_not_allowed`). - `events`: de 1 a 50 tipos del [catálogo](/docs/webhooks/eventos/), sin repetir, de cualquiera de las tres áreas. No hay un comodín para «todos»: una suscripción recibe solo los tipos que eligió. - `description` (opcional, hasta 160 caracteres) te ayuda a reconocerla en la lista. - `filters` (opcional) admite `campaign_ids`; la especificación no describe más su efecto. - La respuesta (`201`) es la suscripción, sin envoltorio `data`: `id`, `url`, `description`, `events`, `filters`, `status`, `disabled_reason`, las fechas y, **solo esta vez**, `secret` (con un aviso en `secret_notice`). Ninguna lectura posterior lo vuelve a mostrar. - **El secreto se rota desde el panel** de Funnelchat, no con la clave: no hay una ruta para rotarlo. Lo mismo para «Activar» una suscripción migrada desde el sistema anterior. ## Tu suscripción y las de otros Las suscripciones son de la cuenta, no de la integración: `GET /api/v1/webhooks` muestra todas las de la cuenta que tu clave puede leer, también las que crearon otras integraciones o personas. - **Guardá el `id` al crearla.** Es lo que identifica la tuya. `description` ayuda a reconocerla en la lista, pero no es única. - **Para cambiar la URL, usá `PUT /api/v1/webhooks/{id}`**, por ejemplo cuando cambia tu túnel. `PUT` reemplaza `url`, `events`, `description` y `filters` (mandá siempre `url` y `events`); el secreto no cambia y no se acumulan suscripciones. Si en cambio creás otra, recibís otro secreto y la anterior sigue recibiendo eventos. - **Pausala en lugar de borrarla** si querés dejar de recibir por un tiempo: `PUT` con `status: "paused"`, y `"active"` para volver. No se puede dejar en `disabled` a mano. - **No borres ni modifiques las que no reconocés** sin confirmarlo: pueden ser de otra integración de la misma cuenta. Borrar una suscripción borra también su historial de entregas. - Una lectura (`GET /api/v1/webhooks/{id}`) no trae el secreto. Si lo perdiste, rotalo desde el panel. **Estados.** `status` es `active`, `paused` o `disabled`; `disabled_reason` dice por qué quedó deshabilitada: - **La clave se revocó.** Una suscripción queda atada a la clave que la creó o a la última clave que cambió su `url`, aunque la haya creado el panel u otra clave. Si esa clave se revoca, deja de recibir eventos de inmediato y pasa a `disabled` con `disabled_reason: "key_revoked"`. No se puede volver a habilitar: creá una nueva. Una suscripción creada desde el panel y que ninguna clave redirigió es de la cuenta: revocar claves no la afecta. - **Fallas seguidas.** Una suscripción que acumula 5 entregas fallidas consecutivas (agotadas o con una respuesta que no se reintenta) se deshabilita sola. Arreglá tu receptor y reactivala con `PUT` y `status: "active"`. ## Qué vas a recibir Cada entrega es un `POST` con un cuerpo JSON con esta forma: | Campo | Qué es | |---|---| | `id` | Identificador del evento (`evt_…`). Es el mismo en todos los reintentos: usalo para deduplicar. | | `type` | Tipo de evento, por ejemplo `message.received`. | | `version` | Versión del formato de `data` (hoy `1`). | | `occurred_at` | Cuándo ocurrió, en UTC (ISO 8601). | | `data` | Los datos del evento. Depende del tipo: ver el [catálogo](/docs/webhooks/eventos/). | Ningún payload lleva identificadores internos de cuenta (`user_id`, `account_id`, `owner_id`). _(C3)_ | Cabecera | Contenido | |---|---| | `Content-Type` | `application/json` | | `X-Funnelchat-Signature` | `t=,v1=`: la firma de esta entrega. | | `X-Funnelchat-Event` | El tipo de evento (igual a `type`). | | `X-Funnelchat-Delivery` | Identificador de este intento de entrega; cambia en cada reintento. | Solo el cuerpo está firmado. `X-Funnelchat-Event` y `X-Funnelchat-Delivery` no: tomá `type` e `id` del cuerpo ya verificado. ## Verificar la firma ```text X-Funnelchat-Signature: t=,v1=.")> ``` 1. Tomá el **cuerpo crudo**, tal como llegó. No lo parsees y vuelvas a serializar: un espacio de diferencia cambia la firma. 2. Armá el texto `t + "." + cuerpo`. 3. Calculá su HMAC-SHA256 en hexadecimal con el **secreto completo** de la suscripción, tal como se mostró (si empieza con `whsec_`, el prefijo es parte del secreto). 4. Comparalo con `v1` en tiempo constante. 5. Rechazá la entrega si `t` se aleja más de 300 segundos del reloj de tu servidor. Estos snippets son los mismos archivos que prueba `tests/test_firmas.py`: firma un cuerpo de ejemplo y comprueba que cada uno acepta la firma correcta y rechaza las alteradas. **Node.js** (`snippets/verificar-firma.js`): ```js // Verifica la cabecera X-Funnelchat-Signature de una entrega de webhook (Node.js 16+). // cuerpoCrudo: el cuerpo tal como llegó (Buffer o string), sin parsear ni volver a serializar. // secreto: el secreto completo de la suscripción, tal como se mostró al crearla o rotarla. const crypto = require('node:crypto'); function verificarFirma(cuerpoCrudo, cabecera, secreto, toleranciaSegundos = 300) { if (typeof cabecera !== 'string' || typeof secreto !== 'string' || secreto === '') return false; let t = null; const firmas = []; for (const parte of cabecera.split(',')) { const i = parte.indexOf('='); if (i === -1) continue; const clave = parte.slice(0, i).trim(); const valor = parte.slice(i + 1).trim(); if (clave === 't') t = valor; if (clave === 'v1') firmas.push(valor); } if (t === null || !/^\d+$/.test(t) || firmas.length === 0) return false; // Rechazá entregas viejas o del futuro (protección contra repeticiones). if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > toleranciaSegundos) return false; const esperada = crypto .createHmac('sha256', secreto) .update(`${t}.`) .update(cuerpoCrudo) .digest('hex'); const a = Buffer.from(esperada, 'utf8'); return firmas.some((v1) => { const b = Buffer.from(v1, 'utf8'); return a.length === b.length && crypto.timingSafeEqual(a, b); }); } module.exports = { verificarFirma }; // Uso con Express: leé el cuerpo crudo, verificá, y recién después parseá. // app.post('/hooks/funnelchat', express.raw({ type: 'application/json' }), (req, res) => { // if (!verificarFirma(req.body, req.get('X-Funnelchat-Signature'), process.env.FUNNELCHAT_WEBHOOK_SECRET)) { // return res.sendStatus(400); // } // const evento = JSON.parse(req.body.toString('utf8')); // tomá type e id de acá, no de las cabeceras // res.sendStatus(200); // respondé rápido; procesá después // }); ``` **Python** (`snippets/verificar_firma.py`): ```python # Verifica la cabecera X-Funnelchat-Signature de una entrega de webhook (Python 3.8+). # cuerpo_crudo: los bytes del cuerpo tal como llegaron, sin parsear ni volver a serializar. # secreto: el secreto completo de la suscripción, tal como se mostró al crearla o rotarla. import hashlib import hmac import time def verificar_firma(cuerpo_crudo: bytes, cabecera: str, secreto: str, tolerancia_segundos: int = 300) -> bool: if not isinstance(cabecera, str) or not isinstance(secreto, str) or not secreto: return False t = None firmas = [] for parte in cabecera.split(","): clave, separador, valor = parte.partition("=") if not separador: continue clave, valor = clave.strip(), valor.strip() if clave == "t": t = valor elif clave == "v1": firmas.append(valor) if t is None or not t.isdigit() or not firmas: return False # Rechazá entregas viejas o del futuro (protección contra repeticiones). if abs(int(time.time()) - int(t)) > tolerancia_segundos: return False esperada = hmac.new(secreto.encode("utf-8"), t.encode("ascii") + b"." + cuerpo_crudo, hashlib.sha256).hexdigest() return any(hmac.compare_digest(esperada, v1) for v1 in firmas) # Uso con Flask: leé el cuerpo crudo, verificá, y recién después parseá. # @app.post("/hooks/funnelchat") # def recibir(): # cuerpo = request.get_data() # bytes crudos # if not verificar_firma(cuerpo, request.headers.get("X-Funnelchat-Signature", ""), os.environ["FUNNELCHAT_WEBHOOK_SECRET"]): # return "", 400 # evento = json.loads(cuerpo) # tomá type e id de acá, no de las cabeceras # return "", 200 # respondé rápido; procesá después ``` **PHP** (`snippets/verificar-firma.php`): ```php $toleranciaSegundos) { return false; } $esperada = hash_hmac('sha256', $t.'.'.$cuerpoCrudo, $secreto); foreach ($firmas as $v1) { if (hash_equals($esperada, $v1)) { return true; } } return false; } // Uso: // $cuerpo = file_get_contents('php://input'); // if (! verificarFirma($cuerpo, $_SERVER['HTTP_X_FUNNELCHAT_SIGNATURE'] ?? '', (string) getenv('FUNNELCHAT_WEBHOOK_SECRET'))) { // http_response_code(400); // exit; // } // $evento = json_decode($cuerpo, true); // tomá type e id de acá, no de las cabeceras // http_response_code(200); // respondé rápido; procesá después ``` Último resultado de `tests/test_firmas.py` (2026-10-22 20:53 (+00:00)): pasó — Python 3.12.15, Node v24.21.0, PHP 8.3.6. ## Reintentos - Una entrega es exitosa si tu servidor responde con cualquier `2xx`. - Hay hasta **4 intentos** por evento y suscripción. Entre uno y el siguiente se espera **60 s, 5 min y 30 min**. - Se reintenta ante un error de red, un `5xx` o un `429`. Un `3xx` o cualquier otro `4xx` es definitivo: no se reintenta. - 5 entregas fallidas seguidas deshabilitan la suscripción ([estados](#tus-suscripciones)). `GET /api/v1/webhooks/{id}/deliveries` devuelve un registro por intento, lo más reciente primero. Pagina igual que el resto de la API: `page` y `perPage` (25 por defecto, 100 como máximo; también vale `per_page`). Cada ítem trae: | Campo | Qué es | |---|---| | `id` | El intento. | | `event_id` | El `id` del sobre: agrupa los intentos de un mismo evento y es el mismo valor que vio tu receptor. | | `event_type` | Tipo de evento. | | `attempt` | Número de intento. | | `url` | A dónde se envió. | | `response_status` | El código HTTP que respondió tu servidor, o `null` si no hubo respuesta. | | `succeeded` | `true` si respondió `2xx`. | | `error` | Por qué falló, o `null`. | | `duration_ms` | Cuánto tardó. | | `attempted_at` | Cuándo se intentó. | Con una clave, el historial muestra solo las entregas de tipos cuya área la clave puede leer; las de prueba (`webhook.test`) se muestran siempre. **Fechas:** todas son ISO 8601 en UTC, pero el sufijo puede cambiar, incluso dentro de un mismo evento: `occurred_at` viene con `Z` (`2026-10-05T03:00:00Z`) y otras fechas de `data` con `+00:00` (`2026-10-05T03:00:00+00:00`). Leelas con un parser ISO 8601, no comparando texto. _(E40)_ Si tenés varias suscripciones de la cuenta al mismo tipo de evento, **cada una recibe su propia entrega**, firmada con su propio secreto y con sus propios reintentos. El `id` del evento es el mismo en todas. ## Qué hacer en tu receptor - **Respondé rápido.** Devolvé `2xx` apenas verificaste la firma y procesá el evento en segundo plano: una entrega que no recibe respuesta cuenta como fallo y se reintenta. - **Sé idempotente.** La entrega es «al menos una vez»: el mismo evento puede llegar más de una vez, por ejemplo si lo procesaste pero respondiste tarde. Guardá los `id` ya procesados y descartá los repetidos. - **Tolerancia de tiempo.** Rechazá entregas con un `t` a más de 300 segundos de tu reloj: protege contra repeticiones. Mantené el reloj del servidor sincronizado. - **Verificá antes de confiar.** Sin firma válida, respondé `400` y no hagas nada. Leé `type` e `id` del cuerpo verificado, no de las cabeceras. - **Ojo con los 4xx.** Un `4xx` distinto de `429` corta los reintentos: devolvelo solo cuando de verdad no querés ese evento. - **Tolerá valores nuevos.** Si un campo trae un valor que no conocés (por ejemplo, un `identity_type` distinto de `phone`), respondé `2xx` igual y no lo proceses: no es un error de la entrega. Ver el [catálogo](/docs/webhooks/eventos/#identity-type). ## El teléfono en los eventos y en la API Comportamiento de v0: lo que traen los eventos según los ejemplos de las especificaciones, y lo que hace la API de conversations, observado con llamadas reales. - **Los eventos traen el teléfono con `+`** y el código de país: `"+5491100000000"` en `contact.phone` de `message.received` y en `participant.identity` de los eventos de communities que traen un participante. - **La API guarda el teléfono sin `+`.** `POST /api/v1/contacts/update-or-create` con `"+5491100000099"` creó el contacto con `phone: "5491100000099"` (y `country_code` vacío); la misma llamada con `"5491100000099"` devolvió ese mismo contacto. - **La búsqueda no quita el `+`.** `GET /api/v1/contacts?phone=%2B5491100000099` (el `+` codificado, como lo manda cualquier cliente HTTP) responde vacío; `?phone=5491100000099` encuentra el contacto. - **La búsqueda por `phone` no es exacta:** también devuelve contactos cuyo teléfono contiene el número buscado (`?phone=1100000000` encontró `5491100000000`). **Qué hacer:** antes de buscar un contacto por el teléfono de un evento, quitale el `+` (`+5491100000000` → `5491100000000`) y, entre los resultados, quedate con el que tenga ese `phone` exacto. Para crear o actualizar, podés mandarlo con o sin `+`. ## Evento de prueba `POST /api/v1/webhooks/{id}/test` envía una única entrega de `webhook.test` a la URL de la suscripción, firmada igual que una real y sin reintentos, y la guarda en el historial. Sirve para probar tu receptor sin esperar un evento real. Funciona con suscripciones `active` o `paused`; una `disabled` responde `422` y no envía nada (si se deshabilitó por fallas, reactivala y probá después; si fue por `key_revoked`, creá una nueva). No se puede suscribir a `webhook.test`. La respuesta (`200`) dice cómo le fue: ```json { "succeeded": true, "response_status": 200, "error": null, "duration_ms": 184, "delivery_id": 9012 } ``` La prueba llegó si `succeeded` es `true`. Si no, `response_status` y `error` dicen por qué; `error` es uno de `non_2xx`, `timeout`, `connection_failed`, `tls_error`, `destination_not_allowed`, `secret_unreadable` o `internal_error`. `delivery_id` es su registro en el historial de entregas. ## El sistema anterior de accounts Antes de las suscripciones unificadas, accounts tenía su propio sistema, en `/api/v1/webhooks/endpoints…`. Sigue funcionando con los permisos `accounts:*`, pero solo para los eventos de la cuenta y sus números (`device.connected`, `device.disconnected`, cobros, suscripción, contactos activos y cuota de IA). Para una integración nueva usá `/api/v1/webhooks`, que recibe esos mismos eventos y los de las otras dos áreas. Sus rutas están en Webhooks › Legado › Endpoints de webhooks (sistema anterior). Lo que vale para los dos sistemas: - **El mismo sobre, las mismas cabeceras y la misma firma** `t=…,v1=…` en `X-Funnelchat-Signature`: los [snippets de arriba](#firma) sirven tal cual, con el secreto de cada uno. - **`data.device.number` va solo con dígitos** (`5491100000000`), sin `+`. Un número de WhatsApp Cloud avisa `device.connected`, pero todavía no `device.disconnected`. _(E29, E30, E37)_ ## No confundir - **communities:** las rutas `campaigns/{campaign}/webhooks` son los webhooks legados de campaña, otro producto con otro formato. No reciben estos eventos. - **conversations:** los webhooks de alertas y el nodo «Petición HTTP» de las automatizaciones son otros productos, con otro formato y otra configuración. - **accounts:** los destinos de alertas de tipo `webhook` (`/api/v1/alerts/destinations`) son parte de las alertas operativas, no de estos webhooks. # Catálogo de eventos Los nombres son `recurso.acción` en inglés y se publican tal como los emite el código: renombrar un evento ya emitido rompería integraciones, así que no se hace en v0. Las descripciones están en español. _(Q36, enmienda C3 del 04-10)_ - **Construido**: el catálogo de eventos de su dominio lo declara emitido (`live` o `active`) en el commit que el dominio tiene en producción, que es de donde sale cada instantánea. Si el catálogo no declara estado, se toma el de DECISIONES-2026-10-04. Cada evento dice de dónde sale su estado. - **Planificado**: anunciado para que te prepares. Todavía no lo vas a recibir, aunque te suscribas ([ver abajo](#suscribirse-planificados)). Los ejemplos de payload salen del código de cada dominio, no se escriben a mano. Si un evento no trae ejemplo, dice «ejemplo pendiente». ### Construidos (48) #### `broadcast.finished` — Conversations · construido Una campaña llegó a su estado final (enviada, fallida o con fallos parciales), con sus conteos. Fuente: catálogo del dominio (GET …/webhooks/events); estado según dominio. ```json { "id": "evt_000000000000000000000011", "type": "broadcast.finished", "version": 1, "occurred_at": "2026-10-01T12:00:00Z", "data": { "broadcast": { "id": 6001, "name": "Campaña de ejemplo" }, "status": "partially_failed", "reason": "completed", "totals": { "processed": 1200, "failed": 30, "skipped_unreachable": 12 } } } ``` #### `broadcast.paused` — Conversations · construido Una campaña se pausó sola, por ejemplo porque bajó la calidad del número. Fuente: catálogo del dominio (GET …/webhooks/events); estado según dominio. ```json { "id": "evt_000000000000000000000007", "type": "broadcast.paused", "version": 1, "occurred_at": "2026-10-01T12:00:00Z", "data": { "broadcast": { "id": 6001, "name": "Campaña de ejemplo" }, "reason": "quality_degraded", "quality": "YELLOW", "quality_baseline": "GREEN" } } ``` #### `contact.created` — Conversations · construido Se creó un contacto nuevo en la cuenta. Fuente: catálogo del dominio (GET …/webhooks/events); estado según dominio. ```json { "id": "evt_000000000000000000000010", "type": "contact.created", "version": 1, "occurred_at": "2026-10-01T12:00:00Z", "data": { "contact": { "id": 2001, "name": "Contacto de ejemplo", "phone": "+5491100000000", "email": "contacto@example.com" } } } ``` #### `contact.stage_changed` — Conversations · construido Un contacto cambió de etapa en un tablero. Fuente: catálogo del dominio (GET …/webhooks/events); estado según dominio. ```json { "id": "evt_000000000000000000000008", "type": "contact.stage_changed", "version": 1, "occurred_at": "2026-10-01T12:00:00Z", "data": { "contact": { "id": 2001, "name": "Contacto de ejemplo", "phone": "+5491100000000" }, "board": { "id": 5001, "name": "Embudo de ejemplo" }, "kind": "move", "from_stage": { "id": 6001, "name": "Nuevo", "position": 0 }, "to_stage": { "id": 6002, "name": "En seguimiento", "position": 1 } } } ``` #### `contact.tag_added` — Conversations · construido Un contacto recibió una etiqueta. Fuente: catálogo del dominio (GET …/webhooks/events); estado según dominio. ```json { "id": "evt_000000000000000000000006", "type": "contact.tag_added", "version": 1, "occurred_at": "2026-10-01T12:00:00Z", "data": { "contact": { "id": 2001, "name": "Contacto de ejemplo", "phone": "+5491100000000" }, "tag": { "id": 4001, "name": "Etiqueta de ejemplo" }, "source": "manual" } } ``` #### `contact.tag_removed` — Conversations · construido Un contacto perdió una etiqueta. Fuente: catálogo del dominio (GET …/webhooks/events); estado según dominio. ```json { "id": "evt_000000000000000000000009", "type": "contact.tag_removed", "version": 1, "occurred_at": "2026-10-01T12:00:00Z", "data": { "contact": { "id": 2001, "name": "Contacto de ejemplo", "phone": "+5491100000000" }, "tag": { "id": 4001, "name": "Etiqueta de ejemplo" }, "source": "manual" } } ``` #### `conversation.assigned` — Conversations · construido Una conversación quedó asignada a una persona del equipo o a un agente de IA. Fuente: catálogo del dominio (GET …/webhooks/events); estado según dominio. ```json { "id": "evt_000000000000000000000005", "type": "conversation.assigned", "version": 1, "occurred_at": "2026-10-01T12:00:00Z", "data": { "contact": { "id": 2001, "name": "Contacto de ejemplo", "phone": "+5491100000000" }, "assignee": { "type": "human", "name": "Agente de ejemplo" } } } ``` #### `conversation.closed` — Conversations · construido Una conversación pasó a cerrada. Fuente: catálogo del dominio (GET …/webhooks/events); estado según dominio. ```json { "id": "evt_000000000000000000000004", "type": "conversation.closed", "version": 1, "occurred_at": "2026-10-01T12:00:00Z", "data": { "contact": { "id": 2001, "name": "Contacto de ejemplo", "phone": "+5491100000000" } } } ``` #### `flow.completed` — Conversations · construido Un contacto terminó un flujo de automatización. Fuente: catálogo del dominio (GET …/webhooks/events); estado según dominio. ```json { "id": "evt_000000000000000000000003", "type": "flow.completed", "version": 1, "occurred_at": "2026-10-01T12:00:00Z", "data": { "flow": { "id": 3001, "name": "Flujo de ejemplo" }, "contact": { "id": 2001, "name": "Contacto de ejemplo", "phone": "+5491100000000" } } } ``` #### `flow.failed` — Conversations · construido La ejecución de un flujo para un contacto terminó por un error. Fuente: catálogo del dominio (GET …/webhooks/events); estado según dominio. ```json { "id": "evt_000000000000000000000013", "type": "flow.failed", "version": 1, "occurred_at": "2026-10-01T12:00:00Z", "data": { "flow": { "id": 3001, "name": "Flujo de ejemplo" }, "contact": { "id": 2001, "name": "Contacto de ejemplo", "phone": "+5491100000000" }, "reason": "execution_error" } } ``` #### `message.received` — Conversations · construido Un contacto le escribió a un número de WhatsApp de la cuenta. Fuente: catálogo del dominio (GET …/webhooks/events); estado según dominio. ```json { "id": "evt_000000000000000000000001", "type": "message.received", "version": 1, "occurred_at": "2026-10-01T12:00:00Z", "data": { "message": { "id": 1001, "type": "text", "text": "Hola, quiero más información", "media": null }, "contact": { "id": 2001, "name": "Contacto de ejemplo", "phone": "+5491100000000" }, "receiver": { "phone": "+5491100000001" }, "device_id": 4521 } } ``` #### `message.status_changed` — Conversations · construido Cambió el estado de entrega de un mensaje enviado. Estados: sent, delivered, read, failed. DECISIONES-2026-10-04 lo registra como planificado; el dominio ya lo emite. Fuente: catálogo del dominio (GET …/webhooks/events); estado según dominio. ```json { "id": "evt_000000000000000000000014", "type": "message.status_changed", "version": 1, "occurred_at": "2026-10-01T12:00:00Z", "data": { "message_id": 1001, "contact_id": 2001, "device_id": 4521, "status": "failed", "previous_status": "sent", "error": { "code": 131047, "title": "Re-engagement message", "reason": null }, "occurred_at": "2026-10-01T12:00:00Z", "client_reference": "pedido-1001" } } ``` #### `template.status_changed` — Conversations · construido Una plantilla cambió de estado de aprobación en Meta. Fuente: catálogo del dominio (GET …/webhooks/events); estado según dominio. ```json { "id": "evt_000000000000000000000012", "type": "template.status_changed", "version": 1, "occurred_at": "2026-10-01T12:00:00Z", "data": { "template": { "id": 7001, "uid": "1234567890", "name": "plantilla_ejemplo", "category": "MARKETING" }, "previous_status": "PENDING", "status": "APPROVED", "reason": null } } ``` #### `whatsapp_form.completed` — Conversations · construido Un contacto completó un formulario de WhatsApp. Fuente: catálogo del dominio (GET …/webhooks/events); estado según dominio. ```json { "id": "evt_000000000000000000000015", "type": "whatsapp_form.completed", "version": 1, "occurred_at": "2026-10-01T12:00:00Z", "data": { "form": { "id": 4001, "name": "Captar un lead", "version_id": 4002 }, "message_id": 1001, "contact_id": 2001, "device_id": 5001, "answers": [ { "key": "nombre", "label": "Nombre", "value": "Contacto de ejemplo" }, { "key": "intereses", "label": "Intereses", "value": [ "op_a", "op_b" ] }, { "key": "comentarios", "label": "Comentarios", "value": null } ], "occurred_at": "2026-10-01T12:00:00Z" } } ``` #### `campaign.no_groups_available` — Communities · construido Una campaña no tiene grupos a los que dirigir a las personas que entran por su link. Fuente: catálogo del dominio (GET …/webhooks/events); estado según dominio. ```json { "id": "evt_0f8e4b526c1d4c7a9a3e2b5d", "type": "campaign.no_groups_available", "version": 1, "occurred_at": "2026-10-01T12:00:00Z", "data": { "campaign": { "uuid": "xK9mQz", "name": "Campaña de ejemplo" }, "reason": "none_selectable" } } ``` #### `campaign.status_changed` — Communities · construido Una campaña se activó o se pausó. Fuente: catálogo del dominio (GET …/webhooks/events); estado según dominio. ```json { "id": "evt_00000000000000000000000000000009", "type": "campaign.status_changed", "version": 1, "occurred_at": "2026-10-01T12:00:00Z", "data": { "campaign": { "uuid": "00000000-0000-4000-8000-000000000009", "name": "Campaña de ejemplo" }, "from": "active", "to": "paused" } } ``` #### `daily_report.generated` — Communities · construido Se generó el reporte diario de la cuenta (clics, ingresos y salidas). Fuente: catálogo del dominio (GET …/webhooks/events); estado según dominio. ```json { "id": "evt_0f8e4b526c1d4c7a9a3e2b5d", "type": "daily_report.generated", "version": 1, "occurred_at": "2026-10-01T12:00:00Z", "data": { "period": { "date": "2026-10-01", "timezone": "America/Argentina/Buenos_Aires" }, "clicks": 120, "joins": 80, "leaves": 10, "conversion_rate": 0.67 } } ``` #### `group.blocked` — Communities · construido Un grupo quedó inoperable: sin administrador conectado, con el link de invitación inválido o suspendido por WhatsApp. Fuente: catálogo del dominio (GET …/webhooks/events); estado según dominio. ```json { "id": "evt_00000000000000000000000000000005", "type": "group.blocked", "version": 1, "occurred_at": "2026-10-01T12:00:00Z", "data": { "group": { "uid": "120363000000000001@g.us", "name": "Grupo de ejemplo" }, "campaign": { "uuid": "00000000-0000-4000-8000-000000000001" }, "reason": "admin_access_lost" } } ``` #### `group.created` — Communities · construido Se creó o se importó un grupo dentro de una campaña de la cuenta. Fuente: catálogo del dominio (GET …/webhooks/events); estado según dominio. ```json { "id": "evt_00000000000000000000000000000001", "type": "group.created", "version": 1, "occurred_at": "2026-10-01T12:00:00Z", "data": { "group": { "uid": "120363000000000001@g.us", "name": "Grupo de ejemplo", "type": "group" }, "campaign": { "uuid": "00000000-0000-4000-8000-000000000001", "name": "Campaña de ejemplo" }, "source": "created" } } ``` #### `group.full` — Communities · construido Un grupo pasó de no estar lleno a estarlo. Fuente: catálogo del dominio (GET …/webhooks/events); estado según dominio. ```json { "id": "evt_0f8e4b526c1d4c7a9a3e2b5d", "type": "group.full", "version": 1, "occurred_at": "2026-10-01T12:00:00Z", "data": { "group": { "uid": "120363000000000000@g.us", "name": "Grupo de ejemplo", "type": "tipo_de_ejemplo" }, "campaign": { "uuid": "xK9mQz", "name": "Campaña de ejemplo" }, "trigger": "participants_limit", "participants": 1024 } } ``` #### `group.invite_link_down` — Communities · construido El enlace de invitación de un grupo quedó confirmado caído (WhatsApp respondió que no existe y la renovación no consiguió otro); no se publica por errores transitorios ni por grupos prohibidos o suspendidos, y se publica una sola vez por episodio de bloqueo. Fuente: catálogo del dominio (GET …/webhooks/events); estado según dominio. ```json { "id": "evt_0000000000000000000000000000000d", "type": "group.invite_link_down", "version": 1, "occurred_at": "2026-10-01T12:00:00Z", "data": { "group": { "uid": "120363000000000001@g.us", "name": "Grupo de ejemplo", "type": "group" }, "campaign": { "uuid": "00000000-0000-4000-8000-000000000001", "name": "Campaña de ejemplo" }, "reason": "http_404", "renewal_attempted": true } } ``` #### `group.invite_link_renewed` — Communities · construido El enlace de invitación de un grupo cambió por una renovación, automática al detectarse inválido o pedida por el usuario; en v0 no se publican los cambios que detecta la sincronización ni el enlace inicial al crear el grupo. Fuente: catálogo del dominio (GET …/webhooks/events); estado según dominio. ```json { "id": "evt_0000000000000000000000000000000c", "type": "group.invite_link_renewed", "version": 1, "occurred_at": "2026-10-01T12:00:00Z", "data": { "group": { "uid": "120363000000000001@g.us", "name": "Grupo de ejemplo", "type": "group" }, "campaign": { "uuid": "00000000-0000-4000-8000-000000000001", "name": "Campaña de ejemplo" }, "invite_link": "https://chat.whatsapp.com/EjemploNuevo0000000000001", "previous_invite_link": "https://chat.whatsapp.com/EjemploAnterior000000000001", "trigger": "automatic", "recovered": false } } ``` #### `group.message_received` — Communities · construido Una persona escribió un mensaje en un grupo. Fuente: catálogo del dominio (GET …/webhooks/events); estado según dominio. ```json { "id": "evt_0f8e4b526c1d4c7a9a3e2b5d", "type": "group.message_received", "version": 1, "occurred_at": "2026-10-01T12:00:00Z", "data": { "group": { "uid": "120363000000000000@g.us", "name": "Grupo de ejemplo" }, "participant": { "identity": "5491100000000", "identity_type": "phone", "name": "Ana Ejemplo" }, "message": { "id": "msg-ejemplo-01", "type": "text", "text": "Hola a todos" } } } ``` #### `group.reaction_added` — Communities · construido Una persona reaccionó a un mensaje de un grupo. Fuente: catálogo del dominio (GET …/webhooks/events); estado según dominio. ```json { "id": "evt_0f8e4b526c1d4c7a9a3e2b5d", "type": "group.reaction_added", "version": 1, "occurred_at": "2026-10-01T12:00:00Z", "data": { "group": { "uid": "120363000000000000@g.us", "name": "Grupo de ejemplo" }, "participant": { "identity": "5491100000000", "identity_type": "phone", "name": "Ana Ejemplo" }, "message": { "id": "msg-ejemplo-01" }, "reaction": "👍" } } ``` #### `group.recovered` — Communities · construido Un grupo que estaba bloqueado volvió a operar. Fuente: catálogo del dominio (GET …/webhooks/events); estado según dominio. ```json { "id": "evt_00000000000000000000000000000006", "type": "group.recovered", "version": 1, "occurred_at": "2026-10-01T12:00:00Z", "data": { "group": { "uid": "120363000000000001@g.us", "name": "Grupo de ejemplo" }, "campaign": { "uuid": "00000000-0000-4000-8000-000000000001" }, "reason": "access_restored" } } ``` #### `group_action.completed` — Communities · construido Terminó una acción sobre un grupo. `action.uuid` es el mismo `action_id` que devolvió la API al pedirla, y `action.type` dice qué acción fue. `result` depende del tipo de acción: en las que reciben una lista de teléfonos (agregar o quitar participantes, nombrar o quitar administradores) trae `participants`, con el resultado por persona: `identity`, `identity_type`, `status` (`added`, `removed`, `promoted` o `demoted` según la acción, o `failed`) y `reason`, que es `null` salvo cuando `status` es `failed`; al crear un grupo o renovar su link de invitación trae `invite_link`; al enviar un mensaje trae `message.uid`; en las demás (cambiar el nombre, la descripción, la foto o los ajustes) viene como objeto vacío (`{}`). `group` y `campaign` pueden venir `null`. Fuente: catálogo del dominio (GET …/webhooks/events); estado según dominio. ```json { "id": "evt_0f8e4b526c1d4c7a9a3e2b5d", "type": "group_action.completed", "version": 1, "occurred_at": "2026-10-01T12:00:00Z", "data": { "action": { "uuid": "00000000-0000-4000-8000-000000000001", "type": "add_participants" }, "group": { "uid": "120363000000000000@g.us", "name": "Grupo de ejemplo" }, "campaign": { "uuid": "xK9mQz", "name": "Campaña de ejemplo" }, "result": { "participants": [ { "identity": "5491100000000", "identity_type": "phone", "status": "added", "reason": null } ] } } } ``` #### `group_action.failed` — Communities · construido Una acción sobre un grupo falló entera (si terminó con resultados por persona, llega `group_action.completed`). Si la pidió la API, `action.uuid` es el mismo `action_id` que devolvió al pedirla y `pipeline` viene `null`. Si la ejecutó una campaña o un pipeline, `action.uuid` viene `null`, y `pipeline` trae ese pipeline (`uuid` y `name`) o viene `null` fuera de un pipeline. `reason` es el motivo de la falla. `group` y `campaign` pueden venir `null`. Fuente: catálogo del dominio (GET …/webhooks/events); estado según dominio. ```json { "id": "evt_0f8e4b526c1d4c7a9a3e2b5d", "type": "group_action.failed", "version": 1, "occurred_at": "2026-10-01T12:00:00Z", "data": { "campaign": { "uuid": "xK9mQz", "name": "Campaña de ejemplo" }, "pipeline": null, "group": { "uid": "120363000000000000@g.us", "name": "Grupo de ejemplo" }, "action": { "uuid": "00000000-0000-4000-8000-000000000001", "type": "add_participants" }, "reason": "motivo_de_ejemplo" } } ``` #### `group_join_request.created` — Communities · construido Una persona pidió unirse a un grupo oficial que pide aprobación para entrar, y su solicitud quedó pendiente. Si el grupo es de una campaña, también llega a las suscripciones filtradas por esa campaña. Se aprueba o rechaza por API con el teléfono de la persona, cuando el número anuncia `groups.join_requests` en `"list_and_decide"`; el resultado por persona llega en `group_action.completed`. Fuente: catálogo del dominio (GET …/webhooks/events); estado según dominio. ```json { "id": "evt_0f8e4b526c1d4c7a9a3e2b5d", "type": "group_join_request.created", "version": 1, "occurred_at": "2026-10-01T12:00:00Z", "data": { "group": { "uid": "120363000000000000@g.us", "name": "Grupo de ejemplo" }, "participant": { "identity": "5491100000000", "identity_type": "phone", "name": "Ana Ejemplo" } } } ``` #### `group_join_request.revoked` — Communities · construido Una persona retiró su solicitud pendiente para unirse a un grupo antes de que se aprobara o rechazara. Trae los mismos campos que `group_join_request.created`. Fuente: catálogo del dominio (GET …/webhooks/events); estado según dominio. ```json { "id": "evt_0f8e4b526c1d4c7a9a3e2b5d", "type": "group_join_request.revoked", "version": 1, "occurred_at": "2026-10-01T12:00:00Z", "data": { "group": { "uid": "120363000000000000@g.us", "name": "Grupo de ejemplo" }, "participant": { "identity": "5491100000000", "identity_type": "phone", "name": "Ana Ejemplo" } } } ``` #### `group_participant.joined` — Communities · construido Una persona entró a un grupo de la cuenta; `campaign` es null si el grupo no es de una campaña. Fuente: catálogo del dominio (GET …/webhooks/events); estado según dominio. ```json { "id": "evt_00000000000000000000000000000002", "type": "group_participant.joined", "version": 1, "occurred_at": "2026-10-01T12:00:00Z", "data": { "group": { "uid": "120363000000000001@g.us", "name": "Grupo de ejemplo" }, "campaign": { "uuid": "00000000-0000-4000-8000-000000000001" }, "participant": { "identity": "+5491100000000", "identity_type": "phone", "name": "Ana Pérez" } } } ``` #### `group_participant.left` — Communities · construido Una persona salió de un grupo de la cuenta; `campaign` es null si el grupo no es de una campaña. Fuente: catálogo del dominio (GET …/webhooks/events); estado según dominio. ```json { "id": "evt_00000000000000000000000000000003", "type": "group_participant.left", "version": 1, "occurred_at": "2026-10-01T12:00:00Z", "data": { "group": { "uid": "120363000000000001@g.us", "name": "Grupo de ejemplo" }, "campaign": { "uuid": "00000000-0000-4000-8000-000000000001" }, "participant": { "identity": "+5491100000000", "identity_type": "phone", "name": "Ana Pérez" } } } ``` #### `pipeline.finished` — Communities · construido Terminó un pipeline de operaciones sobre grupos, con su resultado agregado y la clave de idempotencia del pedido que lo originó, cuando la hay. Fuente: catálogo del dominio (GET …/webhooks/events); estado según dominio. ```json { "id": "evt_0000000000000000000000000000000a", "type": "pipeline.finished", "version": 1, "occurred_at": "2026-10-01T12:00:00Z", "data": { "campaign": { "uuid": "00000000-0000-4000-8000-00000000000a" }, "pipeline": { "uuid": "00000000-0000-4000-8000-00000000000b", "name": "Pipeline de ejemplo" }, "outcome": "completed", "actions": { "total": 5, "succeeded": 4, "failed": 0, "skipped": 1, "cancelled": 0 }, "idempotency_key": "00000000-0000-4000-8000-00000000000d" } } ``` #### `poll.voted` — Communities · construido Un participante votó en una encuesta enviada a un grupo, o cambió su voto. Fuente: catálogo del dominio (GET …/webhooks/events); estado según dominio. ```json { "id": "evt_0000000000000000000000000000000b", "type": "poll.voted", "version": 1, "occurred_at": "2026-10-01T12:00:00Z", "data": { "poll": { "uid": "3EB0000000000000000001" }, "group": { "uid": "120363000000000001@g.us" }, "scheduled_message": { "uuid": "00000000-0000-4000-8000-000000000004" }, "participant": { "identity": "+5491100000000", "identity_type": "phone", "name": null }, "options": [ "Opción A" ] } } ``` #### `scheduled_message.finished` — Communities · construido Un envío programado terminó, con su resultado agregado. Fuente: catálogo del dominio (GET …/webhooks/events); estado según dominio. ```json { "id": "evt_00000000000000000000000000000004", "type": "scheduled_message.finished", "version": 1, "occurred_at": "2026-10-01T12:00:00Z", "data": { "scheduled_message": { "uuid": "00000000-0000-4000-8000-000000000004", "name": "Envío de ejemplo" }, "outcome": "partially_failed", "groups": { "total": 10, "failed": 1, "partially_failed": 2 }, "occurrence": 1 } } ``` #### `scheduled_message.group_delivery_failed` — Communities · construido Un envío programado falló en un grupo concreto, con el motivo desagregado. Fuente: catálogo del dominio (GET …/webhooks/events); estado según dominio. ```json { "id": "evt_00000000000000000000000000000007", "type": "scheduled_message.group_delivery_failed", "version": 1, "occurred_at": "2026-10-01T12:00:00Z", "data": { "scheduled_message": { "uuid": "00000000-0000-4000-8000-000000000007" }, "group": { "uid": "120363000000000001@g.us", "name": "Grupo de ejemplo" }, "reason": "device_disconnected", "partial": false, "occurrence": 1 } } ``` #### `scheduled_message.response_received` — Communities · construido Alguien respondió a un envío programado votando una encuesta o respondiendo a un evento. Fuente: catálogo del dominio (GET …/webhooks/events); estado según dominio. ```json { "id": "evt_00000000000000000000000000000008", "type": "scheduled_message.response_received", "version": 1, "occurred_at": "2026-10-01T12:00:00Z", "data": { "scheduled_message": { "uuid": "00000000-0000-4000-8000-000000000008" }, "group": { "uid": "120363000000000001@g.us" }, "participant": { "identity": "+5491100000000", "identity_type": "phone", "name": "Ana Pérez" }, "response": { "type": "poll", "options": [ "Opción A" ], "rsvp": null } } } ``` #### `account.blocked` — Accounts · construido La cuenta quedó bloqueada por consumo: terminó la gracia de 24 horas tras agotar los contactos activos mensuales sin que se regularizara. Fuente: catálogo del dominio (GET …/webhooks/events); estado según dominio. ```json { "id": "evt_0f8e4b526c1d4c7a9a3e2b5d", "type": "account.blocked", "version": 1, "occurred_at": "2026-10-01T12:00:00Z", "data": { "reason": "mac_grace_expired", "grace_started_at": "2026-10-01T12:00:00Z", "grace_expired_at": "2026-10-02T12:00:00Z" } } ``` #### `billing.payment_failed` — Accounts · construido Un cobro de la suscripción no pudo procesarse (solo suscripciones de Stripe). Se emite una vez por factura (el primer intento fallido); no se repite en los reintentos de cobro. No incluye las recargas de IA. Fuente: catálogo del dominio (GET …/webhooks/events); estado según dominio. ```json { "id": "evt_0f8e4b526c1d4c7a9a3e2b5d", "type": "billing.payment_failed", "version": 1, "occurred_at": "2026-10-01T12:00:00Z", "data": { "amount_cents": 2990, "currency": "USD" } } ``` #### `billing.upcoming_charge` — Accounts · construido Se acerca el próximo cobro de la suscripción (solo suscripciones de Stripe); el aviso llega unos días antes de la fecha de cobro. Fuente: catálogo del dominio (GET …/webhooks/events); estado según dominio. ```json { "id": "evt_0f8e4b526c1d4c7a9a3e2b5d", "type": "billing.upcoming_charge", "version": 1, "occurred_at": "2026-10-01T12:00:00Z", "data": { "charge_date": "2026-11-05T00:00:00Z", "amount_cents": 2990, "currency": "USD" } } ``` #### `device.connected` — Accounts · construido Uno de los números de WhatsApp de la cuenta quedó conectado: al escanear el código QR o al terminar el alta de un número de WhatsApp Cloud. Fuente: catálogo del dominio (GET …/webhooks/events); estado según dominio. ```json { "id": "evt_0f8e4b526c1d4c7a9a3e2b5d", "type": "device.connected", "version": 1, "occurred_at": "2026-10-05T03:00:00Z", "data": { "device": { "id": 4521, "name": "Ventas", "number": "5491100000000" }, "connected_at": "2026-10-05T03:00:00+00:00" } } ``` #### `device.disconnected` — Accounts · construido Uno de los números de WhatsApp de la cuenta se desconectó y dejó de poder enviar o recibir. Solo números conectados por código QR: los de WhatsApp Cloud todavía no lo emiten. Fuente: catálogo del dominio (GET …/webhooks/events); estado según dominio. ```json { "id": "evt_0f8e4b526c1d4c7a9a3e2b5d", "type": "device.disconnected", "version": 1, "occurred_at": "2026-10-05T03:00:00Z", "data": { "device": { "id": 4521, "name": "Ventas", "number": "5491100000000" }, "disconnected_at": "2026-10-05T03:00:00+00:00" } } ``` #### `mac.expiring` — Accounts · construido Hay contactos activos mensuales que vencen en los próximos 7 días. Fuente: catálogo del dominio (GET …/webhooks/events); estado según dominio. ```json { "id": "evt_0f8e4b526c1d4c7a9a3e2b5d", "type": "mac.expiring", "version": 1, "occurred_at": "2026-10-01T12:00:00Z", "data": { "contacts_expiring": 1200, "expires_at": "2026-10-08T03:00:00Z" } } ``` #### `mac.usage_100` — Accounts · construido La cuenta agotó sus contactos activos mensuales del ciclo; se abre una gracia de 24 horas cuyo vencimiento viaja en `grace_expires_at`. Fuente: catálogo del dominio (GET …/webhooks/events); estado según dominio. ```json { "id": "evt_0f8e4b526c1d4c7a9a3e2b5d", "type": "mac.usage_100", "version": 1, "occurred_at": "2026-10-01T12:00:00Z", "data": { "used": 10000, "limit": 10000, "percentage": 100, "cycle_started_at": "2026-10-01T00:00:00Z", "grace_expires_at": "2026-10-02T12:00:00Z" } } ``` #### `mac.usage_80` — Accounts · construido La cuenta alcanzó el 80% de sus contactos activos mensuales del ciclo. Fuente: catálogo del dominio (GET …/webhooks/events); estado según dominio. ```json { "id": "evt_0f8e4b526c1d4c7a9a3e2b5d", "type": "mac.usage_80", "version": 1, "occurred_at": "2026-10-01T12:00:00Z", "data": { "used": 8000, "limit": 10000, "percentage": 80, "cycle_started_at": "2026-10-01T00:00:00Z" } } ``` #### `subscription.cancellation_confirmed` — Accounts · construido Quedó confirmada la cancelación de la suscripción (solo suscripciones de Stripe): se conserva el acceso hasta la fecha indicada. Fuente: catálogo del dominio (GET …/webhooks/events); estado según dominio. ```json { "id": "evt_0f8e4b526c1d4c7a9a3e2b5d", "type": "subscription.cancellation_confirmed", "version": 1, "occurred_at": "2026-10-01T12:00:00Z", "data": { "access_until": "2026-11-05T00:00:00Z" } } ``` #### `subscription.dormant_ending` — Accounts · construido El período de cuenta en reposo está por terminar (el aviso llega unos 7 días antes); en esa fecha se intenta el cobro indicado. Fuente: catálogo del dominio (GET …/webhooks/events); estado según dominio. ```json { "id": "evt_0f8e4b526c1d4c7a9a3e2b5d", "type": "subscription.dormant_ending", "version": 1, "occurred_at": "2026-10-01T12:00:00Z", "data": { "charge_date": "2026-10-08T00:00:00Z", "amount_cents": 1990, "currency": "USD" } } ``` #### `subscription.reactivated` — Accounts · construido Se revirtió la cancelación programada de la suscripción (solo suscripciones de Stripe): sigue vigente y se renueva en la fecha indicada. Fuente: catálogo del dominio (GET …/webhooks/events); estado según dominio. ```json { "id": "evt_0f8e4b526c1d4c7a9a3e2b5d", "type": "subscription.reactivated", "version": 1, "occurred_at": "2026-10-01T12:00:00Z", "data": { "renews_at": "2026-11-05T00:00:00Z" } } ``` #### `subscription.trial_ending` — Accounts · construido El período de prueba está por terminar; el aviso llega entre 1 y 3 días antes del final, según cómo se contrató (en las suscripciones de Stripe, el aviso de Stripe). Fuente: catálogo del dominio (GET …/webhooks/events); estado según dominio. ```json { "id": "evt_0f8e4b526c1d4c7a9a3e2b5d", "type": "subscription.trial_ending", "version": 1, "occurred_at": "2026-10-01T12:00:00Z", "data": { "trial_ends_at": "2026-10-04T00:00:00Z" } } ``` ### Planificados (0) — todavía no se emiten | Evento | Dominio | Descripción | Nota | |---|---|---|---| ## Suscribirse a un evento planificado El catálogo (`GET /api/v1/webhooks/events`, con una clave con `accounts:read`) lista los eventos de las tres áreas. Cada entrada trae `type`, `area` (`accounts`, `conversations` o `communities`), `label`, `description`, `status`, `subscribable` y `example` con el cuerpo completo que vas a recibir; `meta` dice cuántos hay, cuántos se emiten y el formato de la firma. `status` es `active` si el evento se emite y `planned` si se puede suscribir pero todavía no se emite. El alta de una suscripción acepta los planificados en `events`: suscribirte a uno no da error, pero **no te llega nada hasta que se empiece a emitir**. - Antes de depender de un evento, mirá su `status` en el catálogo: `active` se emite; `planned`, todavía no. - No hay comodín para «todos»: elegís cada tipo, así que solo recibís lo que pediste. - El ejemplo de un evento planificado puede cambiar antes de que se empiece a emitir. - `webhook.test` figura en el catálogo con `subscribable: false`: llega solo cuando pedís una prueba. ## `identity_type` Los eventos de communities que traen un participante (`participant`) lo identifican con `identity` e `identity_type`. v0 documenta un solo valor de `identity_type`: `phone`, y en ese caso `identity` es un teléfono con `+` ([cómo buscarlo en la API](/docs/webhooks/#telefonos)). Pueden aparecer otros valores sin aviso. **Qué hacer:** procesá `identity` como teléfono solo si `identity_type` es `phone`. Si llega un valor que no conocés, respondé `2xx` igual e ignorá ese participante (o registralo para revisarlo): no es un error de la entrega. ## Otros eventos y notas - `webhook.test`: la entrega de prueba que manda `POST /api/v1/webhooks/{id}/test`, firmada igual que un evento real. No se puede suscribir a él. - Accounts anuncia además 5 tipos planificados que no están en el catálogo v0 (DECISIONES-2026-10-04) y por eso no se listan arriba: `billing.plan_blocked`, `subscription.expiring`, `ai_quota.low_balance`, `ai_quota.topup_succeeded`, `ai_quota.topup_failed`. **Fuentes del catálogo por dominio:** Conversations: catálogo del dominio (GET …/webhooks/events); Communities: catálogo del dominio (GET …/webhooks/events); Accounts: catálogo del dominio (GET …/webhooks/events). - **`message.status_changed` se emite** cuando cambia el estado de entrega de un mensaje que enviaste a un contacto, desde el chat, una automatización, un asistente de IA o con tu clave. No se emite para los mensajes de difusiones ni para los recibidos. Hay que pedirlo de forma explícita en `events`. Ver [Estados de mensaje](/docs/piezas/estados-de-mensaje/). - **`group.invite_link_down` puede repetirse:** si después de un link caído una sincronización vuelve a habilitar el grupo aunque el link siga muerto, la validación siguiente emite otro `down`. Tratá el evento como idempotente por grupo. _(E32)_ - **`device.connected` y `device.disconnected` (accounts)** se suscriben en `/api/v1/webhooks`, como el resto; también llegan por el [sistema anterior de accounts](/docs/webhooks/#accounts). Su `data.device.number` va sin `+`, y un número de WhatsApp Cloud todavía no emite `device.disconnected`. _(E29, E30, E37)_ - **Datos planificados dentro de `message.received`**, no como eventos aparte: si el contacto llegó desde un anuncio (Click-to-WhatsApp) y si está respondiendo a una difusión. El ejemplo de hoy todavía no los trae. - **Fuera de v0:** puntaje de leads, pagos y redes sociales. _(Q35)_ - Un evento entra al catálogo si un integrador actúa sobre él, no es una manguera de cambios, no expone datos internos ni perfila personas, y el dominio lo emite con test. _(Q33)_ # Reglas de WhatsApp Meta castiga a los números que rompen sus reglas: les baja la calidad, les limita los envíos o los bloquea. La capa de Funnelchat aplica estas reglas **a cada envío con tu clave, antes de mandarlo**, y no se pueden apagar. Así armás lo tuyo sin aprenderte las reglas de memoria y sin poner en riesgo tu número. Cuando una regla frena un envío, la respuesta es `{ "error": "", "message": "" }` y **no se envía ni se registra nada**. Cada operación de envío de la [referencia](/docs/referencia/) dice qué códigos puede responder. ## Ventana de 24 horas Con un **número de WhatsApp Cloud** (el oficial de Meta) solo podés mandar mensajes que no son plantilla a un contacto que te escribió en las últimas 24 horas. Fuera de esa ventana, solo plantillas aprobadas, que no tienen esta restricción. Los números por QR no tienen ventana. - **Antes de mandar**, la capa mira la ventana. Si el mensaje no es una plantilla y la ventana está cerrada, no lo manda: responde `422 window_closed`. Para retomar la conversación, mandá una plantilla aprobada. Para decidir antes de mandar, consultá Ver la ventana de 24 horas de un contacto (`GET /api/v1/contacts/{contact}/meta-window`): responde `is_open`. ```json { "error": "window_closed", "message": "" } ``` ## Frenos contra spam Lo que más rápido hace que WhatsApp marque un número es repetir el mismo mensaje e insistirle a quien no contesta. Los envíos con tu clave pasan por dos frenos, en modo **cortar**: el envío que se pasa no sale y la llamada responde con un error claro. No se encola en silencio. | Freno | Límite, por contacto y en 24 horas | Respuesta | |---|---|---| | Mismo mensaje repetido | Hasta 2 mensajes con el mismo contenido; el tercero se frena | `422 antiblock_duplicate_content` | | Insistir sin respuesta | Hasta 5 mensajes seguidos sin que el contacto conteste; el sexto se frena | `422 antiblock_unanswered_limit` | Cuando el contacto responde, los dos contadores vuelven a cero. Los frenos valen para mensajes y plantillas, no para reacciones. **`422`:** no reintentes el mismo envío. Cambiá el contenido o esperá a que el contacto responda. ## Ritmo por número Los envíos con tu clave se limitan **solo por el número** que manda, según su tipo. **No hay un límite por minuto por clave ni por cuenta** y conversations no tiene [tope diario de llamadas](/docs/primeros-pasos/#tope): todas las claves de tu cuenta que mandan por el mismo número comparten su ritmo. Las reacciones solo tienen esta regla. | Tipo de número | Mensajes por segundo | Al mismo contacto | |---|---|---| | WhatsApp Cloud (el oficial de Meta) | Hasta 60 | Hasta 10 por minuto | | Coexistencia (el mismo teléfono en WhatsApp Cloud y en la app WhatsApp Business del celular) | Hasta 15 entre las dos conexiones, mandes por la que mandes | Hasta 10 por minuto en lo que mandás por WhatsApp Cloud | | Por QR, sin coexistencia | Sin límite | Sin límite | - **Al pasarte**, la llamada responde `429 rate_limited` con `Retry-After`: los segundos que tenés que esperar antes de reintentar. El envío frenado no sale y no queda registrado. - **Repartí tus envíos en el tiempo** en lugar de mandarlos todos juntos y esperar el `429`. - **Sin límite no quiere decir sin riesgo:** a un número por QR se le aplican igual los frenos contra spam, y mandar mucho y muy rápido es de lo que más rápido bloquea un número. ## Bajas: todavía no las registramos > [!WARNING] Lo que todavía no hacemos > Funnelchat **todavía no registra bajas**: si un contacto te pide que no le escriban más, la capa no frena los envíos a ese contacto. **Respetalo vos en tu sistema:** guardá el pedido y dejá de escribirle por todos tus canales. Es un tema que vamos a resolver para toda la plataforma. ## Qué hacer con estos errores | Respuesta | Qué hacer | |---|---| | `422 window_closed` | Mandá una plantilla aprobada ([Mensajes](/docs/piezas/mensajes/)). | | `422 antiblock_duplicate_content` | No repitas el contenido. | | `422 antiblock_unanswered_limit` | Esperá a que el contacto responda. | | `429 rate_limited` | Esperá lo que indica `Retry-After` y reintentá. Si se repite, bajá el ritmo de ese número ([Ritmo por número](#ritmo)). | | `422 recipient_unsupported` | El contacto es un grupo, una lista de difusión o un canal: por la API solo se les escribe a personas. | | `422 recipient_mismatch` | El teléfono o el identificador de WhatsApp del cuerpo no es el del contacto de la ruta. Revisá a quién le mandás. | | `422 device_unavailable` | El número no es de tu cuenta o no está conectado. Reconectalo en Funnelchat y volvé a mandar. | Decidí por el código HTTP y por `error`, nunca por el texto de `message`. Los demás errores de la API, los del portero, están en [Autenticación y permisos](/docs/autenticacion/#errores). ## Por qué recomendamos el número oficial Un número oficial de Meta usa la conexión que WhatsApp admite para empresas; un número por QR se conecta como un WhatsApp Web. La API es la misma para los dos y las reglas de esta página se aplican igual, pero el oficial es el que recomendamos para lo que armes. Ver [Números](/docs/piezas/numeros/). # Funciones de Funnelchat Si ya usás Funnelchat, desde la API también podés consultar lo que armaste en el panel. Es un extra: **no lo necesitás para armar lo tuyo**, que se hace con las piezas ([Mensajes](/docs/piezas/mensajes/), [Estados de mensaje](/docs/piezas/estados-de-mensaje/), [Grupos](/docs/piezas/grupos/), [Números](/docs/piezas/numeros/), [Contactos y datos](/docs/piezas/contactos-y-datos/)). | Función | Qué podés hacer desde la API | Referencia | |---|---|---| | Flows | Ver tus flows, sus pasos y qué contactos están en cada uno; pausar o reanudar a un contacto. | [Flujos](/docs/referencia/flujos/), [Automatizaciones del contacto](/docs/referencia/automatizaciones-del-contacto/) | | Campañas de grupos | Tus campañas, sus grupos, participantes, operaciones y estadísticas. | [Campañas](/docs/referencia/campanas/), [Estadísticas](/docs/referencia/estadisticas/) | | Mensajes programados | Lo que programaste para tus grupos, a quién le llegó y las respuestas. | [Mensajes programados](/docs/referencia/mensajes-programados/) | | Difusiones | Tus difusiones, sus mensajes, costos y estadísticas. | [Difusiones](/docs/referencia/difusiones/) | | Kanban | Tus tableros, sus etapas y qué contacto está en cada una. | [Tableros](/docs/referencia/tableros/) | | Lead score y ventas | La configuración y la distribución del puntaje, y las métricas de ventas. | [Lead score](/docs/referencia/configuracion-del-lead-score/), [Ventas](/docs/referencia/ventas/) | | Atención | Respuestas rápidas, tickets y tu equipo. | [Respuestas rápidas](/docs/referencia/respuestas-rapidas/), [Tickets](/docs/referencia/tickets/) | | Alertas | Destinos y configuración de tus alertas. | [Destinos de alertas](/docs/referencia/destinos-de-alertas/) | - **Esta sección se mantiene como está:** no se le suman funciones nuevas mientras completamos las piezas. - **Lo que puede producir muchos mensajes con una sola llamada** —ejecutar una difusión, arrancar un flow, disparadores, ponerle etiquetas a contactos— sigue cerrado a claves (`403 route_not_available`). Ver [Qué está abierto a claves](/docs/autenticacion/#etapa-1). - **Si querés tus propios tiempos y tu propia lógica**, no hace falta un flow: [Armá tu propio flujo](/docs/armar-tu-flujo/). Guías de estas funciones: [Mover un lead de etapa](/docs/guias/mover-lead-de-etapa/) y [Seguir grupos](/docs/guias/seguir-grupos/). Sus eventos (`flow.completed`, `broadcast.finished`, `campaign.status_changed` y otros) están en el [catálogo](/docs/webhooks/eventos/). # Referencia Cada endpoint tiene su página: para qué sirve, el permiso que pide, sus parámetros, ejemplos en varios lenguajes y las respuestas posibles. Están ordenados por recurso, sin importar de qué dominio vienen: primero **las piezas** con las que armás lo tuyo (mensajes, contactos y datos, grupos, números) y después **las funciones de Funnelchat** (flows, campañas, difusiones, tableros, lead score, ventas, alertas), que podés consultar si ya las usás. Cada página muestra su URL completa: la URL base única + la ruta `/api/v1/…` (ver [Cómo armar la URL](/docs/#url)). - **Conversations** · 152 operaciones: 152 disponibles para claves en v0. - **Communities** · 53 operaciones: 53 disponibles para claves en v0. - **Accounts** · 68 operaciones: 68 disponibles para claves en v0. Los eventos y las suscripciones de webhooks están en su propia pestaña: [Webhooks](/docs/webhooks/). **Probar en vivo:** en cualquier endpoint, el botón para probar abre un formulario. Pegá tu clave completa (`|fc_live_…`) en `Authorization` y enviá: la llamada sale de tu navegador directo a la URL base de la API. La clave queda en el navegador solo mientras recorrés el portal en esa pestaña: se borra al cerrarla, al recargarla o al salir del portal. Las operaciones que no están abiertas a claves en v0 llevan una «Nota del portal». **Toda la API en una sola especificación:** [`specs/funnelchat.json`](/specs/funnelchat.json), OpenAPI 3.1. Es la principal: 237 operaciones de las tres áreas con la URL base única en `servers` y las rutas completas, cada una con el permiso que pide (`x-fc-permission`), el área que la atiende (`x-fc-domain`) y las notas de v0. Es la que conviene para generar un cliente o dársela a tu IA. Qué rutas no entran y por qué: [Procedencia](/docs/procedencia/#unificada). Por área siguen las especificaciones tal como las publicó cada dominio (`specs/.json`) o con las notas de v0 (`specs/.anotada.json`). Ver [Procedencia](/docs/procedencia/) para el SHA y la hora de cada una. # Procedencia Nada de lo que el portal muestra como referencia ni como catálogo de eventos se escribe a mano: sale de instantáneas de la especificación que cada dominio genera desde su código. El portal no lee las especificaciones en vivo: las copia, las rotula con el SHA del dominio y la hora, y se regenera después de cada cambio de rutas. _(C7)_ | Campo | Conversations | Communities | Accounts | |---|---|---|---| | Estado | disponible | disponible | disponible | | SHA (HEAD del worktree) | `68cce1b4e` | `6f32f0b4` | `300cc8435` | | Rama | `snapshot/referencia-mac-2210` | `snapshot/referencia-2210` | `snapshot/referencia-2210` | | Árbol de trabajo | limpio | 2 archivos sin commitear | limpio | | Instantánea tomada | 2026-10-22 16:30 (-03:00) | 2026-10-22 09:46 (-03:00) | 2026-10-22 09:46 (-03:00) | | Especificación | OpenAPI 3.1.2 · 119 paths · 152 operaciones · sha256 `71f5b92344ae` | OpenAPI 3.1.2 · 50 paths · 53 operaciones · sha256 `0b1142cddd2a` | OpenAPI 3.1.2 · 52 paths · 68 operaciones · sha256 `787052b9ec1b` | | Catálogo de eventos | bajado 2026-10-22 09:46 (-03:00) (15 tipos) | bajado 2026-10-22 09:46 (-03:00) (14 tipos) | bajado 2026-10-22 09:46 (-03:00) (54 tipos) | | Último intento | 2026-10-22 16:30 (-03:00): spec OK | 2026-10-22 09:46 (-03:00): spec OK | 2026-10-22 09:46 (-03:00): spec OK | | Origen | `http://localhost:8118/docs/api.json` | `http://localhost:8130/docs/api.json` | `http://localhost:8149/docs/api.json` | - **SHA** es el `HEAD` del árbol de trabajo del dominio al tomar la instantánea. Si el árbol tenía un merge en curso o cambios sin commitear, lo documentado puede no coincidir exactamente con ese commit. - **Catálogo de eventos:** cada dominio expone `GET /api/v1/webhooks/events`, que pide una clave. Si la instantánea se tomó sin clave, el catálogo sale de la sección `webhooks` de la especificación (o, en su defecto, del esquema de respuesta del catálogo) y lo dice en la página de eventos. Por la URL base, esa ruta llega a accounts, cuyo catálogo lista los eventos de las tres áreas con su `area`: el portal atribuye cada evento a su área y, si el área lo declara en su propia instantánea, usa esa declaración. - Si un dominio no responde o responde algo que no es una especificación válida, se conserva la instantánea anterior. ## La especificación unificada [`specs/funnelchat.json`](/specs/funnelchat.json) junta en una sola especificación OpenAPI 3.1 las operaciones de las tres instantáneas, con la URL base única y las rutas completas: 237 operaciones. Sale de las mismas instantáneas, con los nombres, las descripciones y las notas de v0 del portal; en `x-fc-sources` dice de qué SHA salió cada parte. Qué área atiende cada ruta no lo decide el portal: lo decide el borde de la API, que reparte las llamadas a la URL base según el primer segmento de la ruta. Cada operación lo dice en `x-fc-domain`. Una ruta que figura en la instantánea de un área pero que el borde manda a otra no entra en la unificada (sigue en la especificación de su área) y queda listada en `x-fc-excluded-routes`. En esta versión: - `GET /api/v1/webhooks/{webhook}/deliveries` (instantánea de conversations): el borde la manda a accounts. - `GET /api/v1/webhooks/events` (instantánea de conversations): el borde la manda a accounts. - `GET, POST /api/v1/webhooks` (instantánea de conversations): el borde la manda a accounts. - `GET, PUT, DELETE /api/v1/webhooks/{webhook}` (instantánea de conversations): el borde la manda a accounts. - `POST /api/v1/webhooks/{webhook}/rotate-secret` (instantánea de conversations): el borde la manda a accounts. - `POST /api/v1/webhooks/{webhook}/test` (instantánea de conversations): el borde la manda a accounts. - `GET /api/v1/forms/templates` (instantánea de conversations): el borde no la enruta. - `GET, POST /api/v1/forms` (instantánea de conversations): el borde no la enruta. - `GET /api/v1/forms/families/{familyId}/metrics` (instantánea de conversations): el borde no la enruta. - `GET /api/v1/forms/families/{familyId}/responses` (instantánea de conversations): el borde no la enruta. - `GET /api/v1/forms/families/{familyId}/responses/{runId}` (instantánea de conversations): el borde no la enruta. - `GET /api/v1/forms/families/{familyId}/versions` (instantánea de conversations): el borde no la enruta. - `GET /api/v1/forms/families/{familyId}/definition` (instantánea de conversations): el borde no la enruta. - `GET, PUT, DELETE /api/v1/forms/{id}` (instantánea de conversations): el borde no la enruta. - `GET, POST /api/v1/forms/{id}/numbers` (instantánea de conversations): el borde no la enruta. - `POST /api/v1/forms/{id}/versions` (instantánea de conversations): el borde no la enruta. - `POST /api/v1/forms/{id}/sync` (instantánea de conversations): el borde no la enruta. - `POST /api/v1/forms/{id}/publish` (instantánea de conversations): el borde no la enruta. - `DELETE /api/v1/forms/{id}/numbers/{deviceId}` (instantánea de conversations): el borde no la enruta. - `POST /api/v1/forms/{id}/numbers/retry` (instantánea de conversations): el borde no la enruta. - `GET, POST /api/v1/webhooks` (instantánea de communities): el borde la manda a accounts. - `GET /api/v1/webhooks/events` (instantánea de communities): el borde la manda a accounts. - `GET, PUT, DELETE /api/v1/webhooks/{webhook}` (instantánea de communities): el borde la manda a accounts. - `POST /api/v1/webhooks/{webhook}/rotate-secret` (instantánea de communities): el borde la manda a accounts. - `POST /api/v1/webhooks/{webhook}/test` (instantánea de communities): el borde la manda a accounts. - `GET /api/v1/webhooks/{webhook}/deliveries` (instantánea de communities): el borde la manda a accounts. Si dos áreas usan el mismo nombre para cosas distintas (un esquema, una respuesta de error), en la unificada llevan el nombre del área delante (`Conversations…`, `Communities…`, `Accounts…`); lo que es igual queda una sola vez. Cada generación del sitio verifica que el archivo sea OpenAPI 3.1 válido y que cada ruta vaya al área que dice el borde. ## Qué se escribe a mano - Los textos de contexto: inicio, guía rápida, autenticación, webhooks y la introducción de eventos y referencia. Salen del contrato de la iniciativa (C1, C3 v4, C6, C7 y las enmiendas del 04-10), del contrato del registro de llamadas (1019: la cabecera `X-Request-Id` y los errores propios de la entrada), de las decisiones del piloto (D5–D23) y de las del 04-10 (Q14–Q36). Los topes diarios (solo en accounts y communities) salen de la Q20 del 14-10. - La orientación del portal —armá tu propio Funnelchat, las piezas primero y las funciones de Funnelchat aparte— y las páginas de piezas, «Armá tu propio flujo» y «Reglas de WhatsApp». Salen del principio y las decisiones del 14-10 (Q1–Q20) y del contrato de esa etapa. El envío con clave, las reglas de WhatsApp y sus errores, el ritmo por número, `message.status_changed` y `capabilities` están en las instantáneas del 22-10 y las páginas siguen lo que dicen. El portal solo muestra lo que un integrador puede usar hoy. - Las reglas de disponibilidad en v0 por dominio y el catálogo v0 de eventos planificados (DECISIONES-2026-10-04, Q34). - Los snippets de verificación de firma, probados en cada generación del sitio. Último resultado de `tests/test_firmas.py` (2026-10-22 20:53 (+00:00)): pasó — Python 3.12.15, Node v24.21.0, PHP 8.3.6. ## Cómo se regenera ```bash python3 scripts/snapshot.py # toma las instantáneas (FC_API_KEY opcional, para el catálogo de eventos) npm run build # genera páginas y specs (scripts/build.py, con el test de firmas), verifica la unificada, la referencia por endpoint y el sitio ``` La referencia por endpoint la genera Fumadocs OpenAPI 12.3.0 a partir de las especificaciones anotadas; el sitio se publica como archivos estáticos. # Kit para asistentes de IA Si le pedís a tu asistente de IA que programe tu integración, este kit le enseña la API de Funnelchat tal como es: las rutas, los campos, los permisos, las reglas de WhatsApp y lo que no existe. Así no inventa, y vos no perdés tiempo ni arriesgás tu número. ## Qué es Un conjunto de **skills**: instrucciones y referencias que el asistente carga solo cuando las necesita. Hay una por pieza de la API, una de arquitectura que arma el plan de tu integración y deriva cada parte a la skill que corresponde, y una para probar la instalación. | Skill | Qué cubre | |---|---| | `funnelchat-api-authentication` | **Autenticación y permisos.** La URL base, la clave, los permisos, qué está abierto a claves y los errores del portero. | | `funnelchat-messages` | **Enviar mensajes.** Enviar texto, archivos, botones, listas, plantillas, tarjetas, reacciones y reenvíos, con la ventana de 24 horas, los frenos y el ritmo por número. | | `funnelchat-inbound-messages` | **Recibir mensajes y leer conversaciones.** Recibir `message.received` con un receptor firmado y leer conversaciones e historial. | | `funnelchat-webhooks` | **Webhooks y eventos.** Suscripciones, catálogo de eventos, firma con código probado, reintentos y entregas. | | `funnelchat-contacts` | **Contactos y datos.** Contactos, campos personalizados, etiquetas y notas; sincronizar un CRM sin duplicados. | | `funnelchat-templates` | **Plantillas.** Crear, editar y elegir plantillas de WhatsApp y enterarse de su aprobación. | | `funnelchat-groups` | **Grupos.** Leer grupos, participantes, entradas y salidas, y sus eventos. | | `funnelchat-numbers` | **Números.** Tus números: estado, salud, registros, cola y las operaciones irreversibles. | | `funnelchat-account` | **Cuenta y consumo.** Gasto de WhatsApp, contactos activos (MAC) y dominios propios. | | `funnelchat-integration-architect` | **Arquitectura de una integración.** Arma el plan de tu integración con las piezas, marca las reglas y lo que no existe, y deriva cada parte. | | `funnelchat-developer-kit-smoke-test` | **Prueba de instalación.** Prueba la instalación: responde un texto fijo sin llamar a nada. | - **Sale de la misma fuente que este portal.** Las operaciones de cada skill se generan de la [especificación unificada](/specs/funnelchat.json) y su comportamiento, de estas páginas. El build del portal verifica que el kit esté al día y que cada operación de la API tenga su skill, salvo las [Funciones de Funnelchat](/docs/funciones-de-funnelchat/): el kit las nombra como alternativa, pero no las enseña como piezas. - **Describe la API como está hoy.** Lo que un integrador puede usar hoy, y lo que no existe, como que no existe. - **Está en español**, con los términos clave también en inglés y en portugués, para que se active con lo que escribas. ## Cómo se instala > [!WARNING] Repositorio privado > El kit vive en el repositorio de este portal, que todavía es privado: hasta que se publique, el comando funciona solo para quien tiene acceso a ese repositorio. Con el instalador de skills (`npx skills`), en la carpeta de tu proyecto: ```bash # Claude Code npx skills add Funnelchat20/developers-portal --skill '*' -a claude-code # Codex npx skills add Funnelchat20/developers-portal --skill '*' -a codex # Cursor npx skills add Funnelchat20/developers-portal --skill '*' -a cursor ``` - `--skill '*'` instala todas. Para una sola, poné su nombre: `--skill funnelchat-messages`. La de arquitectura deriva a las demás, así que conviene tenerlas todas. - **A mano, en Claude Code:** copiá las carpetas `skills/funnelchat-*` del repositorio a `.claude/skills/` de tu proyecto, o a `~/.claude/skills/` para usarlas en todos. - Cada skill es una carpeta con su `SKILL.md` y sus referencias: se puede leer como cualquier Markdown. ## Cómo se prueba la instalación Pedile a tu asistente: «Probá la instalación del kit de Funnelchat». Tiene que responder exactamente este texto, sin llamar a ninguna herramienta ni a la red: ```text Kit de Funnelchat para asistentes de IA: instalado. Para empezar, pedime «armá el plan de mi integración con Funnelchat». ``` Si responde otra cosa o intenta llamar a la API, la skill `funnelchat-developer-kit-smoke-test` no está instalada o el asistente no la cargó. Para empezar a trabajar, pedile el plan de tu integración: lo arma `funnelchat-integration-architect`. ## Qué hace y qué no - **Te ayuda a programar:** arma el plan con las piezas, escribe el código contra las rutas y los campos reales, verifica la firma de los webhooks con el mismo código que probamos acá y te avisa qué [reglas de WhatsApp](/docs/reglas-de-whatsapp/) aplican. - **No publica nada ni manda mensajes sin que se lo pidas.** Cada skill trae los mismos límites de ejecución: un pedido claro autoriza esa operación y solo esa; tu clave se usa sin mostrarla; el trabajo local no autoriza llamadas reales, y nunca reintenta a ciegas una escritura que no respondió. - **Te pide confirmación antes de lo irreversible**, como reiniciar, limpiar o eliminar un número, aunque se lo hayas pedido. - **No sabe más que la API:** si algo no está en la especificación, el kit dice que no existe en lugar de adivinarlo. - **No guarda tu clave.** La usan tu código o tu terminal, desde una variable de entorno. No hay sandbox: todo lo que haga con tu clave actúa sobre tu cuenta real ([Autenticación y permisos](/docs/autenticacion/)). # Referencia de endpoints (resumen de las instantáneas) ### Conversations URL base: `https://api.funnelchat.com` (URL = URL base + ruta completa; el `servers` de la especificación es `https://api.funnelchat.com/api/v1` y sus `paths` son relativos a eso) · instantánea 68cce1b4e · 2026-10-22 16:30 (-03:00) · 152 operaciones, 152 disponibles para claves en v0 según el contrato. Formato: `MÉTODO ruta-completa` — para qué (si la especificación lo dice) · permiso · disponibilidad en v0. Parámetros: `nombre` (lugar, tipo, * = obligatorio). Cuerpo: `campo` (tipo, * = obligatorio). Agrupado por la etiqueta (tag) de la especificación. #### Conversations · Broadcast - `GET /api/v1/broadcasts` — permiso `conversations:read` · disponible Parámetros: `name` (query, string|null), `device_id` (query, integer|null), `status[]` (query, array), `folder_id` (query, integer|null), `unclassified` (query, string|null), `send_type` (query, string|null ∈ {"immediate", "scheduled", null}), `device_type` (query, string|null ∈ {"business", "api", null}), `send_at_from` (query, string|null), `send_at_to` (query, string|null), `perPage` (query, integer), `orderBy` (query, string ∈ {"name", "created_at", "updated_at"}), `orderByMethod` (query, string ∈ {"asc", "desc"}) - `POST /api/v1/broadcasts/contacts` — permiso `conversations:write` · disponible Parámetros: `perPage` (query, integer) Cuerpo (application/json): `tags` (array), `some_tags` (array), `exclude_tags` (array), `countries` (array), `date_from` (string|null), `date_to` (string|null), `only_with_phone` (boolean), `only_paused` (boolean), `kanban_setting_id` (integer|null), `kanban_stage_ids` (array), `only_stagnant` (boolean|null), `exclude_never_replied` (boolean), `lead_score_categories` (array), `lead_score_min` (integer|null), `lead_score_max` (integer|null), `per_page` (integer|null) - `POST /api/v1/broadcasts/contacts-count` — permiso `conversations:write` · disponible Cuerpo (application/json): `tags` (array), `some_tags` (array), `exclude_tags` (array), `countries` (array), `date_from` (string|null), `date_to` (string|null), `only_with_phone` (boolean), `only_paused` (boolean), `kanban_setting_id` (integer|null), `kanban_stage_ids` (array), `only_stagnant` (boolean|null), `exclude_never_replied` (boolean), `lead_score_categories` (array), `lead_score_min` (integer|null), `lead_score_max` (integer|null) - `GET /api/v1/broadcasts/cost-tiers` — permiso `conversations:read` · disponible - `POST /api/v1/broadcasts/move-to-folder` — permiso `conversations:write` · disponible Cuerpo (application/json): `broadcast_ids` (array, *), `folder_id` (integer|null) - `GET /api/v1/broadcasts/{broadcast}` — permiso `conversations:read` · disponible Parámetros: `broadcast` (path, integer, *) - `DELETE /api/v1/broadcasts/{broadcast}` — permiso `conversations:write` · disponible Parámetros: `broadcast` (path, integer, *) - `GET /api/v1/broadcasts/{broadcast}/statistics` — permiso `conversations:read` · disponible Parámetros: `broadcast` (path, integer, *) - `GET /api/v1/broadcasts/{broadcast}/cost` — permiso `conversations:read` · disponible Parámetros: `broadcast` (path, integer, *) - `GET /api/v1/broadcasts/{broadcast}/contacts` — permiso `conversations:read` · disponible Parámetros: `broadcast` (path, integer, *), `perPage` (query, integer), `orderByMethod` (query, string ∈ {"asc", "desc"}) - `POST /api/v1/broadcasts/{broadcast}/cancel` — permiso `conversations:write` · disponible Parámetros: `broadcast` (path, integer, *) - `GET /api/v1/broadcasts/{broadcast}/messages` — permiso `conversations:read` · disponible Parámetros: `broadcast` (path, integer, *), `status[]` (query, array, *), `tab` (query, string|null ∈ {"all", "delivered", "read", "replied", "failed", "skipped", null}), `perPage` (query, integer), `orderByMethod` (query, string ∈ {"asc", "desc"}) #### Conversations · BroadcastFolder - `GET /api/v1/broadcasts/folders` — permiso `conversations:read` · disponible Parámetros: `perPage` (query, integer) - `POST /api/v1/broadcasts/folders` — permiso `conversations:write` · disponible Cuerpo (application/json): `name` (string, *) - `PUT /api/v1/broadcasts/folders/{broadcastFolder}` — permiso `conversations:write` · disponible Parámetros: `broadcastFolder` (path, integer, *) Cuerpo (application/json): `name` (string, *) - `DELETE /api/v1/broadcasts/folders/{broadcastFolder}` — permiso `conversations:write` · disponible Parámetros: `broadcastFolder` (path, integer, *) #### Conversations · Contact - `GET /api/v1/contacts` — permiso `conversations:read` · disponible Parámetros: `name` (query, string), `email` (query, string), `phone` (query, string), `whatsapp_id` (query, string), `country_codes[]` (query, array), `status` (query, string), `image` (query, string), `created_at` (query, string), `created_from` (query, string), `created_to` (query, string), `tag_ids[]` (query, array), `tag_mode` (query, string ∈ {"all", "any", "exclude"}), `custom_field_ids[]` (query, array), `custom_field_mode` (query, string ∈ {"all", "any", "exclude"}), `custom_field_value` (query, string), `custom_field_date_from` (query, string), `custom_field_date_to` (query, string), `custom_field_numeric_operator` (query, string ∈ {"lt", "eq", "gt"}), `custom_field_numeric_value` (query, number), `only_with_phone` (query, string), `only_with_mac` (query, string), `scopeToAdvisor` (query, string), `lead_score_categories[]` (query, array), `lead_score_min` (query, integer), `lead_score_max` (query, integer), `lead_score_disqualified` (query, string|null ∈ {"include", "exclude", "only", null}), `tag_filters` (query, array), `custom_field_filters` (query, array), `perPage` (query, integer), `orderBy` (query, string), `orderByMethod` (query, string ∈ {"asc", "desc"}) - `POST /api/v1/contacts` — permiso `conversations:write` · disponible Cuerpo (application/json): `phone` (string), `whatsapp_id` (string), `name` (string|null), `email` (string|null), `image` (string|null), `deviceId` (integer|null) - `POST /api/v1/contacts/update-or-create` — permiso `conversations:write` · disponible Cuerpo (application/json): `phone` (string, *), `name` (string|null), `email` (string|null) - `POST /api/v1/contacts/attach-custom-field` — permiso `conversations:write` · disponible - `POST /api/v1/contacts/detach-custom-field` — permiso `conversations:write` · disponible - `GET /api/v1/contacts/{contact}` — permiso `conversations:read` · disponible Parámetros: `contact` (path, integer, *) - `PUT /api/v1/contacts/{contact}` — permiso `conversations:write` · disponible Parámetros: `contact` (path, integer, *) Cuerpo (application/json): `name` (string|null), `email` (string|null), `phone` (string|null), `notes` (string|null), `status` (integer), `adviser_id` (integer|null), `active_conversation` (boolean), `favorite` (boolean), `fixed` (boolean), `image` (string|null), `can_execute_flows` (boolean), `manually_unread` (boolean) - `GET /api/v1/contacts/{contact}/tags` — permiso `conversations:read` · disponible Parámetros: `contact` (path, integer, *), `perPage` (query, integer) - `GET /api/v1/contacts/{contact}/custom-fields` — permiso `conversations:read` · disponible Parámetros: `contact` (path, integer, *), `name` (query, string), `perPage` (query, integer) - `GET /api/v1/contacts/{contact}/meta-window` — permiso `conversations:read` · disponible Parámetros: `contact` (path, integer, *), `device_id` (query, integer, *) - `GET /api/v1/devices/{device}/contacts` — permiso `conversations:read` · disponible Parámetros: `device` (path, integer, *), `perPage` (query, integer) - `POST /api/v1/contacts/delete` — permiso `conversations:write` · disponible Cuerpo (application/json): `all` (boolean), `contactIds` (array), `created_from` (string|null), `created_to` (string|null), `tag_mode` (string|null ∈ {"all", "any", "exclude", null}), `tag_ids` (array), `tag_filters` (array), `custom_field_mode` (string|null ∈ {"all", "any", "exclude", null}), `custom_field_ids` (array), `custom_field_value` (string|null), `custom_field_date_from` (string|null), `custom_field_date_to` (string|null), `custom_field_numeric_operator` (string|null ∈ {"lt", "eq", "gt", null}), `custom_field_numeric_value` (number|null), `custom_field_filters` (array), `country_codes` (array) #### Conversations · ContactAutomation - `POST /api/v1/contacts/{contact}/pause` — permiso `conversations:write` · disponible Parámetros: `contact` (path, integer, *) - `GET /api/v1/contacts/{contact}/flows` — permiso `conversations:read` · disponible Parámetros: `contact` (path, integer, *), `perPage` (query, integer) #### Conversations · ContactCard - `POST /api/v1/contacts/{contact}/message/contact-card` — permiso `conversations:send` · disponible Parámetros: `contact` (path, integer, *) Cuerpo (application/json): `device_id` (integer, *), `contact_card_ids` (array, *), `client_reference` (string|null) #### Conversations · ContactLeadScore - `GET /api/v1/contacts/{contact}/lead-score` — permiso `conversations:read` · disponible #### Conversations · ContactNote - `GET /api/v1/contacts/{contact}/notes` — permiso `conversations:read` · disponible Parámetros: `contact` (path, integer, *), `search` (query, string|null), `perPage` (query, integer) - `POST /api/v1/contacts/{contact}/notes` — permiso `conversations:write` · disponible Parámetros: `contact` (path, integer, *) Cuerpo (application/json): `body` (string, *) - `DELETE /api/v1/contacts/{contact}/notes/{note}` — permiso `conversations:write` · disponible Parámetros: `contact` (path, integer, *), `note` (path, integer, *) #### Conversations · Conversation - `GET /api/v1/conversations` — permiso `conversations:read` · disponible Parámetros: `favorites` (query, boolean), `ownContacts` (query, boolean), `ownAndUnassigned` (query, boolean), `perName` (query, string), `deviceId` (query, integer), `contactId` (query, integer), `advisorId` (query, integer), `advisorIds` (query, string), `activeConversation` (query, boolean), `tags` (query, string), `kanbanSettingId` (query, integer), `kanbanStageIds` (query, string), `unreadMessages` (query, boolean), `onlyWithMac` (query, boolean), `cursor` (query, string), `updated_since` (query, string), `paused` (query, string ∈ {"true", "false", "1", "0"}), `leadScoreCategories` (query, string|null), `leadScoreMin` (query, integer), `leadScoreMax` (query, integer), `leadScoreDisqualified` (query, string|null ∈ {"include", "exclude", "only", null}), `metaWindowOpen` (query, boolean), `metaWindowDeviceIds` (query, string|null), `lastMessageDateFrom` (query, string|null), `lastMessageDateTo` (query, string|null), `timezone` (query, string|null), `customFieldFilters` (query, string|null), `perPage` (query, integer), `orderBy` (query, string ∈ {"asc", "desc"}) - `GET /api/v1/conversations/count/unread` — permiso `conversations:read` · disponible Parámetros: `advisorIds` (query, string), `favorites` (query, boolean), `ownAndUnassigned` (query, boolean) - `GET /api/v1/conversations/count/open` — permiso `conversations:read` · disponible Parámetros: `advisorIds` (query, string), `favorites` (query, boolean), `ownAndUnassigned` (query, boolean) - `GET /api/v1/conversations/count/closed` — permiso `conversations:read` · disponible Parámetros: `advisorIds` (query, string), `favorites` (query, boolean), `ownAndUnassigned` (query, boolean) - `GET /api/v1/conversations/count/all` — permiso `conversations:read` · disponible Parámetros: `advisorIds` (query, string), `favorites` (query, boolean), `ownAndUnassigned` (query, boolean) - `POST /api/v1/conversations/mark-all-read` — permiso `conversations:write` · disponible Cuerpo (application/json): `advisorIds` (string), `favorites` (boolean), `ownAndUnassigned` (boolean) - `POST /api/v1/conversations/mark-all-unread` — permiso `conversations:write` · disponible Cuerpo (application/json): `advisorIds` (string), `favorites` (boolean), `ownAndUnassigned` (boolean) - `PUT /api/v1/contacts/{contact}/update` — permiso `conversations:write` · disponible Parámetros: `contact` (path, integer, *) Cuerpo (application/json): `favorite` (boolean), `active_conversation` (boolean), `unread_messages` (integer), `adviser_id` (integer|null), `device_id` (integer), `text` (string), `notes` (string|null), `custom_fields` (array), `manually_unread` (boolean) - `POST /api/v1/contacts/{contact}/message` — permiso `conversations:send` · disponible Parámetros: `contact` (path, integer, *) Cuerpo (application/json): `phone` (string), `whatsapp_id` (string), `device_id` (integer, *), `text` (string), `contact_id` (integer, *), `file` (string), `file_type` (string|null ∈ {"image", "video", "audio", "document", "sticker", null}), `file_name` (string|null), `temporal_uuid` (string), `note` (boolean), `options` (array), `reference_message_uid` (string|null), `client_reference` (string|null), `link_preview` (boolean), `link_preview_meta` (object) - `GET /api/v1/contacts/{contactId}/messages` — permiso `conversations:read` · disponible Parámetros: `contactId` (path, integer, *), `cursor` (query, string), `updated_since` (query, string), `perPage` (query, integer) #### Conversations · CustomField - `GET /api/v1/custom-fields` — permiso `conversations:read` · disponible Parámetros: `name` (query, string), `description` (query, string), `type` (query, integer), `withContactCount` (query, boolean), `perPage` (query, integer), `orderBy` (query, string ∈ {"name", "created_at", "updated_at"}), `orderByMethod` (query, string ∈ {"asc", "desc"}) - `POST /api/v1/custom-fields` — permiso `conversations:write` · disponible Cuerpo (application/json): `name` (string, *), `description` (string|null), `type` (integer, *) - `POST /api/v1/custom-fields/delete` — permiso `conversations:write` · disponible Cuerpo (application/json): `customFieldIds` (array, *) - `GET /api/v1/custom-fields/{customField}` — permiso `conversations:read` · disponible Parámetros: `customField` (path, integer, *) - `PUT /api/v1/custom-fields/{customField}` — permiso `conversations:write` · disponible Parámetros: `customField` (path, integer, *) Cuerpo (application/json): `name` (string), `description` (string|null) #### Conversations · Flow - `GET /api/v1/contact-flows/{contactId}` — permiso `conversations:read` · disponible Parámetros: `contactId` (path, integer, *), `search` (query, string), `status_group` (query, string ∈ {"en_curso", "completado", "invalidado", "cancelado"}), `date_from` (query, string), `date_to` (query, string), `perPage` (query, integer) - `PUT /api/v1/contact-flows/{contactFlowId}/cancel` — permiso `conversations:write` · disponible Parámetros: `contactFlowId` (path, integer, *) - `GET /api/v1/flows` — permiso `conversations:read` · disponible Parámetros: `name` (query, string), `folder_id` (query, integer), `unclassified` (query, string), `trigger_type` (query, string ∈ {"1", "2", "3", "4", "5", "6", "7", "inbound_automation"}), `status` (query, string ∈ {"active", "inactive"}), `date_from` (query, string), `date_to` (query, string), `date_field` (query, string ∈ {"created_at", "updated_at"}), `device_id` (query, string), `device_ids[]` (query, array), `perPage` (query, integer), `orderBy` (query, string ∈ {"name", "created_at", "updated_at"}), `orderByMethod` (query, string ∈ {"asc", "desc"}) - `GET /api/v1/flows/{flow}` — permiso `conversations:read` · disponible Parámetros: `flow` (path, integer, *) - `GET /api/v1/flows/{flow}/nodes` — permiso `conversations:read` · disponible Parámetros: `flow` (path, integer, *), `perPage` (query, integer) - `GET /api/v1/flows/{flow}/active-contacts` — permiso `conversations:read` · disponible Parámetros: `flow` (path, integer, *), `payment_event_type` (query, string ∈ {"approved", "pending", "rejected", "cancelled", "refunded", "chargeback"}) - `GET /api/v1/flows/{flow}/active-contacts-list` — permiso `conversations:read` · disponible Parámetros: `flow` (path, integer, *), `search` (query, string), `perPage` (query, integer) - `GET /api/v1/flows/{flow}/flow-contacts` — permiso `conversations:read` · disponible Parámetros: `flow` (path, integer, *), `search` (query, string), `status_group` (query, string ∈ {"en_curso", "completado", "invalidado", "cancelado"}), `date_from` (query, string), `date_to` (query, string), `perPage` (query, integer) #### Conversations · Folder - `GET /api/v1/folders` — permiso `conversations:read` · disponible Parámetros: `name` (query, string|null), `perPage` (query, integer) - `POST /api/v1/folders` — permiso `conversations:write` · disponible Cuerpo (application/json): `name` (string, *), `parent_id` (integer|null) - `GET /api/v1/folders/{folder}` — permiso `conversations:read` · disponible Parámetros: `folder` (path, integer, *) - `PUT /api/v1/folders/{folder}` — permiso `conversations:write` · disponible Parámetros: `folder` (path, integer, *) Cuerpo (application/json): `name` (string), `parent_id` (integer|null) - `DELETE /api/v1/folders/{folder}` — permiso `conversations:write` · disponible Parámetros: `folder` (path, integer, *) #### Conversations · ForwardMessage - `POST /api/v1/contacts/{contact}/messages/{message}/forward` — permiso `conversations:send` · disponible Parámetros: `contact` (path, integer, *), `message` (path, integer, *) Cuerpo (application/json): `targets` (array, *), `client_reference` (string|null) #### Conversations · KanbanSetting - `GET /api/v1/kanban-settings` — permiso `conversations:read` · disponible Parámetros: `perPage` (query, integer) - `POST /api/v1/kanban-settings` — permiso `conversations:write` · disponible Cuerpo (application/json): `name` (string, *), `mode` (string ∈ {"tags", "stages"}), `structure` (array), `stages` (array), `stagnant_days` (integer|null), `view_mode` (string|null ∈ {"compact", "extended", null}), `icon` (string|null), `description` (string|null), `is_primary` (boolean), `color_by_lead_score` (boolean) - `GET /api/v1/kanban-settings/{kanbanSetting}` — permiso `conversations:read` · disponible Parámetros: `kanbanSetting` (path, integer, *) - `PUT /api/v1/kanban-settings/{kanbanSetting}` — permiso `conversations:write` · disponible Parámetros: `kanbanSetting` (path, integer, *) Cuerpo (application/json): `name` (string|null), `mode` (string), `structure` (array), `stagnant_days` (integer|null), `view_mode` (string|null ∈ {"compact", "extended", null}), `icon` (string|null), `description` (string|null), `is_primary` (boolean), `color_by_lead_score` (boolean) - `DELETE /api/v1/kanban-settings/{kanbanSetting}` — permiso `conversations:write` · disponible Parámetros: `kanbanSetting` (path, integer, *) - `GET /api/v1/kanban-settings/{kanbanSetting}/members` — permiso `conversations:read` · disponible Parámetros: `kanbanSetting` (path, integer, *), `contact_ids[]` (query, array) #### Conversations · KanbanStage - `GET /api/v1/contacts/{contact}/kanban-stages` — permiso `conversations:read` · disponible Parámetros: `contact` (path, integer, *), `perPage` (query, integer) - `POST /api/v1/kanban-settings/{kanbanSetting}/move-contact` — permiso `conversations:write` · disponible Parámetros: `kanbanSetting` (path, integer, *) Cuerpo (application/json): `contact_id` (integer, *), `stage_id` (integer, *) - `POST /api/v1/kanban-settings/{kanbanSetting}/bulk-move` — permiso `conversations:write` · disponible Parámetros: `kanbanSetting` (path, integer, *) Cuerpo (application/json): `contact_ids` (array, *), `stage_id` (integer, *) - `POST /api/v1/kanban-settings/{kanbanSetting}/remove-contact` — permiso `conversations:write` · disponible Parámetros: `kanbanSetting` (path, integer, *) Cuerpo (application/json): `contact_id` (integer, *) - `POST /api/v1/kanban-settings/{kanbanSetting}/bulk-remove` — permiso `conversations:write` · disponible Parámetros: `kanbanSetting` (path, integer, *) Cuerpo (application/json): `contact_ids` (array, *) - `POST /api/v1/kanban-settings/{kanbanSetting}/stages` — permiso `conversations:write` · disponible Parámetros: `kanbanSetting` (path, integer, *) Cuerpo (application/json): `name` (string, *), `after_stage_id` (integer|null) - `POST /api/v1/kanban-settings/{kanbanSetting}/stages/reorder` — permiso `conversations:write` · disponible Parámetros: `kanbanSetting` (path, integer, *) Cuerpo (application/json): `stage_ids` (array, *) - `PUT /api/v1/kanban-settings/{kanbanSetting}/stages/{stage}` — permiso `conversations:write` · disponible Parámetros: `kanbanSetting` (path, integer, *), `stage` (path, integer, *) Cuerpo (application/json): `name` (string, *) - `DELETE /api/v1/kanban-settings/{kanbanSetting}/stages/{stage}` — permiso `conversations:write` · disponible Parámetros: `kanbanSetting` (path, integer, *), `stage` (path, integer, *), `reassign_to_stage_id` (query, integer|null) - `GET /api/v1/kanban-settings/{kanbanSetting}/stages/{stage}/contacts` — permiso `conversations:read` · disponible Parámetros: `kanbanSetting` (path, integer, *), `stage` (path, integer, *), `name` (query, string), `search` (query, string), `messages` (query, string ∈ {"all", "read", "unread"}), `dateFrom` (query, string), `dateTo` (query, string), `dateBasis` (query, string ∈ {"board_entry", "created", "last_activity"}), `adviserId` (query, string), `valueMin` (query, number), `valueMax` (query, number), `stagnant` (query, boolean), `tagIds[]` (query, array), `leadScoreCategories[]` (query, array), `leadScoreMin` (query, integer), `leadScoreMax` (query, integer), `leadScoreDisqualified` (query, string|null ∈ {"include", "exclude", "only", null}), `page` (query, integer), `perPage` (query, integer), `orderBy` (query, string ∈ {"name", "last_message_time", "stage_since"}), `orderByMethod` (query, string ∈ {"asc", "desc"}) #### Conversations · LeadScoreDistribution - `GET /api/v1/lead-score/distribution` — permiso `conversations:read` · disponible #### Conversations · LeadScoreEventIngest - `POST /api/v1/lead-score/events` — permiso `conversations:write` · disponible Cuerpo (application/json): `events` (array, *) #### Conversations · LeadScoreSettings - `GET /api/v1/lead-score/settings` — permiso `conversations:read` · disponible - `PUT /api/v1/lead-score/settings` — permiso `conversations:write` · disponible - `POST /api/v1/contacts/{contact}/lead-score/adjustments` — permiso `conversations:write` · disponible Parámetros: `contact` (path, integer, *) Cuerpo (application/json): `delta` (integer, *), `reason` (string, *) #### Conversations · Mac - `GET /api/v1/mac/status` — permiso `conversations:read` · disponible - `GET /api/v1/mac/contacts` — permiso `conversations:read` · disponible Parámetros: `perPage` (query, integer) - `GET /api/v1/mac/contacts/history` — permiso `conversations:read` · disponible Parámetros: `perPage` (query, integer) #### Conversations · Message - `POST /api/v1/message/send-message` — permiso `conversations:send` · disponible Cuerpo (application/json): `device_id` (integer, *), `text` (string), `contact_id` (integer, *), `file` (string), `file_type` (string|null ∈ {"image", "video", "audio", "document", "sticker", null}), `file_name` (string|null), `temporal_uuid` (string), `local_id` (string|null), `note` (boolean), `client_reference` (string|null) #### Conversations · MessageReaction - `PUT /api/v1/contacts/{contact}/messages/{message}/reaction` — permiso `conversations:send` · disponible Parámetros: `contact` (path, integer, *), `message` (path, integer, *) Cuerpo (application/json): `emoji` (string, *) - `DELETE /api/v1/contacts/{contact}/messages/{message}/reaction` — permiso `conversations:send` · disponible Parámetros: `contact` (path, integer, *), `message` (path, integer, *) #### Conversations · MessageTemplate - `GET /api/v1/templates` — permiso `conversations:read` · disponible Parámetros: `name` (query, string|null), `uid` (query, string|null), `language_code` (query, string|null), `status` (query, string|null ∈ {"PENDING", "APPROVED", "REJECTED", "PENDING_DELETION", "PAUSED", "DISABLED", "ARCHIVED", null}), `category` (query, string|null), `deviceId` (query, integer|null), `type` (query, string|null ∈ {"text", "image", "document", "video", "location", "default", null}), `created_by_webhook` (query, boolean), `perPage` (query, integer), `orderBy` (query, string ∈ {"name", "created_at", "updated_at"}), `orderByMethod` (query, string ∈ {"asc", "desc"}) - `POST /api/v1/templates` — permiso `conversations:write` · disponible Cuerpo (application/json): `name` (string, *), `language_code` (string, *), `category` (string ∈ {"UTILITY", "MARKETING", "AUTHENTICATION"}, *), `type` (string ∈ {"text", "image", "document", "video", "location", "default"}, *), `header` (string|null), `file` (string), `body` (string, *), `params` (array), `footer` (string|null), `buttons` (array) - `GET /api/v1/templates/{template}` — permiso `conversations:read` · disponible Parámetros: `template` (path, integer, *) - `PUT /api/v1/templates/{template}` — permiso `conversations:write` · disponible Parámetros: `template` (path, integer, *) Cuerpo (application/json): `device_id` (integer, *), `category` (string ∈ {"UTILITY", "MARKETING", "AUTHENTICATION"}, *), `type` (string ∈ {"text", "image", "document", "video", "location", "default"}, *), `header` (string|null), `file` (string), `body` (string, *), `params` (array), `footer` (string|null), `buttons` (array) - `GET /api/v1/templates/{template}/quick-reply-buttons` — permiso `conversations:read` · disponible Parámetros: `template` (path, integer, *) - `POST /api/v1/devices/{device}/templates/{template}/send-template` — permiso `conversations:send` · disponible Parámetros: `device` (path, integer, *), `template` (path, string, *) Cuerpo (application/json): `phone` (string, *), `contact_id` (integer, *), `template_variables` (object), `client_reference` (string|null) - `DELETE /api/v1/devices/{device}/templates/{template}` — permiso `conversations:write` · disponible Parámetros: `device` (path, integer, *), `template` (path, integer, *) #### Conversations · PaymentProduct - `GET /api/v1/payments/products/{platform}` — permiso `conversations:read` · disponible Parámetros: `platform` (path, string, *), `perPage` (query, integer) #### Conversations · QuickReply - `GET /api/v1/quick-replies` — permiso `conversations:read` · disponible Parámetros: `search` (query, string), `perPage` (query, integer), `orderBy` (query, string ∈ {"created_at", "updated_at"}), `orderByMethod` (query, string ∈ {"asc", "desc"}) - `POST /api/v1/quick-replies` — permiso `conversations:write` · disponible Cuerpo (application/json): `name` (string, *), `message` (string|null), `file` (string|null), `type` (integer, *), `visibility` (integer ∈ {1, 2, 3}, *), `agent_ids` (array) - `GET /api/v1/quick-replies/{quickReply}` — permiso `conversations:read` · disponible Parámetros: `quickReply` (path, integer, *) - `PUT /api/v1/quick-replies/{quickReply}` — permiso `conversations:write` · disponible Parámetros: `quickReply` (path, integer, *) Cuerpo (application/json): `name` (string, *), `message` (string|null), `file` (string|null), `type` (integer, *), `visibility` (integer ∈ {1, 2, 3}, *), `agent_ids` (array) - `DELETE /api/v1/quick-replies/{quickReply}` — permiso `conversations:write` · disponible Parámetros: `quickReply` (path, integer, *) #### Conversations · SalesDashboard - `GET /api/v1/sales/metrics` — permiso `conversations:read` · disponible Parámetros: `window` (query, integer ∈ {"7", "30", "90"}), `page` (query, integer), `per_page` (query, integer), `event_type[]` (query, array) - `GET /api/v1/sales/funnel` — permiso `conversations:read` · disponible Parámetros: `window` (query, integer ∈ {"7", "30", "90"}), `page` (query, integer), `per_page` (query, integer), `event_type[]` (query, array) - `GET /api/v1/sales/by-platform` — permiso `conversations:read` · disponible Parámetros: `window` (query, integer ∈ {"7", "30", "90"}), `page` (query, integer), `per_page` (query, integer), `event_type[]` (query, array) - `GET /api/v1/sales` — permiso `conversations:read` · disponible Parámetros: `window` (query, integer ∈ {"7", "30", "90"}), `page` (query, integer), `event_type[]` (query, array), `perPage` (query, integer) #### Conversations · ShowMessage - `GET /api/v1/contacts/{contact}/messages/{message}` — Devuelve un mensaje de tu cuenta por el id que te dio el envío o un webhook. Aceptado no es entregado: el estado final llega por `message.status_changed` o por este GET. `status` es `queued`, `sent`, `delivered`, `read` o `failed` en los mensajes que enviaste. `sent`, `delivered`, `read` y `failed` tienen el mismo significado que en `message.status_changed`; `queued` es un mensaje en cola que toda… · permiso `conversations:read` · disponible Parámetros: `contact` (path, integer, *), `message` (path, integer, *) #### Conversations · Tag - `GET /api/v1/tags` — permiso `conversations:read` · disponible Parámetros: `name` (query, string), `withContactCount` (query, boolean), `perPage` (query, integer), `orderBy` (query, string ∈ {"name", "created_at", "updated_at"}), `orderByMethod` (query, string ∈ {"asc", "desc"}) - `POST /api/v1/tags` — permiso `conversations:write` · disponible Cuerpo (application/json): `name` (string, *), `description` (string|null), `color` (string|null) - `GET /api/v1/tags/{tag}` — permiso `conversations:read` · disponible Parámetros: `tag` (path, integer, *), `withContactCount` (query, boolean) - `PUT /api/v1/tags/{tag}` — permiso `conversations:write` · disponible Parámetros: `tag` (path, integer, *) Cuerpo (application/json): `name` (string, *), `description` (string|null), `color` (string|null) - `GET /api/v1/tags/{tag}/contacts` — permiso `conversations:read` · disponible Parámetros: `tag` (path, integer, *), `perPage` (query, integer), `orderBy` (query, string ∈ {"name", "last_message_time"}), `orderByMethod` (query, string ∈ {"asc", "desc"}), `sortBy` (query, string ∈ {"asc", "desc"}) - `POST /api/v1/tags/delete` — permiso `conversations:write` · disponible Cuerpo (application/json): `tagIds` (array, *) #### Conversations · Ticket - `GET /api/v1/tickets` — permiso `conversations:read` · disponible Parámetros: `status` (query, integer ∈ {"0", "1", "2"}), `userId` (query, integer), `perPage` (query, integer) - `POST /api/v1/tickets` — permiso `conversations:write` · disponible Cuerpo (application/json): `userId` (integer, *), `contactId` (integer, *), `title` (string, *), `description` (string, *) - `GET /api/v1/tickets/{id}` — permiso `conversations:read` · disponible Parámetros: `id` (path, string, *) - `PUT /api/v1/tickets/{id}` — permiso `conversations:write` · disponible Parámetros: `id` (path, string, *) Cuerpo (application/json): `title` (string, *), `description` (string, *) - `POST /api/v1/tickets/assign` — permiso `conversations:write` · disponible Cuerpo (application/json): `ticketId` (integer, *), `userId` (integer, *) - `POST /api/v1/tickets/{id}/close` — permiso `conversations:write` · disponible Parámetros: `id` (path, string, *) - `POST /api/v1/tickets/{id}/reopen` — permiso `conversations:write` · disponible Parámetros: `id` (path, string, *) #### Conversations · User - `GET /api/v1/agents/{user}` — permiso `conversations:read` · disponible Parámetros: `user` (path, integer, *) - `GET /api/v1/agents` — permiso `conversations:read` · disponible Parámetros: `name` (query, string), `email` (query, string), `perPage` (query, integer), `orderBy` (query, string ∈ {"name", "email", "created_at"}), `orderByMethod` (query, string ∈ {"asc", "desc"}) #### Conversations · WebhookDelivery - `GET /api/v1/webhooks/{webhook}/deliveries` — Listar entregas de un webhook. Devuelve, de la más reciente a la más antigua, los intentos de entrega de una suscripción: estado, código HTTP recibido, duración y fecha del próximo reintento. Admite paginación con `perPage` (entero de 1 a 100; 25 si no se manda). · permiso `conversations:read` · disponible Parámetros: `webhook` (path, string, *), `page` (query, integer), `perPage` (query, integer) #### Conversations · WebhookEventCatalog - `GET /api/v1/webhooks/events` — Listar tipos de evento. Devuelve el catálogo de tipos de evento a los que se puede suscribir un webhook, con su estado, si es suscribible y un ejemplo del cuerpo que se envía. · permiso `conversations:read` · disponible #### Conversations · WebhookSubscription - `GET /api/v1/webhooks` — Listar webhooks. Devuelve las suscripciones de webhook de la cuenta, de la más nueva a la más antigua. Admite paginación con `perPage` (entero de 1 a 100; 25 si no se manda). · permiso `conversations:read` · disponible Parámetros: `page` (query, integer), `perPage` (query, integer) - `POST /api/v1/webhooks` — Crear un webhook. Crea una suscripción a uno o más tipos de evento. El secreto de firma se devuelve en esta respuesta una sola vez: guardalo, no se puede volver a consultar. · permiso `conversations:write` · disponible Cuerpo (application/json): `url` (string, *), `description` (string|null), `events` (array, *) - `GET /api/v1/webhooks/{webhook}` — Ver un webhook. Devuelve una suscripción de la cuenta por su identificador. · permiso `conversations:read` · disponible Parámetros: `webhook` (path, string, *) - `PUT /api/v1/webhooks/{webhook}` — Actualizar un webhook. Modifica la URL, los tipos de evento o el estado activo de una suscripción. · permiso `conversations:write` · disponible Parámetros: `webhook` (path, string, *) Cuerpo (application/json): `url` (string), `description` (string|null), `events` (array), `active` (boolean) - `DELETE /api/v1/webhooks/{webhook}` — Eliminar un webhook. Elimina una suscripción. Deja de recibir eventos de inmediato. · permiso `conversations:write` · disponible Parámetros: `webhook` (path, string, *) - `POST /api/v1/webhooks/{webhook}/rotate-secret` — Rotar el secreto de un webhook. Genera un secreto de firma nuevo y lo devuelve una sola vez. El anterior deja de ser válido. · permiso `conversations:write` · disponible Parámetros: `webhook` (path, string, *) #### Conversations · WebhookTest - `POST /api/v1/webhooks/{webhook}/test` — Enviar un evento de prueba. Envía ahora un evento `webhook.test` firmado a la URL de la suscripción y devuelve el intento como un ítem del historial de entregas; queda en el historial y no se reintenta. · permiso `conversations:write` · disponible Parámetros: `webhook` (path, string, *) #### Conversations · WhatsappForm - `GET /api/v1/forms/templates` — Devuelve las plantillas con las que puedes empezar un formulario nuevo: `key`, `title`, `description` y `category`. El catálogo es fijo y el mismo para todas las cuentas. · permiso `conversations:read` · disponible - `GET /api/v1/forms` — Lista los formularios de tu cuenta, uno por formulario aunque tenga varias versiones; de cada uno ves la versión que usas hoy (la publicada o, si no hay, la más nueva). El `id` de cada fila es el de esa versión y `family_id` es el del formulario. Filtra con `q` (parte del nombre), `status` (`draft`, `published` o `deprecated`) y `device_id` (un número de tu cuenta; si no es WhatsApp Cloud responde… · permiso `conversations:read` · disponible Parámetros: `q` (query, string|null), `status` (query, string|null ∈ {"draft", "published", "deprecated", null}), `device_id` (query, integer|null), `page` (query, integer|null), `perPage` (query, integer) - `POST /api/v1/forms` — Crea un formulario nuevo como borrador de tu cuenta. Envía `template_key` (una de las `key` de `GET /forms/templates`; `blank` empieza vacío), `device_id` (un número de WhatsApp Cloud de tu cuenta) y, si quieres, `device_ids` con otros números donde también vivirá. Responde 201 con la versión (`id`), su `family_id`, `lint` y `numbers`. Si un número no es de tu cuenta o no es WhatsApp Cloud respond… · permiso `conversations:write` · disponible Cuerpo (application/json): `device_id` (integer, *), `device_ids` (array), `template_key` (string, *) - `GET /api/v1/forms/families/{familyId}/metrics` — Devuelve los números de un formulario de tu cuenta: cuántos se enviaron, cuántos se abrieron y cuántos se completaron, y sus porcentajes. `{familyId}` es el `family_id` del formulario. Acepta los mismos filtros de fecha, versión y origen que la lista de respuestas; un filtro inválido responde 422. Responde 404 si el formulario no existe o no es de tu cuenta. · permiso `conversations:read` · disponible Parámetros: `familyId` (path, integer, *), `from` (query, string|null), `to` (query, string|null), `version_id` (query, integer|null), `origin` (query, string|null), `status` (query, string|null), `kind` (query, string|null), `effect_status` (query, string|null), `search` (query, string|null), `per_page` (query, integer|null), `page` (query, integer|null) - `GET /api/v1/forms/families/{familyId}/responses` — Lista las respuestas de un formulario de tu cuenta, de la más reciente a la más antigua, sumando todas sus versiones. Junto a `data` viene `columns`, las preguntas que usan esas respuestas. `{familyId}` es el `family_id` del formulario. Filtra con `from` y `to` (fechas), `version_id` (el `id` de una versión de ese formulario), `origin`, `status` y `search` (nombre o teléfono del contacto). Un filt… · permiso `conversations:read` · disponible Parámetros: `familyId` (path, integer, *), `from` (query, string|null), `to` (query, string|null), `version_id` (query, integer|null), `origin` (query, string|null), `status` (query, string|null), `kind` (query, string|null), `effect_status` (query, string|null), `search` (query, string|null), `page` (query, integer|null), `perPage` (query, integer) - `GET /api/v1/forms/families/{familyId}/responses/{runId}` — Devuelve una respuesta completa de un formulario de tu cuenta: las respuestas del contacto y lo que se hizo con ellas. `{familyId}` es el `family_id` del formulario y `{runId}` el `id` de la respuesta que viene en `GET /forms/families/{familyId}/responses`. Responde 404 si no existe o no es de tu cuenta. · permiso `conversations:read` · disponible Parámetros: `familyId` (path, integer, *), `runId` (path, integer, *) - `GET /api/v1/forms/families/{familyId}/versions` — Lista las versiones de un formulario de tu cuenta, de la más nueva a la más vieja, con su estado, si es la vigente y cuántas respuestas recibió cada una. `{familyId}` es el `family_id` del formulario. Pagina con `perPage` (1 a 100, 25 por defecto). Responde 404 si el formulario no existe o no es de tu cuenta. · permiso `conversations:read` · disponible Parámetros: `familyId` (path, integer, *), `perPage` (query, integer) - `GET /api/v1/forms/families/{familyId}/definition` — Devuelve la definición de un formulario: la de la versión publicada o, si todavía no hay ninguna, la de la versión más nueva. `{familyId}` es el `family_id` del formulario, no el `id` de una versión. Responde 404 si el formulario no existe o no es de tu cuenta. · permiso `conversations:read` · disponible Parámetros: `familyId` (path, integer, *) - `GET /api/v1/forms/{id}` — Devuelve una versión de un formulario de tu cuenta con su definición y su detalle. `{id}` es el `id` de la versión (el de las filas de `GET /forms`), no el `family_id`. Trae además `lint` (lo que falta corregir antes de publicar), `versions` (las versiones del formulario) y `numbers` (los números donde vive). Responde 404 si la versión no existe o no es de tu cuenta. · permiso `conversations:read` · disponible Parámetros: `id` (path, integer, *) - `PUT /api/v1/forms/{id}` — Guarda la definición de un borrador. `{id}` es el `id` de la versión. Envía `definition` con `screens` (cada pantalla con `id` de texto y `items`; cada bloque con `type`, y las preguntas con `name`) y, si quieres, `name` y `expected_updated_at`. Una definición con la forma rota responde 422 con el error en su ruta. Lo incompleto (sin título, sin bloques, etiquetas vacías) se guarda y la respuesta … · permiso `conversations:write` · disponible Parámetros: `id` (path, integer, *) Cuerpo (application/json): `name` (string), `definition` (object, *), `expected_updated_at` (string|null) - `DELETE /api/v1/forms/{id}` — Elimina un borrador. `{id}` es el `id` de la versión. Un formulario publicado no se elimina: responde 409 `PUBLISHED`. Responde 404 si la versión no existe o no es de tu cuenta. · permiso `conversations:write` · disponible Parámetros: `id` (path, integer, *) - `GET /api/v1/forms/{id}/numbers` — Lista los números de WhatsApp Cloud donde vive una versión de formulario de tu cuenta y el estado de cada uno (`draft`, `publishing`, `published` o `failed`). El primero es el número con el que se creó. `{id}` es el `id` de la versión. Responde 404 si la versión no existe o no es de tu cuenta. · permiso `conversations:read` · disponible Parámetros: `id` (path, integer, *) - `POST /api/v1/forms/{id}/numbers` — Suma un número de WhatsApp Cloud a una versión de formulario. `{id}` es el `id` de la versión y `device_id` el número de tu cuenta. Si la versión ya está publicada, se publica en ese número de inmediato. Responde `numbers` con el estado de cada número; si el número no es de tu cuenta o no es WhatsApp Cloud responde 422 `NUMBER_NOT_ELIGIBLE`. Puedes llamarla hasta 30 veces por minuto. · permiso `conversations:write` · disponible Parámetros: `id` (path, integer, *) Cuerpo (application/json): `device_id` (integer, *) - `POST /api/v1/forms/{id}/versions` — Crea la versión siguiente de un formulario publicado, como borrador, para poder cambiarlo. `{id}` es el `id` de la versión desde la que partes. Responde 201 con la versión nueva, o 200 con `created: false` si el formulario ya tenía un borrador (ese borrador es la respuesta). Responde 404 si la versión no existe o no es de tu cuenta. · permiso `conversations:write` · disponible Parámetros: `id` (path, integer, *) - `POST /api/v1/forms/{id}/sync` — Valida una versión en Meta sin publicarla. `{id}` es el `id` de la versión. Responde la versión con `lint` y lo que Meta observó. Si Meta rechaza la llamada responde 502 `META`. Responde 404 si no existe o no es de tu cuenta. Puedes llamarla hasta 30 veces por minuto. · permiso `conversations:write` · disponible Parámetros: `id` (path, integer, *) - `POST /api/v1/forms/{id}/publish` — Publica una versión en Meta; la publicación es inmediata. `{id}` es el `id` de la versión. Si la definición tiene errores responde 422 `LINT` con la lista; si Meta rechaza la llamada, 502 `META`. Los otros números del formulario se publican en segundo plano: consulta su estado en `GET /forms/{id}/numbers`. Responde 404 si no existe o no es de tu cuenta. Puedes llamarla hasta 30 veces por minuto. · permiso `conversations:write` · disponible Parámetros: `id` (path, integer, *) - `DELETE /api/v1/forms/{id}/numbers/{deviceId}` — Quita un número de una versión de formulario: deja de enviarse por él. `{id}` es el `id` de la versión y `{deviceId}` el número de tu cuenta. El número con el que se creó el formulario no se quita: responde 422 `PRIMARY_NUMBER`. Responde 404 si la versión o el número no existen o no son de tu cuenta. · permiso `conversations:write` · disponible Parámetros: `id` (path, integer, *), `deviceId` (path, integer, *) - `POST /api/v1/forms/{id}/numbers/retry` — Reintenta publicar los números que fallaron o que aún no se publicaron en una versión ya publicada. `{id}` es el `id` de la versión. Responde `numbers` con el estado de cada número. Responde 404 si no existe o no es de tu cuenta. · permiso `conversations:write` · disponible Parámetros: `id` (path, integer, *) #### Conversations · WhatsappFormSend - `POST /api/v1/contacts/{contact}/message/form` — Envía a un contacto un formulario de WhatsApp publicado. `form_id` es el identificador estable del formulario (el mismo en todas sus versiones) y `device_id` el número de WhatsApp Cloud de tu cuenta por el que sale. `text` (hasta 1024 caracteres) acompaña al formulario y por defecto es su nombre; `button_text` (hasta 30 caracteres, sin emojis) es el botón y por defecto es «Abrir» u «Open» según el… · permiso `conversations:send` · disponible Parámetros: `contact` (path, integer, *) Cuerpo (application/json): `form_id` (integer, *), `device_id` (integer, *), `text` (string), `button_text` (string), `client_reference` (string|null) ### Communities URL base: `https://api.funnelchat.com` (URL = URL base + ruta completa; el `servers` de la especificación es `https://api.funnelchat.com/api` y sus `paths` son relativos a eso) · instantánea 6f32f0b4 (árbol con cambios sin commitear) · 2026-10-22 09:46 (-03:00) · 53 operaciones, 53 disponibles para claves en v0 según el contrato. Formato: `MÉTODO ruta-completa` — para qué (si la especificación lo dice) · permiso · disponibilidad en v0. Parámetros: `nombre` (lugar, tipo, * = obligatorio). Cuerpo: `campo` (tipo, * = obligatorio). Agrupado por la etiqueta (tag) de la especificación. #### Communities · Campañas - `GET /api/v1/campaigns` — Lista las campañas de la cuenta. **Con API key:** la respuesta siempre viene paginada. Tamaño de página: `perPage` (por defecto 25, máximo 100; un valor mayor, menor que 1 o no entero responde `422`). Orden: `orderBy` acepta `created_at`, `id`, `name`, `updated_at`; dirección `orderByMethod`: `asc` o `desc`. Otro valor responde `422`. · permiso `communities:read` · disponible Parámetros: `name` (query, string), `orderBy` (query, string), `orderByMethod` (query, string), `perPage` (query, integer) - `GET /api/v1/campaigns/{campaign}` — Detalle de una campaña · permiso `communities:read` · disponible Parámetros: `campaign` (path, integer, *) - `GET /api/v1/campaigns/{campaign}/conditionals` — Condicionales de la campaña · permiso `communities:read` · disponible Parámetros: `campaign` (path, integer, *) #### Communities · Grupos de una campaña y operaciones - `GET /api/v1/campaigns/{campaign}/available-groups` — Grupos de la cuenta que se pueden sumar a la campaña. **Con API key:** la respuesta siempre viene paginada. Tamaño de página: `pageSize` (por defecto 25, máximo 100; un valor mayor, menor que 1 o no entero responde `422`). · permiso `communities:read` · disponible Parámetros: `campaign` (path, integer, *), `device_id` (query, integer, *), `type` (query, string ∈ {"groups", "ad-groups", "channels"}, *), `page` (query, integer), `pageSize` (query, integer), `name` (query, string|null) - `GET /api/v1/campaigns/{campaign}/groups` — Grupos de la campaña. **Con API key:** la respuesta siempre viene paginada. Tamaño de página: `perPage` (por defecto 25, máximo 100; un valor mayor, menor que 1 o no entero responde `422`). Orden: `orderBy` acepta `id`, `name`, `created_at`, `updated_at`; dirección `orderByMethod`: `asc` o `desc`. Otro valor responde `422`. · permiso `communities:read` · disponible Parámetros: `campaign` (path, integer, *), `name` (query, string), `orderBy` (query, string), `orderByMethod` (query, string), `perPage` (query, integer), `deviceId` (query, integer), `status` (query, integer), `origin` (query, integer ∈ {"0", "1"}), `adminConnection` (query, integer ∈ {"1", "2", "3"}), `adminPhone` (query, string) - `GET /api/v1/campaigns/{campaign}/pipelines` — Pipelines de operaciones de la campaña. **Con API key:** la respuesta siempre viene paginada. Tamaño de página: `per_page` (por defecto 25, máximo 100; un valor mayor, menor que 1 o no entero responde `422`). Orden: `orderBy` acepta `id`, `name`, `group_index`, `created_at`, `updated_at`; dirección `orderByMethod`: `asc` o `desc`. Otro valor responde `422`. · permiso `communities:read` · disponible Parámetros: `campaign` (path, integer, *), `page` (query, integer), `per_page` (query, integer), `status` (query, string ∈ {"scheduled", "running", "completed", "failed", "cancelled"}), `action_type` (query, string), `orderBy` (query, string ∈ {"id", "name", "group_index", "created_at", "updated_at"}), `orderByMethod` (query, string ∈ {"asc", "desc"}) - `GET /api/v1/campaigns/{campaign}/pipelines/{pipeline}` — Detalle de un pipeline · permiso `communities:read` · disponible Parámetros: `campaign` (path, integer, *), `pipeline` (path, integer, *) - `GET /api/v1/campaigns/{campaign}/operation-plans/config` — Configuración disponible para planes · permiso `communities:read` · disponible Parámetros: `campaign` (path, integer, *) - `GET /api/v1/campaigns/{campaign}/operation-plans` — Planes de operación de la campaña. **Con API key:** la respuesta siempre viene paginada. Tamaño de página: `per_page` (por defecto 25, máximo 100; un valor mayor, menor que 1 o no entero responde `422`). · permiso `communities:read` · disponible Parámetros: `campaign` (path, integer, *), `page` (query, integer), `per_page` (query, integer) - `GET /api/v1/campaigns/{campaign}/operation-plans/{plan}` — Detalle de un plan · permiso `communities:read` · disponible Parámetros: `campaign` (path, integer, *), `plan` (path, integer, *) - `GET /api/v1/campaigns/{campaign}/actions` — Acciones de sesión ejecutadas para la campaña. **Con API key:** página fija de 15 elementos; se recorre con `page`. · permiso `communities:read` · disponible Parámetros: `campaign` (path, integer, *) - `GET /api/v1/campaigns/{campaign}/actions/{action}/groups` — Grupos alcanzados por una acción. **Con API key:** la respuesta siempre viene paginada. Tamaño de página: `perPage` (por defecto 25, máximo 100; un valor mayor, menor que 1 o no entero responde `422`). Orden: `orderBy` acepta `name`, `status`, `created_at`, `updated_at`; dirección `orderByMethod`: `asc` o `desc`. Otro valor responde `422`. · permiso `communities:read` · disponible Parámetros: `campaign` (path, integer, *), `action` (path, integer, *), `name` (query, string), `orderBy` (query, string ∈ {"name", "status", "created_at", "updated_at"}), `orderByMethod` (query, string ∈ {"asc", "desc"}), `page` (query, integer), `perPage` (query, integer) #### Communities · Grupos - `GET /api/v1/groups` — Lista los grupos de la cuenta. **Con API key:** la respuesta siempre viene paginada. Tamaño de página: `perPage` (por defecto 25, máximo 100; un valor mayor, menor que 1 o no entero responde `422`). Orden: `orderBy` acepta `created_at`, `id`, `name`, `updated_at`; dirección `orderByMethod`: `asc` o `desc`. Otro valor responde `422`. · permiso `communities:read` · disponible Parámetros: `name` (query, string), `orderBy` (query, string), `orderByMethod` (query, string), `type` (query, string ∈ {"1", "2", "3", "4"}), `perPage` (query, integer), `status` (query, string), `deviceId` (query, integer), `noCampaign` (query, boolean) - `GET /api/v1/devices/{device}/groups` — Grupos que el número administra, según el proveedor. **Con API key:** la respuesta siempre viene paginada. Tamaño de página: `pageSize` (por defecto 25, máximo 100; un valor mayor, menor que 1 o no entero responde `422`). · permiso `communities:read` · disponible Parámetros: `device` (path, integer, *), `page` (query, integer), `pageSize` (query, integer), `name` (query, string|null) - `GET /api/v1/devices/{device}/ad-groups` — Grupos de anuncios del número. **Con API key:** la respuesta siempre viene paginada. Tamaño de página: `pageSize` (por defecto 25, máximo 100; un valor mayor, menor que 1 o no entero responde `422`). · permiso `communities:read` · disponible Parámetros: `device` (path, integer, *), `page` (query, integer), `pageSize` (query, integer), `name` (query, string|null) - `GET /api/v1/devices/{device}/channels` — Canales del número. **Con API key:** la respuesta siempre viene paginada. Tamaño de página: `pageSize` (por defecto 25, máximo 100; un valor mayor, menor que 1 o no entero responde `422`). · permiso `communities:read` · disponible Parámetros: `device` (path, integer, *), `name` (query, string|null), `page` (query, integer), `pageSize` (query, integer) - `GET /api/v1/groups/{groupUid}/metadata` — Nombre, foto y descripción actuales en WhatsApp · permiso `communities:read` · disponible Parámetros: `groupUid` (path, string, *), `device_id` (query, integer, *), `group_uid` (query, string, *), `type` (query, string ∈ {"groups", "ad-groups", "channels"}) - `GET /api/v1/groups/{group}` — Detalle de un grupo · permiso `communities:read` · disponible Parámetros: `group` (path, string, *) - `GET /api/v1/groups/{group}/actions` — Acciones de sesión sobre el grupo. **Con API key:** la respuesta siempre viene paginada. Tamaño de página: `perPage` (por defecto 25, máximo 100; un valor mayor, menor que 1 o no entero responde `422`). Orden: `orderBy` acepta `status`, `created_at`, `updated_at`, `completed_at`; dirección `orderByMethod`: `asc` o `desc`. Otro valor responde `422`. · permiso `communities:read` · disponible Parámetros: `group` (path, string, *), `orderBy` (query, string ∈ {"status", "created_at", "updated_at", "completed_at"}), `orderByMethod` (query, string ∈ {"asc", "desc"}), `page` (query, integer), `perPage` (query, integer) - `GET /api/v1/groups/{group}/messages` — Mensajes enviados al grupo. **Con API key:** página fija de 3 elementos; se recorre con `page`. · permiso `communities:read` · disponible Parámetros: `group` (path, string, *) - `GET /api/v1/groups/{group}/scheduled-messages` — Envíos programados que incluyen al grupo. **Con API key:** la respuesta siempre viene paginada. Tamaño de página: `perPage` (por defecto 25, máximo 100; un valor mayor, menor que 1 o no entero responde `422`). Orden: `orderBy` acepta `send_at`, `name`, `status`, `id`, `created_at`, `updated_at`; dirección `orderByMethod`: `asc` o `desc`. Otro valor responde `422`. · permiso `communities:read` · disponible Parámetros: `group` (path, string, *), `name` (query, string), `orderBy` (query, string ∈ {"send_at", "name", "status", "id", "created_at", "updated_at"}), `orderByMethod` (query, string ∈ {"ASC", "DESC"}), `perPage` (query, integer) #### Communities · Participantes - `GET /api/v1/campaigns/{campaign}/participants` — Participantes de la campaña. **Con API key:** la respuesta siempre viene paginada. Tamaño de página: `perPage` (por defecto 25, máximo 100; un valor mayor, menor que 1 o no entero responde `422`). Orden: `orderBy` acepta `updated_at`, `id`, `created_at`; dirección `orderByMethod`: `asc` o `desc`. Otro valor responde `422`. · permiso `communities:read` · disponible Parámetros: `campaign` (path, integer, *), `phone` (query, string|null), `orderBy` (query, string|null), `orderByMethod` (query, string|null ∈ {"asc", "desc", null}), `perPage` (query, integer|null) - `GET /api/v1/campaigns/{campaign}/campaign-participants` — Participantes con su grupo actual. **Con API key:** la respuesta siempre viene paginada. Tamaño de página: `perPage` (por defecto 25, máximo 100; un valor mayor, menor que 1 o no entero responde `422`). Orden: `orderBy` acepta `joined_at`, `left_at`; dirección `orderByMethod`: `asc` o `desc`. Otro valor responde `422`. · permiso `communities:read` · disponible Parámetros: `campaign` (path, integer, *), `search` (query, string|null), `status` (query, string|null ∈ {"active", "exited", null}), `orderBy` (query, string|null ∈ {"joined_at", "left_at", null}), `orderByMethod` (query, string|null ∈ {"asc", "desc", "ASC", "DESC", null}), `perPage` (query, integer|null), `start_date` (query, string|null), `end_date` (query, string|null), `timezone` (query, string|null), `date_rule` (query, integer|null ∈ {"1", "2", "3", null}), `selection_type` (query, string|null ∈ {"all", "none", null}), `groups_uids[]` (query, array) - `GET /api/v1/campaigns/{campaign}/participant-events` — Entradas y salidas en toda la campaña. **Con API key:** la respuesta siempre viene paginada. Tamaño de página: `perPage` (por defecto 25, máximo 100; un valor mayor, menor que 1 o no entero responde `422`). · permiso `communities:read` · disponible Parámetros: `campaign` (path, integer, *), `search` (query, string|null), `event_type` (query, string|null ∈ {"all", "join", "leave", null}), `page` (query, integer|null), `perPage` (query, integer|null), `start_date` (query, string|null), `end_date` (query, string|null), `timezone` (query, string|null), `date_mode` (query, string|null ∈ {"both", "joins_only", "leaves_only", null}), `selection_type` (query, string|null ∈ {"all", "none", null}), `groups_uids[]` (query, array), `participant_filter` (query, string|null ∈ {"all", "active", null}) - `GET /api/v1/groups/participants` — Participantes de un grupo. **Con API key:** la respuesta siempre viene paginada. Tamaño de página: `perPage` (por defecto 25, máximo 100; un valor mayor, menor que 1 o no entero responde `422`). Orden: `orderBy` acepta `created_at`, `id`, `updated_at`; dirección `orderDirection`: `asc` o `desc`. Otro valor responde `422`. · permiso `communities:read` · disponible Parámetros: `groupUid` (query, string, *), `phone` (query, string), `isAdmin` (query, string ∈ {"true", "false", "1", "0"}), `eligibleForAdmin` (query, string ∈ {"true", "false", "1", "0"}), `orderBy` (query, string), `orderDirection` (query, string ∈ {"asc", "desc"}), `perPage` (query, integer) - `GET /api/v1/groups/{group}/participant-events` — Entradas y salidas de un grupo. **Con API key:** la respuesta siempre viene paginada. Tamaño de página: `perPage` (por defecto 25, máximo 100; un valor mayor, menor que 1 o no entero responde `422`). · permiso `communities:read` · disponible Parámetros: `group` (path, string, *), `search` (query, string|null), `event_type` (query, string|null ∈ {"all", "join", "leave", null}), `page` (query, integer|null), `perPage` (query, integer|null), `start_date` (query, string|null), `end_date` (query, string|null), `timezone` (query, string|null), `date_mode` (query, string|null ∈ {"both", "joins_only", "leaves_only", null}), `selection_type` (query, string|null ∈ {"all", "none", null}), `groups_uids[]` (query, array), `participant_filter` (query, string|null ∈ {"all", "active", null}) #### Communities · Mensajes programados - `GET /api/v1/scheduled-messages` — Lista los envíos programados. **Con API key:** la respuesta siempre viene paginada. Tamaño de página: `perPage` (por defecto 25, máximo 100; un valor mayor, menor que 1 o no entero responde `422`). Orden: `orderBy` acepta `created_at`, `id`, `name`, `updated_at`; dirección `orderByMethod`: `asc` o `desc`. Otro valor responde `422`. · permiso `communities:read` · disponible Parámetros: `name` (query, string), `orderBy` (query, string), `orderByMethod` (query, string), `perPage` (query, integer), `status` (query, string), `only_needs_connection` (query, string ∈ {"true", "false", "1", "0"}), `folder_id` (query, integer|null), `unclassified` (query, string ∈ {"true", "false", "1", "0"}) - `GET /api/v1/scheduled-messages/{scheduledMessage}` — Detalle de un envío · permiso `communities:read` · disponible Parámetros: `scheduledMessage` (path, string, *) - `GET /api/v1/scheduled-messages/{scheduledMessage}/groups` — Grupos destino del envío. **Con API key:** página fija de 3 elementos; se recorre con `page`. Orden: `orderBy` acepta `created_at`, `id`, `name`; dirección `orderByMethod`: `asc` o `desc`. Otro valor responde `422`. · permiso `communities:read` · disponible Parámetros: `scheduledMessage` (path, string, *), `name` (query, string), `orderBy` (query, string), `orderByMethod` (query, string), `perPage` (query, integer), `status` (query, integer) - `GET /api/v1/scheduled-messages/{scheduledMessage}/group-messages` — Resultado por grupo. **Con API key:** la respuesta siempre viene paginada. Tamaño de página: `perPage` (por defecto 25, máximo 100; un valor mayor, menor que 1 o no entero responde `422`). Orden: `orderBy` acepta `group_message_template.id`, `group_message_template.status`, `groups.name`, `group_message_template.created_at`; dirección `orderByMethod`: `asc` o `desc`. Otro valor responde `422`. · permiso `communities:read` · disponible Parámetros: `scheduledMessage` (path, string, *), `name` (query, string), `orderBy` (query, string ∈ {"group_message_template.id", "group_message_template.status", "groups.name", "group_message_template.created_at"}), `orderByMethod` (query, string ∈ {"asc", "desc"}), `perPage` (query, integer), `status` (query, integer), `edit_status` (query, integer ∈ {1, 2, 3, 4}), `run` (query, string) - `GET /api/v1/scheduled-messages/{scheduledMessage}/readed-played-rates` — Tasas de lectura y reproducción · permiso `communities:read` · disponible Parámetros: `scheduledMessage` (path, string, *), `run` (query, string) - `GET /api/v1/scheduled-messages/{scheduledMessage}/runs` — Ocurrencias ejecutadas de un envío recurrente. **Con API key:** la respuesta siempre viene paginada. Tamaño de página: `perPage` (por defecto 25, máximo 100; un valor mayor, menor que 1 o no entero responde `422`). · permiso `communities:read` · disponible Parámetros: `scheduledMessage` (path, string, *), `perPage` (query, integer), `page` (query, integer) #### Communities · Respuestas y carpetas - `GET /api/v1/scheduled-messages/folders` — Carpetas de envíos · permiso `communities:read` · disponible - `GET /api/v1/scheduled-messages/{scheduledMessage}/responses` — Respuestas recibidas a un envío. **Con API key:** la respuesta siempre viene paginada. Tamaño de página: `perPage` (por defecto 25, máximo 100; un valor mayor, menor que 1 o no entero responde `422`). · permiso `communities:read` · disponible Parámetros: `scheduledMessage` (path, string, *), `page` (query, integer), `perPage` (query, integer), `type` (query, string ∈ {"poll", "event"}) - `GET /api/v1/scheduled-messages/{scheduledMessage}/response-tags` — Etiquetas que se aplican a quien responde · permiso `communities:read` · disponible Parámetros: `scheduledMessage` (path, string, *) #### Communities · Estadísticas - `GET /api/v1/stats/participants` — Participantes por período. **Con API key:** `start_date` y `end_date` son obligatorios, con formato `AAAA-MM-DD`, y el rango entre los dos, ambos incluidos, admite como máximo 366 días (un año completo, bisiesto incluido). Si falta una de las dos fechas, si una es inválida, si `end_date` es anterior a `start_date` o si el rango es mayor, responde `422`. · permiso `communities:read` · disponible Parámetros: `start_date` (query, string), `end_date` (query, string), `unit` (query, string ∈ {"day", "month", "hour"}), `type` (query, string ∈ {"in", "out"}), `target` (query, string ∈ {"groups", "campaigns"}), `ids[]` (query, array), `fill_missing` (query, boolean) - `GET /api/v1/stats/active-participants` — Participantes activos. **Con API key:** `start_date` y `end_date` son obligatorios, con formato `AAAA-MM-DD`, y el rango entre los dos, ambos incluidos, admite como máximo 366 días (un año completo, bisiesto incluido). Si falta una de las dos fechas, si una es inválida, si `end_date` es anterior a `start_date` o si el rango es mayor, responde `422`. · permiso `communities:read` · disponible Parámetros: `start_date` (query, string), `end_date` (query, string), `unit` (query, string ∈ {"day", "month", "hour"}), `target` (query, string ∈ {"groups", "campaigns"}), `ids[]` (query, array), `status` (query, string ∈ {"active", "inactive"}) - `GET /api/v1/stats/groups` — Grupos por estado. **Con API key:** `start_date` y `end_date` son obligatorios, con formato `AAAA-MM-DD`, y el rango entre los dos, ambos incluidos, admite como máximo 366 días (un año completo, bisiesto incluido). Si falta una de las dos fechas, si una es inválida, si `end_date` es anterior a `start_date` o si el rango es mayor, responde `422`. · permiso `communities:read` · disponible Parámetros: `start_date` (query, string), `end_date` (query, string), `unit` (query, string ∈ {"day", "month", "hour"}, *), `fill_missing` (query, boolean) - `GET /api/v1/stats/clicks` — Clics en links. **Con API key:** `start_date` y `end_date` son obligatorios, con formato `AAAA-MM-DD`, y el rango entre los dos, ambos incluidos, admite como máximo 366 días (un año completo, bisiesto incluido). Si falta una de las dos fechas, si una es inválida, si `end_date` es anterior a `start_date` o si el rango es mayor, responde `422`. · permiso `communities:read` · disponible Parámetros: `start_date` (query, string), `end_date` (query, string), `unit` (query, string ∈ {"day", "month", "hour"}), `target` (query, string ∈ {"groups", "campaigns"}), `ids[]` (query, array), `fill_missing` (query, boolean) - `GET /api/v1/stats/records` — Registros. **Con API key:** la respuesta siempre viene paginada. Tamaño de página: `per_page` (por defecto 25, máximo 100; un valor mayor, menor que 1 o no entero responde `422`). · permiso `communities:read` · disponible Parámetros: `start_date` (query, string), `end_date` (query, string), `target` (query, string ∈ {"groups", "campaigns"}), `ids[]` (query, array), `page` (query, integer), `per_page` (query, integer), `devices[]` (query, array), `countries[]` (query, array), `search` (query, string) - `GET /api/v1/stats/country-metrics` — Métricas por país · permiso `communities:read` · disponible Parámetros: `start_date` (query, string), `end_date` (query, string), `target` (query, string ∈ {"groups", "campaigns"}), `ids[]` (query, array), `countries[]` (query, array) - `GET /api/v1/stats/countries` — Países presentes · permiso `communities:read` · disponible #### Communities · Webhooks de campaña (legado) - `GET /api/v1/campaigns/{campaign}/webhooks` — Webhooks configurados en la campaña. **Nota:** Webhooks legados de campaña, con otro formato; no reciben los eventos de plataforma (ver `/webhooks`). · permiso `communities:read` · disponible Parámetros: `campaign` (path, integer, *) - `GET /api/v1/campaigns/{campaign}/webhooks/logs` — Entregas de esos webhooks. **Con API key:** la respuesta siempre viene paginada. Tamaño de página: `per_page` (por defecto 25, máximo 100; un valor mayor, menor que 1 o no entero responde `422`). · permiso `communities:read` · disponible Parámetros: `campaign` (path, integer, *), `start_date` (query, string), `end_date` (query, string), `status` (query, integer ∈ {"0", "1", "2"}), `type` (query, integer ∈ {"0", "1", "2"}), `per_page` (query, integer) #### Communities · Webhooks - `GET /api/v1/webhooks` — Lista las suscripciones de la cuenta. **Con API key:** la respuesta siempre viene paginada. Tamaño de página: `per_page` o `perPage` (por defecto 25, máximo 100; un valor mayor, menor que 1 o no entero responde `422`; mandar los dos con valores distintos también responde `422`). · permiso `communities:read` · disponible Parámetros: `per_page` (query, integer), `page` (query, integer), `perPage` (query, integer) - `POST /api/v1/webhooks` — Crea una suscripción. **Nota:** Hasta 10 activas por cuenta; la URL tiene que ser `https` y pública. · permiso `communities:write` · disponible — En v0, communities:write solo administra suscripciones de webhooks. Cuerpo (application/json): `url` (string, *), `description` (string|null), `events` (array, *) - `GET /api/v1/webhooks/events` — Catálogo de eventos con un ejemplo de cada uno · permiso `communities:read` · disponible - `GET /api/v1/webhooks/{webhook}` — Detalle de una suscripción · permiso `communities:read` · disponible Parámetros: `webhook` (path, string, *) - `PUT /api/v1/webhooks/{webhook}` — Edita una suscripción · permiso `communities:write` · disponible — En v0, communities:write solo administra suscripciones de webhooks. Parámetros: `webhook` (path, string, *) Cuerpo (application/json): `url` (string), `description` (string|null), `events` (array), `active` (boolean) - `DELETE /api/v1/webhooks/{webhook}` — Borra una suscripción · permiso `communities:write` · disponible — En v0, communities:write solo administra suscripciones de webhooks. Parámetros: `webhook` (path, string, *) - `POST /api/v1/webhooks/{webhook}/rotate-secret` — Genera un secreto de firma nuevo · permiso `communities:write` · disponible — En v0, communities:write solo administra suscripciones de webhooks. Parámetros: `webhook` (path, string, *) - `POST /api/v1/webhooks/{webhook}/test` — Envía un evento de prueba firmado. **Nota:** Envía una petición real a la URL; 6 por minuto. · permiso `communities:write` · disponible — En v0, communities:write solo administra suscripciones de webhooks. Parámetros: `webhook` (path, string, *) - `GET /api/v1/webhooks/{webhook}/deliveries` — Historial de entregas de una suscripción. **Con API key:** la respuesta siempre viene paginada. Tamaño de página: `per_page` o `perPage` (por defecto 25, máximo 100; un valor mayor, menor que 1 o no entero responde `422`; mandar los dos con valores distintos también responde `422`). · permiso `communities:read` · disponible Parámetros: `webhook` (path, string, *), `page` (query, integer), `perPage` (query, integer), `per_page` (query, integer) ### Accounts URL base: `https://api.funnelchat.com` (URL = URL base + ruta completa; el `servers` de la especificación es `https://api.funnelchat.com/api/v1` y sus `paths` son relativos a eso) · instantánea 300cc8435 · 2026-10-22 09:46 (-03:00) · 68 operaciones, 68 disponibles para claves en v0 según el contrato. Formato: `MÉTODO ruta-completa` — para qué (si la especificación lo dice) · permiso · disponibilidad en v0. Parámetros: `nombre` (lugar, tipo, * = obligatorio). Cuerpo: `campo` (tipo, * = obligatorio). Agrupado por la etiqueta (tag) de la especificación. #### Accounts · General - `GET /api/v1/me` — permiso `accounts:read` · disponible #### Accounts · AlertCampaign - `GET /api/v1/alerts/campaigns` — List campaigns visible to the authenticated account (owner + agents scope), reading from the read-only `groups` connection. Paginated, searchable by `name`. Degrades to 503 when the `groups` connection is unavailable instead of leaking a 500 / stack trace. **Listado** · `perPage` de 1 a 100 (por defecto 25); también `per_page` · orden fijo: id ascendente · no acepta parámetros de orden. · permiso `accounts:read` · disponible Parámetros: `page` (query, integer|null), `per_page` (query, integer|null), `search` (query, string|null) #### Accounts · AlertCatalog - `GET /api/v1/alerts/catalog` — Return the alert catalog, filtered by the owner's payment provider. Stripe-only alerts (concepts/events that only exist for accounts billed through Stripe, e.g. subscription reactivation, dormant period ending) are excluded when the owner's payment_provider is not Stripe (Hotmart or null). · permiso `accounts:read` · disponible #### Accounts · AlertDailyReport - `GET /api/v1/alerts/daily-report` — Return the daily report configuration for the authenticated owner. Returns a default structure if no record exists yet · permiso `accounts:read` · disponible - `PUT /api/v1/alerts/daily-report` — Upsert the daily report configuration for the authenticated owner. Ensures exactly one row per owner (unique(owner_id)). When `all_campaigns` is false, the submitted `campaign_ids` are pruned to only those that still resolve to a visible campaign (owner + agents scope) in the `groups` database, dropping orphans silently. The purge is best-effort: if the `groups` connection is unavailable, the submitted ids are persisted unpruned rather than being wiped out. · permiso `accounts:write` · disponible Cuerpo (application/json): `enabled` (boolean, *), `send_time` (string|null), `all_campaigns` (boolean, *), `campaign_ids` (array) #### Accounts · AlertDestination - `GET /api/v1/alerts/destinations` — List destinations for the authenticated owner. Seed lazy: if the owner has no destinations and has a phone number, create an unverified phone destination from it. **Listado** · `perPage` de 1 a 100 (por defecto 25) · orden fijo: id ascendente · no acepta parámetros de orden. · permiso `accounts:read` · disponible - `POST /api/v1/alerts/destinations` — Create a new destination for the authenticated owner. Enforces, para `webhook`: - tope de {@see self::WEBHOOK_DESTINATION_LIMIT} destinos por cuenta; - `SsrfGuard::assertSafe()` ANTES de persistir. Hasta este incremento el unico chequeo en el alta era `str_starts_with($destination, 'https://')`, y el guard corria solo en el boton de prueba manual: se podia guardar tranquilamente un destino apuntando a `https://127.0.0.1/...` o al endpoint de metadata… · permiso `accounts:write` · disponible Cuerpo (application/json): `type` (string ∈ {"phone", "webhook"}, *), `destination` (string, *), `name` (string|null) - `PATCH /api/v1/alerts/destinations/{id}` — Update the name of a destination, y —solo para `webhook`— su URL. La URL de un `phone` sigue siendo de solo lectura post-creacion (se verifica por OTP contra ese numero; cambiarlo seria crear otro destino). Para un `webhook` se permite corregirla, y esa es justamente la razon por la que el guard anti-SSRF tiene que correr TAMBIEN aca: si solo corriera en el alta, editar seria la puerta de atras para dejar persistida una URL que apunta a la red interna. · permiso `accounts:write` · disponible Parámetros: `id` (path, integer, *) Cuerpo (application/json): `name` (string|null), `destination` (string) - `DELETE /api/v1/alerts/destinations/{id}` — Delete a destination (settings cascade via DB constraint). Returns 404 for both non-existent and other-owner destinations to prevent resource-existence enumeration by unauthenticated/unauthorized callers. · permiso `accounts:write` · disponible Parámetros: `id` (path, integer, *) #### Accounts · AlertOtp - `POST /api/v1/alerts/destinations/{id}/otp` — Send (or resend) an OTP to a phone destination. HTTP 202: OTP dispatched. HTTP 422: destination is not a phone or is already verified. HTTP 429: cooldown active or hourly limit reached. · permiso `accounts:write` · disponible Parámetros: `id` (path, integer, *) - `POST /api/v1/alerts/destinations/{id}/otp/verify` — Verify the OTP code submitted by the user. HTTP 200: verified — returns updated destination resource. HTTP 422: destination not a phone, already verified, or wrong/expired code. · permiso `accounts:write` · disponible Parámetros: `id` (path, integer, *) Cuerpo (application/json): `code` (string, *) #### Accounts · AlertQuietHour - `GET /api/v1/alerts/quiet-hours` — Return the quiet hours configuration for the authenticated owner. Returns an empty/default structure if no record exists yet · permiso `accounts:read` · disponible - `PUT /api/v1/alerts/quiet-hours` — Upsert the quiet hours configuration for the authenticated owner. Ensures exactly one row per owner (unique(owner_id)) · permiso `accounts:write` · disponible Cuerpo (application/json): `enabled` (boolean, *), `start_time` (string|null), `end_time` (string|null) #### Accounts · AlertSetting - `PUT /api/v1/alerts/destinations/{id}/settings` — Upsert an alert setting (toggle on/off) for a destination. - Validates that the module/action pair exists in AlertCatalog. - Validates that the destination is verified (verified_at not null). - Enforces unique(alert_destination_id, module, action) via updateOrCreate. - Owner-scoped: returns 404 for both non-existent and other-owner destinations to prevent resource-existence enumeration. · permiso `accounts:write` · disponible Parámetros: `id` (path, integer, *) Cuerpo (application/json): `module` (string ∈ {"device", "mac", "billing", "subscription", "account", "campaign", "group", "ai_quota"}, *), `action` (string, *), `enabled` (boolean, *) #### Accounts · AlertWebhookTest - `POST /api/v1/alerts/destinations/{id}/test` — Test the webhook destination by sending a fixed test payload. - Owner-scoped: returns 404 for both non-existent and other-owner destinations to prevent resource-existence enumeration. - Only webhook-type destinations are allowed. - SSRF guard is applied inside WebhookTestService before any HTTP call. · permiso `accounts:write` · disponible Parámetros: `id` (path, integer, *) #### Accounts · CapiConnection - `POST /api/v1/devices/{device}/capi/dataset` — Idempotently create or recover the Meta Conversions API dataset for the device's WABA and persist it in `waba_capi_datasets`. · permiso `accounts:write` · disponible Parámetros: `device` (path, integer, *) - `GET /api/v1/devices/{device}/capi/test` — "Probar conexión": verifies the device token has the `whatsapp_business_manage_events` scope (`GET /debug_token`) and that the WABA has a dataset (`GET /{WABA_ID}/dataset`), persists the result, and returns an aggregated status + a link to Meta's Events Manager. · permiso `accounts:read` · disponible Parámetros: `device` (path, integer, *) - `GET /api/v1/devices/capi/datasets` — **Listado** · `perPage` de 1 a 100 (por defecto 25) · orden fijo: id ascendente · no acepta parámetros de orden. · permiso `accounts:read` · disponible #### Accounts · CustomDomain - `GET /api/v1/custom-domains` — List the authenticated admin's custom domains. **Listado** · `perPage` de 1 a 100 (por defecto 25) · orden fijo: id ascendente · no acepta parámetros de orden. · permiso `accounts:read` · disponible - `POST /api/v1/custom-domains` — Register a new custom domain for the authenticated admin. `StoreCustomDomainRequest` already sanitized `hostname` and validated shape + uniqueness (422, no Cloudflare call) before this method runs. Cloudflare is called next; on failure NOTHING is persisted locally (spec scenario "Cloudflare API failure on creation"). · permiso `accounts:write` · disponible Cuerpo (application/json): `hostname` (string, *) - `POST /api/v1/custom-domains/{customDomain}/verify` — Actively re-trigger Cloudflare Domain Control Validation for a domain and persist the resolved canonical status ("verify now", design.md D4/D6/D10). Scoped to the authenticated admin: a domain id belonging to another account resolves to a `ModelNotFoundException`, which the app's global exception handler (`bootstrap/app.php`) renders as a 404 JSON response for API requests — same behavior a mismatched implicit route-model binding would produce. · permiso `accounts:write` · disponible Parámetros: `customDomain` (path, integer, *) - `POST /api/v1/custom-domains/{customDomain}/switch-validation` — Switch a domain's SSL Domain Control Validation (DCV) method between `HTTP` (default) and `TXT` (fallback, design.md D11). This is the escape hatch for domains whose CNAME does not yet route to Cloudflare: HTTP DCV cannot complete without traffic reaching the origin, but TXT DCV validates purely from a DNS record. Scoped to the authenticated admin via `findOwned()` (404 if not owned). Calls `patchSsl()` with the NEW method (a "switch", as opposed to `verify()`'s same-method "re-trigger") and persists it. When switchin… · permiso `accounts:write` · disponible Parámetros: `customDomain` (path, integer, *) Cuerpo (application/json): `method` (string ∈ {"TXT", "HTTP"}, *) - `DELETE /api/v1/custom-domains/{customDomain}` — Unregister a custom domain: delete it from Cloudflare, then hard-delete the local row (design.md D6). > ⚠️ **Operación irreversible.** No hay papelera ni deshacer: resetear o limpiar un número mata su sesión de WhatsApp (hay que volver a escanear el código), purgar la cola descarta los mensajes encolados sin dejar registro de lo descartado, y los borrados eliminan el recurso. · permiso `accounts:operate` · disponible Parámetros: `customDomain` (path, integer, *) #### Accounts · Device - `GET /api/v1/devices` — **Listado** · `perPage` de 1 a 100 (por defecto 25) · orden fijo: id ascendente · no acepta parámetros de orden. · permiso `accounts:read` · disponible - `GET /api/v1/devices/{device}/me` — permiso `accounts:read` · disponible Parámetros: `device` (path, integer, *) - `GET /api/v1/devices/{device}/check-phone` — permiso `accounts:read` · disponible Parámetros: `device` (path, integer, *), `phone` (query, string, *) - `GET /api/v1/devices/{device}` — permiso `accounts:read` · disponible Parámetros: `device` (path, integer, *) - `PUT /api/v1/devices/{device}` — permiso `accounts:write` · disponible Parámetros: `device` (path, integer, *) Cuerpo (application/json): `alias` (string|null), `color` (string|null), `image` (string|null), `number` (string|null), `first_conecction_at` (string|null), `is_business` (boolean), `phone_number_id` (string), `waba_id` (string), `code` (string), `whatsApp_business_app` (boolean), `capi_consent` (boolean) - `DELETE /api/v1/devices/{device}` — > ⚠️ **Operación irreversible.** No hay papelera ni deshacer: resetear o limpiar un número mata su sesión de WhatsApp (hay que volver a escanear el código), purgar la cola descarta los mensajes encolados sin dejar registro de lo descartado, y los borrados eliminan el recurso. · permiso `accounts:operate` · disponible Parámetros: `device` (path, integer, *) - `PUT /api/v1/devices/{device}/reset` — "Borrar datos" (whatsapp-preverified-number-assignment D60; spec `device-connection-actions`): erases every column of the number on this connection, QR and Meta, both modes, so the card connects a DIFFERENT number (normal free-card rules: class A when the pool is full). Kept: alias (and color; Meta also image and `is_coexistence`), `active`, `is_additional`, `provider`. See `EraseDeviceNumberData`. 409 `device_retired` / `operation_in_progress` (nothing erased). 200 `{data: DeviceResource, numbers_capacity}`. The front asks for a red confirmation before calling it. > ⚠️ **Operación irreversible.** No hay papelera ni deshacer: resetear o limpiar un número mata su sesión de WhatsApp (hay que volver a escanear el código), purgar la cola descarta los mensajes encolados sin dejar registro de lo descartado, y los borrados eliminan el recurso. · permiso `accounts:operate` · disponible Parámetros: `device` (path, integer, *) - `PUT /api/v1/devices/{device}/update-webhook-received` — permiso `accounts:write` · disponible Parámetros: `device` (path, integer, *) - `PUT /api/v1/devices/{device}/update-webhook-received-and-delivery` — permiso `accounts:write` · disponible Parámetros: `device` (path, integer, *) - `POST /api/v1/devices/{device}/clean` — "Desconectar" (whatsapp-preverified-number-assignment D60; spec `device-connection-actions`): never erases the number information and never frees the place, QR and Meta, both modes. - QR: provider logout of the WhatsApp session; the instance (`uid`, `token`) stays so the user scans again; alias, color, image, app name, identity and history are kept. - Meta: the QR sibling's one-to-one is turned back on FIRST (synchronously, while the row still has its number, D60.7); then only the access token is cleared (no provider call, `status()` answers `pending_login`, D60.10); `uid` (phone_number_id), `waba_id`, identity and `is_coexistence` are kept, so the same number reconnects with Embedded Signup on the same row (also at or over the pool: D59 S1 / D20 rule 4). `number` goes NULL as before (the card shows `number_key`). 409 `device_retired` on a retired row; 409 `operation_in_progress` while a Meta signup holds a number reservation. 200 `{data: DeviceResource, numbers_capacity}` (`numbers_capacity` omitted when it cannot be computed, D59.12). > ⚠️ **Operación irreversible.** No hay papelera ni deshacer: resetear o limpiar un número mata su sesión de WhatsApp (hay que volver a escanear el código), purgar la cola descarta los mensajes encolados sin dejar registro de lo descartado, y los borrados eliminan el recurso. · permiso `accounts:operate` · disponible Parámetros: `device` (path, integer, *) - `GET /api/v1/devices/{device}/chats` — **Listado** · `perPage` de 1 a 100 (por defecto 25); también `pageSize` · orden fijo: el que devuelve el proveedor · no acepta parámetros de orden. · permiso `accounts:read` · disponible Parámetros: `device` (path, integer, *), `phone` (query, string), `page` (query, integer), `pageSize` (query, integer), `type` (query, integer ∈ {"1", "2"}) - `GET /api/v1/devices/{device}/open-conversations` — **Con API key** · Con una clave de API, `phones` admite como máximo 100 teléfonos por llamada. Más responde `422`. · permiso `accounts:read` · disponible Parámetros: `device` (path, integer, *), `phones[]` (query, array, *) - `GET /api/v1/devices/{device}/message-queue` — **Listado** · `perPage` de 1 a 100 (por defecto 25); también `pageSize` · orden fijo: el que devuelve el proveedor · no acepta parámetros de orden. · permiso `accounts:read` · disponible Parámetros: `device` (path, integer, *), `page` (query, integer), `pageSize` (query, integer) - `DELETE /api/v1/devices/{device}/message-queue` — permiso `accounts:write` · disponible Parámetros: `device` (path, integer, *), `messageUids[]` (query, array, *) - `GET /api/v1/devices/{device}/message-queue/count` — permiso `accounts:read` · disponible Parámetros: `device` (path, integer, *) - `DELETE /api/v1/devices/{device}/message-queue/all` — > ⚠️ **Operación irreversible.** No hay papelera ni deshacer: resetear o limpiar un número mata su sesión de WhatsApp (hay que volver a escanear el código), purgar la cola descarta los mensajes encolados sin dejar registro de lo descartado, y los borrados eliminan el recurso. · permiso `accounts:operate` · disponible Parámetros: `device` (path, integer, *) - `GET /api/v1/devices/{device}/status` — permiso `accounts:read` · disponible Parámetros: `device` (path, integer, *) - `POST /api/v1/devices/{device}/extension-token` — Request a short-lived extension token for transferring a WhatsApp Web session to this device instance via the Z-API Conector Chrome extension. Only available for ZApi and ZApiLite providers. Returns a one-time code with an expiry timestamp. The frontend uses this code to authenticate the extension without ever receiving the raw instance credentials. · permiso `accounts:write` · disponible Parámetros: `device` (path, integer, *) #### Accounts · DeviceConnectionEvent - `GET /api/v1/devices/{device}/connection-history` — **Listado** · `perPage` de 1 a 100 (por defecto 25) · orden fijo: created_at descendente · no acepta parámetros de orden. · permiso `accounts:read` · disponible Parámetros: `device` (path, integer, *), `orderBy` (query, string), `orderByMethod` (query, string), `perPage` (query, integer) #### Accounts · DeviceDisconnectSurvey - `GET /api/v1/devices/{device}/disconnect-survey` — permiso `accounts:read` · disponible Parámetros: `device` (path, integer, *) - `POST /api/v1/devices/{device}/disconnect-survey` — permiso `accounts:write` · disponible Parámetros: `device` (path, integer, *) Cuerpo (application/json): `reason` (integer ∈ {"1", "2", "4", "5"}, *) #### Accounts · DeviceFleetLogs - `GET /api/v1/devices/logs` — List `device_logs` rows across the account within a bounded window, newest-first, keyset-paginated. Returns a `summary` only on the first page (no `cursor`). **Listado** · `perPage` de 1 a 100 (por defecto 25); también `limit` · orden fijo: más reciente primero · no acepta parámetros de orden. · permiso `accounts:read` · disponible Parámetros: `range` (query, string|null ∈ {"1h", "6h", "24h", "7d", "30d", null}), `from` (query, string|null), `to` (query, string|null), `only_errors` (query, boolean|null), `exclude_sends` (query, boolean|null), `methods[]` (query, array), `cursor` (query, string|null), `limit` (query, integer|null), `per_device_cap` (query, integer|null) #### Accounts · DeviceHealth - `GET /api/v1/devices/{device}/health` — permiso `accounts:read` · disponible Parámetros: `device` (path, integer, *) - `POST /api/v1/devices/{device}/health/refresh` — `refresh_outcome` (security-auditor finding — Increment 4 plan review, "the endpoint must tell the truth about what happened"): `refreshAndPersist()` can return the pre-existing snapshot for THREE different reasons — it genuinely refreshed, it bounced off the 60s cooldown, or it couldn't acquire the per-device lock (another refresh — the scheduled command, another tab, another agent — is already i… · permiso `accounts:write` · disponible Parámetros: `device` (path, integer, *) #### Accounts · DeviceLogs - `GET /api/v1/devices/{device}/logs` — List `device_logs` rows for the device within a bounded window, newest-first, keyset-paginated. Returns a `summary` only on the first page (no `cursor`). **Listado** · `perPage` de 1 a 100 (por defecto 25); también `limit` · orden fijo: más reciente primero · no acepta parámetros de orden. · permiso `accounts:read` · disponible Parámetros: `device` (path, integer, *), `range` (query, string|null ∈ {"1h", "6h", "24h", "7d", "30d", null}), `from` (query, string|null), `to` (query, string|null), `only_errors` (query, boolean|null), `exclude_sends` (query, boolean|null), `methods[]` (query, array), `cursor` (query, string|null), `limit` (query, integer|null) - `GET /api/v1/devices/{device}/logs/health` — Return the device's health signal: the last 5 non-send requests plus a verdict (`healthy`/`review`/`attention`) · permiso `accounts:read` · disponible Parámetros: `device` (path, integer, *) #### Accounts · DeviceUsername - `GET /api/v1/devices/{device}/username` — Fetch the current reserved username from Meta and return it along with the locally stored value. · permiso `accounts:read` · disponible Parámetros: `device` (path, integer, *) - `POST /api/v1/devices/{device}/username` — Reserve a username via Meta API and persist it locally. · permiso `accounts:write` · disponible Parámetros: `device` (path, integer, *) Cuerpo (application/json): `username` (string, *) - `GET /api/v1/devices/{device}/username/suggestions` — Fetch username suggestions from Meta API. · permiso `accounts:read` · disponible Parámetros: `device` (path, integer, *) #### Accounts · WebhookDelivery - `GET /api/v1/webhooks/endpoints/{id}/deliveries` — **Listado** · `perPage` de 1 a 100 (por defecto 25); también `per_page` · orden fijo: attempted_at descendente, después id descendente · no acepta parámetros de orden. · permiso `accounts:read` · disponible Parámetros: `id` (path, integer, *) #### Accounts · WebhookEndpoint - `GET /api/v1/webhooks/endpoints` — Los endpoints de la cuenta, con sus suscripciones. **Listado** · `perPage` de 1 a 100 (por defecto 25) · orden fijo: id ascendente · no acepta parámetros de orden. · permiso `accounts:read` · disponible - `POST /api/v1/webhooks/endpoints` — Registra un endpoint y devuelve su secreto de firma — la única vez · permiso `accounts:write` · disponible Cuerpo (application/json): `url` (string, *), `description` (string|null), `events` (array) - `GET /api/v1/webhooks/endpoints/{id}` — permiso `accounts:read` · disponible Parámetros: `id` (path, integer, *) - `PATCH /api/v1/webhooks/endpoints/{id}` — Edita la URL, la descripción o el estado. El `SsrfGuard` corre TAMBIÉN acá y no sólo en el alta: si corriera sólo en el alta, editar sería la puerta de atrás para dejar persistida una URL que apunta a la red interna. · permiso `accounts:write` · disponible Parámetros: `id` (path, integer, *) Cuerpo (application/json): `url` (string), `description` (string|null), `status` (string ∈ {"enabled", "disabled"}) - `DELETE /api/v1/webhooks/endpoints/{id}` — Da de baja el endpoint. Las suscripciones y el historial de entregas caen con él por `ON DELETE CASCADE` · permiso `accounts:write` · disponible Parámetros: `id` (path, integer, *) #### Accounts · WebhookEndpointEvent - `PUT /api/v1/webhooks/endpoints/{id}/events` — permiso `accounts:write` · disponible Parámetros: `id` (path, integer, *) Cuerpo (application/json): `events` (array, *) #### Accounts · WebhookEventCatalog - `GET /api/v1/webhooks/events` — permiso `accounts:read` · disponible #### Accounts · WebhookSubscription - `GET /api/v1/webhooks` — Lista las suscripciones de webhook de la cuenta, por id ascendente. **Listado** · `perPage` de 1 a 100 (por defecto 25) · orden fijo: id ascendente · no acepta parámetros de orden. · permiso `webhooks:read` · disponible - `POST /api/v1/webhooks` — Crea una suscripción y devuelve su secreto — la única vez. Con una clave de API, cada tipo de `events` exige además el permiso de lectura de su área (`accounts:read`, `conversations:read`, `communities:read`); si falta, 422. · permiso `webhooks:write` · disponible Cuerpo (application/json): `url` (string, *), `description` (string|null), `events` (array, *), `filters` (object) - `GET /api/v1/webhooks/{id}` — Devuelve una suscripción de webhook de la cuenta, sin su secreto. Con una clave de API, la clave necesita además el permiso de lectura del área de cada tipo de evento de la suscripción; si falta alguno, 403 `permission_denied`. · permiso `webhooks:read` · disponible Parámetros: `id` (path, string, *) - `PUT /api/v1/webhooks/{id}` — Reemplaza `url`, `events`, `description` y `filters`; `status` opcional. Con una clave de API, cada tipo de `events` exige además el permiso de lectura de su área (`accounts:read`, `conversations:read`, `communities:read`); si falta, 422. · permiso `webhooks:write` · disponible Parámetros: `id` (path, string, *) Cuerpo (application/json): `url` (string, *), `description` (string|null), `events` (array, *), `filters` (object), `status` (string ∈ {"active", "paused"}) - `DELETE /api/v1/webhooks/{id}` — Da de baja la suscripción. Sus eventos y su historial caen por cascada. Con una clave de API, la clave necesita además el permiso de lectura del área de cada tipo de evento de la suscripción; si falta alguno, 403 `permission_denied` y no se borra. · permiso `webhooks:write` · disponible Parámetros: `id` (path, string, *) #### Accounts · WebhookSubscriptionDelivery - `GET /api/v1/webhooks/{id}/deliveries` — Lista los intentos de entrega de una suscripción, el más nuevo primero. **Listado** · `perPage` de 1 a 100 (por defecto 25); también `per_page` · orden fijo: attempted_at descendente, después id descendente · no acepta parámetros de orden. · permiso `webhooks:read` · disponible Parámetros: `id` (path, string, *) #### Accounts · WebhookSubscriptionTest - `POST /api/v1/webhooks/{id}/test` — Envía una entrega de prueba firmada a la URL de la suscripción y devuelve el resultado. Solo a suscripciones `active` o `paused`; una `disabled` responde 422 sin enviar nada. · permiso `webhooks:write` · disponible Parámetros: `id` (path, string, *) #### Accounts · WhatsappCost - `GET /api/v1/me/whatsapp-costs` — Returns the full `WhatsappCostSummaryResource` for the requested (or current, when omitted) calendar month — `?month=YYYY-MM` · permiso `accounts:read` · disponible Parámetros: `month` (query, string)