Documentación de integración

Todo lo que hace falta para consumir la API: cada endpoint con su JSON exacto, los estados del agente, el modelo de lectura con cursor, la idempotencia de los envíos y las trampas que no se deducen del resto. Los ejemplos ya vienen con la dirección de este servidor.

Está en Markdown a propósito: pasale la dirección de abajo a tu agente de codificación, o copiá el texto entero y pegáselo, para que implemente la integración en tus sistemas. Es pública: no hace falta ninguna clave para leerla.

Dirección para tu agente de codificación https://chatchan.papaya.com.pe/docs.md
Verla en crudo Ir al panel
# ChatChan — documentación de integración

API para mandar y recibir mensajes de WhatsApp desde tu plataforma, saliendo del
número del propio negocio.

Este documento es autosuficiente: contiene cada endpoint con su JSON exacto, los
códigos de estado, los errores posibles y los límites. Está pensado para que lo
leas vos o se lo pases entero a tu agente de codificación.

Base de todos los ejemplos: `https://chatchan.papaya.com.pe`

---

## 1. Cómo funciona

La sesión de WhatsApp vive en **la computadora del negocio**, no en el hub. Un
agente instalado ahí mantiene un WebSocket abierto contra el hub; el hub solo
enruta.

```
tu plataforma  ──HTTPS──>  hub  ──WebSocket──>  agente en la PC del negocio  ──>  WhatsApp
```

Tres consecuencias que mandan sobre todo el diseño de tu integración:

1. **Si la computadora está apagada, no se puede enviar.** El estado del agente
   es parte central de la API, no un detalle de monitoreo.
2. **Los mensajes entrantes se leen por polling** (`GET /v1/inbox` con cursor),
   no por webhook.
3. **El inbox retiene 24 horas.** Un consumidor que deje de leer más de un día
   pierde mensajes de forma definitiva.

---

## 2. Autenticación

Todas las llamadas autenticadas usan:

```
Authorization: Bearer <clave>
```

Hay cuatro niveles. Una clave alcanza su nivel y lo de abajo, **nunca lo de al
lado**.

| Prefijo | Quién | Para qué |
|---|---|---|
| `wizr_` | el dueño del servicio | Alta de plataformas y publicar versiones del agente. Nunca manda mensajes. |
| `wizt_` | **tu plataforma** | Alta de tus negocios, y leer/mandar por cualquiera de ellos con una sola conexión. |
| `wizc_` | un negocio | Solo lo suyo, sin necesidad de decir su `client_id`. |
| `wiza_` | el agente instalado | Solo abrir su WebSocket. No sirve para ningún endpoint REST. |

Solo dos endpoints no piden clave: `GET /health` y `POST /v1/pair/claim`
(donde el código de 6 dígitos ES la credencial). Todo lo demás va con
`Authorization: Bearer`.

**La clave que vas a usar es la `wizt_`.** Se te entrega una sola vez, al darte
de alta; el hub solo guarda su hash. Si la perdés, hay que rotarla.

> El prefijo `wiz` es del nombre anterior del producto y se mantiene a
> propósito: cambiarlo invalidaría todas las claves ya emitidas. Es cosmético —
> el nivel de una clave sale de la base, nunca de su prefijo.

### Reglas de autorización que importan

- Un negocio que no es tuyo responde **404, nunca 403** — un 403 confirmaría que
  ese negocio existe en la plataforma de un competidor. Vale igual para
  `client_id` y para `external_ref`.
- La clave raíz **no** tiene acceso universal: cada endpoint declara sus niveles
  exactos, y `wizr_` solo entra en `/v1/tenants*` y `/v1/releases`.
- Errores comunes a todo endpoint autenticado:
  - `401 {"error":"sin_credencial"}` — falta el header.
  - `401 {"error":"credencial_invalida"}` — clave inexistente, revocada, o de un
    negocio/plataforma dado de baja.
  - `403 {"error":"scope_insuficiente"}` — la clave es válida pero de otro nivel.

---

## 3. Puesta en marcha, de punta a punta

