@funnelchat-hq/sandbox 0.3.1 → 0.3.2

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.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  A local server that imitates the Funnelchat public API and sends signed webhooks, with no real effects: nothing goes out to WhatsApp, nothing is charged, everything stays in memory. Build and test your integration without an account.
4
4
 
5
- It imitates production on 05-11: accounts v1.5.97, conversations v1.19.21, communities v2.35.5, platform-gate v0.9.1 and the API edge v0.3.0.
5
+ It imitates production on 05-11: accounts v1.5.97, conversations v1.19.21, communities v2.35.7, platform-gate v0.9.1 and the API edge v0.3.0.
6
6
 
7
7
  ## Try it in 3 minutes, without an account
8
8
 
@@ -62,21 +62,25 @@ On an official number (`3`) this also opens the 24-hour window: free text only g
62
62
 
63
63
  ### Groups
64
64
 
65
- The groups of your numbers, with the shape of communities: `GET /api/v1/groups` (paginated, 25 per page, sorted by `created_at`; `name`, `deviceId`… filter) and `GET /api/v1/groups/{uid}`. The sandbox starts with one, `120363000000000000@g.us`, of the QR number `4`; `POST /_sandbox/groups` (`{"device_id": 5, "name": "Clientes", "participants": ["5491155550000"]}`) gives a connected QR number another one.
65
+ Groups start from your number's live list: `GET /api/v1/devices/{device}/groups` reads the groups the number is in on WhatsApp (QR numbers), and `device_id` says which ones the account already has. The sandbox's QR number `4` is in two: `120363000000000000@g.us` («Grupo de ejemplo», already in the account) and `120363000000000001@g.us` («Clientes VIP», not yet).
66
66
 
67
67
  ```bash
68
- curl -s "$API/groups" -H "Authorization: Bearer $FUNNELCHAT_API_KEY"
69
- # {"data": [{"id": 7001, "uid": "120363000000000000@g.us", "name": "Grupo de ejemplo", "deviceId": 4, "participants_count": 2, …}], "links": {…}, "meta": {…}}
68
+ curl -s "$API/devices/4/groups" -H "Authorization: Bearer $FUNNELCHAT_API_KEY"
69
+ # {"data": [{"uid": "120363000000000000@g.us", "name": "Grupo de ejemplo", "device_id": 4, …}, {"uid": "120363000000000001@g.us", "name": "Clientes VIP", "device_id": "", …}], "meta": {…}}
70
70
  ```
71
71
 
72
- **Someone joins or leaves a group:** `group_participant.joined` and `group_participant.left`, with `group`, `campaign` (`null` without one) and `participant` (`identity`, the phone in digits). In the sandbox they are simulated:
72
+ **Follow a group** to know who joins and leaves: `POST /api/v1/groups/{uid}/import` with `device_id` imports it to the account, as the panel does (`201`, or `200` if it already was there, with `is_admin` read from WhatsApp). From then on it is in `GET /api/v1/groups` and `GET /api/v1/groups/{uid}`, and announces `group_participant.joined` and `group_participant.left` (`group`, `campaign`, `null` without one, and `participant`, whose `identity` is the phone in digits). It needs the permission **Grupos y campañas → Administrar grupos** (`403 import_permission_required`), only QR numbers (`422 provider_unsupported`), and each number imports up to 5 groups per minute (`429 import_rate_limited`, with `Retry-After`).
73
73
 
74
74
  ```bash
75
+ curl -s -X POST "$API/groups/120363000000000001@g.us/import" -H "Authorization: Bearer $FUNNELCHAT_API_KEY" \
76
+ -H 'Content-Type: application/json' -d '{"device_id": 4}'
77
+ # 201 {"data": {"uid": "120363000000000001@g.us", "name": "Clientes VIP", "imported": true, "is_admin": true}}
75
78
  curl -s -X POST http://localhost:4010/_sandbox/groups/participant-joined -H 'Content-Type: application/json' \
76
- -d '{"group_id": 7001, "participant": {"phone": "5491155550000", "name": "Ana"}}'
79
+ -d '{"uid": "120363000000000001@g.us", "participant": {"phone": "5491155550000", "name": "Ana"}}'
80
+ # group_participant.joined (a group the account does not have announces nothing)
77
81
  ```
78
82
 
