# BUZWIK — Referencia de integración (API, eventos, OTP, chatbot web y webhooks)

> Versión / última actualización: 2026-10-31
>
> Documento único y autocontenido para desarrolladores y agentes de IA (LLM) que integran un sistema externo con BUZWIK **de servidor a servidor**. Está derivado del código fuente de la plataforma. Donde algo no está garantizado o no existe, se dice explícitamente.
>
> Versiones de este documento: `https://www.buzwik.com/docs/api.md` (Markdown) y `https://www.buzwik.com/llms-full.txt` (mismo contenido). Índice corto: `https://www.buzwik.com/llms.txt`. Especificación OpenAPI 3.1 (endpoints con API key, errores y webhooks): [`https://www.buzwik.com/docs/openapi.yaml`](https://www.buzwik.com/docs/openapi.yaml).

## Índice

1. [Resumen y modelo mental](#resumen)
2. [URL base y convenciones](#convenciones)
3. [Autenticación](#autenticacion)
4. [Formato de respuesta y errores](#errores)
5. [Límites de tasa](#rate-limits)
6. [Conceptos de WhatsApp que tenés que entender](#whatsapp)
7. [POST /api/messages y GET /api/messages/{id} — envío directo y seguimiento](#messages)
8. [POST /api/events — disparar automatizaciones](#events)
9. [POST /api/otp/send y POST /api/otp/verify — códigos de verificación](#otp)
10. [POST /api/chatbot/ask — chatbot para tu web](#chatbot)
11. [GET /api/ping, GET /api/account, /api/contacts y /api/v1/requests — credenciales, plantillas, números, contactos y solicitudes](#ping) ([Solicitudes por API](#requests-api), [Puntos de entrada por API](#entry-points-api), [Catálogo por API](#catalog-api), [Agenda por API](#agenda-api))
12. [Webhooks salientes (BUZWIK → tu sistema)](#webhooks)
13. [Receta: integrar un sistema de turnos/reservas](#receta-turnos)
14. [Endpoints que NO son para integraciones](#panel-only)
15. [Limitaciones actuales](#limitaciones)
16. [Checklist para un agente LLM](#checklist)
17. [Versionado y compatibilidad](#versionado)

---

<a id="resumen"></a>
## 1. Resumen y modelo mental

BUZWIK es un SaaS multi-empresa (multi-tenant) sobre la API oficial de WhatsApp Cloud (Meta). Cada empresa (tenant) conecta su número de WhatsApp Business en el panel y desde ahí configura plantillas, automatizaciones, chatbot y webhooks.

Un sistema externo se integra por estas vías:

| Vía | Dirección | Para qué |
|---|---|---|
| `POST /api/messages` | tu sistema → BUZWIK | **Envío directo**: mandar una plantilla aprobada (por nombre, con sus variables y payloads propios en sus botones), un texto libre o un mensaje con botones (estos dos solo dentro de la ventana de 24 h) a un número. Vos decidís qué y cuándo. |
| `GET /api/messages/{id}` | tu sistema → BUZWIK | Consultar el estado de un mensaje (alternativa o complemento al webhook `message.status`). |
| `POST /api/events` | tu sistema → BUZWIK | Avisar que pasó algo de negocio (reserva creada, pedido enviado). Las **automatizaciones** configuradas en el panel deciden qué WhatsApp se manda. |
| `POST /api/otp/send` / `POST /api/otp/verify` | tu sistema → BUZWIK | Enviar y validar códigos de verificación por WhatsApp. |
| `POST /api/chatbot/ask` | tu backend → BUZWIK | Respuesta sincrónica del chatbot para el chat de tu sitio web. |
| `GET /api/account`, `/api/account/templates`, `/api/account/numbers` | tu sistema → BUZWIK | De qué empresa es la key, qué plantillas están aprobadas (y sus variables) y por qué números se puede enviar. |
| `/api/v1/requests` | tu sistema → BUZWIK | **Solicitudes**: crear una desde tu sistema de reclamos (con tu referencia, sin duplicar), listarlas para sincronizar, cambiarles el estado y bajar sus fotos (ver [11.2](#requests-api)). |
| Webhooks salientes | BUZWIK → tu sistema | Recibir mensajes entrantes (`message.received`), cambios de estado de envíos (`message.status`) y solicitudes (`request.created`, `request.updated`, `request.rated`). |

¿Envío directo o eventos? Dos formas de mandar mensajes desde tu sistema:

- **`POST /api/messages` (envío directo)**: tu sistema elige la plantilla y los valores en cada llamada. Es la opción recomendada para avisos fuera de la ventana de 24 h (confirmaciones, recordatorios), porque envía **plantillas aprobadas**. Idempotencia opcional con `reference`.
- **`POST /api/events` + automatizaciones**: tu sistema solo informa "pasó X" y el contenido se configura en el panel sin tocar código. La automatización puede enviar una **plantilla aprobada** (llega aunque el contacto no te haya escrito) o un texto libre (solo dentro de la ventana de 24 h); ver [8.5](#events).

---

<a id="convenciones"></a>
## 2. URL base y convenciones

- URL base: `https://www.buzwik.com`
- Todos los endpoints de integración están bajo `https://www.buzwik.com/api/...`.
- Cuerpos en JSON UTF-8. Mandá siempre `Content-Type: application/json` en los `POST`. Si falta (por ejemplo `curl -d` sin `-H "Content-Type: application/json"` manda `application/x-www-form-urlencoded`), el servidor responde `415 UNSUPPORTED_MEDIA_TYPE`.
- **Nombres de campos**:
  - En los **requests y responses de la API** los campos van en `camelCase` (`eventType`, `entityRef`, `waPhone`, `messageId`, `conversationId`).
  - En los **webhooks salientes** los campos de `data` van en `snake_case` (`message_id`, `wa_message_id`, `conversation_id`).
- Los campos desconocidos del request se ignoran (no dan error). Consecuencia: si mandás `entity_ref` en vez de `entityRef`, el error que vas a ver es `entityRef es obligatorio`.
- Fechas: ISO-8601 en UTC (`2026-10-12T21:40:05.120Z`).
- Identificadores internos: UUID en formato texto.
- Placeholders usados en este documento: `$BUZWIK_API_KEY` (API key), `$BUZWIK_WEBHOOK_SECRET` (secreto de firma del webhook), `$BUZWIK_BASE_URL` (= `https://www.buzwik.com`).

---

<a id="autenticacion"></a>
## 3. Autenticación

### 3.1 API key (`X-Api-Key`)

Los endpoints de integración se autentican con una API key de la empresa en el header `X-Api-Key`:

```http
X-Api-Key: bzk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```

- Se emite en el panel: **Configuración → API keys** (botón para crear, con un nombre descriptivo, sus **permisos** y un **vencimiento** opcional).
- El secreto completo se muestra **una sola vez** al crearla. BUZWIK guarda solo su hash: si la perdés, emití otra y revocá la anterior.
- Siempre empieza con el prefijo `bzk_`.
- **Vencimiento**: opcional. Sin fecha, la key no vence. Con fecha, vence al terminar ese día (UTC) y desde ahí responde `401` ("API key inválida, revocada o vencida"). Para rotar sin cortes: emití la nueva, cambiala en tu sistema y después revocá (o dejá vencer) la vieja.
- Revocar una key (desde el mismo panel) es inmediato e irreversible: la siguiente request con esa key recibe `401`.
- **Permisos (scopes)**: cada key solo puede usar los endpoints de los permisos que tiene (ver 3.3). Las keys creadas antes de los permisos tienen todos.
- El panel muestra de cada key su prefijo, permisos, vencimiento y **último uso** (se actualiza como mucho una vez por minuto).
- La key identifica a la empresa: todo lo que hagas con ella ocurre en esa empresa (tenant). No hay que mandar ningún id de empresa.

> **Seguridad**: la API key es un secreto de servidor. Nunca la pongas en JavaScript del navegador, en una app móvil ni en un repositorio. Quien la tenga puede enviar mensajes facturables a nombre de la empresa. Guardala en una variable de entorno (`BUZWIK_API_KEY`) o en un gestor de secretos.

### 3.2 Qué endpoint acepta qué

| Ruta | API key (`X-Api-Key`) | Permiso de la key | JWT del panel (`Authorization: Bearer`) |
|---|---|---|---|
| `POST /api/otp/send`, `POST /api/otp/verify` | **Obligatoria** (sin key válida: `401`) | `otp` | No aplica |
| `POST /api/messages`, `GET /api/messages/{id}` | Sí (uso previsto para integraciones) | `messages:send` | Sí |
| `POST /api/events` | Sí (uso previsto para integraciones) | `events` | Sí (pruebas desde el panel) |
| `POST /api/chatbot/ask` | Sí (uso previsto) | `chatbot` | Sí (pruebas desde el panel) |
| `GET /api/ping`, `GET /api/account`, `GET /api/account/templates`, `GET /api/account/numbers` | Sí | ninguno (cualquier key válida) | Sí |
| `GET /api/contacts`, `GET /api/contacts/{id}`, `POST /api/contacts`, `PUT /api/contacts/{id}`, `PUT /api/contacts/{id}/opt-in` | Sí | `contacts` | Sí |
| `GET /api/v1/requests`, `GET /api/v1/requests/{id}`, `GET /api/v1/requests/{id}/photos/{photoId}`, `GET /api/v1/requests/areas` | **Obligatoria** | `requests:read` | No (el panel usa `/api/requests`) |
| `POST /api/v1/requests`, `PUT /api/v1/requests/{id}/status` | **Obligatoria** | `requests:write` | No |
| `GET /api/v1/entry-points`, `POST /api/v1/entry-points`, `GET /api/v1/entry-points/{id}`, `PUT /api/v1/entry-points/{id}`, `GET /api/v1/entry-points/{id}/report` | **Obligatoria** | `entry-points` | No (el panel usa `/api/entry-points`) |
| `GET /api/v1/catalog`, `GET /api/v1/catalog/{code}`, `PUT /api/v1/catalog/{code}` | **Obligatoria** | `catalog` | No (el panel usa `/api/catalog`) |
| Resto de `/api/**` | **No**: con API key responde `403 FORBIDDEN` (ver [sección 14](#panel-only)) | — | Sí |

Cómo funciona (del código de los filtros de seguridad):

1. Si la request trae `X-Api-Key` y la key es válida (existe, no revocada, no vencida), la request queda autenticada para esa empresa.
2. Si la request trae `X-Api-Key` pero la key no existe, fue revocada o venció: `401 UNAUTHORIZED` ("API key inválida, revocada o vencida"), en cualquier ruta de `/api/**`.
3. Una key válida solo sirve para los endpoints de integración de la tabla. En cualquier otra ruta de `/api/**` responde `403 FORBIDDEN`: una key filtrada no puede tocar plantillas, números, webhooks ni otras keys.
4. Si la key no tiene el permiso de esa ruta: `403 FORBIDDEN` con el permiso que falta en el mensaje (`Esta API key no tiene el permiso "requests:write". ...`).
5. Las rutas `/api/otp/**` exigen API key; si falta responden `401`.
6. El resto de `/api/**` (salvo `/api/auth/**`) sin API key exige un JWT del panel; si falta o es inválido: `401`.

Todos estos `401`/`403` usan el envelope de BUZWIK (`{"success":false,"error":{"code":"UNAUTHORIZED"|"FORBIDDEN","message":"..."}}`).

<a id="scopes"></a>
### 3.3 Permisos de una API key (scopes)

Al crear una key en el panel se eligen sus permisos (por defecto, todos). Dale a cada sistema solo lo que necesita: si la key se filtra, no puede hacer más que eso.

| Permiso | Habilita |
|---|---|
| `messages:send` | `POST /api/messages` y `GET /api/messages/{id}` |
| `otp` | `POST /api/otp/send` y `POST /api/otp/verify` |
| `events` | `POST /api/events` |
| `contacts` | `/api/contacts` (listar, ver, crear, actualizar, opt-in) |
| `requests:read` | `GET /api/v1/requests`, `/{id}`, `/{id}/photos/{photoId}` y `/areas` |
| `requests:write` | `POST /api/v1/requests` y `PUT /api/v1/requests/{id}/status` |
| `chatbot` | `POST /api/chatbot/ask` |
| `entry-points` | `/api/v1/entry-points` (listar, ver, crear, cambiar y el reporte de cada punto). Las keys creadas antes de este permiso no lo tienen: emití una nueva |
| `catalog` | `/api/v1/catalog` (listar, ver y crear o actualizar por código los ítems del catálogo: propiedades, habitaciones, lugares). Las keys creadas antes de este permiso no lo tienen: emití una nueva |

`GET /api/ping` y `GET /api/account/*` funcionan con cualquier key válida (sirven para verificar la configuración). Los permisos de una key no se pueden cambiar después de crearla: emití otra con los permisos nuevos y revocá la anterior.

---

<a id="errores"></a>
## 4. Formato de respuesta y errores

### 4.1 Envelope

Todas las respuestas de los controllers usan el mismo envelope. Los campos nulos se omiten.

Éxito:

```json
{
  "success": true,
  "data": { "accepted": true }
}
```

Error:

```json
{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "entityRef es obligatorio",
    "details": { }
  }
}
```

Tipos:

```ts
type ApiResponse<T> = {
  success: boolean;
  data?: T;              // presente en éxito
  error?: {
    code: string;        // código estable (ver tabla); ramificá por esto, no por message
    message: string;     // texto legible en español; puede cambiar
    details?: Record<string, unknown>; // opcional
  };
  meta?: unknown;        // solo en listados paginados (no aplica a los endpoints de integración)
};
```

### 4.2 Códigos de error

Códigos definidos en la plataforma y su HTTP. Los marcados con ★ son los que un integrador puede encontrarse en los endpoints de este documento.

| `error.code` | HTTP | Cuándo |
|---|---|---|
| ★ `VALIDATION_ERROR` | 400 | Cuerpo inválido, campo obligatorio faltante, límite superado, JSON mal formado. |
| ★ `UNAUTHORIZED` | 401 | API key inválida, revocada o vencida; o falta autenticación (ver 3.2). |
| ★ `FORBIDDEN` | 403 | API key usada en un endpoint del panel, o key sin el permiso de esa ruta (ver 3.2 y 3.3). |
| ★ `NOT_FOUND` | 404 | `contactId` inexistente en `/api/events`; plantilla inexistente (o borrada) en `/api/messages`; mensaje inexistente en `GET /api/messages/{id}`; solicitud o foto inexistente en `/api/v1/requests`. |
| ★ `METHOD_NOT_ALLOWED` | 405 | Método HTTP que la ruta no acepta (ej. `DELETE /api/messages`). |
| ★ `CONFLICT` | 409 | Llegaron dos pedidos iguales al mismo tiempo (misma `reference`, o misma automatización + `entityRef`): reintentá, el reintento ve el primero y responde como duplicado. En `POST /api/v1/requests`: ya hay otra solicitud con ese `externalRef` y **datos distintos** (no reintentes: usá otra referencia). |
| ★ `UNSUPPORTED_MEDIA_TYPE` | 415 | Falta `Content-Type: application/json` (o es otro). |
| ★ `RATE_LIMITED` | 429 | Límite de tasa superado. Trae header `Retry-After` (segundos). |
| ★ `INTERNAL_ERROR` | 500 | Error inesperado. Mensaje genérico, sin detalles. |
| `TENANT_NOT_FOUND` | 404 | — |
| `TENANT_SUSPENDED` | 403 | — |
| `TENANT_CONTEXT_MISSING` | 500 | — |
| `QUOTA_EXCEEDED` | 429 | Definido; hoy no se usa (pasarse del plan no bloquea envíos). |
| ★ `FEATURE_NOT_IN_PLAN` | 403 | El módulo necesario no está activo en el plan (ej. chatbot web). |
| ★ `ACCOUNT_SUSPENDED` | 402 | Prueba vencida, pago atrasado o baja: la cuenta **no envía** hasta regularizar. |
| `PAYMENT_REQUIRED` | 402 | Acciones del panel que requieren pago. |
| ★ `WABA_NOT_CONNECTED` | 409 | La empresa no tiene un número de WhatsApp conectado/utilizable, o el `from` indicado no existe o no está conectado (`/api/messages`, OTP). |
| ★ `TEMPLATE_NOT_APPROVED` | 409 | `/api/messages`: la plantilla existe pero no tiene una versión aprobada por Meta. (OTP y automatizaciones no lo devuelven: OTP cae a texto y las automatizaciones registran el evento como salteado.) |
| ★ `CONVERSATION_WINDOW_CLOSED` | 409 | `/api/messages` con `text`: el contacto no escribió en las últimas 24 h. Usá una plantilla. |
| ★ `CONTACT_OPTED_OUT` | 409 | `/api/messages` con plantilla: el contacto pidió la baja. Las plantillas `AUTHENTICATION` sí se envían. |
| ★ `META_API_ERROR` | 502 | Falla de Meta en una operación sincrónica. |
| ★ `AI_UNAVAILABLE` | 503 | El proveedor de IA del chatbot no respondió. |

### 4.3 Política de reintentos recomendada (cliente)

| Respuesta | ¿Reintentar? |
|---|---|
| `2xx` | No. |
| `400`, `401`, `403`, `404` | No. Corregí el request o la configuración. |
| `402 ACCOUNT_SUSPENDED` | No automáticamente. Alertá a un humano: la cuenta tiene que regularizar el plan. |
| `409 WABA_NOT_CONNECTED` | No automáticamente. Alertá: falta conectar el número en el panel. |
| `409 TEMPLATE_NOT_APPROVED` / `CONVERSATION_WINDOW_CLOSED` / `CONTACT_OPTED_OUT` | No. Son decisiones de negocio: cambiar de plantilla, esperar aprobación o no escribirle a ese contacto. |
| `409 CONFLICT` | En `/api/messages` y `/api/events`: sí, una vez y enseguida (llegó otro pedido igual al mismo tiempo; el reintento responde como duplicado). En `POST /api/v1/requests`: no (la referencia ya es de otra solicitud con otros datos). |
| `429` | Sí, después de `Retry-After` segundos. |
| `500`, `502`, `503`, timeout, error de red | Sí, con backoff exponencial. `/api/messages` es idempotente **si mandás `reference`** (ver 7.4); `/api/events` es idempotente por `entityRef` (ver 8.4); `POST /api/v1/requests` lo es **si mandás `externalRef`** (ver 11.2); `/api/otp/send` **no** lo es (cada llamada manda un código nuevo). |

---

<a id="rate-limits"></a>
## 5. Límites de tasa

Valores por defecto de la plataforma (configurables por BUZWIK, no por la empresa). Se aplican con un token bucket **por empresa**:

| Ruta (prefijo) | Límite por defecto |
|---|---|
| `POST /api/messages` (solo el POST exacto; `GET /api/messages/{id}` no tiene límite) | 300 requests por minuto por empresa |
| `/api/events` | 600 requests por minuto por empresa |
| `/api/otp` (incluye `/send` **y** `/verify`, comparten el mismo cupo) | 60 requests por minuto por empresa |
| `/api/chatbot/ask` | 120 requests por minuto por empresa |
| `/api/contacts` (todos los métodos) | 300 requests por minuto por empresa |
| `/api/v1/requests` (todos los métodos: alta, estado, lecturas y fotos) | 300 requests por minuto por empresa |
| `/api/v1/entry-points` (todos los métodos) | 300 requests por minuto por empresa |
| `/api/v1/catalog` (todos los métodos) | 300 requests por minuto por empresa |

Al superarlo: HTTP `429`, header `Retry-After: <segundos>` y cuerpo:

```json
{"success":false,"error":{"code":"RATE_LIMITED","message":"Demasiadas peticiones. Espera un momento y volve a intentar."}}
```

Además, el envío a Meta tiene su propio control de ritmo por número (los mensajes encolados se difieren, no se pierden).

---

<a id="whatsapp"></a>
## 6. Conceptos de WhatsApp que tenés que entender

### 6.1 Números de teléfono

- WhatsApp identifica a cada persona por su `wa_id`: el número en formato internacional **solo dígitos**, con código de país y sin `+` (ej. `59899123456`). Es E.164 sin el `+`.
- Recomendación para todos los endpoints: mandá el número **solo con dígitos, con código de país, sin `+`, espacios ni guiones** (`59899123456`).
  - `/api/messages` quita espacios, `+`, `-` y paréntesis; lo que queda tiene que ser solo dígitos, entre 8 y 15 (si no: `400`).
  - `/api/events` quita todo lo que no sea dígito (`"+598 99 123 456"` → `59899123456`) y exige al menos 8 dígitos.
  - `/api/otp/send` y `/api/otp/verify` normalizan igual que `/api/messages` (`"+598 99 123 456"` y `59899123456` son el mismo número; si no quedan entre 8 y 15 dígitos: `400`).
- En los webhooks, `from.wa_id` llega en ese formato de solo dígitos.

### 6.2 Ventana de servicio de 24 horas

- Cuando un contacto le escribe a la empresa, se abre (o renueva) una **ventana de 24 horas** contada desde su último mensaje.
- **Dentro** de la ventana se pueden mandar mensajes libres (texto, documentos). Esos mensajes son categoría `SERVICE` y no consumen mensajes del plan.
- **Fuera** de la ventana, Meta solo entrega **plantillas aprobadas**. Un texto libre fuera de ventana es aceptado inicialmente por Meta y luego informado como `failed` (lo vas a ver como `message.status` con `status: "failed"` y `errors`).
- Si el envío falla **antes** de llegar a WhatsApp (Meta lo rechaza al recibirlo, por ejemplo por un parámetro inválido o porque la conexión de la cuenta venció), también recibís `message.status` con `status: "failed"`: `wa_message_id` viene en `null` y `errors[0].source` es `"buzwik"`, con el código de Meta en `errors[0].code` cuando lo hubo. Los errores que se repetirían igual no se reintentan; los transitorios (red, límites de Meta) se reintentan solos y, mientras tanto, el mensaje sigue en `QUEUED` con el motivo del último intento en `error`.

### 6.3 Plantillas (templates)

- Son mensajes pre-aprobados por Meta. Se crean en el panel (**Plantillas**): se escriben, se envían a revisión y Meta las aprueba o rechaza. También se pueden importar plantillas ya existentes en la cuenta de Meta.
- **Categorías** (enum `MessageCategory`): `MARKETING` (promociones), `UTILITY` (avisos transaccionales: confirmaciones, recordatorios), `AUTHENTICATION` (códigos OTP). `SERVICE` es la categoría de los mensajes libres dentro de la ventana.
- **Variables con nombre**: en BUZWIK se escriben como `{{nombre}}` (`Hola {{nombre}}, tu turno es el {{fecha}}`). Regla del nombre: empieza con letra, luego letras, dígitos, `_` o `.`; máximo 20 variables por plantilla. Meta las recibe como posicionales (`{{1}}`, `{{2}}`) según el orden de primera aparición; BUZWIK hace la traducción. Vos solo mandás un mapa `{"nombre": "Ana", "fecha": "15/10"}`.
- **Plantillas importadas**: el cuerpo `{{1}} {{2}}` de Meta se convierte en `{{var1}} {{var2}}`. Las variables se llaman así.
- **Botones** (hasta 10; respuestas rápidas agrupadas):
  - `QUICK_REPLY`: botón de respuesta rápida. Cuando el contacto lo toca, recibís un `message.received` con `type: "button"` y `button.text` / `button.payload` (ver 12.5).
  - `URL`: link fijo o con **una** variable al final (`https://tusitio.com/reserva/{{reserva}}`); hasta 2 botones de link. El valor de esa variable se pasa igual que las del cuerpo, en el mismo mapa `variables`. En plantillas importadas esas variables se llaman `{{url1}}`, `{{url2}}`.
  - `PHONE_NUMBER`: llamar a un teléfono fijo (uno solo).
- **Plantillas de autenticación** (`AUTHENTICATION`): un solo código como variable; BUZWIK lo pone también en el botón de "copiar código". El servicio OTP usa una plantilla específica (ver 9.5).
- Variables faltantes: `/api/messages` **rechaza** el envío con `400` si falta (o está vacía) cualquier variable de la plantilla, incluidas las de botones de link; el mensaje de error lista las variables que usa la plantilla. En automatizaciones con plantilla, si falta alguna **no se envía** y el evento queda `SKIPPED` ("faltan variables de la plantilla: …") en el registro del panel. En OTP la única variable es el código. Mandá siempre todas.

### 6.4 Bajas (opt-out)

- Si un contacto **escribe** un mensaje de texto corto (hasta 3 palabras) que sea **exactamente** una de estas palabras/frases (sin importar mayúsculas, acentos ni puntuación), queda dado de baja (`OPTED_OUT`): `baja`, `stop`, `unsubscribe`, `cancelar suscripcion`, `no molestar`, `darme de baja`, `dar de baja`.
- Tocar un botón (respuesta rápida o interactivo) **nunca** da de baja, diga lo que diga el botón: es una respuesta al negocio.
- Si escribe `alta`, `start`, `subscribe`, `si quiero`, `suscribir` o `reactivar`, se vuelve a dar de alta (`OPTED_IN`).
- Un contacto dado de baja **no recibe automatizaciones**: `/api/events` responde `200` igual, pero los triggers quedan registrados como salteados ("contacto opt-out").
- Por **envío directo** (`/api/messages`), a un contacto dado de baja no se le pueden mandar plantillas (`409 CONTACT_OPTED_OUT`), salvo las de categoría `AUTHENTICATION`. El texto libre no se bloquea por baja (solo se puede dentro de la ventana, es decir, si el contacto escribió en las últimas 24 h).
- OTP (`/api/otp/send`) no verifica el estado de baja.
- Tocar un botón de plantilla **no** cuenta como baja (solo se analiza el texto escrito).
- Palabras de todos los días como `cancelar`, `eliminar` o `salir` **no** dan de baja: en un sistema de turnos, "cancelar" es cancelar el turno. Igual conviene usar botones de respuesta rápida para las acciones: llegan con su `payload` y no hay que interpretar texto libre.
- El estado de baja se puede cambiar a mano en el panel (**Contactos**). No hay endpoint de API para consultarlo ni cambiarlo.

### 6.5 Categorías y facturación

- Cada mensaje enviado se cuenta según su categoría (`MARKETING`, `UTILITY`, `AUTHENTICATION`, `SERVICE`) y la zona del número destino. `SERVICE` (dentro de la ventana) no cuenta para el límite del plan.
- Los mensajes de las automatizaciones se cuentan con la categoría de su plantilla (o la configurada en la automatización si envía texto); los OTP como `AUTHENTICATION`; los de `/api/messages` con la categoría de la plantilla (o `SERVICE` si es texto libre).
- Pasarse del límite de mensajes enviados del plan **no bloquea** el envío: se factura como excedente de plataforma (US$5 cada 1.000).
- Los mensajes de WhatsApp en sí los cobra Meta directo a la empresa, a su tarifa por país. Desde el 1/10/2026 Meta también cobra `SERVICE` pasadas las 1.000 respuestas gratis por número por mes.
- Lo que sí bloquea es una cuenta suspendida (prueba vencida o pago pendiente): todo envío responde `402 ACCOUNT_SUSPENDED`.

---

<a id="messages"></a>
## 7. POST /api/messages y GET /api/messages/{id} — envío directo y seguimiento

### 7.1 Qué hace

Envía **un** mensaje a **un** número: una **plantilla aprobada** (por nombre, con sus variables y, opcionalmente, un payload propio por botón), un **texto libre** o un **mensaje interactivo** con botones, lista de opciones o botón con link (`interactive`; estos dos últimos solo si el contacto escribió en las últimas 24 h). El mensaje queda encolado y lo despacha el mismo motor que el resto de los envíos (outbox → Meta). El estado se sigue con `GET /api/messages/{id}` o con el webhook `message.status`.

### 7.2 Request

```
POST https://www.buzwik.com/api/messages
X-Api-Key: $BUZWIK_API_KEY
Content-Type: application/json
```

```ts
type SendRequest = {
  to: string;            // obligatorio. Destino con código de país. Se quitan espacios, "+", "-" y paréntesis; debe quedar 8..15 dígitos (ej. "59899123456").
  from?: string;         // opcional. phone_number_id de Meta del número de origen (NO el número mostrado). Sin esto: el número principal (por defecto) de la empresa.
  template?: {           // plantilla aprobada. Exactamente uno de template / text / interactive.
    name: string;        // obligatorio dentro de template. Nombre de la plantilla en Meta (ej. "recordatorio_turno").
    language?: string;   // opcional si la plantilla existe en un solo idioma (ej. "es"). Obligatorio si existe en varios.
    variables?: Record<string, string>; // TODAS las variables de la plantilla, no vacías: las del cuerpo ({{nombre}}, o {{var1}}… en importadas) y las de botones de link ({{reserva}}, o {{url1}}… en importadas).
    buttonPayloads?: string[]; // opcional. Payload propio de cada botón de RESPUESTA RÁPIDA de la plantilla, en el orden de esos botones (ej. ["confirmar:48213","cancelar:48213"]). 1..256 caracteres cada uno; no más que los botones de respuesta rápida que tiene. Vuelve en message.received → button.payload al tocarlo. Sin esto, el payload es el texto del botón.
  };
  text?: string;         // texto libre, hasta 4096 caracteres. Solo con la ventana de 24 h abierta.
  interactive?: {        // mensaje interactivo propio (no necesita plantilla). Solo con la ventana de 24 h abierta.
    body: string;        // obligatorio, hasta 1024 caracteres.
    header?: string;     // opcional, texto, hasta 60.
    footer?: string;     // opcional, hasta 60.
                         // Lleva exactamente UNO de buttons / list / link:
    buttons?: Array<{    // 1 a 3 botones, sin repetir id ni title.
      id: string;        // tu identificador, 1..256 caracteres (ej. "confirmar:48213"). Vuelve en message.received → interactive.id.
      title: string;     // texto del botón, hasta 20 caracteres.
    }>;
    list?: {             // lista de opciones: un botón que abre hasta 10 opciones.
      button: string;    // texto del botón que abre la lista, hasta 20 caracteres (ej. "Ver servicios").
      rows: Array<{      // 1 a 10 opciones, sin repetir id ni title.
        id: string;      // tu identificador, 1..200 caracteres. Vuelve en message.received → interactive.id (interactive.type = "list_reply").
        title: string;   // hasta 24 caracteres.
        description?: string; // texto chico debajo, hasta 72.
      }>;
    };
    link?: {             // un botón que abre un link (no vuelve nada al tocarlo).
      text: string;      // texto del botón, hasta 20 caracteres (ej. "Reservar").
      url: string;       // http:// o https://, hasta 2000 caracteres.
    };
  };
  reference?: string;    // opcional. Tu clave: 1..100 caracteres de [A-Za-z0-9_ . : @ / # -]. Idempotencia + correlación en webhooks.
};
```

Validaciones (`400 VALIDATION_ERROR`):

- `to` obligatorio y con forma válida ("El numero de destino tiene que ir con codigo de pais y solo digitos, por ejemplo 59899123456").
- Exactamente uno de `template`, `text` o `interactive`.
- `template.buttonPayloads`: más payloads que botones de respuesta rápida, o alguno vacío o de más de 256 caracteres. (`buttonPayloads` solo va dentro de `template`.)
- `interactive`: sin `body`, sin `buttons` / `list` / `link` o con más de uno, más de 3 botones o de 10 opciones, `title` de botón de más de 20 caracteres (24 en una opción de lista), `id` o `title` repetidos, `link.url` que no sea http(s), textos más largos que los límites.
- `reference` con caracteres o largo inválidos.
- `text` de más de 4096 caracteres.
- Plantilla en varios idiomas y sin `template.language` (el error lista los idiomas).
- Falta alguna variable de la plantilla (el error dice cuáles faltan y cuáles usa la plantilla).

### 7.3 Response

`200 OK`:

```ts
type SendResponse = {
  messageId: string;             // UUID del mensaje (para GET /api/messages/{id} y para cruzar con message_id de los webhooks)
  status: string;                // estado al responder; normalmente "QUEUED". En un duplicado, el estado actual del mensaje original.
  type: "template" | "text" | "interactive";
  conversationId: string | null; // UUID de la conversación (contacto + número de origen)
  reference: string | null;      // la reference que mandaste (recortada), o null
  duplicate: boolean;            // true = ya existía un envío con esa reference: NO se mandó otro, se devuelve el original
};
```

```json
{
  "success": true,
  "data": {
    "messageId": "0199f3a1-6c1e-7b2a-9d1e-2f6a1c0b9e11",
    "status": "QUEUED",
    "type": "template",
    "conversationId": "0199e0aa-2222-7000-8000-000000000002",
    "reference": "turno-48213-recordatorio",
    "duplicate": false
  }
}
```

Errores:

| HTTP | `error.code` | Cuándo |
|---|---|---|
| 400 | `VALIDATION_ERROR` | Ver 7.2. |
| 401 | — / `UNAUTHORIZED` | Sin API key válida ni sesión. |
| 402 | `ACCOUNT_SUSPENDED` | Cuenta suspendida: no envía. |
| 404 | `NOT_FOUND` | No existe la plantilla con ese nombre (o en ese idioma), o está borrada. |
| 409 | `TEMPLATE_NOT_APPROVED` | La plantilla no tiene una versión aprobada por Meta. |
| 409 | `CONTACT_OPTED_OUT` | Plantilla no-`AUTHENTICATION` a un contacto dado de baja. |
| 409 | `CONVERSATION_WINDOW_CLOSED` | `text` o `interactive` a un contacto que no escribió en las últimas 24 h. |
| 409 | `WABA_NOT_CONNECTED` | El número de origen (`from` o el principal) no existe o no está conectado. |
| 429 | `RATE_LIMITED` | Más de 300 envíos por minuto por empresa (`Retry-After`). |

### 7.4 Idempotencia y `reference`

- Si mandás `reference`, BUZWIK la usa como clave de idempotencia: **una sola vez por empresa, para siempre**. Repetir un `POST /api/messages` con la misma `reference` devuelve `200` con `duplicate: true` y los datos del mensaje **original**, sin enviar otro. Ojo: no compara el resto del cuerpo (si cambiaste `to`, plantilla o variables, igual devuelve el original).
- La verificación de duplicado ocurre **antes** de validar destino, plantilla, ventana o baja.
- Sin `reference` no hay idempotencia: reintentar ante un timeout puede mandar el mensaje dos veces. **Mandá siempre `reference`** si vas a reintentar.
- Las `reference` de envío directo no chocan con las claves de automatizaciones (`trg:…`).
- En los webhooks, la `reference` vuelve **exactamente como la mandaste** (`"turno-48213-recordatorio"`), en `message.status` → `data.reference` y en `message.received` → `data.context.reference` cuando el contacto responde a ese mensaje (ver 12.5).
- Diseño recomendado: `reference` = `<entidad>-<id>-<tipo de aviso>[-<versión>]`, por ejemplo `turno-48213-confirmacion`, `turno-48213-recordatorio-2026-10-15T10:30`.

### 7.5 Comportamiento

1. Normaliza `to`, elige el número de origen (`from` o el principal; debe estar conectado), busca o crea el contacto y la conversación.
2. Plantilla: busca por nombre (e idioma), toma la **última versión aprobada**, controla la baja (salvo `AUTHENTICATION`) y que estén todas las variables. Categoría de facturación = la de la plantilla. Prioridad normal, envío inmediato (no hay programación por fecha).
3. Texto o `interactive`: exige ventana de 24 h abierta. Categoría `SERVICE`.
4. Encola. Un despachador asíncrono lo manda a Meta (con reintentos internos ante fallas de Meta). Entre el encolado y el envío se vuelve a verificar que la plantilla siga aprobada.
5. A diferencia de las automatizaciones, el número de origen **no** se elige por categoría: es `from` o el principal.

### 7.6 GET /api/messages/{id} — estado de un mensaje

```
GET https://www.buzwik.com/api/messages/{messageId}
X-Api-Key: $BUZWIK_API_KEY
```

Sirve para cualquier mensaje de la empresa (envío directo, automatización, OTP). Sin límite de tasa propio; preferí el webhook `message.status` y usá este endpoint como respaldo o para reconciliar.

```ts
type TrackingResponse = {
  id: string;                  // = messageId
  status: "QUEUED" | "SENT" | "DELIVERED" | "READ" | "FAILED"; // en MAYÚSCULAS (en los webhooks va en minúsculas)
  waMessageId: string | null;  // wamid, cuando Meta lo aceptó
  error: string | null;        // si falló antes de/al enviar: texto JSON como "{\"error\":\"...\"}". Puede ser null aun con FAILED (fallo informado después por Meta: el detalle llega en message.status.errors).
  createdAt: string;           // ISO-8601
  sentAt: string | null;       // ISO-8601
  events: Array<{ type: "SENT" | "DELIVERED" | "READ" | "FAILED"; at: string }>; // historial de estados informados por Meta
};
```

`404 NOT_FOUND` si el id no existe en la empresa; `400 VALIDATION_ERROR` si el id no es un UUID.

### 7.7 Ejemplos

curl, plantilla:

```bash
curl -sS -X POST "https://www.buzwik.com/api/messages" \
  -H "X-Api-Key: $BUZWIK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "59899123456",
    "template": {
      "name": "recordatorio_turno",
      "language": "es",
      "variables": { "nombre": "Ana", "fecha": "15/10/2026", "hora": "10:30", "reserva": "48213" }
    },
    "reference": "turno-48213-recordatorio"
  }'
```

curl, texto libre (solo dentro de la ventana):

```bash
curl -sS -X POST "https://www.buzwik.com/api/messages" \
  -H "X-Api-Key: $BUZWIK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "to": "59899123456", "text": "¡Listo! Tu turno quedó confirmado.", "reference": "turno-48213-ack-1" }'
```

curl, estado:

```bash
curl -sS "https://www.buzwik.com/api/messages/0199f3a1-6c1e-7b2a-9d1e-2f6a1c0b9e11" -H "X-Api-Key: $BUZWIK_API_KEY"
```

Node.js (usa `postJson` de la sección 8.6):

```js
// buzwikMessages.js
import { postJson } from "./buzwikClient.js";

const BASE_URL = process.env.BUZWIK_BASE_URL ?? "https://www.buzwik.com";

/** Idempotente por reference: reintentar es seguro. */
export async function sendTemplate({ to, name, language, variables, buttonPayloads, reference }) {
  if (!reference) throw new Error("reference es obligatoria para poder reintentar sin duplicar");
  const stringVars = Object.fromEntries(Object.entries(variables ?? {}).map(([k, v]) => [k, String(v)]));
  return postJson("/api/messages", {
    to: String(to).replace(/\D/g, ""),
    template: { name, language, variables: stringVars, ...(buttonPayloads ? { buttonPayloads } : {}) },
    reference,
  });
}

export async function sendText({ to, text, reference }) {
  return postJson("/api/messages", { to: String(to).replace(/\D/g, ""), text, reference });
}

/** Mensaje con hasta 3 botones propios (solo dentro de la ventana de 24 h). buttons: [{ id, title }] */
export async function sendButtons({ to, body, buttons, footer, reference }) {
  return postJson("/api/messages", {
    to: String(to).replace(/\D/g, ""),
    interactive: { body, footer, buttons },
    reference,
  });
}

export async function getMessageStatus(messageId) {
  const res = await fetch(`${BASE_URL}/api/messages/${encodeURIComponent(messageId)}`, {
    headers: { "X-Api-Key": process.env.BUZWIK_API_KEY },
    signal: AbortSignal.timeout(10_000),
  });
  const payload = await res.json().catch(() => null);
  if (!res.ok || !payload?.success) throw new Error(`${res.status} ${payload?.error?.code ?? ""}`);
  return payload.data; // { id, status, waMessageId, error, createdAt, sentAt, events }
}
```

Manejo de errores típico:

```js
try {
  await sendTemplate({ to, name: "recordatorio_turno", language: "es", variables, reference });
} catch (err) {
  switch (err.code) {
    case "CONTACT_OPTED_OUT": return markBookingNotifiable(false);   // no insistir
    case "TEMPLATE_NOT_APPROVED":                                      // config: avisar a un humano
    case "WABA_NOT_CONNECTED":
    case "ACCOUNT_SUSPENDED": return alertOps(err);
    case "CONVERSATION_WINDOW_CLOSED": return fallbackToTemplate();    // solo con text
    default: throw err;                                                // 400 = bug; 5xx/429 ya se reintentaron
  }
}
```

---

<a id="events"></a>
## 8. POST /api/events — disparar automatizaciones

### 8.1 Qué hace

Tu sistema informa un **evento de negocio** (`eventType`) sobre una **entidad** (`entityRef`: una reserva, un pedido) y un **destinatario**. BUZWIK busca las **automatizaciones** (triggers) habilitadas de la empresa cuyo evento coincide y encola los mensajes que correspondan. El endpoint es genérico: el nombre del evento lo elegís vos y tiene que coincidir con el configurado en el panel.

### 8.2 Request

```
POST https://www.buzwik.com/api/events
X-Api-Key: $BUZWIK_API_KEY
Content-Type: application/json
```

```ts
type IngestRequest = {
  eventType: string;                 // obligatorio. 1..60 caracteres. Se recorta (trim). Coincidencia EXACTA (sensible a mayúsculas) con el evento de la automatización.
  entityRef: string;                 // obligatorio. Clave de negocio de la entidad. Se recorta. Máx. validado: 120; ver nota: usá <= 80.
  waPhone?: string;                  // teléfono del destinatario. Exactamente uno de waPhone / contactId.
  contactId?: string;                // UUID de un contacto de BUZWIK (ej. from.contact_id de un webhook). Exactamente uno de waPhone / contactId.
  variables?: Record<string, string>; // opcional. Máx. 30 entradas; cada valor máx. 500 caracteres. Mandá strings.
};
```

Reglas de validación (respuesta `400 VALIDATION_ERROR` si fallan):

- `eventType` y `entityRef` obligatorios y no vacíos, dentro de su largo máximo.
- Exactamente uno de `waPhone` o `contactId` ("Indica waPhone o contactId (uno de los dos, no ambos)").
- `waPhone`: se le quitan todos los caracteres que no son dígitos; tiene que quedar con al menos 8 dígitos ("Telefono invalido").
- `variables`: hasta 30 claves; valores de hasta 500 caracteres.
- `contactId` inexistente → `404 NOT_FOUND`.

> **Nota sobre el largo de `entityRef`**: se aceptan hasta 120 caracteres. La referencia que vuelve en los webhooks es `trg:<uuid-de-la-automatización>:<entityRef>`; si `entityRef` supera **87 caracteres**, en su lugar va `trg:<uuid>:sha256:<hash hex del entityRef>` (la idempotencia sigue funcionando igual, pero no podés leer el `entityRef` de vuelta). Usá identificadores cortos (ej. `reserva-48213` o `reserva-48213:recordatorio`).

### 8.3 Response

`200 OK`:

```ts
type IngestResponse = {
  accepted: true;     // el evento se procesó y quedó registrado
  enqueued: number;   // cuántos mensajes se encolaron con este evento (0 = no se envió nada)
  results: Array<{    // una entrada por automatización del evento; vacío si no hay ninguna habilitada
    automationId: string;
    automation: string;            // nombre de la automatización
    status: "ENQUEUED" | "SKIPPED";
    reason?: string;               // con SKIPPED: "ya enviado para esta entidad", "contacto opt-out", "sin numero conectado", "faltan variables de la plantilla: …", "plantilla '…' sin version aprobada"
    messageId?: string;            // con ENQUEUED: seguilo con GET /api/messages/{id} o el webhook message.status
  }>;
};
```

```json
{
  "success": true,
  "data": {
    "accepted": true,
    "enqueued": 1,
    "results": [
      { "automationId": "0199e0aa-…", "automation": "Recordatorio de turno", "status": "ENQUEUED",
        "reason": "plantilla recordatorio_turno", "messageId": "0199f3a1-…" }
    ]
  }
}
```

`accepted: true` **no** significa que se haya enviado un mensaje: mirá `enqueued` y `results`. Un reintento del mismo evento devuelve `SKIPPED` con `reason: "ya enviado para esta entidad"` (no es un error). El resultado de la entrega llega después por webhook `message.status`.

Errores posibles: `400 VALIDATION_ERROR`, `401`, `402 ACCOUNT_SUSPENDED` (cuenta suspendida: el evento **no** se registra), `404 NOT_FOUND` (`contactId`), `409 CONFLICT` (el mismo evento llegó dos veces al mismo tiempo: reintentá), `429 RATE_LIMITED`, `500 INTERNAL_ERROR`.

### 8.4 Qué pasa adentro (en orden)

Todo ocurre dentro de la request, en una sola transacción:

1. **Destinatario**: con `contactId`, se usa ese contacto. Con `waPhone`, se normaliza a dígitos y se busca el contacto; si no existe se crea.
2. **Caso**: si el evento pertenece a un flujo de rubro configurado, se actualiza el estado del "caso" de esa entidad y las `variables` quedan guardadas como atributos (sirven para segmentar en el panel).
3. **Triggers**: se buscan las automatizaciones **habilitadas** con `eventType` igual al recibido.
   - Si no hay ninguna: se registra el evento como `SKIPPED` ("sin triggers habilitados").
   - Si el contacto está dado de baja: todas se registran `SKIPPED` ("contacto opt-out").
4. Para **cada** automatización que coincide:
   1. **Número de origen**: se elige el número de WhatsApp de la empresa según la **categoría** de la automatización (un número puede estar asignado a utility, otro a marketing; si no, el número por defecto). Si no hay ninguno: `SKIPPED` ("sin numero conectado").
   2. **Idempotencia**: se arma la clave `trg:<id-de-la-automatización>:<entityRef>`. Si ya existe un mensaje con esa clave: `SKIPPED` ("ya enviado para esta entidad"). **Se envía como máximo un mensaje por automatización + `entityRef`, para siempre** (no hay ventana de expiración).
   3. **Programación**: si la automatización tiene demora (`scheduleDelaySeconds`), el envío se programa para `ahora + demora`; si no, sale inmediatamente.
   4. **Contenido**: ver 8.5.
   5. Se encola el mensaje (`ENQUEUED`) con la prioridad de la automatización (`HIGH`, `NORMAL`, `LOW`).
5. Un despachador asíncrono manda los mensajes encolados a Meta. Si Meta falla, se reintenta hasta 5 veces con espera exponencial (desde ~30 s hasta un tope de 30 min).

Consecuencias para el integrador:

- **Reintentar es seguro**: mandar dos veces el mismo `eventType` + `entityRef` no duplica el mensaje. Ante timeout o `5xx`, reintentá con el mismo cuerpo.
- **Un mismo `entityRef` no vuelve a disparar la misma automatización**. Si una reserva se reprograma y querés volver a avisar, usá un `entityRef` distinto (ej. `reserva-48213:v2` o `reserva-48213:2026-10-15T10:00`).
- Si dos requests idénticas llegan exactamente al mismo tiempo, una puede responder `409 CONFLICT`; reintentarla devuelve `200` sin duplicar.
- Una misma `eventType` puede disparar **varias** automatizaciones (cada una con su propia clave de idempotencia).

### 8.5 Cómo se elige el contenido (plantilla o texto)

La automatización (configurada en el panel: **Automatizaciones**) tiene: nombre, evento (`eventType`), prioridad, demora opcional y **qué se envía**: una **plantilla aprobada** (nombre + idioma; la categoría es la de la plantilla) o un **texto** con variables `{{clave}}` (con su categoría).

El motor resuelve así:

1. Si la automatización tiene una **plantilla** y esa plantilla tiene una versión **aprobada** en el idioma elegido, envía la plantilla. Las `variables` del evento llenan las variables del cuerpo y de los botones de link de la plantilla, por nombre. Si falta alguna, no envía y registra `SKIPPED` ("faltan variables de la plantilla: …").
2. Si tiene plantilla pero **sin versión aprobada**: no envía nada y registra `SKIPPED` ("plantilla '…' sin version aprobada"). No degrada a texto.
3. Si no tiene plantilla: envía el **cuerpo de texto** de la automatización, reemplazando cada `{{clave}}` (letras, dígitos y `_`) por `variables[clave]`; una clave sin valor queda vacía.

> **Importante**: el texto libre Meta solo lo entrega **dentro de la ventana de 24 horas** (ver 6.2); fuera de la ventana termina en `message.status` `failed`. Para avisos a contactos que no escribieron recién, la automatización tiene que usar una **plantilla aprobada**. Pedile al humano el `eventType` de cada automatización y la lista exacta de variables de su plantilla.

### 8.6 Ejemplos

curl:

```bash
curl -sS -X POST "https://www.buzwik.com/api/events" \
  -H "X-Api-Key: $BUZWIK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "eventType": "reserva_confirmada",
    "waPhone": "59899123456",
    "entityRef": "reserva-48213",
    "variables": {
      "nombre": "Ana",
      "fecha": "15/10/2026",
      "hora": "10:30",
      "reserva": "48213"
    }
  }'
# → {"success":true,"data":{"accepted":true}}
```

Node.js (18+, `fetch` nativo), con reintentos seguros:

```js
// buzwikClient.js
const BASE_URL = process.env.BUZWIK_BASE_URL ?? "https://www.buzwik.com";
const API_KEY = process.env.BUZWIK_API_KEY;
if (!API_KEY) throw new Error("BUZWIK_API_KEY no está configurada");

class BuzwikError extends Error {
  constructor(status, code, message) {
    super(`${status} ${code ?? ""} ${message ?? ""}`.trim());
    this.status = status;
    this.code = code;
  }
}

async function postJson(path, body, { retries = 3, timeoutMs = 10_000 } = {}) {
  for (let attempt = 0; ; attempt++) {
    let res;
    try {
      res = await fetch(`${BASE_URL}${path}`, {
        method: "POST",
        headers: { "X-Api-Key": API_KEY, "Content-Type": "application/json" },
        body: JSON.stringify(body),
        signal: AbortSignal.timeout(timeoutMs),
      });
    } catch (networkError) {
      if (attempt >= retries) throw networkError;
      await sleep(backoffMs(attempt));
      continue;
    }

    const payload = await res.json().catch(() => null); // por si un proxy intermedio responde sin el envelope
    if (res.ok && payload?.success) return payload.data;

    const retryable = res.status === 429 || res.status >= 500 || payload?.error?.code === "CONFLICT";
    if (!retryable || attempt >= retries) {
      throw new BuzwikError(res.status, payload?.error?.code, payload?.error?.message);
    }
    const retryAfter = Number(res.headers.get("Retry-After"));
    await sleep(Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : backoffMs(attempt));
  }
}

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const backoffMs = (attempt) => Math.min(30_000, 500 * 2 ** attempt) + Math.floor(Math.random() * 250);

/** Idempotente por (automatización, entityRef): se puede reintentar sin duplicar. */
export async function sendBuzwikEvent({ eventType, waPhone, contactId, entityRef, variables = {} }) {
  const stringVars = Object.fromEntries(
    Object.entries(variables).map(([k, v]) => [k, v == null ? "" : String(v)]),
  );
  return postJson("/api/events", { eventType, waPhone, contactId, entityRef, variables: stringVars });
}

export { postJson, BuzwikError };
```

```js
await sendBuzwikEvent({
  eventType: "reserva_confirmada",
  waPhone: "59899123456",
  entityRef: "reserva-48213",
  variables: { nombre: "Ana", fecha: "15/10/2026", hora: "10:30", reserva: "48213" },
});
```

---

<a id="otp"></a>
## 9. POST /api/otp/send y POST /api/otp/verify — códigos de verificación

Requieren `X-Api-Key` (obligatoria). Comparten el límite de 60 requests/minuto por empresa.

### 9.1 Dos modos

| Modo | Cómo se elige | Quién genera y valida |
|---|---|---|
| **Gestionado** | No mandás `code` | BUZWIK genera el código (6 dígitos por defecto), lo guarda hasheado con vencimiento (300 s por defecto) y máximo de intentos (5 por defecto). Validás con `/api/otp/verify`. |
| **Entrega** | Mandás `code` | Tu sistema generó el código; BUZWIK solo lo entrega por WhatsApp. No se guarda; `/api/otp/verify` **no** aplica. |

Los valores por defecto (largo, vencimiento, intentos) son de plataforma; no se configuran por empresa ni por request.

### 9.2 POST /api/otp/send

Request:

```ts
type OtpSendRequest = {
  to: string;       // obligatorio. Teléfono destino con código de país (ej. "59899123456"; se aceptan "+", espacios, guiones y paréntesis).
  purpose?: string; // opcional. Default "verification". Separa códigos de distintos usos (ej. "login", "reset_password").
  code?: string;    // opcional. Si viene (no vacío) → modo entrega: se manda ese código tal cual.
  from?: string;    // opcional. Número de origen: id del número en Meta (phone_number_id) o el número mostrado. Sin esto, sale por el número asignado a códigos (AUTHENTICATION) o el número por defecto.
};
```

Response `200`:

```ts
type OtpSendResponse = {
  messageId: string;        // UUID del mensaje en BUZWIK
  expiresAt: string | null; // ISO-8601 en modo gestionado; null en modo entrega
  managed: boolean;         // true = gestionado (se puede /verify); false = entrega
};
```

```json
{
  "success": true,
  "data": { "messageId": "0199f3a1-6c1e-7b2a-9d1e-2f6a1c0b9e11", "expiresAt": "2026-10-12T21:45:05.120Z", "managed": true }
}
```

Errores: `400 VALIDATION_ERROR` (falta `to` o no es un teléfono válido), `401` (API key ausente o inválida), `402 ACCOUNT_SUSPENDED`, `409 WABA_NOT_CONNECTED` (sin número conectado, o `from` no corresponde a un número de la empresa), `429 RATE_LIMITED`.

**No es idempotente**: cada llamada envía un mensaje nuevo (y, en modo gestionado, un código nuevo). No reintentes automáticamente ante timeout sin control del usuario; preferí un botón "reenviar código" con espera.

Al enviar un código gestionado nuevo, los anteriores del mismo `to` + `purpose` siguen existiendo, pero `/verify` solo compara contra el **más reciente** que no fue usado ni anulado.

### 9.3 POST /api/otp/verify

Request:

```ts
type OtpVerifyRequest = {
  to: string;       // obligatorio. EXACTAMENTE el mismo string que usaste en /send.
  purpose?: string; // opcional. Default "verification". El mismo que en /send.
  code: string;     // obligatorio. Lo que ingresó el usuario.
};
```

Response `200`:

```json
{ "success": true, "data": { "verified": true } }
```

Comportamiento:

- Busca el código emitido más reciente para (`to`, `purpose`) en estado emitido.
- No hay código → `verified: false`.
- Vencido → `verified: false` (y queda vencido).
- Correcto → `verified: true` y el código queda usado: una segunda verificación con el mismo código da `false`.
- Incorrecto → `verified: false` y suma un intento; al llegar al máximo (5 por defecto) el código queda anulado y hay que pedir otro.
- Un código incorrecto **no** es un error HTTP: siempre `200` con `verified: false`. Errores HTTP solo por validación (`400`, falta `code` o `to`), autenticación (`401`) o tasa (`429`).

### 9.4 Ejemplos

curl, modo gestionado:

```bash
# 1) Enviar (BUZWIK genera el código)
curl -sS -X POST "https://www.buzwik.com/api/otp/send" \
  -H "X-Api-Key: $BUZWIK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "to": "59899123456", "purpose": "login" }'

# 2) Verificar lo que escribió el usuario
curl -sS -X POST "https://www.buzwik.com/api/otp/verify" \
  -H "X-Api-Key: $BUZWIK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "to": "59899123456", "purpose": "login", "code": "482913" }'
```

curl, modo entrega:

```bash
curl -sS -X POST "https://www.buzwik.com/api/otp/send" \
  -H "X-Api-Key: $BUZWIK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "to": "59899123456", "code": "482913" }'
# → {"success":true,"data":{"messageId":"...","expiresAt":null,"managed":false}}
```

Node.js (usa `postJson` de la sección 8.6, con `retries: 0` para `/send`):

```js
import { postJson } from "./buzwikClient.js";

const normalizePhone = (raw) => String(raw).replace(/\D/g, ""); // mismo formato en send y verify

export async function sendLoginCode(phone) {
  return postJson("/api/otp/send", { to: normalizePhone(phone), purpose: "login" }, { retries: 0 });
}

export async function verifyLoginCode(phone, code) {
  const { verified } = await postJson("/api/otp/verify", {
    to: normalizePhone(phone),
    purpose: "login",
    code: String(code).trim(),
  });
  return verified === true;
}
```

### 9.5 Plantilla de los códigos

- En producción Meta exige una plantilla de categoría `AUTHENTICATION` para mandar códigos.
- BUZWIK busca una plantilla aprobada con nombre **`codigo_verificacion`** e idioma **`es`** (nombre e idioma son configuración de la plataforma, iguales para todas las empresas). Está disponible como plantilla **predefinida** en el panel (**Plantillas**): creala y enviala a aprobación. También sirve una importada de Meta con ese nombre.
- El código va en la **primera variable** de la plantilla, se llame como se llame (`{{code}}` en la predefinida, `{{var1}}` si la importaste).
- Si no existe o no está aprobada, el código sale como **texto libre** ("Tu codigo de verificacion es …"), que Meta solo entrega dentro de la ventana de 24 h. Sirve para pruebas, no para producción.
- El código va en el cuerpo y en el botón "copiar código" de la plantilla.

---

<a id="chatbot"></a>
## 10. POST /api/chatbot/ask — chatbot para tu web

Respuesta **sincrónica** del mismo motor de chatbot que atiende WhatsApp, para el chat de tu sitio. Se llama desde **tu backend** (la API key nunca va al navegador).

Requisitos:

- Módulos `CHATBOT` y `CHATBOT_MULTICANAL` activos en el plan. Si no: `403 FEATURE_NOT_IN_PLAN`.
- Canal **WEB** configurado en el panel (**Chatbot**): modo `Apagado`, `Reglas` o `IA`. Con el canal apagado, nunca responde (`answered: false`).

Diferencias con WhatsApp: no hay ventana de 24 h, ni bajas, ni agentes humanos, ni sesión del lado de BUZWIK. Tu sistema mantiene la conversación y manda el historial en cada request.

### 10.1 Request

```ts
type AskRequest = {
  message: string;   // obligatorio, no vacío. Lo que escribió el usuario.
  context?: string;  // opcional. Contexto extra para esta conversación; se suma al contexto del canal configurado en el panel. Se usa en modo IA.
  entryPoint?: string; // opcional. Código de un punto de entrada de la empresa (ej. "LAMANO"): el bot suma lo que sabe de ese lugar. Un código que no es de un punto activo se ignora.
  history?: Array<{  // opcional. Turnos previos, del más viejo al más nuevo. Se usa en modo IA (memoria).
    role: "user" | "assistant"; // sin importar mayúsculas. Cualquier otro rol ("tool", "system"...) se descarta.
    text: string;               // turnos con text vacío, o que digan ser un resultado de una acción, se descartan.
  }>;
  sessionId?: string;        // opcional. Un id propio del visitante en tu sitio: los topes de acciones del bot van por visitante (si no lo mandás, por IP).
  confirmationId?: string;   // si la respuesta anterior lo trajo: mandalo con la respuesta de la persona ("sí" o "no").
  confirmationToken?: string; // el secreto que vino con ese confirmationId: sin él no se confirma.
};
```

Límites: `message` hasta 2000 caracteres, `context` hasta 4000, `history` hasta 40 turnos de hasta 2000 caracteres cada uno (si no: `400 VALIDATION_ERROR`). Mantené el historial acotado (por ejemplo los últimos 10 turnos): en modo IA todo se envía al modelo y el consumo de IA se factura.

### 10.2 Response

```ts
type AskResponse = {
  reply?: string;        // respuesta del bot. Se OMITE si no hubo respuesta (canal apagado o ninguna regla coincidió).
  answered: boolean;     // true si hay reply.
  handoff: boolean;      // true si el bot decidió derivar a una persona.
  whatsappUrl?: string;  // solo con handoff=true y número conectado: link https://wa.me/<dígitos>?text=... al número de la empresa que atiende conversaciones, con el pedido de hablar con una persona y la consulta ya escritos.
  confirmationId?: string;    // el bot quiere hacer una acción que cambia algo (Premium, Conexiones): reply muestra los datos y pide "sí" o "no".
  confirmationToken?: string; // secreto de esa confirmación. Guardalo SOLO para ese visitante y devolvelo con su respuesta (vence a los 10 minutos).
};
```

Cómo responde:

- Modo **IA** (requiere además el módulo de IA y cupo disponible): responde el modelo. Si el modelo pide derivar, `handoff: true`. Si se agotó el cupo de IA del período, cae a modo reglas sin avisar.
- Modo **Reglas**: busca la regla habilitada que coincida con `message`; `handoff` es el de la regla. Sin coincidencia: `answered: false`.
- `handoff: true` significa "seguir con una persona por WhatsApp": mostrá un botón con `whatsappUrl`. La conversación de WhatsApp entra a la bandeja del panel.
- **Acciones del bot** (Premium, con conexiones marcadas para el bot): en modo IA el bot puede consultar el sistema de la empresa (hasta 2 acciones por pregunta). Lo que cambia algo pide confirmación: llega `confirmationId` + `confirmationToken`; el siguiente request con `message: "sí"` (o `"no"`) y esos dos campos la resuelve. Un texto que no sea un sí o un no claros sigue como una pregunta común. Además del límite por empresa, `/api/chatbot/ask` tiene un límite por IP (`429`).

Errores: `400 VALIDATION_ERROR` (`message` vacío), `401`, `403 FEATURE_NOT_IN_PLAN`, `503 AI_UNAVAILABLE` (el proveedor de IA falló; mostrá un mensaje genérico y ofrecé WhatsApp).

### 10.3 Ejemplos

curl:

```bash
curl -sS -X POST "https://www.buzwik.com/api/chatbot/ask" \
  -H "X-Api-Key: $BUZWIK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "message": "¿Tienen turnos el sábado?",
    "context": "El usuario está mirando la página de la sucursal Centro.",
    "history": [
      { "role": "user", "text": "hola" },
      { "role": "assistant", "text": "¡Hola! ¿En qué te ayudo?" }
    ]
  }'
```

Respuesta con derivación:

```json
{
  "success": true,
  "data": {
    "reply": "Para eso te conviene hablar con una persona del equipo.",
    "answered": true,
    "handoff": true,
    "whatsappUrl": "https://wa.me/59898123456?text=Hola%2C%20vengo%20del%20chat%20de%20la%20web..."
  }
}
```

Node.js (endpoint de tu backend que hace de proxy para el widget del navegador):

```js
import { postJson, BuzwikError } from "./buzwikClient.js";

// POST /chat  { message, history } desde tu frontend
export async function chatHandler(req, res) {
  const { message, history = [] } = req.body ?? {};
  if (typeof message !== "string" || message.trim() === "") {
    return res.status(400).json({ error: "message es obligatorio" });
  }
  try {
    const data = await postJson(
      "/api/chatbot/ask",
      { message, history: history.slice(-10) },
      { retries: 1 },
    );
    return res.json({
      reply: data.answered ? data.reply : null,
      handoff: data.handoff,
      whatsappUrl: data.whatsappUrl ?? null,
    });
  } catch (err) {
    const status = err instanceof BuzwikError ? err.status : 502;
    return res.status(status === 403 ? 503 : 502).json({ error: "El chat no está disponible ahora." });
  }
}
```

---

<a id="ping"></a>
## 11. GET /api/ping, GET /api/account, /api/contacts y /api/v1/requests — credenciales, plantillas, números, contactos y solicitudes

Ping y cuenta aceptan cualquier API key válida y son de solo lectura. Contactos ([11.1](#ping)) y solicitudes ([11.2](#requests-api)) piden su permiso.

`GET /api/ping`: health check de la integración (verifica URL base y key) y dice de qué empresa es la key.

```bash
curl -sS "https://www.buzwik.com/api/ping" -H "X-Api-Key: $BUZWIK_API_KEY"
# 200 → {"success":true,"data":{"service":"buzwik","status":"up","account":{"id":"0199…","name":"Clínica Sur"}}}
# 401 → key inválida o revocada
```

`account.id` es el mismo `account_id` que llega en cada webhook (12.2): guardalo junto a la key para rutear webhooks si integrás varias empresas.

`GET /api/account` → `{ id, name }` de la empresa.

`GET /api/account/templates` → todas las plantillas (no borradas) con su estado. Sirve para verificar antes de enviar que una plantilla existe, está aprobada y qué variables pide:

```ts
type AccountTemplate = {
  name: string;              // el que va en template.name
  language: string;
  category: "UTILITY" | "MARKETING" | "AUTHENTICATION";
  status: string;            // estado de la última versión: "DRAFT" | "PENDING" | "APPROVED" | "REJECTED" | "PAUSED" | "DISABLED"…
  sendable: boolean;         // true = hay una versión aprobada: se puede enviar con POST /api/messages
  body?: string;             // texto de la versión aprobada (o de la última si no hay aprobada), con {{variables}}
  variables: string[];       // las que hay que mandar en template.variables (cuerpo y botones de link)
  rejectionReason?: string;  // motivo de Meta si la última versión fue rechazada
};
```

Una plantilla puede tener `sendable: true` y `status: "PENDING"`: hay una versión nueva en revisión, pero se sigue enviando la última aprobada.

`GET /api/account/numbers` → los números de WhatsApp de la empresa:

```ts
type AccountNumber = {
  phoneNumberId: string;       // id de Meta: es el `from` de POST /api/messages
  displayPhoneNumber: string;  // ej. "+598 98 892 474"
  verifiedName?: string;
  status: string;              // "CONNECTED" | …
  default: boolean;            // por este sale todo si no mandás `from`
  capabilities: string[];      // categorías para las que se usa al rutear automatizaciones
  qualityRating?: string;      // "GREEN" | "YELLOW" | "RED" según Meta
};
```

### 11.1 Contactos — `/api/contacts`

Para sincronizar los clientes de tu sistema con los contactos de BUZWIK (nombre, email, etiquetas, campos extra y consentimiento). Aceptan API key. Límite: 300 requests por minuto por empresa (`429 RATE_LIMITED`).

| Método y ruta | Qué hace |
|---|---|
| `GET /api/contacts?q=&labelId=&optIn=&page=0&size=50` | Lista paginada (los más nuevos primero). `q` busca en nombre, teléfono y email; `optIn` = `OPTED_IN` \| `OPTED_OUT` \| `UNKNOWN`; `size` hasta 100. El total viene en `meta.total`. |
| `GET /api/contacts/{id}` | Un contacto. |
| `POST /api/contacts?upsert=true` | **Crear o actualizar por teléfono** (lo recomendado para sincronizar). Sin `upsert`, si el teléfono ya existe responde `409 CONFLICT`. |
| `PUT /api/contacts/{id}` | Editar (lo que no mandás no se toca). |
| `PUT /api/contacts/{id}/opt-in` | `{ "optedIn": true \| false }`: alta o baja de mensajes. |

```ts
type ContactRequest = {
  phone?: string;                      // obligatorio al crear: con código de país ("+598 98 123 456" o "59898123456"). No se puede cambiar después.
  name?: string;                       // hasta 200. "" lo borra.
  email?: string;                      // "" lo borra.
  notes?: string;                      // notas internas, hasta 2000.
  attributes?: Record<string, string>; // campos extra (hasta 30; clave hasta 40, valor hasta 500). Reemplaza todos los que tenía.
  labels?: string[];                   // hasta 30 nombres; las que no existen se crean. Reemplaza las que tenía; [] las saca.
  optIn?: boolean;                     // true = aceptó recibir mensajes, false = se dio de baja.
};

type Contact = {
  id: string;
  phone: string;                // solo dígitos, con código de país (ej. "59898123456")
  name?: string;                // el que le puso la empresa, si no el de su perfil de WhatsApp
  profileName?: string;         // el de su perfil de WhatsApp
  email?: string;
  notes?: string;
  optInStatus: "UNKNOWN" | "OPTED_IN" | "OPTED_OUT";
  optInAt?: string;             // desde cuándo aceptó (ISO); solo con OPTED_IN
  labels: { id: string; name: string; color: string }[];
  attributes: Record<string, string>;
  conversationId?: string;      // su conversación más reciente
  lastMessageAt?: string;
  conversations: { conversationId: string; numberId: string; lastMessageAt?: string }[];
  createdAt: string;
  created?: boolean;            // solo en POST: true si se creó, false si ya existía
};
```

```bash
curl -sS -X POST "https://www.buzwik.com/api/contacts?upsert=true" \
  -H "X-Api-Key: $BUZWIK_API_KEY" -H "Content-Type: application/json" \
  -d '{"phone":"+598 98 123 456","name":"Ana Pérez","email":"ana@mail.com","labels":["Clientes"],"attributes":{"cliente_id":"48213"}}'
```

Notas:

- **Una integración no deshace una baja.** Si el contacto se dio de baja (escribió BAJA o STOP, o lo dieron de baja), `optIn: true` en `POST`/`PUT` no lo cambia: sigue `OPTED_OUT` (fijate `optInStatus` en la respuesta), y `PUT /api/contacts/{id}/opt-in` con `true` responde `409 CONFLICT`. Volver a darlo de alta se hace desde el panel, cuando la persona lo pide. `optIn: false` siempre da de baja.
- `PUT /api/contacts/{id}` reemplaza `attributes` y `labels` enteros si los mandás: para cambiar uno, mandá todos.
- `notes` es para el equipo: no se le muestra al contacto, pero sí la devuelve esta API.
- Importar y exportar CSV y borrar contactos son solo del panel (con API key responden `403`).

<a id="requests-api"></a>
### 11.2 Solicitudes por API — `/api/v1/requests`

Las **solicitudes** son lo que un cliente pide y tiene que hacer una persona: un reclamo municipal (un bache, una luminaria), un pedido al bar, un late checkout. Normalmente las toma el chatbot por WhatsApp; con esta API tu sistema (de reclamos, PMS, CRM) también puede crearlas, seguirlas y cambiarles el estado. Una creada por API es igual a una del bot: recibe el **número correlativo** de la empresa, va al **área** que le toca por sus temas, dispara el webhook `request.created`, el aviso por WhatsApp al equipo del área y aparece en el panel y en la estación del área.

- Solo con API key (el panel usa `/api/requests`, que no es un contrato estable). Permisos: `requests:read` para leer, `requests:write` para crear y cambiar el estado (ver 3.3).
- Pide el mismo plan que la sección Solicitudes del panel (módulo Chatbot): sin él, `403 FEATURE_NOT_IN_PLAN`.
- Límite: 300 requests por minuto por empresa (`429 RATE_LIMITED`).

| Método y ruta | Qué hace |
|---|---|
| `POST /api/v1/requests` | Crea una solicitud. `201` si la creó; `200` si ya existía con ese `externalRef` y los mismos datos (reintento); `409 CONFLICT` si el `externalRef` ya es de otra con datos distintos. |
| `GET /api/v1/requests?status=&area=&updatedSince=&externalRef=&cursor=&limit=50` | Lista, de la más nueva a la más vieja (por `number`). `status` separados por coma (`NEW,IN_PROGRESS`); `updatedSince` ISO-8601 (las que cambiaron desde ese momento: para sincronizar); `limit` hasta 100. Para la página siguiente mandá `cursor` = el `nextCursor` de la anterior (`null` = no hay más). |
| `GET /api/v1/requests/{id}` | Una solicitud. |
| `PUT /api/v1/requests/{id}/status` | Cambia el estado (`NEW`, `IN_PROGRESS`, `DONE`, `CANCELLED`) con una nota opcional; con `notifyClient: true` le avisa al cliente por WhatsApp al marcarla lista o cancelarla. |
| `GET /api/v1/requests/{id}/photos/{photoId}` | El archivo de una foto (los ids vienen en `photoIds`). Solo las adjuntas a esa solicitud. |
| `GET /api/v1/requests/areas` | Las áreas a las que puede ir (el `area` del alta), con sus temas y cuál es la por defecto. |

```ts
type CreateRequest = {
  kind: string;          // obligatorio, hasta 60: qué es ("Bache", "Luminaria")
  detail: string;        // obligatorio, hasta 1000: todo lo que el equipo necesita para hacerlo
  area?: string;         // una de GET /api/v1/requests/areas (sin distinguir mayúsculas); sin area, va a la que le toca por sus temas
  address?: string;      // hasta 200: la dirección; sin latitude/longitude y con el plan Premium se ubica sola en el mapa
  latitude?: number;     // con longitude: dónde es (se toma como exacta, source "API")
  longitude?: number;
  contact: {
    phone: string;       // obligatorio, con código de país ("+598 98 123 456"); si no existe el contacto, se crea
    name?: string;       // se le pone solo si el contacto todavía no tiene un nombre cargado
  };
  externalRef?: string;  // tu id (hasta 100: letras, números y . _ : / # -); único por empresa
};

type StatusRequest = {
  status: "NEW" | "IN_PROGRESS" | "DONE" | "CANCELLED";
  note?: string;          // por qué se canceló o una nota al resolverla (hasta 200; va al cliente tal cual si se le avisa)
  notifyClient?: boolean; // solo si es true se le escribe al cliente
};

type ServiceRequest = {
  id: string;
  number: number;               // correlativo de la empresa ("N.º 128"), único y fijo
  externalRef: string | null;
  status: "NEW" | "IN_PROGRESS" | "DONE" | "CANCELLED";
  area: string | null;
  kind: string;
  detail: string;
  address: string | null;
  location: { latitude: number; longitude: number; label: string | null; source: "PIN" | "GEOCODED" | "MANUAL" | "API" } | null;
  photoIds: string[];
  note: string | null;          // la del último cambio de estado
  createdAt: string;
  updatedAt: string;
  contact: { phone: string; name: string | null } | null;
};

type Page = { items: ServiceRequest[]; nextCursor: string | null };
type StatusResult = { request: ServiceRequest; clientNotice?: "SENT" | "SENT_TEMPLATE" | "WINDOW_CLOSED" | "OPTED_OUT" | "NO_CONVERSATION" | "FAILED" };
type Area = { name: string; topics: string[]; isDefault: boolean };
```

```bash
curl -sS -X POST "https://www.buzwik.com/api/v1/requests" \
  -H "X-Api-Key: $BUZWIK_API_KEY" -H "Content-Type: application/json" \
  -d '{"kind":"Luminaria","detail":"Poste apagado frente al 1234","address":"18 de Julio 1234","contact":{"phone":"+598 98 123 456","name":"Ana Pérez"},"externalRef":"RECLAMO-48213"}'
# 201 → {"success":true,"data":{"id":"0199…","number":128,"externalRef":"RECLAMO-48213","status":"NEW","area":"Alumbrado",…}}

curl -sS -X PUT "https://www.buzwik.com/api/v1/requests/0199…/status" \
  -H "X-Api-Key: $BUZWIK_API_KEY" -H "Content-Type: application/json" \
  -d '{"status":"DONE","note":"Se cambió la lámpara","notifyClient":true}'

# Sincronizar: lo que cambió desde la última vez, página por página.
curl -sS "https://www.buzwik.com/api/v1/requests?updatedSince=2026-10-31T00:00:00Z&limit=100" -H "X-Api-Key: $BUZWIK_API_KEY"
```

Notas:

- **Idempotencia con `externalRef`**: mandá siempre tu id. Si un timeout te hace reintentar, el mismo cuerpo con la misma referencia devuelve la que ya se creó (`200`) en vez de duplicarla. Para comparar se usan los datos tal como llegaron (sin espacios de más y con el teléfono normalizado).
- `notifyClient` funciona igual que en el panel y solo para solicitudes que tienen una conversación de WhatsApp (las que tomó el bot): dentro de las 24 h se manda texto (`SENT`); fuera, la plantilla de avisos de solicitudes si está aprobada (`SENT_TEMPLATE`, si no `WINDOW_CLOSED`); si el cliente se dio de baja no se le escribe (`OPTED_OUT`). **Una solicitud creada por API no tiene conversación**: el aviso responde `NO_CONVERSATION` y no se manda nada (avisale vos, por ejemplo con `POST /api/messages`). Dos llamadas iguales a la vez avisan una sola vez. Al marcarla `DONE` con aviso `SENT`, si la empresa activó la encuesta, se le pregunta cómo fue la atención (`request.rated`).
- Cada cambio genera un `request.updated` con `previous_status` (ver 12.8): si tu sistema cambió el estado, vas a recibir el webhook de tu propio cambio.
- El cursor es el `number` de la última de la página: no saltea ni repite aunque se creen solicitudes mientras paginás (las nuevas tienen números mayores; para verlas, volvé a empezar sin `cursor`).

<a id="entry-points-api"></a>
### 11.3 Puntos de entrada por API — `/api/v1/entry-points`

Un **punto de entrada** es un lugar o una cosa desde donde alguien le escribe a la empresa: la habitación 101, la mesa 4, una plaza, un contenedor. Cada uno tiene un QR con un link propio (`https://www.buzwik.com/q/<código público>`) que abre WhatsApp con un primer mensaje que termina en `(#CODIGO)`. Cuando llega, el bot sabe desde dónde le escriben, suma el contexto del lugar, y las solicitudes salen con el área y la ubicación del punto (`entry_point` en el webhook, ver 12.8).

- Solo con API key y el permiso `entry-points` (ver 3.3). Plan Estándar o Premium (si no: `403 FEATURE_NOT_IN_PLAN`).
- Sirve para sincronizar desde tu sistema (las habitaciones del PMS, los contenedores del sistema de residuos). Borrar no está en la API (se hace en el panel): para dejar de usar un punto, mandá `active: false` y su QR deja de llevar a WhatsApp.
- Hasta 1.000 puntos por empresa. El panel también los carga de a muchos con un CSV.

| Método y ruta | Qué hace |
|---|---|
| `GET /api/v1/entry-points` | Todos los puntos, por nombre: `{"items": EntryPoint[], "max": 1000}` |
| `GET /api/v1/entry-points/{id}` | Un punto |
| `POST /api/v1/entry-points` | Crea uno: `201` con el punto (su `link` es el del QR) |
| `PUT /api/v1/entry-points/{id}` | Lo cambia. El `link` NO cambia aunque cambien el código o el número de WhatsApp: el QR impreso sigue sirviendo |
| `GET /api/v1/entry-points/{id}/report?days=30` | Escaneos, conversaciones y solicitudes del punto en los últimos `days` días (1 a 365, por defecto 30) |

```ts
// Lo que mandás en POST y PUT
type EntryPointInput = {
  name: string;          // obligatorio, hasta 80 ("Habitación 101", "La Mano")
  code?: string;         // 2 a 30 letras, números o guiones (se guarda en mayúsculas). Vacío: se propone a partir del nombre (al crear) o queda el que tenía (al cambiar). Único por empresa
  greeting?: string;     // el primer mensaje que se le propone a la persona, hasta 200, sin "#". Vacío: "Hola, estoy en <name>"
  context?: string;      // lo que el bot tiene que saber del lugar, hasta 2.000
  area?: string;         // área de las solicitudes que no encajan en ningún tema; tiene que ser una de las cargadas (GET /api/v1/requests/areas)
  latitude?: number;     // la ubicación del punto: las dos o ninguna
  longitude?: number;
  active?: boolean;      // por defecto true al crear; al cambiar, si no viene, queda como estaba
};

// Lo que devuelve
type EntryPoint = {
  id: string;
  code: string;          // "LAMANO": va al final del primer mensaje, "(#LAMANO)"
  publicCode: string;    // el del link público ("hotel-buzwik-lamano"); no cambia nunca
  name: string;
  context: string | null;
  area: string | null;
  latitude: number | null;
  longitude: number | null;
  greeting: string;
  active: boolean;
  link: string;          // el link del QR: https://www.buzwik.com/q/<publicCode>
  message: string;       // el primer mensaje completo: "Hola, estoy en La Mano (#LAMANO)"
  scans: number;         // aperturas del QR o del link, desde siempre
  conversations: number; // conversaciones que llegaron con el código, desde siempre
  requests: number;      // solicitudes que salieron del punto, desde siempre
  createdAt: string;
  updatedAt: string;
};

// GET /api/v1/entry-points/{id}/report
type EntryPointReport = {
  days: number;
  from: string;          // desde cuándo se cuenta (ISO-8601)
  totals: { scans: number; conversations: number; requests: number };
  items: Array<{ id: string; code: string; name: string; active: boolean; latitude: number | null; longitude: number | null;
                 scans: number; conversations: number; requests: number }>;
};
```

```bash
# Crear la habitación 101 con lo que el bot tiene que saber de ella
curl -sS -X POST "https://www.buzwik.com/api/v1/entry-points" \
  -H "X-Api-Key: $BUZWIK_API_KEY" -H "Content-Type: application/json" \
  -d '{"name":"Habitación 101","code":"H101","context":"Doble, piso 1, vista al mar","area":"Mantenimiento"}'
# 201 → {"success":true,"data":{"id":"0199…","code":"H101","link":"https://www.buzwik.com/q/hotel-h101","message":"Hola, estoy en Habitación 101 (#H101)",…}}

# Cuánta gente entró por ese QR en la última semana
curl -sS "https://www.buzwik.com/api/v1/entry-points/0199…/report?days=7" -H "X-Api-Key: $BUZWIK_API_KEY"
```

Notas:

- **Escaneos**: cada apertura del link cuenta (aunque después no escriban). **Conversaciones**: una por visita (escanear de nuevo el mismo QR en las 12 h siguientes no suma). **Solicitudes**: las creadas en el período que salieron del punto.
- No hay webhook por escaneo (serían demasiados): el punto viaja en `request.created`/`request.updated` (`entry_point`, ver 12.8). Para números, consultá el reporte.
- Los errores de validación (`400 VALIDATION_ERROR`) dicen qué corregir: código repetido, área que no existe, ubicación incompleta, saludo con `#`.

<a id="catalog-api"></a>
### 11.4 Catálogo por API — `/api/v1/catalog`

El **catálogo con ubicación** son las fichas de la empresa con su punto en el mapa: las propiedades de una inmobiliaria, las habitaciones de un hotel, los lugares de un municipio. Cada ítem tiene un **código** (el de tu sistema o tu CRM, ej. `AP-1234`: letras, números, `.`, `_` o `-`, hasta 40, único por empresa sin importar mayúsculas).

- Solo con API key y el permiso `catalog` (ver 3.3). Plan Estándar o Premium (si no: `403 FEATURE_NOT_IN_PLAN`).
- `PUT /api/v1/catalog/{code}` **crea o actualiza** (idempotente por código): `201` si lo creó, `200` si ya existía. Al actualizar, **lo que no mandás queda como estaba** (una sincronización parcial no borra datos); los `attributes` que mandás se suman a los que había.
- Borrar no está en la API (se hace en el panel): para sacar un ítem de la oferta mandá `status: "PAUSADA"` o `"CERRADA"`.
- Hasta 5.000 ítems por empresa. El panel también los carga de a muchos con un CSV.

| Método y ruta | Qué hace |
|---|---|
| `GET /api/v1/catalog?status=&operation=&type=&q=&page=0&size=50` | Lista paginada (más nuevos primero): `{"items": Item[], "max": 5000}` y `meta` `{page,size,total,elements}`. `size` de 1 a 200; `q` busca en código, título, barrio y dirección |
| `GET /api/v1/catalog/{code}` | Un ítem por su código |
| `PUT /api/v1/catalog/{code}` | Lo crea o lo actualiza (el código de la URL manda) |

Campos de un ítem (todos opcionales al actualizar; al crear, `title` es obligatorio):

| Campo | Tipo | Notas |
|---|---|---|
| `title` | string | Hasta 140 |
| `type` | string | Libre, hasta 40 (ej. `apartamento`, `casa`, `suite`) |
| `operation` | string | `VENTA`, `ALQUILER` u `OTRO` (por defecto) |
| `price`, `currency` | number, string | Precio ≥ 0 con hasta 2 decimales; con precio, `currency` es `UYU` o `USD` |
| `attributes` | objeto | Claves del diccionario del catálogo: `dormitorios`, `banos`, `piso`, `capacidad` (enteros), `m2_totales`, `m2_edificados`, `gastos_comunes` (decimales), `garaje`, `mascotas`, `amueblado`, `accesible` (sí/no), `orientacion`, `camas`, `vista`, `horario` (texto). Una clave desconocida da `400` |
| `description` | string | Hasta 4.000 |
| `photos` | string[] | Hasta 12 links **https** (no se suben archivos) |
| `address`, `zone` | string | Dirección (hasta 200) y barrio o zona (hasta 80) |
| `latitude`, `longitude` | number | Los dos o ninguno |
| `status` | string | `DISPONIBLE` (por defecto), `RESERVADA`, `CERRADA` (vendida o alquilada) o `PAUSADA` |
| `externalUrl` | string | Link **https** a la ficha en tu web o portal |
| `agentEmail` | string | El email de un usuario marcado como **agente** en el panel (Configuración → Equipo). Estándar: hasta 3 agentes; Premium: ilimitados |

```bash
curl -sS -X PUT "https://www.buzwik.com/api/v1/catalog/AP-1234" \
  -H "X-Api-Key: $BUZWIK_API_KEY" -H "Content-Type: application/json" \
  -d '{"title":"Apartamento 2 dormitorios en Pocitos","type":"apartamento","operation":"ALQUILER",
       "price":1200,"currency":"USD","zone":"Pocitos","latitude":-34.9145,"longitude":-56.15,
       "attributes":{"dormitorios":2,"banos":1,"gastos_comunes":6500,"mascotas":true},
       "photos":["https://tuweb.example/fotos/ap-1234-1.jpg"],"externalUrl":"https://tuweb.example/ap-1234",
       "agentEmail":"ana@tuinmobiliaria.example"}'
# 201 → {"success":true,"data":{"id":"0199…","code":"AP-1234","status":"DISPONIBLE","agentName":"Ana",…}}

# Se alquiló
curl -sS -X PUT "https://www.buzwik.com/api/v1/catalog/AP-1234" -H "X-Api-Key: $BUZWIK_API_KEY" \
  -H "Content-Type: application/json" -d '{"status":"CERRADA"}'
```

<a id="agenda-api"></a>
### 11.5 Agenda por API — `/api/v1/agenda`

Las citas de la agenda de BUZWIK (visitas, turnos): para que tu sistema vea los horarios libres, agende y
cambie estados. API key con el permiso **`agenda`**; planes Estándar y Premium (si no, 403
`FEATURE_NOT_IN_PLAN`). Las fechas van en ISO-8601 (UTC con `Z` o con offset); la agenda usa la zona horaria
de la empresa (`timezone` en los webhooks). El agente va por su email.

| Método y ruta | Qué hace |
| --- | --- |
| `GET /api/v1/agenda/slots?agentEmail=&itemCode=&day=AAAA-MM-DD` | Horarios libres (hasta 30, o 100 de un día): `[{agentId, agentName, start, end}]`. Respeta duración, margen, anticipación, horizonte y bloqueos. |
| `GET /api/v1/agenda/appointments?from=&to=&agentEmail=` | Citas que tocan el intervalo (hasta 92 días, 500 citas). |
| `GET /api/v1/agenda/appointments/{id}` | Una cita. |
| `POST /api/v1/agenda/appointments` | Crea una cita (201). Body: `start`, `agentEmail` (si falta, el que toque libre a esa hora), `guestName`, `guestPhone` (con código de país), `itemCode` o `itemId`, `notes`, `status` (`CONFIRMADA` por defecto: tiene que caer en un hueco libre; `PEDIDA` con `preferred`: la confirma el equipo y le llega el aviso), `notifyClient`. Si `guestPhone` ya escribió por WhatsApp, la cita queda con ese contacto (aviso y recordatorio). 409 si el horario ya no está libre. |
| `PUT /api/v1/agenda/appointments/{id}/status` | `status`: `CONFIRMADA` (una pedida o reprogramada; con `start` en otro horario), `REPROGRAMADA` (con `start`: la persona recibe el horario nuevo con dos botones), `CANCELADA` (con `reason`), `REALIZADA` (dispara la encuesta si está activa) o `AUSENTE`. `notifyClient: true` le avisa por WhatsApp si escribió en las últimas 24 h. |

La respuesta de crear y cambiar es `{"appointment": {...}, "clientNotice": "SENT" | "WINDOW_CLOSED" | "NO_CONVERSATION" | "OPTED_OUT" | "FAILED" | null}`.
Cada cita trae `id`, `status` (`PEDIDA`, `CONFIRMADA`, `REPROGRAMADA`, `CANCELADA`, `REALIZADA`, `AUSENTE`),
`startsAt`, `endsAt`, `agentId`, `agentName`, `contactName`, `contactPhone`, `guestName`, `guestPhone`,
`itemCode`, `itemTitle`, `origin` (`BOT`, `PANEL`, `API`), `preferred`, `notes`, `reminderStatus`, `rating`,
`cancelReason`, `clientConfirmedAt`, `createdAt` y `updatedAt`.

```bash
curl -sS -X POST "https://www.buzwik.com/api/v1/agenda/appointments" -H "X-Api-Key: $BUZWIK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"agentEmail":"ana@tuinmobiliaria.example","start":"2026-11-06T13:00:00Z","guestName":"Laura",
       "guestPhone":"+59899123456","itemCode":"PDE-101"}'
```

---

<a id="webhooks"></a>
## 12. Webhooks salientes (BUZWIK → tu sistema)

### 12.1 Configuración (panel)

En **Configuración → Webhooks**:

- URL de destino: `https://` obligatorio, dominio **público** (no IPs privadas, localhost ni redes internas), puerto 443 o mayor a 1023, hasta 500 caracteres. No se siguen redirecciones (un `3xx` cuenta como fallo).
- Descripción opcional.
- Eventos a recibir: `message.received`, `message.status`, `request.created`, `request.updated`, `request.rated`, `appointment.created`, `appointment.updated`, `conversation.handoff`, `conversation.urgent`. **Sin selección = todos** (incluye eventos que se agreguen en el futuro).
- Hasta 5 webhooks por empresa. Cada evento se envía a todos los webhooks suscriptos.
- Al crear el webhook se muestra el **secreto de firma** (prefijo `whsec_`) **una sola vez**. Se puede rotar ("Nuevo secreto"); el anterior deja de valer de inmediato.
- Botón de **prueba**: envía un `webhook.test` (llega aunque el webhook filtre eventos; máximo 10 pruebas por hora por webhook).
- Historial de entregas por webhook (código de respuesta, error, cuerpo) y **reenvío manual** de una entrega. El historial se conserva 30 días.
- Un webhook **desactivado** no recibe; las entregas pendientes que le tocan mientras está desactivado se marcan como fallidas sin reintento (se pueden reenviar a mano).

### 12.2 Request que recibe tu sistema

```
POST <tu URL>
Content-Type: application/json; charset=UTF-8
User-Agent: Buzwik-Webhooks/1.0
X-Buzwik-Event: message.received
X-Buzwik-Event-Id: 0199f3a2-0b7e-7c55-8f0e-1d2c3b4a5f60
X-Buzwik-Delivery: 0199f3a2-0b80-7a11-9c3d-6e5f4a3b2c1d
X-Buzwik-Webhook-Version: 2026-10-31
X-Buzwik-Signature: t=1792100405,v1=5d41402abc4b2a76b9719d911017c592ae7d1a5f0c3a8e0f1b2c3d4e5f6a7b8c
```

| Header | Contenido |
|---|---|
| `X-Buzwik-Event` | Tipo de evento (`message.received`, `message.status`, `request.created`, `request.updated`, `request.rated`, `appointment.created`, `appointment.updated`, `conversation.handoff`, `conversation.urgent`, `webhook.test`). Igual a `type` del cuerpo. |
| `X-Buzwik-Webhook-Version` | Versión del formato de los webhooks (hoy `2026-10-31`). Igual a `api_version` del cuerpo. Ver [sección 17](#versionado). |
| `X-Buzwik-Event-Id` | Id único del evento (UUID). Igual a `id` del cuerpo. **Se repite en cada reintento** y es el mismo para todos los webhooks que reciben ese evento. Usalo para descartar duplicados. |
| `X-Buzwik-Delivery` | Id de la entrega a **este** webhook (UUID). Es el mismo en todos los reintentos de esa entrega. Útil para soporte/logs. |
| `X-Buzwik-Signature` | `t=<unix segundos>,v1=<hex HMAC-SHA256>`. Ver 12.6. |

Cuerpo (envelope común a todos los eventos):

```ts
type WebhookEnvelope<T> = {
  id: string;          // UUID del evento (= X-Buzwik-Event-Id)
  type: "message.received" | "message.status" | "request.created" | "request.updated" | "request.rated" | "appointment.created" | "appointment.updated" | "conversation.handoff" | "conversation.urgent" | "webhook.test";
  api_version: string; // versión del formato (= X-Buzwik-Webhook-Version), hoy "2026-10-31"
  account_id: string;  // UUID de la empresa (= account.id de GET /api/ping con su API key)
  created_at: string;  // ISO-8601 UTC con milisegundos: cuándo se generó el evento
  data: T;
};
```

Tratá los campos de `data` como abiertos: pueden agregarse campos nuevos. Los campos marcados opcionales se **omiten** cuando no hay valor.

### 12.3 Evento `message.received`

Se emite por cada mensaje entrante de un contacto a un número de la empresa: texto, botón tocado, respuesta interactiva, archivos, ubicación, etc. Los duplicados que manda Meta se descartan antes (un mismo `wa_message_id` genera un solo evento).

```ts
type MessageReceivedData = {
  message_id: string;       // UUID del mensaje en BUZWIK
  wa_message_id: string;    // id de WhatsApp ("wamid....")
  timestamp: string;        // ISO-8601: cuándo lo mandó el contacto (según Meta)
  from: {
    contact_id: string;     // UUID del contacto en BUZWIK (se puede usar como contactId en /api/events)
    wa_id: string;          // teléfono del contacto, solo dígitos con código de país
    name?: string;          // nombre de perfil de WhatsApp, si se conoce
  };
  to: {
    phone_number_id: string;       // id del número de la empresa en Meta
    display_phone_number?: string; // número de la empresa como lo muestra Meta
  };
  conversation_id: string;  // UUID de la conversación (contacto + número)
  type: string;             // tipo de WhatsApp tal cual: "text", "button", "interactive", "image", "document", "audio", "video", "sticker", "location", "contacts", "reaction", ...
  text?: string;            // SOLO para type="text": el texto escrito
  button?: {                // SOLO si tocó un botón de respuesta rápida de una PLANTILLA (type="button")
    text?: string;          // texto del botón
    payload?: string;       // payload del botón (ver 12.5)
  };
  interactive?: {           // SOLO si respondió a un mensaje INTERACTIVO (type="interactive")
    type?: string;          // "button_reply" | "list_reply" (u otro que mande WhatsApp)
    id?: string;            // id de la opción elegida
    title?: string;
    description?: string;   // solo en list_reply
  };
  context?: {               // si el mensaje responde/cita a un mensaje anterior (incluye tocar un botón)
    wa_message_id?: string; // wamid del mensaje respondido
    message_id?: string;    // UUID de ese mensaje en BUZWIK, si es un mensaje que mandó BUZWIK
    reference?: string;     // clave de referencia de ese mensaje (ver 12.5), si tiene
  };
  raw: object;              // el mensaje original de WhatsApp (objeto "messages[i]" de Meta), para lo que no esté resumido
};
```

Ejemplo (tocó un botón de respuesta rápida de una plantilla):

```json
{
  "id": "0199f3a2-0b7e-7c55-8f0e-1d2c3b4a5f60",
  "type": "message.received",
  "created_at": "2026-10-12T21:40:05.120Z",
  "data": {
    "message_id": "0199f3a1-6c1e-7b2a-9d1e-2f6a1c0b9e11",
    "wa_message_id": "wamid.HBgLNTk4OTkxMjM0NTYVAgASGBQzQTk...",
    "timestamp": "2026-10-12T21:40:04Z",
    "from": { "contact_id": "0199e0aa-1111-7000-8000-000000000001", "wa_id": "59899123456", "name": "Ana" },
    "to": { "phone_number_id": "108538896132973", "display_phone_number": "+598 98 123 456" },
    "conversation_id": "0199e0aa-2222-7000-8000-000000000002",
    "type": "button",
    "button": { "text": "Confirmar", "payload": "Confirmar" },
    "context": {
      "wa_message_id": "wamid.HBgLNTk4OTkxMjM0NTYVAgARGBI5...",
      "message_id": "0199f390-3333-7000-8000-000000000003",
      "reference": "trg:0199d111-4444-7000-8000-000000000004:reserva-48213"
    },
    "raw": {
      "from": "59899123456",
      "id": "wamid.HBgLNTk4OTkxMjM0NTYVAgASGBQzQTk...",
      "timestamp": "1792100404",
      "type": "button",
      "context": { "from": "59898123456", "id": "wamid.HBgLNTk4OTkxMjM0NTYVAgARGBI5..." },
      "button": { "text": "Confirmar", "payload": "Confirmar" }
    }
  }
}
```

Ejemplo (texto):

```json
{
  "id": "0199f3a5-...",
  "type": "message.received",
  "created_at": "2026-10-12T21:42:10.004Z",
  "data": {
    "message_id": "0199f3a5-...",
    "wa_message_id": "wamid....",
    "timestamp": "2026-10-12T21:42:09Z",
    "from": { "contact_id": "0199e0aa-...", "wa_id": "59899123456", "name": "Ana" },
    "to": { "phone_number_id": "108538896132973", "display_phone_number": "+598 98 123 456" },
    "conversation_id": "0199e0aa-...",
    "type": "text",
    "text": "¿Puedo cambiar el horario?",
    "raw": { "from": "59899123456", "id": "wamid....", "timestamp": "1792100529", "type": "text", "text": { "body": "¿Puedo cambiar el horario?" } }
  }
}
```

Efectos del mensaje entrante en BUZWIK (además del webhook): abre/renueva la ventana de 24 h, puede cambiar el estado de baja (6.4), y puede disparar el chatbot de WhatsApp si está activo para ese número.

### 12.4 Evento `message.status`

Se emite cuando Meta informa un cambio de estado de un mensaje **enviado por BUZWIK** (envío directo, automatizaciones, OTP, respuestas del panel, chatbot).

```ts
type MessageStatusData = {
  message_id: string;         // UUID del mensaje en BUZWIK (el messageId que devuelven POST /api/messages y /api/otp/send)
  wa_message_id: string;      // wamid
  status: string;             // en minúsculas, tal como lo informa Meta: "sent" | "delivered" | "read" | "failed" (otros valores de Meta pasan tal cual)
  timestamp: string;          // ISO-8601: cuándo ocurrió según Meta
  recipient?: string;         // wa_id del destinatario
  reference?: string;         // clave de referencia del mensaje (ver 12.5); se omite si el mensaje no tiene
  conversation_id: string | null; // null en mensajes de automatizaciones y OTP; presente en envíos directos
  errors?: unknown[];         // solo si Meta incluyó errores (típico en "failed"): array de Meta con code, title, message, error_data...
};
```

```json
{
  "id": "0199f3b0-...",
  "type": "message.status",
  "created_at": "2026-10-12T21:41:00.000Z",
  "data": {
    "message_id": "0199f390-3333-7000-8000-000000000003",
    "wa_message_id": "wamid.HBgLNTk4OTkxMjM0NTYVAgARGBI5...",
    "status": "delivered",
    "timestamp": "2026-10-12T21:40:59Z",
    "recipient": "59899123456",
    "reference": "trg:0199d111-4444-7000-8000-000000000004:reserva-48213",
    "conversation_id": null
  }
}
```

Reglas:

- Cada estado se emite **una vez** por mensaje (un estado repetido por Meta no se reenvía).
- **El orden no está garantizado** (Meta los manda desordenados y las entregas van en paralelo). Aplicá una máquina de estados que no retroceda: `sent` < `delivered` < `read`; `failed` es terminal.
- Si un mensaje **no llega a ser aceptado por Meta** (por ejemplo, se agotan los reintentos internos de envío), **no** hay `message.status`: ese fallo solo se ve en el panel.

### 12.5 Correlación: `reference`, botones e interactivos

`reference` identifica el envío del lado de tu sistema. Aparece en `message.status` → `data.reference` y, cuando el contacto responde a ese mensaje (toca un botón o lo cita), en `message.received` → `data.context.reference`:

| Origen del mensaje | Valor de `reference` en los webhooks |
|---|---|
| Envío directo (`POST /api/messages`) con `reference` | **exactamente la `reference` que mandaste** (ej. `turno-48213-recordatorio`) |
| Envío directo sin `reference` | se omite (correlacioná por `message_id` = `messageId` de la respuesta) |
| Automatización (`/api/events`) | `trg:<id-de-la-automatización>:<entityRef>` |
| OTP (`/api/otp/send`) | `otp:<token aleatorio>` (correlacioná OTP por `message_id`) |
| Respuestas manuales desde el panel y otros | se omite (sin clave) |

No empieces tus `reference` de envío directo con `trg:` ni `otp:`, para poder distinguirlas de las internas.

Para interpretar una `reference` (el `entityRef` puede contener `:`, por eso en `trg:` se corta solo en los dos primeros):

```js
/**
 * "trg:<uuid>:<entityRef>" → { source: "automation", triggerId, entityRef }
 * "otp:<token>"            → { source: "otp" }
 * cualquier otra           → { source: "direct", reference }  (la que mandaste en POST /api/messages)
 * vacía                    → null
 */
export function parseReference(reference) {
  if (typeof reference !== "string" || reference === "") return null;
  if (reference.startsWith("trg:")) {
    const rest = reference.slice(4);
    const sep = rest.indexOf(":");
    if (sep < 0) return null;
    return { source: "automation", triggerId: rest.slice(0, sep), entityRef: rest.slice(sep + 1) };
  }
  if (reference.startsWith("otp:")) return { source: "otp" };
  return { source: "direct", reference };
}
```

Cómo llegan las respuestas del contacto:

- **Botón de respuesta rápida de una plantilla** → `type: "button"`, con `button.text` y `button.payload`. Si al enviar mandaste `template.buttonPayloads`, `button.payload` es **tu** payload (ej. `"confirmar:48213"`): con eso ya sabés acción y entidad. Si no, es el texto del botón; identificá la acción por `button.payload` y la **entidad** por `context.reference` (ej. `"turno-48213-recordatorio"` o `"trg:…:reserva-48213"`). Las automatizaciones no mandan payloads propios.
- **Botón u opción de lista de un mensaje `interactive`** → `type: "interactive"`, con `interactive.type = "button_reply"` (o `"list_reply"` si eligió de una lista), `interactive.id` = el `id` que pusiste y `interactive.title`. También trae `context.reference` si el envío tenía `reference`. El botón con link no manda nada al tocarlo.
- **Texto libre** → `type: "text"` y `text`. Si el contacto citó un mensaje, viene `context`.
- **Otros tipos** (imagen, audio, documento, ubicación…) → solo `type` y `raw`. Los archivos vienen como referencias de Meta dentro de `raw`; BUZWIK no los descarga por vos.

> Cuál usar: **fuera** de la ventana de 24 h solo llegan plantillas, así que para "confirmá tu turno" usá una plantilla con botones de respuesta rápida y `buttonPayloads`. **Dentro** de la ventana (el cliente te escribió recién) podés usar `interactive`, sin plantilla ni aprobación de Meta.

### 12.6 Firma: `X-Buzwik-Signature`

Formato: `t=<timestamp unix en segundos>,v1=<firma>`

- `firma` = HMAC-SHA256 en **hexadecimal en minúsculas** (64 caracteres).
- Clave del HMAC: el **secreto completo del webhook, incluido el prefijo `whsec_`**, como bytes UTF-8.
- Mensaje firmado: `t + "." + cuerpo`, donde `cuerpo` es el **body crudo exacto** (bytes UTF-8 tal como llegaron). No parsees y re-serialices el JSON antes de verificar.
- `t` es el momento del envío de **ese intento** (cambia en cada reintento; el `id` no).

Verificación correcta:

1. Leé el body crudo.
2. Parseá el header: separá por `,`, cada parte por el primer `=`; tomá `t` y `v1`.
3. Rechazá si falta alguno, si `t` no es entero o si `|ahora - t| > 300` segundos (tolerancia recomendada: 5 minutos).
4. Calculá `HMAC_SHA256(secreto, t + "." + body)` en hex.
5. Compará en **tiempo constante**, verificando antes que los largos coincidan.
6. Si falla: respondé `401` y no proceses.

Node.js (Express):

```js
import crypto from "node:crypto";
import express from "express";

const WEBHOOK_SECRET = process.env.BUZWIK_WEBHOOK_SECRET; // "whsec_..."
if (!WEBHOOK_SECRET) throw new Error("BUZWIK_WEBHOOK_SECRET no está configurada");
const TOLERANCE_SECONDS = 300;

export function verifyBuzwikSignature(rawBody, header, secret, nowSeconds = Math.floor(Date.now() / 1000)) {
  if (typeof header !== "string") return false;
  let t = null;
  let v1 = null;
  for (const part of header.split(",")) {
    const i = part.indexOf("=");
    if (i < 0) continue;
    const key = part.slice(0, i).trim();
    const value = part.slice(i + 1).trim();
    if (key === "t") t = value;
    else if (key === "v1") v1 = value;
  }
  if (!t || !v1 || !/^\d+$/.test(t)) return false;
  if (Math.abs(nowSeconds - Number(t)) > TOLERANCE_SECONDS) return false;

  const body = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody, "utf8");
  const expected = crypto
    .createHmac("sha256", Buffer.from(secret, "utf8"))
    .update(Buffer.concat([Buffer.from(`${t}.`, "utf8"), body]))
    .digest("hex");
  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(v1, "utf8");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();

// IMPORTANTE: express.raw en ESTA ruta (no express.json), para tener el body crudo.
app.post("/webhooks/buzwik", express.raw({ type: "application/json", limit: "1mb" }), async (req, res) => {
  if (!verifyBuzwikSignature(req.body, req.get("X-Buzwik-Signature"), WEBHOOK_SECRET)) {
    return res.status(401).send("invalid signature");
  }
  const event = JSON.parse(req.body.toString("utf8"));

  // Dedupe: si ya procesaste event.id, respondé 2xx sin hacer nada.
  if (await alreadyProcessed(event.id)) return res.sendStatus(200);

  // Encolá y respondé rápido (< 10 s). Procesá en segundo plano.
  await enqueueForProcessing(event); // tu cola / tabla de inbox
  await markProcessed(event.id);
  return res.sendStatus(200);
});
```

Python (Flask):

```python
import hashlib
import hmac
import os
import time

from flask import Flask, abort, request

WEBHOOK_SECRET = os.environ["BUZWIK_WEBHOOK_SECRET"]  # "whsec_..."
TOLERANCE_SECONDS = 300


def verify_buzwik_signature(raw_body: bytes, header: str | None, secret: str, now: int | None = None) -> bool:
    if not header:
        return False
    parts = {}
    for part in header.split(","):
        key, sep, value = part.strip().partition("=")
        if sep:
            parts[key] = value
    t, v1 = parts.get("t"), parts.get("v1")
    if not t or not v1 or not t.isdigit():
        return False
    now = int(time.time()) if now is None else now
    if abs(now - int(t)) > TOLERANCE_SECONDS:
        return False
    expected = hmac.new(secret.encode("utf-8"), t.encode("utf-8") + b"." + raw_body, hashlib.sha256).hexdigest()
    if len(expected) != len(v1):
        return False
    return hmac.compare_digest(expected, v1)


app = Flask(__name__)


@app.post("/webhooks/buzwik")
def buzwik_webhook():
    raw = request.get_data()  # bytes crudos, antes de parsear
    if not verify_buzwik_signature(raw, request.headers.get("X-Buzwik-Signature"), WEBHOOK_SECRET):
        abort(401)
    event = request.get_json(force=True)
    # dedupe por event["id"], encolar, responder rápido
    return "", 200
```

Java (sin dependencias; usable desde Spring con `@RequestBody byte[] body`):

```java
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;

public final class BuzwikSignature {

    private static final long TOLERANCE_SECONDS = 300;

    private BuzwikSignature() {
    }

    public static boolean verify(String secret, String header, byte[] rawBody, long nowSeconds) {
        if (header == null || rawBody == null) {
            return false;
        }
        String t = null;
        String v1 = null;
        for (String part : header.split(",")) {
            int i = part.indexOf('=');
            if (i < 0) {
                continue;
            }
            String key = part.substring(0, i).trim();
            String value = part.substring(i + 1).trim();
            if (key.equals("t")) {
                t = value;
            } else if (key.equals("v1")) {
                v1 = value;
            }
        }
        if (t == null || v1 == null || !t.matches("\\d+")) {
            return false;
        }
        if (Math.abs(nowSeconds - Long.parseLong(t)) > TOLERANCE_SECONDS) {
            return false;
        }
        try {
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
            mac.update((t + ".").getBytes(StandardCharsets.UTF_8));
            String expected = HexFormat.of().formatHex(mac.doFinal(rawBody));
            byte[] a = expected.getBytes(StandardCharsets.UTF_8);
            byte[] b = v1.getBytes(StandardCharsets.UTF_8);
            return a.length == b.length && MessageDigest.isEqual(a, b);
        } catch (java.security.GeneralSecurityException ex) {
            throw new IllegalStateException("HmacSHA256 no disponible", ex);
        }
    }
}
```

```java
// Spring Boot
@PostMapping(value = "/webhooks/buzwik", consumes = "application/json")
public ResponseEntity<Void> receive(@RequestHeader(value = "X-Buzwik-Signature", required = false) String signature,
                                    @RequestBody byte[] body) {
    if (!BuzwikSignature.verify(webhookSecret, signature, body, java.time.Instant.now().getEpochSecond())) {
        return ResponseEntity.status(401).build();
    }
    // parsear body, dedupe por "id", encolar, responder 2xx
    return ResponseEntity.ok().build();
}
```

Vector de prueba para tus tests unitarios: calculalo con cualquiera de las funciones de arriba y verificá que (a) el mismo body con otro secreto falla, (b) un byte cambiado en el body falla, (c) `t` con más de 300 s de diferencia falla, (d) `v1` con largo distinto falla sin lanzar excepción.

### 12.7 Entrega, timeouts y reintentos

- **Éxito** = cualquier respuesta `2xx`. El cuerpo de tu respuesta se ignora.
- **Timeouts**: conexión 5 s, respuesta 10 s, tope total 15 s por intento. Respondé `2xx` en menos de 10 s; si tenés que hacer algo largo, encolalo y respondé.
- Cualquier otra cosa (`3xx`, `4xx`, `5xx`, timeout, error de red/TLS) es fallo y se **reintenta**. Ojo: un `4xx` (incluido `401` por firma inválida) también se reintenta.
- El primer intento sale apenas se genera el evento (el despachador revisa la cola cada ~2 s). Esperas tras cada fallo: **1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h**. En total hasta **8 intentos** en unas ~45 h; después la entrega queda como fallida definitiva (se puede reenviar a mano desde el panel).
- **Al menos una vez**: el mismo evento puede llegar más de una vez (reintentos, reenvíos manuales, cortes a mitad). Descartá duplicados por `id` (= `X-Buzwik-Event-Id`). Guardá los `id` procesados al menos unos días (los reintentos cubren ~2 días; los reenvíos manuales, hasta 30).
- **Sin orden garantizado** entre eventos (se envían en paralelo).
- El evento se genera en la misma transacción que el hecho (mensaje guardado / estado aplicado): no se pierden eventos por caídas entre medio.

### 12.8 Eventos `request.created` y `request.updated` (solicitudes)

Si la empresa activó **Solicitudes** (panel → Solicitudes), el chatbot con IA de WhatsApp registra lo que un cliente pide y tiene que hacer una persona: un pedido al bar, un late checkout, una reserva. Primero le pregunta lo que falta y se lo confirma; recién ahí la registra. También se pueden crear por API ([11.2](#requests-api)). `request.created` llega cuando se registra (por el bot o por API) y `request.updated` cuando cambia: alguien del equipo le cambia el estado (en el panel, desde la **estación** del área —la tablet de la barra, que entra con un código— o por API), o se le suma una foto o la ubicación.

```json
{
  "id": "0199f3d0-...",
  "type": "request.created",
  "api_version": "2026-10-31",
  "account_id": "01a13072-...",
  "created_at": "2026-10-12T21:55:00.000Z",
  "data": {
    "request_id": "0199f3d0-1111-7000-8000-000000000001",
    "number": 128,
    "external_ref": null,
    "kind": "Bar",
    "detail": "2 mojitos a la piscina, reposera 14, hab. 212 (Pérez)",
    "area": "Barra",
    "status": "new",
    "location": { "latitude": -34.910512, "longitude": -54.958301, "label": "Piscina", "source": "pin" },
    "photos": 1,
    "photo_ids": ["0199f3cf-3333-7000-8000-000000000003"],
    "conversation_id": "0199f380-2222-7000-8000-000000000002",
    "contact": { "contact_id": "0199f370-...", "wa_id": "59899123456", "name": "Ana Pérez" },
    "created_at": "2026-10-12T21:55:00.000Z",
    "updated_at": "2026-10-12T21:55:00.000Z"
  }
}
```

```ts
type RequestData = {
  request_id: string;
  number: number;      // correlativo de la empresa, desde 1: el "N.º 128" que recibe el cliente y usa el equipo
  external_ref: string | null; // el externalRef con que la creó tu sistema por API; null si la tomó el bot
  kind: string;        // qué es, en una o dos palabras (lo elige el modelo: "Bar", "Late checkout", "Excursión")
  detail: string;      // todo lo que el equipo necesita para hacerlo, en una línea (hasta 1000 caracteres)
  area: string | null; // área del equipo a la que fue ("Barra", "Recepción"); null si la empresa no cargó áreas
  status: "new" | "in_progress" | "done" | "cancelled";
  previous_status?: "new" | "in_progress" | "done" | "cancelled"; // solo en request.updated, cuando ESE cambio movió el estado
  note?: string;       // por qué se rechazó ("Se agotó: queda de frutilla") o una nota al marcarla lista
  handled_by?: string; // la estación que la movió ("Barra de la piscina"); falta si fue desde el panel
  address?: string;    // la dirección que escribió el cliente ("Sarandí 1234"), si el pedido es en un lugar
  location?: {         // dónde es; falta si no se sabe
    latitude: number;
    longitude: number;
    label: string | null;                 // nombre del lugar o dirección, si vino con el pin
    source: "pin" | "geocoded" | "manual" | "api" | "point"; // pin = el de WhatsApp (exacto); geocoded = de una dirección (aproximado); api = la mandó tu sistema; point = la del punto de entrada desde donde escribió
  };
  entry_point?: { code: string; name: string }; // el punto de entrada (QR de la mesa, de la plaza) desde donde escribió, si vino por uno
  photos: number;      // cuántas fotos mandó el cliente (se mantiene por compatibilidad; es photo_ids.length)
  photo_ids: string[]; // ids de las fotos: se bajan con GET /api/v1/requests/{request_id}/photos/{id}
  conversation_id: string | null; // null si se creó por API
  contact?: { contact_id: string; wa_id: string; name?: string };
  created_at: string;  // cuándo se registró la solicitud (no cambia en request.updated)
  updated_at: string;  // cuándo cambió por última vez (en request.updated, el momento de ese cambio)
};
```

- `number` es el número que el cliente recibe al registrarla ("📋 Solicitud N.º 128") y con el que pregunta cómo va: es único por empresa y no cambia. Usalo para mostrarle la solicitud a una persona; para identificarla en tu sistema, `request_id`.
- `kind` y `detail` son **texto libre** que redacta el modelo: sirven para mostrarlos o rutearlos a un área, no para parsearlos como datos estructurados.
- `area` sale de las áreas que la empresa carga en el panel, cada una con sus temas y, opcionalmente, su número y su estación (por ejemplo, "Barra": bar, tragos). Lo que no coincide con ningún tema va al área por defecto. Sirve para rutear la solicitud en tu sistema igual que el aviso por WhatsApp.
- `address` es la dirección tal como la escribió el cliente. En el plan Premium se ubica sola en el mapa: llega un `request.updated` con `location.source` = `geocoded` (aproximada). Si el equipo corrige el punto en el panel, `source` = `manual`.
- `location` y `photos` se completan solos con el pin y las fotos que el cliente mandó por WhatsApp mientras armaba la solicitud. Si los manda después de registrarla, se suman a esa solicitud y llega un `request.updated` con el mismo `status`.
- `request.updated` trae el estado nuevo en `status` y, si ese cambio movió el estado, el anterior en `previous_status` (una nota nueva sola, una foto o la ubicación llegan sin `previous_status`). Se emite una vez por cambio. Un rechazo desde una estación llega como `cancelled` con el motivo en `note`.
- Las solicitudes se pueden crear, leer y cambiar de estado por API ([11.2](#requests-api)); la configuración (áreas, avisos, encuesta) es del panel.

**`request.rated`**: si la empresa activó la encuesta (panel → Solicitudes), al resolver una solicitud se le pregunta al cliente cómo fue la atención con 3 botones. Llega con los mismos datos de la solicitud más `rating`. Si contestó Regular o Mal y después escribe qué mejorar, llega otro `request.rated` con el `comment`.

```ts
type RatingData = RequestData & {
  rating: {
    score: 1 | 2 | 3;                  // 3 = Bien, 2 = Regular, 1 = Mal
    label: "good" | "regular" | "bad";
    comment?: string;                  // lo que contó (solo después de Regular o Mal)
  };
};
```

### 12.9 Eventos `appointment.created` y `appointment.updated` (agenda)

Cada cita nueva (la reserva el bot con el "Sí, confirmo" de la persona, la carga el panel o tu sistema) y
cada cambio (confirmada, reprogramada, cancelada, realizada, ausente, o la calificación de la encuesta en
`rating`). `previous_status` va solo si cambió el estado.

```json
{
  "type": "appointment.updated",
  "data": {
    "id": "0199…", "status": "CANCELADA", "previous_status": "CONFIRMADA",
    "starts_at": "2026-11-06T13:00:00Z", "ends_at": "2026-11-06T13:45:00Z", "timezone": "America/Montevideo",
    "origin": "BOT", "cancel_reason": "La canceló la persona",
    "agent": {"id": "…", "name": "Ana", "email": "ana@tuinmobiliaria.example"},
    "contact": {"id": "…", "wa_id": "59899123456", "name": "Laura"},
    "item": {"id": "…", "code": "PDE-101", "title": "Apartamento 2 dormitorios frente a la Mansa"},
    "conversation_id": "…", "created_at": "…", "updated_at": "…"
  }
}
```

Sin WhatsApp (la web o la API) en vez de `contact` llega `guest` (`name`, `phone`).

### 12.10 Evento `conversation.handoff` (el bot pasó la conversación a una persona)

El chatbot de WhatsApp deriva una conversación cuando el cliente pide hablar con alguien o cuando no puede resolver lo que necesita. Desde ese momento el bot no contesta en esa conversación hasta que alguien del equipo la toma y la libera, o la devuelve al bot desde la bandeja. Este evento sirve para avisarle a tu equipo por tus propios canales.

```json
{
  "id": "0199f3e0-...",
  "type": "conversation.handoff",
  "account_id": "01a13072-...",
  "created_at": "2026-10-13T15:08:43.000Z",
  "data": {
    "conversation_id": "0199f380-2222-7000-8000-000000000002",
    "contact": { "contact_id": "0199f370-...", "wa_id": "59899123456", "name": "Ana Pérez" },
    "last_message": "La habitación está sucia, quiero hablar con alguien",
    "reply": "Disculpá las molestias. Te paso con una persona del equipo 🙋",
    "handed_off_at": "2026-10-13T15:08:43.000Z"
  }
}
```

```ts
type HandoffData = {
  conversation_id: string;
  contact?: { contact_id: string; wa_id: string; name?: string };
  last_message: string;  // el mensaje del cliente que llevó a derivar
  reply: string;         // lo que el bot le contestó al derivar
  handed_off_at: string;
};
```

- Llega una vez por derivación. Si el cliente sigue escribiendo, los mensajes llegan como `message.received`, y el bot no los contesta.
- Las derivaciones del chat web (`POST /api/chatbot/ask`) no generan este evento: ahí la respuesta trae `handoff: true`.

### 12.11 Evento `conversation.urgent` (el bot detectó una urgencia)

Cuando un cliente reporta una emergencia (alguien herido o descompuesto, un pedido de auxilio, fuego o humo, pérdida de gas, robo o violencia), el bot le dice que avisó al equipo y que llame al número de emergencias si hay riesgo, y deriva la conversación a una persona. En el panel suena una sirena hasta que alguien la atiende. Este evento llega en lugar de `conversation.handoff`.

```json
{
  "id": "0199f3f0-...",
  "type": "conversation.urgent",
  "account_id": "01a13072-...",
  "created_at": "2026-10-13T16:02:10.000Z",
  "data": {
    "alert_id": "0199f3f0-3333-7000-8000-000000000003",
    "conversation_id": "0199f380-2222-7000-8000-000000000002",
    "contact": { "contact_id": "0199f370-...", "wa_id": "59899123456", "name": "Ana Pérez" },
    "reason": "Humo en la habitación 305",
    "message": "hay humo en la habitación 305, ayuda",
    "created_at": "2026-10-13T16:02:10.000Z"
  }
}
```

```ts
type UrgentData = {
  alert_id: string;
  conversation_id: string | null;
  contact?: { contact_id: string; wa_id: string; name?: string };
  reason: string;   // qué pasa, en pocas palabras (lo resume el modelo)
  message: string;  // el mensaje del cliente que la disparó
  created_at: string;
};
```

- Llega una vez por urgencia. Mientras la urgencia de esa conversación siga abierta, los mensajes siguientes no abren otra.
- `reason` es texto libre que redacta el modelo. No lo uses para decidir el tipo de emergencia: es para mostrarlo.

### 12.12 Evento `webhook.test`

```json
{
  "id": "0199f3c0-...",
  "type": "webhook.test",
  "created_at": "2026-10-12T21:50:00.000Z",
  "data": {
    "message": "Evento de prueba de Buzwik: si lo recibís, tu webhook está bien configurado.",
    "webhook_id": "0199f300-..."
  }
}
```

Respondé `2xx` y no hagas nada más. Tu handler tiene que aceptar tipos de evento desconocidos con `2xx` (y loguearlos) para no generar reintentos inútiles.

---

<a id="receta-turnos"></a>
## 13. Receta: integrar un sistema de turnos/reservas

Flujo genérico para cualquier sistema de agenda (turnos, reservas, citas). Hay dos formas de enviar los avisos; elegí una por tipo de aviso:

| | Opción A — Envío directo (`POST /api/messages`) | Opción B — Eventos + automatizaciones (`POST /api/events`) |
|---|---|---|
| Quién decide el contenido | Tu código (nombre de plantilla + variables) | El panel (plantilla o texto de la automatización) |
| Plantillas aprobadas | Sí | Sí (elegida en el panel) |
| Llega fuera de la ventana de 24 h | Sí (con plantilla) | Sí (con plantilla) |
| Botones de respuesta rápida | Sí (los de la plantilla) | Sí (los de la plantilla) |
| Idempotencia | Con `reference` (opcional, recomendada) | Siempre, por automatización + `entityRef` |
| Cambiar el aviso sin deploy | Solo editando la plantilla (nueva aprobación) | Sí: cambiar de plantilla en la automatización |
| Saber qué pasó con el evento | Respuesta inmediata con `messageId` | Solo en el registro del panel (`/api/events` responde `accepted`) |
| **Recomendado para** | Cuando tu sistema decide qué mandar y quiere el `messageId` | Cuando el negocio quiere decidir desde el panel qué aviso sale con cada evento |

Las dos sirven para un sistema de turnos; la opción A da más control y trazabilidad desde tu código.

### Paso 1 — Preparar la cuenta (panel, una vez)

1. Conectar el número de WhatsApp (**WhatsApp**).
2. Crear una API key (**Configuración → API keys**) y guardarla como `BUZWIK_API_KEY` en el servidor del sistema de turnos.
3. Crear las plantillas (**Plantillas**), categoría `UTILITY`, con variables con nombre, y enviarlas a aprobación de Meta. Ejemplo `recordatorio_turno` (idioma `es`):
   - Cuerpo: `Hola {{nombre}}, te recordamos tu turno del {{fecha}} a las {{hora}} en {{lugar}}.`
   - Botones de respuesta rápida: `Confirmar`, `Reprogramar`, `Cancelar` (tocar un botón nunca da de baja, ver 6.4).
   - Botón de link opcional: `https://tusitio.com/turno/{{reserva}}`.
   - Otras sugeridas: `turno_confirmado`, `turno_reprogramado`, `turno_anulado`.
4. Solo opción B: crear una automatización por evento (**Automatizaciones**), por ejemplo `turno_creado`, `turno_recordatorio`, `turno_reprogramado`, `turno_anulado` (categoría `UTILITY`, sin demora), con un cuerpo que use las mismas claves que vas a mandar en `variables`.
5. Configurar un webhook (**Configuración → Webhooks**) a `https://tusistema.com/webhooks/buzwik`, eventos `message.received` y `message.status`. Guardar el secreto como `BUZWIK_WEBHOOK_SECRET`. Probarlo con el botón de prueba.

### Paso 2A — Enviar avisos con envío directo (recomendado)

- **`reference` siempre**, estable y única por aviso: `turno-<id>-<tipo>[-<fechaHora>]`. Ejemplos: `turno-48213-confirmacion`, `turno-48213-recordatorio-2026-10-15T10:30`. Si el turno se reprograma, la nueva fecha da una `reference` nueva y el recordatorio vuelve a salir; repetir la misma no duplica.
- **Cuándo**: BUZWIK envía en el momento. Los recordatorios ("24 h antes") los programa **tu sistema** (cron/cola) y en ese momento llama a `POST /api/messages`.
- Llamá después de confirmar la transacción de tu base (outbox propio o job asíncrono) y reintentá `429`/`5xx`: con `reference` es seguro.

```js
import { sendTemplate } from "./buzwikMessages.js";

export async function sendBookingReminder(booking) {
  return sendTemplate({
    to: booking.customerPhone,                       // se normaliza a dígitos
    name: "recordatorio_turno",
    language: "es",
    variables: {
      nombre: booking.customerFirstName,
      fecha: formatDate(booking.startsAt),           // "15/10/2026"
      hora: formatTime(booking.startsAt),            // "10:30"
      lugar: booking.locationName,
      reserva: String(booking.id),                   // variable del botón de link
    },
    // Un payload por botón de respuesta rápida, en orden (Confirmar, Reprogramar, Anular):
    // al tocarlo vuelve en message.received → button.payload, con acción y turno.
    buttonPayloads: ["confirmar", "reprogramar", "anular"].map((a) => `${a}:${booking.id}`),
    reference: `turno-${booking.id}-recordatorio-${booking.startsAt.toISOString().slice(0, 16)}`,
  });
  // → { messageId, status: "QUEUED", type: "template", conversationId, reference, duplicate }
}
```

- Guardá `messageId` y `reference` junto al aviso en tu base.
- `409 CONTACT_OPTED_OUT`: el cliente pidió la baja; marcá que no recibe WhatsApp y no reintentes. `409 TEMPLATE_NOT_APPROVED`, `409 WABA_NOT_CONNECTED`, `402 ACCOUNT_SUSPENDED`: alertá a un humano.
- Para responder con texto libre a un cliente que acaba de interactuar (por ejemplo "Tu turno quedó confirmado" después de que tocó el botón), usá `text` en `POST /api/messages`: la ventana está abierta porque el contacto acaba de responder.

### Paso 2B — Enviar avisos con eventos (alternativa)

- `entityRef` = identidad del aviso (≤ 80 caracteres): `reserva-<id>` para `turno_creado`/`turno_anulado`, `reserva-<id>:<fechaHoraISO>` para `turno_recordatorio`/`turno_reprogramado`.
- Mandá todas las variables como strings ya formateados. Recordá la limitación: texto libre, solo llega con ventana abierta.

```js
import { sendBuzwikEvent } from "./buzwikClient.js";

await sendBuzwikEvent({
  eventType: "turno_creado",
  waPhone: booking.customerPhone.replace(/\D/g, ""),
  entityRef: `reserva-${booking.id}`,
  variables: { nombre: booking.customerFirstName, fecha: "15/10/2026", hora: "10:30", lugar: "Centro", reserva: String(booking.id) },
});
```

### Paso 3 — Recibir respuestas (BUZWIK → tu sistema)

En el handler de `message.received` (después de verificar firma y deduplicar por `id`):

```js
import { parseReference } from "./buzwikReference.js";

const ACTIONS = { confirmar: "confirm", reprogramar: "reschedule", anular: "cancel" };

/** "confirmar:48213" (payload propio) → { action, bookingId }; un payload sin ":" es el texto del botón. */
function parsePayload(payload) {
  const [action, bookingId] = String(payload ?? "").trim().toLowerCase().split(":");
  return { action: ACTIONS[action], bookingId: bookingId || null };
}

/** Id de turno desde una reference directa ("turno-48213-...") o de automatización ("trg:…:reserva-48213..."). */
function bookingIdFrom(reference) {
  const ref = parseReference(reference);
  if (!ref) return null;
  if (ref.source === "direct") return ref.reference.match(/^turno-([^-]+)-/)?.[1] ?? null;
  if (ref.source === "automation") return ref.entityRef.match(/^reserva-([^:]+)/)?.[1] ?? null;
  return null;
}

export async function onMessageReceived(data) {
  const bookingId = bookingIdFrom(data.context?.reference);

  // Botón de plantilla (button.payload) o de mensaje interactive (interactive.id): mismo formato de payload.
  const tapped = data.type === "button" ? data.button?.payload
    : data.type === "interactive" ? data.interactive?.id : null;
  if (tapped != null) {
    const { action, bookingId: fromPayload } = parsePayload(tapped);
    const id = fromPayload ?? bookingId; // sin payload propio, el turno sale de context.reference
    if (id && action) return applyBookingAction(id, action, data.from.wa_id);
    return flagForHuman(data); // botón desconocido o sin turno
  }
  if (data.type === "text") {
    return storeInboundText({ phone: data.from.wa_id, text: data.text, bookingId }); // no adivinar intención crítica
  }
  return storeOther(data); // imagen, audio, ubicación…
}
```

- Validá que `from.wa_id` coincida con el teléfono del turno antes de aplicar la acción.
- Las acciones tienen que ser idempotentes (el mismo evento puede llegar dos veces).
- Si la acción genera un aviso nuevo (ej. "anulado"), envialo con su propia `reference` (`turno-48213-anulacion`).

### Paso 4 — Seguir el estado de los envíos

- Webhook `message.status`: correlacioná por `data.reference` (o `data.message_id`), guardá el estado con una máquina que no retroceda (`sent` → `delivered` → `read`; `failed` terminal) y ante `failed` revisá `data.errors`.
- Respaldo: `GET /api/messages/{messageId}` para reconciliar avisos sin estado final (por ejemplo, un job que cada hora consulta los que siguen en `QUEUED` o `SENT` después de un tiempo razonable). Un mensaje que falla antes de llegar a Meta solo se ve acá (`status: "FAILED"`, `error`), no por webhook.

### Paso 5 — Errores y reintentos (resumen)

| Situación | Qué hacer |
|---|---|
| Timeout / `5xx` / `429` | Reintentar con backoff (`/api/messages` con la misma `reference`; `/api/events` con el mismo cuerpo). |
| `400` | Bug de integración: loguear `error.message` (dice qué variable falta o qué campo está mal) y corregir. |
| `401` | Key revocada o mal configurada: alertar. |
| `402`, `409 WABA_NOT_CONNECTED`, `409 TEMPLATE_NOT_APPROVED`, `404` plantilla | Problema de cuenta/configuración: alertar a un humano, no reintentar en bucle. |
| `409 CONTACT_OPTED_OUT` | No volver a intentar con plantillas; marcar el contacto. |
| `409 CONVERSATION_WINDOW_CLOSED` | Mandar una plantilla en lugar de texto. |
| `duplicate: true` | Normal en reintentos: ya se había enviado. |
| `/api/events` respondió `200` pero no llegó nada | Revisar el registro de **Automatizaciones** en el panel (`SKIPPED` y motivo) y los `message.status`. |
| Webhook duplicado | Ignorar por `id`. |
| Webhook con firma inválida | Responder `401` (se va a reintentar; si persiste, revisar secreto/rotación). |

---

<a id="panel-only"></a>
## 14. Endpoints que NO son para integraciones

Los siguientes endpoints existen pero son **del panel** (pensados para el JWT de un usuario logueado). **No los uses desde integraciones**: no son un contrato estable y varios exigen sesión de usuario. Con una API key responden `403 FORBIDDEN`.

- `/api/auth/**` y `/api/me` — login, registro, refresh, logout, usuario actual.
- `/api/automation/**` — ABM de automatizaciones, presets por rubro, registro de eventos (`GET /api/automation/events`).
- `/api/templates/**` — ABM, envío a revisión, importación y sincronización de plantillas.
- `/api/contacts/import`, `/api/contacts/export`, `/api/contacts/labels` y `DELETE /api/contacts/{id}` — importar, exportar, etiquetas y borrar (el resto de `/api/contacts` sí es de integración).
- `/api/inbox/**` — bandeja de conversaciones, respuestas manuales, envío de plantilla a una conversación, adjuntos, campañas (`broadcast`), opt-in.
- `/api/chatbot/config`, `/api/chatbot/rules/**`, `/api/chatbot/presets/**` — configuración del chatbot (el único de integración es `/api/chatbot/ask`).
- `/api/messages/test` — pantalla "Probar envío" (para integraciones usá `POST /api/messages`).
- `/api/requests/**` y `/api/station/**` — solicitudes del panel (bandeja, mapa, reporte, configuración) y estaciones de área. Para integraciones usá `/api/v1/requests` ([11.2](#requests-api)).
- `/api/entry-points/**` — puntos de entrada del panel (incluye el reporte, el mapa y la carga por CSV) y `GET /q/{código}` (el link público de los QR). Para integraciones usá `/api/v1/entry-points` ([11.3](#entry-points-api)).
- `/api/catalog/**` — catálogo del panel (lista, alta, CSV, agentes, mapa de oferta y demanda y zonas). Para integraciones usá `/api/v1/catalog` ([11.4](#catalog-api)).
- `/api/agenda/**` — la agenda del panel (semana por agente, horarios, ajustes). Para integraciones usá `/api/v1/agenda` ([11.5](#agenda-api)).
- `/api/waba/**` — conexión y gestión de números de WhatsApp.
- `/api/settings/api-keys/**`, `/api/settings/webhooks/**` — gestión de API keys y webhooks.
- `/api/plan/**`, `/api/modules/**`, `/api/onboarding/status` — plan, módulos, puesta en marcha.
- `/webhooks/meta` y `/meta/**` — callbacks de Meta hacia BUZWIK. No son para terceros.

---

<a id="limitaciones"></a>
## 15. Limitaciones actuales

Lo que la API **no** ofrece hoy (verificado en el código):

1. **Las automatizaciones se configuran solo en el panel** (plantilla o texto, demora, prioridad): no hay API para crearlas o editarlas, y no mandan payloads propios en los botones.
2. **Mensajes interactivos**: botones de respuesta (hasta 3), lista (hasta 10 opciones) o un botón con link. No hay botón de llamada fuera de plantillas, ni plantillas con encabezado multimedia o con variable en el encabezado.
3. **No hay envío programado por fecha**: `POST /api/messages` envía en el momento; las automatizaciones solo admiten una demora fija configurada en el panel. No se puede cancelar ni reprogramar un envío ya encolado o programado.
4. **Idempotencia permanente**: una `reference` de envío directo, o un par automatización + `entityRef`, no vuelve a enviar nunca; para reenviar hay que usar otra clave. Una `reference` repetida devuelve el mensaje original aunque el resto del pedido sea distinto.
5. **Los fallos previos a Meta no generan webhook** (por ejemplo, reintentos internos agotados): solo se ven con `GET /api/messages/{id}` o en el panel.
6. **No hay API para listar mensajes o conversaciones**. Contactos sí (sección 11.1), pero importar/exportar CSV y borrar son solo del panel. Plantillas y números son de solo lectura (`/api/account/*`).
7. **Número de origen en envío directo**: se indica por `phone_number_id` (listalos con `GET /api/account/numbers`); sin `from` sale por el número principal (no se elige por categoría como en las automatizaciones).
8. **OTP**: largo del código, vencimiento e intentos son de plataforma (6 dígitos / 300 s / 5 intentos por defecto), no por request. `/api/otp/send` no es idempotente y no verifica bajas.
9. **Permisos y vencimiento de una API key se eligen al crearla** (ver 3.1 y 3.3) y no se pueden editar después: para cambiarlos, emití otra key y revocá la anterior. No hay permisos por número ni por área.
10. **Archivos**: los adjuntos de `message.received` (fotos, documentos, audios) vienen como referencias de Meta en `raw`; no hay endpoint para descargarlos ni para enviar archivos por API. Si necesitás que el cliente mande un archivo, pedíselo por tu web.

---

<a id="checklist"></a>
## 16. Checklist para un agente LLM

Seguí estos pasos en orden. No inventes endpoints ni campos que no estén en este documento.

1. **Variables de entorno**: definí `BUZWIK_BASE_URL=https://www.buzwik.com`, `BUZWIK_API_KEY` (empieza con `bzk_`) y `BUZWIK_WEBHOOK_SECRET` (empieza con `whsec_`). Validá al arrancar que existan; nunca las expongas al frontend ni las commitees.
2. **Pedile al humano** lo que solo se hace en el panel: número conectado, API key, nombres e idiomas de las plantillas **aprobadas** con la lista exacta de variables de cada una (incluidas las de botones de link), webhook configurado y, si usás eventos, los `eventType` exactos de cada automatización y las variables de la plantilla que usa cada una.
3. **Elegí el mecanismo de envío** por tipo de aviso: `POST /api/messages` con plantilla para todo lo que deba llegar fuera de la ventana de 24 h (recomendado); `POST /api/events` solo si el negocio quiere editar textos en el panel y acepta la limitación de ventana.
4. **Cliente HTTP**: un módulo único que mande `X-Api-Key` y `Content-Type: application/json`, parsee el envelope (`success`, `data`, `error.code`), tolere respuestas sin envelope (proxies), reintente `429` (respetando `Retry-After`) y `5xx`/timeouts con backoff, reintente `409 CONFLICT` (pedido concurrente duplicado) y no reintente el resto de `400/401/402/403/404/409`.
5. **Envío directo**: `sendTemplate(to, name, language, variables, reference)` y `sendText(to, text, reference)` → `POST /api/messages`. Teléfono solo dígitos con código de país. Todas las variables de la plantilla como strings no vacíos. `reference` **siempre**, única por aviso, `[A-Za-z0-9_.:@/#-]{1,100}`, sin prefijos `trg:`/`otp:`. Guardá `messageId` + `reference`. Tratá `duplicate: true` como éxito. Mapeá `CONTACT_OPTED_OUT`, `TEMPLATE_NOT_APPROVED`, `CONVERSATION_WINDOW_CLOSED`, `WABA_NOT_CONNECTED` y `ACCOUNT_SUSPENDED` a decisiones explícitas.
6. **Eventos** (si aplica): `sendEvent(eventType, phone|contactId, entityRef, variables)` → `POST /api/events`. Campos en camelCase. `entityRef` estable, ≤ 80 caracteres, versionado cuando el mismo aviso deba repetirse.
7. **Programación**: los recordatorios los agenda tu sistema (cron/cola) y llaman a la API en el momento de enviar. Dispará los envíos después de confirmar la transacción de tu base.
8. **OTP** (si aplica): `POST /api/otp/send` y `POST /api/otp/verify` con el **mismo** `to` (solo dígitos) y `purpose`. Sin reintentos automáticos en `/send`. `verified: false` es una respuesta normal.
9. **Chatbot web** (si aplica): endpoint propio en tu backend que haga proxy a `POST /api/chatbot/ask`, acote `history` y muestre el botón de WhatsApp con `whatsappUrl` cuando `handoff` sea `true`.
    - **Solicitudes** (si aplica): `POST /api/v1/requests` con `externalRef` **siempre** (tu id); `200` y `201` son éxito; `409 CONFLICT` no se reintenta. Sincronizá con `GET /api/v1/requests?updatedSince=` paginando por `nextCursor`, o escuchá `request.created`/`request.updated` y usá `external_ref` para ubicar la tuya. Pedí una key con solo `requests:read`/`requests:write` si no necesita más.
10. **Receptor de webhooks**: ruta `POST` con body **crudo**; verificá `X-Buzwik-Signature` (HMAC-SHA256 hex de `t + "." + body` con el secreto completo, tolerancia 300 s, comparación en tiempo constante con chequeo de largo); respondé `401` si falla.
11. **Deduplicación**: tabla/cache de `id` de eventos procesados (retención ≥ 30 días); si ya está, respondé `2xx` sin procesar.
12. **Respuesta rápida**: encolá y respondé `2xx` en < 10 s; procesá en segundo plano. Aceptá con `2xx` los `type` desconocidos y `webhook.test`.
13. **Correlación**: `parseReference` (sección 12.5): `trg:<uuid>:<entityRef>` → automatización; `otp:` → OTP; cualquier otra → tu `reference` de envío directo, tal cual la mandaste. Usá `context.reference` en `message.received` y `reference` en `message.status`; como respaldo, `message_id`.
14. **Acciones por botones**: mapeá `button.payload` (o `button.text`) a acciones idempotentes; validá que `from.wa_id` coincida con el teléfono de la entidad. No pidas que escriban "baja" o "stop" para una acción del negocio (son palabras de baja).
15. **Estados**: máquina de estados sin retroceso (`sent` < `delivered` < `read`; `failed` terminal), manejo de `errors` en `failed`, y un job de reconciliación con `GET /api/messages/{id}` para mensajes sin estado final.
16. **Tests a escribir**:
    - Firma: válida pasa; secreto incorrecto falla; body alterado falla; `t` fuera de tolerancia falla; `v1` de largo distinto falla sin excepción; header ausente o mal formado falla.
    - Receptor: evento duplicado se procesa una sola vez; tipo desconocido devuelve `2xx`; responde antes del timeout.
    - Cliente: `429` con `Retry-After` reintenta; `400`/`409` no reintentan; `5xx` reintenta con la misma `reference`; `duplicate: true` se trata como éxito; `401`/`403` no reintentan.
    - Construcción de `reference`: única por aviso, cambia al reprogramar, respeta patrón y largo.
    - `parseReference`: `trg:` con y sin `:` dentro del `entityRef`; `otp:`; `reference` directa; vacía → `null`.
    - Mapeo de botones → acciones; `from.wa_id` distinto al del turno no aplica la acción.
    - Variables: el cliente arma todas las variables que pide cada plantilla (un test por plantilla).
17. **Prueba de punta a punta**: `GET /api/ping` con la key → `200`; botón de prueba del webhook → recibís `webhook.test` con firma válida; `POST /api/messages` con una plantilla aprobada a un teléfono de prueba → recibís `message.status` `sent`/`delivered` con tu `reference`; tocás un botón → `message.received` con `context.reference` igual a tu `reference`; repetir el POST → `duplicate: true`.
18. **Documentá las limitaciones** de la [sección 15](#limitaciones) que afecten al caso de uso y avisale al humano.

---

<a id="versionado"></a>
## 17. Versionado y compatibilidad

- La API documentada acá es la **versión 1**. Los endpoints históricos no llevan prefijo de versión (`/api/...`); los nuevos lo llevan (`/api/v1/requests`). Ambos son la misma versión 1, con las mismas garantías.
- **Webhooks**: el formato tiene su propia versión, por fecha, en `api_version` del cuerpo y en el header `X-Buzwik-Webhook-Version` (hoy `2026-10-31`). Agregar campos o eventos no la cambia; solo cambiaría ante un cambio incompatible (quitar o renombrar un campo), con aviso previo. Si tu receptor depende de un formato, chequeá la versión y alertá si llega otra.
- Cambios **compatibles** se hacen sin aviso y sin cambiar de versión: endpoints nuevos, campos nuevos en respuestas y webhooks, valores nuevos en enums abiertos (`status`, `reason`, `type`), headers nuevos. Tu cliente tiene que **ignorar campos desconocidos** y no fallar ante valores nuevos.
- Cambios **incompatibles** (quitar o renombrar campos, cambiar tipos o el significado de un código de error) no se hacen sobre `/api/...`: irían en una ruta nueva (`/api/v2/...`) y la versión 1 se mantiene funcionando, con aviso previo a las empresas.
- Lo que está fuera de este documento (endpoints del panel, sección 14) no tiene garantía de compatibilidad.