```
1. POST /v1/clients            das de alta el negocio           -> te devuelve wizc_
2. POST /v1/pair               (con la wizc_) generás el código -> 6 dígitos, 15 min
3. el dueño escribe ese código en el agente instalado en su PC
4. GET  /v1/status             hasta ver estado "vinculado"
5. POST /v1/messages           ya podés enviar
6. GET  /v1/inbox              leés lo que entra, con cursor
```

El paso 3 lo hace una persona, una sola vez por negocio.

---

## 4. Negocios

### `POST /v1/clients` — alta

Nivel: `wizt_`.

```json
{"name": "Pizzería Don Luis", "external_ref": "org_882"}
```

- `name` obligatorio. Vacío → `400 {"error":"nombre_requerido"}`.
- `external_ref` opcional: **tu** identificador interno, para no tener que
  guardar el `id` del hub. Es único dentro de tu plataforma; dos plataformas
  distintas pueden usar el mismo string sin chocar.

Respuesta (mismo shape en todos los casos):

```json
{"id":"cli_...", "name":"Pizzería Don Luis", "external_ref":"org_882",
 "client_key":"wizc_...", "creado":true, "reactivado":false}
```

⚠ **Dos códigos de éxito distintos:**

| Situación | Status | `creado` | `reactivado` |
|---|---|---|---|
| Alta nueva | `201` | `true` | `false` |
| El `external_ref` existía y estaba dado de baja → se reactiva | `200` | `false` | `true` |
| El `external_ref` existía y estaba activo | `200` | `false` | `false` |

En una reactivación, el `name` que vuelve es **el ya guardado**, no el que
mandaste: reactivar no renombra. En los tres casos se emite una `wizc_` nueva, y
**la anterior no se revoca**.

Errores: `400 cuerpo_invalido`, `400 nombre_requerido`,
`402 limite_del_plan` (el plan no admite más negocios; `detail` dice cuántos
admite. Volver a dar de alta el mismo `external_ref` NO cuenta como uno nuevo),
`500 error_interno`.

### `GET /v1/clients` — tu cartera completa

Nivel: `wizt_`. Sin paginación: trae todo.

```json
[{"id":"cli_...", "name":"...", "external_ref":null,
  "estado":"agente_offline", "version":null, "equipo":null,
  "telefono":null, "ultima_vez":null,
  "created_at":"2026-09-04T12:00:00Z", "disabled_at":null}]
```

Siempre un array, nunca `null`. `telefono` solo viene si el estado es
`vinculado`.

### `POST /v1/clients/{id}/keys` — rotar la clave de un negocio

Nivel: `wizt_`. `201 {"client_key":"wizc_..."}`. **La anterior sigue siendo
válida** — si querés invalidarla, hay que dar de baja el negocio.

Errores: `404 cliente_no_encontrado`, `500 error_interno`.

### `DELETE /v1/clients/{id}` — baja

Nivel: `wizt_`. `200 {"ok":true}`, idempotente.

Revoca **todas** las claves de ese negocio y desaloja su WebSocket. Errores:
`404 cliente_no_encontrado`, `500 error_interno`.

---

## 5. Vincular el WhatsApp del negocio

### `POST /v1/pair` — generar el código

Nivel: `wizc_` (la clave del negocio; con `wizt_` da `403`). Sin body.

```json
{"code":"482913", "expires_at":"2026-09-04T12:15:00Z"}
```

⚠ Devuelve **`200`**, no 201. El código son 6 dígitos, vive **15 minutos**, y un
negocio tiene **uno solo pendiente**: pedir otro invalida el anterior.

Error posible: `503 {"error":"sin_codigos_disponibles"}` (extremadamente
improbable).

### `POST /v1/pair/claim` — lo usa el agente, no vos

Público, sin autenticación: el código de 6 dígitos **es** la credencial. Lo llama
el agente instalado en la PC del negocio cuando el dueño escribe el código.

Se documenta acá solo para que sepas qué pasa del otro lado:

- `201 {"agent_token":"wiza_..."}`.
- `410 {"error":"codigo_invalido_o_vencido"}` — código inexistente, ya usado o
  vencido. ⚠ Es `410`, no 400 ni 404.