79
- **Sending a message to a group** is `POST /api/v1/groups/{uid}/messages`, a group action: it answers `202` with an `action_id`, and `GET /api/v1/groups/actions/{action}` says when it is `completed` (with `result.message.uid`, the message's id in WhatsApp), as does the event `group_action.completed`:
83
+ **Writing to a group** is `POST /api/v1/groups/{uid}/messages`, a group action: it answers `202` with an `action_id`, and `GET /api/v1/groups/actions/{action}` says when it is `completed` (with `result.message.uid`, the message's id in WhatsApp), as does the event `group_action.completed`. A group the account does not have is imported first, with the same rules (and the number must be its admin):
80
84
 
81
85
  ```bash
82
86
  curl -s -X POST "$API/groups/120363000000000000@g.us/messages" -H "Authorization: Bearer $FUNNELCHAT_API_KEY" \
@@ -84,7 +88,7 @@ curl -s -X POST "$API/groups/120363000000000000@g.us/messages" -H "Authorization
84
88
  # 202 {"action_id": "…", "status": "queued"}
85
89
  ```
86
90
 
87
- `type` is `text` (with `text`), `image`, `video`, `audio` or `document` (with `media_url`, an `https` URL with an extension of the type; `caption` for `image` and `video`, `file_name` for `document`). The number must be a QR number that is an admin of the group: `404 device_not_found` or `group_not_found`, `422 device_not_connected`, `device_not_group_admin` or, for an official number, `provider_unsupported` (the sandbox account is not in the beta of official groups). To imitate communities' switch `API_GROUP_ACTIONS_ENABLED` off, start it with `--no-group-actions`: every key then gets `403 route_not_available`.
91
+ `type` is `text` (with `text`), `image`, `video`, `audio` or `document` (with `media_url`, an `https` URL with an extension of the type; `caption` for `image` and `video`, `file_name` for `document`). The number must be a QR number that is an admin of the group: `404 device_not_found` or `group_not_found`, `422 device_not_connected`, `device_not_group_admin` or, for an official number, `provider_unsupported` (the sandbox account is not in the beta of official groups). Two imports of the same group at a time cannot happen in the sandbox; `X-Sandbox-Reject: import_in_progress` gives production's `409`. A key minted with `POST /_sandbox/keys` and `"permissions": ["communities:send"]` lacks the permission to import. To imitate communities' switch `API_GROUP_ACTIONS_ENABLED` off, start it with `--no-group-actions`: every key then gets `403 route_not_available`.
88
92
 
89
93
  ### Workspaces
90
94
 
@@ -124,7 +128,7 @@ npx funnelchat-sandbox
124
128
  ```
125
129
 
126
130
  ```
127
- funnelchat-sandbox 0.3.1 at http://127.0.0.1:4010
131
+ funnelchat-sandbox 0.3.2 at http://127.0.0.1:4010
128
132
  API: http://127.0.0.1:4010/api/v1/... (Authorization: Bearer 1|fc_live_sandbox, or any well-formed key)
129
133
  panel: http://127.0.0.1:4010/_sandbox
130
134
  example numbers: 3 (official, with the 24-hour window) and 4 (QR, no window); contact 2001; template 45
@@ -249,7 +253,8 @@ Every session starts with this:
249
253
  | Contact «Contacto de ejemplo» | `2001` | Phone `5491100000000`. |
250
254
  | Tag «Cliente» | `4001` | |
251
255
  | Template `bienvenida`, approved, of number 3 | `45` | One variable: «Hola {{1}}, gracias por escribirnos…». |
252
- | Group «Grupo de ejemplo» of number 4 | `7001`, `120363000000000000@g.us` | Number 4 is its admin; contact 2001, a participant. |
256
+ | Group «Grupo de ejemplo» of number 4 | `7001`, `120363000000000000@g.us` | In the account. Number 4 is its admin; contact 2001, a participant. |
257
+ | Group «Clientes VIP» of number 4 | `120363000000000001@g.us` | Only on WhatsApp, to follow (import). Number 4 is its admin. |
253
258
  | Workspace «Principal» | `1` | The default one: every key belongs to it unless it was minted for another. |
254
259
 
255
260
  `POST /_sandbox/reset` goes back to this and cancels everything that was scheduled.
@@ -592,7 +597,7 @@ These routes take no key: they belong to the sandbox, not to the API.
592
597
  | Route | What it simulates |
593
598
  |---|---|
594
599
  | `POST /_sandbox/inbound/message` | A contact writes to you: `{"phone": "5491100000000", "device_id": 3, "text": "Hola"}` (or `contact_id`; optional `name`, `type`, `file` and `occurred_at`). If the phone is new it creates the contact (`contact.created`); then it emits `message.received` and opens the 24-hour window of that number. |
595
- | `POST /_sandbox/groups`, `GET /_sandbox/groups` | A group of a connected QR number, its admin, in the number's workspace: `{"device_id": 5, "name": "Clientes", "participants": [{"phone": "5491155550000", "name": "Ana"}]}`. |
600
+ | `POST /_sandbox/groups`, `GET /_sandbox/groups` | A group of a connected QR number, in the number's workspace: `{"device_id": 5, "name": "Clientes", "participants": [{"phone": "5491155550000", "name": "Ana"}]}`. In the account unless `"imported": false` (only on WhatsApp, to import); the number is its admin unless `"admin": false`. |
596
601
  | `POST /_sandbox/groups/participant-joined`, `…/participant-left` | Someone joins or leaves a group: `{"group_id": 7001, "participant": {"phone": "…", "name": "…"}}` (or `"uid"`; the example group without either). The group's participants change and the event goes to its workspace. A `group` the sandbox does not have (`{"group": {"uid": "…@g.us", "name": "…"}}`) goes out from the catalog's example. |
597
602
  | `POST /_sandbox/forms/complete` | The contact completes a sent form: emits `whatsapp_form.completed` (see [WhatsApp forms](#whatsapp-forms)). |
598
603
  | `POST /_sandbox/mac`, `GET /_sandbox/mac` | The account's MAC: `{"lockout": true, "active_contact_ids": [2001], "inactive_contact_ids": []}`. With `lockout`, sends to contacts that are not active answer `429 mac_lockout_24h`; an inactive contact always gets it. |
@@ -603,7 +608,7 @@ These routes take no key: they belong to the sandbox, not to the API.
603
608
  | `POST /_sandbox/templates/{id}/status` | Meta approves or rejects a template: `{"status": "REJECTED", "reason": "…"}` emits `template.status_changed`. |
604
609
  | `POST /_sandbox/events/{type}` | Any event of the catalog, with its example; what you send in `{"data": {…}}` overrides it, and `{"workspace_id": 2}` sends it to that workspace (the default one otherwise). `GET /_sandbox/events` lists them. `group.message_received` and `group.reaction_added` go out with the shape communities emits (`message.uid` and every key of the message), not with the example of the accounts catalog, which uses `message.id`. |
605
610
  | `POST /_sandbox/window` | Opens or closes the window by hand: `{"contact_id": 2001, "device_id": 3, "open": false}`. |
606
- | `POST /_sandbox/workspaces`, `GET /_sandbox/workspaces` | A [workspace](#workspaces) with a key that only sees it: `{"name": "Cliente B"}` answers `201 {workspace, key}`. `POST /_sandbox/keys` with `{"workspace_id": 2}` mints another key (of the default workspace without `workspace_id`). |
611
+ | `POST /_sandbox/workspaces`, `GET /_sandbox/workspaces` | A [workspace](#workspaces) with a key that only sees it: `{"name": "Cliente B"}` answers `201 {workspace, key}`. `POST /_sandbox/keys` with `{"workspace_id": 2}` mints another key (of the default workspace without `workspace_id`); with `"permissions": [...]` it has only those (the sandbox checks just `communities:operate`, to import a group). |
607
612
  | `POST /_sandbox/reset` | Back to the initial state. |
608
613
  | `GET /_sandbox/state`, `/_sandbox/requests`, `/_sandbox/deliveries`, `/_sandbox/emitted` | The state, the last requests, the last deliveries with what your server answered, and every event emitted, delivered or not (with how many subscriptions got it). |
609
614
 
@@ -690,7 +695,7 @@ And at `http://localhost:4010/_sandbox` you see each request and each delivery w
690
695
  - **The validation messages** are Laravel's in Spanish, built from the schema: they may not match the API's word for word (decide by the HTTP status and by `error`, not by `message`). The real API's validation `422` is Laravel's, `{message, errors}`, without `error`; the sandbox adds `"error": "validation_failed"`.
691
696
  - **Webhook URLs** may be `http` and local, without the real API's protection against internal destinations (try `--require-https`). Do not expose the sandbox to the internet: the `/_sandbox` routes take no key and the sandbox `POST`s to any URL subscribed to it.
692
697
  - **The catalog** is rendered from the code of accounts v1.5.97 (`spec/catalog.json`, with `scripts/transcribe-catalog.php`), not read from a response: if accounts changes it, it has to be rendered again.
693
- - **Groups have state only for the list, one group and the send of a message** (the action is `completed` after the delays of `sent` and `delivered`; it never fails on its own). The other group actions, the campaigns and the rest of the groups' routes answer examples, and their events are fired from `/_sandbox`. There are no official groups (the account is not in their beta), no `too_many_pending_actions` and no rate limit of 30 sends per minute.
698
+ - **Groups have state only for the number's live list, the list, one group, the import and the send of a message** (the action is `completed` after the delays of `sent` and `delivered`; it never fails on its own). The live list is never cached (production keeps it a minute for a key). The other group actions, the campaigns and the rest of the groups' routes answer examples, and their events are fired from `/_sandbox`. There are no official groups (the account is not in their beta), no `too_many_pending_actions` and no rate limit of 30 sends per minute.
694
699
  - **Workspaces confine** numbers, sign-up links, webhook subscriptions, contacts, conversations, messages, templates and groups. Tags, custom fields and forms are not confined in the sandbox. A contact belongs to one workspace, and the phone stays unique in the account: `POST /api/v1/contacts`, `update-or-create` and a send by phone with a phone of another workspace answer `404`.
695
700
  - **Forms do not talk to Meta:** `sync` and `publish` succeed unless the linter finds an error, and the linter is a short version of the real one (screens without a title, without an ending, without questions, with a connection to the server). The metrics (`…/metrics`) answer the example of their schema, and there are no dynamic forms.
696
701
  - **Files are not downloaded:** the type comes from the extension or from `file_type`. The «known» extensions (the ones that give `415` instead of `422 errors.file_type`) are the common ones; the API uses symfony/mime's full list.
@@ -702,7 +707,7 @@ And at `http://localhost:4010/_sandbox` you see each request and each delivery w
702
707
 
703
708
  ## Pending of the spec
704
709
 
705
- The spec (`spec/funnelchat.json`) is the portal's full unified spec of 05-11 (developers-portal `f5464516`, v0.6.2), made from accounts v1.5.97, conversations v1.19.21 and communities v2.35.5 (with the send to a group): every route an API key reaches, not only the ones the portal shows. What production has and that spec lacks is in two separate layers, which the next regeneration drops on its own once the spec carries it:
710
+ The spec (`spec/funnelchat.json`) is the portal's full unified spec of 05-11 (developers-portal `99928866`, v0.6.3), made from accounts v1.5.97, conversations v1.19.21 and communities v2.35.7 (with the send to a group and the import): every route an API key reaches, not only the ones the portal shows. What production has and that spec lacks is in two separate layers, which the next regeneration drops on its own once the spec carries it:
706
711
 
707
712
  - `spec/pending.json`: the pending layer of the Node SDK, copied unchanged (`funnelchat-node` `feat/enviar-por-telefono` @ `a8f8350`). It brings what the spec lacks (the forms, `rotate-secret`, the accounts and communities events) and, guarded by JSON Pointer conditions, corrections where the spec says otherwise than production answers (the send answers, `devices/{device}/qr`, the template list, `PUT webhooks/{id}`, `GET groups/actions/{action}`, and `message.received`, `message.status_changed` and `whatsapp_form.completed`).
708
713
  - `spec/pending-sandbox.json`: what the sandbox adds, with its sources inside.
@@ -745,7 +750,7 @@ What it does:
745
750
  2. It applies over the snapshot `spec/pending.json` and then `spec/pending-sandbox.json`, but **only what the snapshot lacks**. At the end it lists what the snapshot already carries, so you can delete it.
746
751
  3. It writes `generated/openapi.json`, the spec the sandbox validates and answers with.
747
752
 
748
- **The current spec** is the portal's full unified spec of 05-11 (`main` @ `f5464516`, `generated/specs/funnelchat-completa.json`): 217 paths, 264 operations and 29 events of accounts v1.5.97 (`558332bcf`), conversations v1.19.21 (`f2937a71`) and communities v2.35.5 (`2be913d4`). The detail is in `spec/procedencia.json`.
753
+ **The current spec** is the portal's full unified spec of 05-11 (`main` @ `99928866`, `generated/specs/funnelchat-completa.json`): 218 paths, 265 operations and 29 events of accounts v1.5.97 (`558332bcf`), conversations v1.19.21 (`f2937a71`) and communities v2.35.7 (`feat/grupos-sin-panel` @ `54643bbd`). The detail is in `spec/procedencia.json`.
749
754
 
750
755
  ## Development
751
756
 
@@ -13645,7 +13645,7 @@ var openapi_default = {
13645
13645
  }
13646
13646
  },
13647
13647
  "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.",
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.",
13649
13649
  "x-fc-domain": "conversations",
13650
13650
  "x-fc-pending": "conversations-templates",
13651
13651
  "x-fc-pending-response": "conversations-templates"
@@ -14190,7 +14190,7 @@ var openapi_default = {
14190
14190
  }
14191
14191
  },
14192
14192
  "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.",
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 recordatorio, una confirmaci\xF3n o una novedad.",
14194
14194
  "x-fc-domain": "conversations",
14195
14195
  "x-fc-pending": "conversations-send",
14196
14196
  "x-fc-pending-response": "conversations-send"
@@ -19449,7 +19449,7 @@ var openapi_default = {
19449
19449
  "/api/v1/devices/{device}/groups": {
19450
19450
  get: {
19451
19451
  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`).",
19452
+ 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
19453
  summary: "Listar los grupos que administra un n\xFAmero",
19454
19454
  tags: [
19455
19455
  "Grupos"
@@ -19605,7 +19605,7 @@ var openapi_default = {
19605
19605
  $ref: "#/components/responses/CommunitiesValidationException"
19606
19606
  }
19607
19607
  },
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.",
19608
+ "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
19609
  "x-fc-domain": "communities",
19610
19610
  "x-fc-permission": "communities:read"
19611
19611
  }
@@ -20086,6 +20086,87 @@ var openapi_default = {
20086
20086
  "x-fc-permission": "communities:read"
20087
20087
  }
20088
20088
  },
20089
+ "/api/v1/groups/{group}/import": {
20090
+ post: {
20091
+ operationId: "v2.apiGroupImport",
20092
+ 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.",
20093
+ summary: "Importar un grupo de un n\xFAmero",
20094
+ tags: [
20095
+ "Grupos"
20096
+ ],
20097
+ parameters: [
20098
+ {
20099
+ name: "group",
20100
+ in: "path",
20101
+ required: true,
20102
+ description: "Identificador del grupo en WhatsApp, como lo lista `GET /v1/devices/{device}/groups`.",
20103
+ schema: {
20104
+ type: "string"
20105
+ }
20106
+ }
20107
+ ],
20108
+ requestBody: {
20109
+ required: true,
20110
+ content: {
20111
+ "application/json": {
20112
+ schema: {
20113
+ $ref: "#/components/schemas/ApiGroupDeviceRequest"
20114
+ }
20115
+ }
20116
+ }
20117
+ },
20118
+ responses: {
20119
+ "200": {
20120
+ description: "El grupo ya estaba en la cuenta: no se import\xF3 de nuevo.",
20121
+ content: {
20122
+ "application/json": {
20123
+ schema: {
20124
+ type: "object",
20125
+ properties: {
20126
+ data: {
20127
+ $ref: "#/components/schemas/ApiGroupImportResource"
20128
+ }
20129
+ },
20130
+ required: [
20131
+ "data"
20132
+ ]
20133
+ }
20134
+ }
20135
+ }
20136
+ },
20137
+ "201": {
20138
+ description: "El grupo se import\xF3 en esta llamada.",
20139
+ content: {
20140
+ "application/json": {
20141
+ schema: {
20142
+ type: "object",
20143
+ properties: {
20144
+ data: {
20145
+ $ref: "#/components/schemas/ApiGroupImportResource"
20146
+ }
20147
+ },
20148
+ required: [
20149
+ "data"
20150
+ ]
20151
+ }
20152
+ }
20153
+ }
20154
+ },
20155
+ "401": {
20156
+ $ref: "#/components/responses/CommunitiesAuthenticationException"
20157
+ },
20158
+ "403": {
20159
+ $ref: "#/components/responses/CommunitiesAuthorizationException"
20160
+ },
20161
+ "422": {
20162
+ $ref: "#/components/responses/CommunitiesValidationException"
20163
+ }
20164
+ },
20165
+ "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.",
20166
+ "x-fc-domain": "communities",
20167
+ "x-fc-permission": "communities:operate"
20168
+ }
20169
+ },
20089
20170
  "/api/v1/groups/{group}": {
20090
20171
  get: {
20091
20172
  operationId: "group.show",
@@ -20524,7 +20605,7 @@ var openapi_default = {
20524
20605
  },
20525
20606
  post: {
20526
20607
  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.",
20608
+ 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
20609
  summary: "Mandar un mensaje a un grupo",
20529
20610
  tags: [
20530
20611
  "Acciones de grupo"
@@ -20570,7 +20651,7 @@ var openapi_default = {
20570
20651
  $ref: "#/components/responses/CommunitiesValidationException"
20571
20652
  }
20572
20653
  },
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.",
20654
+ "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
20655
  "x-fc-domain": "communities",
20575
20656
  "x-fc-permission": "communities:send"
20576
20657
  }
@@ -27404,7 +27485,7 @@ var openapi_default = {
27404
27485
  "x-fc-access": "write",
27405
27486
  "x-fc-permission": "accounts:operate",
27406
27487
  "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.",
27488
+ "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
27489
  "x-fc-domain": "accounts",
27409
27490
  "x-fc-pending": "accounts-prohibited-null"
27410
27491
  },
@@ -40691,6 +40772,50 @@ var openapi_default = {
40691
40772
  ],
40692
40773
  title: "ApiGroupDescriptionRequest"
40693
40774
  },
40775
+ ApiGroupDeviceRequest: {
40776
+ type: "object",
40777
+ description: "`device_id` of the number that acts (query string of the live invite-link read, body of the renew\naction).",
40778
+ properties: {
40779
+ device_id: {
40780
+ type: "integer"
40781
+ }
40782
+ },
40783
+ required: [
40784
+ "device_id"
40785
+ ],
40786
+ title: "ApiGroupDeviceRequest"
40787
+ },
40788
+ ApiGroupImportResource: {
40789
+ type: "object",
40790
+ properties: {
40791
+ uid: {
40792
+ type: "string",
40793
+ description: "Identificador del grupo en WhatsApp."
40794
+ },
40795
+ name: {
40796
+ type: [
40797
+ "string",
40798
+ "null"
40799
+ ],
40800
+ description: "Nombre del grupo."
40801
+ },
40802
+ imported: {
40803
+ type: "boolean",
40804
+ description: "`true` si esta llamada lo import\xF3; `false` si ya estaba en la cuenta."
40805
+ },
40806
+ is_admin: {
40807
+ type: "boolean",
40808
+ description: "Si el n\xFAmero indicado es administrador del grupo, seg\xFAn WhatsApp. Sin serlo no puede enviarle mensajes ni hacer acciones de grupo."
40809
+ }
40810
+ },
40811
+ required: [
40812
+ "uid",
40813
+ "name",
40814
+ "imported",
40815
+ "is_admin"
40816
+ ],
40817
+ title: "ApiGroupImportResource"
40818
+ },
40694
40819
  ApiGroupNameRequest: {
40695
40820
  type: "object",
40696
40821
  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,7 +51326,7 @@ var openapi_default = {
51201
51326
  title: "Funnelchat Conversations",
51202
51327
  version: "0.0.1",
51203
51328
  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`.",
51329
+ 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.",
51205
51330
  operations: 128,
51206
51331
  excluded_routes: 20,
51207
51332
  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.'
@@ -51211,17 +51336,17 @@ var openapi_default = {
51211
51336
  title: "Funnelchat Communities",
51212
51337
  version: "1.0.0",
51213
51338
  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,
51339
+ 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.",
51340
+ operations: 61,
51216
51341
  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.'
51342
+ 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
51343
  },
51219
51344
  {
51220
51345
  domain: "accounts",
51221
51346
  title: "Funnelchat Accounts \u2014 API",
51222
51347
  version: "0.1.0",
51223
51348
  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`.",
51349
+ 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
51350
  operations: 76,
51226
51351
  excluded_routes: 0,
51227
51352
  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 +51576,11 @@ var openapi_default = {
51451
51576
  generated_by: "scripts/generate.mjs",
51452
51577
  note: "GENERATED: do not edit it. The portal's snapshot plus the pending layers.",
51453
51578
  inputs: {
51454
- "spec/funnelchat.json": "8228ea7369daed0655b779ca6af381355a6c1cc13dd702ed6e9c322779bafe94",
51579
+ "spec/funnelchat.json": "0ffaad2b25733d28b2cc07dde8152400c3c68974b3515e71e116ce599312b0fa",
51455
51580
  "spec/pending.json": "051680bd82944c0e248d20d1499d89e61a231c6ba3ef6a5d864cf18a97aae1ad",
51456
51581
  "spec/pending-sandbox.json": "e75e9a7b63c5913c11fc35fa06d23bd32a697832a4da105c377eac9a8f4e62a3"
51457
51582
  },
51458
- snapshot_commit: "f5464516",
51583
+ snapshot_commit: "99928866",
51459
51584
  pending_commit: "a8f83509d4a40d6116a9c0487bf0aee639948273",
51460
51585
  pending_applied: [
51461
51586
  "POST /api/v1/webhooks/{id}/rotate-secret [pending.json]",
@@ -52478,7 +52603,7 @@ function resolveFile(body2, style) {
52478
52603
 
52479
52604
  // package.json
52480
52605
  var package_default = {
52481
- version: "0.3.1"};
52606
+ version: "0.3.2"};
52482
52607
 
52483
52608
  // src/version.ts
52484
52609
  var VERSION = package_default.version;
@@ -52502,8 +52627,13 @@ var Store = class {
52502
52627
  }
52503
52628
  historyLimit;
52504
52629
  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. */
52630
+ /**
52631
+ * The sandbox keys minted for a workspace (`POST /_sandbox/workspaces`, `/_sandbox/keys`), with their permissions
52632
+ * (`null`: every one). Any other key is the default workspace's, with every permission.
52633
+ */
52506
52634
  keys = /* @__PURE__ */ new Map();
52635
+ /** When each number imported a group in the last minute: communities' 5 per minute. */
52636
+ groupImports = /* @__PURE__ */ new Map();
52507
52637
  devices = /* @__PURE__ */ new Map();
52508
52638
  contacts = /* @__PURE__ */ new Map();
52509
52639
  messages = /* @__PURE__ */ new Map();
@@ -52664,6 +52794,24 @@ function seed(store, now = /* @__PURE__ */ new Date()) {
52664
52794
  { phone: "5491100000000", name: "Contacto de ejemplo", admin: false }
52665
52795
  ],
52666
52796
  link: "https://chat.whatsapp.com/SandboxEjemplo0000000000",
52797
+ imported: true,
52798
+ createdAt: now,
52799
+ updatedAt: now
52800
+ });
52801
+ const liveId = store.ids.group.next();
52802
+ store.groups.set(liveId, {
52803
+ id: liveId,
52804
+ uid: "120363000000000001@g.us",
52805
+ name: "Clientes VIP",
52806
+ description: "",
52807
+ deviceId: 4,
52808
+ workspaceId,
52809
+ participants: [
52810
+ { phone: "5491100000002", name: null, admin: true },
52811
+ { phone: "5491100000000", name: "Contacto de ejemplo", admin: false }
52812
+ ],
52813
+ link: "https://chat.whatsapp.com/SandboxEjemplo0000000001",
52814
+ imported: false,
52667
52815
  createdAt: now,
52668
52816
  updatedAt: now
52669
52817
  });
@@ -52700,17 +52848,19 @@ function scopeOf(workspace) {
52700
52848
  return { id: workspace.id, isDefault: workspace.isDefault };
52701
52849
  }
52702
52850
  function scopeOfKey(store, key) {
52703
- const id = store.keys.get(key);
52851
+ const id = store.keys.get(key)?.workspaceId;
52704
52852
  const workspace = id === void 0 ? void 0 : store.workspaces.get(id);
52705
52853
  return scopeOf(workspace ?? store.defaultWorkspace);
52706
52854
  }
52855
+ var permissionsOfKey = (store, key) => store.keys.get(key)?.permissions ?? null;
52856
+ var PERMISSION = /^(accounts|conversations|communities|webhooks):(read|write|send|operate)$/;
52707
52857
  function routedWorkspace(store, declared) {
52708
52858
  if (declared !== null && declared !== void 0 && store.workspaces.has(declared)) return declared;
52709
52859
  return store.defaultWorkspace.id;
52710
52860
  }
52711
- function mintKey(store, workspaceId, fixed) {
52861
+ function mintKey(store, workspaceId, fixed, permissions = null) {
52712
52862
  const key = fixed ?? `${store.ids.apiKey.next()}|fc_live_${randomAlnum(40)}`;
52713
- store.keys.set(key, workspaceId);
52863
+ store.keys.set(key, { workspaceId, permissions });
52714
52864
  return key;
52715
52865
  }
52716
52866
  function createWorkspace(app, name, now = /* @__PURE__ */ new Date()) {
@@ -52723,7 +52873,7 @@ function workspaceView(store, workspace) {
52723
52873
  id: workspace.id,
52724
52874
  name: workspace.name,
52725
52875
  is_default: workspace.isDefault,
52726
- keys: [...store.keys.entries()].filter(([, id]) => id === workspace.id).map(([key]) => key)
52876
+ keys: [...store.keys.entries()].filter(([, k]) => k.workspaceId === workspace.id).map(([key]) => key)
52727
52877
  };
52728
52878
  }
52729
52879
 
@@ -55410,10 +55560,17 @@ function groupResource(app, group, scope, mode) {
55410
55560
  lastSyncAt: dateTime(group.updatedAt)
55411
55561
  };
55412
55562
  }
55563
+ var withSuffix = (uid) => {
55564
+ const text2 = String(uid);
55565
+ return text2.endsWith("@g.us") ? text2 : `${text2}@g.us`;
55566
+ };
55567
+ var accountGroup = (app, uid) => [...app.store.groups.values()].find((g) => g.imported && g.uid === withSuffix(uid));
55413
55568
  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;
55569
+ const group = accountGroup(app, uid);
55570
+ return group && sees(scope, group.workspaceId) ? group : void 0;
55416
55571
  }
55572
+ var groupsOnWhatsApp = (app, device) => device.number === null ? [] : [...app.store.groups.values()].filter((g) => g.participants.some((p) => p.phone === device.number));
55573
+ var isAdminOf = (device, group) => group.participants.some((p) => p.admin && p.phone === device.number);
55417
55574
  function checkListQuery(q) {
55418
55575
  const errors = {};
55419
55576
  if (q.perPage !== void 0) {
@@ -55428,7 +55585,58 @@ function checkListQuery(q) {
55428
55585
  const first = Object.values(errors)[0];
55429
55586
  if (first) throw new RawApiError(422, { message: first[0], errors });
55430
55587
  }
55431
- var groupError = (status, code, message, extra = {}) => new ApiError(status, code, message, extra);
55588
+ var groupError = (status, code, message, extra = {}, headers = {}) => new ApiError(status, code, message, extra, headers);
55589
+ var groupNotFound = () => groupError(404, "group_not_found", "No encontramos ese grupo en tu cuenta.");
55590
+ var deviceNotConnected = () => groupError(422, "device_not_connected", "El n\xFAmero est\xE1 desconectado. Reconectalo e intent\xE1 de nuevo.");
55591
+ function importDevice(ctx, deviceId, connected) {
55592
+ const device = ctx.app.store.devices.get(Number(deviceId));
55593
+ if (!device || !sees(ctx.workspace, device.workspaceId)) throw groupError(404, "device_not_found", "No encontramos ese n\xFAmero en tu cuenta.");
55594
+ if (connected && (!device.connected || device.number === null)) throw deviceNotConnected();
55595
+ return device;
55596
+ }
55597
+ var IMPORTS_PER_MINUTE = 5;
55598
+ var GROUP_UID = /^[0-9]{10,25}(-[0-9]+)?(@g\.us)?$/;
55599
+ function importGroup(ctx, device, uid, forSend) {
55600
+ const { app } = ctx;
55601
+ if (ctx.permissions !== null && !ctx.permissions.includes("communities:operate")) {
55602
+ 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.");
55603
+ }
55604
+ 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.");
55605
+ if (!GROUP_UID.test(uid)) throw groupNotFound();
55606
+ if (device.kind === "cloud") {
55607
+ 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" });
55608
+ }
55609
+ const existing = accountGroup(app, uid);
55610
+ if (existing) {
55611
+ if (!sees(ctx.workspace, existing.workspaceId)) throw groupNotFound();
55612
+ return { group: existing, imported: false, isAdmin: isAdminOf(device, existing) };
55613
+ }
55614
+ const now = Date.now();
55615
+ const recent = (app.store.groupImports.get(device.id) ?? []).filter((at) => now - at < 6e4);
55616
+ if (recent.length >= IMPORTS_PER_MINUTE) {
55617
+ const retryAfter = Math.max(1, Math.ceil((6e4 - (now - recent[0])) / 1e3));
55618
+ throw groupError(
55619
+ 429,
55620
+ "import_rate_limited",
55621
+ `Este n\xFAmero ya import\xF3 ${IMPORTS_PER_MINUTE} grupos en el \xFAltimo minuto. Esper\xE1 ${retryAfter} segundos e intent\xE1 de nuevo.`,
55622
+ { retry_after: retryAfter },
55623
+ { "Retry-After": String(retryAfter) }
55624
+ );
55625
+ }
55626
+ app.store.groupImports.set(device.id, [...recent, now]);
55627
+ if (ctx.headers["x-sandbox-reject"] === "import_in_progress") {
55628
+ throw groupError(409, "import_in_progress", "Ese grupo se est\xE1 importando en otro pedido. Intent\xE1 de nuevo en unos segundos.");
55629
+ }
55630
+ const live = groupsOnWhatsApp(app, device).find((g) => g.uid === withSuffix(uid));
55631
+ if (!live) throw groupNotFound();
55632
+ const isAdmin = isAdminOf(device, live);
55633
+ 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.");
55634
+ live.imported = true;
55635
+ live.deviceId = device.id;
55636
+ live.workspaceId = ctx.workspace.id;
55637
+ live.updatedAt = /* @__PURE__ */ new Date();
55638
+ return { group: live, imported: true, isAdmin };
55639
+ }
55432
55640
  var MEDIA_EXTENSIONS = {
55433
55641
  image: ["jpg", "jpeg", "png"],
55434
55642
  video: ["mp4", "mov", "gif"],
@@ -55476,11 +55684,12 @@ function summarize2(errors) {
55476
55684
  }
55477
55685
  function resolveTarget(ctx, deviceId, uid) {
55478
55686
  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.");
55687
+ const device = importDevice(ctx, deviceId, false);
55688
+ const inAccount = accountGroup(app, uid);
55689
+ const importIt = !inAccount && device.kind === "qr";
55690
+ if (!importIt && (!inAccount || !sees(ctx.workspace, inAccount.workspaceId))) throw groupNotFound();
55691
+ if (!device.connected || device.number === null) throw deviceNotConnected();
55692
+ const group = importIt ? importGroup(ctx, device, String(uid), true).group : inAccount;
55484
55693
  if (device.kind === "cloud") {
55485
55694
  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
55695
  provider: "official",
@@ -55552,7 +55761,7 @@ var groupHandlers = {
55552
55761
  const key = String(q.orderBy ?? "created_at");
55553
55762
  const desc = (q.orderByMethod ?? "desc") === "desc";
55554
55763
  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) => {
55764
+ 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
55765
  const va = value(a);
55557
55766
  const vb = value(b);
55558
55767
  const cmp = va < vb ? -1 : va > vb ? 1 : 0;
@@ -55589,6 +55798,28 @@ var groupHandlers = {
55589
55798
  scheduleSend(ctx.app, action);
55590
55799
  return ok({ action_id: action.uuid, status: "queued" }, 202);
55591
55800
  },
55801
+ // Follow a group: import it to the account. `201` imported now, `200` already in the account; `is_admin` from WhatsApp.
55802
+ "POST /api/v1/groups/{group}/import": (ctx) => {
55803
+ const device = importDevice(ctx, body(ctx).device_id, true);
55804
+ const result = importGroup(ctx, device, String(ctx.path.group), false);
55805
+ return ok({ data: { uid: result.group.uid, name: result.group.name, imported: result.imported, is_admin: result.isAdmin } }, result.imported ? 201 : 200);
55806
+ },
55807
+ // The number's groups on WhatsApp, live (`GroupMinResource`): `device_id` only for one the account already has.
55808
+ "GET /api/v1/devices/{device}/groups": (ctx) => {
55809
+ const device = ctx.app.store.devices.get(Number(ctx.path.device));
55810
+ if (!device || !sees(ctx.workspace, device.workspaceId)) throw new RawApiError(404, { error: "Device not found" });
55811
+ if (!device.connected) throw new RawApiError(400, { error: "An error has occurred" });
55812
+ const name = ctx.query.name ? String(ctx.query.name).toLowerCase() : null;
55813
+ const items = (device.kind === "qr" ? groupsOnWhatsApp(ctx.app, device) : []).filter((g) => !name || g.name.toLowerCase().includes(name)).map((g) => {
55814
+ const own = g.imported && sees(ctx.workspace, g.workspaceId) ? ctx.app.store.devices.get(g.deviceId) : void 0;
55815
+ const deviceId = g.imported && sees(ctx.workspace, g.workspaceId) ? own && sees(ctx.workspace, own.workspaceId) ? own.id : null : "";
55816
+ return { uid: g.uid, name: g.name, description: g.description, device_id: deviceId, alias: "", number: "", communityUid: "" };
55817
+ });
55818
+ const page2 = Math.max(1, Number(ctx.query.page ?? 1));
55819
+ const perPage = Math.max(1, Number(ctx.query.pageSize ?? 250));
55820
+ const data = items.slice((page2 - 1) * perPage, page2 * perPage);
55821
+ return ok({ data, meta: { current_page: page2, last_page: Math.max(1, Math.ceil(items.length / perPage)), per_page: perPage, total: items.length } });
55822
+ },
55592
55823
  "GET /api/v1/groups/actions/{action}": (ctx) => {
55593
55824
  const action = ctx.app.store.groupActions.get(String(ctx.path.action));
55594
55825
  if (!action || action.workspaceId !== ctx.workspace.id) throw groupError(404, "action_not_found", "No encontramos esa acci\xF3n en tu cuenta.");
@@ -55605,8 +55836,9 @@ function createGroup(app, input) {
55605
55836
  description: "",
55606
55837
  deviceId: input.device.id,
55607
55838
  workspaceId: input.device.workspaceId,
55839
+ imported: input.imported ?? true,
55608
55840
  participants: [
55609
- { phone: input.device.number, name: null, admin: true },
55841
+ { phone: input.device.number, name: null, admin: input.admin ?? true },
55610
55842
  ...(input.participants ?? []).filter((p) => p.phone !== input.device.number).map((p) => ({ ...p, admin: false }))
55611
55843
  ],
55612
55844
  link: `https://chat.whatsapp.com/Sandbox${randomAlnum(20)}`,
@@ -56274,6 +56506,7 @@ function participantEvent(app, req, type) {
56274
56506
  if (type === "group_participant.joined" && !current) group.participants.push({ phone: identity, name: name === void 0 ? null : String(name), admin: false });
56275
56507
  if (type === "group_participant.left" && current) group.participants.splice(index2, 1);
56276
56508
  group.updatedAt = /* @__PURE__ */ new Date();
56509
+ if (!group.imported) return { status: 202, body: { event: null, participants: group.participants.length, imported: false } };
56277
56510
  const data2 = participantData(group, { phone: identity, name: name === void 0 ? current?.name ?? null : String(name) });
56278
56511
  return { status: 202, body: { event: emitted(app.webhooks.emit(type, data2, { workspaceId: group.workspaceId })), participants: group.participants.length } };
56279
56512
  }
@@ -56298,6 +56531,7 @@ var groupView = (group) => ({
56298
56531
  name: group.name,
56299
56532
  device_id: group.deviceId,
56300
56533
  workspace_id: group.workspaceId,
56534
+ imported: group.imported,
56301
56535
  participants: group.participants.map((p) => ({ phone: p.phone, name: p.name, admin: p.admin }))
56302
56536
  });
56303
56537
  var ROUTES = [
@@ -56366,7 +56600,14 @@ var ROUTES = [
56366
56600
  const b = obj(req.body);
56367
56601
  const id = b.workspace_id === void 0 || b.workspace_id === null ? app.store.defaultWorkspace.id : Number(b.workspace_id);
56368
56602
  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 } };
56603
+ let permissions = null;
56604
+ if (b.permissions !== void 0 && b.permissions !== null) {
56605
+ if (!Array.isArray(b.permissions) || b.permissions.some((p) => typeof p !== "string" || !PERMISSION.test(p))) {
56606
+ throw fieldError("permissions", 'permissions must be a list like ["communities:read", "communities:send"].');
56607
+ }
56608
+ permissions = [...new Set(b.permissions)];
56609
+ }
56610
+ return { status: 201, body: { key: mintKey(app.store, id, void 0, permissions), workspace_id: id, permissions } };
56370
56611
  }
56371
56612
  },
56372
56613
  { method: "POST", pattern: /^\/_sandbox\/inbound\/message$/, handle: inboundMessage },
@@ -56432,7 +56673,11 @@ var ROUTES = [
56432
56673
  const name = obj(p).name;
56433
56674
  return { phone, name: typeof name === "string" ? name : null };
56434
56675
  }) : [];
56435
- return { status: 201, body: { group: groupView(createGroup(app, { name: b.name.trim(), device, participants })) } };
56676
+ for (const field of ["imported", "admin"]) {
56677
+ if (b[field] !== void 0 && typeof b[field] !== "boolean") throw fieldError(field, `${field} must be true or false.`);
56678
+ }
56679
+ const group = createGroup(app, { name: b.name.trim(), device, participants, imported: b.imported, admin: b.admin });
56680
+ return { status: 201, body: { group: groupView(group) } };
56436
56681
  }
56437
56682
  },
56438
56683
  { method: "POST", pattern: /^\/_sandbox\/groups\/participant-joined$/, handle: (app, req) => participantEvent(app, req, "group_participant.joined") },
@@ -56724,7 +56969,8 @@ function createSandbox(options = {}) {
56724
56969
  headers,
56725
56970
  origin: `http://${headers.host ?? `localhost:${config.port}`}`,
56726
56971
  pathname: url.pathname,
56727
- workspace
56972
+ workspace,
56973
+ permissions: permissionsOfKey(app.store, key)
56728
56974
  };
56729
56975
  if (handler) return { result: await handler(ctx), operation: op.key, requestBody };
56730
56976
  const success = successResponse(op.operation);
@@ -56831,5 +57077,5 @@ function createSandbox(options = {}) {
56831
57077
  }
56832
57078
 
56833
57079
  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
57080
+ //# sourceMappingURL=chunk-MPXB6GIV.js.map
57081
+ //# sourceMappingURL=chunk-MPXB6GIV.js.map
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { ConfigError, parseArgs, VERSION, DEFAULT_PORT, DEFAULT_CONFIG_FILE, resolveConfig, createSandbox } from './chunk-FUK4UDIM.js';
2
+ import { ConfigError, parseArgs, VERSION, DEFAULT_PORT, DEFAULT_CONFIG_FILE, resolveConfig, createSandbox } from './chunk-MPXB6GIV.js';
3
3
 
4
4
  // src/cli.ts
5
5
  var HELP = `funnelchat-sandbox ${VERSION}
package/dist/index.d.ts CHANGED
@@ -252,6 +252,11 @@ interface Group {
252
252
  description: string;
253
253
  /** The number that is in the group (a QR number: the sandbox account has no official groups). */
254
254
  deviceId: number;
255
+ /**
256
+ * In the account (communities `groups`): listed by `GET /groups` and announcing who joins and leaves. `false`: a
257
+ * group of the number on WhatsApp only, seen by `GET /devices/{device}/groups` until it is imported.
258
+ */
259
+ imported: boolean;
255
260
  /** The workspace the group is in; `null` counts as the default one. */
256
261
  workspaceId: number | null;
257
262
  participants: GroupParticipant[];
@@ -432,8 +437,16 @@ declare class Sequence {
432
437
  declare class Store {
433
438
  readonly historyLimit: number;
434
439
  workspaces: Map<number, Workspace>;
435
- /** The sandbox keys minted for a workspace (`POST /_sandbox/workspaces`, `/_sandbox/keys`): any other key is the default one's. */
436
- keys: Map<string, number>;
440
+ /**
441
+ * The sandbox keys minted for a workspace (`POST /_sandbox/workspaces`, `/_sandbox/keys`), with their permissions
442
+ * (`null`: every one). Any other key is the default workspace's, with every permission.
443
+ */
444
+ keys: Map<string, {
445
+ workspaceId: number;
446
+ permissions: string[] | null;
447
+ }>;
448
+ /** When each number imported a group in the last minute: communities' 5 per minute. */
449
+ groupImports: Map<number, number[]>;
437
450
  devices: Map<number, Device>;
438
451
  contacts: Map<number, Contact>;
439
452
  messages: Map<number, Message>;
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
- export { 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, signature } from './chunk-FUK4UDIM.js';
1
+ export { 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, signature } from './chunk-MPXB6GIV.js';
2
2
  //# sourceMappingURL=index.js.map
3
3
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@funnelchat-hq/sandbox",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "A local server that imitates the Funnelchat public API and sends signed webhooks, with no real effects",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -2,26 +2,26 @@
2
2
  "description": "Where each file of spec/ came from. Written by `npm run generate -- --spec <file>` or `-- --pending <file>`; do not edit it by hand. spec/pending-sandbox.json belongs to this repo: its sources are inside it.",
3
3
  "snapshot": {
4
4
  "file": "spec/funnelchat.json",
5
- "sha256": "8228ea7369daed0655b779ca6af381355a6c1cc13dd702ed6e9c322779bafe94",
6
- "bytes": 1214988,
5
+ "sha256": "0ffaad2b25733d28b2cc07dde8152400c3c68974b3515e71e116ce599312b0fa",
6
+ "bytes": 1222005,
7
7
  "source": {
8
8
  "repository": "git@github.com:Funnelchat20/developers-portal.git",
9
9
  "path": "generated/specs/funnelchat-completa.json",
10
10
  "branch": "main",
11
- "commit": "f5464516",
11
+ "commit": "99928866",
12
12
  "commit_date": null,
13
13
  "file_tracked_in_git": false,
14
14
  "tracked_files_clean": null,
15
- "file_modified_at": "2026-11-05T16:59:01.577Z",
16
- "note": "Built by `npm run build` of developers-portal at the merge of PR #37, deployed as v0.6.2 on 05-11. Not the published /specs/ file, which carries only the visible operations. communities' snapshot was taken with the group actions on."
15
+ "file_modified_at": "2026-11-05T19:41:33.011Z",
16
+ "note": "Built by `npm run build` of developers-portal at the merge of PR #39, deployed as v0.6.3 on 05-11. Not the published /specs/ file, which carries only the visible operations. communities from feat/grupos-sin-panel 54643bbd (= v2.35.7 app code)."
17
17
  },
18
- "imported_at": "2026-11-05T17:01:38.054Z",
18
+ "imported_at": "2026-11-05T19:44:20.193Z",
19
19
  "spec": {
20
20
  "openapi": "3.1.0",
21
21
  "title": "Funnelchat API",
22
22
  "version": "v0",
23
- "paths": 217,
24
- "operations": 264,
23
+ "paths": 218,
24
+ "operations": 265,
25
25
  "webhooks": 29,
26
26
  "domains": [
27
27
  {
@@ -33,8 +33,8 @@
33
33
  },
34
34
  {
35
35
  "domain": "communities",
36
- "tag": "v2.35.5",
37
- "sha": "2be913d4",
36
+ "tag": "v2.35.7",
37
+ "sha": "54643bbd",
38
38
  "branch": null,
39
39
  "snapshot_at": null
40
40
  },