@funnelchat-hq/sandbox 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7200,6 +7200,13 @@ var openapi_default = {
7200
7200
  type: "boolean"
7201
7201
  }
7202
7202
  },
7203
+ {
7204
+ name: "awaitingReply",
7205
+ in: "query",
7206
+ schema: {
7207
+ type: "boolean"
7208
+ }
7209
+ },
7203
7210
  {
7204
7211
  name: "onlyWithMac",
7205
7212
  in: "query",
@@ -13645,7 +13652,7 @@ var openapi_default = {
13645
13652
  }
13646
13653
  },
13647
13654
  "x-fc-permission": "conversations:read",
13648
- "x-fc-proposito": "\xDAsalo para listar tus plantillas de WhatsApp con su estado de aprobaci\xF3n, categor\xEDa, idioma y n\xFAmero, y elegir cu\xE1l usar en un env\xEDo o una difusi\xF3n.",
13655
+ "x-fc-proposito": "\xDAsalo para listar tus plantillas de WhatsApp con su estado de aprobaci\xF3n, categor\xEDa, idioma y n\xFAmero, y elegir cu\xE1l usar en un env\xEDo. Si una que creaste directamente en Meta no aparece, tr\xE1ela con \xABTraer tus plantillas de Meta\xBB (`POST /api/v1/devices/{device}/templates`) y vuelve a listar.",
13649
13656
  "x-fc-domain": "conversations",
13650
13657
  "x-fc-pending": "conversations-templates",
13651
13658
  "x-fc-pending-response": "conversations-templates"
@@ -13873,6 +13880,159 @@ var openapi_default = {
13873
13880
  "x-fc-domain": "conversations"
13874
13881
  }
13875
13882
  },