- `429 {"error":"demasiados_intentos"}` — 5 fallos en 15 minutos desde la misma
  IP. Solo cuentan los fallos.

---

## 6. Estado del agente

### `GET /v1/status`

Nivel: `wizc_`. Sin body ni query.

```json
{"estado":"vinculado", "desde":"2026-09-04T11:00:00Z", "motivo":null,
 "accion":"", "telefono":"51987654321", "equipo":"PC-MOSTRADOR",
 "version":"2.0.6", "ultima_vez":"2026-09-04T12:30:00Z",
 "actualizacion":{"disponible":false}}
```

Las nueve claves están **siempre presentes**; las que no aplican vienen en
`null`. Para ver el estado de toda tu cartera de una sola vez, usá
`GET /v1/clients` con tu `wizt_`.

### Los cinco estados

| `estado` | Qué significa | `accion` |
|---|---|---|
| `vinculado` | Todo bien, se puede enviar | `""` |
| `reconectando` | Caída pasajera, se arregla solo | `""` — a propósito sin acción: avisar sería una falsa alarma diaria |
| `necesita_qr` | La sesión se cayó y hay que volver a escanear | `escanear_qr` |
| `nunca_vinculado` | El agente está instalado pero nunca se vinculó | `escanear_qr` |
| `agente_offline` | No hay WebSocket vivo: computadora apagada, sin internet, o agente cerrado | `revisar_computadora` |

Detalles que evitan bugs:

- `agente_offline` **no lo reporta el agente**: lo deduce el hub por ausencia de
  conexión. Si no hay WebSocket vivo, el estado es `agente_offline` sin importar
  lo que diga el último reporte guardado.
- En ese caso, `desde` es la **última vez que se lo vio**, no el momento de la
  última transición guardada — así no aparece un "sin señal desde hace días" que
  en realidad es de hace dos minutos.
- `telefono` solo viene si el estado es `vinculado`; en cualquier otro es `null`
  aunque el hub tenga uno guardado.

### `GET /v1/events` — historial de transiciones

Nivel: `wizt_` o `wizc_`. Polling puro, sin long-poll.

Query: `client_id` (opcional), `after` (cursor por `id`, default `0`), `limit`
(default `100`, tope `500`).

```json
[{"id":123, "client_id":"cli_...", "external_ref":"org_882",
  "de":null, "a":"vinculado", "motivo":null, "cuando":"2026-09-04T11:00:00Z"}]
```

`de` es `null` en la primera transición de un agente. Retención: **90 días** —
es historial de soporte, no el camino caliente.

Un `limit` o `after` inválido **no da error**: cae al default en silencio.

---

## 7. Enviar mensajes

### `POST /v1/messages`

Nivel: `wizt_` o `wizc_`.

```json
{"to": "51987654321",
 "text": "Tu pedido está listo",
 "idempotency_key": "pedido:8821:listo",
 "external_ref": "org_882",
 "demora_ms": 1500}
```

| Campo | Obligatorio | Notas |
|---|---|---|
| `to` | sí | Se le quitan `+`, espacios y guiones antes de validar: no es error mandarlos |
| `text` | sí, salvo con adjunto | ⚠ Se llama `text`, en inglés — no `texto`. Con `document`/`media` es el pie del archivo y puede ir vacío |
| `idempotency_key` | sí | Ver abajo. ⚠ No confundir con `client_id`, que es otro campo del mismo body |
| `client_id` o `external_ref` | con `wizt_`, uno de los dos | Con `wizc_` se ignoran (y un `client_id` ajeno da 404) |
| `demora_ms` | no | `[0, 60000]`. Si no viene, se usa el valor configurado en tu plataforma |
| `document` | no | Adjunto que **siempre** llega como archivo descargable: comprobantes PDF y XML. Ver abajo |
| `media` | no | Adjunto general (imagen, audio, video o documento), con el tipo deducido. Ver abajo |

Éxito, **dos códigos distintos**:

