@funnelchat-hq/sandbox 0.3.0 → 0.3.1

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.
@@ -1928,7 +1928,7 @@ var openapi_default = {
1928
1928
  {
1929
1929
  name: "Message",
1930
1930
  "x-displayName": "Mensajes",
1931
- description: "Contiene el env\xEDo de un mensaje de texto o archivo indicando en el cuerpo el `contact_id` y el n\xFAmero desde el que sale (`device_id`). Para enviar desde la ruta del contacto o leer el historial de mensajes, mira Conversaciones. Requiere **Contactos y conversaciones \u2192 Enviar**; revisa la nota de disponibilidad de la operaci\xF3n."
1931
+ description: "Contiene el env\xEDo de un mensaje de texto o archivo indicando en el cuerpo el tel\xE9fono (`phone`) o el `contact_id` y el n\xFAmero desde el que sale (`device_id`); si el tel\xE9fono todav\xEDa no es un contacto, se crea. Para enviar desde la ruta del contacto o leer el historial de mensajes, mira Conversaciones. Requiere **Contactos y conversaciones \u2192 Enviar**; revisa la nota de disponibilidad de la operaci\xF3n."
1932
1932
  },
1933
1933
  {
1934
1934
  name: "ShowMessage"
@@ -1986,6 +1986,11 @@ var openapi_default = {
1986
1986
  "x-displayName": "Grupos",
1987
1987
  description: "Un grupo es un grupo de WhatsApp que administra uno de tus n\xFAmeros, con o sin campa\xF1a asignada; ac\xE1 tambi\xE9n aparecen los canales y los grupos de anuncios de una comunidad. Su ficha trae `uid`, nombre, link de invitaci\xF3n, administradores, si el due\xF1o est\xE1 conectado, participantes, clics, `status` e `isFull` (lleno). Puedes leer sus datos en vivo desde WhatsApp, sus acciones, sus mensajes y qu\xE9 env\xEDos programados lo incluyen."
1988
1988
  },
1989
+ {
1990
+ name: "Acciones de grupo",
1991
+ "x-displayName": "Acciones de grupo",
1992
+ description: "Lo que le pides a un grupo, como mandarle un mensaje, no se hace dentro de la llamada: se acepta con un `202` que trae `action_id` y se hace despu\xE9s, al ritmo de tu n\xFAmero. Con ese `action_id` consultas c\xF3mo termin\xF3: `queued`, `running`, `completed` o `failed`, y su resultado."
1993
+ },
1989
1994
  {
1990
1995
  name: "Participantes",
1991
1996
  "x-displayName": "Participantes",
@@ -2283,6 +2288,7 @@ var openapi_default = {
2283
2288
  "x-fc-bloque": "Piezas",
2284
2289
  tags: [
2285
2290
  "Grupos",
2291
+ "Acciones de grupo",
2286
2292
  "Participantes"
2287
2293
  ]
2288
2294
  },
@@ -9602,7 +9608,8 @@ var openapi_default = {
9602
9608
  "rejected",
9603
9609
  "cancelled",
9604
9610
  "refunded",
9605
- "chargeback"
9611
+ "chargeback",
9612
+ "abandoned_cart"
9606
9613
  ]
9607
9614
  }
9608
9615
  }
@@ -12829,7 +12836,7 @@ var openapi_default = {
12829
12836
  "/api/v1/message/send-message": {
12830
12837
  post: {
12831
12838
  operationId: "message.sendMessage",
12832
- description: "**Permiso requerido:** **Contactos y conversaciones \u2192 Enviar**",
12839
+ description: "**Permiso requerido:** **Contactos y conversaciones \u2192 Enviar**\n\nIndica 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`.",
12833
12840
  summary: "Enviar un mensaje",
12834
12841
  tags: [
12835
12842
  "Message"
@@ -12874,21 +12881,36 @@ var openapi_default = {
12874
12881
  content: {
12875
12882
  "application/json": {
12876
12883
  schema: {
12877
- type: "object",
12878
- properties: {
12879
- status: {
12880
- type: "string",
12881
- enum: [
12884
+ anyOf: [
12885
+ {
12886
+ type: "object",
12887
+ properties: {
12888
+ status: {
12889
+ type: "string",
12890
+ enum: [
12891
+ "error"
12892
+ ]
12893
+ },
12894
+ error: {
12895
+ type: "string"
12896
+ }
12897
+ },
12898
+ required: [
12899
+ "status",
12882
12900
  "error"
12883
12901
  ]
12884
12902
  },
12885
- error: {
12886
- type: "string"
12903
+ {
12904
+ type: "object",
12905
+ properties: {
12906
+ message: {
12907
+ type: "string"
12908
+ }
12909
+ },
12910
+ required: [
12911
+ "message"
12912
+ ]
12887
12913
  }
12888
- },
12889
- required: [
12890
- "status",
12891
- "error"
12892
12914
  ]
12893
12915
  }
12894
12916
  }
@@ -12930,6 +12952,7 @@ var openapi_default = {
12930
12952
  "window_closed",
12931
12953
  "antiblock_duplicate_content",
12932
12954
  "antiblock_unanswered_limit",
12955
+ "recipient_mismatch",
12933
12956
  "recipient_unsupported"
12934
12957
  ]
12935
12958
  },
@@ -13022,7 +13045,7 @@ var openapi_default = {
13022
13045
  }
13023
13046
  },
13024
13047
  "x-fc-permission": "conversations:send",
13025
- "x-fc-proposito": "Sirve para enviar un mensaje de texto o un archivo a un contacto, indicando en el cuerpo el `contact_id` y el n\xFAmero desde el que sale.",
13048
+ "x-fc-proposito": "Sirve para enviar un mensaje de texto o un archivo a un tel\xE9fono, aunque todav\xEDa no sea tu contacto (se crea solo), o a un contacto por su `contact_id`, indicando en el cuerpo el n\xFAmero desde el que sale.",
13026
13049
  "x-fc-domain": "conversations",
13027
13050
  "x-fc-pending": "conversations-send",
13028
13051
  "x-fc-pending-response": "conversations-send"
@@ -13853,7 +13876,7 @@ var openapi_default = {
13853
13876
  "/api/v1/devices/{device}/templates/{template}/send-template": {
13854
13877
  post: {
13855
13878
  operationId: "messageTemplate.send",
13856
- description: "**Permiso requerido:** **Contactos y conversaciones \u2192 Enviar**",
13879
+ description: "**Permiso requerido:** **Contactos y conversaciones \u2192 Enviar**\n\nIndica 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`.",
13857
13880
  summary: "Enviar una plantilla",
13858
13881
  tags: [
13859
13882
  "MessageTemplate"
@@ -13877,17 +13900,30 @@ var openapi_default = {
13877
13900
  }
13878
13901
  ],
13879
13902
  requestBody: {
13880
- required: true,
13881
13903
  content: {
13882
13904
  "application/json": {
13883
13905
  schema: {
13884
13906
  type: "object",
13885
13907
  properties: {
13908
+ contact_id: {
13909
+ type: [
13910
+ "integer",
13911
+ "null"
13912
+ ]
13913
+ },
13886
13914
  phone: {
13887
- type: "string"
13915
+ type: [
13916
+ "string",
13917
+ "null"
13918
+ ],
13919
+ maxLength: 255
13888
13920
  },
13889
- contact_id: {
13890
- type: "integer"
13921
+ whatsapp_id: {
13922
+ type: [
13923
+ "string",
13924
+ "null"
13925
+ ],
13926
+ maxLength: 255
13891
13927
  },
13892
13928
  template_variables: {
13893
13929
  type: "object",
@@ -13913,11 +13949,7 @@ var openapi_default = {
13913
13949
  pattern: "^[\\x21-\\x7E]{1,64}$",
13914
13950
  maxLength: 64
13915
13951
  }
13916
- },
13917
- required: [
13918
- "phone",
13919
- "contact_id"
13920
- ]
13952
+ }
13921
13953
  }
13922
13954
  }
13923
13955
  }
@@ -19371,134 +19403,31 @@ var openapi_default = {
19371
19403
  "x-fc-proposito": "\xDAsalo para listar los grupos de tu cuenta con filtros por tipo, estado, n\xFAmero o sin campa\xF1a asignada, por ejemplo para sincronizarlos con tu sistema.",
19372
19404
  "x-fc-domain": "communities",
19373
19405
  "x-fc-permission": "communities:read"
19374
- }
19375
- },
19376
- "/api/v1/devices/{device}/groups": {
19377
- get: {
19378
- operationId: "group.groups",
19379
- 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`).",
19380
- summary: "Listar los grupos que administra un n\xFAmero",
19406
+ },
19407
+ post: {
19408
+ operationId: "apiGroupAction.create",
19409
+ description: "**Permiso requerido:** **Grupos y campa\xF1as \u2192 Administrar grupos**\n\n**Nota:** As\xEDncrona: responde 202 con `action_id`; consulta el resultado en `GET /groups/actions/{action}`. Hasta 20 participantes iniciales, sin contar al propio n\xFAmero. Con un n\xFAmero QR la descripci\xF3n se pone justo despu\xE9s de crear el grupo, y las creaciones respetan la espera entre creaciones del n\xFAmero. El resultado trae el grupo y su link de invitaci\xF3n. Con un n\xFAmero oficial, `join_approval_required: true` crea el grupo pidiendo aprobaci\xF3n para entrar (no se puede cambiar despu\xE9s); con un n\xFAmero QR responde `422 provider_unsupported`.",
19410
+ summary: "Crea un grupo",
19381
19411
  tags: [
19382
- "Grupos"
19412
+ "Acciones de grupo"
19383
19413
  ],
19384
- parameters: [
19385
- {
19386
- name: "device",
19387
- in: "path",
19388
- required: true,
19389
- schema: {
19390
- type: "integer"
19391
- }
19392
- },
19393
- {
19394
- name: "page",
19395
- in: "query",
19396
- schema: {
19397
- type: "integer",
19398
- minimum: 1
19399
- }
19400
- },
19401
- {
19402
- name: "pageSize",
19403
- in: "query",
19404
- schema: {
19405
- type: "integer",
19406
- minimum: 1,
19407
- maximum: 250
19408
- }
19409
- },
19410
- {
19411
- name: "name",
19412
- in: "query",
19413
- schema: {
19414
- type: [
19415
- "string",
19416
- "null"
19417
- ],
19418
- maxLength: 255
19414
+ requestBody: {
19415
+ required: true,
19416
+ content: {
19417
+ "application/json": {
19418
+ schema: {
19419
+ $ref: "#/components/schemas/CreateApiGroupRequest"
19420
+ }
19419
19421
  }
19420
19422
  }
19421
- ],
19423
+ },
19422
19424
  responses: {
19423
- "200": {
19424
- description: "Array of `GroupMinResource`",
19425
- content: {
19426
- "application/json": {
19427
- schema: {
19428
- type: "object",
19429
- properties: {
19430
- data: {
19431
- type: "array",
19432
- items: {
19433
- $ref: "#/components/schemas/GroupMinResource"
19434
- }
19435
- },
19436
- meta: {
19437
- type: "object",
19438
- properties: {
19439
- current_page: {},
19440
- last_page: {},
19441
- per_page: {},
19442
- total: {
19443
- type: "integer"
19444
- }
19445
- },
19446
- required: [
19447
- "current_page",
19448
- "last_page",
19449
- "per_page",
19450
- "total"
19451
- ]
19452
- }
19453
- },
19454
- required: [
19455
- "data",
19456
- "meta"
19457
- ]
19458
- }
19459
- }
19460
- }
19461
- },
19462
- "400": {
19463
- description: "",
19425
+ "202": {
19426
+ description: "`ApiGroupActionAcceptedResource`",
19464
19427
  content: {
19465
19428
  "application/json": {
19466
19429
  schema: {
19467
- anyOf: [
19468
- {
19469
- type: "object",
19470
- properties: {
19471
- error: {
19472
- anyOf: [
19473
- {
19474
- type: "string"
19475
- },
19476
- {
19477
- type: "string",
19478
- enum: [
19479
- "An error has occurred"
19480
- ]
19481
- }
19482
- ]
19483
- }
19484
- },
19485
- required: [
19486
- "error"
19487
- ]
19488
- },
19489
- {
19490
- type: "object",
19491
- properties: {
19492
- error: {
19493
- type: "string",
19494
- const: "An error has occurred"
19495
- }
19496
- },
19497
- required: [
19498
- "error"
19499
- ]
19500
- }
19501
- ]
19430
+ $ref: "#/components/schemas/ApiGroupActionAcceptedResource"
19502
19431
  }
19503
19432
  }
19504
19433
  }
@@ -19509,39 +19438,19 @@ var openapi_default = {
19509
19438
  "403": {
19510
19439
  $ref: "#/components/responses/CommunitiesAuthorizationException"
19511
19440
  },
19512
- "404": {
19513
- description: "",
19514
- content: {
19515
- "application/json": {
19516
- schema: {
19517
- type: "object",
19518
- properties: {
19519
- error: {
19520
- type: "string",
19521
- const: "Device not found"
19522
- }
19523
- },
19524
- required: [
19525
- "error"
19526
- ]
19527
- }
19528
- }
19529
- }
19530
- },
19531
19441
  "422": {
19532
19442
  $ref: "#/components/responses/CommunitiesValidationException"
19533
19443
  }
19534
19444
  },
19535
- "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.",
19536
19445
  "x-fc-domain": "communities",
19537
- "x-fc-permission": "communities:read"
19446
+ "x-fc-permission": "communities:operate"
19538
19447
  }
19539
19448
  },
19540
- "/api/v1/devices/{device}/ad-groups": {
19449
+ "/api/v1/devices/{device}/groups": {
19541
19450
  get: {
19542
- operationId: "group.adGroups",
19451
+ operationId: "group.groups",
19543
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`).",
19544
- summary: "Listar los grupos de anuncios de un n\xFAmero",
19453
+ summary: "Listar los grupos que administra un n\xFAmero",
19545
19454
  tags: [
19546
19455
  "Grupos"
19547
19456
  ],
@@ -19696,16 +19605,16 @@ var openapi_default = {
19696
19605
  $ref: "#/components/responses/CommunitiesValidationException"
19697
19606
  }
19698
19607
  },
19699
- "x-fc-proposito": "\xDAsalo para listar los grupos de anuncios que administra un n\xFAmero (la respuesta indica la comunidad a la que pertenecen), por ejemplo para elegir cu\xE1les sumar a una campa\xF1a.",
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.",
19700
19609
  "x-fc-domain": "communities",
19701
19610
  "x-fc-permission": "communities:read"
19702
19611
  }
19703
19612
  },
19704
- "/api/v1/devices/{device}/channels": {
19613
+ "/api/v1/devices/{device}/ad-groups": {
19705
19614
  get: {
19706
- operationId: "group.channels",
19615
+ operationId: "group.adGroups",
19707
19616
  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`).",
19708
- summary: "Listar los canales de un n\xFAmero",
19617
+ summary: "Listar los grupos de anuncios de un n\xFAmero",
19709
19618
  tags: [
19710
19619
  "Grupos"
19711
19620
  ],
@@ -19718,17 +19627,6 @@ var openapi_default = {
19718
19627
  type: "integer"
19719
19628
  }
19720
19629
  },
19721
- {
19722
- name: "name",
19723
- in: "query",
19724
- schema: {
19725
- type: [
19726
- "string",
19727
- "null"
19728
- ],
19729
- maxLength: 255
19730
- }
19731
- },
19732
19630
  {
19733
19631
  name: "page",
19734
19632
  in: "query",
@@ -19745,11 +19643,22 @@ var openapi_default = {
19745
19643
  minimum: 1,
19746
19644
  maximum: 250
19747
19645
  }
19646
+ },
19647
+ {
19648
+ name: "name",
19649
+ in: "query",
19650
+ schema: {
19651
+ type: [
19652
+ "string",
19653
+ "null"
19654
+ ],
19655
+ maxLength: 255
19656
+ }
19748
19657
  }
19749
19658
  ],
19750
19659
  responses: {
19751
19660
  "200": {
19752
- description: "Array of `ChannelMinResource`",
19661
+ description: "Array of `GroupMinResource`",
19753
19662
  content: {
19754
19663
  "application/json": {
19755
19664
  schema: {
@@ -19758,7 +19667,7 @@ var openapi_default = {
19758
19667
  data: {
19759
19668
  type: "array",
19760
19669
  items: {
19761
- $ref: "#/components/schemas/ChannelMinResource"
19670
+ $ref: "#/components/schemas/GroupMinResource"
19762
19671
  }
19763
19672
  },
19764
19673
  meta: {
@@ -19793,18 +19702,182 @@ var openapi_default = {
19793
19702
  "application/json": {
19794
19703
  schema: {
19795
19704
  anyOf: [
19796
- {
19797
- type: "object",
19798
- properties: {
19799
- error: {
19800
- type: "string",
19801
- const: "Invalid response format from WAPI"
19802
- }
19803
- },
19804
- required: [
19805
- "error"
19806
- ]
19807
- },
19705
+ {
19706
+ type: "object",
19707
+ properties: {
19708
+ error: {
19709
+ anyOf: [
19710
+ {
19711
+ type: "string"
19712
+ },
19713
+ {
19714
+ type: "string",
19715
+ enum: [
19716
+ "An error has occurred"
19717
+ ]
19718
+ }
19719
+ ]
19720
+ }
19721
+ },
19722
+ required: [
19723
+ "error"
19724
+ ]
19725
+ },
19726
+ {
19727
+ type: "object",
19728
+ properties: {
19729
+ error: {
19730
+ type: "string",
19731
+ const: "An error has occurred"
19732
+ }
19733
+ },
19734
+ required: [
19735
+ "error"
19736
+ ]
19737
+ }
19738
+ ]
19739
+ }
19740
+ }
19741
+ }
19742
+ },
19743
+ "401": {
19744
+ $ref: "#/components/responses/CommunitiesAuthenticationException"
19745
+ },
19746
+ "403": {
19747
+ $ref: "#/components/responses/CommunitiesAuthorizationException"
19748
+ },
19749
+ "404": {
19750
+ description: "",
19751
+ content: {
19752
+ "application/json": {
19753
+ schema: {
19754
+ type: "object",
19755
+ properties: {
19756
+ error: {
19757
+ type: "string",
19758
+ const: "Device not found"
19759
+ }
19760
+ },
19761
+ required: [
19762
+ "error"
19763
+ ]
19764
+ }
19765
+ }
19766
+ }
19767
+ },
19768
+ "422": {
19769
+ $ref: "#/components/responses/CommunitiesValidationException"
19770
+ }
19771
+ },
19772
+ "x-fc-proposito": "\xDAsalo para listar los grupos de anuncios que administra un n\xFAmero (la respuesta indica la comunidad a la que pertenecen), por ejemplo para elegir cu\xE1les sumar a una campa\xF1a.",
19773
+ "x-fc-domain": "communities",
19774
+ "x-fc-permission": "communities:read"
19775
+ }
19776
+ },
19777
+ "/api/v1/devices/{device}/channels": {
19778
+ get: {
19779
+ operationId: "group.channels",
19780
+ 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`).",
19781
+ summary: "Listar los canales de un n\xFAmero",
19782
+ tags: [
19783
+ "Grupos"
19784
+ ],
19785
+ parameters: [
19786
+ {
19787
+ name: "device",
19788
+ in: "path",
19789
+ required: true,
19790
+ schema: {
19791
+ type: "integer"
19792
+ }
19793
+ },
19794
+ {
19795
+ name: "name",
19796
+ in: "query",
19797
+ schema: {
19798
+ type: [
19799
+ "string",
19800
+ "null"
19801
+ ],
19802
+ maxLength: 255
19803
+ }
19804
+ },
19805
+ {
19806
+ name: "page",
19807
+ in: "query",
19808
+ schema: {
19809
+ type: "integer",
19810
+ minimum: 1
19811
+ }
19812
+ },
19813
+ {
19814
+ name: "pageSize",
19815
+ in: "query",
19816
+ schema: {
19817
+ type: "integer",
19818
+ minimum: 1,
19819
+ maximum: 250
19820
+ }
19821
+ }
19822
+ ],
19823
+ responses: {
19824
+ "200": {
19825
+ description: "Array of `ChannelMinResource`",
19826
+ content: {
19827
+ "application/json": {
19828
+ schema: {
19829
+ type: "object",
19830
+ properties: {
19831
+ data: {
19832
+ type: "array",
19833
+ items: {
19834
+ $ref: "#/components/schemas/ChannelMinResource"
19835
+ }
19836
+ },
19837
+ meta: {
19838
+ type: "object",
19839
+ properties: {
19840
+ current_page: {},
19841
+ last_page: {},
19842
+ per_page: {},
19843
+ total: {
19844
+ type: "integer"
19845
+ }
19846
+ },
19847
+ required: [
19848
+ "current_page",
19849
+ "last_page",
19850
+ "per_page",
19851
+ "total"
19852
+ ]
19853
+ }
19854
+ },
19855
+ required: [
19856
+ "data",
19857
+ "meta"
19858
+ ]
19859
+ }
19860
+ }
19861
+ }
19862
+ },
19863
+ "400": {
19864
+ description: "",
19865
+ content: {
19866
+ "application/json": {
19867
+ schema: {
19868
+ anyOf: [
19869
+ {
19870
+ type: "object",
19871
+ properties: {
19872
+ error: {
19873
+ type: "string",
19874
+ const: "Invalid response format from WAPI"
19875
+ }
19876
+ },
19877
+ required: [
19878
+ "error"
19879
+ ]
19880
+ },
19808
19881
  {
19809
19882
  type: "object",
19810
19883
  properties: {
@@ -20450,15 +20523,12 @@ var openapi_default = {
20450
20523
  "x-fc-permission": "communities:read"
20451
20524
  },
20452
20525
  post: {
20453
- operationId: "pendingGroups.sendMessage",
20454
- summary: "Send a message to a group",
20526
+ 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.",
20528
+ summary: "Mandar un mensaje a un grupo",
20455
20529
  tags: [
20456
- "GroupAction"
20530
+ "Acciones de grupo"
20457
20531
  ],
20458
- "x-fc-domain": "communities",
20459
- "x-fc-permission": "communities:send",
20460
- "x-fc-pending": "communities-group-send",
20461
- description: "Sends a text or one file to the group now, from one of your numbers. Asynchronous: it answers `202` with `action_id`; the result arrives in `GET /api/v1/groups/actions/{action}` (`result.message.uid` is the id of the message in WhatsApp) and in the events `group_action.completed` and `group_action.failed`. A QR number goes out at the pace of the scheduled messages; an official number checks the 24-hour window and its quality first. It needs **Groups and campaigns \u2192 Send**. Rejections: `404 device_not_found`, `404 group_not_found`, `422 unsupported_group_type`, `422 device_not_connected`, `422 device_not_group_admin` (a QR number that is not admin), `422 device_not_group_number` (an official group of another number), `422 provider_unsupported`, `422 window_closed`, `422 number_restricted`, `422 unsupported_type` and `429 too_many_pending_actions`.",
20462
20532
  parameters: [
20463
20533
  {
20464
20534
  name: "group",
@@ -20466,8 +20536,7 @@ var openapi_default = {
20466
20536
  required: true,
20467
20537
  schema: {
20468
20538
  type: "string"
20469
- },
20470
- description: "The WhatsApp uid of the group (`\u2026@g.us`), the `uid` of `GET /api/v1/groups`."
20539
+ }
20471
20540
  }
20472
20541
  ],
20473
20542
  requestBody: {
@@ -20475,23 +20544,35 @@ var openapi_default = {
20475
20544
  content: {
20476
20545
  "application/json": {
20477
20546
  schema: {
20478
- $ref: "#/components/schemas/PendingGroupMessageCreate"
20547
+ $ref: "#/components/schemas/SendGroupMessageRequest"
20479
20548
  }
20480
20549
  }
20481
20550
  }
20482
20551
  },
20483
20552
  responses: {
20484
20553
  "202": {
20485
- description: "Queued.",
20554
+ description: "`ApiGroupActionAcceptedResource`",
20486
20555
  content: {
20487
20556
  "application/json": {
20488
20557
  schema: {
20489
- $ref: "#/components/schemas/PendingGroupActionAccepted"
20558
+ $ref: "#/components/schemas/ApiGroupActionAcceptedResource"
20490
20559
  }
20491
20560
  }
20492
20561
  }
20562
+ },
20563
+ "401": {
20564
+ $ref: "#/components/responses/CommunitiesAuthenticationException"
20565
+ },
20566
+ "403": {
20567
+ $ref: "#/components/responses/CommunitiesAuthorizationException"
20568
+ },
20569
+ "422": {
20570
+ $ref: "#/components/responses/CommunitiesValidationException"
20493
20571
  }
20494
- }
20572
+ },
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.",
20574
+ "x-fc-domain": "communities",
20575
+ "x-fc-permission": "communities:send"
20495
20576
  }
20496
20577
  },
20497
20578
  "/api/v1/groups/{group}/scheduled-messages": {
@@ -20711,6 +20792,767 @@ var openapi_default = {
20711
20792
  "x-fc-permission": "communities:read"
20712
20793
  }
20713
20794
  },
20795
+ "/api/v1/groups/actions/{action}": {
20796
+ get: {
20797
+ operationId: "apiGroupAction.show",
20798
+ description: "**Permiso requerido:** **Grupos y campa\xF1as \u2192 Ver**",
20799
+ summary: "Ver c\xF3mo sali\xF3 una acci\xF3n de grupo",
20800
+ tags: [
20801
+ "Acciones de grupo"
20802
+ ],
20803
+ parameters: [
20804
+ {
20805
+ name: "action",
20806
+ in: "path",
20807
+ required: true,
20808
+ schema: {
20809
+ type: "string"
20810
+ }
20811
+ }
20812
+ ],
20813
+ responses: {
20814
+ "200": {
20815
+ description: "The action.",
20816
+ content: {
20817
+ "application/json": {
20818
+ schema: {
20819
+ type: "object",
20820
+ properties: {
20821
+ data: {
20822
+ $ref: "#/components/schemas/PendingGroupAction"
20823
+ }
20824
+ },
20825
+ required: [
20826
+ "data"
20827
+ ]
20828
+ }
20829
+ }
20830
+ }
20831
+ },
20832
+ "401": {
20833
+ $ref: "#/components/responses/CommunitiesAuthenticationException"
20834
+ },
20835
+ "403": {
20836
+ $ref: "#/components/responses/CommunitiesAuthorizationException"
20837
+ }
20838
+ },
20839
+ "x-fc-proposito": "Sirve para saber c\xF3mo termin\xF3 lo que le pediste a un grupo, como mandarle un mensaje: si sigue en cola, se est\xE1 haciendo, termin\xF3 o fall\xF3, y su resultado.",
20840
+ "x-fc-domain": "communities",
20841
+ "x-fc-permission": "communities:read",
20842
+ "x-fc-pending": "communities-group-action",
20843
+ "x-fc-pending-response": "communities-group-action"
20844
+ }
20845
+ },
20846
+ "/api/v1/groups/{group}/participants": {
20847
+ post: {
20848
+ operationId: "apiGroupAction.addParticipants",
20849
+ description: "**Permiso requerido:** **Grupos y campa\xF1as \u2192 Administrar grupos**\n\n**Nota:** As\xEDncrona: responde 202 con `action_id`; consulta el resultado en `GET /groups/actions/{action}`. Hasta 20 tel\xE9fonos por pedido. Con un n\xFAmero QR cada tel\xE9fono se agrega por separado, uno tras otro, al ritmo de las campa\xF1as. El resultado informa cada persona como `added` o `failed`.",
20850
+ summary: "Suma participantes al grupo",
20851
+ tags: [
20852
+ "Acciones de grupo"
20853
+ ],
20854
+ parameters: [
20855
+ {
20856
+ name: "group",
20857
+ in: "path",
20858
+ required: true,
20859
+ schema: {
20860
+ type: "string"
20861
+ }
20862
+ }
20863
+ ],
20864
+ requestBody: {
20865
+ required: true,
20866
+ content: {
20867
+ "application/json": {
20868
+ schema: {
20869
+ $ref: "#/components/schemas/ApiGroupPhonesRequest"
20870
+ }
20871
+ }
20872
+ }
20873
+ },
20874
+ responses: {
20875
+ "202": {
20876
+ description: "`ApiGroupActionAcceptedResource`",
20877
+ content: {
20878
+ "application/json": {
20879
+ schema: {
20880
+ $ref: "#/components/schemas/ApiGroupActionAcceptedResource"
20881
+ }
20882
+ }
20883
+ }
20884
+ },
20885
+ "401": {
20886
+ $ref: "#/components/responses/CommunitiesAuthenticationException"
20887
+ },
20888
+ "403": {
20889
+ $ref: "#/components/responses/CommunitiesAuthorizationException"
20890
+ },
20891
+ "422": {
20892
+ $ref: "#/components/responses/CommunitiesValidationException"
20893
+ }
20894
+ },
20895
+ "x-fc-domain": "communities",
20896
+ "x-fc-permission": "communities:operate"
20897
+ }
20898
+ },
20899
+ "/api/v1/groups/{group}/participants/remove": {
20900
+ post: {
20901
+ operationId: "apiGroupAction.removeParticipants",
20902
+ description: "**Permiso requerido:** **Grupos y campa\xF1as \u2192 Administrar grupos**\n\n**Nota:** As\xEDncrona: responde 202 con `action_id`; consulta el resultado en `GET /groups/actions/{action}`. Hasta 20 tel\xE9fonos por pedido, sacados en una sola operaci\xF3n de WhatsApp. No admite el tel\xE9fono del propio n\xFAmero que ejecuta.",
20903
+ summary: "Saca participantes del grupo",
20904
+ tags: [
20905
+ "Acciones de grupo"
20906
+ ],
20907
+ parameters: [
20908
+ {
20909
+ name: "group",
20910
+ in: "path",
20911
+ required: true,
20912
+ schema: {
20913
+ type: "string"
20914
+ }
20915
+ }
20916
+ ],
20917
+ requestBody: {
20918
+ required: true,
20919
+ content: {
20920
+ "application/json": {
20921
+ schema: {
20922
+ $ref: "#/components/schemas/ApiGroupPhonesRequest"
20923
+ }
20924
+ }
20925
+ }
20926
+ },
20927
+ responses: {
20928
+ "202": {
20929
+ description: "`ApiGroupActionAcceptedResource`",
20930
+ content: {
20931
+ "application/json": {
20932
+ schema: {
20933
+ $ref: "#/components/schemas/ApiGroupActionAcceptedResource"
20934
+ }
20935
+ }
20936
+ }
20937
+ },
20938
+ "401": {
20939
+ $ref: "#/components/responses/CommunitiesAuthenticationException"
20940
+ },
20941
+ "403": {
20942
+ $ref: "#/components/responses/CommunitiesAuthorizationException"
20943
+ },
20944
+ "422": {
20945
+ $ref: "#/components/responses/CommunitiesValidationException"
20946
+ }
20947
+ },
20948
+ "x-fc-domain": "communities",
20949
+ "x-fc-permission": "communities:operate"
20950
+ }
20951
+ },
20952
+ "/api/v1/groups/{group}/admins": {
20953
+ post: {
20954
+ operationId: "apiGroupAction.promoteAdmins",
20955
+ description: "**Permiso requerido:** **Grupos y campa\xF1as \u2192 Administrar grupos**\n\n**Nota:** As\xEDncrona: responde 202 con `action_id`; consulta el resultado en `GET /groups/actions/{action}`. Hasta 20 tel\xE9fonos por pedido, en una sola operaci\xF3n de WhatsApp. Un tel\xE9fono que no est\xE1 en el grupo se agrega primero. Solo con n\xFAmeros QR. Se ejecuta al ritmo de las campa\xF1as del n\xFAmero.",
20956
+ summary: "Promueve tel\xE9fonos a administrador",
20957
+ tags: [
20958
+ "Acciones de grupo"
20959
+ ],
20960
+ parameters: [
20961
+ {
20962
+ name: "group",
20963
+ in: "path",
20964
+ required: true,
20965
+ schema: {
20966
+ type: "string"
20967
+ }
20968
+ }
20969
+ ],
20970
+ requestBody: {
20971
+ required: true,
20972
+ content: {
20973
+ "application/json": {
20974
+ schema: {
20975
+ $ref: "#/components/schemas/ApiGroupPhonesRequest"
20976
+ }
20977
+ }
20978
+ }
20979
+ },
20980
+ responses: {
20981
+ "202": {
20982
+ description: "`ApiGroupActionAcceptedResource`",
20983
+ content: {
20984
+ "application/json": {
20985
+ schema: {
20986
+ $ref: "#/components/schemas/ApiGroupActionAcceptedResource"
20987
+ }
20988
+ }
20989
+ }
20990
+ },
20991
+ "401": {
20992
+ $ref: "#/components/responses/CommunitiesAuthenticationException"
20993
+ },
20994
+ "403": {
20995
+ $ref: "#/components/responses/CommunitiesAuthorizationException"
20996
+ },
20997
+ "422": {
20998
+ $ref: "#/components/responses/CommunitiesValidationException"
20999
+ }
21000
+ },
21001
+ "x-fc-domain": "communities",
21002
+ "x-fc-permission": "communities:operate"
21003
+ }
21004
+ },
21005
+ "/api/v1/groups/{group}/admins/remove": {
21006
+ post: {
21007
+ operationId: "apiGroupAction.demoteAdmins",
21008
+ description: "**Permiso requerido:** **Grupos y campa\xF1as \u2192 Administrar grupos**\n\n**Nota:** As\xEDncrona: responde 202 con `action_id`; consulta el resultado en `GET /groups/actions/{action}`. Hasta 20 tel\xE9fonos por pedido, en una sola operaci\xF3n de WhatsApp. No admite el tel\xE9fono del propio n\xFAmero que ejecuta. Solo con n\xFAmeros QR. Se ejecuta al ritmo de las campa\xF1as del n\xFAmero.",
21009
+ summary: "Quita el rol de administrador a tel\xE9fonos",
21010
+ tags: [
21011
+ "Acciones de grupo"
21012
+ ],
21013
+ parameters: [
21014
+ {
21015
+ name: "group",
21016
+ in: "path",
21017
+ required: true,
21018
+ schema: {
21019
+ type: "string"
21020
+ }
21021
+ }
21022
+ ],
21023
+ requestBody: {
21024
+ required: true,
21025
+ content: {
21026
+ "application/json": {
21027
+ schema: {
21028
+ $ref: "#/components/schemas/ApiGroupPhonesRequest"
21029
+ }
21030
+ }
21031
+ }
21032
+ },
21033
+ responses: {
21034
+ "202": {
21035
+ description: "`ApiGroupActionAcceptedResource`",
21036
+ content: {
21037
+ "application/json": {
21038
+ schema: {
21039
+ $ref: "#/components/schemas/ApiGroupActionAcceptedResource"
21040
+ }
21041
+ }
21042
+ }
21043
+ },
21044
+ "401": {
21045
+ $ref: "#/components/responses/CommunitiesAuthenticationException"
21046
+ },
21047
+ "403": {
21048
+ $ref: "#/components/responses/CommunitiesAuthorizationException"
21049
+ },
21050
+ "422": {
21051
+ $ref: "#/components/responses/CommunitiesValidationException"
21052
+ }
21053
+ },
21054
+ "x-fc-domain": "communities",
21055
+ "x-fc-permission": "communities:operate"
21056
+ }
21057
+ },
21058
+ "/api/v1/groups/{group}/name": {
21059
+ put: {
21060
+ operationId: "apiGroupAction.updateName",
21061
+ description: "**Permiso requerido:** **Grupos y campa\xF1as \u2192 Administrar grupos**\n\n**Nota:** As\xEDncrona: responde 202 con `action_id`; consulta el resultado en `GET /groups/actions/{action}`. El nombre se env\xEDa tal cual, sin numeraci\xF3n de campa\xF1a. Hasta 100 caracteres. Se ejecuta al ritmo de las campa\xF1as del n\xFAmero.",
21062
+ summary: "Cambia el nombre del grupo",
21063
+ tags: [
21064
+ "Acciones de grupo"
21065
+ ],
21066
+ parameters: [
21067
+ {
21068
+ name: "group",
21069
+ in: "path",
21070
+ required: true,
21071
+ schema: {
21072
+ type: "string"
21073
+ }
21074
+ }
21075
+ ],
21076
+ requestBody: {
21077
+ required: true,
21078
+ content: {
21079
+ "application/json": {
21080
+ schema: {
21081
+ $ref: "#/components/schemas/ApiGroupNameRequest"
21082
+ }
21083
+ }
21084
+ }
21085
+ },
21086
+ responses: {
21087
+ "202": {
21088
+ description: "`ApiGroupActionAcceptedResource`",
21089
+ content: {
21090
+ "application/json": {
21091
+ schema: {
21092
+ $ref: "#/components/schemas/ApiGroupActionAcceptedResource"
21093
+ }
21094
+ }
21095
+ }
21096
+ },
21097
+ "401": {
21098
+ $ref: "#/components/responses/CommunitiesAuthenticationException"
21099
+ },
21100
+ "403": {
21101
+ $ref: "#/components/responses/CommunitiesAuthorizationException"
21102
+ },
21103
+ "422": {
21104
+ $ref: "#/components/responses/CommunitiesValidationException"
21105
+ }
21106
+ },
21107
+ "x-fc-domain": "communities",
21108
+ "x-fc-permission": "communities:operate"
21109
+ }
21110
+ },
21111
+ "/api/v1/groups/{group}/description": {
21112
+ put: {
21113
+ operationId: "apiGroupAction.updateDescription",
21114
+ description: "**Permiso requerido:** **Grupos y campa\xF1as \u2192 Administrar grupos**\n\n**Nota:** As\xEDncrona: responde 202 con `action_id`; consulta el resultado en `GET /groups/actions/{action}`. Hasta 2048 caracteres; una descripci\xF3n vac\xEDa la borra. Se ejecuta al ritmo de las campa\xF1as del n\xFAmero.",
21115
+ summary: "Cambia la descripci\xF3n del grupo",
21116
+ tags: [
21117
+ "Acciones de grupo"
21118
+ ],
21119
+ parameters: [
21120
+ {
21121
+ name: "group",
21122
+ in: "path",
21123
+ required: true,
21124
+ schema: {
21125
+ type: "string"
21126
+ }
21127
+ }
21128
+ ],
21129
+ requestBody: {
21130
+ required: true,
21131
+ content: {
21132
+ "application/json": {
21133
+ schema: {
21134
+ $ref: "#/components/schemas/ApiGroupDescriptionRequest"
21135
+ }
21136
+ }
21137
+ }
21138
+ },
21139
+ responses: {
21140
+ "202": {
21141
+ description: "`ApiGroupActionAcceptedResource`",
21142
+ content: {
21143
+ "application/json": {
21144
+ schema: {
21145
+ $ref: "#/components/schemas/ApiGroupActionAcceptedResource"
21146
+ }
21147
+ }
21148
+ }
21149
+ },
21150
+ "401": {
21151
+ $ref: "#/components/responses/CommunitiesAuthenticationException"
21152
+ },
21153
+ "403": {
21154
+ $ref: "#/components/responses/CommunitiesAuthorizationException"
21155
+ },
21156
+ "422": {
21157
+ $ref: "#/components/responses/CommunitiesValidationException"
21158
+ }
21159
+ },
21160
+ "x-fc-domain": "communities",
21161
+ "x-fc-permission": "communities:operate"
21162
+ }
21163
+ },
21164
+ "/api/v1/groups/{group}/photo": {
21165
+ put: {
21166
+ operationId: "apiGroupAction.updatePhoto",
21167
+ description: "**Permiso requerido:** **Grupos y campa\xF1as \u2192 Administrar grupos**\n\n**Nota:** As\xEDncrona: responde 202 con `action_id`; consulta el resultado en `GET /groups/actions/{action}`. `photo_url` debe ser una URL `https` de una imagen jpg, jpeg o png. Solo con n\xFAmeros QR. Se ejecuta al ritmo de las campa\xF1as del n\xFAmero.",
21168
+ summary: "Cambia la foto del grupo",
21169
+ tags: [
21170
+ "Acciones de grupo"
21171
+ ],
21172
+ parameters: [
21173
+ {
21174
+ name: "group",
21175
+ in: "path",
21176
+ required: true,
21177
+ schema: {
21178
+ type: "string"
21179
+ }
21180
+ }
21181
+ ],
21182
+ requestBody: {
21183
+ required: true,
21184
+ content: {
21185
+ "application/json": {
21186
+ schema: {
21187
+ $ref: "#/components/schemas/ApiGroupPhotoRequest"
21188
+ }
21189
+ }
21190
+ }
21191
+ },
21192
+ responses: {
21193
+ "202": {
21194
+ description: "`ApiGroupActionAcceptedResource`",
21195
+ content: {
21196
+ "application/json": {
21197
+ schema: {
21198
+ $ref: "#/components/schemas/ApiGroupActionAcceptedResource"
21199
+ }
21200
+ }
21201
+ }
21202
+ },
21203
+ "401": {
21204
+ $ref: "#/components/responses/CommunitiesAuthenticationException"
21205
+ },
21206
+ "403": {
21207
+ $ref: "#/components/responses/CommunitiesAuthorizationException"
21208
+ },
21209
+ "422": {
21210
+ $ref: "#/components/responses/CommunitiesValidationException"
21211
+ }
21212
+ },
21213
+ "x-fc-domain": "communities",
21214
+ "x-fc-permission": "communities:operate"
21215
+ }
21216
+ },
21217
+ "/api/v1/groups/{group}/settings": {
21218
+ put: {
21219
+ operationId: "apiGroupAction.updateSettings",
21220
+ description: "**Permiso requerido:** **Grupos y campa\xF1as \u2192 Administrar grupos**\n\n**Nota:** As\xEDncrona: responde 202 con `action_id`; consulta el resultado en `GET /groups/actions/{action}`. `members_can_send_messages`; bloquear la escritura tambi\xE9n deja a solo los administradores editando la informaci\xF3n del grupo. Solo con n\xFAmeros QR. Se ejecuta al ritmo de las campa\xF1as del n\xFAmero.",
21221
+ summary: "Cambia si los miembros pueden escribir",
21222
+ tags: [
21223
+ "Acciones de grupo"
21224
+ ],
21225
+ parameters: [
21226
+ {
21227
+ name: "group",
21228
+ in: "path",
21229
+ required: true,
21230
+ schema: {
21231
+ type: "string"
21232
+ }
21233
+ }
21234
+ ],
21235
+ requestBody: {
21236
+ required: true,
21237
+ content: {
21238
+ "application/json": {
21239
+ schema: {
21240
+ $ref: "#/components/schemas/ApiGroupSettingsRequest"
21241
+ }
21242
+ }
21243
+ }
21244
+ },
21245
+ responses: {
21246
+ "202": {
21247
+ description: "`ApiGroupActionAcceptedResource`",
21248
+ content: {
21249
+ "application/json": {
21250
+ schema: {
21251
+ $ref: "#/components/schemas/ApiGroupActionAcceptedResource"
21252
+ }
21253
+ }
21254
+ }
21255
+ },
21256
+ "401": {
21257
+ $ref: "#/components/responses/CommunitiesAuthenticationException"
21258
+ },
21259
+ "403": {
21260
+ $ref: "#/components/responses/CommunitiesAuthorizationException"
21261
+ },
21262
+ "422": {
21263
+ $ref: "#/components/responses/CommunitiesValidationException"
21264
+ }
21265
+ },
21266
+ "x-fc-domain": "communities",
21267
+ "x-fc-permission": "communities:operate"
21268
+ }
21269
+ },
21270
+ "/api/v1/groups/{group}/invite-link/renew": {
21271
+ post: {
21272
+ operationId: "apiGroupAction.renewInviteLink",
21273
+ description: "**Permiso requerido:** **Grupos y campa\xF1as \u2192 Administrar grupos**\n\n**Nota:** As\xEDncrona: responde 202 con `action_id`; consulta el resultado en `GET /groups/actions/{action}`. Con un n\xFAmero QR relee el link vigente y NO lo revoca (los proveedores QR de WhatsApp no exponen revocaci\xF3n ac\xE1); con un n\xFAmero oficial revoca el link y WhatsApp genera uno nuevo. El resultado trae `invite_link`.",
21274
+ summary: "Renueva el link de invitaci\xF3n del grupo",
21275
+ tags: [
21276
+ "Acciones de grupo"
21277
+ ],
21278
+ parameters: [
21279
+ {
21280
+ name: "group",
21281
+ in: "path",
21282
+ required: true,
21283
+ schema: {
21284
+ type: "string"
21285
+ }
21286
+ }
21287
+ ],
21288
+ requestBody: {
21289
+ required: true,
21290
+ content: {
21291
+ "application/json": {
21292
+ schema: {
21293
+ $ref: "#/components/schemas/ApiGroupRenewLinkRequest"
21294
+ }
21295
+ }
21296
+ }
21297
+ },
21298
+ responses: {
21299
+ "202": {
21300
+ description: "`ApiGroupActionAcceptedResource`",
21301
+ content: {
21302
+ "application/json": {
21303
+ schema: {
21304
+ $ref: "#/components/schemas/ApiGroupActionAcceptedResource"
21305
+ }
21306
+ }
21307
+ }
21308
+ },
21309
+ "401": {
21310
+ $ref: "#/components/responses/CommunitiesAuthenticationException"
21311
+ },
21312
+ "403": {
21313
+ $ref: "#/components/responses/CommunitiesAuthorizationException"
21314
+ },
21315
+ "422": {
21316
+ $ref: "#/components/responses/CommunitiesValidationException"
21317
+ }
21318
+ },
21319
+ "x-fc-domain": "communities",
21320
+ "x-fc-permission": "communities:operate"
21321
+ }
21322
+ },
21323
+ "/api/v1/groups/{group}/invite-link/live": {
21324
+ get: {
21325
+ operationId: "apiGroupAction.inviteLink",
21326
+ description: "**Permiso requerido:** **Grupos y campa\xF1as \u2192 Ver**\n\n**Nota:** Se lee en vivo del proveedor con el n\xFAmero indicado en `device_id`. L\xEDmite: 30 pedidos por minuto.",
21327
+ summary: "Link de invitaci\xF3n vigente del grupo",
21328
+ tags: [
21329
+ "Acciones de grupo"
21330
+ ],
21331
+ parameters: [
21332
+ {
21333
+ name: "group",
21334
+ in: "path",
21335
+ required: true,
21336
+ schema: {
21337
+ type: "string"
21338
+ }
21339
+ },
21340
+ {
21341
+ name: "device_id",
21342
+ in: "query",
21343
+ required: true,
21344
+ schema: {
21345
+ type: "integer"
21346
+ }
21347
+ }
21348
+ ],
21349
+ responses: {
21350
+ "200": {
21351
+ description: "El link de invitaci\xF3n vigente del grupo. Una lectura exitosa se reutiliza durante unos segundos.",
21352
+ content: {
21353
+ "application/json": {
21354
+ schema: {
21355
+ type: "object",
21356
+ properties: {
21357
+ data: {
21358
+ type: "object",
21359
+ properties: {
21360
+ invite_link: {
21361
+ type: "string"
21362
+ }
21363
+ },
21364
+ required: [
21365
+ "invite_link"
21366
+ ]
21367
+ }
21368
+ },
21369
+ required: [
21370
+ "data"
21371
+ ]
21372
+ }
21373
+ }
21374
+ }
21375
+ },
21376
+ "401": {
21377
+ $ref: "#/components/responses/CommunitiesAuthenticationException"
21378
+ },
21379
+ "403": {
21380
+ $ref: "#/components/responses/CommunitiesAuthorizationException"
21381
+ },
21382
+ "422": {
21383
+ $ref: "#/components/responses/CommunitiesValidationException"
21384
+ }
21385
+ },
21386
+ "x-fc-domain": "communities",
21387
+ "x-fc-permission": "communities:read"
21388
+ }
21389
+ },
21390
+ "/api/v1/groups/{group}/join-requests": {
21391
+ get: {
21392
+ operationId: "apiGroupAction.joinRequests",
21393
+ description: "**Permiso requerido:** **Grupos y campa\xF1as \u2192 Ver**\n\n**Nota:** Solo n\xFAmeros oficiales con la beta de grupos de la API oficial; un n\xFAmero QR recibe `422 provider_unsupported`. Se lee en vivo del proveedor con el n\xFAmero indicado en `device_id`; devuelve hasta 100 solicitudes, en el orden del proveedor, y omite las que no traen tel\xE9fono (no hay BSUID). L\xEDmite: 30 pedidos por minuto.",
21394
+ summary: "Solicitudes pendientes para unirse al grupo",
21395
+ tags: [
21396
+ "Acciones de grupo"
21397
+ ],
21398
+ parameters: [
21399
+ {
21400
+ name: "group",
21401
+ in: "path",
21402
+ required: true,
21403
+ schema: {
21404
+ type: "string"
21405
+ }
21406
+ },
21407
+ {
21408
+ name: "device_id",
21409
+ in: "query",
21410
+ required: true,
21411
+ schema: {
21412
+ type: "integer"
21413
+ }
21414
+ }
21415
+ ],
21416
+ responses: {
21417
+ "200": {
21418
+ description: "",
21419
+ content: {
21420
+ "application/json": {
21421
+ schema: {
21422
+ type: "object",
21423
+ properties: {
21424
+ data: {
21425
+ type: "array",
21426
+ items: {}
21427
+ }
21428
+ },
21429
+ required: [
21430
+ "data"
21431
+ ]
21432
+ }
21433
+ }
21434
+ }
21435
+ },
21436
+ "401": {
21437
+ $ref: "#/components/responses/CommunitiesAuthenticationException"
21438
+ },
21439
+ "403": {
21440
+ $ref: "#/components/responses/CommunitiesAuthorizationException"
21441
+ },
21442
+ "422": {
21443
+ $ref: "#/components/responses/CommunitiesValidationException"
21444
+ }
21445
+ },
21446
+ "x-fc-domain": "communities",
21447
+ "x-fc-permission": "communities:read"
21448
+ }
21449
+ },
21450
+ "/api/v1/groups/{group}/join-requests/approve": {
21451
+ post: {
21452
+ operationId: "apiGroupAction.approveJoinRequests",
21453
+ description: "**Permiso requerido:** **Grupos y campa\xF1as \u2192 Administrar grupos**\n\n**Nota:** As\xEDncrona: responde 202 con `action_id`; consulta el resultado en `GET /groups/actions/{action}`. Solo n\xFAmeros oficiales con la beta de grupos de la API oficial y con la habilitaci\xF3n de decisiones; si no, `422 provider_unsupported`. Verifica que los tel\xE9fonos entren en los lugares libres del grupo (`capacity_full`). El resultado es por persona.",
21454
+ summary: "Aprueba solicitudes para unirse al grupo",
21455
+ tags: [
21456
+ "Acciones de grupo"
21457
+ ],
21458
+ parameters: [
21459
+ {
21460
+ name: "group",
21461
+ in: "path",
21462
+ required: true,
21463
+ schema: {
21464
+ type: "string"
21465
+ }
21466
+ }
21467
+ ],
21468
+ requestBody: {
21469
+ required: true,
21470
+ content: {
21471
+ "application/json": {
21472
+ schema: {
21473
+ $ref: "#/components/schemas/ApiGroupPhonesRequest"
21474
+ }
21475
+ }
21476
+ }
21477
+ },
21478
+ responses: {
21479
+ "202": {
21480
+ description: "`ApiGroupActionAcceptedResource`",
21481
+ content: {
21482
+ "application/json": {
21483
+ schema: {
21484
+ $ref: "#/components/schemas/ApiGroupActionAcceptedResource"
21485
+ }
21486
+ }
21487
+ }
21488
+ },
21489
+ "401": {
21490
+ $ref: "#/components/responses/CommunitiesAuthenticationException"
21491
+ },
21492
+ "403": {
21493
+ $ref: "#/components/responses/CommunitiesAuthorizationException"
21494
+ },
21495
+ "422": {
21496
+ $ref: "#/components/responses/CommunitiesValidationException"
21497
+ }
21498
+ },
21499
+ "x-fc-domain": "communities",
21500
+ "x-fc-permission": "communities:operate"
21501
+ }
21502
+ },
21503
+ "/api/v1/groups/{group}/join-requests/reject": {
21504
+ post: {
21505
+ operationId: "apiGroupAction.rejectJoinRequests",
21506
+ description: "**Permiso requerido:** **Grupos y campa\xF1as \u2192 Administrar grupos**\n\n**Nota:** As\xEDncrona: responde 202 con `action_id`; consulta el resultado en `GET /groups/actions/{action}`. Solo n\xFAmeros oficiales con la beta de grupos de la API oficial y con la habilitaci\xF3n de decisiones; si no, `422 provider_unsupported`. El resultado es por persona.",
21507
+ summary: "Rechaza solicitudes para unirse al grupo",
21508
+ tags: [
21509
+ "Acciones de grupo"
21510
+ ],
21511
+ parameters: [
21512
+ {
21513
+ name: "group",
21514
+ in: "path",
21515
+ required: true,
21516
+ schema: {
21517
+ type: "string"
21518
+ }
21519
+ }
21520
+ ],
21521
+ requestBody: {
21522
+ required: true,
21523
+ content: {
21524
+ "application/json": {
21525
+ schema: {
21526
+ $ref: "#/components/schemas/ApiGroupPhonesRequest"
21527
+ }
21528
+ }
21529
+ }
21530
+ },
21531
+ responses: {
21532
+ "202": {
21533
+ description: "`ApiGroupActionAcceptedResource`",
21534
+ content: {
21535
+ "application/json": {
21536
+ schema: {
21537
+ $ref: "#/components/schemas/ApiGroupActionAcceptedResource"
21538
+ }
21539
+ }
21540
+ }
21541
+ },
21542
+ "401": {
21543
+ $ref: "#/components/responses/CommunitiesAuthenticationException"
21544
+ },
21545
+ "403": {
21546
+ $ref: "#/components/responses/CommunitiesAuthorizationException"
21547
+ },
21548
+ "422": {
21549
+ $ref: "#/components/responses/CommunitiesValidationException"
21550
+ }
21551
+ },
21552
+ "x-fc-domain": "communities",
21553
+ "x-fc-permission": "communities:operate"
21554
+ }
21555
+ },
20714
21556
  "/api/v1/campaigns/{campaign}/participants": {
20715
21557
  get: {
20716
21558
  operationId: "campaign.participants",
@@ -33905,51 +34747,6 @@ var openapi_default = {
33905
34747
  }
33906
34748
  }
33907
34749
  },
33908
- "/api/v1/groups/actions/{action}": {
33909
- get: {
33910
- operationId: "pendingGroups.retrieveAction",
33911
- summary: "A group action",
33912
- tags: [
33913
- "GroupAction"
33914
- ],
33915
- "x-fc-domain": "communities",
33916
- "x-fc-permission": "communities:read",
33917
- "x-fc-pending": "communities-group-send",
33918
- description: "Where an action is (`queued`, `running`, `completed` or `failed`) and its `result`. An action of another key's workspace, or that does not exist, is `404 action_not_found`.",
33919
- parameters: [
33920
- {
33921
- name: "action",
33922
- in: "path",
33923
- required: true,
33924
- schema: {
33925
- type: "string",
33926
- format: "uuid"
33927
- },
33928
- description: "The `action_id` the action returned."
33929
- }
33930
- ],
33931
- responses: {
33932
- "200": {
33933
- description: "OK",
33934
- content: {
33935
- "application/json": {
33936
- schema: {
33937
- type: "object",
33938
- properties: {
33939
- data: {
33940
- $ref: "#/components/schemas/PendingGroupAction"
33941
- }
33942
- },
33943
- required: [
33944
- "data"
33945
- ]
33946
- }
33947
- }
33948
- }
33949
- }
33950
- }
33951
- }
33952
- },
33953
34750
  "/api/v1/contacts/{contact}/message/poll": {
33954
34751
  post: {
33955
34752
  operationId: "pendingSandboxGate.poll",
@@ -39856,6 +40653,134 @@ var openapi_default = {
39856
40653
  ],
39857
40654
  title: "AlertSettingResource"
39858
40655
  },
40656
+ ApiGroupActionAcceptedResource: {
40657
+ type: "object",
40658
+ properties: {
40659
+ action_id: {
40660
+ type: "string"
40661
+ },
40662
+ status: {
40663
+ type: "string",
40664
+ const: "queued"
40665
+ }
40666
+ },
40667
+ required: [
40668
+ "action_id",
40669
+ "status"
40670
+ ],
40671
+ title: "ApiGroupActionAcceptedResource"
40672
+ },
40673
+ ApiGroupDescriptionRequest: {
40674
+ type: "object",
40675
+ description: "Cuerpo de `PUT /groups/{group}/description`: una descripci\xF3n vac\xEDa la borra, salvo con un n\xFAmero oficial,\nen el que WhatsApp no permite borrarla. Los campos que no figuran se ignoran.",
40676
+ properties: {
40677
+ device_id: {
40678
+ type: "integer"
40679
+ },
40680
+ description: {
40681
+ type: [
40682
+ "string",
40683
+ "null"
40684
+ ],
40685
+ maxLength: 2048
40686
+ }
40687
+ },
40688
+ required: [
40689
+ "device_id",
40690
+ "description"
40691
+ ],
40692
+ title: "ApiGroupDescriptionRequest"
40693
+ },
40694
+ ApiGroupNameRequest: {
40695
+ type: "object",
40696
+ 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.",
40697
+ properties: {
40698
+ device_id: {
40699
+ type: "integer"
40700
+ },
40701
+ name: {
40702
+ type: "string",
40703
+ minLength: 1,
40704
+ maxLength: 100
40705
+ }
40706
+ },
40707
+ required: [
40708
+ "device_id",
40709
+ "name"
40710
+ ],
40711
+ title: "ApiGroupNameRequest"
40712
+ },
40713
+ ApiGroupPhonesRequest: {
40714
+ type: "object",
40715
+ description: "Cuerpo de las acciones de grupo que reciben una lista de tel\xE9fonos (`device_id` y `phones`).\nLos tel\xE9fonos se normalizan y se quitan los repetidos antes de contarlos contra el m\xE1ximo por pedido.\nLos campos que no figuran se ignoran.",
40716
+ properties: {
40717
+ device_id: {
40718
+ type: "integer"
40719
+ },
40720
+ phones: {
40721
+ type: "array",
40722
+ items: {
40723
+ type: "string",
40724
+ maxLength: 32
40725
+ },
40726
+ minItems: 1,
40727
+ maxItems: 100
40728
+ }
40729
+ },
40730
+ required: [
40731
+ "device_id",
40732
+ "phones"
40733
+ ],
40734
+ title: "ApiGroupPhonesRequest"
40735
+ },
40736
+ ApiGroupPhotoRequest: {
40737
+ type: "object",
40738
+ description: "Cuerpo de `PUT /groups/{group}/photo`: una URL `https` de una imagen jpg, jpeg o png.\nLos campos que no figuran se ignoran.",
40739
+ properties: {
40740
+ device_id: {
40741
+ type: "integer"
40742
+ },
40743
+ photo_url: {
40744
+ type: "string",
40745
+ maxLength: 2048
40746
+ }
40747
+ },
40748
+ required: [
40749
+ "device_id",
40750
+ "photo_url"
40751
+ ],
40752
+ title: "ApiGroupPhotoRequest"
40753
+ },
40754
+ ApiGroupRenewLinkRequest: {
40755
+ type: "object",
40756
+ description: "Cuerpo de la renovaci\xF3n del link de invitaci\xF3n (`device_id`).",
40757
+ properties: {
40758
+ device_id: {
40759
+ type: "integer"
40760
+ }
40761
+ },
40762
+ required: [
40763
+ "device_id"
40764
+ ],
40765
+ title: "ApiGroupRenewLinkRequest"
40766
+ },
40767
+ ApiGroupSettingsRequest: {
40768
+ type: "object",
40769
+ description: "Cuerpo de `PUT /groups/{group}/settings`: el \xFAnico ajuste es si los miembros pueden enviar mensajes.\nLos campos que no figuran se ignoran.",
40770
+ properties: {
40771
+ device_id: {
40772
+ type: "integer"
40773
+ },
40774
+ members_can_send_messages: {
40775
+ type: "boolean"
40776
+ }
40777
+ },
40778
+ required: [
40779
+ "device_id",
40780
+ "members_can_send_messages"
40781
+ ],
40782
+ title: "ApiGroupSettingsRequest"
40783
+ },
39859
40784
  "App.Modules.ScheduledMessages.Resources.MessageTemplateResource": {
39860
40785
  type: "object",
39861
40786
  properties: {
@@ -40950,6 +41875,27 @@ var openapi_default = {
40950
41875
  },
40951
41876
  template_status: {
40952
41877
  type: "string"
41878
+ },
41879
+ quality_risk_accepted: {
41880
+ type: "object",
41881
+ properties: {
41882
+ at: {
41883
+ type: [
41884
+ "string",
41885
+ "null"
41886
+ ]
41887
+ },
41888
+ quality: {
41889
+ type: [
41890
+ "string",
41891
+ "null"
41892
+ ]
41893
+ }
41894
+ },
41895
+ required: [
41896
+ "at",
41897
+ "quality"
41898
+ ]
40953
41899
  }
40954
41900
  },
40955
41901
  required: [
@@ -40975,7 +41921,8 @@ var openapi_default = {
40975
41921
  "number_brake_until",
40976
41922
  "number_brake_lifted_at",
40977
41923
  "template_name",
40978
- "template_status"
41924
+ "template_status",
41925
+ "quality_risk_accepted"
40979
41926
  ]
40980
41927
  },
40981
41928
  {
@@ -43141,6 +44088,45 @@ var openapi_default = {
43141
44088
  ],
43142
44089
  title: "UserResource"
43143
44090
  },
44091
+ CreateApiGroupRequest: {
44092
+ type: "object",
44093
+ description: "Cuerpo de `POST /groups`: `device_id`, `name`, `description` opcional y `participants` (con un n\xFAmero QR,\nde 1 al m\xE1ximo permitido, tel\xE9fonos distintos y nunca el del propio n\xFAmero que crea el grupo).\nLos tel\xE9fonos se normalizan y se quitan los repetidos antes de contarlos. Los campos que no figuran se ignoran.",
44094
+ properties: {
44095
+ device_id: {
44096
+ type: "integer"
44097
+ },
44098
+ name: {
44099
+ type: "string",
44100
+ minLength: 1,
44101
+ maxLength: 100
44102
+ },
44103
+ description: {
44104
+ type: [
44105
+ "string",
44106
+ "null"
44107
+ ],
44108
+ maxLength: 2048
44109
+ },
44110
+ join_approval_required: {
44111
+ type: "boolean"
44112
+ },
44113
+ participants: {
44114
+ type: "array",
44115
+ items: {
44116
+ type: "string",
44117
+ maxLength: 32
44118
+ },
44119
+ minItems: 1,
44120
+ maxItems: 100
44121
+ }
44122
+ },
44123
+ required: [
44124
+ "device_id",
44125
+ "name",
44126
+ "participants"
44127
+ ],
44128
+ title: "CreateApiGroupRequest"
44129
+ },
43144
44130
  CustomDomainResource: {
43145
44131
  type: "object",
43146
44132
  properties: {
@@ -45922,7 +46908,8 @@ var openapi_default = {
45922
46908
  "rejected",
45923
46909
  "cancelled",
45924
46910
  "refunded",
45925
- "chargeback"
46911
+ "chargeback",
46912
+ "abandoned_cart"
45926
46913
  ],
45927
46914
  title: "PaymentEventTypeEnum"
45928
46915
  },
@@ -46527,7 +47514,24 @@ var openapi_default = {
46527
47514
  type: "string"
46528
47515
  },
46529
47516
  contact_id: {
46530
- type: "integer"
47517
+ type: [
47518
+ "integer",
47519
+ "null"
47520
+ ]
47521
+ },
47522
+ phone: {
47523
+ type: [
47524
+ "string",
47525
+ "null"
47526
+ ],
47527
+ maxLength: 255
47528
+ },
47529
+ whatsapp_id: {
47530
+ type: [
47531
+ "string",
47532
+ "null"
47533
+ ],
47534
+ maxLength: 255
46531
47535
  },
46532
47536
  file: {
46533
47537
  type: "string",
@@ -46592,11 +47596,46 @@ var openapi_default = {
46592
47596
  }
46593
47597
  },
46594
47598
  required: [
46595
- "device_id",
46596
- "contact_id"
47599
+ "device_id"
46597
47600
  ],
46598
47601
  title: "SendDirectMessageRequest"
46599
47602
  },
47603
+ SendGroupMessageRequest: {
47604
+ type: "object",
47605
+ description: "Cuerpo de `POST /groups/{group}/messages`: un texto o un solo archivo multimedia, que se env\xEDa en el momento.\nNo admite encuestas, eventos, stickers, contactos ni ubicaciones. Los campos que no figuran (por ejemplo,\n`shipping_speed`) se ignoran.",
47606
+ properties: {
47607
+ device_id: {
47608
+ type: "integer"
47609
+ },
47610
+ type: {
47611
+ type: "string",
47612
+ enum: [
47613
+ "text",
47614
+ "image",
47615
+ "video",
47616
+ "audio",
47617
+ "document"
47618
+ ]
47619
+ },
47620
+ text: {
47621
+ type: "string"
47622
+ },
47623
+ media_url: {
47624
+ type: "string"
47625
+ },
47626
+ caption: {
47627
+ type: "string"
47628
+ },
47629
+ file_name: {
47630
+ type: "string"
47631
+ }
47632
+ },
47633
+ required: [
47634
+ "device_id",
47635
+ "type"
47636
+ ],
47637
+ title: "SendGroupMessageRequest"
47638
+ },
46600
47639
  SendMessageRequest: {
46601
47640
  type: "object",
46602
47641
  properties: {
@@ -49679,77 +50718,6 @@ var openapi_default = {
49679
50718
  "external_reference"
49680
50719
  ]
49681
50720
  },
49682
- PendingGroupMessageCreate: {
49683
- type: "object",
49684
- description: "A text or one file, sent to the group right away. Polls, events, stickers, contacts and locations are not accepted.",
49685
- properties: {
49686
- device_id: {
49687
- type: "integer",
49688
- description: "The number of your account that sends it. A QR number must be admin of the group; an official number, the one the group belongs to."
49689
- },
49690
- type: {
49691
- type: "string",
49692
- enum: [
49693
- "text",
49694
- "image",
49695
- "video",
49696
- "audio",
49697
- "document"
49698
- ]
49699
- },
49700
- text: {
49701
- type: "string",
49702
- minLength: 1,
49703
- maxLength: 4096,
49704
- description: "Only with `type: text`, and required there."
49705
- },
49706
- media_url: {
49707
- type: "string",
49708
- maxLength: 255,
49709
- description: "A public `https` URL that ends in an extension of the type. Required with a file; not with `type: text`."
49710
- },
49711
- caption: {
49712
- type: [
49713
- "string",
49714
- "null"
49715
- ],
49716
- maxLength: 1024,
49717
- description: "Image or video only."
49718
- },
49719
- file_name: {
49720
- type: [
49721
- "string",
49722
- "null"
49723
- ],
49724
- maxLength: 255,
49725
- description: "Document only."
49726
- }
49727
- },
49728
- required: [
49729
- "device_id",
49730
- "type"
49731
- ]
49732
- },
49733
- PendingGroupActionAccepted: {
49734
- type: "object",
49735
- description: "The action is queued. Follow it with `GET /api/v1/groups/actions/{action}` or with the events `group_action.completed` and `group_action.failed`.",
49736
- properties: {
49737
- action_id: {
49738
- type: "string",
49739
- format: "uuid"
49740
- },
49741
- status: {
49742
- type: "string",
49743
- enum: [
49744
- "queued"
49745
- ]
49746
- }
49747
- },
49748
- required: [
49749
- "action_id",
49750
- "status"
49751
- ]
49752
- },
49753
50721
  PendingGroupParticipantResult: {
49754
50722
  type: "object",
49755
50723
  properties: {
@@ -49791,6 +50759,30 @@ var openapi_default = {
49791
50759
  "reason"
49792
50760
  ]
49793
50761
  },
50762
+ PendingDeviceQr: {
50763
+ type: "object",
50764
+ properties: {
50765
+ connected: {
50766
+ type: "boolean"
50767
+ },
50768
+ qr_code: {
50769
+ type: [
50770
+ "string",
50771
+ "null"
50772
+ ],
50773
+ description: "A PNG image as a data URI, or `null` when the number is already connected."
50774
+ },
50775
+ expires_in: {
50776
+ type: "integer",
50777
+ description: "Seconds after which to ask for it again: 5 with a QR, 3 while the session starts, 0 once connected. WhatsApp changes the code every few seconds and an old one no longer links: show the latest one and have it scanned right away; do not store or forward a QR."
50778
+ }
50779
+ },
50780
+ required: [
50781
+ "connected",
50782
+ "qr_code",
50783
+ "expires_in"
50784
+ ]
50785
+ },
49794
50786
  PendingGroupAction: {
49795
50787
  type: "object",
49796
50788
  properties: {
@@ -49935,30 +50927,6 @@ var openapi_default = {
49935
50927
  "completed_at",
49936
50928
  "failed_at"
49937
50929
  ]
49938
- },
49939
- PendingDeviceQr: {
49940
- type: "object",
49941
- properties: {
49942
- connected: {
49943
- type: "boolean"
49944
- },
49945
- qr_code: {
49946
- type: [
49947
- "string",
49948
- "null"
49949
- ],
49950
- description: "A PNG image as a data URI, or `null` when the number is already connected."
49951
- },
49952
- expires_in: {
49953
- type: "integer",
49954
- description: "Seconds after which to ask for it again: 5 with a QR, 3 while the session starts, 0 once connected. WhatsApp changes the code every few seconds and an old one no longer links: show the latest one and have it scanned right away; do not store or forward a QR."
49955
- }
49956
- },
49957
- required: [
49958
- "connected",
49959
- "qr_code",
49960
- "expires_in"
49961
- ]
49962
50930
  }
49963
50931
  },
49964
50932
  responses: {
@@ -50236,7 +51204,7 @@ var openapi_default = {
50236
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`.",
50237
51205
  operations: 128,
50238
51206
  excluded_routes: 20,
50239
- 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- **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.\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.'
51207
+ description: 'Public API of Funnelchat Conversations. Authenticate every request with `Authorization: Bearer <id>|fc_live_...`. Each operation shows the permission your key needs, as the Developers panel names it: **Contacts and conversations \u2192 View**, **Contacts and conversations \u2192 Edit** or **Contacts and conversations \u2192 Send**.\n\n## Webhooks\n\nLos webhooks avisan a un sistema externo, por HTTP, cuando algo pasa en tu cuenta, sin que tengas que consultar la API cada tanto. Registras una URL HTTPS, eliges los tipos de evento que quieres recibir y Funnelchat env\xEDa a esa URL un `POST` firmado cada vez que ocurre uno. Las rutas de administraci\xF3n requieren una clave de API y que la use el titular de la cuenta.\n\n### C\xF3mo suscribirse\n\nEstas son las nueve rutas para administrar suscripciones:\n\n| Ruta | Para qu\xE9 sirve | Permiso |\n| --- | --- | --- |\n| `GET /api/v1/webhooks` | Listar las suscripciones | Contactos y conversaciones \u2192 Ver |\n| `POST /api/v1/webhooks` | Crear una suscripci\xF3n | Contactos y conversaciones \u2192 Editar |\n| `GET /api/v1/webhooks/events` | Ver el cat\xE1logo de tipos de evento | Contactos y conversaciones \u2192 Ver |\n| `GET /api/v1/webhooks/{webhook}` | Ver una suscripci\xF3n | Contactos y conversaciones \u2192 Ver |\n| `PUT /api/v1/webhooks/{webhook}` | Cambiar URL, tipos o estado activo | Contactos y conversaciones \u2192 Editar |\n| `DELETE /api/v1/webhooks/{webhook}` | Eliminar una suscripci\xF3n | Contactos y conversaciones \u2192 Editar |\n| `POST /api/v1/webhooks/{webhook}/rotate-secret` | Generar un secreto nuevo | Contactos y conversaciones \u2192 Editar |\n| `POST /api/v1/webhooks/{webhook}/test` | Enviar ahora un evento de prueba | Contactos y conversaciones \u2192 Editar |\n| `GET /api/v1/webhooks/{webhook}/deliveries` | Ver el historial de entregas | Contactos y conversaciones \u2192 Ver |\n\nEjemplo de alta:\n\n```bash\ncurl -X POST "https://TU-HOST/api/v1/webhooks" \\\n -H "Authorization: Bearer <id>|fc_live_..." \\\n -H "Content-Type: application/json" \\\n -d \'{\n "url": "https://example.com/hooks/funnelchat",\n "description": "Mi integraci\xF3n",\n "events": ["message.received"]\n }\'\n```\n\nRespuesta `201`:\n\n```json\n{\n "data": {\n "uuid": "00000000-0000-4000-8000-000000000001",\n "url": "https://example.com/hooks/funnelchat",\n "description": "Mi integraci\xF3n",\n "events": ["message.received"],\n "active": true,\n "inactive_reason": null,\n "created_at": "2026-10-01T12:00:00+00:00",\n "updated_at": "2026-10-01T12:00:00+00:00",\n "secret": "<secreto de firma>"\n }\n}\n```\n\nEl `secret` se muestra **una sola vez**, en esta respuesta (y al rotarlo). Gu\xE1rdalo: no se puede volver a consultar. En `events` puedes usar los tipos `message.received`, `flow.completed`, `broadcast.finished`, `contact.stage_changed`, `message.status_changed`, `conversation.closed`, `conversation.assigned`, `contact.tag_added`, `broadcast.paused`, `contact.tag_removed`, `contact.created`, `template.status_changed`, `flow.failed`, `whatsapp_form.completed` o `*` para todos los dem\xE1s que se puedan suscribir (`message.status_changed` siempre se suscribe de forma expl\xEDcita: `*` no lo incluye). La URL debe ser `https` y apuntar a un destino p\xFAblico.\n\n### El sobre\n\nCada entrega es un `POST` con un cuerpo JSON con esta forma. El contenido de `data` depende del tipo de evento (ver el cat\xE1logo m\xE1s abajo).\n\n| Campo | Descripci\xF3n |\n| --- | --- |\n| `id` | Identificador \xFAnico del evento (`evt_...`). Es el mismo en todos los reintentos. |\n| `type` | Tipo de evento, por ejemplo `message.received`. |\n| `version` | Versi\xF3n del formato del evento (hoy `1`). |\n| `occurred_at` | Momento en que ocurri\xF3, en UTC (ISO 8601). |\n| `data` | Datos del evento. |\n\n### Cabeceras\n\n| Cabecera | Contenido |\n| --- | --- |\n| `Content-Type` | `application/json` |\n| `X-Funnelchat-Signature` | Firma de la entrega, con la forma `t=<timestamp>,v1=<firma>` |\n| `X-Funnelchat-Event` | Tipo de evento (igual que `type` en el cuerpo) |\n| `X-Funnelchat-Delivery` | Identificador de esta entrega (cambia en cada intento) |\n\n### C\xF3mo verificar la firma\n\nCada entrega lleva la cabecera `X-Funnelchat-Signature` con la forma `t=<timestamp>,v1=<firma>`. Para verificarla:\n\n1. Toma el **cuerpo crudo** de la petici\xF3n, tal como lleg\xF3, sin volver a codificar el JSON.\n2. Arma el texto `t + "." + cuerpo` (el timestamp, un punto y el cuerpo).\n3. Calcula su HMAC-SHA256 con el secreto de la suscripci\xF3n y comp\xE1ralo, en tiempo constante, con `v1`.\n4. Rechaza la entrega si `t` se aleja del reloj de tu servidor m\xE1s de 300 segundos.\n\nEjemplo en PHP:\n\n```php\nfunction verificarFirma(string $cuerpoCrudo, string $cabecera, string $secreto, int $tolerancia = 300): bool\n{\n parse_str(str_replace(\',\', \'&\', $cabecera), $partes);\n\n if (! isset($partes[\'t\'], $partes[\'v1\']) || ! ctype_digit($partes[\'t\'])) {\n return false;\n }\n\n if (abs(time() - (int) $partes[\'t\']) > $tolerancia) {\n return false;\n }\n\n $esperada = hash_hmac(\'sha256\', $partes[\'t\'].\'.\'.$cuerpoCrudo, $secreto);\n\n return hash_equals($esperada, $partes[\'v1\']);\n}\n```\n\nEjemplo en JavaScript (Node.js):\n\n```javascript\nconst crypto = require(\'crypto\');\n\nfunction verificarFirma(cuerpoCrudo, cabecera, secreto, tolerancia = 300) {\n const partes = Object.fromEntries(cabecera.split(\',\').map((p) => p.split(\'=\')));\n if (!/^\\d+$/.test(partes.t || \'\') || !partes.v1) return false;\n\n if (Math.abs(Date.now() / 1000 - Number(partes.t)) > tolerancia) return false;\n\n const esperada = crypto\n .createHmac(\'sha256\', secreto)\n .update(`${partes.t}.${cuerpoCrudo}`)\n .digest(\'hex\');\n\n const a = Buffer.from(esperada);\n const b = Buffer.from(partes.v1);\n\n return a.length === b.length && crypto.timingSafeEqual(a, b);\n}\n```\n\nEn JavaScript, lee el cuerpo como texto o `Buffer` antes de convertirlo a objeto (por ejemplo con `express.raw({ type: \'application/json\' })`).\n\n### Reintentos\n\nUna entrega es exitosa si tu servidor responde con un c\xF3digo `2xx` en 10 segundos o menos. Funnelchat no sigue redirecciones.\n\n- Se hacen hasta **4 intentos** por evento y suscripci\xF3n. Entre un intento y el siguiente se espera 60, 300, 1800 segundos.\n- Se reintenta cuando hay un error de red o un timeout, ante un `5xx` y ante un `429`.\n- Un `3xx` o cualquier otro `4xx` es terminal: no se reintenta.\n- Despu\xE9s del \xFAltimo intento fallido la entrega queda como `failed`, sin m\xE1s reintentos.\n\n### Estados de entrega\n\n`GET /api/v1/webhooks/{webhook}/deliveries` devuelve un registro por intento, con estos estados:\n\n| Estado | Significado |\n| --- | --- |\n| `pending` | El intento est\xE1 en curso: todav\xEDa no hay resultado. |\n| `succeeded` | Tu servidor respondi\xF3 `2xx`. |\n| `failed` con `next_attempt_at` | El intento fall\xF3 y hay otro programado para esa fecha. |\n| `failed` sin `next_attempt_at` | El intento fall\xF3 y no habr\xE1 m\xE1s: era terminal o se agotaron los intentos. |\n\n### Probar una suscripci\xF3n\n\n`POST /api/v1/webhooks/{webhook}/test` env\xEDa ahora un evento `webhook.test` firmado a la URL de la suscripci\xF3n, sin esperar a que ocurra nada en tu cuenta, y responde con el resultado del intento. Funciona tambi\xE9n sobre una suscripci\xF3n inactiva, para probarla antes de activarla.\n\nLa respuesta es un \xEDtem del historial de entregas, con la misma forma que cada elemento de `GET /api/v1/webhooks/{webhook}/deliveries`:\n\n```json\n{\n "data": {\n "uuid": "0d3c2f4e-6a1b-4c8d-9e2f-5b7a1c3d4e6f",\n "event_id": "evt_01hexample",\n "event_type": "webhook.test",\n "attempt": 1,\n "status": "succeeded",\n "http_status": 200,\n "duration_ms": 184,\n "error": null,\n "next_attempt_at": null,\n "created_at": "2026-01-15T12:00:00+00:00",\n "updated_at": "2026-01-15T12:00:00+00:00"\n}\n}\n```\n\n`status` es `succeeded` solo si tu servidor respondi\xF3 `2xx`; si no, es `failed` y `error` indica la causa. La prueba aparece en el historial con `event_type: "webhook.test"`, no se reintenta y no consume tu cupo de eventos.\n\n### Suscripciones creadas con una clave de API\n\nUna suscripci\xF3n creada, o reactivada, con una clave de API queda atada a esa clave. Tambi\xE9n la atan a una clave cambiar su URL con esa clave, o rotar su secreto con esa clave (rotarlo desde la sesi\xF3n web no modifica el v\xEDnculo): la suscripci\xF3n queda atada a la \xFAltima credencial que fij\xF3 su destino. Si la clave se revoca (o vence, que cuenta como revocada), la suscripci\xF3n deja de recibir eventos: pasa a `active: false` con `inactive_reason: "key_revoked"`, y no se le env\xEDan m\xE1s eventos.\n\nEse cambio se ve a partir del primer evento que ocurra despu\xE9s de revocar la clave. Hasta entonces, un `GET` puede seguir mostr\xE1ndola activa, pero no recibe eventos.\n\nLa prueba manual (`POST /api/v1/webhooks/{webhook}/test`) sigue funcionando sobre una suscripci\xF3n inactiva.\n\nUna suscripci\xF3n con `inactive_reason: "key_revoked"` no se puede reactivar: `PUT /api/v1/webhooks/{webhook}` con `"active": true` responde 422, sin importar la credencial que se use. Para seguir recibiendo eventos, crea una suscripci\xF3n nueva con `POST /api/v1/webhooks`. Una suscripci\xF3n que desactivaste a mano s\xED se puede reactivar con `PUT` y `"active": true`; queda atada a la credencial que la reactiva. Mientras est\xE9 activa, `inactive_reason` es `null`.\n\nLas suscripciones cuyo destino fij\xF3 por \xFAltima vez la sesi\xF3n web no dependen de ninguna clave: revocar una clave no las afecta.\n\n### Idempotencia\n\nUn mismo evento puede llegarte m\xE1s de una vez (por ejemplo, si tu servidor proces\xF3 la entrega pero respondi\xF3 tarde). Usa el campo `id` del sobre para descartar los que ya procesaste: es el mismo en todos los reintentos del evento.\n\n### Eventos disponibles\n\nSe generan desde el cat\xE1logo vigente. Los tipos marcados como **planificado** todav\xEDa no se emiten: se anuncian para que puedas prepararte, pero no los recibir\xE1s.\n\n`webhook.test` solo se env\xEDa con `POST /api/v1/webhooks/{webhook}/test`.\n\n#### `message.received`\n\nUn contacto le escribi\xF3 a un n\xFAmero de WhatsApp de la cuenta.\n\n```json\n{\n "id": "evt_000000000000000000000001",\n "type": "message.received",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "message": {\n "id": 1001,\n "type": "text",\n "text": "Hola, quiero m\xE1s informaci\xF3n",\n "media": null\n },\n "contact": {\n "id": 2001,\n "name": "Contacto de ejemplo",\n "phone": "+5491100000000"\n },\n "receiver": {\n "phone": "+5491100000001"\n },\n "device_id": 4521\n }\n}\n```\n\nValores posibles de `data.message.type`: `text`, `image`, `video`, `document`, `sticker`, `contact`, `audio`, `note`, `action`, `invalid`, `interactive`, `location`, `voice_call`, `video_call`, `event`, `button`, `template`, `notification`, `option_list`, `template_button_reply`, `poll`, `whatsapp_form`, `call_permission_request`, `call_permission_reply`, `request_contact_info`.\n\n`device_id` es el n\xFAmero de tu cuenta que recibi\xF3 el mensaje: el mismo `device_id` que mandas para responder por ese n\xFAmero. Es `null` si el mensaje no tiene n\xFAmero asociado.\n\n#### `flow.completed`\n\nUn contacto termin\xF3 un flujo de automatizaci\xF3n.\n\n```json\n{\n "id": "evt_000000000000000000000003",\n "type": "flow.completed",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "flow": {\n "id": 3001,\n "name": "Flujo de ejemplo"\n },\n "contact": {\n "id": 2001,\n "name": "Contacto de ejemplo",\n "phone": "+5491100000000"\n }\n }\n}\n```\n\nSe env\xEDa una sola vez por cada ejecuci\xF3n de un flujo, cuando termina con \xE9xito. No se env\xEDa si la ejecuci\xF3n se cort\xF3, se cancel\xF3, qued\xF3 bloqueada o fall\xF3. `contact` es `null` si la ejecuci\xF3n no tiene un contacto asociado. Un flujo muy usado puede generar muchos eventos seguidos.\n\n#### `broadcast.finished`\n\nUna campa\xF1a lleg\xF3 a su estado final (enviada, fallida o con fallos parciales), con sus conteos.\n\n```json\n{\n "id": "evt_000000000000000000000011",\n "type": "broadcast.finished",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "broadcast": {\n "id": 6001,\n "name": "Campa\xF1a de ejemplo"\n },\n "status": "partially_failed",\n "reason": "completed",\n "totals": {\n "processed": 1200,\n "failed": 30,\n "skipped_unreachable": 12\n }\n }\n}\n```\n\n`status` es `success`, `failed` o `partially_failed`. `reason` dice por qu\xE9 termin\xF3: `completed` (se enviaron todas las p\xE1ginas), `cancelled` (se cerr\xF3 una campa\xF1a pausada), `template_rejected` (Meta rechaz\xF3 la plantilla, o su categor\xEDa cambi\xF3, mientras la campa\xF1a esperaba; tambi\xE9n cuando Meta la paus\xF3 durante el env\xEDo y se detuvo la campa\xF1a), `approval_expired` (la plantilla no se aprob\xF3 a tiempo), `mac_lockout` (la cuenta alcanz\xF3 su l\xEDmite de contactos activos), `mac_check_unavailable` (la campa\xF1a la inici\xF3 una API key y el l\xEDmite de contactos activos no se pudo verificar, as\xED que no se envi\xF3 nada), `no_recipients` (la audiencia qued\xF3 vac\xEDa) o `error` (el env\xEDo no pudo despacharse). `totals` son los conteos del env\xEDo: `processed` (destinatarios procesados), `failed` y `skipped_unreachable` (destinatarios que el canal no puede alcanzar); valen 0 si la campa\xF1a termin\xF3 antes de enviar. Se emite una sola vez por campa\xF1a.\n\n#### `contact.stage_changed`\n\nUn contacto cambi\xF3 de etapa en un tablero.\n\n```json\n{\n "id": "evt_000000000000000000000008",\n "type": "contact.stage_changed",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "contact": {\n "id": 2001,\n "name": "Contacto de ejemplo",\n "phone": "+5491100000000"\n },\n "board": {\n "id": 5001,\n "name": "Embudo de ejemplo"\n },\n "kind": "move",\n "from_stage": {\n "id": 6001,\n "name": "Nuevo",\n "position": 0\n },\n "to_stage": {\n "id": 6002,\n "name": "En seguimiento",\n "position": 1\n }\n }\n}\n```\n\nSe env\xEDa un evento por cada contacto que cambia de etapa en un tablero por etapas. `kind` es entry (entra al tablero, `from_stage` es null), move (pasa de una etapa a otra), exit (sale del tablero, `to_stage` es null) o stage_deleted (se reubica porque su etapa se borr\xF3; el nombre de la etapa borrada es null). No se env\xEDa si el contacto se coloca en la etapa en la que ya estaba.\n\n#### `message.status_changed`\n\nCambi\xF3 el estado de entrega de un mensaje enviado.\n\n```json\n{\n "id": "evt_000000000000000000000014",\n "type": "message.status_changed",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "message_id": 1001,\n "contact_id": 2001,\n "device_id": 4521,\n "status": "failed",\n "previous_status": "sent",\n "error": {\n "code": 131047,\n "title": "Re-engagement message",\n "reason": null\n },\n "occurred_at": "2026-10-01T12:00:00Z",\n "client_reference": "pedido-1001"\n }\n}\n```\n\nSe emite cuando cambia el estado de entrega de un mensaje que enviaste a un contacto, ya sea desde el chat, una automatizaci\xF3n o un asistente de IA (incluye los enviados con clave). No se emite para mensajes de difusiones ni para mensajes recibidos. `message_id` es el id que devuelve la API, `contact_id` el del contacto y `device_id` el n\xFAmero de tu cuenta por el que sali\xF3 el mensaje, el mismo `device_id` que usas para enviar (`null` si no tiene). `status` es `sent`, `delivered`, `read` o `failed`. `previous_status` es el estado que el mensaje ten\xEDa justo antes de este cambio y es `null` en tres casos: es el primer aviso del mensaje, es el mismo estado que `status` (por ejemplo, el proveedor confirma el `sent` que ya registr\xF3 el env\xEDo) o no se pudo leer. `error` solo viene cuando `status` es `failed`: `code` es el c\xF3digo num\xE9rico del proveedor y `title` su t\xEDtulo, y cualquiera de los dos puede ser `null` si el proveedor no los informa. `error.reason` es `null` cuando el fallo lo inform\xF3 el proveedor; cuando Funnelchat decidi\xF3 que un mensaje enviado con clave no sal\xEDa (por ejemplo, por saldo insuficiente o ventana de 24 horas cerrada) trae el motivo (`device_unavailable`, `insufficient_balance`, `window_closed`, `recipient_unreachable`, `template_not_found`, `template_unresolved_placeholder`, `not_queued` o `not_sent`), `code` es `null` y `title` es un texto llano en el idioma de la cuenta. `occurred_at` es el momento en que Funnelchat registr\xF3 el estado, en UTC. `client_reference` es la referencia que mandaste al enviar el mensaje (por ejemplo `pedido-1001`) y `null` si el env\xEDo no la llevaba: sirve para relacionar cada aviso con tu env\xEDo tambi\xE9n cuando la llamada de env\xEDo no respondi\xF3. Cada estado se informa una sola vez por mensaje: si llegan varios avisos juntos se informa el m\xE1s avanzado y un aviso atrasado que no cambia el estado no genera evento. Un mensaje que no lleg\xF3 a salir (por ejemplo, por ventana de 24 horas cerrada) no recibe avisos de estado, salvo uno enviado con clave: ese recibe un \xFAnico aviso `failed` con `previous_status` en `null`, su `error.reason` y su `client_reference`. Un mensaje enviado con clave cuyo resultado no se conoce (el proveedor no respondi\xF3 o el env\xEDo se interrumpi\xF3) no recibe aviso. Un mensaje cuyo env\xEDo no empez\xF3 dentro de la hora ya no sale: pasada la hora sin resultado, su estado es `null`, o `failed` con `error.reason` `not_sent` y su aviso si su env\xEDo llega tarde. Reintentar es un env\xEDo nuevo, que s\xF3lo puede duplicar si el env\xEDo hab\xEDa empezado y se cort\xF3. Un mensaje `queued` no es enviado: su resultado llega por este evento o por la consulta del mensaje. En cuentas con mucho tr\xE1fico, los eventos que excedan el l\xEDmite de entrega por minuto de la cuenta se descartan. Para recibirlo debes suscribirte a `message.status_changed` de forma expl\xEDcita: una suscripci\xF3n a `*` no lo incluye.\n\n#### `conversation.closed`\n\nUna conversaci\xF3n pas\xF3 a cerrada.\n\n```json\n{\n "id": "evt_000000000000000000000004",\n "type": "conversation.closed",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "contact": {\n "id": 2001,\n "name": "Contacto de ejemplo",\n "phone": "+5491100000000"\n }\n }\n}\n```\n\nSe env\xEDa cuando una conversaci\xF3n pasa de abierta a cerrada. No se env\xEDa si la conversaci\xF3n ya estaba cerrada ni cuando se reabre.\n\n#### `conversation.assigned`\n\nUna conversaci\xF3n qued\xF3 asignada a una persona del equipo o a un agente de IA.\n\n```json\n{\n "id": "evt_000000000000000000000005",\n "type": "conversation.assigned",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "contact": {\n "id": 2001,\n "name": "Contacto de ejemplo",\n "phone": "+5491100000000"\n },\n "assignee": {\n "type": "human",\n "name": "Agente de ejemplo"\n }\n }\n}\n```\n\nSe env\xEDa cuando una conversaci\xF3n queda asignada a una persona del equipo (`assignee.type` = `human`) o a un agente de IA (`assignee.type` = `ai`), por cualquier camino. `assignee` trae el tipo y el nombre, nunca un identificador. No se env\xEDa al desasignar, ni cuando se vuelve a enviar el mismo asignado.\n\n#### `contact.tag_added`\n\nUn contacto recibi\xF3 una etiqueta.\n\n```json\n{\n "id": "evt_000000000000000000000006",\n "type": "contact.tag_added",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "contact": {\n "id": 2001,\n "name": "Contacto de ejemplo",\n "phone": "+5491100000000"\n },\n "tag": {\n "id": 4001,\n "name": "Etiqueta de ejemplo"\n },\n "source": "manual"\n }\n}\n```\n\nSe env\xEDa un evento por cada contacto y etiqueta agregados, tambi\xE9n en operaciones en bloque; no se env\xEDa si el contacto ya ten\xEDa la etiqueta. `source` indica el origen: manual, flow, bulk, import, sync, walink o ai. En operaciones en bloque grandes el l\xEDmite de volumen de la cuenta puede descartar parte de los eventos.\n\n#### `broadcast.paused`\n\nUna campa\xF1a se paus\xF3 sola, por ejemplo porque baj\xF3 la calidad del n\xFAmero.\n\n```json\n{\n "id": "evt_000000000000000000000007",\n "type": "broadcast.paused",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "broadcast": {\n "id": 6001,\n "name": "Campa\xF1a de ejemplo"\n },\n "reason": "quality_degraded",\n "quality": "YELLOW",\n "quality_baseline": "GREEN",\n "template": null\n }\n}\n```\n\n`reason` es `quality_degraded` (la calidad del n\xFAmero baj\xF3 respecto de la del inicio, o lleg\xF3 a RED), `quality_unreadable` (no se pudo leer la calidad en tres controles seguidos), `template_unavailable` (Meta dej\xF3 la plantilla en `PAUSED`, `DISABLED` o `REJECTED` antes de una tanda) o `rejection_rate` (en los env\xEDos recientes del n\xFAmero, por defecto las \xFAltimas 24 horas, Meta rechaz\xF3 una proporci\xF3n igual o mayor al umbral configurable: el c\xF3digo 131049, por defecto 5 %; el c\xF3digo 131026, por defecto 5 %; o los c\xF3digos que indican una restricci\xF3n de la cuenta o del n\xFAmero, por defecto 15 %, con la lista configurable y por defecto 131049, 131048, 131031 y 368). Con `rejection_rate` llega adem\xE1s `rejection`: `codes` (los que llegaron a su umbral: `131049`, `131026` o `failed`), `rates` (el porcentaje de cada uno), `attempts` (los env\xEDos medidos) y `window_hours`. `quality` y `quality_baseline` son la calidad de Meta le\xEDda en el control y la del inicio del env\xEDo (`GREEN`, `YELLOW`, `RED`), o `null` si no se pudo leer. `template` es `{name, status}` con el nombre de la plantilla y su estado en Meta cuando `reason` es `template_unavailable`, y `null` en las dem\xE1s pausas (por calidad o por rechazos). Con `rejection_rate` el n\xFAmero adem\xE1s queda frenado 7 d\xEDas (configurable): mientras dure no se pueden crear, ejecutar ni reanudar campa\xF1as desde ese n\xFAmero, ni mandar plantillas a contactos que no le escribieron en las \xFAltimas 24 horas, y solo soporte lo levanta antes. S\xF3lo las campa\xF1as con env\xEDo inteligente se pausan solas. Reanudar la campa\xF1a y que se pause otra vez emite otro evento.\n\n#### `contact.tag_removed`\n\nUn contacto perdi\xF3 una etiqueta.\n\n```json\n{\n "id": "evt_000000000000000000000009",\n "type": "contact.tag_removed",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "contact": {\n "id": 2001,\n "name": "Contacto de ejemplo",\n "phone": "+5491100000000"\n },\n "tag": {\n "id": 4001,\n "name": "Etiqueta de ejemplo"\n },\n "source": "manual"\n }\n}\n```\n\nSe env\xEDa un evento por cada contacto y etiqueta quitados, tambi\xE9n en operaciones en bloque; no se env\xEDa si el contacto no ten\xEDa la etiqueta. No se env\xEDa al borrar la etiqueta, al borrar o fusionar contactos ni al desvincular etiquetas de un tablero. `source` indica el origen: manual, flow, bulk o ai. En operaciones en bloque grandes el l\xEDmite de volumen de la cuenta puede descartar parte de los eventos.\n\n#### `contact.created`\n\nSe cre\xF3 un contacto nuevo en la cuenta.\n\n```json\n{\n "id": "evt_000000000000000000000010",\n "type": "contact.created",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "contact": {\n "id": 2001,\n "name": "Contacto de ejemplo",\n "phone": "+5491100000000",\n "email": "contacto@example.com"\n }\n }\n}\n```\n\nSe env\xEDa un evento por cada contacto nuevo, sea cual sea el canal: un mensaje entrante, la creaci\xF3n manual, una importaci\xF3n o la API. No se env\xEDa cuando el contacto ya exist\xEDa y s\xF3lo se actualiza, ni para los contactos que la plataforma crea para sus propias notificaciones. En importaciones grandes el l\xEDmite de volumen de la cuenta puede descartar parte de los eventos.\n\n#### `template.status_changed`\n\nUna plantilla cambi\xF3 de estado de aprobaci\xF3n en Meta.\n\n```json\n{\n "id": "evt_000000000000000000000012",\n "type": "template.status_changed",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "template": {\n "id": 7001,\n "uid": "1234567890",\n "name": "plantilla_ejemplo",\n "category": "MARKETING"\n },\n "previous_status": "PENDING",\n "status": "APPROVED",\n "reason": null\n }\n}\n```\n\n`template` identifica la plantilla igual que `GET /templates` (`id`, `uid`, `name`, `category`). `previous_status` y `status` son los estados tal como los devuelve la API (por ejemplo `PENDING`, `APPROVED`, `REJECTED`, `PAUSED`); `previous_status` es `null` si la plantilla no ten\xEDa estado. `reason` trae la observaci\xF3n de rechazo (traducida como en la API y limitada a 500 caracteres) cuando el cambio es un rechazo informado al crear la plantilla, y es `null` en el resto. No se emite si Meta reenv\xEDa el estado que la plantilla ya ten\xEDa. Si una misma plantilla de Meta figura en varias cuentas, cada cuenta recibe su propio evento.\n\n#### `flow.failed`\n\nLa ejecuci\xF3n de un flujo para un contacto termin\xF3 por un error.\n\n```json\n{\n "id": "evt_000000000000000000000013",\n "type": "flow.failed",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "flow": {\n "id": 3001,\n "name": "Flujo de ejemplo"\n },\n "contact": {\n "id": 2001,\n "name": "Contacto de ejemplo",\n "phone": "+5491100000000"\n },\n "reason": "execution_error"\n }\n}\n```\n\nSe env\xEDa una sola vez por cada ejecuci\xF3n de un flujo que termina por un error, no porque lleg\xF3 al final. `reason` indica el motivo y toma uno de estos valores: `node_missing` (el paso a ejecutar ya no existe), `unknown_node_type` (el paso es de un tipo no reconocido), `missing_subflow` (el flujo que deb\xEDa ejecutarse dentro de otro ya no existe), `malformed_node` (el paso tiene una configuraci\xF3n da\xF1ada), `loop_detected` (la ejecuci\xF3n dio vueltas sin esperar nada) y `execution_error` (un error inesperado detuvo la ejecuci\xF3n). No se env\xEDa si la ejecuci\xF3n se cancel\xF3, qued\xF3 bloqueada o se cort\xF3 porque se edit\xF3 el flujo o se unieron contactos. `contact` es `null` si la ejecuci\xF3n no tiene un contacto asociado.\n\n#### `whatsapp_form.completed`\n\nUn contacto complet\xF3 un formulario de WhatsApp.\n\n```json\n{\n "id": "evt_000000000000000000000015",\n "type": "whatsapp_form.completed",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "form": {\n "id": 4001,\n "name": "Captar un lead",\n "version_id": 4002\n },\n "message_id": 1001,\n "contact_id": 2001,\n "device_id": 5001,\n "answers": [\n {\n "key": "nombre",\n "label": "Nombre",\n "value": "Contacto de ejemplo"\n },\n {\n "key": "intereses",\n "label": "Intereses",\n "value": [\n "op_a",\n "op_b"\n ]\n },\n {\n "key": "comentarios",\n "label": "Comentarios",\n "value": null\n }\n ],\n "occurred_at": "2026-10-01T12:00:00Z"\n }\n}\n```\n\nSe env\xEDa cuando un contacto completa un formulario, aunque la respuesta llegue tarde. Puede llegar m\xE1s de una vez con el mismo `id`: deduplica por `id`. Si el contacto vuelve a enviar el mismo formulario, no se genera otro aviso: queda la primera respuesta. Cada respuesta de texto llega con a lo sumo 600 caracteres. `form.id` es el formulario (el mismo `family_id` de la lectura de formularios) y `form.version_id` la versi\xF3n que recibi\xF3 el contacto. `answers` es una lista con una entrada por pregunta, en el orden del formulario: `key` es el nombre de la pregunta, `label` su etiqueta y `value` la respuesta (texto, n\xFAmero, `true` o `false`, una lista de textos en opci\xF3n m\xFAltiple, o `null` si no la contest\xF3). En las preguntas de lista y de opci\xF3n m\xFAltiple, `value` es el id de la opci\xF3n. `message_id` es el id del mensaje del formulario y es `null` si el env\xEDo no tuvo un mensaje p\xFAblico; `device_id` es el n\xFAmero de WhatsApp por el que sali\xF3, o `null` si ya no es de la cuenta. `contact_id` es `null` si el contacto se elimin\xF3 antes de que saliera el aviso (las respuestas llegan igual); si se fusion\xF3 con otro, es el id del contacto que qued\xF3. Mientras se fusionan dos contactos, el id puede ser el del contacto que se est\xE1 fusionando.\n\n#### `webhook.test`\n\nEvento de prueba enviado desde la ruta de prueba de una suscripci\xF3n.\n\n```json\n{\n "id": "evt_000000000000000000000002",\n "type": "webhook.test",\n "version": 1,\n "occurred_at": "2026-10-01T12:00:00Z",\n "data": {\n "subscription": "00000000-0000-4000-8000-000000000001",\n "sent_at": "2026-10-01T12:00:00Z"\n }\n}\n```\n\n### Otros webhooks de la plataforma\n\nLos webhooks de alertas y el nodo \xABPetici\xF3n HTTP\xBB de las automatizaciones son otros productos, con otro formato y otra configuraci\xF3n. No se administran con las rutas de esta secci\xF3n ni llevan este sobre ni esta firma.\n\n## Listados\n\nCon una clave de API, los listados siempre responden una p\xE1gina con la forma `{ "data": [...], "links": {...}, "meta": {...} }`: `data` trae los \xEDtems de la p\xE1gina, `links` las URL de `first`, `last`, `prev` y `next` (`null` cuando no hay p\xE1gina), y `meta` el detalle (`current_page`, `from`, `last_page`, `links`, `path`, `per_page`, `to`, `total`).\n\n- **Tama\xF1o de p\xE1gina:** el par\xE1metro `perPage` es un entero entre 1 y 100. Si no lo mandas, la p\xE1gina trae 25 \xEDtems. Un valor mayor que 100, menor que 1 o que no sea un entero responde 422 con el detalle en `errors.perPage`. Algunos listados aceptan adem\xE1s `per_page` como alias con los mismos l\xEDmites; si mandas `perPage` y `per_page` con valores distintos, la respuesta es 422 con el detalle en `errors.perPage`.\n- **Orden:** cada listado documenta los par\xE1metros de orden que admite y los valores permitidos de cada uno (por ejemplo `orderBy` y `orderByMethod`). Un par\xE1metro de orden que el listado no admite, o un valor fuera de la lista, responde 422 con el detalle en `errors.<par\xE1metro>`.\n- **P\xE1ginas siguientes:** las URL de `links` y de `meta.links` conservan `perPage`, el orden y los filtros que admite el listado; los dem\xE1s par\xE1metros no se copian y s\xF3lo cambia `page`. En los listados que se piden por `POST`, los filtros viajan en el cuerpo y las URL llevan s\xF3lo `perPage` y `page`.\n- **Primero el permiso:** si tu clave no tiene el permiso del listado, la respuesta es la de siempre (401 o 403), aunque mandes un `perPage` inv\xE1lido.\n\n## Env\xEDo de mensajes con clave\n\nCuando env\xEDas con una clave de API, antes de enviar se aplican reglas de protecci\xF3n de tu n\xFAmero y de tus contactos. Si una regla frena el env\xEDo, la respuesta es un JSON `{ "error": "<c\xF3digo>", "message": "<explicaci\xF3n>" }` y no se env\xEDa nada ni se registra ning\xFAn mensaje. Cada operaci\xF3n de env\xEDo documenta los c\xF3digos que puede responder.\n- **A qui\xE9n env\xEDas con `POST /message/send-message` y `POST /devices/{device}/templates/{template_id}/send-template`:** Indica a qui\xE9n env\xEDas con `phone`: el tel\xE9fono con c\xF3digo de pa\xEDs, por ejemplo `5491122334455`. No hace falta crear el contacto antes: si ese tel\xE9fono todav\xEDa no es un contacto de tu cuenta, se crea en el workspace de tu clave, igual que con `POST /contacts/update-or-create` y con el mismo permiso, que **Enviar** ya incluye. La respuesta trae su id en `message.contact_id`, el mismo que llevan los avisos `message.status_changed`. Si el tel\xE9fono no es v\xE1lido, o ya es un contacto de otro workspace de tu cuenta, la respuesta es la misma que dar\xEDa `POST /contacts/update-or-create`: 422, o 404 sin datos de ese contacto. Con un n\xFAmero oficial de WhatsApp (API Cloud) tambi\xE9n puedes indicar `whatsapp_id`, el BSUID que env\xEDa Meta (por ejemplo `US.13491208655302741918`), en lugar de `phone`; usado as\xED con un n\xFAmero conectado por QR, responde 422. En un n\xFAmero oficial, el primer mensaje a un contacto nuevo tiene que ser una plantilla: un mensaje libre solo sale dentro de las 24 horas desde que el contacto te escribi\xF3. Si ya tienes el `contact_id`, puedes seguir envi\xE1ndolo: si junto con \xE9l mandas `phone` o `whatsapp_id`, tienen que ser de ese mismo contacto (si no, 422 `recipient_mismatch`). En `send-template`, `phone` o `whatsapp_id` va siempre, con o sin `contact_id`.\n- **Ritmo:** el l\xEDmite depende s\xF3lo del tipo de n\xFAmero por el que env\xEDas; en los env\xEDos no hay un l\xEDmite por minuto por clave ni por cuenta. Al superarlo, la respuesta es 429 con `error` = `rate_limited` y el encabezado `Retry-After`, que trae los segundos que debes esperar antes de reintentar. Las reacciones s\xF3lo tienen esta regla.\n - **N\xFAmero de WhatsApp Cloud (API oficial):** hasta 60 mensajes por segundo y hasta 10 mensajes por minuto al mismo contacto.\n - **N\xFAmero en coexistencia** (el mismo tel\xE9fono conectado a WhatsApp Cloud y a la aplicaci\xF3n WhatsApp Business del celular): hasta 15 mensajes por segundo en total entre las dos conexiones, env\xEDes por la que env\xEDes. El l\xEDmite de 10 mensajes por minuto al mismo contacto aplica s\xF3lo a lo que env\xEDas por WhatsApp Cloud.\n - **N\xFAmero conectado por c\xF3digo QR** (sin coexistencia): esta regla no limita sus env\xEDos.\n- **Ventana de 24 horas:** por un n\xFAmero de WhatsApp Cloud s\xF3lo puedes enviar mensajes que no son plantilla a un contacto que te escribi\xF3 en las \xFAltimas 24 horas. Fuera de esa ventana la respuesta es 422 con `error` = `window_closed`; para retomar la conversaci\xF3n env\xEDa una plantilla aprobada, que no tiene esta restricci\xF3n.\n- **Antibloqueo:** en las \xFAltimas 24 horas, un mismo contenido se puede enviar como m\xE1ximo 2 veces al mismo contacto (el siguiente responde 422 con `error` = `antiblock_duplicate_content`), y como m\xE1ximo 5 mensajes seguidos sin que el contacto responda (el siguiente responde 422 con `error` = `antiblock_unanswered_limit`). Cuando el contacto responde, los contadores se reinician. Aplica a mensajes y a plantillas, no a reacciones.\n- **Destinatario:** `recipient_mismatch` (422) indica que el tel\xE9fono o el identificador de WhatsApp que mandaste en el cuerpo no corresponde al contacto de la ruta (s\xF3lo las operaciones que reciben ese dato en el cuerpo). `recipient_unsupported` (422) indica que el contacto es un grupo, una lista de difusi\xF3n o un canal: por la API s\xF3lo se escribe a personas individuales.\n- **Estados de entrega:** el evento de webhook `message.status_changed` (enviado, entregado, le\xEDdo, fallido) no se incluye al suscribirte a `*`: tienes que pedirlo de forma expl\xEDcita en `events` al crear o editar la suscripci\xF3n.\n- **Resultado de tus env\xEDos:** cuando env\xEDas con una clave, la llamada no espera al proveedor: la respuesta trae el mensaje ya registrado y en cola, el mismo objeto que devuelve `GET /contacts/{contact}/messages/{message}`, y el env\xEDo sale enseguida despu\xE9s.\n - **En cola no es enviado:** `status` `queued` solo dice que registramos el mensaje y que est\xE1 por salir. El resultado llega por el evento `message.status_changed` o consultando `GET /contacts/{contact}/messages/{message}`. Un mensaje `queued` no se reintenta: reenviarlo lo duplicar\xEDa.\n - **Aceptado no es entregado:** `status` `sent` solo dice que el proveedor acept\xF3 el mensaje. El estado final (entregado, le\xEDdo o fallido) llega por el evento `message.status_changed` o consultando `GET /contacts/{contact}/messages/{message}`.\n - **`failed`:** el proveedor rechaz\xF3 el mensaje dando un motivo, o Funnelchat lo fren\xF3 antes de enviarlo. No sali\xF3: reintentar es un intento nuevo y no lo duplica. Cuando lo decidi\xF3 Funnelchat, `error.reason` trae el motivo, `error.code` es `null` y `error.title` es un texto llano en tu idioma; si lo rechaz\xF3 el proveedor, `error.reason` es `null`. Qu\xE9 hacer seg\xFAn `error.reason`: `device_unavailable`, reconecta el n\xFAmero en Funnelchat y env\xEDa de nuevo; `insufficient_balance`, recarga tu billetera de WhatsApp; `window_closed`, env\xEDa una plantilla aprobada; `recipient_unreachable`, revisa el tel\xE9fono del contacto; `template_not_found` o `template_unresolved_placeholder`, corrige la plantilla o sus variables; `not_queued` o `not_sent`, vuelve a intentarlo. Un mensaje enviado con clave que termina `failed` tambi\xE9n llega por `message.status_changed`, con `previous_status` en `null` y su `client_reference`; un `status` `null` no recibe aviso.\n - **`status` `null`:** el proveedor no confirm\xF3 ni rechaz\xF3 el env\xEDo (no respondi\xF3, respondi\xF3 con una falla sin motivo o el env\xEDo se interrumpi\xF3). Puede haber llegado al contacto, no recibir\xE1 avisos de estado y reintentarlo puede duplicarlo.\n - **M\xE1s de una hora en `queued`:** un mensaje cuyo env\xEDo no empez\xF3 dentro de la hora ya no sale. Pasada la hora sin resultado, su `status` es `null`, o `failed` con `error.reason` `not_sent` y su aviso `message.status_changed` si su env\xEDo llega tarde. Reintentar es un env\xEDo nuevo, que s\xF3lo puede duplicar si el env\xEDo hab\xEDa empezado y se cort\xF3.\n - **Error 5xx o llamada sin respuesta:** busca tu `client_reference` en el historial del contacto antes de reintentar, y hazlo al menos un minuto despu\xE9s de la llamada. Si aparece, el mensaje qued\xF3 registrado: no lo reenv\xEDes, su resultado llega por `message.status_changed` o consultando `GET /contacts/{contact}/messages/{message}`; la excepci\xF3n son las tarjetas y los destinos de un reenv\xEDo tras un 5xx: si todos los que aparecen vienen `failed` con `not_queued`, se reintenta la llamada completa; si alguno aparece `queued`, qued\xF3 registrado: no lo reenv\xEDes. Si no aparece, no sali\xF3 nada y puedes reintentar. Repetir una referencia no evita duplicados: dos llamadas con la misma `client_reference` registran dos mensajes.\n - **Error 404 en un env\xEDo:** el contacto no existe. En un reenv\xEDo, tambi\xE9n cuando el mensaje de origen no existe o no es de ese contacto. Al enviar por `phone` o `whatsapp_id`, cuando ese contacto es de otro workspace de tu cuenta.\n - **`POST /contacts/{contact}/message`:** la respuesta trae el mensaje en `message`: `queued` si es un mensaje, o el mensaje registrado de una nota.\n - **`POST /contacts/{contact}/message/contact-card`:** la respuesta trae `messages`: un mensaje por tarjeta, en el orden de `contact_card_ids`, cada uno con su `id` y en cola (`queued`). Todas salen despu\xE9s, desde la cola: el resultado de cada una llega por `message.status_changed` o consultando `GET /contacts/{contact}/messages/{message}`, con tu `client_reference`. Si una tarjeta no pudo encolarse, su entrada viene `failed` con `error.reason` `not_queued`: no sali\xF3 y volver a enviar esa tarjeta es un intento nuevo. Si alguna no pudo registrarse, la llamada responde un error 5xx y ninguna tarjeta sali\xF3: las que ya aparezcan en el historial del contacto vienen `failed` con `error.reason` `not_queued` y su propio aviso `message.status_changed` `failed`. Si todas las que aparecen vienen `failed` con `not_queued`, reintenta la llamada completa; si alguna aparece `queued`, qued\xF3 registrada: no la reenv\xEDes.\n - **Reenv\xEDo:** la respuesta trae un elemento por destino, en el orden de `targets`, y cada destino que qued\xF3 en cola trae su `message` con su `id` y en cola (`queued`). Todos salen despu\xE9s, desde la cola: el resultado de cada uno llega por `message.status_changed` o consultando `GET /contacts/{contact}/messages/{message}`, con tu `client_reference`. Un destino que no pudo encolarse viene `blocked` con `reason` `not_sent` y su `message` `failed` con `error.reason` `not_queued`: no sali\xF3 y volver a reenviarlo a ese destino es un intento nuevo. Un destino `blocked` por MAC (`mac_limit_reached` o `mac_lockout_24h`) o con `reason` `target_not_found` (no existe o no es de tu cuenta), `contact_blocked` (bloqueaste al contacto en ese n\xFAmero) o `device_unavailable` trae `message` `null`: no sali\xF3 nada. Si alg\xFAn destino no pudo registrarse, la llamada responde un error 5xx y ninguno sali\xF3: los que ya aparezcan en el historial del contacto vienen `failed` con `error.reason` `not_queued` y su propio aviso `message.status_changed` `failed`. Si todos los que aparecen vienen `failed` con `not_queued`, reintenta la llamada completa; si alguno aparece `queued`, qued\xF3 registrado: no lo reenv\xEDes.\n - **`POST /contacts/{contact}/message/form`:** la respuesta trae solo `message`: el mensaje del formulario en cola (`queued`). Si no pudo ponerse en cola, la respuesta sigue siendo 200 con el mensaje en estado `failed` y `error.reason` `not_queued`: no sali\xF3, as\xED que reintentar no lo duplica. Su resultado llega por `message.status_changed` o consultando `GET /contacts/{contact}/messages/{message}`, y la respuesta del contacto por `whatsapp_form.completed`. Si tu cuenta est\xE1 en bloqueo por el l\xEDmite de contactos activos responde 429 `mac_lockout_24h` y no guarda ning\xFAn mensaje ni `client_reference`. Sus errores propios est\xE1n en la gu\xEDa \xABFormularios de WhatsApp\xBB.\n - **`POST /message/send-message` y `POST /devices/{device}/templates/{template_id}/send-template`:** la respuesta trae solo `message`: el mensaje en cola (`queued`), o el mensaje registrado de una nota en `send-message`. Si una variable de la plantilla se completa con un campo personalizado que no existe en tu cuenta, `send-template` responde 422 con `error` `template_unresolved_placeholder`, `placeholders` (hasta 20) y `count` (el total), y no registra ni env\xEDa nada; si la plantilla no existe en tu cuenta, responde 404 `template_not_found`.\n- **Correlacionar tus env\xEDos:** manda `client_reference` en el cuerpo (texto opcional de 1 a 64 caracteres visibles, sin espacios) para identificar cada env\xEDo con tu propia referencia. La misma referencia vuelve en el historial de mensajes del contacto y en el evento `message.status_changed`, de modo que puedes relacionar cada aviso de estado con tu env\xEDo tambi\xE9n cuando la llamada de env\xEDo no respondi\xF3. La referencia no es \xFAnica (dos env\xEDos pueden usar el mismo valor) y no est\xE1 autenticada: la ven todas las claves y todas las suscripciones de la cuenta. Por eso relaci\xF3nala siempre junto con `message_id` y `contact_id`, y tr\xE1tala como texto no confiable cuando la muestres. No pongas datos personales en la referencia. Una llamada que frena una regla de protecci\xF3n de esta secci\xF3n (ritmo, ventana de 24 horas, antibloqueo o destinatario) no registra ning\xFAn mensaje ni referencia. Un rechazo por MAC (`mac_limit_reached` o `mac_lockout_24h`) es distinto: guarda un mensaje con estado de error que lleva la referencia; el env\xEDo de formularios (`POST /contacts/{contact}/message/form`) es la excepci\xF3n: con `mac_lockout_24h` responde 429 y no guarda ning\xFAn mensaje ni referencia. Un mensaje enviado con clave que Funnelchat frena antes de que salga (por ejemplo, por saldo insuficiente) aparece en el historial con estado de error y su referencia, y recibe un aviso `failed` con esa referencia; uno enviado desde el panel no recibe avisos de estado. Las reacciones no aceptan `client_reference`.\n\n## Le\xEDdo y escribiendo\n\nPuedes marcar como le\xEDdo un mensaje que te mand\xF3 un contacto y mostrarle \xABescribiendo\u2026\xBB mientras preparas la respuesta. Ninguna de las dos operaciones env\xEDa un mensaje: no cuentan para el ritmo de env\xEDo ni para las dem\xE1s reglas de \xABEnv\xEDo de mensajes con clave\xBB, y requieren el permiso **Contactos y conversaciones \u2192 Enviar** porque el contacto ve su efecto.\n\n- **Tipos de n\xFAmero:** \xABmarcar como le\xEDdo\xBB funciona en n\xFAmeros de WhatsApp Cloud (API oficial, tambi\xE9n el lado oficial de un n\xFAmero en coexistencia) y en n\xFAmeros conectados por c\xF3digo QR que lo permiten; \xABescribiendo\u2026\xBB funciona solo en n\xFAmeros de WhatsApp Cloud. Si el n\xFAmero no lo permite, la respuesta es 422 con `error` = `provider_unsupported`.\n- **Marcar como le\xEDdo** (`POST /contacts/{contact}/messages/{message}/read`): el contacto ve el doble check azul. WhatsApp marca tambi\xE9n los mensajes anteriores de esa conversaci\xF3n y solo acepta mensajes recibidos en los \xFAltimos 30 d\xEDas. En Funnelchat la conversaci\xF3n queda le\xEDda, igual que cuando un agente la abre en el panel; si llegaron mensajes despu\xE9s del que marcaste, siguen contando como no le\xEDdos.\n- **\xABEscribiendo\u2026\xBB** (`POST /contacts/{contact}/typing`): se muestra sobre el \xFAltimo mensaje que el contacto te mand\xF3 en las \xFAltimas 24 horas, por el n\xFAmero que indiques en `device_id` o, si no lo mandas, por el n\xFAmero de su \xFAltimo mensaje. Dura hasta que respondes o 25 segundos, lo que pase primero. WhatsApp tambi\xE9n marca ese mensaje (y los anteriores) como le\xEDdo, as\xED que el contacto ve el doble check azul; en Funnelchat la conversaci\xF3n no cambia. P\xEDdelo solo cuando vayas a responder.\n- **L\xEDmites:** entre las dos operaciones, hasta 20 llamadas por segundo por n\xFAmero y 50 por cuenta. El l\xEDmite por cuenta incluye las llamadas rechazadas con 429 o con alguno de los c\xF3digos 422 de esta secci\xF3n; no cuentan los 404 ni los errores de validaci\xF3n del cuerpo (422 con errors). Al superar cualquiera de los dos, la respuesta es 429 con `error` = `rate_limited` y el encabezado `Retry-After`.\n- **Si WhatsApp no confirma:** la respuesta es 502 con `error` = `provider_error` y no se cambia nada; puedes reintentar.\n\n## Enviar un archivo por URL\nNo subes el archivo a Funnelchat: en `file` mandas la direcci\xF3n (URL) del archivo y WhatsApp o el proveedor lo descarga desde ah\xED. Funnelchat no abre la URL al recibir tu llamada, as\xED que un archivo inaccesible, vencido o fuera de los l\xEDmites se conoce al enviarlo: el mensaje termina `failed` con el error del proveedor.\n- **La URL:** `file` admite hasta 4096 caracteres; m\xE1s de eso se rechaza con un 422 en `file`. Si `file` empieza con `http`, en may\xFAsculas o min\xFAsculas, tiene que ser una URL que empiece con `http://` o `https://`; si no (por ejemplo `httpfoto.jpg`), la respuesta es 422 con el error en `file`. Te recomendamos `https://`; `http://` tambi\xE9n se acepta, pero viaja sin cifrar. Debe poder descargarse sin credenciales; puede ser una URL firmada. Tiene que seguir v\xE1lida hasta que el mensaje sale: un env\xEDo con clave puede esperar en cola hasta 54 minutos, as\xED que usa URLs que duren al menos una hora. No pongas secretos en la ruta ni en el dominio de la URL: en los registros de actividad (logs) del env\xEDo que escribe Funnelchat, de la URL quedan s\xF3lo el esquema, el dominio y la ruta (sin el usuario y la contrase\xF1a, la parte de consulta despu\xE9s de `?` ni el fragmento despu\xE9s de `#`) y del resultado s\xF3lo identificadores, el tipo del mensaje y, del error del proveedor, sus c\xF3digos y, s\xF3lo si tu n\xFAmero es de WhatsApp Cloud, su categor\xEDa, su t\xEDtulo corto y su identificador de traza, nunca el texto de la respuesta del proveedor; la ruta y el dominio s\xED quedan. La URL completa viaja a WhatsApp o al proveedor de tu n\xFAmero, que la descarga y puede guardarla en sus propios registros. Adem\xE1s, la URL completa, con su consulta, se guarda en el historial del contacto, la ven los usuarios de tu cuenta y se copia al reenviar el mensaje: usa URLs firmadas de corta duraci\xF3n (al menos el tiempo de la cola, cerca de una hora) y limitadas a un solo archivo.\n- **El tipo:** decide la extensi\xF3n de la ruta de la URL si es una de la lista; si no, el final de la URL completa si es una de la lista. Si la ruta no termina en una extensi\xF3n de la lista y s\xF3lo lo hace el final de la URL completa (por ejemplo `https://cdn.example.com/video.mov?poster=frame.jpg`), el mensaje queda registrado con ese tipo, pero WhatsApp o el proveedor pueden enviarlo seg\xFAn la extensi\xF3n de la ruta; si necesitas un tipo fijo, env\xEDa `file_type`:\n - `.jpg`, `.jpeg`, `.png`: `image`\n - `.webp`: `sticker`\n - `.ogg`, `.oga`, `.mp3`, `.m4a`, `.aac`, `.opus`, `.wav`: `audio`\n - `.gif`, `.mp4`: `video`\n - `.doc`, `.docx`, `.pdf`: `document`\n- **Cu\xE1ndo env\xEDas `file_type`:** si la URL no termina en una extensi\xF3n reconocida (por ejemplo una URL firmada sin extensi\xF3n), la respuesta es 422 con el error en `file_type`: agrega `file_type` con uno de estos valores: `image`, `video`, `audio`, `document`, `sticker`. Cuando lo env\xEDas, manda sobre la extensi\xF3n: sirve tambi\xE9n para un archivo cuya extensi\xF3n no es de los tipos de la lista, como `.xlsx`, que se env\xEDa con `file_type` = `document` y `file_name` (por ejemplo `reporte.xlsx`), porque un documento cuya URL no termina en una extensi\xF3n de la lista necesita su nombre. Si la URL termina en una extensi\xF3n que no es la del archivo (como `/descarga.php?id=7`) y no env\xEDas `file_type`, la respuesta es 415 con la extensi\xF3n recibida (`php`); con `file_type` se env\xEDa y, si es un documento, tambi\xE9n hace falta `file_name`.\n- **Extensi\xF3n no soportada:** si la ruta de la URL termina en un formato real que no enviamos solos (por ejemplo `.xlsx` o `.zip`), la URL completa no termina en una extensi\xF3n de la lista y no mandas `file_type`, la respuesta es 415 con la extensi\xF3n recibida.\n- **El nombre del archivo:** `file_name` (opcional, hasta 255 caracteres, sin `/`, `\\`, `?`, `#` ni caracteres de control) es el nombre con el que el contacto ve un documento; ll\xE9valo siempre con su extensi\xF3n, por ejemplo `factura.pdf`. Es obligatorio cuando env\xEDas un documento por URL y la URL no termina en una extensi\xF3n de la lista: sin \xE9l la respuesta es 422 con el error en `file_name`.\n- **Formatos y tama\xF1os:** los formatos aceptados por WhatsApp y el tama\xF1o m\xE1ximo de cada tipo son:\n - `image`: image/jpeg, image/png, image/webp; hasta 5 MB.\n - `video`: video/mp4, video/3gpp, image/gif; hasta 16 MB.\n - `audio`: audio/aac, audio/mp4, audio/mpeg, audio/amr, audio/ogg, audio/opus; hasta 16 MB.\n - `document`: text/plain, application/pdf, application/vnd.ms-powerpoint, application/msword, application/vnd.ms-excel, application/vnd.openxmlformats-officedocument.wordprocessingml.document, application/vnd.openxmlformats-officedocument.presentationml.presentation, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet; hasta 100 MB.\n - `sticker`: image/webp; hasta 100 KB si es est\xE1tico y 500 KB si es animado.\n- **Historial y reenv\xEDo:** el historial del contacto guarda la URL tal cual la enviaste, y el nombre `file_name` no se guarda. Al reenviar un mensaje cuyo archivo es una URL, se reenv\xEDa la misma URL; con una extensi\xF3n de la lista, como esa extensi\xF3n indica; si no, un mensaje que enviaste se reenv\xEDa con su tipo s\xF3lo cuando su extensi\xF3n es un formato que WhatsApp acepta para ese tipo (los de \xABFormatos y tama\xF1os\xBB, por ejemplo `.3gp` para `video` o `.amr` para `audio`). Una URL sin extensi\xF3n, un formato no aceptado, un documento cuya URL no termina en una extensi\xF3n de la lista (por ejemplo `/descarga.php?id=7`) o un mensaje recibido sin una extensi\xF3n de la lista no se puede reenviar: la respuesta es 422 con `error` = `not_forwardable` y `reason` = `unsupported_file_extension`.\n\n## Formularios de WhatsApp\n\nUn formulario de WhatsApp es un conjunto de pantallas con preguntas que tu contacto abre y completa dentro del chat. Con la API puedes armarlo, publicarlo, enviarlo a un contacto y recibir sus respuestas. S\xF3lo funciona con n\xFAmeros de WhatsApp Cloud (API oficial) de tu cuenta, y s\xF3lo se puede enviar dentro de las 24 horas siguientes al \xFAltimo mensaje del contacto a ese n\xFAmero. Los n\xFAmeros conectados por c\xF3digo QR no sirven para formularios.\n- **Identificadores:** un formulario tiene un identificador estable y una versi\xF3n por cada vez que lo cambias. No los confundas:\n\n | Qu\xE9 es | D\xF3nde aparece |\n |---|---|\n | **Formulario** (estable, el mismo en todas sus versiones) | `family_id` en las respuestas de formularios, `{familyId}` en las rutas `/forms/families/{familyId}/\u2026`, `form_id` al enviar y `data.form.id` en `whatsapp_form.completed` |\n | **Versi\xF3n** (cambia con cada versi\xF3n nueva) | `id` en las respuestas de formularios, `{id}` en las rutas `/forms/{id}/\u2026` de edici\xF3n, publicaci\xF3n y n\xFAmeros, y `data.form.version_id` en `whatsapp_form.completed` |\n\n Al enviar pides el formulario, no la versi\xF3n: sale la versi\xF3n publicada vigente. Si publicas una versi\xF3n nueva, lo que se env\xEDa cambia sin que cambies tu integraci\xF3n.\n- **Recorrido, paso a paso:**\n 1. **Crea el formulario:** `POST /forms` con `template_key` `blank` (un formulario para armar desde cero; `GET /forms/templates` lista las dem\xE1s plantillas), `device_id` (el n\xFAmero de WhatsApp Cloud de tu cuenta) y, si quieres, `device_ids` con otros n\xFAmeros. Responde `201` con la versi\xF3n en borrador (`id`), su `family_id`, `lint` y `numbers`. Un formulario no se crea mandando su contenido: el contenido va en el paso siguiente. Ojo con `blank`: no viene vac\xEDo, trae una pantalla (`SCREEN_BLNK`) sin elementos cuyo `next` es `complete`, es decir, que termina el formulario. Si en el `PUT` conservas esa pantalla y agregas otra, la segunda queda inalcanzable y `lint` marca `screen_unreachable` (error que impide publicar) hasta que el `next` de la primera apunte a la segunda (o `auto`, que sigue el orden). Lo m\xE1s simple es reemplazar `screens` entera con tus propias pantallas.\n 2. **Arma la definici\xF3n:** `PUT /forms/{id}` con `definition` (`screens`, cada una con `id`, `title`, `cta`, `next` e `items`) y, si quieres, `name` y `expected_updated_at`. El servidor guarda la definici\xF3n tal como llega y la respuesta trae `lint` junto a `data`: una lista de `{code, severity, screenId, itemId, message}` con lo que falta corregir. Lo incompleto se guarda igual; los errores (`severity` `error`) impiden publicar y los avisos (`aviso`) no. Una definici\xF3n con la forma rota responde `422`. Para leerla de nuevo, `GET /forms/{id}` trae la versi\xF3n con su definici\xF3n y `GET /forms/families/{familyId}/definition` la del formulario: la de la versi\xF3n publicada vigente o, si todav\xEDa no hay ninguna, la de la m\xE1s nueva. Con `expected_updated_at` evitas pisar un cambio m\xE1s nuevo: si no es el \xFAltimo guardado responde `409` `STALE`.\n 3. **Valida en Meta (opcional):** `POST /forms/{id}/sync` corre el linter, sube el formulario a Meta y trae sus observaciones en `lint` con `code` `meta`, sin publicar. Si la definici\xF3n tiene errores responde `422` `LINT`, igual que al publicar.\n 4. **Publica:** `POST /forms/{id}/publish` hace la validaci\xF3n y publica; es inmediato. Si la definici\xF3n tiene errores responde `422` `LINT` con la lista, y si Meta la rechaza, `502` `META` (`422` `META` si la rechaza porque tu cuenta de WhatsApp Business no cumple sus requisitos de integridad). Un formulario sin ninguna pregunta no se puede publicar. Los otros n\xFAmeros del formulario se publican en segundo plano: consulta su estado (`draft`, `publishing`, `published` o `failed`) en `GET /forms/{id}/numbers`.\n 5. **Suma o quita n\xFAmeros (opcional):** `POST /forms/{id}/numbers` con `device_id` publica el formulario en otro n\xFAmero de WhatsApp Cloud de tu cuenta, `POST /forms/{id}/numbers/retry` reintenta los que fallaron y `DELETE /forms/{id}/numbers/{deviceId}` quita uno. El n\xFAmero con el que creaste el formulario no se quita: `422` `PRIMARY_NUMBER`. Un n\xFAmero que no es de tu cuenta o no es WhatsApp Cloud responde `422` `NUMBER_NOT_ELIGIBLE`.\n 6. **Env\xEDa:** `POST /contacts/{contact}/message/form` con `form_id` (el formulario, no la versi\xF3n) y `device_id`; opcionalmente `text` (hasta 1024 caracteres), `button_text` (hasta 30, sin emojis) y `client_reference`. S\xF3lo se env\xEDa con una clave de API. La clave de un asiento s\xF3lo env\xEDa a los contactos asignados a ese asiento (con cualquier otro contacto responde el `404` de un contacto que no existe); la clave del titular env\xEDa a cualquier contacto de la cuenta. Responde `200` con `message` en cola (`queued`); sus errores propios est\xE1n al final de esta gu\xEDa.\n 7. **Recibe las respuestas:** suscr\xEDbete al evento `whatsapp_form.completed` (tambi\xE9n llega si te suscribes a `*`) o lee `GET /forms/families/{familyId}/responses` y `GET /forms/families/{familyId}/responses/{runId}`. `GET /forms/families/{familyId}/metrics` trae los env\xEDos, aperturas y respuestas.\n - **Cambiar un formulario publicado:** un formulario publicado no se edita (`PUT` y `DELETE` responden `409` `PUBLISHED`). Crea una versi\xF3n nueva con `POST /forms/{id}/versions`, que nace como borrador (`201`, o `200` con `created: false` si ya hab\xEDa un borrador), ed\xEDtala y publ\xEDcala. El `form_id` no cambia: el env\xEDo usa la versi\xF3n publicada vigente.\n- **Reglas de la definici\xF3n:** el servidor no genera ni corrige identificadores: quien arma la definici\xF3n los genera, y deben ser v\xE1lidos y \xFAnicos. Meta acepta s\xF3lo letras y guiones bajos.\n - **Pantallas:** `id` con `SCREEN_` m\xE1s letras (por ejemplo `SCREEN_ABCD`), sin d\xEDgitos, y \xFAnicos en el formulario. `next` es `auto` (la siguiente), `complete` (termina) o el `id` de una pantalla posterior: s\xF3lo se salta hacia adelante.\n - **Preguntas:** `name` en min\xFAsculas y guiones bajos (`a-z` y `_`), \xFAnico en todo el formulario. Sin d\xEDgitos, tildes, `\xF1` ni espacios. No uses los nombres reservados `flow_token`, `version` ni `extension_message_response`. No cambies un `name` despu\xE9s de crearlo: las respuestas, las ramas y las etiquetas lo usan.\n - **Opciones:** `id` de cada opci\xF3n en min\xFAsculas y guiones bajos, \xFAnico en su pregunta (por ejemplo `op_comercio`).\n - **Qu\xE9 marca el linter:** `field_name_reserved` (nombre reservado), `field_name_invalid` (nombre fuera de letras y guiones bajos) y `option_id_invalid` (id de opci\xF3n fuera de ese alfabeto) son errores que impiden validar y publicar. El linter no revisa que los identificadores sean \xFAnicos ni que existan los campos personalizados y las etiquetas que referencias: compru\xE9balo t\xFA.\n - **Campos personalizados y etiquetas:** si una pregunta guarda su respuesta en un campo personalizado (`map` con `target` `custom` y `key` con el nombre del campo) o asigna etiquetas (`tagMap`, con el nombre de la etiqueta), ese campo y esa etiqueta tienen que existir en tu cuenta. El formulario no los crea: si no existen, la respuesta se guarda en el formulario pero no se escribe en el contacto ni se le asigna la etiqueta. Un campo num\xE9rico s\xF3lo admite preguntas `number` y uno de fecha s\xF3lo preguntas `date`; el servidor no lo revisa.\n - **No se pueden publicar** los formularios que necesitan un servidor propio (`endpoint` en `true`, opciones en vivo): el linter los marca como error.\n- **L\xEDmites del formulario:** hasta 5 pantallas, 50 elementos por pantalla, 3 im\xE1genes por pantalla y 2 enlaces por pantalla; pasarse es un error que impide publicar. El nombre del formulario admite hasta 120 caracteres. Las im\xE1genes (`type` `image`, con `src` en base64 sin el prefijo `data:`) deben ser JPG o PNG, de hasta 300 KB cada una y 4 MB entre todas las del formulario; el servidor no revisa su tama\xF1o ni su formato, as\xED que quien arma la definici\xF3n debe respetarlos. El guardado acepta un `src` con el prefijo `data:`, pero se env\xEDa a Meta tal como lo guardaste y Meta dibuja un recuadro roto: manda siempre la carga en base64 pelada, sin prefijo.\n - **Tope de cada guardado:** adem\xE1s, un guardado rechaza (`422`) una definici\xF3n con m\xE1s de 20 pantallas, 100 elementos en una pantalla o 200 opciones en una pregunta. Esos topes quedan por encima de los l\xEDmites de arriba. Adem\xE1s, cada guardado env\xEDa la definici\xF3n entera, im\xE1genes incluidas, y debe pesar menos de unos 6 MB: pasado ese tama\xF1o la plataforma rechaza la llamada antes de que llegue al formulario, as\xED que no cuentes con m\xE1s.\n- **Qu\xE9 puede hacer cada clave:** los permisos se heredan hacia abajo: **Contactos y conversaciones \u2192 Enviar** incluye **Contactos y conversaciones \u2192 Editar**, que incluye **Contactos y conversaciones \u2192 Ver**. Con **Contactos y conversaciones \u2192 Ver** lees formularios, su definici\xF3n (`GET /forms/families/{familyId}/definition`), versiones, n\xFAmeros, respuestas y m\xE9tricas; con **Contactos y conversaciones \u2192 Editar** adem\xE1s armas, guardas, publicas, validas, creas versiones, eliminas borradores y sumas, reintentas o quitas n\xFAmeros; con **Contactos y conversaciones \u2192 Enviar** adem\xE1s env\xEDas. Por eso una clave con **Contactos y conversaciones \u2192 Enviar**, aunque s\xF3lo la hayas pensado para enviar, tambi\xE9n puede armar, publicar y quitar n\xFAmeros de formularios: dale ese permiso s\xF3lo a quien conf\xEDas para eso. Las operaciones `summary`, `analysis` y `deliveries` del panel no est\xE1n abiertas a claves.\n - **Topes de llamadas por clave:** cada clave puede hacer hasta 120 lecturas por minuto y hasta 60 escrituras por minuto en formularios (cada clave lleva su propio contador: dos claves de la misma cuenta, sea del titular o de un asiento, no se frenan entre s\xED, y ninguna gasta el tope del panel). Al superarlos la respuesta es `429` con el encabezado `Retry-After`, que trae los segundos que debes esperar. Validar, publicar y tocar n\xFAmeros tienen adem\xE1s el tope de 30 por minuto compartido por toda la cuenta, que se explica en los errores al final de esta gu\xEDa.\n - **Cuenta:** la clave de un asiento (un agente) opera sobre los formularios del titular de la cuenta, no sobre formularios propios del asiento.\n - **Respuestas con clave de un asiento:** `responses` y `responses/{runId}` se limitan a los contactos que ese asiento puede ver en Funnelchat; las m\xE9tricas (`metrics`) cuentan todas las respuestas del formulario.\n - **Detalle de una respuesta con clave:** `GET /forms/families/{familyId}/responses/{runId}` no trae `last_error` en sus efectos; el resto de cada efecto es igual al del panel.\n- **Aviso `whatsapp_form.completed`:** `data.form` trae `id` (el formulario), `name` y `version_id` (la versi\xF3n que recibi\xF3 el contacto); `message_id` es el mensaje del formulario y puede ser `null`; `answers` es una lista de `{key, label, value}`, una por pregunta y en el orden del formulario (`key` es el `name` de la pregunta, y `value` es `null` si no la contest\xF3). El mismo aviso puede llegar m\xE1s de una vez con el mismo `id`: deduplica por `id`. Cada respuesta de texto llega con a lo sumo 600 caracteres. Si el contacto vuelve a enviar el mismo formulario no se genera otro aviso: queda la primera respuesta. Los detalles y el ejemplo est\xE1n en la secci\xF3n \xABWebhooks\xBB.\n- **Errores al armar y publicar:** `404` si el `id` no existe o no es de tu cuenta (un id de otra cuenta responde igual); `409` `STALE` (el guardado no es el \xFAltimo) y `409` `PUBLISHED` (la versi\xF3n ya est\xE1 publicada); `422` `LINT` al publicar con errores en la definici\xF3n, `422` `NUMBER_NOT_ELIGIBLE` y `422` `PRIMARY_NUMBER` en los n\xFAmeros; `502` `META` si Meta rechaza la operaci\xF3n o no responde, y `422` `META` si la rechaza porque tu cuenta de WhatsApp Business no cumple sus requisitos de integridad. Validar, publicar y tocar n\xFAmeros (`sync`, `publish` y las operaciones de `numbers`) sube a Meta, que limita por cuenta de WhatsApp Business: todas las claves de tu cuenta, la del titular y las de los asientos, comparten un tope de 30 de estas llamadas por minuto. Al superarlo responde `429` con el encabezado `Retry-After`, que trae los segundos que debes esperar.\n- **Errores al enviar un formulario:** adem\xE1s de los errores de las reglas de protecci\xF3n de esta documentaci\xF3n, `POST /contacts/{contact}/message/form` responde: `404` si el contacto no existe, no es de tu cuenta o, con la clave de un asiento, no est\xE1 asignado a ese asiento; `404` con `form_not_found` si el formulario no existe en tu cuenta; `422` con `device_unavailable` (el n\xFAmero no es de tu cuenta o no est\xE1 conectado), `number_not_eligible` (no es un n\xFAmero de WhatsApp Cloud que pueda enviar formularios), `form_not_published` (el formulario no tiene versi\xF3n publicada: publ\xEDcalo), `form_not_on_number` (no est\xE1 publicado en ese n\xFAmero: s\xFAmalo con `POST /forms/{id}/numbers` y espera a que se publique), `recipient_unreachable` (el contacto no tiene tel\xE9fono ni identificador de WhatsApp al que ese n\xFAmero pueda escribir), `contact_blocked` (bloqueaste al contacto en ese n\xFAmero: desbloqu\xE9alo para escribirle) y `window_closed` (pasaron m\xE1s de 24 horas desde el \xFAltimo mensaje del contacto); `429` con `rate_limited` y `Retry-After` si superas el ritmo; y `422` con `not_sent` (el destinatario que se registr\xF3 no coincide con el contacto verificado, o el mensaje no qued\xF3 como un env\xEDo de la conversaci\xF3n: no se registr\xF3 ni sali\xF3 nada y puedes reintentar; son las \xFAnicas dos situaciones y en el uso normal no ocurren). Si Funnelchat no pudo poner el mensaje en cola, la respuesta no es un error: es `200` con el mensaje en estado `failed` y `error.reason` `not_queued`; no sali\xF3, as\xED que reintentar no lo duplica. Si tu cuenta est\xE1 bloqueada por el l\xEDmite de contactos activos, el env\xEDo responde `429` con `mac_lockout_24h`, como los dem\xE1s env\xEDos con clave, pero a diferencia de ellos no guarda ning\xFAn mensaje ni `client_reference`: no queda nada en el historial del contacto ni se puede buscar por la referencia, as\xED que reintenta cuando termine el bloqueo. Un `500` indica que el mensaje no pudo registrarse. Si la llamada no responde o da un error 5xx, busca tu `client_reference` en el historial del contacto antes de reintentar.'
50240
51208
  },
50241
51209
  {
50242
51210
  domain: "communities",
@@ -50244,9 +51212,9 @@ var openapi_default = {
50244
51212
  version: "1.0.0",
50245
51213
  spec: "specs/communities.json",
50246
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`.",
50247
- operations: 44,
51215
+ operations: 60,
50248
51216
  excluded_routes: 6,
50249
- 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, pero en v0 no hay ninguna operaci\xF3n de env\xEDo disponible.\n- **Grupos y campa\xF1as \u2192 Administrar grupos**: en v0 no habilita ninguna operaci\xF3n.\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`.'
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.'
50250
51218
  },
50251
51219
  {
50252
51220
  domain: "accounts",
@@ -50483,12 +51451,12 @@ var openapi_default = {
50483
51451
  generated_by: "scripts/generate.mjs",
50484
51452
  note: "GENERATED: do not edit it. The portal's snapshot plus the pending layers.",
50485
51453
  inputs: {
50486
- "spec/funnelchat.json": "0f451feaba59418f3a556f58856f841f12570973fd6525103e8cc0128ab64374",
50487
- "spec/pending.json": "3ccf04ff5aa2b6acf6baa9b3d7e56ba1fee1e9c83ae1906b16cedb8422da942a",
51454
+ "spec/funnelchat.json": "8228ea7369daed0655b779ca6af381355a6c1cc13dd702ed6e9c322779bafe94",
51455
+ "spec/pending.json": "051680bd82944c0e248d20d1499d89e61a231c6ba3ef6a5d864cf18a97aae1ad",
50488
51456
  "spec/pending-sandbox.json": "e75e9a7b63c5913c11fc35fa06d23bd32a697832a4da105c377eac9a8f4e62a3"
50489
51457
  },
50490
- snapshot_commit: "c90565e4f1aa",
50491
- pending_commit: "8469a5c176ef36506b0102a68e2c506d0ff21407",
51458
+ snapshot_commit: "f5464516",
51459
+ pending_commit: "a8f83509d4a40d6116a9c0487bf0aee639948273",
50492
51460
  pending_applied: [
50493
51461
  "POST /api/v1/webhooks/{id}/rotate-secret [pending.json]",
50494
51462
  "GET /api/v1/forms [pending.json]",
@@ -50509,8 +51477,6 @@ var openapi_default = {
50509
51477
  "GET /api/v1/forms/families/{familyId}/responses [pending.json]",
50510
51478
  "GET /api/v1/forms/families/{familyId}/responses/{runId} [pending.json]",
50511
51479
  "GET /api/v1/forms/families/{familyId}/metrics [pending.json]",
50512
- "POST /api/v1/groups/{group}/messages [pending.json]",
50513
- "GET /api/v1/groups/actions/{action} [pending.json]",
50514
51480
  "POST /api/v1/contacts/{contact}/message (respuestas: 200) [pending.json]",
50515
51481
  "POST /api/v1/message/send-message (respuestas: 200) [pending.json]",
50516
51482
  "POST /api/v1/devices/{device}/templates/{template}/send-template (respuestas: 200) [pending.json]",
@@ -50518,6 +51484,7 @@ var openapi_default = {
50518
51484
  "GET /api/v1/devices/{device}/qr (respuestas: 200) [pending.json]",
50519
51485
  "GET /api/v1/templates (respuestas: 200) [pending.json]",
50520
51486
  "PUT /api/v1/webhooks/{id} (respuestas: 200) [pending.json]",
51487
+ "GET /api/v1/groups/actions/{action} (respuestas: 200) [pending.json]",
50521
51488
  "webhook device.connected [pending.json]",
50522
51489
  "webhook device.disconnected [pending.json]",
50523
51490
  "webhook billing.payment_failed [pending.json]",
@@ -51511,7 +52478,7 @@ function resolveFile(body2, style) {
51511
52478
 
51512
52479
  // package.json
51513
52480
  var package_default = {
51514
- version: "0.3.0"};
52481
+ version: "0.3.1"};
51515
52482
 
51516
52483
  // src/version.ts
51517
52484
  var VERSION = package_default.version;
@@ -52928,6 +53895,28 @@ function createContact(app, input) {
52928
53895
  app.webhooks.emit("contact.created", { contact: { ...eventContact(contact), email: contact.email } }, { workspaceId: contact.workspaceId });
52929
53896
  return contact;
52930
53897
  }
53898
+ var BSUID = /^[A-Za-z]{2}\.(?:[A-Za-z0-9]+\.)*[A-Za-z0-9]{1,128}$/;
53899
+ var isBsuid = (value) => BSUID.test(value);
53900
+ var invalidPhone = (message, error) => new RawApiError(422, { message, errors: { phone: [error] } });
53901
+ function sendRecipient(ctx, b) {
53902
+ const { app } = ctx;
53903
+ const inScope = (contact) => {
53904
+ if (!sees(ctx.workspace, contact.workspaceId)) throw outsideWorkspace();
53905
+ return contact;
53906
+ };
53907
+ if (b.phone !== void 0 && b.phone !== null) {
53908
+ const phone = digits(b.phone);
53909
+ if (phone === "") throw invalidPhone("El n\xFAmero de tel\xE9fono proporcionado no es v\xE1lido.", "El n\xFAmero de tel\xE9fono es inv\xE1lido.");
53910
+ if (phone.length < 7 || phone.length > 15 || !/[1-9]/.test(phone)) {
53911
+ throw invalidPhone("La longitud del n\xFAmero de tel\xE9fono no es v\xE1lida.", "El n\xFAmero de tel\xE9fono debe tener una longitud v\xE1lida.");
53912
+ }
53913
+ const existing2 = app.store.findContactByPhone(phone);
53914
+ return existing2 ? inScope(existing2) : createContact(app, { workspaceId: ctx.workspace.id, phone });
53915
+ }
53916
+ const whatsappId = String(b.whatsapp_id);
53917
+ const existing = [...app.store.contacts.values()].sort((x, y) => x.id - y.id).find((c) => c.whatsappId === whatsappId);
53918
+ return existing ? inScope(existing) : createContact(app, { workspaceId: ctx.workspace.id, whatsappId });
53919
+ }
52931
53920
  function sortContacts(list, orderBy, direction) {
52932
53921
  const key = typeof orderBy === "string" ? orderBy : "id";
52933
53922
  const desc = direction === void 0 ? key === "id" || key.endsWith("_at") : direction === "desc";
@@ -53030,7 +54019,7 @@ var contactHandlers = {
53030
54019
  return ok(paginate(list, listContext(ctx), (c) => contactResource(ctx.app.store, c)));
53031
54020
  },
53032
54021
  // `ContactService::firstOrCreate`: `201` with the new contact, or `200` with the one the account already has with that
53033
- // phone (or `whatsapp_id`), even if it is in another workspace (conversations v1.19.7 does not check it there).
54022
+ // phone (or `whatsapp_id`); one of another workspace answers 404, like `update-or-create` (conversations v1.19.21).
53034
54023
  "POST /api/v1/contacts": (ctx) => {
53035
54024
  const b = body(ctx);
53036
54025
  if (!b.phone && !b.whatsapp_id) throw fieldError("phone", "El campo phone es obligatorio cuando whatsapp_id no est\xE1 presente.");
@@ -53041,6 +54030,7 @@ var contactHandlers = {
53041
54030
  }
53042
54031
  const phone = digits(b.phone);
53043
54032
  const existing = phone ? ctx.app.store.findContactByPhone(phone) : [...ctx.app.store.contacts.values()].find((c) => c.whatsappId !== null && c.whatsappId === String(b.whatsapp_id));
54033
+ if (existing && !sees(ctx.workspace, existing.workspaceId)) throw outsideWorkspace();
53044
54034
  if (existing) return ok(contactResource(ctx.app.store, existing));
53045
54035
  const contact = createContact(ctx.app, { workspaceId: ctx.workspace.id, name: b.name, email: b.email, phone: b.phone, whatsappId: b.whatsapp_id, image: b.image, deviceId: b.deviceId });
53046
54036
  return ok(contactResource(ctx.app.store, contact), 201);
@@ -53560,13 +54550,28 @@ function note(ctx, contact, device, text2, reference) {
53560
54550
  });
53561
54551
  return ok({ message: publicMessage(message) });
53562
54552
  }
53563
- function sendToContact(ctx, contact, style) {
54553
+ var present2 = (value) => value !== void 0 && value !== null;
54554
+ var byIdentity = (b) => !present2(b.contact_id);
54555
+ function assertUsable(b, device) {
54556
+ if (!present2(b.whatsapp_id) || !byIdentity(b)) return;
54557
+ if (device.kind !== "cloud") {
54558
+ throw fieldError("whatsapp_id", "whatsapp_id (el BSUID de Meta) solo se puede usar con un n\xFAmero oficial de WhatsApp (API Cloud). Con un n\xFAmero conectado por QR, indica el destinatario en phone.");
54559
+ }
54560
+ if (present2(b.phone)) throw fieldError("whatsapp_id", "Sin contact_id, indica el destinatario con phone o con whatsapp_id, no con los dos.");
54561
+ if (!isBsuid(String(b.whatsapp_id))) {
54562
+ throw fieldError("whatsapp_id", "whatsapp_id tiene que ser el BSUID que env\xEDa Meta, por ejemplo US.13491208655302741918. Para enviar a un n\xFAmero de tel\xE9fono, usa phone.");
54563
+ }
54564
+ }
54565
+ function sendToContact(ctx, given, style) {
53564
54566
  const b = body(ctx);
53565
54567
  const reference = clientReference(b);
53566
54568
  const options = Array.isArray(b.options) ? b.options : null;
53567
54569
  if (b.note === true && options) throw new RawApiError(422, { error: "A note cannot carry interactive options." });
54570
+ if (given === null) assertUsable(b, getDevice(ctx.app, b.device_id));
53568
54571
  const file = resolveFile(b, style);
53569
54572
  const device = getDevice(ctx.app, b.device_id);
54573
+ if (given === null && !sees(ctx.workspace, device.workspaceId)) throw outsideWorkspace();
54574
+ const contact = given ?? sendRecipient(ctx, b);
53570
54575
  if (b.note === true) return note(ctx, contact, device, b.text, reference);
53571
54576
  const text2 = typeof b.text === "string" && b.text !== "" ? b.text : null;
53572
54577
  if (!text2 && !file) throw fieldError("text", "El campo text es obligatorio cuando file no est\xE1 presente.");
@@ -53576,9 +54581,9 @@ function sendToContact(ctx, contact, style) {
53576
54581
  contact,
53577
54582
  profile: "free_form",
53578
54583
  items: [{ type, text: text2, file: file?.file ?? null, hash: contentHash("message", text2, file?.file ?? null, options) }],
53579
- // `recipient_mismatch` only exists on the routes with the recipient in the body (`ApiRecipientBinding`).
53580
- phone: style === "conversation" ? b.phone : void 0,
53581
- whatsappId: style === "conversation" ? b.whatsapp_id : void 0,
54584
+ // `recipient_mismatch` (`ApiRecipientBinding`): a contact found by the body's phone is it by construction.
54585
+ phone: given === null ? void 0 : b.phone,
54586
+ whatsappId: given === null ? void 0 : b.whatsapp_id,
53582
54587
  clientReference: reference,
53583
54588
  mac: true
53584
54589
  });
@@ -53608,19 +54613,31 @@ function getTemplate(ctx, id) {
53608
54613
  function sendTemplate(ctx) {
53609
54614
  const b = body(ctx);
53610
54615
  const reference = clientReference(b);
54616
+ if (!present2(b.phone) && !present2(b.whatsapp_id)) {
54617
+ throw validationFailed({
54618
+ ...present2(b.contact_id) ? {} : { contact_id: ["El campo contact_id es obligatorio cuando ninguno de phone / whatsapp_id est\xE1 presente."] },
54619
+ phone: ["El campo phone es obligatorio cuando whatsapp_id no est\xE1 presente."]
54620
+ });
54621
+ }
53611
54622
  const own = ctx.app.store.devices.get(Number(ctx.path.device));
53612
54623
  if (own && !sees(ctx.workspace, own.workspaceId)) throw outsideWorkspace();
53613
- const template = ctx.app.store.templates.get(Number(ctx.path.template_id ?? ctx.path.template));
54624
+ const found = ctx.app.store.templates.get(Number(ctx.path.template_id ?? ctx.path.template));
54625
+ const template = found && sees(ctx.workspace, found.workspaceId) ? found : void 0;
53614
54626
  if (!template) throw new ApiError(404, "template_not_found", "No se envi\xF3: la plantilla no existe en tu cuenta. Revisa el identificador de la plantilla.");
53615
54627
  const device = ctx.app.store.devices.get(Number(ctx.path.device));
53616
54628
  if (!device) throw rejection("device_unavailable");
53617
54629
  if (template.deviceId !== device.id) throw new RawApiError(422, { error: "La plantilla no pertenece al n\xFAmero actual", error_code: "template_waba_mismatch" });
53618
54630
  if (template.status !== "APPROVED") throw fieldError("template", `La plantilla est\xE1 ${template.status}: solo se env\xEDan plantillas aprobadas.`);
53619
- const contact = ctx.app.store.contacts.get(Number(b.contact_id));
53620
- if (!contact) throw new RawApiError(404, { error: "Contact not found" });
54631
+ assertUsable(b, device);
54632
+ const identity = byIdentity(b);
54633
+ const contactId = identity ? sendRecipient(ctx, b).id : Number(b.contact_id);
54634
+ const contact = ctx.app.store.contacts.get(contactId);
54635
+ if (!contact || !sees(ctx.workspace, contact.workspaceId)) throw new RawApiError(404, { error: "Contact not found" });
53621
54636
  if (!device.connected) throw rejection("device_unavailable");
53622
54637
  const variables = b.template_variables?.body;
53623
- const check = checkSend(ctx.app, { device, contact, profile: "template", phone: b.phone, contentHashes: [] });
54638
+ const phone = identity ? void 0 : b.phone;
54639
+ const whatsappId = identity ? void 0 : b.whatsapp_id;
54640
+ const check = checkSend(ctx.app, { device, contact, profile: "template", phone, whatsappId, contentHashes: [] });
53624
54641
  if (check?.code === "recipient_mismatch") throw check;
53625
54642
  const text2 = renderTemplate(template, variables);
53626
54643
  const [message] = send(ctx, {
@@ -53628,7 +54645,8 @@ function sendTemplate(ctx) {
53628
54645
  contact,
53629
54646
  profile: "template",
53630
54647
  items: [{ type: "template", text: text2, file: template.file, templateId: template.id, hash: contentHash("template", template.id, variables) }],
53631
- phone: b.phone,
54648
+ phone,
54649
+ whatsappId,
53632
54650
  clientReference: reference,
53633
54651
  mac: false
53634
54652
  });
@@ -53636,10 +54654,17 @@ function sendTemplate(ctx) {
53636
54654
  }
53637
54655
  var messageHandlers = {
53638
54656
  "POST /api/v1/contacts/{contact}/message": (ctx) => sendToContact(ctx, getContact(ctx.app, ctx.path.contact, ctx.workspace), "conversation"),
54657
+ // With `contact_id`, or by `phone` / `whatsapp_id` alone: the contact is found or created (conversations v1.19.21).
53639
54658
  "POST /api/v1/message/send-message": (ctx) => {
53640
54659
  const b = body(ctx);
53641
- const contact = ctx.app.store.contacts.get(Number(b.contact_id));
53642
- if (!contact || !sees(ctx.workspace, contact.workspaceId)) throw new RawApiError(404, { status: "error", error: "Contacto no encontrado." });
54660
+ if (byIdentity(b) && !present2(b.phone) && !present2(b.whatsapp_id)) {
54661
+ throw fieldError("contact_id", "El campo contact_id es obligatorio cuando ninguno de phone / whatsapp_id est\xE1 presente.");
54662
+ }
54663
+ let contact = null;
54664
+ if (!byIdentity(b)) {
54665
+ contact = ctx.app.store.contacts.get(Number(b.contact_id)) ?? null;
54666
+ if (!contact || !sees(ctx.workspace, contact.workspaceId)) throw new RawApiError(404, { status: "error", error: "Contacto no encontrado." });
54667
+ }
53643
54668
  if (!ctx.app.store.devices.has(Number(b.device_id))) throw new RawApiError(403, { status: "error", error: "No autorizado" });
53644
54669
  return sendToContact(ctx, contact, "direct");
53645
54670
  },
@@ -54413,17 +55438,17 @@ var MEDIA_EXTENSIONS = {
54413
55438
  function checkSendBody(b) {
54414
55439
  const errors = {};
54415
55440
  const type = typeof b.type === "string" ? b.type : "";
54416
- const present2 = (key) => b[key] !== void 0 && b[key] !== null && b[key] !== "";
55441
+ const present3 = (key) => b[key] !== void 0 && b[key] !== null && b[key] !== "";
54417
55442
  const prohibited = (key) => {
54418
- if (present2(key)) errors[key] = [`El campo ${key} est\xE1 prohibido.`];
55443
+ if (present3(key)) errors[key] = [`El campo ${key} est\xE1 prohibido.`];
54419
55444
  };
54420
55445
  if (type === "text") {
54421
- if (!present2("text")) errors.text = ["El campo text es obligatorio."];
55446
+ if (!present3("text")) errors.text = ["El campo text es obligatorio."];
54422
55447
  else if (typeof b.text !== "string" || b.text.length > 4096) errors.text = ["El campo text no debe tener m\xE1s de 4096 caracteres."];
54423
55448
  } else prohibited("text");
54424
55449
  if (type in MEDIA_EXTENSIONS) {
54425
55450
  const url = String(b.media_url ?? "");
54426
- if (!present2("media_url")) errors.media_url = ["El campo media url es obligatorio."];
55451
+ if (!present3("media_url")) errors.media_url = ["El campo media url es obligatorio."];
54427
55452
  else if (url.length > 255) errors.media_url = ["La URL del archivo puede tener hasta 255 caracteres."];
54428
55453
  else {
54429
55454
  let parsed = null;
@@ -54440,7 +55465,7 @@ function checkSendBody(b) {
54440
55465
  }
54441
55466
  } else prohibited("media_url");
54442
55467
  if (type !== "image" && type !== "video") prohibited("caption");
54443
- else if (present2("caption") && String(b.caption).length > 1024) errors.caption = ["El campo caption no debe tener m\xE1s de 1024 caracteres."];
55468
+ else if (present3("caption") && String(b.caption).length > 1024) errors.caption = ["El campo caption no debe tener m\xE1s de 1024 caracteres."];
54444
55469
  if (type !== "document") prohibited("file_name");
54445
55470
  if (Object.keys(errors).length) throw new RawApiError(422, { message: summarize2(errors), errors });
54446
55471
  }
@@ -55806,5 +56831,5 @@ function createSandbox(options = {}) {
55806
56831
  }
55807
56832
 
55808
56833
  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 };
55809
- //# sourceMappingURL=chunk-PCGMUC35.js.map
55810
- //# sourceMappingURL=chunk-PCGMUC35.js.map
56834
+ //# sourceMappingURL=chunk-FUK4UDIM.js.map
56835
+ //# sourceMappingURL=chunk-FUK4UDIM.js.map