13883
+ "/api/v1/devices/{device}/templates": {
13884
+ post: {
13885
+ operationId: "messageTemplate.sync",
13886
+ description: "**Permiso requerido:** **Contactos y conversaciones \u2192 Editar**\n\nSincroniza con Meta las plantillas de un n\xFAmero de la API oficial de WhatsApp: trae las que se crearon o cambiaron directamente en Meta y quita las de una cuenta de WhatsApp Business que el n\xFAmero ya no usa. `{device}` es el `id` del n\xFAmero.\n\n- **As\xEDncrona:** la primera p\xE1gina (hasta 100 plantillas) se lee en el momento y las siguientes en segundo plano. Responde 202 con `status`: `completed` si ya termin\xF3, `in_progress` si sigue. Las plantillas sincronizadas se listan con `GET /templates`.\n- **Una a la vez:** mientras hay una sincronizaci\xF3n de ese n\xFAmero en curso, otra llamada responde 409 y no lanza una nueva.\n- **Errores de Meta:** si Meta rechaza la lectura (por ejemplo, porque el token del n\xFAmero venci\xF3 o la cuenta de WhatsApp Business no existe), responde 422 `meta_rejected` con el c\xF3digo de error de Meta en `meta_code` y no sincroniza nada.\n\n| HTTP | `error` | Cu\xE1ndo |\n|---|---|---|\n| 404 | \u2014 | El n\xFAmero no existe en tu cuenta o est\xE1 fuera del workspace de tu clave. |\n| 409 | `template_sync_in_progress` | Ya hay una sincronizaci\xF3n de ese n\xFAmero en curso. |\n| 422 | `number_not_connected_to_meta` | El n\xFAmero est\xE1 conectado por QR o no tiene una cuenta de WhatsApp Business o credenciales en Meta. |\n| 422 | `meta_rejected` | Meta rechaz\xF3 la lectura de las plantillas (`meta_code` trae su c\xF3digo de error). |\n| 429 | \u2014 | Demasiadas solicitudes por minuto (ver `Retry-After`). |\n| 502 | `meta_unavailable` | Meta no respondi\xF3. |",
13887
+ summary: "Sincronizar las plantillas de un n\xFAmero con Meta",
13888
+ tags: [
13889
+ "MessageTemplate"
13890
+ ],
13891
+ parameters: [
13892
+ {
13893
+ name: "device",
13894
+ in: "path",
13895
+ required: true,
13896
+ schema: {
13897
+ type: "integer"
13898
+ }
13899
+ }
13900
+ ],
13901
+ responses: {
13902
+ "202": {
13903
+ description: "Accepted",
13904
+ content: {
13905
+ "application/json": {
13906
+ schema: {
13907
+ type: "object",
13908
+ properties: {
13909
+ status: {
13910
+ type: "string",
13911
+ enum: [
13912
+ "in_progress",
13913
+ "completed"
13914
+ ]
13915
+ }
13916
+ },
13917
+ required: [
13918
+ "status"
13919
+ ]
13920
+ }
13921
+ }
13922
+ }
13923
+ },
13924
+ "401": {
13925
+ $ref: "#/components/responses/ConversationsAuthenticationException"
13926
+ },
13927
+ "403": {
13928
+ $ref: "#/components/responses/ConversationsAuthorizationException"
13929
+ },
13930
+ "404": {
13931
+ description: "Not Found",
13932
+ content: {
13933
+ "application/json": {
13934
+ schema: {
13935
+ type: "object",
13936
+ properties: {
13937
+ message: {
13938
+ type: "string"
13939
+ }
13940
+ },
13941
+ required: [
13942
+ "message"
13943
+ ]
13944
+ }
13945
+ }
13946
+ }
13947
+ },
13948
+ "409": {
13949
+ description: "Conflict",
13950
+ content: {
13951
+ "application/json": {
13952
+ schema: {
13953
+ type: "object",
13954
+ properties: {
13955
+ error: {
13956
+ type: "string",
13957
+ enum: [
13958
+ "template_sync_in_progress"
13959
+ ]
13960
+ },
13961
+ message: {
13962
+ type: "string"
13963
+ }
13964
+ },
13965
+ required: [
13966
+ "error",
13967
+ "message"
13968
+ ]
13969
+ }
13970
+ }
13971
+ }
13972
+ },
13973
+ "422": {
13974
+ description: "Unprocessable Content",
13975
+ content: {
13976
+ "application/json": {
13977
+ schema: {
13978
+ type: "object",
13979
+ properties: {
13980
+ error: {
13981
+ type: "string",
13982
+ enum: [
13983
+ "number_not_connected_to_meta",
13984
+ "meta_rejected"
13985
+ ]
13986
+ },
13987
+ message: {
13988
+ type: "string"
13989
+ },
13990
+ meta_code: {
13991
+ type: [
13992
+ "integer",
13993
+ "null"
13994
+ ]
13995
+ }
13996
+ },
13997
+ required: [
13998
+ "error",
13999
+ "message"
14000
+ ]
14001
+ }
14002
+ }
14003
+ }
14004
+ },
14005
+ "502": {
14006
+ description: "Bad Gateway",
14007
+ content: {
14008
+ "application/json": {
14009
+ schema: {
14010
+ type: "object",
14011
+ properties: {
14012
+ error: {
14013
+ type: "string",
14014
+ enum: [
14015
+ "meta_unavailable"
14016
+ ]
14017
+ },
14018
+ message: {
14019
+ type: "string"
14020
+ }
14021
+ },
14022
+ required: [
14023
+ "error",
14024
+ "message"
14025
+ ]
14026
+ }
14027
+ }
14028
+ }
14029
+ }
14030
+ },
14031
+ "x-fc-permission": "conversations:write",
14032
+ "x-fc-proposito": "\xDAsalo si creaste o cambiaste una plantilla directamente en Meta y todav\xEDa no aparece en tu lista (llegan solas una vez por d\xEDa): la trae en el momento. Solo con n\xFAmeros oficiales de WhatsApp. Si responde `in_progress`, vuelve a listar en unos segundos.",
14033
+ "x-fc-domain": "conversations"
14034
+ }
14035
+ },
13876
14036
  "/api/v1/devices/{device}/templates/{template}/send-template": {
13877
14037
  post: {
13878
14038
  operationId: "messageTemplate.send",
@@ -14190,7 +14350,7 @@ var openapi_default = {
14190
14350
  }
14191
14351
  },
14192
14352
  "x-fc-permission": "conversations:send",
14193
- "x-fc-proposito": "Sirve para enviar una plantilla desde uno de tus n\xFAmeros a un tel\xE9fono o contacto, completando sus variables, por ejemplo un aviso, un recordatorio o una confirmaci\xF3n.",
14353
+ "x-fc-proposito": "Sirve para enviar una plantilla desde uno de tus n\xFAmeros a un tel\xE9fono o contacto, completando sus variables, por ejemplo un recordatorio, una confirmaci\xF3n o una novedad. Si una plantilla que creaste directamente en Meta todav\xEDa no aparece en tu lista, tr\xE1ela con \xABTraer tus plantillas de Meta\xBB (`POST /api/v1/devices/{device}/templates`).",
14194
14354
  "x-fc-domain": "conversations",
14195
14355
  "x-fc-pending": "conversations-send",
14196
14356
  "x-fc-pending-response": "conversations-send"
@@ -19449,7 +19609,7 @@ var openapi_default = {
19449
19609
  "/api/v1/devices/{device}/groups": {
19450
19610
  get: {
19451
19611
  operationId: "group.groups",
19452
- description: "**Permiso requerido:** **Grupos y campa\xF1as \u2192 Ver**\n\n**Con API key:** la respuesta siempre viene paginada. Tama\xF1o de p\xE1gina: `pageSize` (por defecto 25, m\xE1ximo 100; un valor mayor, menor que 1 o no entero responde `422`).",
19612
+ description: "**Permiso requerido:** **Grupos y campa\xF1as \u2192 Ver**\n\n**Nota:** La lista se lee de WhatsApp y, para una API key, se reutiliza durante un minuto por n\xFAmero: un grupo reci\xE9n creado en WhatsApp puede tardar hasta un minuto en aparecer. Si un grupo ya est\xE1 en la cuenta, trae `device_id`. Para importar uno: `POST /groups/{group}/import`.\n\n**Con API key:** la respuesta siempre viene paginada. Tama\xF1o de p\xE1gina: `pageSize` (por defecto 25, m\xE1ximo 100; un valor mayor, menor que 1 o no entero responde `422`).",
19453
19613
  summary: "Listar los grupos que administra un n\xFAmero",
19454
19614
  tags: [
19455
19615
  "Grupos"
@@ -19605,7 +19765,7 @@ var openapi_default = {
19605
19765
  $ref: "#/components/responses/CommunitiesValidationException"
19606
19766
  }
19607
19767
  },
19608
- "x-fc-proposito": "Sirve para ver los grupos que administra un n\xFAmero seg\xFAn lo que informa el proveedor de WhatsApp, por ejemplo para encontrar grupos que todav\xEDa no sumaste a una campa\xF1a.",
19768
+ "x-fc-proposito": "Sirve para ver los grupos de un n\xFAmero tal como est\xE1n en WhatsApp, tambi\xE9n los que todav\xEDa no importaste a tu cuenta: de ac\xE1 sale el `uid` para escribirle a un grupo o seguirlo.",
19609
19769
  "x-fc-domain": "communities",
19610
19770
  "x-fc-permission": "communities:read"
19611
19771
  }
@@ -20086,6 +20246,87 @@ var openapi_default = {
20086
20246
  "x-fc-permission": "communities:read"
20087
20247
  }
20088
20248
  },
20249
+ "/api/v1/groups/{group}/import": {
20250
+ post: {
20251
+ operationId: "v2.apiGroupImport",
20252
+ description: "**Permiso requerido:** **Grupos y campa\xF1as \u2192 Administrar grupos**\n\n**Nota:** Importa a la cuenta un grupo del n\xFAmero indicado en `device_id`, igual que importarlo desde el panel: queda visible en el panel, se sincronizan sus participantes y desde entonces genera los avisos `group_participant.joined` y `group_participant.left`. Los datos del grupo y si el n\xFAmero es administrador se leen de WhatsApp en la misma llamada. Responde `201` si lo import\xF3 y `200` si ya estaba en la cuenta, con `is_admin`. Solo n\xFAmeros QR: con un n\xFAmero oficial responde `422 provider_unsupported`. Si el grupo no es del n\xFAmero responde `404 group_not_found`. Cada n\xFAmero importa hasta 5 grupos por minuto (`429 import_rate_limited`, con `retry_after` en segundos). Dos pedidos a la vez por el mismo grupo lo importan una sola vez. Un grupo que ya est\xE1 en otro workspace de la cuenta no se puede importar en este.",
20253
+ summary: "Importar un grupo de un n\xFAmero",
20254
+ tags: [
20255
+ "Grupos"
20256
+ ],
20257
+ parameters: [
20258
+ {
20259
+ name: "group",
20260
+ in: "path",
20261
+ required: true,
20262
+ description: "Identificador del grupo en WhatsApp, como lo lista `GET /v1/devices/{device}/groups`.",
20263
+ schema: {
20264
+ type: "string"
20265
+ }
20266
+ }
20267
+ ],
20268
+ requestBody: {
20269
+ required: true,
20270
+ content: {
20271
+ "application/json": {
20272
+ schema: {
20273
+ $ref: "#/components/schemas/ApiGroupDeviceRequest"
20274
+ }
20275
+ }
20276
+ }
20277
+ },
20278
+ responses: {
20279
+ "200": {
20280
+ description: "El grupo ya estaba en la cuenta: no se import\xF3 de nuevo.",
20281
+ content: {
20282
+ "application/json": {
20283
+ schema: {
20284
+ type: "object",
20285
+ properties: {
20286
+ data: {
20287
+ $ref: "#/components/schemas/ApiGroupImportResource"
20288
+ }
20289
+ },
20290
+ required: [
20291
+ "data"
20292
+ ]
20293
+ }
20294
+ }
20295
+ }
20296
+ },
20297
+ "201": {
20298
+ description: "El grupo se import\xF3 en esta llamada.",
20299
+ content: {
20300
+ "application/json": {
20301
+ schema: {
20302
+ type: "object",
20303
+ properties: {
20304
+ data: {
20305
+ $ref: "#/components/schemas/ApiGroupImportResource"
20306
+ }
20307
+ },
20308
+ required: [
20309
+ "data"
20310
+ ]
20311
+ }
20312
+ }
20313
+ }
20314
+ },
20315
+ "401": {
20316
+ $ref: "#/components/responses/CommunitiesAuthenticationException"
20317
+ },
20318
+ "403": {
20319
+ $ref: "#/components/responses/CommunitiesAuthorizationException"
20320
+ },
20321
+ "422": {
20322
+ $ref: "#/components/responses/CommunitiesValidationException"
20323
+ }
20324
+ },
20325
+ "x-fc-proposito": "\xDAsalo para seguir un grupo de tu n\xFAmero sin escribirle: lo importa a tu cuenta y desde entonces te enteras de qui\xE9n entra y qui\xE9n sale. Si ya estaba en tu cuenta, no lo importa de nuevo.",
20326
+ "x-fc-domain": "communities",
20327
+ "x-fc-permission": "communities:operate"
20328
+ }
20329
+ },
20089
20330
  "/api/v1/groups/{group}": {
20090
20331
  get: {
20091
20332
  operationId: "group.show",
@@ -20524,7 +20765,7 @@ var openapi_default = {
20524
20765
  },
20525
20766
  post: {
20526
20767
  operationId: "apiGroupAction.sendMessage",
20527
- description: "**Permiso requerido:** **Grupos y campa\xF1as \u2192 Enviar**\n\n**Nota:** As\xEDncrona: responde 202 con `action_id`; consulta el resultado en `GET /groups/actions/{action}` (`result.message.uid` es el id del mensaje en WhatsApp). `type`: `text`, `image`, `video`, `audio` o `document`. Con un n\xFAmero QR sale al ritmo de los mensajes programados de velocidad Low, que el integrador no puede cambiar. Con un n\xFAmero oficial se verifican, antes de aceptar, la ventana de servicio de 24 horas y la calidad del n\xFAmero. No tiene tope de volumen del plan.",
20768
+ description: "**Permiso requerido:** **Grupos y campa\xF1as \u2192 Enviar**\n\n**Nota:** As\xEDncrona: responde 202 con `action_id`; consulta el resultado en `GET /groups/actions/{action}` (`result.message.uid` es el id del mensaje en WhatsApp). `type`: `text`, `image`, `video`, `audio` o `document`. Con un n\xFAmero QR sale al ritmo de los mensajes programados de velocidad Low, que el integrador no puede cambiar. Con un n\xFAmero oficial se verifican, antes de aceptar, la ventana de servicio de 24 horas y la calidad del n\xFAmero. No tiene tope de volumen del plan. Si el grupo todav\xEDa no est\xE1 en la cuenta y el n\xFAmero es QR, primero se importa como en `POST /groups/{group}/import` (pide adem\xE1s **Grupos y campa\xF1as \u2192 Administrar grupos**, `403 import_permission_required` sin \xE9l) y el env\xEDo sigue con las mismas reglas: si el n\xFAmero no es administrador del grupo no se importa nada y responde `422 device_not_group_admin`. Esa importaci\xF3n cuenta en el tope de 5 por minuto del n\xFAmero.",
20528
20769
  summary: "Mandar un mensaje a un grupo",
20529
20770
  tags: [
20530
20771
  "Acciones de grupo"
@@ -20570,7 +20811,7 @@ var openapi_default = {
20570
20811
  $ref: "#/components/responses/CommunitiesValidationException"
20571
20812
  }
20572
20813
  },
20573
- "x-fc-proposito": "\xDAsalo para mandarle a un grupo un texto, o una imagen, un video, un audio o un documento, desde uno de tus n\xFAmeros. El pedido se acepta enseguida y el mensaje sale despu\xE9s: con el `action_id` de la respuesta consultas c\xF3mo sali\xF3.",
20814
+ "x-fc-proposito": "\xDAsalo para mandarle a un grupo un texto, o una imagen, un video, un audio o un documento, desde uno de tus n\xFAmeros. El pedido se acepta enseguida, queda en cola y el mensaje sale despu\xE9s, al ritmo del n\xFAmero.",
20574
20815
  "x-fc-domain": "communities",
20575
20816
  "x-fc-permission": "communities:send"
20576
20817
  }
@@ -27404,7 +27645,7 @@ var openapi_default = {
27404
27645
  "x-fc-access": "write",
27405
27646
  "x-fc-permission": "accounts:operate",
27406
27647
  "x-fc-destructive": false,
27407
- "x-fc-proposito": "Sirve para que tu cliente conecte su n\xFAmero de WhatsApp a tu cuenta desde una p\xE1gina de Funnelchat, sin entrar al panel: le mandas la `url` de un solo uso y te enteras del resultado por webhook, en tu URL de vuelta o consultando el link.",
27648
+ "x-fc-proposito": "Sirve para conectar un n\xFAmero de WhatsApp a tu cuenta \u2014el tuyo o el de tu cliente\u2014 desde una p\xE1gina de Funnelchat, sin entrar al panel: le das la `url` de un solo uso a quien lo va a conectar y te enteras del resultado por webhook o en tu URL de vuelta.",
27408
27649
  "x-fc-domain": "accounts",
27409
27650
  "x-fc-pending": "accounts-prohibited-null"
27410
27651
  },
@@ -35362,7 +35603,7 @@ var openapi_default = {
35362
35603
  "broadcast.paused": {
35363
35604
  post: {
35364
35605
  summary: "broadcast.paused",
35365
- description: "Una campa\xF1a se paus\xF3 sola, por ejemplo porque baj\xF3 la calidad del n\xFAmero.\n\n`reason` es `quality_degraded` (la calidad del n\xFAmero baj\xF3 respecto de la del inicio, o lleg\xF3 a RED), `quality_unreadable` (no se pudo leer la calidad en tres controles seguidos), `template_unavailable` (Meta dej\xF3 la plantilla en `PAUSED`, `DISABLED` o `REJECTED` antes de una tanda) o `rejection_rate` (en los env\xEDos recientes del n\xFAmero, por defecto las \xFAltimas 24 horas, Meta rechaz\xF3 una proporci\xF3n igual o mayor al umbral configurable: el c\xF3digo 131049, por defecto 5 %; el c\xF3digo 131026, por defecto 5 %; o los c\xF3digos que indican una restricci\xF3n de la cuenta o del n\xFAmero, por defecto 15 %, con la lista configurable y por defecto 131049, 131048, 131031 y 368). Con `rejection_rate` llega adem\xE1s `rejection`: `codes` (los que llegaron a su umbral: `131049`, `131026` o `failed`), `rates` (el porcentaje de cada uno), `attempts` (los env\xEDos medidos) y `window_hours`. `quality` y `quality_baseline` son la calidad de Meta le\xEDda en el control y la del inicio del env\xEDo (`GREEN`, `YELLOW`, `RED`), o `null` si no se pudo leer. `template` es `{name, status}` con el nombre de la plantilla y su estado en Meta cuando `reason` es `template_unavailable`, y `null` en las dem\xE1s pausas (por calidad o por rechazos). Con `rejection_rate` el n\xFAmero adem\xE1s queda frenado 7 d\xEDas (configurable): mientras dure no se pueden crear, ejecutar ni reanudar campa\xF1as desde ese n\xFAmero, ni mandar plantillas a contactos que no le escribieron en las \xFAltimas 24 horas, y solo soporte lo levanta antes. S\xF3lo las campa\xF1as con env\xEDo inteligente se pausan solas. Reanudar la campa\xF1a y que se pause otra vez emite otro evento.",
35606
+ description: "Una campa\xF1a se paus\xF3 sola, por ejemplo porque baj\xF3 la calidad del n\xFAmero.\n\n`reason` es `quality_degraded` (la calidad del n\xFAmero baj\xF3 respecto de la del inicio, o lleg\xF3 a RED), `quality_unreadable` (no se pudo leer la calidad en tres controles seguidos), `template_unavailable` (Meta dej\xF3 la plantilla en `PAUSED`, `DISABLED` o `REJECTED` antes de una tanda), `payment_issue` (el 2 % o m\xE1s de los env\xEDos de la misma campa\xF1a, con un m\xEDnimo de 20 env\xEDos, fueron rechazados por Meta con el c\xF3digo 131042, un problema de pago o de facturaci\xF3n de la cuenta de WhatsApp Business; el 2 % y el m\xEDnimo de 20 env\xEDos son los valores por defecto y se pueden configurar; aplica a cualquier campa\xF1a, tenga o no env\xEDo inteligente; no frena el n\xFAmero; al reanudar la campa\xF1a el conteo, incluidos los env\xEDos que cuentan, vuelve a empezar de cero) o `rejection_rate` (entre los env\xEDos recientes de la propia campa\xF1a, por defecto las \xFAltimas 24 horas, Meta rechaz\xF3 una proporci\xF3n igual o mayor al umbral configurable, por defecto 15 %, con los c\xF3digos que indican una restricci\xF3n de la cuenta o del n\xFAmero; la lista es configurable y por defecto es 131048, 131031 y 368; los c\xF3digos 131049 y 131026 no pausan la campa\xF1a). Con `rejection_rate` llega adem\xE1s `rejection`: `codes` (los que llegaron a su umbral: hoy `failed`), `rates` (el porcentaje de cada uno; las tasas de `131049` y `131026` se informan como dato aunque no pausen), `attempts` (los env\xEDos medidos) y `window_hours`. `quality` y `quality_baseline` son la calidad de Meta le\xEDda en el control y la del inicio del env\xEDo (`GREEN`, `YELLOW`, `RED`), o `null` si no se pudo leer, y siempre `null` en una difusi\xF3n sin env\xEDo inteligente. `template` es `{name, status}` con el nombre de la plantilla y su estado en Meta cuando `reason` es `template_unavailable`, y `null` en las dem\xE1s pausas (por calidad, pago o rechazos). Con `rejection_rate` el n\xFAmero adem\xE1s queda frenado 7 d\xEDas (configurable): mientras dure no se pueden crear, ejecutar ni reanudar campa\xF1as desde ese n\xFAmero, ni mandar plantillas a contactos que no le escribieron en las \xFAltimas 24 horas, y solo soporte lo levanta antes. Las pausas por calidad, por plantilla y por rechazos son solo de las campa\xF1as con env\xEDo inteligente; la pausa por `payment_issue` puede ocurrir en cualquier campa\xF1a. Reanudar la campa\xF1a y que se pause otra vez emite otro evento.",
35366
35607
  "x-fc-status": "live",
35367
35608
  parameters: [
35368
35609
  {
@@ -40691,6 +40932,50 @@ var openapi_default = {
40691
40932
  ],
40692
40933
  title: "ApiGroupDescriptionRequest"
40693
40934
  },
40935
+ ApiGroupDeviceRequest: {
40936
+ type: "object",
40937
+ description: "`device_id` of the number that acts (query string of the live invite-link read, body of the renew\naction).",
40938
+ properties: {
40939
+ device_id: {
40940
+ type: "integer"
40941
+ }
40942
+ },
40943
+ required: [
40944
+ "device_id"
40945
+ ],
40946
+ title: "ApiGroupDeviceRequest"
40947
+ },
40948
+ ApiGroupImportResource: {
40949
+ type: "object",
40950
+ properties: {
40951
+ uid: {
40952
+ type: "string",
40953
+ description: "Identificador del grupo en WhatsApp."
40954
+ },
40955
+ name: {
40956
+ type: [
40957
+ "string",
40958
+ "null"
40959
+ ],
40960
+ description: "Nombre del grupo."
40961
+ },
40962
+ imported: {
40963
+ type: "boolean",
40964
+ description: "`true` si esta llamada lo import\xF3; `false` si ya estaba en la cuenta."
40965
+ },
40966
+ is_admin: {
40967
+ type: "boolean",
40968
+ description: "Si el n\xFAmero indicado es administrador del grupo, seg\xFAn WhatsApp. Sin serlo no puede enviarle mensajes ni hacer acciones de grupo."
40969
+ }
40970
+ },
40971
+ required: [
40972
+ "uid",
40973
+ "name",
40974
+ "imported",
40975
+ "is_admin"
40976
+ ],
40977
+ title: "ApiGroupImportResource"
40978
+ },
40694
40979
  ApiGroupNameRequest: {
40695
40980
  type: "object",
40696
40981
  description: "Cuerpo de `PUT /groups/{group}/name`: el nombre se env\xEDa a WhatsApp tal cual, sin numeraci\xF3n de campa\xF1a.\nLos campos que no figuran se ignoran.",
@@ -51201,27 +51486,27 @@ var openapi_default = {
51201
51486
  title: "Funnelchat Conversations",
51202
51487
  version: "0.0.1",
51203
51488
  spec: "specs/conversations.json",
51204
- spec_changes: "La especificaci\xF3n del \xE1rea con `servers` en la URL base p\xFAblica y, en el texto, cada permiso nombrado como en el panel (\xAB\xC1rea \u2192 Casilla\xBB), sin el nombre t\xE9cnico; el nombre t\xE9cnico queda en `x-fc-permission`.",
51205
- operations: 128,
51489
+ spec_changes: "La especificaci\xF3n del \xE1rea con `servers` en la URL base p\xFAblica y, en el texto, cada permiso nombrado como en el panel (\xAB\xC1rea \u2192 Casilla\xBB), sin el nombre t\xE9cnico; el nombre t\xE9cnico queda en `x-fc-permission`. En la publicada, cada operaci\xF3n lleva el nombre y el grupo del portal.",
51490
+ operations: 129,
51206
51491
  excluded_routes: 20,
51207
- description: 'Public API of Funnelchat Conversations. Authenticate every request with `Authorization: Bearer <id>|fc_live_...`. Each operation shows the permission your key needs, as the Developers panel names it: **Contacts and conversations \u2192 View**, **Contacts and conversations \u2192 Edit** or **Contacts and conversations \u2192 Send**.\n\n## Webhooks\n\nLos webhooks avisan a un sistema externo, por HTTP, cuando algo pasa en tu cuenta, sin que tengas que consultar la API cada tanto. Registras una URL HTTPS, eliges los tipos de evento que quieres recibir y Funnelchat env\xEDa a esa URL un `POST` firmado cada vez que ocurre uno. Las rutas de administraci\xF3n requieren una clave de API y que la use el titular de la cuenta.\n\n### C\xF3mo suscribirse\n\nEstas son las nueve rutas para administrar suscripciones:\n\n| Ruta | Para qu\xE9 sirve | Permiso |\n| --- | --- | --- |\n| `GET /api/v1/webhooks` | Listar las suscripciones | Contactos y conversaciones \u2192 Ver |\n| `POST /api/v1/webhooks` | Crear una suscripci\xF3n | Contactos y conversaciones \u2192 Editar |\n| `GET /api/v1/webhooks/events` | Ver el cat\xE1logo de tipos de evento | Contactos y conversaciones \u2192 Ver |\n| `GET /api/v1/webhooks/{webhook}` | Ver una suscripci\xF3n | Contactos y conversaciones \u2192 Ver |\n| `PUT /api/v1/webhooks/{webhook}` | Cambiar URL, tipos o estado activo | Contactos y conversaciones \u2192 Editar |\n| `DELETE /api/v1/webhooks/{webhook}` | Eliminar una suscripci\xF3n | Contactos y conversaciones \u2192 Editar |\n| `POST /api/v1/webhooks/{webhook}/rotate-secret` | Generar un secreto nuevo | Contactos y conversaciones \u2192 Editar |\n| `POST /api/v1/webhooks/{webhook}/test` | Enviar ahora un evento de prueba | Contactos y conversaciones \u2192 Editar |\n| `GET /api/v1/webhooks/{webhook}/deliveries` | Ver el historial de entregas | Contactos y conversaciones \u2192 Ver |\n\nEjemplo de alta:\n\n```bash\ncurl -X POST "https://TU-HOST/api/v1/webhooks" \\\n -H "Authorization: Bearer <id>|fc_live_..." \\\n -H "Content-Type: application/json" \\\n -d \'{\n "url": "https://example.com/hooks/funnelchat",\n "description": "Mi integraci\xF3n",\n "events": ["message.received"]\n }\'\n```\n\nRespuesta `201`:\n\n```json\n{\n "data": {\n "uuid": "00000000-0000-4000-8000-000000000001",\n "url": "https://example.com/hooks/funnelchat",\n "description": "Mi integraci\xF3n",\n "events": ["message.received"],\n "active": true,\n "inactive_reason": null,\n "created_at": "2026-10-01T12:00:00+00:00",\n "updated_at": "2026-10-01T12:00:00+00:00",\n "secret": "<secreto de firma>"\n }\n}\n```\n\nEl `secret` se muestra **una sola vez**, en esta respuesta (y al rotarlo). Gu\xE1rdalo: no se puede volver a consultar. En `events` puedes usar los tipos `message.received`, `flow.completed`, `broadcast.finished`, `contact.stage_changed`, `message.status_changed`, `conversation.closed`, `conversation.assigned`, `contact.tag_added`, `broadcast.paused`, `contact.tag_removed`, `contact.created`, `template.status_changed`, `flow.failed`, `whatsapp_form.completed` o `*` para todos los dem\xE1s que se puedan suscribir (`message.status_changed` siempre se suscribe de forma expl\xEDcita: `*` no lo incluye). La URL debe ser `https` y apuntar a un destino p\xFAblico.\n\n### El sobre\n\nCada entrega es un `POST` con un cuerpo JSON con esta forma. El contenido de `data` depende del tipo de evento (ver el cat\xE1logo m\xE1s abajo).\n\n| Campo | Descripci\xF3n |\n| --- | --- |\n| `id` | Identificador \xFAnico del evento (`evt_...`). Es el mismo en todos los reintentos. |\n| `type` | Tipo de evento, por ejemplo `message.received`. |\n| `version` | Versi\xF3n del formato del evento (hoy `1`). |\n| `occurred_at` | Momento en que ocurri\xF3, en UTC (ISO 8601). |\n| `data` | Datos del evento. |\n\n### Cabeceras\n\n| Cabecera | Contenido |\n| --- | --- |\n| `Content-Type` | `application/json` |\n| `X-Funnelchat-Signature` | Firma de la entrega, con la forma `t=<timestamp>,v1=<firma>` |\n| `X-Funnelchat-Event` | Tipo de evento (igual que `type` en el cuerpo) |\n| `X-Funnelchat-Delivery` | Identificador de esta entrega (cambia en cada intento) |\n\n### C\xF3mo verificar la firma\n\nCada entrega lleva la cabecera `X-Funnelchat-Signature` con la forma `t=<timestamp>,v1=<firma>`. Para verificarla:\n\n1. Toma el **cuerpo crudo** de la petici\xF3n, tal como lleg\xF3, sin volver a codificar el JSON.\n2. Arma el texto `t + "." + cuerpo` (el timestamp, un punto y el cuerpo).\n3. Calcula su HMAC-SHA256 con el secreto de la suscripci\xF3n y comp\xE1ralo, en tiempo constante, con `v1`.\n4. Rechaza la entrega si `t` se aleja del reloj de tu servidor m\xE1s de 300 segundos.\n\nEjemplo en PHP:\n\n```php\nfunction verificarFirma(string $cuerpoCrudo, string $cabecera, string $secreto, int $tolerancia = 300): bool\n{\n parse_str(str_replace(\',\', \'&\', $cabecera), $partes);\n\n if (! isset($partes[\'t\'], $partes[\'v1\']) || ! ctype_digit($partes[\'t\'])) {\n return false;\n }\n\n if (abs(time() - (int) $partes[\'t\']) > $tolerancia) {\n return false;\n }\n\n $esperada = hash_hmac(\'sha256\', $partes[\'t\'].\'.\'.$cuerpoCrudo, $secreto);\n\n return hash_equals($esperada, $partes[\'v1\']);\n}\n```\n\nEjemplo en JavaScript (Node.js):\n\n```javascript\nconst crypto = require(\'crypto\');\n\nfunction verificarFirma(cuerpoCrudo, cabecera, secreto, tolerancia = 300) {\n const partes = Object.fromEntries(cabecera.split(\',\').map((p) => p.split(\'=\')));\n if (!/^\\d+$/.test(partes.t || \'\') || !partes.v1) return false;\n\n if (Math.abs(Date.now() / 1000 - Number(partes.t)) > tolerancia) return false;\n\n const esperada = crypto\n .createHmac(\'sha256\', secreto)\n .update(`${partes.t}.${cuerpoCrudo}`)\n .digest(\'hex\');\n\n const a = Buffer.from(esperada);\n const b = Buffer.from(partes.v1);\n\n return a.length === b.length && crypto.timingSafeEqual(a, b);\n}\n```\n\nEn JavaScript, lee el cuerpo como texto o `Buffer` antes de convertirlo a objeto (por ejemplo con `express.raw({ type: \'application/json\' })`).\n\n### Reintentos\n\nUna entrega es exitosa si tu servidor responde con un c\xF3digo `2xx` en 10 segundos o menos. Funnelchat no sigue redirecciones.\n\n- Se hacen hasta **4 intentos** por evento y suscripci\xF3n. Entre un intento y el siguiente se espera 60, 300, 1800 segundos.\n- Se reintenta cuando hay un error de red o un timeout, ante un `5xx` y ante un `429`.\n- Un `3xx` o cualquier otro `4xx` es terminal: no se reintenta.\n- Despu\xE9s del \xFAltimo intento fallido la entrega queda como `failed`, sin m\xE1s reintentos.\n\n### Estados de entrega\n\n`GET /api/v1/webhooks/{webhook}/deliveries` devuelve un registro por intento, con estos estados:\n\n| Estado | Significado |\n| --- | --- |\n| `pending` | El intento est\xE1 en curso: todav\xEDa no hay resultado. |\n| `succeeded` | Tu servidor respondi\xF3 `2xx`. |\n| `failed` con `next_attempt_at` | El intento fall\xF3 y hay otro programado para esa fecha. |\n| `failed` sin `next_attempt_at` | El intento fall\xF3 y no habr\xE1 m\xE1s: era terminal o se agotaron los intentos. |\n\n### Probar una suscripci\xF3n\n\n`POST /api/v1/webhooks/{webhook}/test` env\xEDa ahora un evento `webhook.test` firmado a la URL de la suscripci\xF3n, sin esperar a que ocurra nada en tu cuenta, y responde con el resultado del intento. Funciona tambi\xE9n sobre una suscripci\xF3n inactiva, para probarla antes de activarla.\n\nLa respuesta es un \xEDtem del historial de entregas, con la misma forma que cada elemento de `GET /api/v1/webhooks/{webhook}/deliveries`:\n\n```json\n{\n "data": {\n "uuid": "0d3c2f4e-6a1b-4c8d-9e2f-5b7a1c3d4e6f",\n "event_id": "evt_01hexample",\n "event_type": "webhook.test",\n "attempt": 1,\n "status": "succeeded",\n "http_status": 200,\n "duration_ms": 184,\n "error": null,\n "next_attempt_at": null,\n "created_at": "2026-01-15T12:00:00+00:00",\n "updated_at": "2026-01-15T12:00:00+00:00"\n}\n}\n```\n\n`status` es `succeeded` solo si tu servidor respondi\xF3 `2xx`; si no, es `failed` y `error` indica la causa. La prueba aparece en el historial con `event_type: "webhook.test"`, no se reintenta y no consume tu cupo de eventos.\n\n### Suscripciones creadas con una clave de API\n\nUna suscripci\xF3n creada, o reactivada, con una clave de API queda atada a esa clave. Tambi\xE9n la atan a una clave cambiar su URL con esa clave, o rotar su secreto con esa clave (rotarlo desde la sesi\xF3n web no modifica el v\xEDnculo): la suscripci\xF3n queda atada a la \xFAltima credencial que fij\xF3 su destino. Si la clave se revoca (o vence, que cuenta como revocada), la suscripci\xF3n deja de recibir eventos: pasa a `active: false` con `inactive_reason: "key_revoked"`, y no se le env\xEDan m\xE1s eventos.\n\nEse cambio se ve a partir del primer evento que ocurra despu\xE9s de revocar la clave. Hasta entonces, un `GET` puede seguir mostr\xE1ndola activa, pero no recibe eventos.\n\nLa prueba manual (`POST /api/v1/webhooks/{webhook}/test`) sigue funcionando sobre una suscripci\xF3n inactiva.\n\nUna suscripci\xF3n con `inactive_reason: "key_revoked"` no se puede reactivar: `PUT /api/v1/webhooks/{webhook}` con `"active": true` responde 422, sin importar la credencial que se use. Para seguir recibiendo eventos, crea una suscripci\xF3n nueva con `POST /api/v1/webhooks`. Una suscripci\xF3n que desactivaste a mano s\xED se puede reactivar con `PUT` y `"active": true`; queda atada a la credencial que la reactiva. Mientras est\xE9 activa, `inactive_reason` es `null`.\n\nLas suscripciones cuyo destino fij\xF3 por \xFAltima vez la sesi\xF3n web no dependen de ninguna clave: revocar una clave no las afecta.\n\n### Idempotencia\n\nUn mismo evento puede llegarte m\xE1s de una vez (por ejemplo, si tu servidor proces\xF3 la entrega pero respondi\xF3 tarde). Usa el campo `id` del sobre para descartar los que ya procesaste: es el mismo en todos los reintentos del evento.\n\n### Eventos disponibles\n\nSe generan desde el cat\xE1logo vigente. Los tipos marcados como **planificado** todav\xEDa no se emiten: se anuncian para que puedas prepararte, pero no los recibir\xE1s.\n\n`webhook.test` solo se env\xEDa con `POST /api/v1/webhooks/{webhook}/test`.\n\n#### `message.received`\n\nUn contacto le escribi\xF3 a un n\xFAmero de WhatsApp de la cuenta.\n\n```json\n{\n "id": "evt_000000000000000000000001",\n "type": "message.received",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "message": {\n "id": 1001,\n "type": "text",\n "text": "Hola, quiero m\xE1s informaci\xF3n",\n "media": null\n },\n "contact": {\n "id": 2001,\n "name": "Contacto de ejemplo",\n "phone": "+5491100000000"\n },\n "receiver": {\n "phone": "+5491100000001"\n },\n "device_id": 4521\n }\n}\n```\n\nValores posibles de `data.message.type`: `text`, `image`, `video`, `document`, `sticker`, `contact`, `audio`, `note`, `action`, `invalid`, `interactive`, `location`, `voice_call`, `video_call`, `event`, `button`, `template`, `notification`, `option_list`, `template_button_reply`, `poll`, `whatsapp_form`, `call_permission_request`, `call_permission_reply`, `request_contact_info`.\n\n`device_id` es el n\xFAmero de tu cuenta que recibi\xF3 el mensaje: el mismo `device_id` que mandas para responder por ese n\xFAmero. Es `null` si el mensaje no tiene n\xFAmero asociado.\n\n#### `flow.completed`\n\nUn contacto termin\xF3 un flujo de automatizaci\xF3n.\n\n```json\n{\n "id": "evt_000000000000000000000003",\n "type": "flow.completed",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "flow": {\n "id": 3001,\n "name": "Flujo de ejemplo"\n },\n "contact": {\n "id": 2001,\n "name": "Contacto de ejemplo",\n "phone": "+5491100000000"\n }\n }\n}\n```\n\nSe env\xEDa una sola vez por cada ejecuci\xF3n de un flujo, cuando termina con \xE9xito. No se env\xEDa si la ejecuci\xF3n se cort\xF3, se cancel\xF3, qued\xF3 bloqueada o fall\xF3. `contact` es `null` si la ejecuci\xF3n no tiene un contacto asociado. Un flujo muy usado puede generar muchos eventos seguidos.\n\n#### `broadcast.finished`\n\nUna campa\xF1a lleg\xF3 a su estado final (enviada, fallida o con fallos parciales), con sus conteos.\n\n```json\n{\n "id": "evt_000000000000000000000011",\n "type": "broadcast.finished",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "broadcast": {\n "id": 6001,\n "name": "Campa\xF1a de ejemplo"\n },\n "status": "partially_failed",\n "reason": "completed",\n "totals": {\n "processed": 1200,\n "failed": 30,\n "skipped_unreachable": 12\n }\n }\n}\n```\n\n`status` es `success`, `failed` o `partially_failed`. `reason` dice por qu\xE9 termin\xF3: `completed` (se enviaron todas las p\xE1ginas), `cancelled` (se cerr\xF3 una campa\xF1a pausada), `template_rejected` (Meta rechaz\xF3 la plantilla, o su categor\xEDa cambi\xF3, mientras la campa\xF1a esperaba; tambi\xE9n cuando Meta la paus\xF3 durante el env\xEDo y se detuvo la campa\xF1a), `approval_expired` (la plantilla no se aprob\xF3 a tiempo), `mac_lockout` (la cuenta alcanz\xF3 su l\xEDmite de contactos activos), `mac_check_unavailable` (la campa\xF1a la inici\xF3 una API key y el l\xEDmite de contactos activos no se pudo verificar, as\xED que no se envi\xF3 nada), `no_recipients` (la audiencia qued\xF3 vac\xEDa) o `error` (el env\xEDo no pudo despacharse). `totals` son los conteos del env\xEDo: `processed` (destinatarios procesados), `failed` y `skipped_unreachable` (destinatarios que el canal no puede alcanzar); valen 0 si la campa\xF1a termin\xF3 antes de enviar. Se emite una sola vez por campa\xF1a.\n\n#### `contact.stage_changed`\n\nUn contacto cambi\xF3 de etapa en un tablero.\n\n```json\n{\n "id": "evt_000000000000000000000008",\n "type": "contact.stage_changed",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "contact": {\n "id": 2001,\n "name": "Contacto de ejemplo",\n "phone": "+5491100000000"\n },\n "board": {\n "id": 5001,\n "name": "Embudo de ejemplo"\n },\n "kind": "move",\n "from_stage": {\n "id": 6001,\n "name": "Nuevo",\n "position": 0\n },\n "to_stage": {\n "id": 6002,\n "name": "En seguimiento",\n "position": 1\n }\n }\n}\n```\n\nSe env\xEDa un evento por cada contacto que cambia de etapa en un tablero por etapas. `kind` es entry (entra al tablero, `from_stage` es null), move (pasa de una etapa a otra), exit (sale del tablero, `to_stage` es null) o stage_deleted (se reubica porque su etapa se borr\xF3; el nombre de la etapa borrada es null). No se env\xEDa si el contacto se coloca en la etapa en la que ya estaba.\n\n#### `message.status_changed`\n\nCambi\xF3 el estado de entrega de un mensaje enviado.\n\n```json\n{\n "id": "evt_000000000000000000000014",\n "type": "message.status_changed",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "message_id": 1001,\n "contact_id": 2001,\n "device_id": 4521,\n "status": "failed",\n "previous_status": "sent",\n "error": {\n "code": 131047,\n "title": "Re-engagement message",\n "reason": null\n },\n "occurred_at": "2026-10-01T12:00:00Z",\n "client_reference": "pedido-1001"\n }\n}\n```\n\nSe emite cuando cambia el estado de entrega de un mensaje que enviaste a un contacto, ya sea desde el chat, una automatizaci\xF3n o un asistente de IA (incluye los enviados con clave). No se emite para mensajes de difusiones ni para mensajes recibidos. `message_id` es el id que devuelve la API, `contact_id` el del contacto y `device_id` el n\xFAmero de tu cuenta por el que sali\xF3 el mensaje, el mismo `device_id` que usas para enviar (`null` si no tiene). `status` es `sent`, `delivered`, `read` o `failed`. `previous_status` es el estado que el mensaje ten\xEDa justo antes de este cambio y es `null` en tres casos: es el primer aviso del mensaje, es el mismo estado que `status` (por ejemplo, el proveedor confirma el `sent` que ya registr\xF3 el env\xEDo) o no se pudo leer. `error` solo viene cuando `status` es `failed`: `code` es el c\xF3digo num\xE9rico del proveedor y `title` su t\xEDtulo, y cualquiera de los dos puede ser `null` si el proveedor no los informa. `error.reason` es `null` cuando el fallo lo inform\xF3 el proveedor; cuando Funnelchat decidi\xF3 que un mensaje enviado con clave no sal\xEDa (por ejemplo, por saldo insuficiente o ventana de 24 horas cerrada) trae el motivo (`device_unavailable`, `insufficient_balance`, `window_closed`, `recipient_unreachable`, `template_not_found`, `template_unresolved_placeholder`, `not_queued` o `not_sent`), `code` es `null` y `title` es un texto llano en el idioma de la cuenta. `occurred_at` es el momento en que Funnelchat registr\xF3 el estado, en UTC. `client_reference` es la referencia que mandaste al enviar el mensaje (por ejemplo `pedido-1001`) y `null` si el env\xEDo no la llevaba: sirve para relacionar cada aviso con tu env\xEDo tambi\xE9n cuando la llamada de env\xEDo no respondi\xF3. Cada estado se informa una sola vez por mensaje: si llegan varios avisos juntos se informa el m\xE1s avanzado y un aviso atrasado que no cambia el estado no genera evento. Un mensaje que no lleg\xF3 a salir (por ejemplo, por ventana de 24 horas cerrada) no recibe avisos de estado, salvo uno enviado con clave: ese recibe un \xFAnico aviso `failed` con `previous_status` en `null`, su `error.reason` y su `client_reference`. Un mensaje enviado con clave cuyo resultado no se conoce (el proveedor no respondi\xF3 o el env\xEDo se interrumpi\xF3) no recibe aviso. Un mensaje cuyo env\xEDo no empez\xF3 dentro de la hora ya no sale: pasada la hora sin resultado, su estado es `null`, o `failed` con `error.reason` `not_sent` y su aviso si su env\xEDo llega tarde. Reintentar es un env\xEDo nuevo, que s\xF3lo puede duplicar si el env\xEDo hab\xEDa empezado y se cort\xF3. Un mensaje `queued` no es enviado: su resultado llega por este evento o por la consulta del mensaje. En cuentas con mucho tr\xE1fico, los eventos que excedan el l\xEDmite de entrega por minuto de la cuenta se descartan. Para recibirlo debes suscribirte a `message.status_changed` de forma expl\xEDcita: una suscripci\xF3n a `*` no lo incluye.\n\n#### `conversation.closed`\n\nUna conversaci\xF3n pas\xF3 a cerrada.\n\n```json\n{\n "id": "evt_000000000000000000000004",\n "type": "conversation.closed",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "contact": {\n "id": 2001,\n "name": "Contacto de ejemplo",\n "phone": "+5491100000000"\n }\n }\n}\n```\n\nSe env\xEDa cuando una conversaci\xF3n pasa de abierta a cerrada. No se env\xEDa si la conversaci\xF3n ya estaba cerrada ni cuando se reabre.\n\n#### `conversation.assigned`\n\nUna conversaci\xF3n qued\xF3 asignada a una persona del equipo o a un agente de IA.\n\n```json\n{\n "id": "evt_000000000000000000000005",\n "type": "conversation.assigned",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "contact": {\n "id": 2001,\n "name": "Contacto de ejemplo",\n "phone": "+5491100000000"\n },\n "assignee": {\n "type": "human",\n "name": "Agente de ejemplo"\n }\n }\n}\n```\n\nSe env\xEDa cuando una conversaci\xF3n queda asignada a una persona del equipo (`assignee.type` = `human`) o a un agente de IA (`assignee.type` = `ai`), por cualquier camino. `assignee` trae el tipo y el nombre, nunca un identificador. No se env\xEDa al desasignar, ni cuando se vuelve a enviar el mismo asignado.\n\n#### `contact.tag_added`\n\nUn contacto recibi\xF3 una etiqueta.\n\n```json\n{\n "id": "evt_000000000000000000000006",\n "type": "contact.tag_added",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "contact": {\n "id": 2001,\n "name": "Contacto de ejemplo",\n "phone": "+5491100000000"\n },\n "tag": {\n "id": 4001,\n "name": "Etiqueta de ejemplo"\n },\n "source": "manual"\n }\n}\n```\n\nSe env\xEDa un evento por cada contacto y etiqueta agregados, tambi\xE9n en operaciones en bloque; no se env\xEDa si el contacto ya ten\xEDa la etiqueta. `source` indica el origen: manual, flow, bulk, import, sync, walink o ai. En operaciones en bloque grandes el l\xEDmite de volumen de la cuenta puede descartar parte de los eventos.\n\n#### `broadcast.paused`\n\nUna campa\xF1a se paus\xF3 sola, por ejemplo porque baj\xF3 la calidad del n\xFAmero.\n\n```json\n{\n "id": "evt_000000000000000000000007",\n "type": "broadcast.paused",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "broadcast": {\n "id": 6001,\n "name": "Campa\xF1a de ejemplo"\n },\n "reason": "quality_degraded",\n "quality": "YELLOW",\n "quality_baseline": "GREEN",\n "template": null\n }\n}\n```\n\n`reason` es `quality_degraded` (la calidad del n\xFAmero baj\xF3 respecto de la del inicio, o lleg\xF3 a RED), `quality_unreadable` (no se pudo leer la calidad en tres controles seguidos), `template_unavailable` (Meta dej\xF3 la plantilla en `PAUSED`, `DISABLED` o `REJECTED` antes de una tanda) o `rejection_rate` (en los env\xEDos recientes del n\xFAmero, por defecto las \xFAltimas 24 horas, Meta rechaz\xF3 una proporci\xF3n igual o mayor al umbral configurable: el c\xF3digo 131049, por defecto 5 %; el c\xF3digo 131026, por defecto 5 %; o los c\xF3digos que indican una restricci\xF3n de la cuenta o del n\xFAmero, por defecto 15 %, con la lista configurable y por defecto 131049, 131048, 131031 y 368). Con `rejection_rate` llega adem\xE1s `rejection`: `codes` (los que llegaron a su umbral: `131049`, `131026` o `failed`), `rates` (el porcentaje de cada uno), `attempts` (los env\xEDos medidos) y `window_hours`. `quality` y `quality_baseline` son la calidad de Meta le\xEDda en el control y la del inicio del env\xEDo (`GREEN`, `YELLOW`, `RED`), o `null` si no se pudo leer. `template` es `{name, status}` con el nombre de la plantilla y su estado en Meta cuando `reason` es `template_unavailable`, y `null` en las dem\xE1s pausas (por calidad o por rechazos). Con `rejection_rate` el n\xFAmero adem\xE1s queda frenado 7 d\xEDas (configurable): mientras dure no se pueden crear, ejecutar ni reanudar campa\xF1as desde ese n\xFAmero, ni mandar plantillas a contactos que no le escribieron en las \xFAltimas 24 horas, y solo soporte lo levanta antes. S\xF3lo las campa\xF1as con env\xEDo inteligente se pausan solas. Reanudar la campa\xF1a y que se pause otra vez emite otro evento.\n\n#### `contact.tag_removed`\n\nUn contacto perdi\xF3 una etiqueta.\n\n```json\n{\n "id": "evt_000000000000000000000009",\n "type": "contact.tag_removed",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "contact": {\n "id": 2001,\n "name": "Contacto de ejemplo",\n "phone": "+5491100000000"\n },\n "tag": {\n "id": 4001,\n "name": "Etiqueta de ejemplo"\n },\n "source": "manual"\n }\n}\n```\n\nSe env\xEDa un evento por cada contacto y etiqueta quitados, tambi\xE9n en operaciones en bloque; no se env\xEDa si el contacto no ten\xEDa la etiqueta. No se env\xEDa al borrar la etiqueta, al borrar o fusionar contactos ni al desvincular etiquetas de un tablero. `source` indica el origen: manual, flow, bulk o ai. En operaciones en bloque grandes el l\xEDmite de volumen de la cuenta puede descartar parte de los eventos.\n\n#### `contact.created`\n\nSe cre\xF3 un contacto nuevo en la cuenta.\n\n```json\n{\n "id": "evt_000000000000000000000010",\n "type": "contact.created",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "contact": {\n "id": 2001,\n "name": "Contacto de ejemplo",\n "phone": "+5491100000000",\n "email": "contacto@example.com"\n }\n }\n}\n```\n\nSe env\xEDa un evento por cada contacto nuevo, sea cual sea el canal: un mensaje entrante, la creaci\xF3n manual, una importaci\xF3n o la API. No se env\xEDa cuando el contacto ya exist\xEDa y s\xF3lo se actualiza, ni para los contactos que la plataforma crea para sus propias notificaciones. En importaciones grandes el l\xEDmite de volumen de la cuenta puede descartar parte de los eventos.\n\n#### `template.status_changed`\n\nUna plantilla cambi\xF3 de estado de aprobaci\xF3n en Meta.\n\n```json\n{\n "id": "evt_000000000000000000000012",\n "type": "template.status_changed",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "template": {\n "id": 7001,\n "uid": "1234567890",\n "name": "plantilla_ejemplo",\n "category": "MARKETING"\n },\n "previous_status": "PENDING",\n "status": "APPROVED",\n "reason": null\n }\n}\n```\n\n`template` identifica la plantilla igual que `GET /templates` (`id`, `uid`, `name`, `category`). `previous_status` y `status` son los estados tal como los devuelve la API (por ejemplo `PENDING`, `APPROVED`, `REJECTED`, `PAUSED`); `previous_status` es `null` si la plantilla no ten\xEDa estado. `reason` trae la observaci\xF3n de rechazo (traducida como en la API y limitada a 500 caracteres) cuando el cambio es un rechazo informado al crear la plantilla, y es `null` en el resto. No se emite si Meta reenv\xEDa el estado que la plantilla ya ten\xEDa. Si una misma plantilla de Meta figura en varias cuentas, cada cuenta recibe su propio evento.\n\n#### `flow.failed`\n\nLa ejecuci\xF3n de un flujo para un contacto termin\xF3 por un error.\n\n```json\n{\n "id": "evt_000000000000000000000013",\n "type": "flow.failed",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "flow": {\n "id": 3001,\n "name": "Flujo de ejemplo"\n },\n "contact": {\n "id": 2001,\n "name": "Contacto de ejemplo",\n "phone": "+5491100000000"\n },\n "reason": "execution_error"\n }\n}\n```\n\nSe env\xEDa una sola vez por cada ejecuci\xF3n de un flujo que termina por un error, no porque lleg\xF3 al final. `reason` indica el motivo y toma uno de estos valores: `node_missing` (el paso a ejecutar ya no existe), `unknown_node_type` (el paso es de un tipo no reconocido), `missing_subflow` (el flujo que deb\xEDa ejecutarse dentro de otro ya no existe), `malformed_node` (el paso tiene una configuraci\xF3n da\xF1ada), `loop_detected` (la ejecuci\xF3n dio vueltas sin esperar nada) y `execution_error` (un error inesperado detuvo la ejecuci\xF3n). No se env\xEDa si la ejecuci\xF3n se cancel\xF3, qued\xF3 bloqueada o se cort\xF3 porque se edit\xF3 el flujo o se unieron contactos. `contact` es `null` si la ejecuci\xF3n no tiene un contacto asociado.\n\n#### `whatsapp_form.completed`\n\nUn contacto complet\xF3 un formulario de WhatsApp.\n\n```json\n{\n "id": "evt_000000000000000000000015",\n "type": "whatsapp_form.completed",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "form": {\n "id": 4001,\n "name": "Captar un lead",\n "version_id": 4002\n },\n "message_id": 1001,\n "contact_id": 2001,\n "device_id": 5001,\n "answers": [\n {\n "key": "nombre",\n "label": "Nombre",\n "value": "Contacto de ejemplo"\n },\n {\n "key": "intereses",\n "label": "Intereses",\n "value": [\n "op_a",\n "op_b"\n ]\n },\n {\n "key": "comentarios",\n "label": "Comentarios",\n "value": null\n }\n ],\n "occurred_at": "2026-10-01T12:00:00Z"\n }\n}\n```\n\nSe env\xEDa cuando un contacto completa un formulario, aunque la respuesta llegue tarde. Puede llegar m\xE1s de una vez con el mismo `id`: deduplica por `id`. Si el contacto vuelve a enviar el mismo formulario, no se genera otro aviso: queda la primera respuesta. Cada respuesta de texto llega con a lo sumo 600 caracteres. `form.id` es el formulario (el mismo `family_id` de la lectura de formularios) y `form.version_id` la versi\xF3n que recibi\xF3 el contacto. `answers` es una lista con una entrada por pregunta, en el orden del formulario: `key` es el nombre de la pregunta, `label` su etiqueta y `value` la respuesta (texto, n\xFAmero, `true` o `false`, una lista de textos en opci\xF3n m\xFAltiple, o `null` si no la contest\xF3). En las preguntas de lista y de opci\xF3n m\xFAltiple, `value` es el id de la opci\xF3n. `message_id` es el id del mensaje del formulario y es `null` si el env\xEDo no tuvo un mensaje p\xFAblico; `device_id` es el n\xFAmero de WhatsApp por el que sali\xF3, o `null` si ya no es de la cuenta. `contact_id` es `null` si el contacto se elimin\xF3 antes de que saliera el aviso (las respuestas llegan igual); si se fusion\xF3 con otro, es el id del contacto que qued\xF3. Mientras se fusionan dos contactos, el id puede ser el del contacto que se est\xE1 fusionando.\n\n#### `webhook.test`\n\nEvento de prueba enviado desde la ruta de prueba de una suscripci\xF3n.\n\n```json\n{\n "id": "evt_000000000000000000000002",\n "type": "webhook.test",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "subscription": "00000000-0000-4000-8000-000000000001",\n "sent_at": "2026-10-01T12:00:00Z"\n }\n}\n```\n\n### Otros webhooks de la plataforma\n\nLos webhooks de alertas y el nodo \xABPetici\xF3n HTTP\xBB de las automatizaciones son otros productos, con otro formato y otra configuraci\xF3n. No se administran con las rutas de esta secci\xF3n ni llevan este sobre ni esta firma.\n\n## Listados\n\nCon una clave de API, los listados siempre responden una p\xE1gina con la forma `{ "data": [...], "links": {...}, "meta": {...} }`: `data` trae los \xEDtems de la p\xE1gina, `links` las URL de `first`, `last`, `prev` y `next` (`null` cuando no hay p\xE1gina), y `meta` el detalle (`current_page`, `from`, `last_page`, `links`, `path`, `per_page`, `to`, `total`).\n\n- **Tama\xF1o de p\xE1gina:** el par\xE1metro `perPage` es un entero entre 1 y 100. Si no lo mandas, la p\xE1gina trae 25 \xEDtems. Un valor mayor que 100, menor que 1 o que no sea un entero responde 422 con el detalle en `errors.perPage`. Algunos listados aceptan adem\xE1s `per_page` como alias con los mismos l\xEDmites; si mandas `perPage` y `per_page` con valores distintos, la respuesta es 422 con el detalle en `errors.perPage`.\n- **Orden:** cada listado documenta los par\xE1metros de orden que admite y los valores permitidos de cada uno (por ejemplo `orderBy` y `orderByMethod`). Un par\xE1metro de orden que el listado no admite, o un valor fuera de la lista, responde 422 con el detalle en `errors.<par\xE1metro>`.\n- **P\xE1ginas siguientes:** las URL de `links` y de `meta.links` conservan `perPage`, el orden y los filtros que admite el listado; los dem\xE1s par\xE1metros no se copian y s\xF3lo cambia `page`. En los listados que se piden por `POST`, los filtros viajan en el cuerpo y las URL llevan s\xF3lo `perPage` y `page`.\n- **Primero el permiso:** si tu clave no tiene el permiso del listado, la respuesta es la de siempre (401 o 403), aunque mandes un `perPage` inv\xE1lido.\n\n## Env\xEDo de mensajes con clave\n\nCuando env\xEDas con una clave de API, antes de enviar se aplican reglas de protecci\xF3n de tu n\xFAmero y de tus contactos. Si una regla frena el env\xEDo, la respuesta es un JSON `{ "error": "<c\xF3digo>", "message": "<explicaci\xF3n>" }` y no se env\xEDa nada ni se registra ning\xFAn mensaje. Cada operaci\xF3n de env\xEDo documenta los c\xF3digos que puede responder.\n- **A qui\xE9n env\xEDas con `POST /message/send-message` y `POST /devices/{device}/templates/{template_id}/send-template`:** Indica a qui\xE9n env\xEDas con `phone`: el tel\xE9fono con c\xF3digo de pa\xEDs, por ejemplo `5491122334455`. No hace falta crear el contacto antes: si ese tel\xE9fono todav\xEDa no es un contacto de tu cuenta, se crea en el workspace de tu clave, igual que con `POST /contacts/update-or-create` y con el mismo permiso, que **Enviar** ya incluye. La respuesta trae su id en `message.contact_id`, el mismo que llevan los avisos `message.status_changed`. Si el tel\xE9fono no es v\xE1lido, o ya es un contacto de otro workspace de tu cuenta, la respuesta es la misma que dar\xEDa `POST /contacts/update-or-create`: 422, o 404 sin datos de ese contacto. Con un n\xFAmero oficial de WhatsApp (API Cloud) tambi\xE9n puedes indicar `whatsapp_id`, el BSUID que env\xEDa Meta (por ejemplo `US.13491208655302741918`), en lugar de `phone`; usado as\xED con un n\xFAmero conectado por QR, responde 422. En un n\xFAmero oficial, el primer mensaje a un contacto nuevo tiene que ser una plantilla: un mensaje libre solo sale dentro de las 24 horas desde que el contacto te escribi\xF3. Si ya tienes el `contact_id`, puedes seguir envi\xE1ndolo: si junto con \xE9l mandas `phone` o `whatsapp_id`, tienen que ser de ese mismo contacto (si no, 422 `recipient_mismatch`). En `send-template`, `phone` o `whatsapp_id` va siempre, con o sin `contact_id`.\n- **Ritmo:** el l\xEDmite depende s\xF3lo del tipo de n\xFAmero por el que env\xEDas; en los env\xEDos no hay un l\xEDmite por minuto por clave ni por cuenta. Al superarlo, la respuesta es 429 con `error` = `rate_limited` y el encabezado `Retry-After`, que trae los segundos que debes esperar antes de reintentar. Las reacciones s\xF3lo tienen esta regla.\n - **N\xFAmero de WhatsApp Cloud (API oficial):** hasta 60 mensajes por segundo y hasta 10 mensajes por minuto al mismo contacto.\n - **N\xFAmero en coexistencia** (el mismo tel\xE9fono conectado a WhatsApp Cloud y a la aplicaci\xF3n WhatsApp Business del celular): hasta 15 mensajes por segundo en total entre las dos conexiones, env\xEDes por la que env\xEDes. El l\xEDmite de 10 mensajes por minuto al mismo contacto aplica s\xF3lo a lo que env\xEDas por WhatsApp Cloud.\n - **N\xFAmero conectado por c\xF3digo QR** (sin coexistencia): esta regla no limita sus env\xEDos.\n- **Ventana de 24 horas:** por un n\xFAmero de WhatsApp Cloud s\xF3lo puedes enviar mensajes que no son plantilla a un contacto que te escribi\xF3 en las \xFAltimas 24 horas. Fuera de esa ventana la respuesta es 422 con `error` = `window_closed`; para retomar la conversaci\xF3n env\xEDa una plantilla aprobada, que no tiene esta restricci\xF3n.\n- **Antibloqueo:** en las \xFAltimas 24 horas, un mismo contenido se puede enviar como m\xE1ximo 2 veces al mismo contacto (el siguiente responde 422 con `error` = `antiblock_duplicate_content`), y como m\xE1ximo 5 mensajes seguidos sin que el contacto responda (el siguiente responde 422 con `error` = `antiblock_unanswered_limit`). Cuando el contacto responde, los contadores se reinician. Aplica a mensajes y a plantillas, no a reacciones.\n- **Destinatario:** `recipient_mismatch` (422) indica que el tel\xE9fono o el identificador de WhatsApp que mandaste en el cuerpo no corresponde al contacto de la ruta (s\xF3lo las operaciones que reciben ese dato en el cuerpo). `recipient_unsupported` (422) indica que el contacto es un grupo, una lista de difusi\xF3n o un canal: por la API s\xF3lo se escribe a personas individuales.\n- **Estados de entrega:** el evento de webhook `message.status_changed` (enviado, entregado, le\xEDdo, fallido) no se incluye al suscribirte a `*`: tienes que pedirlo de forma expl\xEDcita en `events` al crear o editar la suscripci\xF3n.\n- **Resultado de tus env\xEDos:** cuando env\xEDas con una clave, la llamada no espera al proveedor: la respuesta trae el mensaje ya registrado y en cola, el mismo objeto que devuelve `GET /contacts/{contact}/messages/{message}`, y el env\xEDo sale enseguida despu\xE9s.\n - **En cola no es enviado:** `status` `queued` solo dice que registramos el mensaje y que est\xE1 por salir. El resultado llega por el evento `message.status_changed` o consultando `GET /contacts/{contact}/messages/{message}`. Un mensaje `queued` no se reintenta: reenviarlo lo duplicar\xEDa.\n - **Aceptado no es entregado:** `status` `sent` solo dice que el proveedor acept\xF3 el mensaje. El estado final (entregado, le\xEDdo o fallido) llega por el evento `message.status_changed` o consultando `GET /contacts/{contact}/messages/{message}`.\n - **`failed`:** el proveedor rechaz\xF3 el mensaje dando un motivo, o Funnelchat lo fren\xF3 antes de enviarlo. No sali\xF3: reintentar es un intento nuevo y no lo duplica. Cuando lo decidi\xF3 Funnelchat, `error.reason` trae el motivo, `error.code` es `null` y `error.title` es un texto llano en tu idioma; si lo rechaz\xF3 el proveedor, `error.reason` es `null`. Qu\xE9 hacer seg\xFAn `error.reason`: `device_unavailable`, reconecta el n\xFAmero en Funnelchat y env\xEDa de nuevo; `insufficient_balance`, recarga tu billetera de WhatsApp; `window_closed`, env\xEDa una plantilla aprobada; `recipient_unreachable`, revisa el tel\xE9fono del contacto; `template_not_found` o `template_unresolved_placeholder`, corrige la plantilla o sus variables; `not_queued` o `not_sent`, vuelve a intentarlo. Un mensaje enviado con clave que termina `failed` tambi\xE9n llega por `message.status_changed`, con `previous_status` en `null` y su `client_reference`; un `status` `null` no recibe aviso.\n - **`status` `null`:** el proveedor no confirm\xF3 ni rechaz\xF3 el env\xEDo (no respondi\xF3, respondi\xF3 con una falla sin motivo o el env\xEDo se interrumpi\xF3). Puede haber llegado al contacto, no recibir\xE1 avisos de estado y reintentarlo puede duplicarlo.\n - **M\xE1s de una hora en `queued`:** un mensaje cuyo env\xEDo no empez\xF3 dentro de la hora ya no sale. Pasada la hora sin resultado, su `status` es `null`, o `failed` con `error.reason` `not_sent` y su aviso `message.status_changed` si su env\xEDo llega tarde. Reintentar es un env\xEDo nuevo, que s\xF3lo puede duplicar si el env\xEDo hab\xEDa empezado y se cort\xF3.\n - **Error 5xx o llamada sin respuesta:** busca tu `client_reference` en el historial del contacto antes de reintentar, y hazlo al menos un minuto despu\xE9s de la llamada. Si aparece, el mensaje qued\xF3 registrado: no lo reenv\xEDes, su resultado llega por `message.status_changed` o consultando `GET /contacts/{contact}/messages/{message}`; la excepci\xF3n son las tarjetas y los destinos de un reenv\xEDo tras un 5xx: si todos los que aparecen vienen `failed` con `not_queued`, se reintenta la llamada completa; si alguno aparece `queued`, qued\xF3 registrado: no lo reenv\xEDes. Si no aparece, no sali\xF3 nada y puedes reintentar. Repetir una referencia no evita duplicados: dos llamadas con la misma `client_reference` registran dos mensajes.\n - **Error 404 en un env\xEDo:** el contacto no existe. En un reenv\xEDo, tambi\xE9n cuando el mensaje de origen no existe o no es de ese contacto. Al enviar por `phone` o `whatsapp_id`, cuando ese contacto es de otro workspace de tu cuenta.\n - **`POST /contacts/{contact}/message`:** la respuesta trae el mensaje en `message`: `queued` si es un mensaje, o el mensaje registrado de una nota.\n - **`POST /contacts/{contact}/message/contact-card`:** la respuesta trae `messages`: un mensaje por tarjeta, en el orden de `contact_card_ids`, cada uno con su `id` y en cola (`queued`). Todas salen despu\xE9s, desde la cola: el resultado de cada una llega por `message.status_changed` o consultando `GET /contacts/{contact}/messages/{message}`, con tu `client_reference`. Si una tarjeta no pudo encolarse, su entrada viene `failed` con `error.reason` `not_queued`: no sali\xF3 y volver a enviar esa tarjeta es un intento nuevo. Si alguna no pudo registrarse, la llamada responde un error 5xx y ninguna tarjeta sali\xF3: las que ya aparezcan en el historial del contacto vienen `failed` con `error.reason` `not_queued` y su propio aviso `message.status_changed` `failed`. Si todas las que aparecen vienen `failed` con `not_queued`, reintenta la llamada completa; si alguna aparece `queued`, qued\xF3 registrada: no la reenv\xEDes.\n - **Reenv\xEDo:** la respuesta trae un elemento por destino, en el orden de `targets`, y cada destino que qued\xF3 en cola trae su `message` con su `id` y en cola (`queued`). Todos salen despu\xE9s, desde la cola: el resultado de cada uno llega por `message.status_changed` o consultando `GET /contacts/{contact}/messages/{message}`, con tu `client_reference`. Un destino que no pudo encolarse viene `blocked` con `reason` `not_sent` y su `message` `failed` con `error.reason` `not_queued`: no sali\xF3 y volver a reenviarlo a ese destino es un intento nuevo. Un destino `blocked` por MAC (`mac_limit_reached` o `mac_lockout_24h`) o con `reason` `target_not_found` (no existe o no es de tu cuenta), `contact_blocked` (bloqueaste al contacto en ese n\xFAmero) o `device_unavailable` trae `message` `null`: no sali\xF3 nada. Si alg\xFAn destino no pudo registrarse, la llamada responde un error 5xx y ninguno sali\xF3: los que ya aparezcan en el historial del contacto vienen `failed` con `error.reason` `not_queued` y su propio aviso `message.status_changed` `failed`. Si todos los que aparecen vienen `failed` con `not_queued`, reintenta la llamada completa; si alguno aparece `queued`, qued\xF3 registrado: no lo reenv\xEDes.\n - **`POST /contacts/{contact}/message/form`:** la respuesta trae solo `message`: el mensaje del formulario en cola (`queued`). Si no pudo ponerse en cola, la respuesta sigue siendo 200 con el mensaje en estado `failed` y `error.reason` `not_queued`: no sali\xF3, as\xED que reintentar no lo duplica. Su resultado llega por `message.status_changed` o consultando `GET /contacts/{contact}/messages/{message}`, y la respuesta del contacto por `whatsapp_form.completed`. Si tu cuenta est\xE1 en bloqueo por el l\xEDmite de contactos activos responde 429 `mac_lockout_24h` y no guarda ning\xFAn mensaje ni `client_reference`. Sus errores propios est\xE1n en la gu\xEDa \xABFormularios de WhatsApp\xBB.\n - **`POST /message/send-message` y `POST /devices/{device}/templates/{template_id}/send-template`:** la respuesta trae solo `message`: el mensaje en cola (`queued`), o el mensaje registrado de una nota en `send-message`. Si una variable de la plantilla se completa con un campo personalizado que no existe en tu cuenta, `send-template` responde 422 con `error` `template_unresolved_placeholder`, `placeholders` (hasta 20) y `count` (el total), y no registra ni env\xEDa nada; si la plantilla no existe en tu cuenta, responde 404 `template_not_found`.\n- **Correlacionar tus env\xEDos:** manda `client_reference` en el cuerpo (texto opcional de 1 a 64 caracteres visibles, sin espacios) para identificar cada env\xEDo con tu propia referencia. La misma referencia vuelve en el historial de mensajes del contacto y en el evento `message.status_changed`, de modo que puedes relacionar cada aviso de estado con tu env\xEDo tambi\xE9n cuando la llamada de env\xEDo no respondi\xF3. La referencia no es \xFAnica (dos env\xEDos pueden usar el mismo valor) y no est\xE1 autenticada: la ven todas las claves y todas las suscripciones de la cuenta. Por eso relaci\xF3nala siempre junto con `message_id` y `contact_id`, y tr\xE1tala como texto no confiable cuando la muestres. No pongas datos personales en la referencia. Una llamada que frena una regla de protecci\xF3n de esta secci\xF3n (ritmo, ventana de 24 horas, antibloqueo o destinatario) no registra ning\xFAn mensaje ni referencia. Un rechazo por MAC (`mac_limit_reached` o `mac_lockout_24h`) es distinto: guarda un mensaje con estado de error que lleva la referencia; el env\xEDo de formularios (`POST /contacts/{contact}/message/form`) es la excepci\xF3n: con `mac_lockout_24h` responde 429 y no guarda ning\xFAn mensaje ni referencia. Un mensaje enviado con clave que Funnelchat frena antes de que salga (por ejemplo, por saldo insuficiente) aparece en el historial con estado de error y su referencia, y recibe un aviso `failed` con esa referencia; uno enviado desde el panel no recibe avisos de estado. Las reacciones no aceptan `client_reference`.\n\n## Le\xEDdo y escribiendo\n\nPuedes marcar como le\xEDdo un mensaje que te mand\xF3 un contacto y mostrarle \xABescribiendo\u2026\xBB mientras preparas la respuesta. Ninguna de las dos operaciones env\xEDa un mensaje: no cuentan para el ritmo de env\xEDo ni para las dem\xE1s reglas de \xABEnv\xEDo de mensajes con clave\xBB, y requieren el permiso **Contactos y conversaciones \u2192 Enviar** porque el contacto ve su efecto.\n\n- **Tipos de n\xFAmero:** \xABmarcar como le\xEDdo\xBB funciona en n\xFAmeros de WhatsApp Cloud (API oficial, tambi\xE9n el lado oficial de un n\xFAmero en coexistencia) y en n\xFAmeros conectados por c\xF3digo QR que lo permiten; \xABescribiendo\u2026\xBB funciona solo en n\xFAmeros de WhatsApp Cloud. Si el n\xFAmero no lo permite, la respuesta es 422 con `error` = `provider_unsupported`.\n- **Marcar como le\xEDdo** (`POST /contacts/{contact}/messages/{message}/read`): el contacto ve el doble check azul. WhatsApp marca tambi\xE9n los mensajes anteriores de esa conversaci\xF3n y solo acepta mensajes recibidos en los \xFAltimos 30 d\xEDas. En Funnelchat la conversaci\xF3n queda le\xEDda, igual que cuando un agente la abre en el panel; si llegaron mensajes despu\xE9s del que marcaste, siguen contando como no le\xEDdos.\n- **\xABEscribiendo\u2026\xBB** (`POST /contacts/{contact}/typing`): se muestra sobre el \xFAltimo mensaje que el contacto te mand\xF3 en las \xFAltimas 24 horas, por el n\xFAmero que indiques en `device_id` o, si no lo mandas, por el n\xFAmero de su \xFAltimo mensaje. Dura hasta que respondes o 25 segundos, lo que pase primero. WhatsApp tambi\xE9n marca ese mensaje (y los anteriores) como le\xEDdo, as\xED que el contacto ve el doble check azul; en Funnelchat la conversaci\xF3n no cambia. P\xEDdelo solo cuando vayas a responder.\n- **L\xEDmites:** entre las dos operaciones, hasta 20 llamadas por segundo por n\xFAmero y 50 por cuenta. El l\xEDmite por cuenta incluye las llamadas rechazadas con 429 o con alguno de los c\xF3digos 422 de esta secci\xF3n; no cuentan los 404 ni los errores de validaci\xF3n del cuerpo (422 con errors). Al superar cualquiera de los dos, la respuesta es 429 con `error` = `rate_limited` y el encabezado `Retry-After`.\n- **Si WhatsApp no confirma:** la respuesta es 502 con `error` = `provider_error` y no se cambia nada; puedes reintentar.\n\n## Enviar un archivo por URL\nNo subes el archivo a Funnelchat: en `file` mandas la direcci\xF3n (URL) del archivo y WhatsApp o el proveedor lo descarga desde ah\xED. Funnelchat no abre la URL al recibir tu llamada, as\xED que un archivo inaccesible, vencido o fuera de los l\xEDmites se conoce al enviarlo: el mensaje termina `failed` con el error del proveedor.\n- **La URL:** `file` admite hasta 4096 caracteres; m\xE1s de eso se rechaza con un 422 en `file`. Si `file` empieza con `http`, en may\xFAsculas o min\xFAsculas, tiene que ser una URL que empiece con `http://` o `https://`; si no (por ejemplo `httpfoto.jpg`), la respuesta es 422 con el error en `file`. Te recomendamos `https://`; `http://` tambi\xE9n se acepta, pero viaja sin cifrar. Debe poder descargarse sin credenciales; puede ser una URL firmada. Tiene que seguir v\xE1lida hasta que el mensaje sale: un env\xEDo con clave puede esperar en cola hasta 54 minutos, as\xED que usa URLs que duren al menos una hora. No pongas secretos en la ruta ni en el dominio de la URL: en los registros de actividad (logs) del env\xEDo que escribe Funnelchat, de la URL quedan s\xF3lo el esquema, el dominio y la ruta (sin el usuario y la contrase\xF1a, la parte de consulta despu\xE9s de `?` ni el fragmento despu\xE9s de `#`) y del resultado s\xF3lo identificadores, el tipo del mensaje y, del error del proveedor, sus c\xF3digos y, s\xF3lo si tu n\xFAmero es de WhatsApp Cloud, su categor\xEDa, su t\xEDtulo corto y su identificador de traza, nunca el texto de la respuesta del proveedor; la ruta y el dominio s\xED quedan. La URL completa viaja a WhatsApp o al proveedor de tu n\xFAmero, que la descarga y puede guardarla en sus propios registros. Adem\xE1s, la URL completa, con su consulta, se guarda en el historial del contacto, la ven los usuarios de tu cuenta y se copia al reenviar el mensaje: usa URLs firmadas de corta duraci\xF3n (al menos el tiempo de la cola, cerca de una hora) y limitadas a un solo archivo.\n- **El tipo:** decide la extensi\xF3n de la ruta de la URL si es una de la lista; si no, el final de la URL completa si es una de la lista. Si la ruta no termina en una extensi\xF3n de la lista y s\xF3lo lo hace el final de la URL completa (por ejemplo `https://cdn.example.com/video.mov?poster=frame.jpg`), el mensaje queda registrado con ese tipo, pero WhatsApp o el proveedor pueden enviarlo seg\xFAn la extensi\xF3n de la ruta; si necesitas un tipo fijo, env\xEDa `file_type`:\n - `.jpg`, `.jpeg`, `.png`: `image`\n - `.webp`: `sticker`\n - `.ogg`, `.oga`, `.mp3`, `.m4a`, `.aac`, `.opus`, `.wav`: `audio`\n - `.gif`, `.mp4`: `video`\n - `.doc`, `.docx`, `.pdf`: `document`\n- **Cu\xE1ndo env\xEDas `file_type`:** si la URL no termina en una extensi\xF3n reconocida (por ejemplo una URL firmada sin extensi\xF3n), la respuesta es 422 con el error en `file_type`: agrega `file_type` con uno de estos valores: `image`, `video`, `audio`, `document`, `sticker`. Cuando lo env\xEDas, manda sobre la extensi\xF3n: sirve tambi\xE9n para un archivo cuya extensi\xF3n no es de los tipos de la lista, como `.xlsx`, que se env\xEDa con `file_type` = `document` y `file_name` (por ejemplo `reporte.xlsx`), porque un documento cuya URL no termina en una extensi\xF3n de la lista necesita su nombre. Si la URL termina en una extensi\xF3n que no es la del archivo (como `/descarga.php?id=7`) y no env\xEDas `file_type`, la respuesta es 415 con la extensi\xF3n recibida (`php`); con `file_type` se env\xEDa y, si es un documento, tambi\xE9n hace falta `file_name`.\n- **Extensi\xF3n no soportada:** si la ruta de la URL termina en un formato real que no enviamos solos (por ejemplo `.xlsx` o `.zip`), la URL completa no termina en una extensi\xF3n de la lista y no mandas `file_type`, la respuesta es 415 con la extensi\xF3n recibida.\n- **El nombre del archivo:** `file_name` (opcional, hasta 255 caracteres, sin `/`, `\\`, `?`, `#` ni caracteres de control) es el nombre con el que el contacto ve un documento; ll\xE9valo siempre con su extensi\xF3n, por ejemplo `factura.pdf`. Es obligatorio cuando env\xEDas un documento por URL y la URL no termina en una extensi\xF3n de la lista: sin \xE9l la respuesta es 422 con el error en `file_name`.\n- **Formatos y tama\xF1os:** los formatos aceptados por WhatsApp y el tama\xF1o m\xE1ximo de cada tipo son:\n - `image`: image/jpeg, image/png, image/webp; hasta 5 MB.\n - `video`: video/mp4, video/3gpp, image/gif; hasta 16 MB.\n - `audio`: audio/aac, audio/mp4, audio/mpeg, audio/amr, audio/ogg, audio/opus; hasta 16 MB.\n - `document`: text/plain, application/pdf, application/vnd.ms-powerpoint, application/msword, application/vnd.ms-excel, application/vnd.openxmlformats-officedocument.wordprocessingml.document, application/vnd.openxmlformats-officedocument.presentationml.presentation, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet; hasta 100 MB.\n - `sticker`: image/webp; hasta 100 KB si es est\xE1tico y 500 KB si es animado.\n- **Historial y reenv\xEDo:** el historial del contacto guarda la URL tal cual la enviaste, y el nombre `file_name` no se guarda. Al reenviar un mensaje cuyo archivo es una URL, se reenv\xEDa la misma URL; con una extensi\xF3n de la lista, como esa extensi\xF3n indica; si no, un mensaje que enviaste se reenv\xEDa con su tipo s\xF3lo cuando su extensi\xF3n es un formato que WhatsApp acepta para ese tipo (los de \xABFormatos y tama\xF1os\xBB, por ejemplo `.3gp` para `video` o `.amr` para `audio`). Una URL sin extensi\xF3n, un formato no aceptado, un documento cuya URL no termina en una extensi\xF3n de la lista (por ejemplo `/descarga.php?id=7`) o un mensaje recibido sin una extensi\xF3n de la lista no se puede reenviar: la respuesta es 422 con `error` = `not_forwardable` y `reason` = `unsupported_file_extension`.\n\n## Formularios de WhatsApp\n\nUn formulario de WhatsApp es un conjunto de pantallas con preguntas que tu contacto abre y completa dentro del chat. Con la API puedes armarlo, publicarlo, enviarlo a un contacto y recibir sus respuestas. S\xF3lo funciona con n\xFAmeros de WhatsApp Cloud (API oficial) de tu cuenta, y s\xF3lo se puede enviar dentro de las 24 horas siguientes al \xFAltimo mensaje del contacto a ese n\xFAmero. Los n\xFAmeros conectados por c\xF3digo QR no sirven para formularios.\n- **Identificadores:** un formulario tiene un identificador estable y una versi\xF3n por cada vez que lo cambias. No los confundas:\n\n | Qu\xE9 es | D\xF3nde aparece |\n |---|---|\n | **Formulario** (estable, el mismo en todas sus versiones) | `family_id` en las respuestas de formularios, `{familyId}` en las rutas `/forms/families/{familyId}/\u2026`, `form_id` al enviar y `data.form.id` en `whatsapp_form.completed` |\n | **Versi\xF3n** (cambia con cada versi\xF3n nueva) | `id` en las respuestas de formularios, `{id}` en las rutas `/forms/{id}/\u2026` de edici\xF3n, publicaci\xF3n y n\xFAmeros, y `data.form.version_id` en `whatsapp_form.completed` |\n\n Al enviar pides el formulario, no la versi\xF3n: sale la versi\xF3n publicada vigente. Si publicas una versi\xF3n nueva, lo que se env\xEDa cambia sin que cambies tu integraci\xF3n.\n- **Recorrido, paso a paso:**\n 1. **Crea el formulario:** `POST /forms` con `template_key` `blank` (un formulario para armar desde cero; `GET /forms/templates` lista las dem\xE1s plantillas), `device_id` (el n\xFAmero de WhatsApp Cloud de tu cuenta) y, si quieres, `device_ids` con otros n\xFAmeros. Responde `201` con la versi\xF3n en borrador (`id`), su `family_id`, `lint` y `numbers`. Un formulario no se crea mandando su contenido: el contenido va en el paso siguiente. Ojo con `blank`: no viene vac\xEDo, trae una pantalla (`SCREEN_BLNK`) sin elementos cuyo `next` es `complete`, es decir, que termina el formulario. Si en el `PUT` conservas esa pantalla y agregas otra, la segunda queda inalcanzable y `lint` marca `screen_unreachable` (error que impide publicar) hasta que el `next` de la primera apunte a la segunda (o `auto`, que sigue el orden). Lo m\xE1s simple es reemplazar `screens` entera con tus propias pantallas.\n 2. **Arma la definici\xF3n:** `PUT /forms/{id}` con `definition` (`screens`, cada una con `id`, `title`, `cta`, `next` e `items`) y, si quieres, `name` y `expected_updated_at`. El servidor guarda la definici\xF3n tal como llega y la respuesta trae `lint` junto a `data`: una lista de `{code, severity, screenId, itemId, message}` con lo que falta corregir. Lo incompleto se guarda igual; los errores (`severity` `error`) impiden publicar y los avisos (`aviso`) no. Una definici\xF3n con la forma rota responde `422`. Para leerla de nuevo, `GET /forms/{id}` trae la versi\xF3n con su definici\xF3n y `GET /forms/families/{familyId}/definition` la del formulario: la de la versi\xF3n publicada vigente o, si todav\xEDa no hay ninguna, la de la m\xE1s nueva. Con `expected_updated_at` evitas pisar un cambio m\xE1s nuevo: si no es el \xFAltimo guardado responde `409` `STALE`.\n 3. **Valida en Meta (opcional):** `POST /forms/{id}/sync` corre el linter, sube el formulario a Meta y trae sus observaciones en `lint` con `code` `meta`, sin publicar. Si la definici\xF3n tiene errores responde `422` `LINT`, igual que al publicar.\n 4. **Publica:** `POST /forms/{id}/publish` hace la validaci\xF3n y publica; es inmediato. Si la definici\xF3n tiene errores responde `422` `LINT` con la lista, y si Meta la rechaza, `502` `META` (`422` `META` si la rechaza porque tu cuenta de WhatsApp Business no cumple sus requisitos de integridad). Un formulario sin ninguna pregunta no se puede publicar. Los otros n\xFAmeros del formulario se publican en segundo plano: consulta su estado (`draft`, `publishing`, `published` o `failed`) en `GET /forms/{id}/numbers`.\n 5. **Suma o quita n\xFAmeros (opcional):** `POST /forms/{id}/numbers` con `device_id` publica el formulario en otro n\xFAmero de WhatsApp Cloud de tu cuenta, `POST /forms/{id}/numbers/retry` reintenta los que fallaron y `DELETE /forms/{id}/numbers/{deviceId}` quita uno. El n\xFAmero con el que creaste el formulario no se quita: `422` `PRIMARY_NUMBER`. Un n\xFAmero que no es de tu cuenta o no es WhatsApp Cloud responde `422` `NUMBER_NOT_ELIGIBLE`.\n 6. **Env\xEDa:** `POST /contacts/{contact}/message/form` con `form_id` (el formulario, no la versi\xF3n) y `device_id`; opcionalmente `text` (hasta 1024 caracteres), `button_text` (hasta 30, sin emojis) y `client_reference`. S\xF3lo se env\xEDa con una clave de API. La clave de un asiento s\xF3lo env\xEDa a los contactos asignados a ese asiento (con cualquier otro contacto responde el `404` de un contacto que no existe); la clave del titular env\xEDa a cualquier contacto de la cuenta. Responde `200` con `message` en cola (`queued`); sus errores propios est\xE1n al final de esta gu\xEDa.\n 7. **Recibe las respuestas:** suscr\xEDbete al evento `whatsapp_form.completed` (tambi\xE9n llega si te suscribes a `*`) o lee `GET /forms/families/{familyId}/responses` y `GET /forms/families/{familyId}/responses/{runId}`. `GET /forms/families/{familyId}/metrics` trae los env\xEDos, aperturas y respuestas.\n - **Cambiar un formulario publicado:** un formulario publicado no se edita (`PUT` y `DELETE` responden `409` `PUBLISHED`). Crea una versi\xF3n nueva con `POST /forms/{id}/versions`, que nace como borrador (`201`, o `200` con `created: false` si ya hab\xEDa un borrador), ed\xEDtala y publ\xEDcala. El `form_id` no cambia: el env\xEDo usa la versi\xF3n publicada vigente.\n- **Reglas de la definici\xF3n:** el servidor no genera ni corrige identificadores: quien arma la definici\xF3n los genera, y deben ser v\xE1lidos y \xFAnicos. Meta acepta s\xF3lo letras y guiones bajos.\n - **Pantallas:** `id` con `SCREEN_` m\xE1s letras (por ejemplo `SCREEN_ABCD`), sin d\xEDgitos, y \xFAnicos en el formulario. `next` es `auto` (la siguiente), `complete` (termina) o el `id` de una pantalla posterior: s\xF3lo se salta hacia adelante.\n - **Preguntas:** `name` en min\xFAsculas y guiones bajos (`a-z` y `_`), \xFAnico en todo el formulario. Sin d\xEDgitos, tildes, `\xF1` ni espacios. No uses los nombres reservados `flow_token`, `version` ni `extension_message_response`. No cambies un `name` despu\xE9s de crearlo: las respuestas, las ramas y las etiquetas lo usan.\n - **Opciones:** `id` de cada opci\xF3n en min\xFAsculas y guiones bajos, \xFAnico en su pregunta (por ejemplo `op_comercio`).\n - **Qu\xE9 marca el linter:** `field_name_reserved` (nombre reservado), `field_name_invalid` (nombre fuera de letras y guiones bajos) y `option_id_invalid` (id de opci\xF3n fuera de ese alfabeto) son errores que impiden validar y publicar. El linter no revisa que los identificadores sean \xFAnicos ni que existan los campos personalizados y las etiquetas que referencias: compru\xE9balo t\xFA.\n - **Campos personalizados y etiquetas:** si una pregunta guarda su respuesta en un campo personalizado (`map` con `target` `custom` y `key` con el nombre del campo) o asigna etiquetas (`tagMap`, con el nombre de la etiqueta), ese campo y esa etiqueta tienen que existir en tu cuenta. El formulario no los crea: si no existen, la respuesta se guarda en el formulario pero no se escribe en el contacto ni se le asigna la etiqueta. Un campo num\xE9rico s\xF3lo admite preguntas `number` y uno de fecha s\xF3lo preguntas `date`; el servidor no lo revisa.\n - **No se pueden publicar** los formularios que necesitan un servidor propio (`endpoint` en `true`, opciones en vivo): el linter los marca como error.\n- **L\xEDmites del formulario:** hasta 5 pantallas, 50 elementos por pantalla, 3 im\xE1genes por pantalla y 2 enlaces por pantalla; pasarse es un error que impide publicar. El nombre del formulario admite hasta 120 caracteres. Las im\xE1genes (`type` `image`, con `src` en base64 sin el prefijo `data:`) deben ser JPG o PNG, de hasta 300 KB cada una y 4 MB entre todas las del formulario; el servidor no revisa su tama\xF1o ni su formato, as\xED que quien arma la definici\xF3n debe respetarlos. El guardado acepta un `src` con el prefijo `data:`, pero se env\xEDa a Meta tal como lo guardaste y Meta dibuja un recuadro roto: manda siempre la carga en base64 pelada, sin prefijo.\n - **Tope de cada guardado:** adem\xE1s, un guardado rechaza (`422`) una definici\xF3n con m\xE1s de 20 pantallas, 100 elementos en una pantalla o 200 opciones en una pregunta. Esos topes quedan por encima de los l\xEDmites de arriba. Adem\xE1s, cada guardado env\xEDa la definici\xF3n entera, im\xE1genes incluidas, y debe pesar menos de unos 6 MB: pasado ese tama\xF1o la plataforma rechaza la llamada antes de que llegue al formulario, as\xED que no cuentes con m\xE1s.\n- **Qu\xE9 puede hacer cada clave:** los permisos se heredan hacia abajo: **Contactos y conversaciones \u2192 Enviar** incluye **Contactos y conversaciones \u2192 Editar**, que incluye **Contactos y conversaciones \u2192 Ver**. Con **Contactos y conversaciones \u2192 Ver** lees formularios, su definici\xF3n (`GET /forms/families/{familyId}/definition`), versiones, n\xFAmeros, respuestas y m\xE9tricas; con **Contactos y conversaciones \u2192 Editar** adem\xE1s armas, guardas, publicas, validas, creas versiones, eliminas borradores y sumas, reintentas o quitas n\xFAmeros; con **Contactos y conversaciones \u2192 Enviar** adem\xE1s env\xEDas. Por eso una clave con **Contactos y conversaciones \u2192 Enviar**, aunque s\xF3lo la hayas pensado para enviar, tambi\xE9n puede armar, publicar y quitar n\xFAmeros de formularios: dale ese permiso s\xF3lo a quien conf\xEDas para eso. Las operaciones `summary`, `analysis` y `deliveries` del panel no est\xE1n abiertas a claves.\n - **Topes de llamadas por clave:** cada clave puede hacer hasta 120 lecturas por minuto y hasta 60 escrituras por minuto en formularios (cada clave lleva su propio contador: dos claves de la misma cuenta, sea del titular o de un asiento, no se frenan entre s\xED, y ninguna gasta el tope del panel). Al superarlos la respuesta es `429` con el encabezado `Retry-After`, que trae los segundos que debes esperar. Validar, publicar y tocar n\xFAmeros tienen adem\xE1s el tope de 30 por minuto compartido por toda la cuenta, que se explica en los errores al final de esta gu\xEDa.\n - **Cuenta:** la clave de un asiento (un agente) opera sobre los formularios del titular de la cuenta, no sobre formularios propios del asiento.\n - **Respuestas con clave de un asiento:** `responses` y `responses/{runId}` se limitan a los contactos que ese asiento puede ver en Funnelchat; las m\xE9tricas (`metrics`) cuentan todas las respuestas del formulario.\n - **Detalle de una respuesta con clave:** `GET /forms/families/{familyId}/responses/{runId}` no trae `last_error` en sus efectos; el resto de cada efecto es igual al del panel.\n- **Aviso `whatsapp_form.completed`:** `data.form` trae `id` (el formulario), `name` y `version_id` (la versi\xF3n que recibi\xF3 el contacto); `message_id` es el mensaje del formulario y puede ser `null`; `answers` es una lista de `{key, label, value}`, una por pregunta y en el orden del formulario (`key` es el `name` de la pregunta, y `value` es `null` si no la contest\xF3). El mismo aviso puede llegar m\xE1s de una vez con el mismo `id`: deduplica por `id`. Cada respuesta de texto llega con a lo sumo 600 caracteres. Si el contacto vuelve a enviar el mismo formulario no se genera otro aviso: queda la primera respuesta. Los detalles y el ejemplo est\xE1n en la secci\xF3n \xABWebhooks\xBB.\n- **Errores al armar y publicar:** `404` si el `id` no existe o no es de tu cuenta (un id de otra cuenta responde igual); `409` `STALE` (el guardado no es el \xFAltimo) y `409` `PUBLISHED` (la versi\xF3n ya est\xE1 publicada); `422` `LINT` al publicar con errores en la definici\xF3n, `422` `NUMBER_NOT_ELIGIBLE` y `422` `PRIMARY_NUMBER` en los n\xFAmeros; `502` `META` si Meta rechaza la operaci\xF3n o no responde, y `422` `META` si la rechaza porque tu cuenta de WhatsApp Business no cumple sus requisitos de integridad. Validar, publicar y tocar n\xFAmeros (`sync`, `publish` y las operaciones de `numbers`) sube a Meta, que limita por cuenta de WhatsApp Business: todas las claves de tu cuenta, la del titular y las de los asientos, comparten un tope de 30 de estas llamadas por minuto. Al superarlo responde `429` con el encabezado `Retry-After`, que trae los segundos que debes esperar.\n- **Errores al enviar un formulario:** adem\xE1s de los errores de las reglas de protecci\xF3n de esta documentaci\xF3n, `POST /contacts/{contact}/message/form` responde: `404` si el contacto no existe, no es de tu cuenta o, con la clave de un asiento, no est\xE1 asignado a ese asiento; `404` con `form_not_found` si el formulario no existe en tu cuenta; `422` con `device_unavailable` (el n\xFAmero no es de tu cuenta o no est\xE1 conectado), `number_not_eligible` (no es un n\xFAmero de WhatsApp Cloud que pueda enviar formularios), `form_not_published` (el formulario no tiene versi\xF3n publicada: publ\xEDcalo), `form_not_on_number` (no est\xE1 publicado en ese n\xFAmero: s\xFAmalo con `POST /forms/{id}/numbers` y espera a que se publique), `recipient_unreachable` (el contacto no tiene tel\xE9fono ni identificador de WhatsApp al que ese n\xFAmero pueda escribir), `contact_blocked` (bloqueaste al contacto en ese n\xFAmero: desbloqu\xE9alo para escribirle) y `window_closed` (pasaron m\xE1s de 24 horas desde el \xFAltimo mensaje del contacto); `429` con `rate_limited` y `Retry-After` si superas el ritmo; y `422` con `not_sent` (el destinatario que se registr\xF3 no coincide con el contacto verificado, o el mensaje no qued\xF3 como un env\xEDo de la conversaci\xF3n: no se registr\xF3 ni sali\xF3 nada y puedes reintentar; son las \xFAnicas dos situaciones y en el uso normal no ocurren). Si Funnelchat no pudo poner el mensaje en cola, la respuesta no es un error: es `200` con el mensaje en estado `failed` y `error.reason` `not_queued`; no sali\xF3, as\xED que reintentar no lo duplica. Si tu cuenta est\xE1 bloqueada por el l\xEDmite de contactos activos, el env\xEDo responde `429` con `mac_lockout_24h`, como los dem\xE1s env\xEDos con clave, pero a diferencia de ellos no guarda ning\xFAn mensaje ni `client_reference`: no queda nada en el historial del contacto ni se puede buscar por la referencia, as\xED que reintenta cuando termine el bloqueo. Un `500` indica que el mensaje no pudo registrarse. Si la llamada no responde o da un error 5xx, busca tu `client_reference` en el historial del contacto antes de reintentar.'
51492
+ description: 'Public API of Funnelchat Conversations. Authenticate every request with `Authorization: Bearer <id>|fc_live_...`. Each operation shows the permission your key needs, as the Developers panel names it: **Contacts and conversations \u2192 View**, **Contacts and conversations \u2192 Edit** or **Contacts and conversations \u2192 Send**.\n\n## Webhooks\n\nLos webhooks avisan a un sistema externo, por HTTP, cuando algo pasa en tu cuenta, sin que tengas que consultar la API cada tanto. Registras una URL HTTPS, eliges los tipos de evento que quieres recibir y Funnelchat env\xEDa a esa URL un `POST` firmado cada vez que ocurre uno. Las rutas de administraci\xF3n requieren una clave de API y que la use el titular de la cuenta.\n\n### C\xF3mo suscribirse\n\nEstas son las nueve rutas para administrar suscripciones:\n\n| Ruta | Para qu\xE9 sirve | Permiso |\n| --- | --- | --- |\n| `GET /api/v1/webhooks` | Listar las suscripciones | Contactos y conversaciones \u2192 Ver |\n| `POST /api/v1/webhooks` | Crear una suscripci\xF3n | Contactos y conversaciones \u2192 Editar |\n| `GET /api/v1/webhooks/events` | Ver el cat\xE1logo de tipos de evento | Contactos y conversaciones \u2192 Ver |\n| `GET /api/v1/webhooks/{webhook}` | Ver una suscripci\xF3n | Contactos y conversaciones \u2192 Ver |\n| `PUT /api/v1/webhooks/{webhook}` | Cambiar URL, tipos o estado activo | Contactos y conversaciones \u2192 Editar |\n| `DELETE /api/v1/webhooks/{webhook}` | Eliminar una suscripci\xF3n | Contactos y conversaciones \u2192 Editar |\n| `POST /api/v1/webhooks/{webhook}/rotate-secret` | Generar un secreto nuevo | Contactos y conversaciones \u2192 Editar |\n| `POST /api/v1/webhooks/{webhook}/test` | Enviar ahora un evento de prueba | Contactos y conversaciones \u2192 Editar |\n| `GET /api/v1/webhooks/{webhook}/deliveries` | Ver el historial de entregas | Contactos y conversaciones \u2192 Ver |\n\nEjemplo de alta:\n\n```bash\ncurl -X POST "https://TU-HOST/api/v1/webhooks" \\\n -H "Authorization: Bearer <id>|fc_live_..." \\\n -H "Content-Type: application/json" \\\n -d \'{\n "url": "https://example.com/hooks/funnelchat",\n "description": "Mi integraci\xF3n",\n "events": ["message.received"]\n }\'\n```\n\nRespuesta `201`:\n\n```json\n{\n "data": {\n "uuid": "00000000-0000-4000-8000-000000000001",\n "url": "https://example.com/hooks/funnelchat",\n "description": "Mi integraci\xF3n",\n "events": ["message.received"],\n "active": true,\n "inactive_reason": null,\n "created_at": "2026-10-01T12:00:00+00:00",\n "updated_at": "2026-10-01T12:00:00+00:00",\n "secret": "<secreto de firma>"\n }\n}\n```\n\nEl `secret` se muestra **una sola vez**, en esta respuesta (y al rotarlo). Gu\xE1rdalo: no se puede volver a consultar. En `events` puedes usar los tipos `message.received`, `flow.completed`, `broadcast.finished`, `contact.stage_changed`, `message.status_changed`, `conversation.closed`, `conversation.assigned`, `contact.tag_added`, `broadcast.paused`, `contact.tag_removed`, `contact.created`, `template.status_changed`, `flow.failed`, `whatsapp_form.completed` o `*` para todos los dem\xE1s que se puedan suscribir (`message.status_changed` siempre se suscribe de forma expl\xEDcita: `*` no lo incluye). La URL debe ser `https` y apuntar a un destino p\xFAblico.\n\n### El sobre\n\nCada entrega es un `POST` con un cuerpo JSON con esta forma. El contenido de `data` depende del tipo de evento (ver el cat\xE1logo m\xE1s abajo).\n\n| Campo | Descripci\xF3n |\n| --- | --- |\n| `id` | Identificador \xFAnico del evento (`evt_...`). Es el mismo en todos los reintentos. |\n| `type` | Tipo de evento, por ejemplo `message.received`. |\n| `version` | Versi\xF3n del formato del evento (hoy `1`). |\n| `occurred_at` | Momento en que ocurri\xF3, en UTC (ISO 8601). |\n| `data` | Datos del evento. |\n\n### Cabeceras\n\n| Cabecera | Contenido |\n| --- | --- |\n| `Content-Type` | `application/json` |\n| `X-Funnelchat-Signature` | Firma de la entrega, con la forma `t=<timestamp>,v1=<firma>` |\n| `X-Funnelchat-Event` | Tipo de evento (igual que `type` en el cuerpo) |\n| `X-Funnelchat-Delivery` | Identificador de esta entrega (cambia en cada intento) |\n\n### C\xF3mo verificar la firma\n\nCada entrega lleva la cabecera `X-Funnelchat-Signature` con la forma `t=<timestamp>,v1=<firma>`. Para verificarla:\n\n1. Toma el **cuerpo crudo** de la petici\xF3n, tal como lleg\xF3, sin volver a codificar el JSON.\n2. Arma el texto `t + "." + cuerpo` (el timestamp, un punto y el cuerpo).\n3. Calcula su HMAC-SHA256 con el secreto de la suscripci\xF3n y comp\xE1ralo, en tiempo constante, con `v1`.\n4. Rechaza la entrega si `t` se aleja del reloj de tu servidor m\xE1s de 300 segundos.\n\nEjemplo en PHP:\n\n```php\nfunction verificarFirma(string $cuerpoCrudo, string $cabecera, string $secreto, int $tolerancia = 300): bool\n{\n parse_str(str_replace(\',\', \'&\', $cabecera), $partes);\n\n if (! isset($partes[\'t\'], $partes[\'v1\']) || ! ctype_digit($partes[\'t\'])) {\n return false;\n }\n\n if (abs(time() - (int) $partes[\'t\']) > $tolerancia) {\n return false;\n }\n\n $esperada = hash_hmac(\'sha256\', $partes[\'t\'].\'.\'.$cuerpoCrudo, $secreto);\n\n return hash_equals($esperada, $partes[\'v1\']);\n}\n```\n\nEjemplo en JavaScript (Node.js):\n\n```javascript\nconst crypto = require(\'crypto\');\n\nfunction verificarFirma(cuerpoCrudo, cabecera, secreto, tolerancia = 300) {\n const partes = Object.fromEntries(cabecera.split(\',\').map((p) => p.split(\'=\')));\n if (!/^\\d+$/.test(partes.t || \'\') || !partes.v1) return false;\n\n if (Math.abs(Date.now() / 1000 - Number(partes.t)) > tolerancia) return false;\n\n const esperada = crypto\n .createHmac(\'sha256\', secreto)\n .update(`${partes.t}.${cuerpoCrudo}`)\n .digest(\'hex\');\n\n const a = Buffer.from(esperada);\n const b = Buffer.from(partes.v1);\n\n return a.length === b.length && crypto.timingSafeEqual(a, b);\n}\n```\n\nEn JavaScript, lee el cuerpo como texto o `Buffer` antes de convertirlo a objeto (por ejemplo con `express.raw({ type: \'application/json\' })`).\n\n### Reintentos\n\nUna entrega es exitosa si tu servidor responde con un c\xF3digo `2xx` en 10 segundos o menos. Funnelchat no sigue redirecciones.\n\n- Se hacen hasta **4 intentos** por evento y suscripci\xF3n. Entre un intento y el siguiente se espera 60, 300, 1800 segundos.\n- Se reintenta cuando hay un error de red o un timeout, ante un `5xx` y ante un `429`.\n- Un `3xx` o cualquier otro `4xx` es terminal: no se reintenta.\n- Despu\xE9s del \xFAltimo intento fallido la entrega queda como `failed`, sin m\xE1s reintentos.\n\n### Estados de entrega\n\n`GET /api/v1/webhooks/{webhook}/deliveries` devuelve un registro por intento, con estos estados:\n\n| Estado | Significado |\n| --- | --- |\n| `pending` | El intento est\xE1 en curso: todav\xEDa no hay resultado. |\n| `succeeded` | Tu servidor respondi\xF3 `2xx`. |\n| `failed` con `next_attempt_at` | El intento fall\xF3 y hay otro programado para esa fecha. |\n| `failed` sin `next_attempt_at` | El intento fall\xF3 y no habr\xE1 m\xE1s: era terminal o se agotaron los intentos. |\n\n### Probar una suscripci\xF3n\n\n`POST /api/v1/webhooks/{webhook}/test` env\xEDa ahora un evento `webhook.test` firmado a la URL de la suscripci\xF3n, sin esperar a que ocurra nada en tu cuenta, y responde con el resultado del intento. Funciona tambi\xE9n sobre una suscripci\xF3n inactiva, para probarla antes de activarla.\n\nLa respuesta es un \xEDtem del historial de entregas, con la misma forma que cada elemento de `GET /api/v1/webhooks/{webhook}/deliveries`:\n\n```json\n{\n "data": {\n "uuid": "0d3c2f4e-6a1b-4c8d-9e2f-5b7a1c3d4e6f",\n "event_id": "evt_01hexample",\n "event_type": "webhook.test",\n "attempt": 1,\n "status": "succeeded",\n "http_status": 200,\n "duration_ms": 184,\n "error": null,\n "next_attempt_at": null,\n "created_at": "2026-01-15T12:00:00+00:00",\n "updated_at": "2026-01-15T12:00:00+00:00"\n}\n}\n```\n\n`status` es `succeeded` solo si tu servidor respondi\xF3 `2xx`; si no, es `failed` y `error` indica la causa. La prueba aparece en el historial con `event_type: "webhook.test"`, no se reintenta y no consume tu cupo de eventos.\n\n### Suscripciones creadas con una clave de API\n\nUna suscripci\xF3n creada, o reactivada, con una clave de API queda atada a esa clave. Tambi\xE9n la atan a una clave cambiar su URL con esa clave, o rotar su secreto con esa clave (rotarlo desde la sesi\xF3n web no modifica el v\xEDnculo): la suscripci\xF3n queda atada a la \xFAltima credencial que fij\xF3 su destino. Si la clave se revoca (o vence, que cuenta como revocada), la suscripci\xF3n deja de recibir eventos: pasa a `active: false` con `inactive_reason: "key_revoked"`, y no se le env\xEDan m\xE1s eventos.\n\nEse cambio se ve a partir del primer evento que ocurra despu\xE9s de revocar la clave. Hasta entonces, un `GET` puede seguir mostr\xE1ndola activa, pero no recibe eventos.\n\nLa prueba manual (`POST /api/v1/webhooks/{webhook}/test`) sigue funcionando sobre una suscripci\xF3n inactiva.\n\nUna suscripci\xF3n con `inactive_reason: "key_revoked"` no se puede reactivar: `PUT /api/v1/webhooks/{webhook}` con `"active": true` responde 422, sin importar la credencial que se use. Para seguir recibiendo eventos, crea una suscripci\xF3n nueva con `POST /api/v1/webhooks`. Una suscripci\xF3n que desactivaste a mano s\xED se puede reactivar con `PUT` y `"active": true`; queda atada a la credencial que la reactiva. Mientras est\xE9 activa, `inactive_reason` es `null`.\n\nLas suscripciones cuyo destino fij\xF3 por \xFAltima vez la sesi\xF3n web no dependen de ninguna clave: revocar una clave no las afecta.\n\n### Idempotencia\n\nUn mismo evento puede llegarte m\xE1s de una vez (por ejemplo, si tu servidor proces\xF3 la entrega pero respondi\xF3 tarde). Usa el campo `id` del sobre para descartar los que ya procesaste: es el mismo en todos los reintentos del evento.\n\n### Eventos disponibles\n\nSe generan desde el cat\xE1logo vigente. Los tipos marcados como **planificado** todav\xEDa no se emiten: se anuncian para que puedas prepararte, pero no los recibir\xE1s.\n\n`webhook.test` solo se env\xEDa con `POST /api/v1/webhooks/{webhook}/test`.\n\n#### `message.received`\n\nUn contacto le escribi\xF3 a un n\xFAmero de WhatsApp de la cuenta.\n\n```json\n{\n "id": "evt_000000000000000000000001",\n "type": "message.received",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "message": {\n "id": 1001,\n "type": "text",\n "text": "Hola, quiero m\xE1s informaci\xF3n",\n "media": null\n },\n "contact": {\n "id": 2001,\n "name": "Contacto de ejemplo",\n "phone": "+5491100000000"\n },\n "receiver": {\n "phone": "+5491100000001"\n },\n "device_id": 4521\n }\n}\n```\n\nValores posibles de `data.message.type`: `text`, `image`, `video`, `document`, `sticker`, `contact`, `audio`, `note`, `action`, `invalid`, `interactive`, `location`, `voice_call`, `video_call`, `event`, `button`, `template`, `notification`, `option_list`, `template_button_reply`, `poll`, `whatsapp_form`, `call_permission_request`, `call_permission_reply`, `request_contact_info`.\n\n`device_id` es el n\xFAmero de tu cuenta que recibi\xF3 el mensaje: el mismo `device_id` que mandas para responder por ese n\xFAmero. Es `null` si el mensaje no tiene n\xFAmero asociado.\n\n#### `flow.completed`\n\nUn contacto termin\xF3 un flujo de automatizaci\xF3n.\n\n```json\n{\n "id": "evt_000000000000000000000003",\n "type": "flow.completed",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "flow": {\n "id": 3001,\n "name": "Flujo de ejemplo"\n },\n "contact": {\n "id": 2001,\n "name": "Contacto de ejemplo",\n "phone": "+5491100000000"\n }\n }\n}\n```\n\nSe env\xEDa una sola vez por cada ejecuci\xF3n de un flujo, cuando termina con \xE9xito. No se env\xEDa si la ejecuci\xF3n se cort\xF3, se cancel\xF3, qued\xF3 bloqueada o fall\xF3. `contact` es `null` si la ejecuci\xF3n no tiene un contacto asociado. Un flujo muy usado puede generar muchos eventos seguidos.\n\n#### `broadcast.finished`\n\nUna campa\xF1a lleg\xF3 a su estado final (enviada, fallida o con fallos parciales), con sus conteos.\n\n```json\n{\n "id": "evt_000000000000000000000011",\n "type": "broadcast.finished",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "broadcast": {\n "id": 6001,\n "name": "Campa\xF1a de ejemplo"\n },\n "status": "partially_failed",\n "reason": "completed",\n "totals": {\n "processed": 1200,\n "failed": 30,\n "skipped_unreachable": 12\n }\n }\n}\n```\n\n`status` es `success`, `failed` o `partially_failed`. `reason` dice por qu\xE9 termin\xF3: `completed` (se enviaron todas las p\xE1ginas), `cancelled` (se cerr\xF3 una campa\xF1a pausada), `template_rejected` (Meta rechaz\xF3 la plantilla, o su categor\xEDa cambi\xF3, mientras la campa\xF1a esperaba; tambi\xE9n cuando Meta la paus\xF3 durante el env\xEDo y se detuvo la campa\xF1a), `approval_expired` (la plantilla no se aprob\xF3 a tiempo), `mac_lockout` (la cuenta alcanz\xF3 su l\xEDmite de contactos activos), `mac_check_unavailable` (la campa\xF1a la inici\xF3 una API key y el l\xEDmite de contactos activos no se pudo verificar, as\xED que no se envi\xF3 nada), `no_recipients` (la audiencia qued\xF3 vac\xEDa) o `error` (el env\xEDo no pudo despacharse). `totals` son los conteos del env\xEDo: `processed` (destinatarios procesados), `failed` y `skipped_unreachable` (destinatarios que el canal no puede alcanzar); valen 0 si la campa\xF1a termin\xF3 antes de enviar. Se emite una sola vez por campa\xF1a.\n\n#### `contact.stage_changed`\n\nUn contacto cambi\xF3 de etapa en un tablero.\n\n```json\n{\n "id": "evt_000000000000000000000008",\n "type": "contact.stage_changed",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "contact": {\n "id": 2001,\n "name": "Contacto de ejemplo",\n "phone": "+5491100000000"\n },\n "board": {\n "id": 5001,\n "name": "Embudo de ejemplo"\n },\n "kind": "move",\n "from_stage": {\n "id": 6001,\n "name": "Nuevo",\n "position": 0\n },\n "to_stage": {\n "id": 6002,\n "name": "En seguimiento",\n "position": 1\n }\n }\n}\n```\n\nSe env\xEDa un evento por cada contacto que cambia de etapa en un tablero por etapas. `kind` es entry (entra al tablero, `from_stage` es null), move (pasa de una etapa a otra), exit (sale del tablero, `to_stage` es null) o stage_deleted (se reubica porque su etapa se borr\xF3; el nombre de la etapa borrada es null). No se env\xEDa si el contacto se coloca en la etapa en la que ya estaba.\n\n#### `message.status_changed`\n\nCambi\xF3 el estado de entrega de un mensaje enviado.\n\n```json\n{\n "id": "evt_000000000000000000000014",\n "type": "message.status_changed",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "message_id": 1001,\n "contact_id": 2001,\n "device_id": 4521,\n "status": "failed",\n "previous_status": "sent",\n "error": {\n "code": 131047,\n "title": "Re-engagement message",\n "reason": null\n },\n "occurred_at": "2026-10-01T12:00:00Z",\n "client_reference": "pedido-1001"\n }\n}\n```\n\nSe emite cuando cambia el estado de entrega de un mensaje que enviaste a un contacto, ya sea desde el chat, una automatizaci\xF3n o un asistente de IA (incluye los enviados con clave). No se emite para mensajes de difusiones ni para mensajes recibidos. `message_id` es el id que devuelve la API, `contact_id` el del contacto y `device_id` el n\xFAmero de tu cuenta por el que sali\xF3 el mensaje, el mismo `device_id` que usas para enviar (`null` si no tiene). `status` es `sent`, `delivered`, `read` o `failed`. `previous_status` es el estado que el mensaje ten\xEDa justo antes de este cambio y es `null` en tres casos: es el primer aviso del mensaje, es el mismo estado que `status` (por ejemplo, el proveedor confirma el `sent` que ya registr\xF3 el env\xEDo) o no se pudo leer. `error` solo viene cuando `status` es `failed`: `code` es el c\xF3digo num\xE9rico del proveedor y `title` su t\xEDtulo, y cualquiera de los dos puede ser `null` si el proveedor no los informa. `error.reason` es `null` cuando el fallo lo inform\xF3 el proveedor; cuando Funnelchat decidi\xF3 que un mensaje enviado con clave no sal\xEDa (por ejemplo, por saldo insuficiente o ventana de 24 horas cerrada) trae el motivo (`device_unavailable`, `insufficient_balance`, `window_closed`, `recipient_unreachable`, `template_not_found`, `template_unresolved_placeholder`, `not_queued` o `not_sent`), `code` es `null` y `title` es un texto llano en el idioma de la cuenta. `occurred_at` es el momento en que Funnelchat registr\xF3 el estado, en UTC. `client_reference` es la referencia que mandaste al enviar el mensaje (por ejemplo `pedido-1001`) y `null` si el env\xEDo no la llevaba: sirve para relacionar cada aviso con tu env\xEDo tambi\xE9n cuando la llamada de env\xEDo no respondi\xF3. Cada estado se informa una sola vez por mensaje: si llegan varios avisos juntos se informa el m\xE1s avanzado y un aviso atrasado que no cambia el estado no genera evento. Un mensaje que no lleg\xF3 a salir (por ejemplo, por ventana de 24 horas cerrada) no recibe avisos de estado, salvo uno enviado con clave: ese recibe un \xFAnico aviso `failed` con `previous_status` en `null`, su `error.reason` y su `client_reference`. Un mensaje enviado con clave cuyo resultado no se conoce (el proveedor no respondi\xF3 o el env\xEDo se interrumpi\xF3) no recibe aviso. Un mensaje cuyo env\xEDo no empez\xF3 dentro de la hora ya no sale: pasada la hora sin resultado, su estado es `null`, o `failed` con `error.reason` `not_sent` y su aviso si su env\xEDo llega tarde. Reintentar es un env\xEDo nuevo, que s\xF3lo puede duplicar si el env\xEDo hab\xEDa empezado y se cort\xF3. Un mensaje `queued` no es enviado: su resultado llega por este evento o por la consulta del mensaje. En cuentas con mucho tr\xE1fico, los eventos que excedan el l\xEDmite de entrega por minuto de la cuenta se descartan. Para recibirlo debes suscribirte a `message.status_changed` de forma expl\xEDcita: una suscripci\xF3n a `*` no lo incluye.\n\n#### `conversation.closed`\n\nUna conversaci\xF3n pas\xF3 a cerrada.\n\n```json\n{\n "id": "evt_000000000000000000000004",\n "type": "conversation.closed",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "contact": {\n "id": 2001,\n "name": "Contacto de ejemplo",\n "phone": "+5491100000000"\n }\n }\n}\n```\n\nSe env\xEDa cuando una conversaci\xF3n pasa de abierta a cerrada. No se env\xEDa si la conversaci\xF3n ya estaba cerrada ni cuando se reabre.\n\n#### `conversation.assigned`\n\nUna conversaci\xF3n qued\xF3 asignada a una persona del equipo o a un agente de IA.\n\n```json\n{\n "id": "evt_000000000000000000000005",\n "type": "conversation.assigned",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "contact": {\n "id": 2001,\n "name": "Contacto de ejemplo",\n "phone": "+5491100000000"\n },\n "assignee": {\n "type": "human",\n "name": "Agente de ejemplo"\n }\n }\n}\n```\n\nSe env\xEDa cuando una conversaci\xF3n queda asignada a una persona del equipo (`assignee.type` = `human`) o a un agente de IA (`assignee.type` = `ai`), por cualquier camino. `assignee` trae el tipo y el nombre, nunca un identificador. No se env\xEDa al desasignar, ni cuando se vuelve a enviar el mismo asignado.\n\n#### `contact.tag_added`\n\nUn contacto recibi\xF3 una etiqueta.\n\n```json\n{\n "id": "evt_000000000000000000000006",\n "type": "contact.tag_added",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "contact": {\n "id": 2001,\n "name": "Contacto de ejemplo",\n "phone": "+5491100000000"\n },\n "tag": {\n "id": 4001,\n "name": "Etiqueta de ejemplo"\n },\n "source": "manual"\n }\n}\n```\n\nSe env\xEDa un evento por cada contacto y etiqueta agregados, tambi\xE9n en operaciones en bloque; no se env\xEDa si el contacto ya ten\xEDa la etiqueta. `source` indica el origen: manual, flow, bulk, import, sync, walink o ai. En operaciones en bloque grandes el l\xEDmite de volumen de la cuenta puede descartar parte de los eventos.\n\n#### `broadcast.paused`\n\nUna campa\xF1a se paus\xF3 sola, por ejemplo porque baj\xF3 la calidad del n\xFAmero.\n\n```json\n{\n "id": "evt_000000000000000000000007",\n "type": "broadcast.paused",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "broadcast": {\n "id": 6001,\n "name": "Campa\xF1a de ejemplo"\n },\n "reason": "quality_degraded",\n "quality": "YELLOW",\n "quality_baseline": "GREEN",\n "template": null\n }\n}\n```\n\n`reason` es `quality_degraded` (la calidad del n\xFAmero baj\xF3 respecto de la del inicio, o lleg\xF3 a RED), `quality_unreadable` (no se pudo leer la calidad en tres controles seguidos), `template_unavailable` (Meta dej\xF3 la plantilla en `PAUSED`, `DISABLED` o `REJECTED` antes de una tanda), `payment_issue` (el 2 % o m\xE1s de los env\xEDos de la misma campa\xF1a, con un m\xEDnimo de 20 env\xEDos, fueron rechazados por Meta con el c\xF3digo 131042, un problema de pago o de facturaci\xF3n de la cuenta de WhatsApp Business; el 2 % y el m\xEDnimo de 20 env\xEDos son los valores por defecto y se pueden configurar; aplica a cualquier campa\xF1a, tenga o no env\xEDo inteligente; no frena el n\xFAmero; al reanudar la campa\xF1a el conteo, incluidos los env\xEDos que cuentan, vuelve a empezar de cero) o `rejection_rate` (entre los env\xEDos recientes de la propia campa\xF1a, por defecto las \xFAltimas 24 horas, Meta rechaz\xF3 una proporci\xF3n igual o mayor al umbral configurable, por defecto 15 %, con los c\xF3digos que indican una restricci\xF3n de la cuenta o del n\xFAmero; la lista es configurable y por defecto es 131048, 131031 y 368; los c\xF3digos 131049 y 131026 no pausan la campa\xF1a). Con `rejection_rate` llega adem\xE1s `rejection`: `codes` (los que llegaron a su umbral: hoy `failed`), `rates` (el porcentaje de cada uno; las tasas de `131049` y `131026` se informan como dato aunque no pausen), `attempts` (los env\xEDos medidos) y `window_hours`. `quality` y `quality_baseline` son la calidad de Meta le\xEDda en el control y la del inicio del env\xEDo (`GREEN`, `YELLOW`, `RED`), o `null` si no se pudo leer, y siempre `null` en una difusi\xF3n sin env\xEDo inteligente. `template` es `{name, status}` con el nombre de la plantilla y su estado en Meta cuando `reason` es `template_unavailable`, y `null` en las dem\xE1s pausas (por calidad, pago o rechazos). Con `rejection_rate` el n\xFAmero adem\xE1s queda frenado 7 d\xEDas (configurable): mientras dure no se pueden crear, ejecutar ni reanudar campa\xF1as desde ese n\xFAmero, ni mandar plantillas a contactos que no le escribieron en las \xFAltimas 24 horas, y solo soporte lo levanta antes. Las pausas por calidad, por plantilla y por rechazos son solo de las campa\xF1as con env\xEDo inteligente; la pausa por `payment_issue` puede ocurrir en cualquier campa\xF1a. Reanudar la campa\xF1a y que se pause otra vez emite otro evento.\n\n#### `contact.tag_removed`\n\nUn contacto perdi\xF3 una etiqueta.\n\n```json\n{\n "id": "evt_000000000000000000000009",\n "type": "contact.tag_removed",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "contact": {\n "id": 2001,\n "name": "Contacto de ejemplo",\n "phone": "+5491100000000"\n },\n "tag": {\n "id": 4001,\n "name": "Etiqueta de ejemplo"\n },\n "source": "manual"\n }\n}\n```\n\nSe env\xEDa un evento por cada contacto y etiqueta quitados, tambi\xE9n en operaciones en bloque; no se env\xEDa si el contacto no ten\xEDa la etiqueta. No se env\xEDa al borrar la etiqueta, al borrar o fusionar contactos ni al desvincular etiquetas de un tablero. `source` indica el origen: manual, flow, bulk o ai. En operaciones en bloque grandes el l\xEDmite de volumen de la cuenta puede descartar parte de los eventos.\n\n#### `contact.created`\n\nSe cre\xF3 un contacto nuevo en la cuenta.\n\n```json\n{\n "id": "evt_000000000000000000000010",\n "type": "contact.created",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "contact": {\n "id": 2001,\n "name": "Contacto de ejemplo",\n "phone": "+5491100000000",\n "email": "contacto@example.com"\n }\n }\n}\n```\n\nSe env\xEDa un evento por cada contacto nuevo, sea cual sea el canal: un mensaje entrante, la creaci\xF3n manual, una importaci\xF3n o la API. No se env\xEDa cuando el contacto ya exist\xEDa y s\xF3lo se actualiza, ni para los contactos que la plataforma crea para sus propias notificaciones. En importaciones grandes el l\xEDmite de volumen de la cuenta puede descartar parte de los eventos.\n\n#### `template.status_changed`\n\nUna plantilla cambi\xF3 de estado de aprobaci\xF3n en Meta.\n\n```json\n{\n "id": "evt_000000000000000000000012",\n "type": "template.status_changed",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "template": {\n "id": 7001,\n "uid": "1234567890",\n "name": "plantilla_ejemplo",\n "category": "MARKETING"\n },\n "previous_status": "PENDING",\n "status": "APPROVED",\n "reason": null\n }\n}\n```\n\n`template` identifica la plantilla igual que `GET /templates` (`id`, `uid`, `name`, `category`). `previous_status` y `status` son los estados tal como los devuelve la API (por ejemplo `PENDING`, `APPROVED`, `REJECTED`, `PAUSED`); `previous_status` es `null` si la plantilla no ten\xEDa estado. `reason` trae la observaci\xF3n de rechazo (traducida como en la API y limitada a 500 caracteres) cuando el cambio es un rechazo informado al crear la plantilla, y es `null` en el resto. No se emite si Meta reenv\xEDa el estado que la plantilla ya ten\xEDa. Si una misma plantilla de Meta figura en varias cuentas, cada cuenta recibe su propio evento.\n\n#### `flow.failed`\n\nLa ejecuci\xF3n de un flujo para un contacto termin\xF3 por un error.\n\n```json\n{\n "id": "evt_000000000000000000000013",\n "type": "flow.failed",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "flow": {\n "id": 3001,\n "name": "Flujo de ejemplo"\n },\n "contact": {\n "id": 2001,\n "name": "Contacto de ejemplo",\n "phone": "+5491100000000"\n },\n "reason": "execution_error"\n }\n}\n```\n\nSe env\xEDa una sola vez por cada ejecuci\xF3n de un flujo que termina por un error, no porque lleg\xF3 al final. `reason` indica el motivo y toma uno de estos valores: `node_missing` (el paso a ejecutar ya no existe), `unknown_node_type` (el paso es de un tipo no reconocido), `missing_subflow` (el flujo que deb\xEDa ejecutarse dentro de otro ya no existe), `malformed_node` (el paso tiene una configuraci\xF3n da\xF1ada), `loop_detected` (la ejecuci\xF3n dio vueltas sin esperar nada) y `execution_error` (un error inesperado detuvo la ejecuci\xF3n). No se env\xEDa si la ejecuci\xF3n se cancel\xF3, qued\xF3 bloqueada o se cort\xF3 porque se edit\xF3 el flujo o se unieron contactos. `contact` es `null` si la ejecuci\xF3n no tiene un contacto asociado.\n\n#### `whatsapp_form.completed`\n\nUn contacto complet\xF3 un formulario de WhatsApp.\n\n```json\n{\n "id": "evt_000000000000000000000015",\n "type": "whatsapp_form.completed",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "form": {\n "id": 4001,\n "name": "Captar un lead",\n "version_id": 4002\n },\n "message_id": 1001,\n "contact_id": 2001,\n "device_id": 5001,\n "answers": [\n {\n "key": "nombre",\n "label": "Nombre",\n "value": "Contacto de ejemplo"\n },\n {\n "key": "intereses",\n "label": "Intereses",\n "value": [\n "op_a",\n "op_b"\n ]\n },\n {\n "key": "comentarios",\n "label": "Comentarios",\n "value": null\n }\n ],\n "occurred_at": "2026-10-01T12:00:00Z"\n }\n}\n```\n\nSe env\xEDa cuando un contacto completa un formulario, aunque la respuesta llegue tarde. Puede llegar m\xE1s de una vez con el mismo `id`: deduplica por `id`. Si el contacto vuelve a enviar el mismo formulario, no se genera otro aviso: queda la primera respuesta. Cada respuesta de texto llega con a lo sumo 600 caracteres. `form.id` es el formulario (el mismo `family_id` de la lectura de formularios) y `form.version_id` la versi\xF3n que recibi\xF3 el contacto. `answers` es una lista con una entrada por pregunta, en el orden del formulario: `key` es el nombre de la pregunta, `label` su etiqueta y `value` la respuesta (texto, n\xFAmero, `true` o `false`, una lista de textos en opci\xF3n m\xFAltiple, o `null` si no la contest\xF3). En las preguntas de lista y de opci\xF3n m\xFAltiple, `value` es el id de la opci\xF3n. `message_id` es el id del mensaje del formulario y es `null` si el env\xEDo no tuvo un mensaje p\xFAblico; `device_id` es el n\xFAmero de WhatsApp por el que sali\xF3, o `null` si ya no es de la cuenta. `contact_id` es `null` si el contacto se elimin\xF3 antes de que saliera el aviso (las respuestas llegan igual); si se fusion\xF3 con otro, es el id del contacto que qued\xF3. Mientras se fusionan dos contactos, el id puede ser el del contacto que se est\xE1 fusionando.\n\n#### `webhook.test`\n\nEvento de prueba enviado desde la ruta de prueba de una suscripci\xF3n.\n\n```json\n{\n "id": "evt_000000000000000000000002",\n "type": "webhook.test",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "subscription": "00000000-0000-4000-8000-000000000001",\n "sent_at": "2026-10-01T12:00:00Z"\n }\n}\n```\n\n### Otros webhooks de la plataforma\n\nLos webhooks de alertas y el nodo \xABPetici\xF3n HTTP\xBB de las automatizaciones son otros productos, con otro formato y otra configuraci\xF3n. No se administran con las rutas de esta secci\xF3n ni llevan este sobre ni esta firma.\n\n## Listados\n\nCon una clave de API, los listados siempre responden una p\xE1gina con la forma `{ "data": [...], "links": {...}, "meta": {...} }`: `data` trae los \xEDtems de la p\xE1gina, `links` las URL de `first`, `last`, `prev` y `next` (`null` cuando no hay p\xE1gina), y `meta` el detalle (`current_page`, `from`, `last_page`, `links`, `path`, `per_page`, `to`, `total`).\n\n- **Tama\xF1o de p\xE1gina:** el par\xE1metro `perPage` es un entero entre 1 y 100. Si no lo mandas, la p\xE1gina trae 25 \xEDtems. Un valor mayor que 100, menor que 1 o que no sea un entero responde 422 con el detalle en `errors.perPage`. Algunos listados aceptan adem\xE1s `per_page` como alias con los mismos l\xEDmites; si mandas `perPage` y `per_page` con valores distintos, la respuesta es 422 con el detalle en `errors.perPage`.\n- **Orden:** cada listado documenta los par\xE1metros de orden que admite y los valores permitidos de cada uno (por ejemplo `orderBy` y `orderByMethod`). Un par\xE1metro de orden que el listado no admite, o un valor fuera de la lista, responde 422 con el detalle en `errors.<par\xE1metro>`.\n- **P\xE1ginas siguientes:** las URL de `links` y de `meta.links` conservan `perPage`, el orden y los filtros que admite el listado; los dem\xE1s par\xE1metros no se copian y s\xF3lo cambia `page`. En los listados que se piden por `POST`, los filtros viajan en el cuerpo y las URL llevan s\xF3lo `perPage` y `page`.\n- **Primero el permiso:** si tu clave no tiene el permiso del listado, la respuesta es la de siempre (401 o 403), aunque mandes un `perPage` inv\xE1lido.\n\n## Env\xEDo de mensajes con clave\n\nCuando env\xEDas con una clave de API, antes de enviar se aplican reglas de protecci\xF3n de tu n\xFAmero y de tus contactos. Si una regla frena el env\xEDo, la respuesta es un JSON `{ "error": "<c\xF3digo>", "message": "<explicaci\xF3n>" }` y no se env\xEDa nada ni se registra ning\xFAn mensaje. Cada operaci\xF3n de env\xEDo documenta los c\xF3digos que puede responder.\n- **A qui\xE9n env\xEDas con `POST /message/send-message` y `POST /devices/{device}/templates/{template_id}/send-template`:** Indica a qui\xE9n env\xEDas con `phone`: el tel\xE9fono con c\xF3digo de pa\xEDs, por ejemplo `5491122334455`. No hace falta crear el contacto antes: si ese tel\xE9fono todav\xEDa no es un contacto de tu cuenta, se crea en el workspace de tu clave, igual que con `POST /contacts/update-or-create` y con el mismo permiso, que **Enviar** ya incluye. La respuesta trae su id en `message.contact_id`, el mismo que llevan los avisos `message.status_changed`. Si el tel\xE9fono no es v\xE1lido, o ya es un contacto de otro workspace de tu cuenta, la respuesta es la misma que dar\xEDa `POST /contacts/update-or-create`: 422, o 404 sin datos de ese contacto. Con un n\xFAmero oficial de WhatsApp (API Cloud) tambi\xE9n puedes indicar `whatsapp_id`, el BSUID que env\xEDa Meta (por ejemplo `US.13491208655302741918`), en lugar de `phone`; usado as\xED con un n\xFAmero conectado por QR, responde 422. En un n\xFAmero oficial, el primer mensaje a un contacto nuevo tiene que ser una plantilla: un mensaje libre solo sale dentro de las 24 horas desde que el contacto te escribi\xF3. Si ya tienes el `contact_id`, puedes seguir envi\xE1ndolo: si junto con \xE9l mandas `phone` o `whatsapp_id`, tienen que ser de ese mismo contacto (si no, 422 `recipient_mismatch`). En `send-template`, `phone` o `whatsapp_id` va siempre, con o sin `contact_id`.\n- **Ritmo:** el l\xEDmite depende s\xF3lo del tipo de n\xFAmero por el que env\xEDas; en los env\xEDos no hay un l\xEDmite por minuto por clave ni por cuenta. Al superarlo, la respuesta es 429 con `error` = `rate_limited` y el encabezado `Retry-After`, que trae los segundos que debes esperar antes de reintentar. Las reacciones s\xF3lo tienen esta regla.\n - **N\xFAmero de WhatsApp Cloud (API oficial):** hasta 60 mensajes por segundo y hasta 10 mensajes por minuto al mismo contacto.\n - **N\xFAmero en coexistencia** (el mismo tel\xE9fono conectado a WhatsApp Cloud y a la aplicaci\xF3n WhatsApp Business del celular): hasta 15 mensajes por segundo en total entre las dos conexiones, env\xEDes por la que env\xEDes. El l\xEDmite de 10 mensajes por minuto al mismo contacto aplica s\xF3lo a lo que env\xEDas por WhatsApp Cloud.\n - **N\xFAmero conectado por c\xF3digo QR** (sin coexistencia): esta regla no limita sus env\xEDos.\n- **Ventana de 24 horas:** por un n\xFAmero de WhatsApp Cloud s\xF3lo puedes enviar mensajes que no son plantilla a un contacto que te escribi\xF3 en las \xFAltimas 24 horas. Fuera de esa ventana la respuesta es 422 con `error` = `window_closed`; para retomar la conversaci\xF3n env\xEDa una plantilla aprobada, que no tiene esta restricci\xF3n.\n- **Antibloqueo:** en las \xFAltimas 24 horas, un mismo contenido se puede enviar como m\xE1ximo 2 veces al mismo contacto (el siguiente responde 422 con `error` = `antiblock_duplicate_content`), y como m\xE1ximo 5 mensajes seguidos sin que el contacto responda (el siguiente responde 422 con `error` = `antiblock_unanswered_limit`). Cuando el contacto responde, los contadores se reinician. Aplica a mensajes y a plantillas, no a reacciones.\n- **Destinatario:** `recipient_mismatch` (422) indica que el tel\xE9fono o el identificador de WhatsApp que mandaste en el cuerpo no corresponde al contacto de la ruta (s\xF3lo las operaciones que reciben ese dato en el cuerpo). `recipient_unsupported` (422) indica que el contacto es un grupo, una lista de difusi\xF3n o un canal: por la API s\xF3lo se escribe a personas individuales.\n- **Estados de entrega:** el evento de webhook `message.status_changed` (enviado, entregado, le\xEDdo, fallido) no se incluye al suscribirte a `*`: tienes que pedirlo de forma expl\xEDcita en `events` al crear o editar la suscripci\xF3n.\n- **Resultado de tus env\xEDos:** cuando env\xEDas con una clave, la llamada no espera al proveedor: la respuesta trae el mensaje ya registrado y en cola, el mismo objeto que devuelve `GET /contacts/{contact}/messages/{message}`, y el env\xEDo sale enseguida despu\xE9s.\n - **En cola no es enviado:** `status` `queued` solo dice que registramos el mensaje y que est\xE1 por salir. El resultado llega por el evento `message.status_changed` o consultando `GET /contacts/{contact}/messages/{message}`. Un mensaje `queued` no se reintenta: reenviarlo lo duplicar\xEDa.\n - **Aceptado no es entregado:** `status` `sent` solo dice que el proveedor acept\xF3 el mensaje. El estado final (entregado, le\xEDdo o fallido) llega por el evento `message.status_changed` o consultando `GET /contacts/{contact}/messages/{message}`.\n - **`failed`:** el proveedor rechaz\xF3 el mensaje dando un motivo, o Funnelchat lo fren\xF3 antes de enviarlo. No sali\xF3: reintentar es un intento nuevo y no lo duplica. Cuando lo decidi\xF3 Funnelchat, `error.reason` trae el motivo, `error.code` es `null` y `error.title` es un texto llano en tu idioma; si lo rechaz\xF3 el proveedor, `error.reason` es `null`. Qu\xE9 hacer seg\xFAn `error.reason`: `device_unavailable`, reconecta el n\xFAmero en Funnelchat y env\xEDa de nuevo; `insufficient_balance`, recarga tu billetera de WhatsApp; `window_closed`, env\xEDa una plantilla aprobada; `recipient_unreachable`, revisa el tel\xE9fono del contacto; `template_not_found` o `template_unresolved_placeholder`, corrige la plantilla o sus variables; `not_queued` o `not_sent`, vuelve a intentarlo. Un mensaje enviado con clave que termina `failed` tambi\xE9n llega por `message.status_changed`, con `previous_status` en `null` y su `client_reference`; un `status` `null` no recibe aviso.\n - **`status` `null`:** el proveedor no confirm\xF3 ni rechaz\xF3 el env\xEDo (no respondi\xF3, respondi\xF3 con una falla sin motivo o el env\xEDo se interrumpi\xF3). Puede haber llegado al contacto, no recibir\xE1 avisos de estado y reintentarlo puede duplicarlo.\n - **M\xE1s de una hora en `queued`:** un mensaje cuyo env\xEDo no empez\xF3 dentro de la hora ya no sale. Pasada la hora sin resultado, su `status` es `null`, o `failed` con `error.reason` `not_sent` y su aviso `message.status_changed` si su env\xEDo llega tarde. Reintentar es un env\xEDo nuevo, que s\xF3lo puede duplicar si el env\xEDo hab\xEDa empezado y se cort\xF3.\n - **Error 5xx o llamada sin respuesta:** busca tu `client_reference` en el historial del contacto antes de reintentar, y hazlo al menos un minuto despu\xE9s de la llamada. Si aparece, el mensaje qued\xF3 registrado: no lo reenv\xEDes, su resultado llega por `message.status_changed` o consultando `GET /contacts/{contact}/messages/{message}`; la excepci\xF3n son las tarjetas y los destinos de un reenv\xEDo tras un 5xx: si todos los que aparecen vienen `failed` con `not_queued`, se reintenta la llamada completa; si alguno aparece `queued`, qued\xF3 registrado: no lo reenv\xEDes. Si no aparece, no sali\xF3 nada y puedes reintentar. Repetir una referencia no evita duplicados: dos llamadas con la misma `client_reference` registran dos mensajes.\n - **Error 404 en un env\xEDo:** el contacto no existe. En un reenv\xEDo, tambi\xE9n cuando el mensaje de origen no existe o no es de ese contacto. Al enviar por `phone` o `whatsapp_id`, cuando ese contacto es de otro workspace de tu cuenta.\n - **`POST /contacts/{contact}/message`:** la respuesta trae el mensaje en `message`: `queued` si es un mensaje, o el mensaje registrado de una nota.\n - **`POST /contacts/{contact}/message/contact-card`:** la respuesta trae `messages`: un mensaje por tarjeta, en el orden de `contact_card_ids`, cada uno con su `id` y en cola (`queued`). Todas salen despu\xE9s, desde la cola: el resultado de cada una llega por `message.status_changed` o consultando `GET /contacts/{contact}/messages/{message}`, con tu `client_reference`. Si una tarjeta no pudo encolarse, su entrada viene `failed` con `error.reason` `not_queued`: no sali\xF3 y volver a enviar esa tarjeta es un intento nuevo. Si alguna no pudo registrarse, la llamada responde un error 5xx y ninguna tarjeta sali\xF3: las que ya aparezcan en el historial del contacto vienen `failed` con `error.reason` `not_queued` y su propio aviso `message.status_changed` `failed`. Si todas las que aparecen vienen `failed` con `not_queued`, reintenta la llamada completa; si alguna aparece `queued`, qued\xF3 registrada: no la reenv\xEDes.\n - **Reenv\xEDo:** la respuesta trae un elemento por destino, en el orden de `targets`, y cada destino que qued\xF3 en cola trae su `message` con su `id` y en cola (`queued`). Todos salen despu\xE9s, desde la cola: el resultado de cada uno llega por `message.status_changed` o consultando `GET /contacts/{contact}/messages/{message}`, con tu `client_reference`. Un destino que no pudo encolarse viene `blocked` con `reason` `not_sent` y su `message` `failed` con `error.reason` `not_queued`: no sali\xF3 y volver a reenviarlo a ese destino es un intento nuevo. Un destino `blocked` por MAC (`mac_limit_reached` o `mac_lockout_24h`) o con `reason` `target_not_found` (no existe o no es de tu cuenta), `contact_blocked` (bloqueaste al contacto en ese n\xFAmero) o `device_unavailable` trae `message` `null`: no sali\xF3 nada. Si alg\xFAn destino no pudo registrarse, la llamada responde un error 5xx y ninguno sali\xF3: los que ya aparezcan en el historial del contacto vienen `failed` con `error.reason` `not_queued` y su propio aviso `message.status_changed` `failed`. Si todos los que aparecen vienen `failed` con `not_queued`, reintenta la llamada completa; si alguno aparece `queued`, qued\xF3 registrado: no lo reenv\xEDes.\n - **`POST /contacts/{contact}/message/form`:** la respuesta trae solo `message`: el mensaje del formulario en cola (`queued`). Si no pudo ponerse en cola, la respuesta sigue siendo 200 con el mensaje en estado `failed` y `error.reason` `not_queued`: no sali\xF3, as\xED que reintentar no lo duplica. Su resultado llega por `message.status_changed` o consultando `GET /contacts/{contact}/messages/{message}`, y la respuesta del contacto por `whatsapp_form.completed`. Si tu cuenta est\xE1 en bloqueo por el l\xEDmite de contactos activos responde 429 `mac_lockout_24h` y no guarda ning\xFAn mensaje ni `client_reference`. Sus errores propios est\xE1n en la gu\xEDa \xABFormularios de WhatsApp\xBB.\n - **`POST /message/send-message` y `POST /devices/{device}/templates/{template_id}/send-template`:** la respuesta trae solo `message`: el mensaje en cola (`queued`), o el mensaje registrado de una nota en `send-message`. Si una variable de la plantilla se completa con un campo personalizado que no existe en tu cuenta, `send-template` responde 422 con `error` `template_unresolved_placeholder`, `placeholders` (hasta 20) y `count` (el total), y no registra ni env\xEDa nada; si la plantilla no existe en tu cuenta, responde 404 `template_not_found`.\n- **Correlacionar tus env\xEDos:** manda `client_reference` en el cuerpo (texto opcional de 1 a 64 caracteres visibles, sin espacios) para identificar cada env\xEDo con tu propia referencia. La misma referencia vuelve en el historial de mensajes del contacto y en el evento `message.status_changed`, de modo que puedes relacionar cada aviso de estado con tu env\xEDo tambi\xE9n cuando la llamada de env\xEDo no respondi\xF3. La referencia no es \xFAnica (dos env\xEDos pueden usar el mismo valor) y no est\xE1 autenticada: la ven todas las claves y todas las suscripciones de la cuenta. Por eso relaci\xF3nala siempre junto con `message_id` y `contact_id`, y tr\xE1tala como texto no confiable cuando la muestres. No pongas datos personales en la referencia. Una llamada que frena una regla de protecci\xF3n de esta secci\xF3n (ritmo, ventana de 24 horas, antibloqueo o destinatario) no registra ning\xFAn mensaje ni referencia. Un rechazo por MAC (`mac_limit_reached` o `mac_lockout_24h`) es distinto: guarda un mensaje con estado de error que lleva la referencia; el env\xEDo de formularios (`POST /contacts/{contact}/message/form`) es la excepci\xF3n: con `mac_lockout_24h` responde 429 y no guarda ning\xFAn mensaje ni referencia. Un mensaje enviado con clave que Funnelchat frena antes de que salga (por ejemplo, por saldo insuficiente) aparece en el historial con estado de error y su referencia, y recibe un aviso `failed` con esa referencia; uno enviado desde el panel no recibe avisos de estado. Las reacciones no aceptan `client_reference`.\n\n## Le\xEDdo y escribiendo\n\nPuedes marcar como le\xEDdo un mensaje que te mand\xF3 un contacto y mostrarle \xABescribiendo\u2026\xBB mientras preparas la respuesta. Ninguna de las dos operaciones env\xEDa un mensaje: no cuentan para el ritmo de env\xEDo ni para las dem\xE1s reglas de \xABEnv\xEDo de mensajes con clave\xBB, y requieren el permiso **Contactos y conversaciones \u2192 Enviar** porque el contacto ve su efecto.\n\n- **Tipos de n\xFAmero:** \xABmarcar como le\xEDdo\xBB funciona en n\xFAmeros de WhatsApp Cloud (API oficial, tambi\xE9n el lado oficial de un n\xFAmero en coexistencia) y en n\xFAmeros conectados por c\xF3digo QR que lo permiten; \xABescribiendo\u2026\xBB funciona solo en n\xFAmeros de WhatsApp Cloud. Si el n\xFAmero no lo permite, la respuesta es 422 con `error` = `provider_unsupported`.\n- **Marcar como le\xEDdo** (`POST /contacts/{contact}/messages/{message}/read`): el contacto ve el doble check azul. WhatsApp marca tambi\xE9n los mensajes anteriores de esa conversaci\xF3n y solo acepta mensajes recibidos en los \xFAltimos 30 d\xEDas. En Funnelchat la conversaci\xF3n queda le\xEDda, igual que cuando un agente la abre en el panel; si llegaron mensajes despu\xE9s del que marcaste, siguen contando como no le\xEDdos.\n- **\xABEscribiendo\u2026\xBB** (`POST /contacts/{contact}/typing`): se muestra sobre el \xFAltimo mensaje que el contacto te mand\xF3 en las \xFAltimas 24 horas, por el n\xFAmero que indiques en `device_id` o, si no lo mandas, por el n\xFAmero de su \xFAltimo mensaje. Dura hasta que respondes o 25 segundos, lo que pase primero. WhatsApp tambi\xE9n marca ese mensaje (y los anteriores) como le\xEDdo, as\xED que el contacto ve el doble check azul; en Funnelchat la conversaci\xF3n no cambia. P\xEDdelo solo cuando vayas a responder.\n- **L\xEDmites:** entre las dos operaciones, hasta 20 llamadas por segundo por n\xFAmero y 50 por cuenta. El l\xEDmite por cuenta incluye las llamadas rechazadas con 429 o con alguno de los c\xF3digos 422 de esta secci\xF3n; no cuentan los 404 ni los errores de validaci\xF3n del cuerpo (422 con errors). Al superar cualquiera de los dos, la respuesta es 429 con `error` = `rate_limited` y el encabezado `Retry-After`.\n- **Si WhatsApp no confirma:** la respuesta es 502 con `error` = `provider_error` y no se cambia nada; puedes reintentar.\n\n## Enviar un archivo por URL\nNo subes el archivo a Funnelchat: en `file` mandas la direcci\xF3n (URL) del archivo y WhatsApp o el proveedor lo descarga desde ah\xED. Funnelchat no abre la URL al recibir tu llamada, as\xED que un archivo inaccesible, vencido o fuera de los l\xEDmites se conoce al enviarlo: el mensaje termina `failed` con el error del proveedor.\n- **La URL:** `file` admite hasta 4096 caracteres; m\xE1s de eso se rechaza con un 422 en `file`. Si `file` empieza con `http`, en may\xFAsculas o min\xFAsculas, tiene que ser una URL que empiece con `http://` o `https://`; si no (por ejemplo `httpfoto.jpg`), la respuesta es 422 con el error en `file`. Te recomendamos `https://`; `http://` tambi\xE9n se acepta, pero viaja sin cifrar. Debe poder descargarse sin credenciales; puede ser una URL firmada. Tiene que seguir v\xE1lida hasta que el mensaje sale: un env\xEDo con clave puede esperar en cola hasta 54 minutos, as\xED que usa URLs que duren al menos una hora. No pongas secretos en la ruta ni en el dominio de la URL: en los registros de actividad (logs) del env\xEDo que escribe Funnelchat, de la URL quedan s\xF3lo el esquema, el dominio y la ruta (sin el usuario y la contrase\xF1a, la parte de consulta despu\xE9s de `?` ni el fragmento despu\xE9s de `#`) y del resultado s\xF3lo identificadores, el tipo del mensaje y, del error del proveedor, sus c\xF3digos y, s\xF3lo si tu n\xFAmero es de WhatsApp Cloud, su categor\xEDa, su t\xEDtulo corto y su identificador de traza, nunca el texto de la respuesta del proveedor; la ruta y el dominio s\xED quedan. La URL completa viaja a WhatsApp o al proveedor de tu n\xFAmero, que la descarga y puede guardarla en sus propios registros. Adem\xE1s, la URL completa, con su consulta, se guarda en el historial del contacto, la ven los usuarios de tu cuenta y se copia al reenviar el mensaje: usa URLs firmadas de corta duraci\xF3n (al menos el tiempo de la cola, cerca de una hora) y limitadas a un solo archivo.\n- **El tipo:** decide la extensi\xF3n de la ruta de la URL si es una de la lista; si no, el final de la URL completa si es una de la lista. Si la ruta no termina en una extensi\xF3n de la lista y s\xF3lo lo hace el final de la URL completa (por ejemplo `https://cdn.example.com/video.mov?poster=frame.jpg`), el mensaje queda registrado con ese tipo, pero WhatsApp o el proveedor pueden enviarlo seg\xFAn la extensi\xF3n de la ruta; si necesitas un tipo fijo, env\xEDa `file_type`:\n - `.jpg`, `.jpeg`, `.png`: `image`\n - `.webp`: `sticker`\n - `.ogg`, `.oga`, `.mp3`, `.m4a`, `.aac`, `.opus`, `.wav`: `audio`\n - `.gif`, `.mp4`: `video`\n - `.doc`, `.docx`, `.pdf`: `document`\n- **Cu\xE1ndo env\xEDas `file_type`:** si la URL no termina en una extensi\xF3n reconocida (por ejemplo una URL firmada sin extensi\xF3n), la respuesta es 422 con el error en `file_type`: agrega `file_type` con uno de estos valores: `image`, `video`, `audio`, `document`, `sticker`. Cuando lo env\xEDas, manda sobre la extensi\xF3n: sirve tambi\xE9n para un archivo cuya extensi\xF3n no es de los tipos de la lista, como `.xlsx`, que se env\xEDa con `file_type` = `document` y `file_name` (por ejemplo `reporte.xlsx`), porque un documento cuya URL no termina en una extensi\xF3n de la lista necesita su nombre. Si la URL termina en una extensi\xF3n que no es la del archivo (como `/descarga.php?id=7`) y no env\xEDas `file_type`, la respuesta es 415 con la extensi\xF3n recibida (`php`); con `file_type` se env\xEDa y, si es un documento, tambi\xE9n hace falta `file_name`.\n- **Extensi\xF3n no soportada:** si la ruta de la URL termina en un formato real que no enviamos solos (por ejemplo `.xlsx` o `.zip`), la URL completa no termina en una extensi\xF3n de la lista y no mandas `file_type`, la respuesta es 415 con la extensi\xF3n recibida.\n- **El nombre del archivo:** `file_name` (opcional, hasta 255 caracteres, sin `/`, `\\`, `?`, `#` ni caracteres de control) es el nombre con el que el contacto ve un documento; ll\xE9valo siempre con su extensi\xF3n, por ejemplo `factura.pdf`. Es obligatorio cuando env\xEDas un documento por URL y la URL no termina en una extensi\xF3n de la lista: sin \xE9l la respuesta es 422 con el error en `file_name`.\n- **Formatos y tama\xF1os:** los formatos aceptados por WhatsApp y el tama\xF1o m\xE1ximo de cada tipo son:\n - `image`: image/jpeg, image/png, image/webp; hasta 5 MB.\n - `video`: video/mp4, video/3gpp, image/gif; hasta 16 MB.\n - `audio`: audio/aac, audio/mp4, audio/mpeg, audio/amr, audio/ogg, audio/opus; hasta 16 MB.\n - `document`: text/plain, application/pdf, application/vnd.ms-powerpoint, application/msword, application/vnd.ms-excel, application/vnd.openxmlformats-officedocument.wordprocessingml.document, application/vnd.openxmlformats-officedocument.presentationml.presentation, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet; hasta 100 MB.\n - `sticker`: image/webp; hasta 100 KB si es est\xE1tico y 500 KB si es animado.\n- **Historial y reenv\xEDo:** el historial del contacto guarda la URL tal cual la enviaste, y el nombre `file_name` no se guarda. Al reenviar un mensaje cuyo archivo es una URL, se reenv\xEDa la misma URL; con una extensi\xF3n de la lista, como esa extensi\xF3n indica; si no, un mensaje que enviaste se reenv\xEDa con su tipo s\xF3lo cuando su extensi\xF3n es un formato que WhatsApp acepta para ese tipo (los de \xABFormatos y tama\xF1os\xBB, por ejemplo `.3gp` para `video` o `.amr` para `audio`). Una URL sin extensi\xF3n, un formato no aceptado, un documento cuya URL no termina en una extensi\xF3n de la lista (por ejemplo `/descarga.php?id=7`) o un mensaje recibido sin una extensi\xF3n de la lista no se puede reenviar: la respuesta es 422 con `error` = `not_forwardable` y `reason` = `unsupported_file_extension`.\n\n## Formularios de WhatsApp\n\nUn formulario de WhatsApp es un conjunto de pantallas con preguntas que tu contacto abre y completa dentro del chat. Con la API puedes armarlo, publicarlo, enviarlo a un contacto y recibir sus respuestas. S\xF3lo funciona con n\xFAmeros de WhatsApp Cloud (API oficial) de tu cuenta, y s\xF3lo se puede enviar dentro de las 24 horas siguientes al \xFAltimo mensaje del contacto a ese n\xFAmero. Los n\xFAmeros conectados por c\xF3digo QR no sirven para formularios.\n- **Identificadores:** un formulario tiene un identificador estable y una versi\xF3n por cada vez que lo cambias. No los confundas:\n\n | Qu\xE9 es | D\xF3nde aparece |\n |---|---|\n | **Formulario** (estable, el mismo en todas sus versiones) | `family_id` en las respuestas de formularios, `{familyId}` en las rutas `/forms/families/{familyId}/\u2026`, `form_id` al enviar y `data.form.id` en `whatsapp_form.completed` |\n | **Versi\xF3n** (cambia con cada versi\xF3n nueva) | `id` en las respuestas de formularios, `{id}` en las rutas `/forms/{id}/\u2026` de edici\xF3n, publicaci\xF3n y n\xFAmeros, y `data.form.version_id` en `whatsapp_form.completed` |\n\n Al enviar pides el formulario, no la versi\xF3n: sale la versi\xF3n publicada vigente. Si publicas una versi\xF3n nueva, lo que se env\xEDa cambia sin que cambies tu integraci\xF3n.\n- **Recorrido, paso a paso:**\n 1. **Crea el formulario:** `POST /forms` con `template_key` `blank` (un formulario para armar desde cero; `GET /forms/templates` lista las dem\xE1s plantillas), `device_id` (el n\xFAmero de WhatsApp Cloud de tu cuenta) y, si quieres, `device_ids` con otros n\xFAmeros. Responde `201` con la versi\xF3n en borrador (`id`), su `family_id`, `lint` y `numbers`. Un formulario no se crea mandando su contenido: el contenido va en el paso siguiente. Ojo con `blank`: no viene vac\xEDo, trae una pantalla (`SCREEN_BLNK`) sin elementos cuyo `next` es `complete`, es decir, que termina el formulario. Si en el `PUT` conservas esa pantalla y agregas otra, la segunda queda inalcanzable y `lint` marca `screen_unreachable` (error que impide publicar) hasta que el `next` de la primera apunte a la segunda (o `auto`, que sigue el orden). Lo m\xE1s simple es reemplazar `screens` entera con tus propias pantallas.\n 2. **Arma la definici\xF3n:** `PUT /forms/{id}` con `definition` (`screens`, cada una con `id`, `title`, `cta`, `next` e `items`) y, si quieres, `name` y `expected_updated_at`. El servidor guarda la definici\xF3n tal como llega y la respuesta trae `lint` junto a `data`: una lista de `{code, severity, screenId, itemId, message}` con lo que falta corregir. Lo incompleto se guarda igual; los errores (`severity` `error`) impiden publicar y los avisos (`aviso`) no. Una definici\xF3n con la forma rota responde `422`. Para leerla de nuevo, `GET /forms/{id}` trae la versi\xF3n con su definici\xF3n y `GET /forms/families/{familyId}/definition` la del formulario: la de la versi\xF3n publicada vigente o, si todav\xEDa no hay ninguna, la de la m\xE1s nueva. Con `expected_updated_at` evitas pisar un cambio m\xE1s nuevo: si no es el \xFAltimo guardado responde `409` `STALE`.\n 3. **Valida en Meta (opcional):** `POST /forms/{id}/sync` corre el linter, sube el formulario a Meta y trae sus observaciones en `lint` con `code` `meta`, sin publicar. Si la definici\xF3n tiene errores responde `422` `LINT`, igual que al publicar.\n 4. **Publica:** `POST /forms/{id}/publish` hace la validaci\xF3n y publica; es inmediato. Si la definici\xF3n tiene errores responde `422` `LINT` con la lista, y si Meta la rechaza, `502` `META` (`422` `META` si la rechaza porque tu cuenta de WhatsApp Business no cumple sus requisitos de integridad). Un formulario sin ninguna pregunta no se puede publicar. Los otros n\xFAmeros del formulario se publican en segundo plano: consulta su estado (`draft`, `publishing`, `published` o `failed`) en `GET /forms/{id}/numbers`.\n 5. **Suma o quita n\xFAmeros (opcional):** `POST /forms/{id}/numbers` con `device_id` publica el formulario en otro n\xFAmero de WhatsApp Cloud de tu cuenta, `POST /forms/{id}/numbers/retry` reintenta los que fallaron y `DELETE /forms/{id}/numbers/{deviceId}` quita uno. El n\xFAmero con el que creaste el formulario no se quita: `422` `PRIMARY_NUMBER`. Un n\xFAmero que no es de tu cuenta o no es WhatsApp Cloud responde `422` `NUMBER_NOT_ELIGIBLE`.\n 6. **Env\xEDa:** `POST /contacts/{contact}/message/form` con `form_id` (el formulario, no la versi\xF3n) y `device_id`; opcionalmente `text` (hasta 1024 caracteres), `button_text` (hasta 30, sin emojis) y `client_reference`. S\xF3lo se env\xEDa con una clave de API. La clave de un asiento s\xF3lo env\xEDa a los contactos asignados a ese asiento (con cualquier otro contacto responde el `404` de un contacto que no existe); la clave del titular env\xEDa a cualquier contacto de la cuenta. Responde `200` con `message` en cola (`queued`); sus errores propios est\xE1n al final de esta gu\xEDa.\n 7. **Recibe las respuestas:** suscr\xEDbete al evento `whatsapp_form.completed` (tambi\xE9n llega si te suscribes a `*`) o lee `GET /forms/families/{familyId}/responses` y `GET /forms/families/{familyId}/responses/{runId}`. `GET /forms/families/{familyId}/metrics` trae los env\xEDos, aperturas y respuestas.\n - **Cambiar un formulario publicado:** un formulario publicado no se edita (`PUT` y `DELETE` responden `409` `PUBLISHED`). Crea una versi\xF3n nueva con `POST /forms/{id}/versions`, que nace como borrador (`201`, o `200` con `created: false` si ya hab\xEDa un borrador), ed\xEDtala y publ\xEDcala. El `form_id` no cambia: el env\xEDo usa la versi\xF3n publicada vigente.\n- **Reglas de la definici\xF3n:** el servidor no genera ni corrige identificadores: quien arma la definici\xF3n los genera, y deben ser v\xE1lidos y \xFAnicos. Meta acepta s\xF3lo letras y guiones bajos.\n - **Pantallas:** `id` con `SCREEN_` m\xE1s letras (por ejemplo `SCREEN_ABCD`), sin d\xEDgitos, y \xFAnicos en el formulario. `next` es `auto` (la siguiente), `complete` (termina) o el `id` de una pantalla posterior: s\xF3lo se salta hacia adelante.\n - **Preguntas:** `name` en min\xFAsculas y guiones bajos (`a-z` y `_`), \xFAnico en todo el formulario. Sin d\xEDgitos, tildes, `\xF1` ni espacios. No uses los nombres reservados `flow_token`, `version` ni `extension_message_response`. No cambies un `name` despu\xE9s de crearlo: las respuestas, las ramas y las etiquetas lo usan.\n - **Opciones:** `id` de cada opci\xF3n en min\xFAsculas y guiones bajos, \xFAnico en su pregunta (por ejemplo `op_comercio`).\n - **Qu\xE9 marca el linter:** `field_name_reserved` (nombre reservado), `field_name_invalid` (nombre fuera de letras y guiones bajos) y `option_id_invalid` (id de opci\xF3n fuera de ese alfabeto) son errores que impiden validar y publicar. El linter no revisa que los identificadores sean \xFAnicos ni que existan los campos personalizados y las etiquetas que referencias: compru\xE9balo t\xFA.\n - **Campos personalizados y etiquetas:** si una pregunta guarda su respuesta en un campo personalizado (`map` con `target` `custom` y `key` con el nombre del campo) o asigna etiquetas (`tagMap`, con el nombre de la etiqueta), ese campo y esa etiqueta tienen que existir en tu cuenta. El formulario no los crea: si no existen, la respuesta se guarda en el formulario pero no se escribe en el contacto ni se le asigna la etiqueta. Un campo num\xE9rico s\xF3lo admite preguntas `number` y uno de fecha s\xF3lo preguntas `date`; el servidor no lo revisa.\n - **No se pueden publicar** los formularios que necesitan un servidor propio (`endpoint` en `true`, opciones en vivo): el linter los marca como error.\n- **L\xEDmites del formulario:** hasta 5 pantallas, 50 elementos por pantalla, 3 im\xE1genes por pantalla y 2 enlaces por pantalla; pasarse es un error que impide publicar. El nombre del formulario admite hasta 120 caracteres. Las im\xE1genes (`type` `image`, con `src` en base64 sin el prefijo `data:`) deben ser JPG o PNG, de hasta 300 KB cada una y 4 MB entre todas las del formulario; el servidor no revisa su tama\xF1o ni su formato, as\xED que quien arma la definici\xF3n debe respetarlos. El guardado acepta un `src` con el prefijo `data:`, pero se env\xEDa a Meta tal como lo guardaste y Meta dibuja un recuadro roto: manda siempre la carga en base64 pelada, sin prefijo.\n - **Tope de cada guardado:** adem\xE1s, un guardado rechaza (`422`) una definici\xF3n con m\xE1s de 20 pantallas, 100 elementos en una pantalla o 200 opciones en una pregunta. Esos topes quedan por encima de los l\xEDmites de arriba. Adem\xE1s, cada guardado env\xEDa la definici\xF3n entera, im\xE1genes incluidas, y debe pesar menos de unos 6 MB: pasado ese tama\xF1o la plataforma rechaza la llamada antes de que llegue al formulario, as\xED que no cuentes con m\xE1s.\n- **Qu\xE9 puede hacer cada clave:** los permisos se heredan hacia abajo: **Contactos y conversaciones \u2192 Enviar** incluye **Contactos y conversaciones \u2192 Editar**, que incluye **Contactos y conversaciones \u2192 Ver**. Con **Contactos y conversaciones \u2192 Ver** lees formularios, su definici\xF3n (`GET /forms/families/{familyId}/definition`), versiones, n\xFAmeros, respuestas y m\xE9tricas; con **Contactos y conversaciones \u2192 Editar** adem\xE1s armas, guardas, publicas, validas, creas versiones, eliminas borradores y sumas, reintentas o quitas n\xFAmeros; con **Contactos y conversaciones \u2192 Enviar** adem\xE1s env\xEDas. Por eso una clave con **Contactos y conversaciones \u2192 Enviar**, aunque s\xF3lo la hayas pensado para enviar, tambi\xE9n puede armar, publicar y quitar n\xFAmeros de formularios: dale ese permiso s\xF3lo a quien conf\xEDas para eso. Las operaciones `summary`, `analysis` y `deliveries` del panel no est\xE1n abiertas a claves.\n - **Topes de llamadas por clave:** cada clave puede hacer hasta 120 lecturas por minuto y hasta 60 escrituras por minuto en formularios (cada clave lleva su propio contador: dos claves de la misma cuenta, sea del titular o de un asiento, no se frenan entre s\xED, y ninguna gasta el tope del panel). Al superarlos la respuesta es `429` con el encabezado `Retry-After`, que trae los segundos que debes esperar. Validar, publicar y tocar n\xFAmeros tienen adem\xE1s el tope de 30 por minuto compartido por toda la cuenta, que se explica en los errores al final de esta gu\xEDa.\n - **Cuenta:** la clave de un asiento (un agente) opera sobre los formularios del titular de la cuenta, no sobre formularios propios del asiento.\n - **Respuestas con clave de un asiento:** `responses` y `responses/{runId}` se limitan a los contactos que ese asiento puede ver en Funnelchat; las m\xE9tricas (`metrics`) cuentan todas las respuestas del formulario.\n - **Detalle de una respuesta con clave:** `GET /forms/families/{familyId}/responses/{runId}` no trae `last_error` en sus efectos; el resto de cada efecto es igual al del panel.\n- **Aviso `whatsapp_form.completed`:** `data.form` trae `id` (el formulario), `name` y `version_id` (la versi\xF3n que recibi\xF3 el contacto); `message_id` es el mensaje del formulario y puede ser `null`; `answers` es una lista de `{key, label, value}`, una por pregunta y en el orden del formulario (`key` es el `name` de la pregunta, y `value` es `null` si no la contest\xF3). El mismo aviso puede llegar m\xE1s de una vez con el mismo `id`: deduplica por `id`. Cada respuesta de texto llega con a lo sumo 600 caracteres. Si el contacto vuelve a enviar el mismo formulario no se genera otro aviso: queda la primera respuesta. Los detalles y el ejemplo est\xE1n en la secci\xF3n \xABWebhooks\xBB.\n- **Errores al armar y publicar:** `404` si el `id` no existe o no es de tu cuenta (un id de otra cuenta responde igual); `409` `STALE` (el guardado no es el \xFAltimo) y `409` `PUBLISHED` (la versi\xF3n ya est\xE1 publicada); `422` `LINT` al publicar con errores en la definici\xF3n, `422` `NUMBER_NOT_ELIGIBLE` y `422` `PRIMARY_NUMBER` en los n\xFAmeros; `502` `META` si Meta rechaza la operaci\xF3n o no responde, y `422` `META` si la rechaza porque tu cuenta de WhatsApp Business no cumple sus requisitos de integridad. Validar, publicar y tocar n\xFAmeros (`sync`, `publish` y las operaciones de `numbers`) sube a Meta, que limita por cuenta de WhatsApp Business: todas las claves de tu cuenta, la del titular y las de los asientos, comparten un tope de 30 de estas llamadas por minuto. Al superarlo responde `429` con el encabezado `Retry-After`, que trae los segundos que debes esperar.\n- **Errores al enviar un formulario:** adem\xE1s de los errores de las reglas de protecci\xF3n de esta documentaci\xF3n, `POST /contacts/{contact}/message/form` responde: `404` si el contacto no existe, no es de tu cuenta o, con la clave de un asiento, no est\xE1 asignado a ese asiento; `404` con `form_not_found` si el formulario no existe en tu cuenta; `422` con `device_unavailable` (el n\xFAmero no es de tu cuenta o no est\xE1 conectado), `number_not_eligible` (no es un n\xFAmero de WhatsApp Cloud que pueda enviar formularios), `form_not_published` (el formulario no tiene versi\xF3n publicada: publ\xEDcalo), `form_not_on_number` (no est\xE1 publicado en ese n\xFAmero: s\xFAmalo con `POST /forms/{id}/numbers` y espera a que se publique), `recipient_unreachable` (el contacto no tiene tel\xE9fono ni identificador de WhatsApp al que ese n\xFAmero pueda escribir), `contact_blocked` (bloqueaste al contacto en ese n\xFAmero: desbloqu\xE9alo para escribirle) y `window_closed` (pasaron m\xE1s de 24 horas desde el \xFAltimo mensaje del contacto); `429` con `rate_limited` y `Retry-After` si superas el ritmo; y `422` con `not_sent` (el destinatario que se registr\xF3 no coincide con el contacto verificado, o el mensaje no qued\xF3 como un env\xEDo de la conversaci\xF3n: no se registr\xF3 ni sali\xF3 nada y puedes reintentar; son las \xFAnicas dos situaciones y en el uso normal no ocurren). Si Funnelchat no pudo poner el mensaje en cola, la respuesta no es un error: es `200` con el mensaje en estado `failed` y `error.reason` `not_queued`; no sali\xF3, as\xED que reintentar no lo duplica. Si tu cuenta est\xE1 bloqueada por el l\xEDmite de contactos activos, el env\xEDo responde `429` con `mac_lockout_24h`, como los dem\xE1s env\xEDos con clave, pero a diferencia de ellos no guarda ning\xFAn mensaje ni `client_reference`: no queda nada en el historial del contacto ni se puede buscar por la referencia, as\xED que reintenta cuando termine el bloqueo. Un `500` indica que el mensaje no pudo registrarse. Si la llamada no responde o da un error 5xx, busca tu `client_reference` en el historial del contacto antes de reintentar.'
51208
51493
  },
51209
51494
  {
51210
51495
  domain: "communities",
51211
51496
  title: "Funnelchat Communities",
51212
51497
  version: "1.0.0",
51213
51498
  spec: "specs/communities.json",
51214
- spec_changes: "La especificaci\xF3n del \xE1rea con `servers` en la URL base p\xFAblica y, en el texto, cada permiso nombrado como en el panel (\xAB\xC1rea \u2192 Casilla\xBB), sin el nombre t\xE9cnico; el nombre t\xE9cnico queda en `x-fc-permission`.",
51215
- operations: 60,
51499
+ spec_changes: "La especificaci\xF3n del \xE1rea con `servers` en la URL base p\xFAblica y, en el texto, cada permiso nombrado como en el panel (\xAB\xC1rea \u2192 Casilla\xBB), sin el nombre t\xE9cnico; el nombre t\xE9cnico queda en `x-fc-permission`. En la publicada, cada operaci\xF3n lleva el nombre y el grupo del portal.",
51500
+ operations: 61,
51216
51501
  excluded_routes: 6,
51217
- description: 'API de Funnelchat para gestionar desde tu propio sistema las campa\xF1as de grupos de WhatsApp de tu cuenta: sus grupos, los participantes y los env\xEDos programados de mensajes a grupos.\n\n## Autenticaci\xF3n\n\nCada llamada lleva tu API key en el encabezado `Authorization: Bearer <id>|fc_live_...`. En \xABTry It\xBB pega tu key: esta documentaci\xF3n no usa las cookies de tu sesi\xF3n.\n\n## Permisos\n\nCada operaci\xF3n indica el permiso que necesita. Una key solo puede llamar a las operaciones de los permisos que tiene.\n\n- **Grupos y campa\xF1as \u2192 Ver**: llama a las operaciones de lectura, que consultan datos de tu cuenta sin cambiar nada.\n- **Grupos y campa\xF1as \u2192 Editar**: incluye Ver; en v0 solo administra suscripciones de webhooks (crear, editar, borrar, rotar el secreto y probar).\n- **Grupos y campa\xF1as \u2192 Enviar**: incluye Editar y Ver; en v0 habilita el env\xEDo de mensajes a un grupo.\n- **Grupos y campa\xF1as \u2192 Administrar grupos**: en v0 habilita las acciones de grupo. No incluye Ver.\n\nLas acciones de grupo piden **Grupos y campa\xF1as \u2192 Administrar grupos**, el env\xEDo de mensajes a un grupo pide **Grupos y campa\xF1as \u2192 Enviar** y consultar una acci\xF3n, leer el link de invitaci\xF3n o listar las solicitudes para unirse pide **Grupos y campa\xF1as \u2192 Ver**. **Grupos y campa\xF1as \u2192 Administrar grupos** no incluye **Grupos y campa\xF1as \u2192 Ver**: para pedir una acci\xF3n y consultar su resultado hacen falta los dos permisos.\n\n## Errores de la API key\n\n- `401` con `error: unauthenticated`: falta la key, o es inv\xE1lida, venci\xF3 o fue revocada. La respuesta trae `WWW-Authenticate: Bearer`.\n- `403` con `error: permission_denied`: la key no tiene el permiso que necesita la operaci\xF3n.\n- `403` con `error: route_not_available`: la operaci\xF3n no est\xE1 disponible para API keys.\n- `403` con `error: use_public_endpoint`: la key lleg\xF3 por una direcci\xF3n que no es la p\xFAblica. El mensaje dice cu\xE1l usar.\n- `429` con `error: daily_cap_exceeded`: la key alcanz\xF3 su l\xEDmite diario de llamadas. La respuesta trae `Retry-After` con los segundos hasta que se renueve.\n- `503` con `error: gate_unavailable`: no se pudo verificar la key en este momento. La respuesta trae `Retry-After`; reintenta pasado ese tiempo.\n\n## Listados con API key\n\nCon una API key, los listados que llevan la l\xEDnea \xABCon API key\xBB devuelven la lista acotada. Las paginadas traen 25 elementos por defecto y 100 como m\xE1ximo; las de p\xE1gina fija traen el tama\xF1o fijo que dice su l\xEDnea. Las dem\xE1s listas no est\xE1n acotadas en la v0, y esa l\xEDnea no aparece en ellas. En las paginadas, pedir m\xE1s, o un tama\xF1o menor que 1 o no entero, responde `422`. El orden solo acepta las columnas que documenta cada operaci\xF3n, y la direcci\xF3n `asc` o `desc`; otro valor responde `422`. Cada operaci\xF3n dice c\xF3mo se llaman sus par\xE1metros de tama\xF1o y de orden. Las estad\xEDsticas por per\xEDodo no son listas: con una API key exigen `start_date` y `end_date` (formato `AAAA-MM-DD`) y aceptan un rango de como m\xE1ximo 366 d\xEDas entre las dos (ambos incluidos); su l\xEDnea \xABCon API key\xBB lo dice. La retenci\xF3n por cohorte y los abandonos no est\xE1n disponibles con una API key en la v0.\n\n## Versi\xF3n v0\n\nLa v0 puede cambiar sin aviso, y algunas respuestas incluyen identificadores internos (por ejemplo `userId`).\n\n## Rutas que no aparecen\n\nSolo se listan las operaciones disponibles para API keys en esta versi\xF3n. Si una ruta no est\xE1 en esta documentaci\xF3n, no est\xE1 disponible para API keys: llamarla con una key responde 403.\n\n## Especificaci\xF3n descargable\n\nLa especificaci\xF3n OpenAPI completa se descarga en `/docs/api.json`, para importarla en tu propia herramienta.\n\n## Webhooks\n\n### C\xF3mo suscribirse\n\nCrea una suscripci\xF3n con tu API key. Crear, editar, borrar, rotar el secreto y probar necesitan el permiso **Grupos y campa\xF1as \u2192 Editar** (o **Grupos y campa\xF1as \u2192 Enviar**, que lo incluye); listar y leer (suscripciones, entregas y eventos) necesitan **Grupos y campa\xF1as \u2192 Ver**, **Grupos y campa\xF1as \u2192 Editar** o **Grupos y campa\xF1as \u2192 Enviar**. Recuerda: en v0, **Grupos y campa\xF1as \u2192 Editar** solo administra suscripciones de webhooks. Una suscripci\xF3n creada con una API key (o reapuntada a otra URL o reactivada con ella) queda ligada a esa key. Si revocas la key, la suscripci\xF3n deja de recibir eventos: con el pr\xF3ximo evento de tu cuenta de un tipo al que est\xE9 suscrita pasa a `active: false` con `inactive_reason: "key_revoked"`, visible en `GET /api/v1/webhooks/{id}`. Las suscripciones creadas con la sesi\xF3n no cambian. Una suscripci\xF3n `key_revoked` no se puede reactivar: editarla con `active: true` responde `422`. Crea una suscripci\xF3n nueva y revisa que la URL sea la tuya: si la key se filtr\xF3, pudo haberla cambiado. Suscripciones inactivas: `active: false` + `inactive_reason` (`key_revoked`), y reactivar una `key_revoked` responde 422 en todos los dominios. Lo mismo vale para una suscripci\xF3n que ya estaba inactiva cuando se revoc\xF3 la key: al intentar reactivarla pasa a `key_revoked`. Cada cuenta puede tener hasta **10 suscripciones activas**; la 11\xAA responde `409`. Una suscripci\xF3n recibe los hechos que ocurren desde que est\xE1 activa y suscrita a su tipo (o a `*`). No se pueden pedir hechos anteriores.\n\n- `GET /api/v1/webhooks`: lista tus suscripciones.\n- `POST /api/v1/webhooks`: crea una suscripci\xF3n.\n- `GET /api/v1/webhooks/events`: lista todos los tipos de evento del dominio, con su estado (`live` o `planned`), si se pueden suscribir y un ejemplo (`null` en los `planned`).\n- `GET`, `PUT`, `DELETE /api/v1/webhooks/{webhook}`: lee, edita o elimina una suscripci\xF3n.\n- `POST /api/v1/webhooks/{webhook}/rotate-secret`: genera un secreto nuevo (el anterior deja de firmar de inmediato).\n- `POST /api/v1/webhooks/{webhook}/test`: env\xEDa un evento de prueba.\n- `GET /api/v1/webhooks/{webhook}/deliveries`: historial de intentos de entrega.\n\n`events` acepta los tipos de la tabla de abajo o `"*"` para todos. La URL debe ser `https` y p\xFAblica.\n\nPedido:\n\n```json\n{\n "url": "https://example.com/hooks/funnelchat",\n "description": "Mi CRM",\n "events": [\n "group.created",\n "group_participant.joined"\n ]\n}\n```\n\nRespuesta `201`. El campo `secret` se muestra **una sola vez** (tambi\xE9n al rotarlo): gu\xE1rdalo.\n\n```json\n{\n "data": {\n "uuid": "00000000-0000-4000-8000-0000000000a1",\n "url": "https://example.com/hooks/funnelchat",\n "description": "Mi CRM",\n "events": [\n "group.created",\n "group_participant.joined"\n ],\n "active": true,\n "inactive_reason": null,\n "created_at": "2026-10-01T12:00:00Z",\n "updated_at": "2026-10-01T12:00:00Z",\n "secret": "whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"\n }\n}\n```\n\n### Qu\xE9 vas a recibir\n\nCada entrega es un `POST` con cuerpo JSON `{id, type, version, occurred_at, data}` y estos headers:\n\n| Header | Contenido |\n|---|---|\n| `Content-Type` | `application/json` |\n| `X-Funnelchat-Event` | El tipo del evento (igual al `type` del cuerpo). |\n| `X-Funnelchat-Delivery` | UUID del intento de entrega; cambia en cada reintento. |\n| `X-Funnelchat-Signature` | `t=<unix>,v1=<hex>`, la firma del cuerpo. |\n\n`id` identifica el evento y es estable entre reintentos; `occurred_at` es UTC; `version` es la versi\xF3n del formato de `data`.\n\n### Verificar la firma\n\n`X-Funnelchat-Signature` es `t=<unix>,v1=<hex>`, donde `v1` es el HMAC-SHA256 en hexadecimal de `t + "." + cuerpo`. La clave es el secreto **completo** tal como se muestra, incluido el prefijo `whsec_` (no se decodifica nada). Comp\xE1ralo en tiempo constante y rechaza entregas con un `t` de m\xE1s de 5 minutos de diferencia.\n\n**Importante:** solo la firma est\xE1 firmada. `X-Funnelchat-Event` y `X-Funnelchat-Delivery` no, as\xED que toma `type` e `id` del cuerpo ya verificado.\n\nPHP:\n\n```php\n<?php\n$secret = \'whsec_...\'; // el secreto COMPLETO, con el prefijo whsec_\n$body = file_get_contents(\'php://input\');\n$header = $_SERVER[\'HTTP_X_FUNNELCHAT_SIGNATURE\'] ?? \'\';\n\nif (! preg_match(\'/^t=(\\d+),v1=([0-9a-f]{64})$/\', $header, $m)) {\n http_response_code(400);\n exit;\n}\n[, $t, $signature] = $m;\n\n// Rechaz\xE1 entregas viejas (protecci\xF3n contra repetici\xF3n).\nif (abs(time() - (int) $t) > 300) {\n http_response_code(400);\n exit;\n}\n\n$expected = hash_hmac(\'sha256\', $t.\'.\'.$body, $secret);\nif (! hash_equals($expected, $signature)) {\n http_response_code(400);\n exit;\n}\n\n$event = json_decode($body, true); // tom\xE1 type e id de AC\xC1, no de los headers\n```\n\nJavaScript (Node.js):\n\n```javascript\nimport crypto from \'node:crypto\';\nimport express from \'express\';\n\nconst secret = \'whsec_...\'; // el secreto COMPLETO, con el prefijo whsec_\nconst app = express();\n\n// Necesit\xE1s el cuerpo crudo: firm\xE1 sobre los bytes recibidos, no sobre JSON re-serializado.\napp.post(\'/hooks/funnelchat\', express.raw({ type: \'application/json\' }), (req, res) => {\n const match = /^t=(\\d+),v1=([0-9a-f]{64})$/.exec(req.get(\'X-Funnelchat-Signature\') ?? \'\');\n if (!match) return res.sendStatus(400);\n const [, t, signature] = match;\n\n // Rechaz\xE1 entregas viejas (protecci\xF3n contra repetici\xF3n).\n if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return res.sendStatus(400);\n\n const expected = crypto.createHmac(\'sha256\', secret).update(`${t}.`).update(req.body).digest(\'hex\');\n const a = Buffer.from(expected);\n const b = Buffer.from(signature);\n if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(400);\n\n const event = JSON.parse(req.body.toString(\'utf8\')); // tom\xE1 type e id de AC\xC1, no de los headers\n res.sendStatus(200);\n});\n```\n\n### Reintentos e idempotencia\n\n- Una entrega se considera exitosa si respondes **2xx dentro de 10 segundos**.\n- Hay hasta **4 intentos**, con esperas de **60 s, 300 s y 1800 s** entre ellos.\n- Se reintenta solo ante un error de red, un `5xx` o un `429`. Un `3xx` u otro `4xx` es definitivo y no se reintenta.\n- Las redirecciones nunca se siguen: configura la URL final.\n- Es una entrega *al menos una vez*: puedes recibir el mismo evento m\xE1s de una vez. **Deduplica por el `id` del cuerpo**; `X-Funnelchat-Delivery` cambia en cada intento.\n- El historial de intentos est\xE1 en `GET /api/v1/webhooks/{webhook}/deliveries`.\n- La entrega puede estar desactivada en un entorno; en ese caso no se env\xEDa nada y la ruta de prueba responde `503`.\n\nEl campo `error` de cada intento fallido usa un vocabulario cerrado:\n\n| `error` | Significado |\n|---|---|\n| `HTTP <c\xF3digo>` | Tu servidor respondi\xF3 con un estado que no es 2xx. |\n| `network error: timeout` | Tu servidor no respondi\xF3 a tiempo. |\n| `network error: tls` | Fall\xF3 la negociaci\xF3n TLS. |\n| `network error: connect_failed` | No se pudo conectar con tu servidor. |\n| `network error: other` | Otro error de red. |\n| `unsafe destination` | La URL resuelve a una direcci\xF3n no p\xFAblica; no se envi\xF3 nada. |\n| `interrupted` | El intento se cort\xF3 antes de terminar. |\n| `deadline` | Se agot\xF3 el tiempo disponible antes de llegar a tu URL; no se envi\xF3 nada y se reintenta. |\n| `secret unreadable` | No se pudo leer el secreto de la suscripci\xF3n; r\xF3talo con `rotate-secret`. |\n\n### Evento de prueba\n\n`POST /api/v1/webhooks/{webhook}/test` env\xEDa un \xFAnico evento `webhook.test` a la URL de la suscripci\xF3n, firmado igual que uno real y sin reintentos. Evento de prueba que se env\xEDa al pedir la prueba de una suscripci\xF3n; no se puede suscribir a \xE9l.\n\nLa prueba espera la respuesta hasta **5 segundos** (en las entregas reales, 10 s) y se limita a **6 por minuto**. Responde `503` si la entrega est\xE1 desactivada en el entorno y `409` si el secreto no se puede leer (r\xF3talo con `rotate-secret`).\n\n```json\n{\n "id": "evt_0000000000000000000000000000000e",\n "type": "webhook.test",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "subscription": "00000000-0000-4000-8000-00000000000c",\n "sent_at": "2026-10-01T12:00:00Z"\n }\n}\n```\n\n### Tipos de evento\n\n| Tipo | Descripci\xF3n | Estado | Suscribible |\n|---|---|---|---|\n| `group.created` | Se cre\xF3 o se import\xF3 un grupo dentro de una campa\xF1a de la cuenta. | live | s\xED |\n| `group_participant.joined` | Una persona entr\xF3 a un grupo de la cuenta; `campaign` es null si el grupo no es de una campa\xF1a. | live | s\xED |\n| `group_participant.left` | Una persona sali\xF3 de un grupo de la cuenta; `campaign` es null si el grupo no es de una campa\xF1a. | live | s\xED |\n| `scheduled_message.finished` | Un env\xEDo programado termin\xF3, con su resultado agregado. | live | s\xED |\n| `group.blocked` | Un grupo qued\xF3 inoperable: sin administrador conectado, con el link de invitaci\xF3n inv\xE1lido o suspendido por WhatsApp. | live | s\xED |\n| `group.recovered` | Un grupo que estaba bloqueado volvi\xF3 a operar. | live | s\xED |\n| `scheduled_message.group_delivery_failed` | Un env\xEDo programado fall\xF3 en un grupo concreto, con el motivo desagregado. | live | s\xED |\n| `scheduled_message.response_received` | Alguien respondi\xF3 a un env\xEDo programado votando una encuesta o respondiendo a un evento. | live | s\xED |\n| `campaign.status_changed` | Una campa\xF1a se activ\xF3 o se paus\xF3. | live | s\xED |\n| `pipeline.finished` | Termin\xF3 un pipeline de operaciones sobre grupos, con su resultado agregado y la clave de idempotencia del pedido que lo origin\xF3, cuando la hay. | live | s\xED |\n| `poll.voted` | Un participante vot\xF3 en una encuesta enviada a un grupo, o cambi\xF3 su voto. | live | s\xED |\n| `group.invite_link_renewed` | El enlace de invitaci\xF3n de un grupo cambi\xF3 por una renovaci\xF3n, autom\xE1tica al detectarse inv\xE1lido o pedida por el usuario; en v0 no se publican los cambios que detecta la sincronizaci\xF3n ni el enlace inicial al crear el grupo. | live | s\xED |\n| `group.invite_link_down` | El enlace de invitaci\xF3n de un grupo qued\xF3 confirmado ca\xEDdo (WhatsApp respondi\xF3 que no existe y la renovaci\xF3n no consigui\xF3 otro); no se publica por errores transitorios ni por grupos prohibidos o suspendidos, y se publica una sola vez por episodio de bloqueo. | live | s\xED |\n| `webhook.test` | Evento de prueba que se env\xEDa al pedir la prueba de una suscripci\xF3n; no se puede suscribir a \xE9l. | live | no |\n\nLos tipos con estado `planned` todav\xEDa no se emiten y no se pueden suscribir: pedirlos en `events` responde `422`. `webhook.test` no se suscribe: llega solo al pedir la prueba de una suscripci\xF3n.\n\n### Ejemplos\n\n#### `group.created`\n\n```json\n{\n "id": "evt_00000000000000000000000000000001",\n "type": "group.created",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "group": {\n "uid": "120363000000000001@g.us",\n "name": "Grupo de ejemplo",\n "type": "group"\n },\n "campaign": {\n "uuid": "00000000-0000-4000-8000-000000000001",\n "name": "Campa\xF1a de ejemplo"\n },\n "source": "created"\n }\n}\n```\n\n#### `group_participant.joined`\n\n```json\n{\n "id": "evt_00000000000000000000000000000002",\n "type": "group_participant.joined",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "group": {\n "uid": "120363000000000001@g.us",\n "name": "Grupo de ejemplo"\n },\n "campaign": {\n "uuid": "00000000-0000-4000-8000-000000000001"\n },\n "participant": {\n "identity": "+5491100000000",\n "identity_type": "phone",\n "name": "Ana P\xE9rez"\n }\n }\n}\n```\n\n#### `group_participant.left`\n\n```json\n{\n "id": "evt_00000000000000000000000000000003",\n "type": "group_participant.left",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "group": {\n "uid": "120363000000000001@g.us",\n "name": "Grupo de ejemplo"\n },\n "campaign": {\n "uuid": "00000000-0000-4000-8000-000000000001"\n },\n "participant": {\n "identity": "+5491100000000",\n "identity_type": "phone",\n "name": "Ana P\xE9rez"\n }\n }\n}\n```\n\n#### `scheduled_message.finished`\n\n```json\n{\n "id": "evt_00000000000000000000000000000004",\n "type": "scheduled_message.finished",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "scheduled_message": {\n "uuid": "00000000-0000-4000-8000-000000000004",\n "name": "Env\xEDo de ejemplo"\n },\n "outcome": "partially_failed",\n "groups": {\n "total": 10,\n "failed": 1,\n "partially_failed": 2\n },\n "occurrence": 1\n }\n}\n```\n\n#### `group.blocked`\n\n```json\n{\n "id": "evt_00000000000000000000000000000005",\n "type": "group.blocked",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "group": {\n "uid": "120363000000000001@g.us",\n "name": "Grupo de ejemplo"\n },\n "campaign": {\n "uuid": "00000000-0000-4000-8000-000000000001"\n },\n "reason": "admin_access_lost"\n }\n}\n```\n\n#### `group.recovered`\n\n```json\n{\n "id": "evt_00000000000000000000000000000006",\n "type": "group.recovered",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "group": {\n "uid": "120363000000000001@g.us",\n "name": "Grupo de ejemplo"\n },\n "campaign": {\n "uuid": "00000000-0000-4000-8000-000000000001"\n },\n "reason": "access_restored"\n }\n}\n```\n\n#### `scheduled_message.group_delivery_failed`\n\n```json\n{\n "id": "evt_00000000000000000000000000000007",\n "type": "scheduled_message.group_delivery_failed",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "scheduled_message": {\n "uuid": "00000000-0000-4000-8000-000000000007"\n },\n "group": {\n "uid": "120363000000000001@g.us",\n "name": "Grupo de ejemplo"\n },\n "reason": "device_disconnected",\n "partial": false,\n "occurrence": 1\n }\n}\n```\n\n#### `scheduled_message.response_received`\n\n```json\n{\n "id": "evt_00000000000000000000000000000008",\n "type": "scheduled_message.response_received",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "scheduled_message": {\n "uuid": "00000000-0000-4000-8000-000000000008"\n },\n "group": {\n "uid": "120363000000000001@g.us"\n },\n "participant": {\n "identity": "+5491100000000",\n "identity_type": "phone",\n "name": "Ana P\xE9rez"\n },\n "response": {\n "type": "poll",\n "options": [\n "Opci\xF3n A"\n ],\n "rsvp": null\n }\n }\n}\n```\n\n#### `campaign.status_changed`\n\n```json\n{\n "id": "evt_00000000000000000000000000000009",\n "type": "campaign.status_changed",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "campaign": {\n "uuid": "00000000-0000-4000-8000-000000000009",\n "name": "Campa\xF1a de ejemplo"\n },\n "from": "active",\n "to": "paused"\n }\n}\n```\n\n#### `pipeline.finished`\n\n```json\n{\n "id": "evt_0000000000000000000000000000000a",\n "type": "pipeline.finished",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "campaign": {\n "uuid": "00000000-0000-4000-8000-00000000000a"\n },\n "pipeline": {\n "uuid": "00000000-0000-4000-8000-00000000000b",\n "name": "Pipeline de ejemplo"\n },\n "outcome": "completed",\n "actions": {\n "total": 5,\n "succeeded": 4,\n "failed": 0,\n "skipped": 1,\n "cancelled": 0\n },\n "idempotency_key": "00000000-0000-4000-8000-00000000000d"\n }\n}\n```\n\n#### `poll.voted`\n\n```json\n{\n "id": "evt_0000000000000000000000000000000b",\n "type": "poll.voted",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "poll": {\n "uid": "3EB0000000000000000001"\n },\n "group": {\n "uid": "120363000000000001@g.us"\n },\n "scheduled_message": {\n "uuid": "00000000-0000-4000-8000-000000000004"\n },\n "participant": {\n "identity": "+5491100000000",\n "identity_type": "phone",\n "name": null\n },\n "options": [\n "Opci\xF3n A"\n ]\n }\n}\n```\n\n#### `group.invite_link_renewed`\n\n```json\n{\n "id": "evt_0000000000000000000000000000000c",\n "type": "group.invite_link_renewed",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "group": {\n "uid": "120363000000000001@g.us",\n "name": "Grupo de ejemplo",\n "type": "group"\n },\n "campaign": {\n "uuid": "00000000-0000-4000-8000-000000000001",\n "name": "Campa\xF1a de ejemplo"\n },\n "invite_link": "https://chat.whatsapp.com/EjemploNuevo0000000000001",\n "previous_invite_link": "https://chat.whatsapp.com/EjemploAnterior000000000001",\n "trigger": "automatic",\n "recovered": false\n }\n}\n```\n\n#### `group.invite_link_down`\n\n```json\n{\n "id": "evt_0000000000000000000000000000000d",\n "type": "group.invite_link_down",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "group": {\n "uid": "120363000000000001@g.us",\n "name": "Grupo de ejemplo",\n "type": "group"\n },\n "campaign": {\n "uuid": "00000000-0000-4000-8000-000000000001",\n "name": "Campa\xF1a de ejemplo"\n },\n "reason": "http_404",\n "renewal_attempted": true\n }\n}\n```\n\n### No confundir con los webhooks de campa\xF1a\n\nLas rutas `campaigns/{campaign}/webhooks` son un producto distinto (los webhooks legados de campa\xF1a), con otro formato, y **no reciben** estos eventos. Los eventos de esta secci\xF3n se reciben solo a trav\xE9s de `/api/v1/webhooks`.\n\n## Acciones de grupo\n\n### Modelo as\xEDncrono\n\nLas acciones sobre grupos no se ejecutan dentro de la llamada: se aceptan y se ejecutan despu\xE9s, al ritmo del n\xFAmero de WhatsApp. La respuesta es `202` con:\n\n```json\n{"action_id": "00000000-0000-4000-8000-000000000001", "status": "queued"}\n```\n\nPara saber c\xF3mo termin\xF3, consulta `GET /api/v1/groups/actions/{action_id}`. El estado es `queued`, `running`, `completed` o `failed`. Tambi\xE9n recibes un aviso por webhook, `group_action.completed` o `group_action.failed`, con el mismo `action_id` como `action.uuid` (solo si tienes una suscripci\xF3n a ese tipo).\n\n### Resultado por tipo de acci\xF3n\n\n- Acciones con lista de tel\xE9fonos (sumar o sacar participantes, promover o quitar administradores): siempre terminan `completed`, y `result` trae una entrada por persona con `added`, `invited`, `removed`, `promoted`, `demoted` o `failed` (con su `reason`). Que la acci\xF3n termine `completed` no quiere decir que cada persona haya salido bien: revisa cada una.\n- `create_group` y `renew_invite_link`: `result` es `{"invite_link": "..."}`. Al crear con un n\xFAmero oficial, `join_approval_required: true` crea el grupo pidiendo aprobaci\xF3n para entrar (WhatsApp no permite cambiarlo despu\xE9s); con un n\xFAmero QR responde `422 provider_unsupported`.\n- `approve_join_requests` y `reject_join_requests` (`POST /api/v1/groups/{group}/join-requests/approve` y `.../reject`, con `device_id` y `phones`): siempre terminan `completed`, y `result.participants` trae una entrada por tel\xE9fono, en el orden del pedido, con `status` `approved` o `rejected` (o `failed`) y su `reason`. Los motivos de `failed` son `join_request_not_found` (esa persona no tiene una solicitud pendiente), `capacity_full` (ya no hay lugar en el grupo), `provider_error` y `provider_unsupported`.\n- `GET /api/v1/groups/{group}/join-requests?device_id=`: no es una acci\xF3n, se lee en vivo. Responde `{"data": [{"participant": {"identity": "...", "identity_type": "phone", "name": null}, "requested_at": "2026-01-01T00:00:00Z"}]}` con las solicitudes pendientes. `requested_at` puede ser `null`.\n- `send_message`: `media_url` admite hasta 255 caracteres. `result` es `{"message": {"uid": "..."}}`, el id del mensaje en WhatsApp.\n- Las dem\xE1s (nombre, descripci\xF3n, foto, ajustes): `result` es `{}`.\n\n### Qu\xE9 hace cada n\xFAmero\n\n| Capacidad | N\xFAmero QR | N\xFAmero oficial (con la beta de grupos de la API oficial) |\n|---|---|---|\n| Crear grupo | s\xED | s\xED |\n| Sumar participantes | directo, al grupo | por invitaci\xF3n: cada persona recibe la plantilla de invitaci\xF3n elegida por la cuenta |\n| Sacar participantes | s\xED | s\xED |\n| Administradores (promover y quitar) | s\xED | no |\n| Foto | s\xED | no |\n| Ajustes (`members_can_send_messages`) | s\xED | no |\n| Link de invitaci\xF3n (leer y renovar) | s\xED | s\xED |\n| Solicitudes para unirse (listar) | no | s\xED |\n| Solicitudes para unirse (aprobar y rechazar) | no | s\xED, pero mientras la plataforma no habilite las decisiones responden `422 provider_unsupported` |\n| Enviar mensaje (`text`, `image`, `video`, `audio`, `document`) | s\xED | s\xED (requiere adem\xE1s el chat de grupos habilitado) |\n\nCuando el n\xFAmero no permite la acci\xF3n, o es oficial y la cuenta no tiene la beta, la respuesta es `422` con `error: provider_unsupported`.\n\n### Errores\n\nLos errores traen `{"error": "<c\xF3digo>", "message": "..."}`.\n\n| `error` | Estado | Significado |\n|---|---|---|\n| `device_not_found` | 404 | El n\xFAmero no existe en tu cuenta. |\n| `group_not_found` | 404 | El grupo no existe en tu cuenta. |\n| `action_not_found` | 404 | La acci\xF3n no existe o no es de tu cuenta (`GET /api/v1/groups/actions/{action_id}`). |\n| `unsupported_group_type` | 422 | El tipo de grupo no admite la acci\xF3n. |\n| `device_not_connected` | 422 | El n\xFAmero no est\xE1 conectado. |\n| `provider_unsupported` | 422 | El n\xFAmero no permite la acci\xF3n; trae `requires` con lo que hace falta. Aprobar y rechazar solicitudes para unirse responden esto mientras la habilitaci\xF3n de decisiones est\xE9 apagada. |\n| `device_not_group_admin` | 422 | El n\xFAmero no es administrador del grupo. |\n| `device_not_group_number` | 422 | El n\xFAmero no es el del grupo. |\n| `cannot_target_executing_device` | 422 | No se puede apuntar al tel\xE9fono del propio n\xFAmero que ejecuta. |\n| `cannot_target_account_device` | 422 | No se puede apuntar a otro n\xFAmero de tu cuenta. |\n| `not_a_participant` | 422 | El tel\xE9fono no es miembro conocido del grupo (promover o quitar administrador). |\n| `invite_template_not_configured` | 422 | La cuenta no eligi\xF3 la plantilla de invitaci\xF3n (n\xFAmero oficial). |\n| `max_phones` | 422 | Hay m\xE1s tel\xE9fonos que el tope por pedido, una vez quitados los repetidos. |\n| `unsupported_type` | 422 | El n\xFAmero oficial no puede enviar ese tipo de mensaje por la v\xEDa oficial. |\n| `capacity_full` | 422 | El grupo no tiene lugares libres; tambi\xE9n al aprobar solicitudes para unirse cuando los tel\xE9fonos pedidos no entran en los lugares libres (rechazar no lo verifica). |\n| `invite_capacity_full` | 422 | Los lugares libres ya est\xE1n comprometidos con invitaciones activas (n\xFAmero oficial). |\n| `duplicate_invite` | 422 | Esa persona ya tiene una invitaci\xF3n activa. |\n| `window_closed` | 422 | La ventana de servicio de 24 horas est\xE1 cerrada (n\xFAmero oficial). |\n| `number_restricted` | 422 | La calidad del n\xFAmero impide enviar (n\xFAmero oficial). |\n| `too_many_pending_actions` | 429 | El n\xFAmero ya tiene el m\xE1ximo de acciones abiertas. |\n| `provider_error` | 502 | Fall\xF3 el proveedor de WhatsApp. |\n| (plan) | 402 | El plan de la cuenta no incluye la acci\xF3n o alcanz\xF3 su tope. |\n| `route_not_available` | 403 | Las acciones de grupo por API no est\xE1n habilitadas en este entorno. |\n\n### Topes y ritmo\n\n- Hasta **20 tel\xE9fonos** por pedido. En un n\xFAmero oficial, adem\xE1s, caben solo los lugares libres del tope de 8 participantes menos las invitaciones activas.\n- Hasta **50 acciones abiertas** (`queued` o `running`) por n\xFAmero.\n- **30 pedidos de escritura por minuto**, y **30 lecturas en vivo por minuto** (link de invitaci\xF3n y solicitudes para unirse).\n- Ritmo de campa\xF1as: sumar participantes se hace de a uno, y entre creaciones de grupos hay una espera. Es el mismo ritmo de las campa\xF1as del n\xFAmero y no se puede acelerar desde la API.\n- Los env\xEDos de mensajes salen al ritmo de los mensajes programados de velocidad Low.\n- Enviar mensajes por esta v\xEDa no tiene tope de volumen del plan.\n\n### Permisos\n\n- Pedir una acci\xF3n (crear, sumar o sacar participantes, administradores, nombre, descripci\xF3n, foto, ajustes, renovar el link, aprobar y rechazar solicitudes para unirse): **Grupos y campa\xF1as \u2192 Administrar grupos**.\n- Enviar un mensaje al grupo: **Grupos y campa\xF1as \u2192 Enviar**.\n- Consultar una acci\xF3n, leer el link de invitaci\xF3n y listar las solicitudes para unirse: **Grupos y campa\xF1as \u2192 Ver**.\n\n**Grupos y campa\xF1as \u2192 Administrar grupos** no incluye **Grupos y campa\xF1as \u2192 Ver**: para pedir una acci\xF3n y consultar su resultado hacen falta los dos permisos.\n\n### Avisos de lo que pasa en el grupo\n\nAdem\xE1s del resultado de tus acciones, puedes suscribirte a lo que ocurre adentro de los grupos: `group.message_received`, `group.reaction_added`, `group_participant.joined`, `group_participant.left`, `poll.voted`, `group_join_request.created` y `group_join_request.revoked`. Salen tambi\xE9n para grupos que no pertenecen a una campa\xF1a (sin campa\xF1a, `campaign_uuid` es `null`); cuando el grupo es de una campa\xF1a, el aviso lleva su `campaign_uuid`, fuera de `data`. Solo se generan si tienes una suscripci\xF3n a ese tipo.\n\n`group.message_received` y `group.reaction_added` reflejan lo que report\xF3 el proveedor de WhatsApp; la autor\xEDa no se verifica de forma independiente. `group_join_request.created` y `group_join_request.revoked` avisan cuando alguien pide entrar a un grupo oficial y cuando retira esa solicitud; llevan `group` y `participant` (`identity`, `identity_type`, `name`), y `participant.identity_type` es `whatsapp_user` cuando la persona se identifica por BSUID o por un identificador de 14 o m\xE1s d\xEDgitos que no es un tel\xE9fono.\n\n### L\xEDmites conocidos\n\n- En un n\xFAmero QR, renovar el link relee el link vigente y **no lo revoca**.\n- `added` significa que WhatsApp acept\xF3 la operaci\xF3n; no garantiza que la persona siga en el grupo.\n- Un `create_group` en un n\xFAmero oficial sin confirmaci\xF3n de WhatsApp termina `failed` con `creation_unconfirmed`, aunque el grupo pueda existir. Consulta `GET /api/v1/groups` antes de reintentar.\n- La lista de solicitudes para unirse trae como m\xE1ximo **100** entradas, en el orden del proveedor, y solo la primera p\xE1gina. Las solicitudes sin tel\xE9fono (identificadas solo por BSUID) no aparecen.\n- Aprobar una solicitud no agrega a la persona a la lista de participantes: el alta y el aviso `group_participant.joined` llegan por el aviso de altas de WhatsApp. Una persona cuya solicitud est\xE1 m\xE1s all\xE1 de la primera p\xE1gina que devuelve el proveedor queda `failed` con `join_request_not_found`.\n- No hay clave de idempotencia: reintentar despu\xE9s de un `5xx` puede duplicar la acci\xF3n.\n- En `group.message_received`, `media_url` puede ser `null` en un grupo QR, o ser una URL temporal del proveedor.'
51502
+ description: 'API de Funnelchat para gestionar desde tu propio sistema las campa\xF1as de grupos de WhatsApp de tu cuenta: sus grupos, los participantes y los env\xEDos programados de mensajes a grupos.\n\n## Autenticaci\xF3n\n\nCada llamada lleva tu API key en el encabezado `Authorization: Bearer <id>|fc_live_...`. En \xABTry It\xBB pega tu key: esta documentaci\xF3n no usa las cookies de tu sesi\xF3n.\n\n## Permisos\n\nCada operaci\xF3n indica el permiso que necesita. Una key solo puede llamar a las operaciones de los permisos que tiene.\n\n- **Grupos y campa\xF1as \u2192 Ver**: llama a las operaciones de lectura, que consultan datos de tu cuenta sin cambiar nada.\n- **Grupos y campa\xF1as \u2192 Editar**: incluye Ver; en v0 solo administra suscripciones de webhooks (crear, editar, borrar, rotar el secreto y probar).\n- **Grupos y campa\xF1as \u2192 Enviar**: incluye Editar y Ver; en v0 habilita el env\xEDo de mensajes a un grupo.\n- **Grupos y campa\xF1as \u2192 Administrar grupos**: en v0 habilita las acciones de grupo. No incluye Ver.\n\nLas acciones de grupo piden **Grupos y campa\xF1as \u2192 Administrar grupos**, el env\xEDo de mensajes a un grupo pide **Grupos y campa\xF1as \u2192 Enviar** y consultar una acci\xF3n, leer el link de invitaci\xF3n o listar las solicitudes para unirse pide **Grupos y campa\xF1as \u2192 Ver**. **Grupos y campa\xF1as \u2192 Administrar grupos** no incluye **Grupos y campa\xF1as \u2192 Ver**: para pedir una acci\xF3n y consultar su resultado hacen falta los dos permisos.\n\n## Errores de la API key\n\n- `401` con `error: unauthenticated`: falta la key, o es inv\xE1lida, venci\xF3 o fue revocada. La respuesta trae `WWW-Authenticate: Bearer`.\n- `403` con `error: permission_denied`: la key no tiene el permiso que necesita la operaci\xF3n.\n- `403` con `error: route_not_available`: la operaci\xF3n no est\xE1 disponible para API keys.\n- `403` con `error: use_public_endpoint`: la key lleg\xF3 por una direcci\xF3n que no es la p\xFAblica. El mensaje dice cu\xE1l usar.\n- `429` con `error: daily_cap_exceeded`: la key alcanz\xF3 su l\xEDmite diario de llamadas. La respuesta trae `Retry-After` con los segundos hasta que se renueve.\n- `503` con `error: gate_unavailable`: no se pudo verificar la key en este momento. La respuesta trae `Retry-After`; reintenta pasado ese tiempo.\n\n## Listados con API key\n\nCon una API key, los listados que llevan la l\xEDnea \xABCon API key\xBB devuelven la lista acotada. Las paginadas traen 25 elementos por defecto y 100 como m\xE1ximo; las de p\xE1gina fija traen el tama\xF1o fijo que dice su l\xEDnea. Las dem\xE1s listas no est\xE1n acotadas en la v0, y esa l\xEDnea no aparece en ellas. En las paginadas, pedir m\xE1s, o un tama\xF1o menor que 1 o no entero, responde `422`. El orden solo acepta las columnas que documenta cada operaci\xF3n, y la direcci\xF3n `asc` o `desc`; otro valor responde `422`. Cada operaci\xF3n dice c\xF3mo se llaman sus par\xE1metros de tama\xF1o y de orden. Las estad\xEDsticas por per\xEDodo no son listas: con una API key exigen `start_date` y `end_date` (formato `AAAA-MM-DD`) y aceptan un rango de como m\xE1ximo 366 d\xEDas entre las dos (ambos incluidos); su l\xEDnea \xABCon API key\xBB lo dice. La retenci\xF3n por cohorte y los abandonos no est\xE1n disponibles con una API key en la v0.\n\n## Versi\xF3n v0\n\nLa v0 puede cambiar sin aviso, y algunas respuestas incluyen identificadores internos (por ejemplo `userId`).\n\n## Rutas que no aparecen\n\nSolo se listan las operaciones disponibles para API keys en esta versi\xF3n. Si una ruta no est\xE1 en esta documentaci\xF3n, no est\xE1 disponible para API keys: llamarla con una key responde 403.\n\n## Especificaci\xF3n descargable\n\nLa especificaci\xF3n OpenAPI completa se descarga en `/docs/api.json`, para importarla en tu propia herramienta.\n\n## Webhooks\n\n### C\xF3mo suscribirse\n\nCrea una suscripci\xF3n con tu API key. Crear, editar, borrar, rotar el secreto y probar necesitan el permiso **Grupos y campa\xF1as \u2192 Editar** (o **Grupos y campa\xF1as \u2192 Enviar**, que lo incluye); listar y leer (suscripciones, entregas y eventos) necesitan **Grupos y campa\xF1as \u2192 Ver**, **Grupos y campa\xF1as \u2192 Editar** o **Grupos y campa\xF1as \u2192 Enviar**. Recuerda: en v0, **Grupos y campa\xF1as \u2192 Editar** solo administra suscripciones de webhooks. Una suscripci\xF3n creada con una API key (o reapuntada a otra URL o reactivada con ella) queda ligada a esa key. Si revocas la key, la suscripci\xF3n deja de recibir eventos: con el pr\xF3ximo evento de tu cuenta de un tipo al que est\xE9 suscrita pasa a `active: false` con `inactive_reason: "key_revoked"`, visible en `GET /api/v1/webhooks/{id}`. Las suscripciones creadas con la sesi\xF3n no cambian. Una suscripci\xF3n `key_revoked` no se puede reactivar: editarla con `active: true` responde `422`. Crea una suscripci\xF3n nueva y revisa que la URL sea la tuya: si la key se filtr\xF3, pudo haberla cambiado. Suscripciones inactivas: `active: false` + `inactive_reason` (`key_revoked`), y reactivar una `key_revoked` responde 422 en todos los dominios. Lo mismo vale para una suscripci\xF3n que ya estaba inactiva cuando se revoc\xF3 la key: al intentar reactivarla pasa a `key_revoked`. Cada cuenta puede tener hasta **10 suscripciones activas**; la 11\xAA responde `409`. Una suscripci\xF3n recibe los hechos que ocurren desde que est\xE1 activa y suscrita a su tipo (o a `*`). No se pueden pedir hechos anteriores.\n\n- `GET /api/v1/webhooks`: lista tus suscripciones.\n- `POST /api/v1/webhooks`: crea una suscripci\xF3n.\n- `GET /api/v1/webhooks/events`: lista todos los tipos de evento del dominio, con su estado (`live` o `planned`), si se pueden suscribir y un ejemplo (`null` en los `planned`).\n- `GET`, `PUT`, `DELETE /api/v1/webhooks/{webhook}`: lee, edita o elimina una suscripci\xF3n.\n- `POST /api/v1/webhooks/{webhook}/rotate-secret`: genera un secreto nuevo (el anterior deja de firmar de inmediato).\n- `POST /api/v1/webhooks/{webhook}/test`: env\xEDa un evento de prueba.\n- `GET /api/v1/webhooks/{webhook}/deliveries`: historial de intentos de entrega.\n\n`events` acepta los tipos de la tabla de abajo o `"*"` para todos. La URL debe ser `https` y p\xFAblica.\n\nPedido:\n\n```json\n{\n "url": "https://example.com/hooks/funnelchat",\n "description": "Mi CRM",\n "events": [\n "group.created",\n "group_participant.joined"\n ]\n}\n```\n\nRespuesta `201`. El campo `secret` se muestra **una sola vez** (tambi\xE9n al rotarlo): gu\xE1rdalo.\n\n```json\n{\n "data": {\n "uuid": "00000000-0000-4000-8000-0000000000a1",\n "url": "https://example.com/hooks/funnelchat",\n "description": "Mi CRM",\n "events": [\n "group.created",\n "group_participant.joined"\n ],\n "active": true,\n "inactive_reason": null,\n "created_at": "2026-10-01T12:00:00Z",\n "updated_at": "2026-10-01T12:00:00Z",\n "secret": "whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"\n }\n}\n```\n\n### Qu\xE9 vas a recibir\n\nCada entrega es un `POST` con cuerpo JSON `{id, type, version, occurred_at, data}` y estos headers:\n\n| Header | Contenido |\n|---|---|\n| `Content-Type` | `application/json` |\n| `X-Funnelchat-Event` | El tipo del evento (igual al `type` del cuerpo). |\n| `X-Funnelchat-Delivery` | UUID del intento de entrega; cambia en cada reintento. |\n| `X-Funnelchat-Signature` | `t=<unix>,v1=<hex>`, la firma del cuerpo. |\n\n`id` identifica el evento y es estable entre reintentos; `occurred_at` es UTC; `version` es la versi\xF3n del formato de `data`.\n\n### Verificar la firma\n\n`X-Funnelchat-Signature` es `t=<unix>,v1=<hex>`, donde `v1` es el HMAC-SHA256 en hexadecimal de `t + "." + cuerpo`. La clave es el secreto **completo** tal como se muestra, incluido el prefijo `whsec_` (no se decodifica nada). Comp\xE1ralo en tiempo constante y rechaza entregas con un `t` de m\xE1s de 5 minutos de diferencia.\n\n**Importante:** solo la firma est\xE1 firmada. `X-Funnelchat-Event` y `X-Funnelchat-Delivery` no, as\xED que toma `type` e `id` del cuerpo ya verificado.\n\nPHP:\n\n```php\n<?php\n$secret = \'whsec_...\'; // el secreto COMPLETO, con el prefijo whsec_\n$body = file_get_contents(\'php://input\');\n$header = $_SERVER[\'HTTP_X_FUNNELCHAT_SIGNATURE\'] ?? \'\';\n\nif (! preg_match(\'/^t=(\\d+),v1=([0-9a-f]{64})$/\', $header, $m)) {\n http_response_code(400);\n exit;\n}\n[, $t, $signature] = $m;\n\n// Rechaz\xE1 entregas viejas (protecci\xF3n contra repetici\xF3n).\nif (abs(time() - (int) $t) > 300) {\n http_response_code(400);\n exit;\n}\n\n$expected = hash_hmac(\'sha256\', $t.\'.\'.$body, $secret);\nif (! hash_equals($expected, $signature)) {\n http_response_code(400);\n exit;\n}\n\n$event = json_decode($body, true); // tom\xE1 type e id de AC\xC1, no de los headers\n```\n\nJavaScript (Node.js):\n\n```javascript\nimport crypto from \'node:crypto\';\nimport express from \'express\';\n\nconst secret = \'whsec_...\'; // el secreto COMPLETO, con el prefijo whsec_\nconst app = express();\n\n// Necesit\xE1s el cuerpo crudo: firm\xE1 sobre los bytes recibidos, no sobre JSON re-serializado.\napp.post(\'/hooks/funnelchat\', express.raw({ type: \'application/json\' }), (req, res) => {\n const match = /^t=(\\d+),v1=([0-9a-f]{64})$/.exec(req.get(\'X-Funnelchat-Signature\') ?? \'\');\n if (!match) return res.sendStatus(400);\n const [, t, signature] = match;\n\n // Rechaz\xE1 entregas viejas (protecci\xF3n contra repetici\xF3n).\n if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return res.sendStatus(400);\n\n const expected = crypto.createHmac(\'sha256\', secret).update(`${t}.`).update(req.body).digest(\'hex\');\n const a = Buffer.from(expected);\n const b = Buffer.from(signature);\n if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(400);\n\n const event = JSON.parse(req.body.toString(\'utf8\')); // tom\xE1 type e id de AC\xC1, no de los headers\n res.sendStatus(200);\n});\n```\n\n### Reintentos e idempotencia\n\n- Una entrega se considera exitosa si respondes **2xx dentro de 10 segundos**.\n- Hay hasta **4 intentos**, con esperas de **60 s, 300 s y 1800 s** entre ellos.\n- Se reintenta solo ante un error de red, un `5xx` o un `429`. Un `3xx` u otro `4xx` es definitivo y no se reintenta.\n- Las redirecciones nunca se siguen: configura la URL final.\n- Es una entrega *al menos una vez*: puedes recibir el mismo evento m\xE1s de una vez. **Deduplica por el `id` del cuerpo**; `X-Funnelchat-Delivery` cambia en cada intento.\n- El historial de intentos est\xE1 en `GET /api/v1/webhooks/{webhook}/deliveries`.\n- La entrega puede estar desactivada en un entorno; en ese caso no se env\xEDa nada y la ruta de prueba responde `503`.\n\nEl campo `error` de cada intento fallido usa un vocabulario cerrado:\n\n| `error` | Significado |\n|---|---|\n| `HTTP <c\xF3digo>` | Tu servidor respondi\xF3 con un estado que no es 2xx. |\n| `network error: timeout` | Tu servidor no respondi\xF3 a tiempo. |\n| `network error: tls` | Fall\xF3 la negociaci\xF3n TLS. |\n| `network error: connect_failed` | No se pudo conectar con tu servidor. |\n| `network error: other` | Otro error de red. |\n| `unsafe destination` | La URL resuelve a una direcci\xF3n no p\xFAblica; no se envi\xF3 nada. |\n| `interrupted` | El intento se cort\xF3 antes de terminar. |\n| `deadline` | Se agot\xF3 el tiempo disponible antes de llegar a tu URL; no se envi\xF3 nada y se reintenta. |\n| `secret unreadable` | No se pudo leer el secreto de la suscripci\xF3n; r\xF3talo con `rotate-secret`. |\n\n### Evento de prueba\n\n`POST /api/v1/webhooks/{webhook}/test` env\xEDa un \xFAnico evento `webhook.test` a la URL de la suscripci\xF3n, firmado igual que uno real y sin reintentos. Evento de prueba que se env\xEDa al pedir la prueba de una suscripci\xF3n; no se puede suscribir a \xE9l.\n\nLa prueba espera la respuesta hasta **5 segundos** (en las entregas reales, 10 s) y se limita a **6 por minuto**. Responde `503` si la entrega est\xE1 desactivada en el entorno y `409` si el secreto no se puede leer (r\xF3talo con `rotate-secret`).\n\n```json\n{\n "id": "evt_0000000000000000000000000000000e",\n "type": "webhook.test",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "subscription": "00000000-0000-4000-8000-00000000000c",\n "sent_at": "2026-10-01T12:00:00Z"\n }\n}\n```\n\n### Tipos de evento\n\n| Tipo | Descripci\xF3n | Estado | Suscribible |\n|---|---|---|---|\n| `group.created` | Se cre\xF3 o se import\xF3 un grupo dentro de una campa\xF1a de la cuenta. | live | s\xED |\n| `group_participant.joined` | Una persona entr\xF3 a un grupo de la cuenta; `campaign` es null si el grupo no es de una campa\xF1a. | live | s\xED |\n| `group_participant.left` | Una persona sali\xF3 de un grupo de la cuenta; `campaign` es null si el grupo no es de una campa\xF1a. | live | s\xED |\n| `scheduled_message.finished` | Un env\xEDo programado termin\xF3, con su resultado agregado. | live | s\xED |\n| `group.blocked` | Un grupo qued\xF3 inoperable: sin administrador conectado, con el link de invitaci\xF3n inv\xE1lido o suspendido por WhatsApp. | live | s\xED |\n| `group.recovered` | Un grupo que estaba bloqueado volvi\xF3 a operar. | live | s\xED |\n| `scheduled_message.group_delivery_failed` | Un env\xEDo programado fall\xF3 en un grupo concreto, con el motivo desagregado. | live | s\xED |\n| `scheduled_message.response_received` | Alguien respondi\xF3 a un env\xEDo programado votando una encuesta o respondiendo a un evento. | live | s\xED |\n| `campaign.status_changed` | Una campa\xF1a se activ\xF3 o se paus\xF3. | live | s\xED |\n| `pipeline.finished` | Termin\xF3 un pipeline de operaciones sobre grupos, con su resultado agregado y la clave de idempotencia del pedido que lo origin\xF3, cuando la hay. | live | s\xED |\n| `poll.voted` | Un participante vot\xF3 en una encuesta enviada a un grupo, o cambi\xF3 su voto. | live | s\xED |\n| `group.invite_link_renewed` | El enlace de invitaci\xF3n de un grupo cambi\xF3 por una renovaci\xF3n, autom\xE1tica al detectarse inv\xE1lido o pedida por el usuario; en v0 no se publican los cambios que detecta la sincronizaci\xF3n ni el enlace inicial al crear el grupo. | live | s\xED |\n| `group.invite_link_down` | El enlace de invitaci\xF3n de un grupo qued\xF3 confirmado ca\xEDdo (WhatsApp respondi\xF3 que no existe y la renovaci\xF3n no consigui\xF3 otro); no se publica por errores transitorios ni por grupos prohibidos o suspendidos, y se publica una sola vez por episodio de bloqueo. | live | s\xED |\n| `webhook.test` | Evento de prueba que se env\xEDa al pedir la prueba de una suscripci\xF3n; no se puede suscribir a \xE9l. | live | no |\n\nLos tipos con estado `planned` todav\xEDa no se emiten y no se pueden suscribir: pedirlos en `events` responde `422`. `webhook.test` no se suscribe: llega solo al pedir la prueba de una suscripci\xF3n.\n\n### Ejemplos\n\n#### `group.created`\n\n```json\n{\n "id": "evt_00000000000000000000000000000001",\n "type": "group.created",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "group": {\n "uid": "120363000000000001@g.us",\n "name": "Grupo de ejemplo",\n "type": "group"\n },\n "campaign": {\n "uuid": "00000000-0000-4000-8000-000000000001",\n "name": "Campa\xF1a de ejemplo"\n },\n "source": "created"\n }\n}\n```\n\n#### `group_participant.joined`\n\n```json\n{\n "id": "evt_00000000000000000000000000000002",\n "type": "group_participant.joined",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "group": {\n "uid": "120363000000000001@g.us",\n "name": "Grupo de ejemplo"\n },\n "campaign": {\n "uuid": "00000000-0000-4000-8000-000000000001"\n },\n "participant": {\n "identity": "+5491100000000",\n "identity_type": "phone",\n "name": "Ana P\xE9rez"\n }\n }\n}\n```\n\n#### `group_participant.left`\n\n```json\n{\n "id": "evt_00000000000000000000000000000003",\n "type": "group_participant.left",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "group": {\n "uid": "120363000000000001@g.us",\n "name": "Grupo de ejemplo"\n },\n "campaign": {\n "uuid": "00000000-0000-4000-8000-000000000001"\n },\n "participant": {\n "identity": "+5491100000000",\n "identity_type": "phone",\n "name": "Ana P\xE9rez"\n }\n }\n}\n```\n\n#### `scheduled_message.finished`\n\n```json\n{\n "id": "evt_00000000000000000000000000000004",\n "type": "scheduled_message.finished",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "scheduled_message": {\n "uuid": "00000000-0000-4000-8000-000000000004",\n "name": "Env\xEDo de ejemplo"\n },\n "outcome": "partially_failed",\n "groups": {\n "total": 10,\n "failed": 1,\n "partially_failed": 2\n },\n "occurrence": 1\n }\n}\n```\n\n#### `group.blocked`\n\n```json\n{\n "id": "evt_00000000000000000000000000000005",\n "type": "group.blocked",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "group": {\n "uid": "120363000000000001@g.us",\n "name": "Grupo de ejemplo"\n },\n "campaign": {\n "uuid": "00000000-0000-4000-8000-000000000001"\n },\n "reason": "admin_access_lost"\n }\n}\n```\n\n#### `group.recovered`\n\n```json\n{\n "id": "evt_00000000000000000000000000000006",\n "type": "group.recovered",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "group": {\n "uid": "120363000000000001@g.us",\n "name": "Grupo de ejemplo"\n },\n "campaign": {\n "uuid": "00000000-0000-4000-8000-000000000001"\n },\n "reason": "access_restored"\n }\n}\n```\n\n#### `scheduled_message.group_delivery_failed`\n\n```json\n{\n "id": "evt_00000000000000000000000000000007",\n "type": "scheduled_message.group_delivery_failed",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "scheduled_message": {\n "uuid": "00000000-0000-4000-8000-000000000007"\n },\n "group": {\n "uid": "120363000000000001@g.us",\n "name": "Grupo de ejemplo"\n },\n "reason": "device_disconnected",\n "partial": false,\n "occurrence": 1\n }\n}\n```\n\n#### `scheduled_message.response_received`\n\n```json\n{\n "id": "evt_00000000000000000000000000000008",\n "type": "scheduled_message.response_received",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "scheduled_message": {\n "uuid": "00000000-0000-4000-8000-000000000008"\n },\n "group": {\n "uid": "120363000000000001@g.us"\n },\n "participant": {\n "identity": "+5491100000000",\n "identity_type": "phone",\n "name": "Ana P\xE9rez"\n },\n "response": {\n "type": "poll",\n "options": [\n "Opci\xF3n A"\n ],\n "rsvp": null\n }\n }\n}\n```\n\n#### `campaign.status_changed`\n\n```json\n{\n "id": "evt_00000000000000000000000000000009",\n "type": "campaign.status_changed",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "campaign": {\n "uuid": "00000000-0000-4000-8000-000000000009",\n "name": "Campa\xF1a de ejemplo"\n },\n "from": "active",\n "to": "paused"\n }\n}\n```\n\n#### `pipeline.finished`\n\n```json\n{\n "id": "evt_0000000000000000000000000000000a",\n "type": "pipeline.finished",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "campaign": {\n "uuid": "00000000-0000-4000-8000-00000000000a"\n },\n "pipeline": {\n "uuid": "00000000-0000-4000-8000-00000000000b",\n "name": "Pipeline de ejemplo"\n },\n "outcome": "completed",\n "actions": {\n "total": 5,\n "succeeded": 4,\n "failed": 0,\n "skipped": 1,\n "cancelled": 0\n },\n "idempotency_key": "00000000-0000-4000-8000-00000000000d"\n }\n}\n```\n\n#### `poll.voted`\n\n```json\n{\n "id": "evt_0000000000000000000000000000000b",\n "type": "poll.voted",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "poll": {\n "uid": "3EB0000000000000000001"\n },\n "group": {\n "uid": "120363000000000001@g.us"\n },\n "scheduled_message": {\n "uuid": "00000000-0000-4000-8000-000000000004"\n },\n "participant": {\n "identity": "+5491100000000",\n "identity_type": "phone",\n "name": null\n },\n "options": [\n "Opci\xF3n A"\n ]\n }\n}\n```\n\n#### `group.invite_link_renewed`\n\n```json\n{\n "id": "evt_0000000000000000000000000000000c",\n "type": "group.invite_link_renewed",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "group": {\n "uid": "120363000000000001@g.us",\n "name": "Grupo de ejemplo",\n "type": "group"\n },\n "campaign": {\n "uuid": "00000000-0000-4000-8000-000000000001",\n "name": "Campa\xF1a de ejemplo"\n },\n "invite_link": "https://chat.whatsapp.com/EjemploNuevo0000000000001",\n "previous_invite_link": "https://chat.whatsapp.com/EjemploAnterior000000000001",\n "trigger": "automatic",\n "recovered": false\n }\n}\n```\n\n#### `group.invite_link_down`\n\n```json\n{\n "id": "evt_0000000000000000000000000000000d",\n "type": "group.invite_link_down",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "group": {\n "uid": "120363000000000001@g.us",\n "name": "Grupo de ejemplo",\n "type": "group"\n },\n "campaign": {\n "uuid": "00000000-0000-4000-8000-000000000001",\n "name": "Campa\xF1a de ejemplo"\n },\n "reason": "http_404",\n "renewal_attempted": true\n }\n}\n```\n\n### No confundir con los webhooks de campa\xF1a\n\nLas rutas `campaigns/{campaign}/webhooks` son un producto distinto (los webhooks legados de campa\xF1a), con otro formato, y **no reciben** estos eventos. Los eventos de esta secci\xF3n se reciben solo a trav\xE9s de `/api/v1/webhooks`.\n\n## Acciones de grupo\n\n### Modelo as\xEDncrono\n\nLas acciones sobre grupos no se ejecutan dentro de la llamada: se aceptan y se ejecutan despu\xE9s, al ritmo del n\xFAmero de WhatsApp. La respuesta es `202` con:\n\n```json\n{"action_id": "00000000-0000-4000-8000-000000000001", "status": "queued"}\n```\n\nPara saber c\xF3mo termin\xF3, consulta `GET /api/v1/groups/actions/{action_id}`. El estado es `queued`, `running`, `completed` o `failed`. Tambi\xE9n recibes un aviso por webhook, `group_action.completed` o `group_action.failed`, con el mismo `action_id` como `action.uuid` (solo si tienes una suscripci\xF3n a ese tipo).\n\n### Resultado por tipo de acci\xF3n\n\n- Acciones con lista de tel\xE9fonos (sumar o sacar participantes, promover o quitar administradores): siempre terminan `completed`, y `result` trae una entrada por persona con `added`, `invited`, `removed`, `promoted`, `demoted` o `failed` (con su `reason`). Que la acci\xF3n termine `completed` no quiere decir que cada persona haya salido bien: revisa cada una.\n- `create_group` y `renew_invite_link`: `result` es `{"invite_link": "..."}`. Al crear con un n\xFAmero oficial, `join_approval_required: true` crea el grupo pidiendo aprobaci\xF3n para entrar (WhatsApp no permite cambiarlo despu\xE9s); con un n\xFAmero QR responde `422 provider_unsupported`.\n- `approve_join_requests` y `reject_join_requests` (`POST /api/v1/groups/{group}/join-requests/approve` y `.../reject`, con `device_id` y `phones`): siempre terminan `completed`, y `result.participants` trae una entrada por tel\xE9fono, en el orden del pedido, con `status` `approved` o `rejected` (o `failed`) y su `reason`. Los motivos de `failed` son `join_request_not_found` (esa persona no tiene una solicitud pendiente), `capacity_full` (ya no hay lugar en el grupo), `provider_error` y `provider_unsupported`.\n- `GET /api/v1/groups/{group}/join-requests?device_id=`: no es una acci\xF3n, se lee en vivo. Responde `{"data": [{"participant": {"identity": "...", "identity_type": "phone", "name": null}, "requested_at": "2026-01-01T00:00:00Z"}]}` con las solicitudes pendientes. `requested_at` puede ser `null`.\n- `send_message`: `media_url` admite hasta 255 caracteres. `result` es `{"message": {"uid": "..."}}`, el id del mensaje en WhatsApp.\n- Las dem\xE1s (nombre, descripci\xF3n, foto, ajustes): `result` es `{}`.\n\n### Qu\xE9 hace cada n\xFAmero\n\n| Capacidad | N\xFAmero QR | N\xFAmero oficial (con la beta de grupos de la API oficial) |\n|---|---|---|\n| Crear grupo | s\xED | s\xED |\n| Sumar participantes | directo, al grupo | por invitaci\xF3n: cada persona recibe la plantilla de invitaci\xF3n elegida por la cuenta |\n| Sacar participantes | s\xED | s\xED |\n| Administradores (promover y quitar) | s\xED | no |\n| Foto | s\xED | no |\n| Ajustes (`members_can_send_messages`) | s\xED | no |\n| Link de invitaci\xF3n (leer y renovar) | s\xED | s\xED |\n| Solicitudes para unirse (listar) | no | s\xED |\n| Solicitudes para unirse (aprobar y rechazar) | no | s\xED, pero mientras la plataforma no habilite las decisiones responden `422 provider_unsupported` |\n| Enviar mensaje (`text`, `image`, `video`, `audio`, `document`) | s\xED | s\xED (requiere adem\xE1s el chat de grupos habilitado) |\n\nCuando el n\xFAmero no permite la acci\xF3n, o es oficial y la cuenta no tiene la beta, la respuesta es `422` con `error: provider_unsupported`.\n\n### Grupos que todav\xEDa no est\xE1n en la cuenta\n\n`GET /api/v1/devices/{device}/groups` lista los grupos reales de un n\xFAmero QR. Para escribirle a uno que todav\xEDa no est\xE1 en la cuenta hay dos caminos, y los dos lo importan igual que el panel: queda visible en el panel, se sincronizan sus participantes y desde entonces genera los avisos `group_participant.joined` y `group_participant.left`.\n\n- **Enviarle directamente.** `POST /api/v1/groups/{group}/messages` con el `device_id` del n\xFAmero importa el grupo y sigue con el env\xEDo, con las mismas reglas de siempre. Pide **Grupos y campa\xF1as \u2192 Administrar grupos** adem\xE1s de **Grupos y campa\xF1as \u2192 Enviar**. Si el n\xFAmero no es administrador del grupo no se importa nada y responde `422 device_not_group_admin`.\n- **Importarlo sin escribirle.** `POST /api/v1/groups/{group}/import` con `device_id` (pide **Grupos y campa\xF1as \u2192 Administrar grupos**). Responde `201` si lo import\xF3 y `200` si ya estaba en la cuenta, con `is_admin`: si el n\xFAmero es administrador del grupo seg\xFAn WhatsApp.\n\nSi el n\xFAmero es administrador lo decide WhatsApp, le\xEDdo en la misma llamada; no se toma de lo que mande el pedido. Solo con n\xFAmeros QR: con un n\xFAmero oficial, importar responde `422 provider_unsupported`. Si el grupo no es del n\xFAmero, `404 group_not_found`. Cada n\xFAmero importa hasta **5 grupos por minuto** (`429 import_rate_limited`). Dos pedidos a la vez por el mismo grupo lo importan una sola vez.\n\n### Errores\n\nLos errores traen `{"error": "<c\xF3digo>", "message": "..."}`.\n\n| `error` | Estado | Significado |\n|---|---|---|\n| `device_not_found` | 404 | El n\xFAmero no existe en tu cuenta. |\n| `group_not_found` | 404 | El grupo no existe en tu cuenta. |\n| `action_not_found` | 404 | La acci\xF3n no existe o no es de tu cuenta (`GET /api/v1/groups/actions/{action_id}`). |\n| `unsupported_group_type` | 422 | El tipo de grupo no admite la acci\xF3n. |\n| `device_not_connected` | 422 | El n\xFAmero no est\xE1 conectado. |\n| `provider_unsupported` | 422 | El n\xFAmero no permite la acci\xF3n; trae `requires` con lo que hace falta. Aprobar y rechazar solicitudes para unirse responden esto mientras la habilitaci\xF3n de decisiones est\xE9 apagada. |\n| `device_not_group_admin` | 422 | El n\xFAmero no es administrador del grupo. |\n| `device_not_group_number` | 422 | El n\xFAmero no es el del grupo. |\n| `cannot_target_executing_device` | 422 | No se puede apuntar al tel\xE9fono del propio n\xFAmero que ejecuta. |\n| `cannot_target_account_device` | 422 | No se puede apuntar a otro n\xFAmero de tu cuenta. |\n| `not_a_participant` | 422 | El tel\xE9fono no es miembro conocido del grupo (promover o quitar administrador). |\n| `invite_template_not_configured` | 422 | La cuenta no eligi\xF3 la plantilla de invitaci\xF3n (n\xFAmero oficial). |\n| `max_phones` | 422 | Hay m\xE1s tel\xE9fonos que el tope por pedido, una vez quitados los repetidos. |\n| `unsupported_type` | 422 | El n\xFAmero oficial no puede enviar ese tipo de mensaje por la v\xEDa oficial. |\n| `capacity_full` | 422 | El grupo no tiene lugares libres; tambi\xE9n al aprobar solicitudes para unirse cuando los tel\xE9fonos pedidos no entran en los lugares libres (rechazar no lo verifica). |\n| `invite_capacity_full` | 422 | Los lugares libres ya est\xE1n comprometidos con invitaciones activas (n\xFAmero oficial). |\n| `duplicate_invite` | 422 | Esa persona ya tiene una invitaci\xF3n activa. |\n| `window_closed` | 422 | La ventana de servicio de 24 horas est\xE1 cerrada (n\xFAmero oficial). |\n| `number_restricted` | 422 | La calidad del n\xFAmero impide enviar (n\xFAmero oficial). |\n| `too_many_pending_actions` | 429 | El n\xFAmero ya tiene el m\xE1ximo de acciones abiertas. |\n| `import_permission_required` | 403 | El grupo no est\xE1 en la cuenta y la API key no tiene el permiso para importarlo. |\n| `import_rate_limited` | 429 | El n\xFAmero ya import\xF3 el m\xE1ximo de grupos del \xFAltimo minuto; trae `retry_after` en segundos. |\n| `import_in_progress` | 409 | Otro pedido est\xE1 importando ese mismo grupo. |\n| `provider_error` | 502 | Fall\xF3 el proveedor de WhatsApp. |\n| (plan) | 402 | El plan de la cuenta no incluye la acci\xF3n o alcanz\xF3 su tope. |\n| `route_not_available` | 403 | Las acciones de grupo por API no est\xE1n habilitadas en este entorno. |\n\n### Topes y ritmo\n\n- Hasta **20 tel\xE9fonos** por pedido. En un n\xFAmero oficial, adem\xE1s, caben solo los lugares libres del tope de 8 participantes menos las invitaciones activas.\n- Hasta **50 acciones abiertas** (`queued` o `running`) por n\xFAmero.\n- **30 pedidos de escritura por minuto**, y **30 lecturas en vivo por minuto** (link de invitaci\xF3n y solicitudes para unirse).\n- Ritmo de campa\xF1as: sumar participantes se hace de a uno, y entre creaciones de grupos hay una espera. Es el mismo ritmo de las campa\xF1as del n\xFAmero y no se puede acelerar desde la API.\n- Los env\xEDos de mensajes salen al ritmo de los mensajes programados de velocidad Low.\n- Enviar mensajes por esta v\xEDa no tiene tope de volumen del plan.\n\n### Permisos\n\n- Pedir una acci\xF3n (crear, sumar o sacar participantes, administradores, nombre, descripci\xF3n, foto, ajustes, renovar el link, aprobar y rechazar solicitudes para unirse): **Grupos y campa\xF1as \u2192 Administrar grupos**.\n- Enviar un mensaje al grupo: **Grupos y campa\xF1as \u2192 Enviar**. Si el grupo todav\xEDa no est\xE1 en la cuenta, adem\xE1s **Grupos y campa\xF1as \u2192 Administrar grupos**.\n- Importar un grupo: **Grupos y campa\xF1as \u2192 Administrar grupos**.\n- Consultar una acci\xF3n, leer el link de invitaci\xF3n y listar las solicitudes para unirse: **Grupos y campa\xF1as \u2192 Ver**.\n\n**Grupos y campa\xF1as \u2192 Administrar grupos** no incluye **Grupos y campa\xF1as \u2192 Ver**: para pedir una acci\xF3n y consultar su resultado hacen falta los dos permisos.\n\n### Avisos de lo que pasa en el grupo\n\nAdem\xE1s del resultado de tus acciones, puedes suscribirte a lo que ocurre adentro de los grupos: `group.message_received`, `group.reaction_added`, `group_participant.joined`, `group_participant.left`, `poll.voted`, `group_join_request.created` y `group_join_request.revoked`. Salen tambi\xE9n para grupos que no pertenecen a una campa\xF1a (sin campa\xF1a, `campaign_uuid` es `null`); cuando el grupo es de una campa\xF1a, el aviso lleva su `campaign_uuid`, fuera de `data`. Solo se generan si tienes una suscripci\xF3n a ese tipo.\n\n`group.message_received` y `group.reaction_added` reflejan lo que report\xF3 el proveedor de WhatsApp; la autor\xEDa no se verifica de forma independiente. `group_join_request.created` y `group_join_request.revoked` avisan cuando alguien pide entrar a un grupo oficial y cuando retira esa solicitud; llevan `group` y `participant` (`identity`, `identity_type`, `name`), y `participant.identity_type` es `whatsapp_user` cuando la persona se identifica por BSUID o por un identificador de 14 o m\xE1s d\xEDgitos que no es un tel\xE9fono.\n\n### L\xEDmites conocidos\n\n- En un n\xFAmero QR, renovar el link relee el link vigente y **no lo revoca**.\n- `added` significa que WhatsApp acept\xF3 la operaci\xF3n; no garantiza que la persona siga en el grupo.\n- Un `create_group` en un n\xFAmero oficial sin confirmaci\xF3n de WhatsApp termina `failed` con `creation_unconfirmed`, aunque el grupo pueda existir. Consulta `GET /api/v1/groups` antes de reintentar.\n- La lista de solicitudes para unirse trae como m\xE1ximo **100** entradas, en el orden del proveedor, y solo la primera p\xE1gina. Las solicitudes sin tel\xE9fono (identificadas solo por BSUID) no aparecen.\n- Aprobar una solicitud no agrega a la persona a la lista de participantes: el alta y el aviso `group_participant.joined` llegan por el aviso de altas de WhatsApp. Una persona cuya solicitud est\xE1 m\xE1s all\xE1 de la primera p\xE1gina que devuelve el proveedor queda `failed` con `join_request_not_found`.\n- No hay clave de idempotencia: reintentar despu\xE9s de un `5xx` puede duplicar la acci\xF3n.\n- En `group.message_received`, `media_url` puede ser `null` en un grupo QR, o ser una URL temporal del proveedor.'
51218
51503
  },
51219
51504
  {
51220
51505
  domain: "accounts",
51221
51506
  title: "Funnelchat Accounts \u2014 API",
51222
51507
  version: "0.1.0",
51223
51508
  spec: "specs/accounts.json",
51224
- spec_changes: "La especificaci\xF3n del \xE1rea con `servers` en la URL base p\xFAblica y, en el texto, cada permiso nombrado como en el panel (\xAB\xC1rea \u2192 Casilla\xBB), sin el nombre t\xE9cnico; el nombre t\xE9cnico queda en `x-fc-permission`.",
51509
+ spec_changes: "La especificaci\xF3n del \xE1rea con `servers` en la URL base p\xFAblica y, en el texto, cada permiso nombrado como en el panel (\xAB\xC1rea \u2192 Casilla\xBB), sin el nombre t\xE9cnico; el nombre t\xE9cnico queda en `x-fc-permission`. En la publicada, cada operaci\xF3n lleva el nombre y el grupo del portal.",
51225
51510
  operations: 76,
51226
51511
  excluded_routes: 0,
51227
51512
  description: 'La API p\xFAblica de **Funnelchat Accounts**: los n\xFAmeros de WhatsApp de la cuenta\n(devices), sus alertas operativas, los dominios propios y los datos de la cuenta.\n\n### C\xF3mo autenticarse\n\nCada llamada viaja con la clave de API en el header `Authorization`:\n\n```\nAuthorization: Bearer <id>|fc_live_...\n```\n\nLas claves se emiten y se revocan desde la pantalla **Desarrolladores** del panel.\nEl valor completo se muestra **una sola vez**, al crearla.\n\n### Permisos\n\nCada ruta de esta documentaci\xF3n declara el permiso que exige, con el nombre que tiene\nen la pantalla **Desarrolladores** del panel: el \xE1rea y la casilla que se marca al\ncrear la clave. La clave s\xF3lo puede llamar las rutas para las que se le otorg\xF3 el\npermiso al crearla:\n\n| Permiso | Qu\xE9 habilita |\n|---|---|\n| **N\xFAmeros de WhatsApp \u2192 Ver** | Leer. Todas las rutas `GET` salvo las de las suscripciones unificadas. |\n| **N\xFAmeros de WhatsApp \u2192 Editar** | Modificar de forma reversible. |\n| **N\xFAmeros de WhatsApp \u2192 Crear, conectar y borrar** | Las cinco operaciones **irreversibles** (reset, limpieza, purga de cola y los dos borrados) y las que **dan el control de un WhatsApp**: crear un n\xFAmero, pedir su QR y crear o revocar un link de alta. |\n| **Avisos (webhooks) \u2192 Ver** | Leer las suscripciones de webhook unificadas y sus entregas. |\n| **Avisos (webhooks) \u2192 Administrar** | Crear, modificar, probar y borrar suscripciones. Incluye **Avisos (webhooks) \u2192 Ver**. |\n\n**N\xFAmeros de WhatsApp \u2192 Crear, conectar y borrar** es una casilla aparte, no un escal\xF3n m\xE1s alto: **N\xFAmeros de WhatsApp \u2192 Editar**\n**no** habilita las irreversibles, y **N\xFAmeros de WhatsApp \u2192 Crear, conectar y borrar** no habilita nada m\xE1s.\n\nLos permisos de **Avisos (webhooks)** y los de **N\xFAmeros de WhatsApp** no se incluyen entre s\xED.\nAdem\xE1s, para crear o modificar una suscripci\xF3n con una clave, la clave necesita el\npermiso de lectura del \xE1rea de cada tipo de evento (**N\xFAmeros de WhatsApp \u2192 Ver**,\n**Contactos y conversaciones \u2192 Ver** o **Grupos y campa\xF1as \u2192 Ver**; de esa misma \xE1rea tambi\xE9n sirven\n**N\xFAmeros de WhatsApp \u2192 Editar**, **Contactos y conversaciones \u2192 Editar**, **Contactos y conversaciones \u2192 Enviar**, **Grupos y campa\xF1as \u2192 Editar** o\n**Grupos y campa\xF1as \u2192 Enviar**); si le falta alguno, responde `422`.\n\nLeer, cambiar, borrar o probar una suscripci\xF3n con una clave sigue la misma regla de\n\xE1reas: para verla (`GET api/v1/webhooks/{id}`), ver su historial de entregas\n(`GET api/v1/webhooks/{id}/deliveries`), cambiarla (`PUT`), borrarla (`DELETE`) o\nprobarla (`POST api/v1/webhooks/{id}/test`), la clave necesita el permiso de lectura\ndel \xE1rea de cada tipo de evento que la suscripci\xF3n tiene ahora; si le falta alguno\nresponde `403 permission_denied`, el mensaje nombra el permiso que falta como en el\npanel y no se\ncambia, borra ni env\xEDa nada. El listado (`GET api/v1/webhooks`) muestra solo las\nsuscripciones que la clave puede leer, y el historial muestra solo las entregas de\ntipos cuya \xE1rea la clave puede leer (si la suscripci\xF3n estuvo suscripta antes a otra\n\xE1rea, esas filas no aparecen); las entregas de prueba (`webhook.test`) se muestran\nsiempre.\n\n### Lo que no est\xE1 ac\xE1 no existe para una clave\n\nLa superficie es cerrada. Una ruta que no figure en esta documentaci\xF3n responde\n`403 route_not_available` a cualquier clave, sin importar qu\xE9 permisos tenga \u2014 y eso\nincluye la facturaci\xF3n, la cuota de IA y la propia pantalla de claves.\n\n### Webhooks\n\nLas suscripciones se administran en `api/v1/webhooks`, el sistema unificado: cada\nsuscripci\xF3n tiene un solo secreto y un solo historial de entregas, y recibe eventos de\nlas tres \xE1reas (`accounts`, `conversations` y `communities`). Rotar el secreto y\n\xABActivar\xBB una suscripci\xF3n migrada se hacen desde el panel. `api/v1/webhooks/endpoints`\nes el sistema anterior y sigue disponible con los permisos de **N\xFAmeros de WhatsApp**.\n\nUna suscripci\xF3n queda atada a la clave de API que la cre\xF3, o a la \xFAltima clave que\n**cambi\xF3 su direcci\xF3n** (`url`), aunque la haya creado el panel u otra clave: as\xED, si\nuna clave robada redirige una suscripci\xF3n, revocarla corta ese destino. Cuando se\nrevoca la clave a la que est\xE1 atada, la suscripci\xF3n deja de recibir eventos de\ninmediato y pasa a `status: disabled` con `disabled_reason: key_revoked`. No se puede\nvolver a habilitar: hay que crear una suscripci\xF3n nueva. Cambiar la direcci\xF3n desde el\npanel no la desata de la clave. Una suscripci\xF3n que no est\xE1 atada a ninguna clave\n(creada desde el panel y sin que ninguna clave le haya cambiado la direcci\xF3n) es de la\ncuenta: revocar claves no la afecta.\n\nEl cat\xE1logo (`GET api/v1/webhooks/events`) lista todos los eventos de las tres \xE1reas\n(`accounts`, `conversations` y `communities`). En cada entrada, `status` dice si el\nevento est\xE1 habilitado (`active`) o si se puede suscribir pero todav\xEDa no emite\n(`planned`), y `example` trae el cuerpo completo que vas a recibir. Al final de esta\np\xE1gina, en \xABEjemplos de payload de webhooks\xBB, est\xE1n los ejemplos de los eventos\nactivos del \xE1rea `accounts` a los que te puedes suscribir; los de `conversations` y\n`communities` est\xE1n en el cat\xE1logo.\n\nSobre los siete eventos de los n\xFAmeros de la cuenta (todos traen el bloque `device`\ncon `id`, `name`, `number`, `connection` \u2014`qr`, `coexistence` o `dedicated`\u2014 y\n`external_reference`, y `connect_link_id`):\n\n- `device.connected`: un n\xFAmero de la cuenta qued\xF3 conectado. Cubre dos caminos: la\n conexi\xF3n por c\xF3digo QR y el alta de un n\xFAmero de WhatsApp Cloud. Trae\n `connect_link_id`: el link de alta que hizo esta conexi\xF3n, o `null`. No se emite por un\n cambio manual hecho por el soporte de Funnelchat, ni cuando se bloquea un n\xFAmero de\n prueba que ya se us\xF3 en otra cuenta, ni dos veces por la misma conexi\xF3n.\n- `device.disconnected`: un n\xFAmero perdi\xF3 la conexi\xF3n. `reason` dice por qu\xE9:\n `qr_logged_out` (se cerr\xF3 la sesi\xF3n de un n\xFAmero por QR), `meta_account_not_registered`\n (un n\xFAmero oficial dej\xF3 de estar registrado en Meta y no se pudo volver a registrar\n solo), `shadow_ban_protection` (Funnelchat lo desconect\xF3 para protegerlo de un posible\n bloqueo), `disconnected_by_user` (alguien de la cuenta us\xF3 \xABDesconectar\xBB o \xABBorrar\n datos\xBB en un n\xFAmero oficial), `provider_error` o `unknown`.\n- En `device.disconnected`, `device.deleted` y los avisos de la salud de un n\xFAmero\n oficial, `connect_link_id` es el link de alta que conect\xF3 el n\xFAmero por \xFAltima vez, o\n `null`.\n- `device.deleted`: un n\xFAmero se borr\xF3 (ver \xABAlta y conexi\xF3n de n\xFAmeros\xBB).\n- `device.health_changed`: cambi\xF3 el estado de env\xEDo de un n\xFAmero oficial seg\xFAn Meta\n (`previous_state` y `state`: `available`, `limited`, `blocked` o `unknown`). Sale solo\n cuando el estado cambia, no con cada aviso de Meta; el motivo se consulta en\n `GET api/v1/devices/{device}/health`.\n- `device.quality_changed`: cambi\xF3 la calidad que Meta le da a un n\xFAmero oficial\n (`previous` y `current`: `GREEN`, `YELLOW` o `RED`). Si baja, conviene frenar los env\xEDos\n de ese n\xFAmero antes de que Meta lo limite.\n- `device.messaging_limit_changed`: cambi\xF3 el l\xEDmite de mensajes por d\xEDa de un n\xFAmero\n oficial (`previous` y `current`: `TIER_250`, `TIER_1K`, `TIER_2K`, `TIER_10K`,\n `TIER_100K` o `TIER_UNLIMITED`). Meta fija el l\xEDmite para todos los n\xFAmeros de una\n misma empresa: llega un aviso por cada n\xFAmero de la cuenta que cambi\xF3.\n- `device.display_name_reviewed`: Meta aprob\xF3 o rechaz\xF3 el nombre visible de un n\xFAmero\n oficial (`decision`: `approved` o `rejected`). Trae los estados de Meta antes y despu\xE9s\n (`previous_status` y `status`), el nombre visible actual (`verified_name`) y, en un\n rechazo, el motivo de Meta (`rejection_reason`) cuando lo informa en su aviso; si no,\n `null`.\n- `device.quality_changed`, `device.messaging_limit_changed` y\n `device.display_name_reviewed` salen una vez por cambio real: no se repiten si Meta\n vuelve a informar lo mismo ni cuando alguien toca \xABActualizar ahora\xBB y no cambi\xF3 nada, y\n no salen cuando el valor pasa de o a \xABsin datos\xBB (`NA`, `UNKNOWN`, `TIER_NOT_SET`). Si\n Meta no avisa de un cambio, Funnelchat lo detecta en su revisi\xF3n peri\xF3dica y el aviso\n puede llegar con unos minutos de demora. Como `device.health_changed`, no llegan a las\n suscripciones filtradas por campa\xF1a: no pertenecen a ninguna.\n\n#### Sobre\n\nCada entrega es un `POST` con cuerpo JSON `{id, type, version, occurred_at, data}`:\n`id` identifica el hecho (es el mismo en todos los reintentos y en todos tus endpoints\nsuscriptos al mismo evento, as\xED que sirve para deduplicar), `type` es el nombre del\nevento, `version` es la versi\xF3n de ese tipo de evento y `occurred_at` es UTC.\n\n#### Cabeceras\n\n- `X-Funnelchat-Signature`: la firma (ver abajo).\n- `X-Funnelchat-Event`: el tipo de evento.\n- `X-Funnelchat-Delivery`: un UUID que cambia en cada intento de entrega.\n\n#### Firma\n\nLa firma es `t=<unix>,v1=<hex>`, donde `v1` es el HMAC-SHA256 de `"<t>.<cuerpo crudo>"`\ncon el secreto del endpoint (completo, con el prefijo `whsec_`). Verifica sobre el cuerpo\n**crudo**, antes de parsear el JSON, y rechaza las entregas con m\xE1s de 300 segundos de\ndiferencia. M\xE1s detalle en `/docs/webhooks/#firma`.\n\nPHP:\n\n```php\nfunction verificarFirma(string $cuerpoCrudo, string $cabecera, string $secreto, int $tolerancia = 300): bool\n{\n parse_str(str_replace(\',\', \'&\', $cabecera), $partes);\n\n if (! isset($partes[\'t\'], $partes[\'v1\']) || ! ctype_digit($partes[\'t\'])) {\n return false;\n }\n\n if (abs(time() - (int) $partes[\'t\']) > $tolerancia) {\n return false;\n }\n\n $esperada = hash_hmac(\'sha256\', $partes[\'t\'].\'.\'.$cuerpoCrudo, $secreto);\n\n return hash_equals($esperada, $partes[\'v1\']);\n}\n```\n\nNode:\n\n```js\nconst crypto = require(\'crypto\');\n\nfunction verificarFirma(cuerpoCrudo, cabecera, secreto, tolerancia = 300) {\n const partes = Object.fromEntries(cabecera.split(\',\').map((p) => p.split(\'=\')));\n if (!/^\\d+$/.test(partes.t || \'\') || !partes.v1) return false;\n\n if (Math.abs(Date.now() / 1000 - Number(partes.t)) > tolerancia) return false;\n\n const esperada = crypto\n .createHmac(\'sha256\', secreto)\n .update(`${partes.t}.${cuerpoCrudo}`)\n .digest(\'hex\');\n\n const a = Buffer.from(esperada);\n const b = Buffer.from(partes.v1);\n\n return a.length === b.length && crypto.timingSafeEqual(a, b);\n}\n```\n\n#### Reintentos\n\nUna entrega es exitosa con cualquier `2xx`. Ante un error de red, un `5xx` o un `429` se\nreintenta hasta 4 intentos en total, esperando 60 segundos, 5 minutos y 30 minutos entre\nellos. Cualquier otra respuesta (`3xx`, `4xx` salvo `429`) no se reintenta. Un endpoint que\nacumula 5 entregas fallidas consecutivas (agotadas o con una respuesta que no se\nreintenta) se deshabilita autom\xE1ticamente.\n\n#### Probar una suscripci\xF3n\n\n`POST api/v1/webhooks/{id}/test` env\xEDa una sola entrega firmada de `webhook.test`, sin\nreintentos, a una suscripci\xF3n `active` o `paused`, y la registra en su historial. Si la\nsuscripci\xF3n est\xE1 `disabled` responde `422` sin enviar ni registrar nada: si se\ndeshabilit\xF3 por entregas fallidas, react\xEDvala (`PUT` con `status: active`) y pru\xE9bala\ndespu\xE9s; si se revoc\xF3 la clave a la que estaba atada (`key_revoked`), crea una\nsuscripci\xF3n nueva. Con una clave, la regla de \xE1reas de \xABPermisos\xBB va primero: si falta\nun permiso responde `403`, aunque la suscripci\xF3n est\xE9 `disabled`.\n\n\n### Alta y conexi\xF3n de n\xFAmeros\n\nUn n\xFAmero se crea y se conecta por la API de dos formas:\n\n- **Por QR, desde tu sistema:** `POST api/v1/devices` con `connection: "qr"` y despu\xE9s\n `GET api/v1/devices/{device}/qr`, que devuelve la imagen del c\xF3digo en `qr_code` y en\n `expires_in` los segundos para volver a pedirlo. WhatsApp cambia el c\xF3digo cada pocos\n segundos y uno viejo no vincula: pide el QR cada `expires_in` segundos (hoy **5**, la\n cadencia del panel), muestra siempre el \xFAltimo y no lo guardes ni lo reenv\xEDes.\n- **Con un link de alta para tu cliente:** `POST api/v1/connect-links` devuelve una `url`\n de un solo uso (aparece **solo** en esa respuesta; tr\xE1tala como un secreto). Tu cliente\n la abre en su tel\xE9fono, en una p\xE1gina de Funnelchat, sin entrar al panel. Abrir la\n p\xE1gina no usa el link (la vista previa de un mensajero no lo gasta): se usa cuando tu\n cliente toca el bot\xF3n para conectar. Con `type: "qr"`, al tocarlo se crea el n\xFAmero y\n la p\xE1gina muestra el QR, que renueva sola cada 5 segundos; tu cliente tiene\n **10 minutos** para escanearlo y, si no llega, el link queda `failed` con `qr_timeout`.\n Con `type: "meta"` corre el alta oficial de Meta (el Embedded Signup del panel) y el\n n\xFAmero se crea al terminar, con la coexistencia solo si `allowed_connection_types` la\n permite. Mientras el alta oficial por link no est\xE9 habilitada, crear un link `meta`\n responde `503 meta_connect_disabled`.\n Con `device_id`, el link reconecta un n\xFAmero que ya existe y no ocupa un lugar nuevo.\n\nPara enterarte del resultado tienes tres v\xEDas: el webhook `device.connected` (trae\n`connect_link_id` y tu `external_reference`), la `return_url` del link y\n`GET api/v1/connect-links/{link}`. Al terminar, la p\xE1gina lleva a tu cliente a la\n`return_url` con el resultado **en la query string**, y nada m\xE1s: `connect_link_id`,\n`status` (`completed` o `failed`), `device_id` (solo si complet\xF3) y `error_code` (solo si\nfall\xF3). Por ejemplo,\n`https://tu-sistema.com/vuelta?connect_link_id=12&status=failed&error_code=qr_timeout`.\nSi tu `return_url` ya trae par\xE1metros, estos se suman a los tuyos.\n\nUsa `external_reference` para identificar a tu cliente en cada n\xFAmero: es texto libre de\nhasta 255 caracteres, no es \xFAnico, se manda al crear el n\xFAmero o el link, se cambia con\n`PUT api/v1/devices/{device}`, vuelve en las consultas y en todos los avisos `device.*`,\ny `GET api/v1/devices?external_reference=...` filtra por igualdad exacta.\n\nLas creaciones aceptan el header `Idempotency-Key` (de 1 a 255 caracteres): la misma clave\ncon el mismo cuerpo, dentro de las 24 horas, devuelve la misma respuesta sin crear otro\nrecurso. La misma clave con otro cuerpo responde `422 idempotency_key_mismatch`, y una\nclave vac\xEDa o de m\xE1s de 255 caracteres, `422 invalid_idempotency_key`.\n\nLo que conviene saber antes de usarlo:\n\n- **Cupo.** Cada n\xFAmero ocupa un lugar del plan, como en el panel, con los mismos\n rechazos. El link controla el cupo al crearlo y otra vez al usarlo: no reserva lugar.\n Una cuenta puede tener hasta 20 links pendientes; un link vive de 30 minutos a 7 d\xEDas\n (24 horas si no lo indicas) y muere si revocas la clave de API que lo cre\xF3.\n- **N\xFAmeros de EE. UU. del pool.** Un link oficial puede ofrecer un n\xFAmero de EE. UU. de\n Funnelchat (`provision_us_number`), con los mismos topes que el panel: hasta 3 por\n cuenta (menos si el plan tiene menos lugares), 1 en las cuentas de prueba, y el n\xFAmero\n queda retenido 15 minutos mientras se conecta. Es siempre un n\xFAmero dedicado: crear un\n link con `provision_us_number: true` exige `dedicated` en `allowed_connection_types` y\n no vale con `device_id` (una reconexi\xF3n conserva su n\xFAmero); si no, responde\n `422 invalid_request`.\n- **Errores del link oficial.** `quota_exceeded`, `trial_number_blocked` y\n `number_not_owned` cierran el link al primer intento. Con `facebook_auth_failed`\n (Facebook no valid\xF3 el inicio de sesi\xF3n) o `meta_error`, el link **sigue `pending`** y tu\n cliente puede intentarlo de nuevo con el mismo link; al quinto intento fallido pasa a\n `failed` con ese c\xF3digo.\n- **Pago de la cuenta.** El link vuelve a mirar el pago de tu cuenta al usarse: con el\n pago vencido queda `failed` con `quota_exceeded`.\n- **Un lugar que ya ten\xEDas.** Si el alta por QR de un link toma un lugar libre que tu\n cuenta ya ten\xEDa, `qr_timeout` no lo borra: queda como estaba, y reci\xE9n al conectarse\n recibe la `external_reference` del link.\n- **El cliente le paga a Meta.** Los mensajes de un n\xFAmero oficial los cobra Meta a la\n cuenta de WhatsApp Business del cliente, con su medio de pago: tiene que cargarlo en\n Meta. La API no agrega ning\xFAn cobro.\n- **Los n\xFAmeros por QR no son oficiales.** WhatsApp puede restringir o bloquear un n\xFAmero\n conectado por QR si env\xEDa mensajes que la gente no espera. Para volumen, usa un n\xFAmero\n oficial.\n- **Borrar un n\xFAmero** (`DELETE api/v1/devices/{device}`) es definitivo: la fila se borra\n y libera su lugar del plan. A un n\xFAmero oficial se le quita la suscripci\xF3n de la app de\n Funnelchat, pero **no** se da de baja en Meta (sigue siendo del cliente, tambi\xE9n si vino\n del pool); a uno por QR se le cierra la sesi\xF3n. Llega el aviso `device.deleted`.\n\n### Listados\n\nCon una clave, todo listado pagina. `perPage` va de 1 a 100 y vale 25 si no se\nenv\xEDa; m\xE1s de 100 (o algo que no sea un entero) responde `422`. Algunas rutas\naceptan adem\xE1s su nombre hist\xF3rico (`per_page`, `pageSize`, `limit`) con el\nmismo tope. El orden es fijo en cada ruta y est\xE1 indicado en ella: ning\xFAn\nlistado acepta par\xE1metros de ordenamiento (`orderBy`, `sort`, \u2026) y enviar uno\nresponde `422`. La sesi\xF3n web del panel no cambia.\n\n### Estado: v0\n\nEsta API est\xE1 en **v0**: puede cambiar sin aviso previo.\n\n- Algunas respuestas de rutas existentes incluyen identificadores internos\n (`user_id` en los devices, `owner_id` en los datos de la cuenta). No los uses\n como claves estables.\n- Las suscripciones de webhook atadas a una clave (creadas o redirigidas con ella)\n mueren con ella (ver \xABWebhooks\xBB).\n\n<!-- webhook-examples -->\n### Ejemplos de payload de webhooks\n\n`device.disconnected`:\n\n```json\n{\n "id": "evt_0f8e4b526c1d4c7a9a3e2b5d",\n "type": "device.disconnected",\n "version": 1,\n "occurred_at": "2026-10-05T03:00:00Z",\n "data": {\n "device": {\n "id": 4521,\n "name": "Ventas",\n "number": "5491100000000",\n "connection": "qr",\n "external_reference": "cliente-42"\n },\n "disconnected_at": "2026-10-05T03:00:00+00:00",\n "reason": "qr_logged_out",\n "connect_link_id": 812,\n "workspace_id": 37\n }\n}\n```\n\n`device.connected`:\n\n```json\n{\n "id": "evt_0f8e4b526c1d4c7a9a3e2b5d",\n "type": "device.connected",\n "version": 1,\n "occurred_at": "2026-10-05T03:00:00Z",\n "data": {\n "device": {\n "id": 4521,\n "name": "Ventas",\n "number": "5491100000000",\n "connection": "qr",\n "external_reference": "cliente-42"\n },\n "connected_at": "2026-10-05T03:00:00+00:00",\n "connect_link_id": 812,\n "workspace_id": 37\n }\n}\n```\n\n`device.deleted`:\n\n```json\n{\n "id": "evt_0f8e4b526c1d4c7a9a3e2b5d",\n "type": "device.deleted",\n "version": 1,\n "occurred_at": "2026-10-05T03:00:00Z",\n "data": {\n "device": {\n "id": 4521,\n "name": "Ventas",\n "number": "5491100000000",\n "connection": "qr",\n "external_reference": "cliente-42"\n },\n "deleted_at": "2026-10-05T03:00:00+00:00",\n "connect_link_id": 812,\n "workspace_id": 37\n }\n}\n```\n\n`device.health_changed`:\n\n```json\n{\n "id": "evt_0f8e4b526c1d4c7a9a3e2b5d",\n "type": "device.health_changed",\n "version": 1,\n "occurred_at": "2026-10-05T03:00:00Z",\n "data": {\n "device": {\n "id": 4522,\n "name": "Soporte",\n "number": "5491100000000",\n "connection": "dedicated",\n "external_reference": "cliente-42"\n },\n "previous_state": "available",\n "state": "limited",\n "changed_at": "2026-10-05T03:00:00+00:00",\n "connect_link_id": null,\n "workspace_id": 37\n }\n}\n```\n\n`device.quality_changed`:\n\n```json\n{\n "id": "evt_0f8e4b526c1d4c7a9a3e2b5d",\n "type": "device.quality_changed",\n "version": 1,\n "occurred_at": "2026-10-05T03:00:00Z",\n "data": {\n "device": {\n "id": 4522,\n "name": "Soporte",\n "number": "5491100000000",\n "connection": "dedicated",\n "external_reference": "cliente-42"\n },\n "previous": "GREEN",\n "current": "YELLOW",\n "changed_at": "2026-10-05T03:00:00+00:00",\n "connect_link_id": null,\n "workspace_id": 37\n }\n}\n```\n\n`device.messaging_limit_changed`:\n\n```json\n{\n "id": "evt_0f8e4b526c1d4c7a9a3e2b5d",\n "type": "device.messaging_limit_changed",\n "version": 1,\n "occurred_at": "2026-10-05T03:00:00Z",\n "data": {\n "device": {\n "id": 4522,\n "name": "Soporte",\n "number": "5491100000000",\n "connection": "dedicated",\n "external_reference": "cliente-42"\n },\n "previous": "TIER_1K",\n "current": "TIER_10K",\n "changed_at": "2026-10-05T03:00:00+00:00",\n "connect_link_id": null,\n "workspace_id": 37\n }\n}\n```\n\n`device.display_name_reviewed`:\n\n```json\n{\n "id": "evt_0f8e4b526c1d4c7a9a3e2b5d",\n "type": "device.display_name_reviewed",\n "version": 1,\n "occurred_at": "2026-10-05T03:00:00Z",\n "data": {\n "device": {\n "id": 4522,\n "name": "Soporte",\n "number": "5491100000000",\n "connection": "dedicated",\n "external_reference": "cliente-42"\n },\n "decision": "approved",\n "previous_status": "PENDING_REVIEW",\n "status": "APPROVED",\n "verified_name": "Soporte Lucky Shrub",\n "rejection_reason": null,\n "reviewed_at": "2026-10-05T03:00:00+00:00",\n "connect_link_id": null,\n "workspace_id": 37\n }\n}\n```\n\n`billing.payment_failed`:\n\n```json\n{\n "id": "evt_0f8e4b526c1d4c7a9a3e2b5d",\n "type": "billing.payment_failed",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "amount_cents": 2990,\n "currency": "USD",\n "workspace_id": 37\n }\n}\n```\n\n`billing.upcoming_charge`:\n\n```json\n{\n "id": "evt_0f8e4b526c1d4c7a9a3e2b5d",\n "type": "billing.upcoming_charge",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "charge_date": "2026-11-05T00:00:00Z",\n "amount_cents": 2990,\n "currency": "USD",\n "workspace_id": 37\n }\n}\n```\n\n`subscription.trial_ending`:\n\n```json\n{\n "id": "evt_0f8e4b526c1d4c7a9a3e2b5d",\n "type": "subscription.trial_ending",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "trial_ends_at": "2026-10-04T00:00:00Z",\n "workspace_id": 37\n }\n}\n```\n\n`subscription.reactivated`:\n\n```json\n{\n "id": "evt_0f8e4b526c1d4c7a9a3e2b5d",\n "type": "subscription.reactivated",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "renews_at": "2026-11-05T00:00:00Z",\n "workspace_id": 37\n }\n}\n```\n\n`subscription.cancellation_confirmed`:\n\n```json\n{\n "id": "evt_0f8e4b526c1d4c7a9a3e2b5d",\n "type": "subscription.cancellation_confirmed",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "access_until": "2026-11-05T00:00:00Z",\n "workspace_id": 37\n }\n}\n```\n\n`subscription.dormant_ending`:\n\n```json\n{\n "id": "evt_0f8e4b526c1d4c7a9a3e2b5d",\n "type": "subscription.dormant_ending",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "charge_date": "2026-10-08T00:00:00Z",\n "amount_cents": 1990,\n "currency": "USD",\n "workspace_id": 37\n }\n}\n```\n\n`mac.usage_80`:\n\n```json\n{\n "id": "evt_0f8e4b526c1d4c7a9a3e2b5d",\n "type": "mac.usage_80",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "used": 8000,\n "limit": 10000,\n "percentage": 80,\n "cycle_started_at": "2026-10-01T00:00:00Z",\n "workspace_id": 37\n }\n}\n```\n\n`mac.usage_100`:\n\n```json\n{\n "id": "evt_0f8e4b526c1d4c7a9a3e2b5d",\n "type": "mac.usage_100",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "used": 10000,\n "limit": 10000,\n "percentage": 100,\n "cycle_started_at": "2026-10-01T00:00:00Z",\n "grace_expires_at": "2026-10-02T12:00:00Z",\n "workspace_id": 37\n }\n}\n```\n\n`mac.expiring`:\n\n```json\n{\n "id": "evt_0f8e4b526c1d4c7a9a3e2b5d",\n "type": "mac.expiring",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "contacts_expiring": 1200,\n "expires_at": "2026-10-08T03:00:00Z",\n "workspace_id": 37\n }\n}\n```\n\n`account.blocked`:\n\n```json\n{\n "id": "evt_0f8e4b526c1d4c7a9a3e2b5d",\n "type": "account.blocked",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "reason": "mac_grace_expired",\n "grace_started_at": "2026-10-01T12:00:00Z",\n "grace_expired_at": "2026-10-02T12:00:00Z",\n "workspace_id": 37\n }\n}\n```\n'
@@ -51451,11 +51736,11 @@ var openapi_default = {
51451
51736
  generated_by: "scripts/generate.mjs",
51452
51737
  note: "GENERATED: do not edit it. The portal's snapshot plus the pending layers.",
51453
51738
  inputs: {
51454
- "spec/funnelchat.json": "8228ea7369daed0655b779ca6af381355a6c1cc13dd702ed6e9c322779bafe94",
51739
+ "spec/funnelchat.json": "38ae497ab7e6089b31b589e1f93c2289524348d581796eba109568885642813c",
51455
51740
  "spec/pending.json": "051680bd82944c0e248d20d1499d89e61a231c6ba3ef6a5d864cf18a97aae1ad",
51456
51741
  "spec/pending-sandbox.json": "e75e9a7b63c5913c11fc35fa06d23bd32a697832a4da105c377eac9a8f4e62a3"
51457
51742
  },
51458
- snapshot_commit: "f5464516",
51743
+ snapshot_commit: "51c0aeb",
51459
51744
  pending_commit: "a8f83509d4a40d6116a9c0487bf0aee639948273",
51460
51745
  pending_applied: [
51461
51746
  "POST /api/v1/webhooks/{id}/rotate-secret [pending.json]",
@@ -52478,7 +52763,7 @@ function resolveFile(body2, style) {
52478
52763
 
52479
52764
  // package.json
52480
52765
  var package_default = {
52481
- version: "0.3.1"};
52766
+ version: "0.3.3"};
52482
52767
 
52483
52768
  // src/version.ts
52484
52769
  var VERSION = package_default.version;
@@ -52502,8 +52787,13 @@ var Store = class {
52502
52787
  }
52503
52788
  historyLimit;
52504
52789
  workspaces = /* @__PURE__ */ new Map();
52505
- /** The sandbox keys minted for a workspace (`POST /_sandbox/workspaces`, `/_sandbox/keys`): any other key is the default one's. */
52790
+ /**
52791
+ * The sandbox keys minted for a workspace (`POST /_sandbox/workspaces`, `/_sandbox/keys`), with their permissions
52792
+ * (`null`: every one). Any other key is the default workspace's, with every permission.
52793
+ */
52506
52794
  keys = /* @__PURE__ */ new Map();
52795
+ /** When each number imported a group in the last minute: communities' 5 per minute. */
52796
+ groupImports = /* @__PURE__ */ new Map();
52507
52797
  devices = /* @__PURE__ */ new Map();
52508
52798
  contacts = /* @__PURE__ */ new Map();
52509
52799
  messages = /* @__PURE__ */ new Map();
@@ -52664,6 +52954,24 @@ function seed(store, now = /* @__PURE__ */ new Date()) {
52664
52954
  { phone: "5491100000000", name: "Contacto de ejemplo", admin: false }
52665
52955
  ],
52666
52956
  link: "https://chat.whatsapp.com/SandboxEjemplo0000000000",
52957
+ imported: true,
52958
+ createdAt: now,
52959
+ updatedAt: now
52960
+ });
52961
+ const liveId = store.ids.group.next();
52962
+ store.groups.set(liveId, {
52963
+ id: liveId,
52964
+ uid: "120363000000000001@g.us",
52965
+ name: "Clientes VIP",
52966
+ description: "",
52967
+ deviceId: 4,
52968
+ workspaceId,
52969
+ participants: [
52970
+ { phone: "5491100000002", name: null, admin: true },
52971
+ { phone: "5491100000000", name: "Contacto de ejemplo", admin: false }
52972
+ ],
52973
+ link: "https://chat.whatsapp.com/SandboxEjemplo0000000001",
52974
+ imported: false,
52667
52975
  createdAt: now,
52668
52976
  updatedAt: now
52669
52977
  });
@@ -52700,17 +53008,19 @@ function scopeOf(workspace) {
52700
53008
  return { id: workspace.id, isDefault: workspace.isDefault };
52701
53009
  }
52702
53010
  function scopeOfKey(store, key) {
52703
- const id = store.keys.get(key);
53011
+ const id = store.keys.get(key)?.workspaceId;
52704
53012
  const workspace = id === void 0 ? void 0 : store.workspaces.get(id);
52705
53013
  return scopeOf(workspace ?? store.defaultWorkspace);
52706
53014
  }
53015
+ var permissionsOfKey = (store, key) => store.keys.get(key)?.permissions ?? null;
53016
+ var PERMISSION = /^(accounts|conversations|communities|webhooks):(read|write|send|operate)$/;
52707
53017
  function routedWorkspace(store, declared) {
52708
53018
  if (declared !== null && declared !== void 0 && store.workspaces.has(declared)) return declared;
52709
53019
  return store.defaultWorkspace.id;
52710
53020
  }
52711
- function mintKey(store, workspaceId, fixed) {
53021
+ function mintKey(store, workspaceId, fixed, permissions = null) {
52712
53022
  const key = fixed ?? `${store.ids.apiKey.next()}|fc_live_${randomAlnum(40)}`;
52713
- store.keys.set(key, workspaceId);
53023
+ store.keys.set(key, { workspaceId, permissions });
52714
53024
  return key;
52715
53025
  }
52716
53026
  function createWorkspace(app, name, now = /* @__PURE__ */ new Date()) {
@@ -52723,7 +53033,7 @@ function workspaceView(store, workspace) {
52723
53033
  id: workspace.id,
52724
53034
  name: workspace.name,
52725
53035
  is_default: workspace.isDefault,
52726
- keys: [...store.keys.entries()].filter(([, id]) => id === workspace.id).map(([key]) => key)
53036
+ keys: [...store.keys.entries()].filter(([, k]) => k.workspaceId === workspace.id).map(([key]) => key)
52727
53037
  };
52728
53038
  }
52729
53039
 
@@ -54820,6 +55130,21 @@ var messageHandlers = {
54820
55130
  const template = getTemplate(ctx, ctx.path.template);
54821
55131
  return ok({ data: (template.buttons ?? []).filter((b) => b.type === "QUICK_REPLY").map((b) => ({ text: b.text ?? "" })) });
54822
55132
  },
55133
+ // Bring the templates the number has in Meta (conversations v1.19.37 `MessageTemplateController::sync`). Nothing goes
55134
+ // to Meta: an official number answers that it finished (its templates are already the sandbox's), a QR one that it
55135
+ // has none, and a number outside the account or the key's workspace, conversations' 404.
55136
+ "POST /api/v1/devices/{device}/templates": (ctx) => {
55137
+ const device = ctx.app.store.devices.get(Number(ctx.path.device));
55138
+ if (!device || !sees(ctx.workspace, device.workspaceId)) throw objectNotFound();
55139
+ if (device.kind !== "cloud") {
55140
+ throw new ApiError(
55141
+ 422,
55142
+ "number_not_connected_to_meta",
55143
+ "Este n\xFAmero no tiene plantillas de Meta que sincronizar: est\xE1 conectado por QR o no tiene una cuenta de WhatsApp Business o credenciales v\xE1lidas en Meta."
55144
+ );
55145
+ }
55146
+ return { status: 202, body: { status: "completed" } };
55147
+ },
54823
55148
  "DELETE /api/v1/devices/{device}/templates/{template}": (ctx) => {
54824
55149
  const template = getTemplate(ctx, ctx.path.template);
54825
55150
  if (template.deviceId !== Number(ctx.path.device)) throw notFound("La plantilla no es de este n\xFAmero.");
@@ -55410,10 +55735,17 @@ function groupResource(app, group, scope, mode) {
55410
55735
  lastSyncAt: dateTime(group.updatedAt)
55411
55736
  };
55412
55737
  }
55738
+ var withSuffix = (uid) => {
55739
+ const text2 = String(uid);
55740
+ return text2.endsWith("@g.us") ? text2 : `${text2}@g.us`;
55741
+ };
55742
+ var accountGroup = (app, uid) => [...app.store.groups.values()].find((g) => g.imported && g.uid === withSuffix(uid));
55413
55743
  function groupOf(app, uid, scope) {
55414
- for (const group of app.store.groups.values()) if (group.uid === String(uid) && sees(scope, group.workspaceId)) return group;
55415
- return void 0;
55744
+ const group = accountGroup(app, uid);
55745
+ return group && sees(scope, group.workspaceId) ? group : void 0;
55416
55746
  }
55747
+ var groupsOnWhatsApp = (app, device) => device.number === null ? [] : [...app.store.groups.values()].filter((g) => g.participants.some((p) => p.phone === device.number));
55748
+ var isAdminOf = (device, group) => group.participants.some((p) => p.admin && p.phone === device.number);
55417
55749
  function checkListQuery(q) {
55418
55750
  const errors = {};
55419
55751
  if (q.perPage !== void 0) {
@@ -55428,7 +55760,58 @@ function checkListQuery(q) {
55428
55760
  const first = Object.values(errors)[0];
55429
55761
  if (first) throw new RawApiError(422, { message: first[0], errors });
55430
55762
  }
55431
- var groupError = (status, code, message, extra = {}) => new ApiError(status, code, message, extra);
55763
+ var groupError = (status, code, message, extra = {}, headers = {}) => new ApiError(status, code, message, extra, headers);
55764
+ var groupNotFound = () => groupError(404, "group_not_found", "No encontramos ese grupo en tu cuenta.");
55765
+ var deviceNotConnected = () => groupError(422, "device_not_connected", "El n\xFAmero est\xE1 desconectado. Reconectalo e intent\xE1 de nuevo.");
55766
+ function importDevice(ctx, deviceId, connected) {
55767
+ const device = ctx.app.store.devices.get(Number(deviceId));
55768
+ if (!device || !sees(ctx.workspace, device.workspaceId)) throw groupError(404, "device_not_found", "No encontramos ese n\xFAmero en tu cuenta.");
55769
+ if (connected && (!device.connected || device.number === null)) throw deviceNotConnected();
55770
+ return device;
55771
+ }
55772
+ var IMPORTS_PER_MINUTE = 5;
55773
+ var GROUP_UID = /^[0-9]{10,25}(-[0-9]+)?(@g\.us)?$/;
55774
+ function importGroup(ctx, device, uid, forSend) {
55775
+ const { app } = ctx;
55776
+ if (ctx.permissions !== null && !ctx.permissions.includes("communities:operate")) {
55777
+ throw groupError(403, "import_permission_required", "Ese grupo no est\xE1 en tu cuenta. Para importarlo, la API key necesita el permiso \xABGrupos y campa\xF1as \u2192 Administrar grupos\xBB.");
55778
+ }
55779
+ if (uid.endsWith("@newsletter")) throw groupError(422, "unsupported_group_type", "Esta operaci\xF3n solo funciona con grupos comunes: no admite comunidades, grupos de avisos ni canales.");
55780
+ if (!GROUP_UID.test(uid)) throw groupNotFound();
55781
+ if (device.kind === "cloud") {
55782
+ throw groupError(422, "provider_unsupported", "Importar grupos solo est\xE1 disponible con n\xFAmeros QR: con un n\xFAmero oficial no se puede leer la lista de grupos.", { provider: "official" });
55783
+ }
55784
+ const existing = accountGroup(app, uid);
55785
+ if (existing) {
55786
+ if (!sees(ctx.workspace, existing.workspaceId)) throw groupNotFound();
55787
+ return { group: existing, imported: false, isAdmin: isAdminOf(device, existing) };
55788
+ }
55789
+ const now = Date.now();
55790
+ const recent = (app.store.groupImports.get(device.id) ?? []).filter((at) => now - at < 6e4);
55791
+ if (recent.length >= IMPORTS_PER_MINUTE) {
55792
+ const retryAfter = Math.max(1, Math.ceil((6e4 - (now - recent[0])) / 1e3));
55793
+ throw groupError(
55794
+ 429,
55795
+ "import_rate_limited",
55796
+ `Este n\xFAmero ya import\xF3 ${IMPORTS_PER_MINUTE} grupos en el \xFAltimo minuto. Esper\xE1 ${retryAfter} segundos e intent\xE1 de nuevo.`,
55797
+ { retry_after: retryAfter },
55798
+ { "Retry-After": String(retryAfter) }
55799
+ );
55800
+ }
55801
+ app.store.groupImports.set(device.id, [...recent, now]);
55802
+ if (ctx.headers["x-sandbox-reject"] === "import_in_progress") {
55803
+ throw groupError(409, "import_in_progress", "Ese grupo se est\xE1 importando en otro pedido. Intent\xE1 de nuevo en unos segundos.");
55804
+ }
55805
+ const live = groupsOnWhatsApp(app, device).find((g) => g.uid === withSuffix(uid));
55806
+ if (!live) throw groupNotFound();
55807
+ const isAdmin = isAdminOf(device, live);
55808
+ if (forSend && !isAdmin) throw groupError(422, "device_not_group_admin", "El n\xFAmero no es administrador de este grupo, por eso no puede ejecutar la operaci\xF3n.");
55809
+ live.imported = true;
55810
+ live.deviceId = device.id;
55811
+ live.workspaceId = ctx.workspace.id;
55812
+ live.updatedAt = /* @__PURE__ */ new Date();
55813
+ return { group: live, imported: true, isAdmin };
55814
+ }
55432
55815
  var MEDIA_EXTENSIONS = {
55433
55816
  image: ["jpg", "jpeg", "png"],
55434
55817
  video: ["mp4", "mov", "gif"],
@@ -55476,11 +55859,12 @@ function summarize2(errors) {
55476
55859
  }
55477
55860
  function resolveTarget(ctx, deviceId, uid) {
55478
55861
  const { app } = ctx;
55479
- const device = app.store.devices.get(Number(deviceId));
55480
- if (!device || !sees(ctx.workspace, device.workspaceId)) throw groupError(404, "device_not_found", "No encontramos ese n\xFAmero en tu cuenta.");
55481
- const group = groupOf(app, uid, ctx.workspace);
55482
- if (!group) throw groupError(404, "group_not_found", "No encontramos ese grupo en tu cuenta.");
55483
- if (!device.connected || device.number === null) throw groupError(422, "device_not_connected", "El n\xFAmero est\xE1 desconectado. Reconectalo e intent\xE1 de nuevo.");
55862
+ const device = importDevice(ctx, deviceId, false);
55863
+ const inAccount = accountGroup(app, uid);
55864
+ const importIt = !inAccount && device.kind === "qr";
55865
+ if (!importIt && (!inAccount || !sees(ctx.workspace, inAccount.workspaceId))) throw groupNotFound();
55866
+ if (!device.connected || device.number === null) throw deviceNotConnected();
55867
+ const group = importIt ? importGroup(ctx, device, String(uid), true).group : inAccount;
55484
55868
  if (device.kind === "cloud") {
55485
55869
  throw groupError(422, "provider_unsupported", "Esta operaci\xF3n no est\xE1 disponible para n\xFAmeros del proveedor official en tu cuenta: requiere la beta `cloud-api-groups`.", {
55486
55870
  provider: "official",
@@ -55552,7 +55936,7 @@ var groupHandlers = {
55552
55936
  const key = String(q.orderBy ?? "created_at");
55553
55937
  const desc = (q.orderByMethod ?? "desc") === "desc";
55554
55938
  const value = (g) => key === "name" ? g.name.toLowerCase() : key === "id" ? g.id : key === "updated_at" ? g.updatedAt.getTime() : g.createdAt.getTime();
55555
- const list = [...ctx.app.store.groups.values()].filter((g) => sees(ctx.workspace, g.workspaceId)).filter((g) => !name || g.name.toLowerCase().includes(name)).filter(() => q.type === void 0 || String(q.type) === "1").filter(() => q.status === void 0 || String(q.status) === "0").filter((g) => q.deviceId === void 0 || g.deviceId === Number(q.deviceId)).sort((a, b) => {
55939
+ const list = [...ctx.app.store.groups.values()].filter((g) => g.imported && sees(ctx.workspace, g.workspaceId)).filter((g) => !name || g.name.toLowerCase().includes(name)).filter(() => q.type === void 0 || String(q.type) === "1").filter(() => q.status === void 0 || String(q.status) === "0").filter((g) => q.deviceId === void 0 || g.deviceId === Number(q.deviceId)).sort((a, b) => {
55556
55940
  const va = value(a);
55557
55941
  const vb = value(b);
55558
55942
  const cmp = va < vb ? -1 : va > vb ? 1 : 0;
@@ -55589,6 +55973,28 @@ var groupHandlers = {
55589
55973
  scheduleSend(ctx.app, action);
55590
55974
  return ok({ action_id: action.uuid, status: "queued" }, 202);
55591
55975
  },
55976
+ // Follow a group: import it to the account. `201` imported now, `200` already in the account; `is_admin` from WhatsApp.
55977
+ "POST /api/v1/groups/{group}/import": (ctx) => {
55978
+ const device = importDevice(ctx, body(ctx).device_id, true);
55979
+ const result = importGroup(ctx, device, String(ctx.path.group), false);
55980
+ return ok({ data: { uid: result.group.uid, name: result.group.name, imported: result.imported, is_admin: result.isAdmin } }, result.imported ? 201 : 200);
55981
+ },
55982
+ // The number's groups on WhatsApp, live (`GroupMinResource`): `device_id` only for one the account already has.
55983
+ "GET /api/v1/devices/{device}/groups": (ctx) => {
55984
+ const device = ctx.app.store.devices.get(Number(ctx.path.device));
55985
+ if (!device || !sees(ctx.workspace, device.workspaceId)) throw new RawApiError(404, { error: "Device not found" });
55986
+ if (!device.connected) throw new RawApiError(400, { error: "An error has occurred" });
55987
+ const name = ctx.query.name ? String(ctx.query.name).toLowerCase() : null;
55988
+ const items = (device.kind === "qr" ? groupsOnWhatsApp(ctx.app, device) : []).filter((g) => !name || g.name.toLowerCase().includes(name)).map((g) => {
55989
+ const own = g.imported && sees(ctx.workspace, g.workspaceId) ? ctx.app.store.devices.get(g.deviceId) : void 0;
55990
+ const deviceId = g.imported && sees(ctx.workspace, g.workspaceId) ? own && sees(ctx.workspace, own.workspaceId) ? own.id : null : "";
55991
+ return { uid: g.uid, name: g.name, description: g.description, device_id: deviceId, alias: "", number: "", communityUid: "" };
55992
+ });
55993
+ const page2 = Math.max(1, Number(ctx.query.page ?? 1));
55994
+ const perPage = Math.max(1, Number(ctx.query.pageSize ?? 250));
55995
+ const data = items.slice((page2 - 1) * perPage, page2 * perPage);
55996
+ return ok({ data, meta: { current_page: page2, last_page: Math.max(1, Math.ceil(items.length / perPage)), per_page: perPage, total: items.length } });
55997
+ },
55592
55998
  "GET /api/v1/groups/actions/{action}": (ctx) => {
55593
55999
  const action = ctx.app.store.groupActions.get(String(ctx.path.action));
55594
56000
  if (!action || action.workspaceId !== ctx.workspace.id) throw groupError(404, "action_not_found", "No encontramos esa acci\xF3n en tu cuenta.");
@@ -55605,8 +56011,9 @@ function createGroup(app, input) {
55605
56011
  description: "",
55606
56012
  deviceId: input.device.id,
55607
56013
  workspaceId: input.device.workspaceId,
56014
+ imported: input.imported ?? true,
55608
56015
  participants: [
55609
- { phone: input.device.number, name: null, admin: true },
56016
+ { phone: input.device.number, name: null, admin: input.admin ?? true },
55610
56017
  ...(input.participants ?? []).filter((p) => p.phone !== input.device.number).map((p) => ({ ...p, admin: false }))
55611
56018
  ],
55612
56019
  link: `https://chat.whatsapp.com/Sandbox${randomAlnum(20)}`,
@@ -56274,6 +56681,7 @@ function participantEvent(app, req, type) {
56274
56681
  if (type === "group_participant.joined" && !current) group.participants.push({ phone: identity, name: name === void 0 ? null : String(name), admin: false });
56275
56682
  if (type === "group_participant.left" && current) group.participants.splice(index2, 1);
56276
56683
  group.updatedAt = /* @__PURE__ */ new Date();
56684
+ if (!group.imported) return { status: 202, body: { event: null, participants: group.participants.length, imported: false } };
56277
56685
  const data2 = participantData(group, { phone: identity, name: name === void 0 ? current?.name ?? null : String(name) });
56278
56686
  return { status: 202, body: { event: emitted(app.webhooks.emit(type, data2, { workspaceId: group.workspaceId })), participants: group.participants.length } };
56279
56687
  }
@@ -56298,6 +56706,7 @@ var groupView = (group) => ({
56298
56706
  name: group.name,
56299
56707
  device_id: group.deviceId,
56300
56708
  workspace_id: group.workspaceId,
56709
+ imported: group.imported,
56301
56710
  participants: group.participants.map((p) => ({ phone: p.phone, name: p.name, admin: p.admin }))
56302
56711
  });
56303
56712
  var ROUTES = [
@@ -56366,7 +56775,14 @@ var ROUTES = [
56366
56775
  const b = obj(req.body);
56367
56776
  const id = b.workspace_id === void 0 || b.workspace_id === null ? app.store.defaultWorkspace.id : Number(b.workspace_id);
56368
56777
  if (!app.store.workspaces.has(id)) throw fieldError("workspace_id", "There is no such workspace in the sandbox. See GET /_sandbox/workspaces.");
56369
- return { status: 201, body: { key: mintKey(app.store, id), workspace_id: id } };
56778
+ let permissions = null;
56779
+ if (b.permissions !== void 0 && b.permissions !== null) {
56780
+ if (!Array.isArray(b.permissions) || b.permissions.some((p) => typeof p !== "string" || !PERMISSION.test(p))) {
56781
+ throw fieldError("permissions", 'permissions must be a list like ["communities:read", "communities:send"].');
56782
+ }
56783
+ permissions = [...new Set(b.permissions)];
56784
+ }
56785
+ return { status: 201, body: { key: mintKey(app.store, id, void 0, permissions), workspace_id: id, permissions } };
56370
56786
  }
56371
56787
  },
56372
56788
  { method: "POST", pattern: /^\/_sandbox\/inbound\/message$/, handle: inboundMessage },
@@ -56432,7 +56848,11 @@ var ROUTES = [
56432
56848
  const name = obj(p).name;
56433
56849
  return { phone, name: typeof name === "string" ? name : null };
56434
56850
  }) : [];
56435
- return { status: 201, body: { group: groupView(createGroup(app, { name: b.name.trim(), device, participants })) } };
56851
+ for (const field of ["imported", "admin"]) {
56852
+ if (b[field] !== void 0 && typeof b[field] !== "boolean") throw fieldError(field, `${field} must be true or false.`);
56853
+ }
56854
+ const group = createGroup(app, { name: b.name.trim(), device, participants, imported: b.imported, admin: b.admin });
56855
+ return { status: 201, body: { group: groupView(group) } };
56436
56856
  }
56437
56857
  },
56438
56858
  { method: "POST", pattern: /^\/_sandbox\/groups\/participant-joined$/, handle: (app, req) => participantEvent(app, req, "group_participant.joined") },
@@ -56724,7 +57144,8 @@ function createSandbox(options = {}) {
56724
57144
  headers,
56725
57145
  origin: `http://${headers.host ?? `localhost:${config.port}`}`,
56726
57146
  pathname: url.pathname,
56727
- workspace
57147
+ workspace,
57148
+ permissions: permissionsOfKey(app.store, key)
56728
57149
  };
56729
57150
  if (handler) return { result: await handler(ctx), operation: op.key, requestBody };
56730
57151
  const success = successResponse(op.operation);
@@ -56831,5 +57252,5 @@ function createSandbox(options = {}) {
56831
57252
  }
56832
57253
 
56833
57254
  export { ConfigError, DEFAULT_CONFIG_FILE, DEFAULT_PORT, DELIVERY_HEADER, EDGE_ERRORS, EVENT_HEADER, FAILURE_REASONS, FORM_TEMPLATES, META_ERRORS, REJECTIONS, REQUEST_ID_HEADER, SEND_FILE_TYPES, SIGNATURE_HEADER, SUPPORTED_EXTENSIONS, VERSION, catalog, createSandbox, defaultConfig, mergeConfig, parseArgs, resolveConfig, signature };
56834
- //# sourceMappingURL=chunk-FUK4UDIM.js.map
56835
- //# sourceMappingURL=chunk-FUK4UDIM.js.map
57255
+ //# sourceMappingURL=chunk-EQLGOYR6.js.map
57256
+ //# sourceMappingURL=chunk-EQLGOYR6.js.map