- `201 {"id":"msg_...", "status":"sent"}` — envío nuevo, salió bien.
- `200 {"id":"msg_...", "status":"sent", "duplicate":true}` — reenvío con la
  misma clave de idempotencia. **No se volvió a mandar nada**; el `id` es el
  original. (⚠ el campo se llama `duplicate`, en inglés.)

Errores:

| Status | Cuerpo | Cuándo |
|---|---|---|
| `400` | `{"error":"to_y_text_requeridos"}` | Falta `to` o `text` (o `to` quedó vacío al limpiarlo) |
| `400` | `{"error":"idempotency_key_requerido"}` | Falta la clave |
| `400` | `{"error":"demora_invalida"}` | `demora_ms` fuera de `[0, 60000]` |
| `400` | `{"error":"client_id_o_external_ref_requerido"}` | Con `wizt_`, no dijiste por qué negocio |
| `404` | `{"error":"cliente_no_encontrado"}` | Negocio inexistente o de otra plataforma |
| `402` | `{"error":"limite_del_plan","detail":"..."}` | Se acabaron los mensajes del plan (solo el de prueba tiene tope) |
| `409` | `{"error":"en_vuelo"}` | Otra request con la misma clave está en curso **ahora** |
| `409` | `{"error":"sin_vincular", "estado":"necesita_qr", "accion":"escanear_qr"}` | El agente está conectado pero su WhatsApp no está vinculado |
| `502` | `{"error":"envio_fallido", "detail":"el agente no respondió"}` | El agente rechazó el envío o no contestó a tiempo |
| `503` | `{"error":"agente_offline"}` | El negocio no tiene ningún agente conectado |

### Mandar un comprobante (PDF, XML) — `document`

Es el caso que domina este producto, y por eso tiene su propio campo: lo que
mandes en `document` llega **siempre** como archivo descargable, nunca
convertido en foto ni recomprimido.

```json
{"to": "51987654321",
 "text": "Adjunto tu comprobante F001-123",
 "document": {
   "url": "https://tuerp.com/cpe/F001-123.pdf",
   "filename": "F001-123.pdf"
 },
 "idempotency_key": "cpe:F001-123",
 "external_ref": "org_882"}
```

- `url` **obligatoria**, y **tiene que ser `https`**. Un `http://` se rechaza
  con `400 media_url_invalida`.
- `filename` **opcional**: si no viene, se deriva del último tramo de la URL
  (`.../cpe/F001-123.pdf` → `F001-123.pdf`). Es el nombre que ve el cliente en
  su teléfono. Si la URL no termina en algo con forma de archivo
  (`.../descargar?id=9`), hay que mandarlo: `400 media_filename_requerido`.
- `text` pasa a ser el **pie** del archivo, y es opcional.

**El archivo lo descarga el agente, desde la PC del negocio** — el hub solo le
pasa la URL. Consecuencias que importan al integrar:

1. La URL tiene que ser alcanzable **desde la computadora del negocio**, no
   desde tu servidor. Si es pública, lo es.
2. Puede llevar su propio token en la query (`?token=abc`): viaja por https y
   no se toca.
3. Tope de **20 MB** y **30 segundos** para descargar. Si se pasa, el envío
   falla con `502 envio_fallido` y el `detail` dice exactamente qué pasó
   (`el documento pesa más de 20 MB`, `el servidor del documento respondió
   404`, `tardó demasiado`…).
4. No se permiten URLs que resuelvan a direcciones de **red interna**
   (`127.0.0.1`, `192.168.x.x`, `10.x.x.x`, `169.254.169.254`…), ni siquiera
   llegando ahí por una redirección. El agente vive dentro de la red del
   negocio y no va a hacer pedidos a sus equipos.

### Mandar imagen, audio o video — `media`

Mismo mecanismo, pero el tipo se deduce de la extensión del archivo:

```json
{"to": "51987654321",
 "text": "Así quedó tu pedido",
 "media": {"url": "https://tuerp.com/fotos/pedido-8821.jpg"},
 "idempotency_key": "foto:8821"}
```

| Extensión | Llega como |
|---|---|
| `jpg` `jpeg` `png` `webp` `gif` | imagen |
| `mp3` `ogg` `opus` `m4a` `wav` | audio |
| `mp4` `3gp` `mov` | video |
| **cualquier otra**, incluida desconocida | documento |

Podés forzarlo con `"tipo"`: `documento`, `imagen`, `audio` o `video`. Sirve,
por ejemplo, para mandar un plano en PNG como archivo y que WhatsApp no lo
recomprima:

```json
{"media": {"url": "https://tuerp.com/planos/local.png", "tipo": "documento"}}
```

Un `tipo` que no sea uno de esos cuatro da `400 media_tipo_invalido`.

⚠ `document` y `media` son el mismo mecanismo y **no se mandan juntos**: hacerlo
da `400 media_y_document_juntos`. Usá `document` para comprobantes (garantiza
archivo descargable) y `media` para el resto.

Errores propios del adjunto, todos `400`:

| Cuerpo | Cuándo |
|---|---|
| `{"error":"media_url_requerida"}` | El adjunto vino sin `url` |
| `{"error":"media_url_invalida"}` | No es `https://`, o no es una URL |
| `{"error":"media_filename_requerido"}` | Sin `filename` y la URL no permite deducir uno |
| `{"error":"media_filename_invalido"}` | El nombre trae `/`, `\`, saltos de línea, o pasa de 200 caracteres |
| `{"error":"media_tipo_invalido"}` | `tipo` no es documento/imagen/audio/video (o es contradictorio dentro de `document`) |
| `{"error":"media_y_document_juntos"}` | Vinieron los dos campos |

Estos se verifican **antes** de reservar la clave de idempotencia: si te
equivocás en la URL, corregís y reintentás con la misma clave sin comerte un
`duplicate` por un envío que nunca salió.

### Idempotencia — leer esto antes de programar reintentos

La clave real es el **par `(negocio, idempotency_key)`**. Dos negocios distintos
pueden reusar el mismo string sin chocar (`"pedido:8821"` en cada uno), así que
podés derivar la clave de tu propio identificador de negocio sin prefijarla.

- Un envío que respondió `sent` (`201` o `200 duplicate`) **nunca se reintenta**,
  llames las veces que llames.
- Se reintenta de verdad **solo** si el intento anterior quedó fallado (agente
  offline, no vinculado, ack negativo, timeout). Ahí el reintento reusa el mismo
  `id` de mensaje.
- `409 en_vuelo` **no es un error tuyo**: es una carrera legítima entre dos
  requests idénticas simultáneas. Tratalo como "esperá y consultá", no como
  fallo.
- Si un proceso muere a mitad de un envío, la reserva queda huérfana y se libera
  a los **2 minutos**; recién ahí otra llamada con la misma clave puede
  reintentar.

### El agente escribe como una persona (y eso se nota en los tiempos)

Una cuenta de WhatsApp que dispara diez mensajes en el mismo segundo es el
patrón más fácil de detectar que existe, y lo que se pierde cuando lo detectan
es el número del negocio. Por eso el agente:

- Deja pasar **entre 1 y 5 segundos al azar** entre dos envíos consecutivos (al
  azar, no fijo: un intervalo exacto es otro patrón).
- Muestra **"escribiendo…"** durante esa pausa, no después.
- Manda de a uno: dos envíos nunca se superponen.

Qué significa para vos: si mandás cinco comprobantes seguidos, el quinto tarda
en salir. **No es un cuelgue** — cada `POST` responde cuando su mensaje salió de
verdad. Si la cola se acumula demasiado, el agente recorta la pausa antes de
arriesgar un timeout: parecer humano importa, duplicar un comprobante importa
más.

⚠ **Timeout de cliente.** El hub espera el ack del agente:

| Mensaje | Espera |
|---|---|
| Texto | `20s + demora_ms` (hasta 80s con `demora_ms` al máximo) |
| Con adjunto | `90s + demora_ms` |

El adjunto tarda más porque el agente espera su turno, **descarga** el archivo
de tu URL (hasta 30s) y recién ahí lo sube a WhatsApp. Ajustá el timeout de tu
cliente HTTP y de cualquier proxy tuyo, o vas a cortar envíos que iban a salir
bien — y un reintento sobre un envío que salió es un comprobante duplicado en el
teléfono de tu cliente.

---

## 8. Recibir mensajes

### `GET /v1/inbox`

Nivel: `wizt_` o `wizc_`.

Query: `client_id` (opcional), `after` (cursor), `limit` (default `100`, tope
`500`), `wait` (segundos de espera larga, default `0`, **tope `25`**).

```json
[{"id":456, "client_id":"cli_...", "external_ref":"org_882",
  "de":"51987654321", "nombre":"Luis", "texto":"hola", "tipo":"texto",
  "cuando":"2026-09-04T12:31:00Z"}]
```

`nombre` y `texto` pueden ser `null`. `tipo` es siempre uno de
`texto | imagen | audio | documento | otro`.

### El bucle de lectura correcto

```
after = 0   (o el último id que guardaste)
repetir:
    GET /v1/inbox?after=<after>&wait=25
    por cada mensaje: procesarlo
    after = el id MÁS ALTO recibido      <- guardalo en tu base, no en memoria
```

- Guardá el cursor **persistido**. Si lo perdés y arrancás de `0`, reprocesás
  todo lo de las últimas 24 h.
- `wait=25` mantiene la conexión abierta hasta que llegue algo: es un long-poll,
  no un sondeo cada segundo. Con `wait=0` responde de inmediato (posiblemente
  `[]`).
- Un `wait` mayor a 25 se recorta **en silencio**, sin error.
- La espera puede despertarse por un mensaje de **otro negocio de tu misma
  plataforma** y devolverte `[]`. Es normal: volvé a llamar.

### Agrupación de mensajes seguidos

Configurable en `PATCH /v1/tenants/me` (`ventana_agrupacion_ms`, tope `30000`).

Con la ventana en `0`, cada mensaje se entrega apenas llega. Con la ventana
activa, mientras el mismo remitente siga escribiendo, sus mensajes quedan
retenidos y **el cursor no avanza por encima de ellos** — así recibís "hola" +
"quiero pedir" + "una pizza" juntos, en vez de tres llamadas a tu bot que se
pisan entre sí.

Tope de retención: **10 × la ventana**. Con `3000ms`, un remitente charlatán
retiene como mucho 30 segundos y después se entrega igual — nadie puede bloquear
tu inbox indefinidamente.

### Retención: 24 horas

Los mensajes se borran a las 24 h (el barrido corre cada hora, así que en la
práctica un mensaje puede vivir un poco más). **No hay recuperación.** Si tu
consumidor se cae, tenés menos de un día para volver a leer.

---

## 9. Configuración de tu plataforma

### `GET /v1/tenants/me` y `PATCH /v1/tenants/me`

Nivel: `wizt_`. Actúan siempre sobre tu propia plataforma.

```json
{"ventana_agrupacion_ms": 3000, "demora_respuesta_ms": 1500}
```

- `ventana_agrupacion_ms`: `[0, 30000]`. Ver agrupación, arriba.
- `demora_respuesta_ms`: `[0, 60000]`. Pausa antes de mandar cada mensaje, para
  que las respuestas no salgan instantáneas.

Un `PATCH` con `{}` no cambia nada y no es error. Un campo ausente **no toca** su
columna — es la única forma de cambiar uno sin pisar el otro. La respuesta
siempre trae **los dos valores vigentes**, no solo lo que mandaste.

Fuera de rango → `400 {"error":"valor_invalido"}`.

### `GET /v1/whoami`

Cualquier nivel. Útil para verificar con qué clave estás hablando:

```json
{"scope":"tenant", "tenant_id":"tnt_...", "client_id":"", "name":"Mi Plataforma"}
```

Las cuatro claves siempre presentes; las que no aplican son `""` (cadena vacía,
no `null`).

### `GET /agent/latest` — la última versión del agente

Nivel: `wizt_` o `wizc_`. La consulta la hacés vos, que sos quien le entrega el
instalador a tus negocios.

```
GET /v1/agent/latest
```

```json
{"version": "2.0.12",
 "url": "https://.../ChatChan-v2.0.12.exe",
 "notas": "Qué cambia en esta versión",
 "obligatoria": false}
```

Sirve para dos cosas:

1. **Darle el enlace de descarga** al negocio que va a instalar el agente por
   primera vez, sin que tengas que copiar la URL a mano en tu sistema cada vez
   que publicamos.
2. **Saber si un negocio está atrasado**: comparás esta `version` con la que
   trae `GET /v1/status` (o la columna `version` de `GET /v1/clients`).

`obligatoria: true` significa que la versión trae algo que no se puede
posponer; `notas` es el texto que podés mostrarle al negocio.

Si todavía no se publicó ninguna versión: `404 {"error":"sin_version_publicada"}`
— no un `200` con campos vacíos, para que un instalador no termine bajando `""`
y fallando mucho más tarde y en otro lado.

### `GET /health`

Sin autenticación. `{"ok":true}`.

---

## 10. Límites

| Límite | Valor | Qué pasa si lo ignorás |
|---|---|---|
| Tamaño del body | 1 MiB | `400 cuerpo_invalido` |
| `limit` de inbox/events | default 100, tope 500 | Se recorta en silencio, nunca da error |
| `wait` de inbox | tope 25 s | Se recorta en silencio |
| `ventana_agrupacion_ms` | 0–30 000 | `400 valor_invalido` |
| `demora_respuesta_ms` / `demora_ms` | 0–60 000 | `400 valor_invalido` / `400 demora_invalida` |
| Timeout de envío (texto) | 20 s + `demora_ms` | Hasta 80 s de espera antes de un `502` |
| Timeout de envío (con adjunto) | 90 s + `demora_ms` | Cubre la espera de turno, la descarga y la subida |
| Separación entre envíos | 1–5 s al azar | El agente los espacia solo; una ráfaga tarda más en salir |
| Tamaño del adjunto | 20 MB | `502 envio_fallido` con el detalle |
| Descarga del adjunto | 30 s | `502 envio_fallido` con el detalle |
| Código de vinculación | 15 minutos | `410 codigo_invalido_o_vencido` |
| Intentos de vinculación | 5 fallos / 15 min por IP | `429 demasiados_intentos` |
| Negocios del plan | 1 (prueba), 5, 20, 50, 100 | `402 limite_del_plan` al dar de alta uno más |
| Mensajes del plan | solo el de prueba tiene tope: 100 | `402 limite_del_plan` al enviar |
| Reserva de idempotencia huérfana | 2 minutos | Hasta ahí, reintentar da `409 en_vuelo` |
| Retención del inbox | 24 horas | Mensajes perdidos, sin recuperación |
| Retención de eventos | 90 días | — |

No hay rate limit general por plataforma: los únicos límites de frecuencia son el
de vinculación por IP y el `en_vuelo` de idempotencia.

Las dos últimas filas dependen del plan contratado. Los planes pagos **no
tienen tope de mensajes**: se paga por negocio y los mensajes no se cuentan.
Volver a dar de alta el MISMO negocio (mismo `external_ref`) no consume cupo:
el alta es idempotente.

Cada rechazo por tope queda registrado y **se le avisa por correo al dueño de la
cuenta** (una vez por día y por negocio), además de aparecer en su panel. No
hace falta que la integración haga nada con eso: es para que quien decide
ampliar el plan se entere sin depender de que alguien le reenvíe un log.

---

## 11. Receta completa

```bash
HUB=https://chatchan.papaya.com.pe
WIZT=wizt_tu_clave

# 1. alta del negocio (guardá client_key: no se vuelve a mostrar)
curl -s -X POST "$HUB/v1/clients" \
  -H "Authorization: Bearer $WIZT" -H "Content-Type: application/json" \
  -d '{"name":"Pizzería Don Luis","external_ref":"org_882"}'

# 2. código de vinculación (con la wizc_ que devolvió el paso 1)
WIZC=wizc_la_del_negocio
curl -s -X POST "$HUB/v1/pair" -H "Authorization: Bearer $WIZC"
# -> {"code":"482913","expires_at":"..."}  el dueño lo escribe en su agente

# 3. esperar a que quede vinculado
curl -s "$HUB/v1/status" -H "Authorization: Bearer $WIZC"

# 4. enviar
curl -s -X POST "$HUB/v1/messages" \
  -H "Authorization: Bearer $WIZT" -H "Content-Type: application/json" \
  -d '{"to":"51987654321","text":"Tu pedido está listo",
       "idempotency_key":"pedido:8821:listo","external_ref":"org_882"}'

# 5. mandar un comprobante (el agente baja el archivo y lo adjunta)
curl -s -X POST "$HUB/v1/messages"   -H "Authorization: Bearer $WIZT" -H "Content-Type: application/json"   -d '{"to":"51987654321","text":"Adjunto tu comprobante",
       "document":{"url":"https://tuerp.com/cpe/F001-123.pdf"},
       "idempotency_key":"cpe:F001-123","external_ref":"org_882"}'

# 6. leer, en bucle, guardando el id más alto como cursor
curl -s "$HUB/v1/inbox?after=0&wait=25" -H "Authorization: Bearer $WIZT"
```

---

## 12. Riesgo de bloqueo — léelo antes de integrar

ChatChan **no es la API oficial de WhatsApp**: vincula el WhatsApp del propio
negocio como un dispositivo más, igual que WhatsApp Web.

**WhatsApp puede restringir o bloquear un número** si detecta comportamiento de
spam: envíos masivos, mensajes a quien no dio su consentimiento, o muchos
destinatarios que reportan o bloquean. Nadie —ni nosotros, ni ningún servicio
parecido— puede garantizar que no pase: el número es del negocio y la decisión
es de WhatsApp.

Está pensado para mensajería **transaccional**: comprobantes, confirmaciones de
pedido, avisos de envío, respuestas a consultas. Mensajes que el destinatario
espera de un negocio con el que ya tiene relación.

Lo que el servicio hace para reducir el riesgo, y que conviene no pelear desde
la integración:

- Los envíos salen desde la conexión del propio negocio, no desde un servidor
  nuestro.
- El agente los espacia entre 1 y 5 segundos al azar y simula el tipeo. Por eso
  una ráfaga tarda: **no la aceleres reintentando en paralelo**, es el
  comportamiento que evita que el número parezca un bot.

Lo que las condiciones de uso prohíben, y por lo que se puede suspender una
cuenta: campañas en frío, listas compradas y promociones a quien no las pidió.
Detalle completo en `/politicas#uso`.

## 13. Trampas — la lista corta

Si tu agente de codificación solo lee una sección, que sea ésta.

1. `POST /v1/messages`: **201** si es nuevo, **200** con `"duplicate":true` si es
   reenvío.
2. `POST /v1/clients`: **201** si es alta nueva, **200** si reactiva o si ya
   existía activo.
3. `POST /v1/pair` devuelve **200**, no 201, aunque crea un código nuevo.
4. `POST /v1/pair/claim` con código vencido: **410 Gone**, no 400 ni 404.
5. El campo es `text` (inglés) al enviar, pero `texto` (español) al recibir del
   inbox.
6. `idempotency_key` y `client_id` son campos **distintos** del mismo body.
7. Un negocio ajeno siempre da **404**, nunca 403.
8. `agente_offline` es a la vez un valor de `estado` y el string de un error
   `503`. Es el mismo texto reusado, no dos cosas distintas.
9. `GET /v1/tenants/{id}` **no existe**: solo la lista y `/v1/tenants/me`.
10. `limit`, `after` y `wait` inválidos o excedidos **no dan error**: se ajustan
    en silencio. No esperes un 400 para detectar que te pasaste.
11. Rotar una clave **no revoca la anterior**. Para cortar el acceso hay que dar
    de baja.
12. Un envío que ya salió `sent` no se reintenta nunca, aunque repitas la
    llamada con la misma clave.
13. El adjunto lo descarga **el agente**, desde la PC del negocio: la URL tiene
    que ser pública y `https`, no basta con que la alcance tu servidor.
14. `document` y `media` nunca van juntos en el mismo mensaje.
15. Con adjunto, `text` es el pie del archivo y puede ir vacío; sin adjunto,
    sigue siendo obligatorio.