# whatzam — guia de integração (para pessoas e assistentes de IA)

Este documento é autocontido: cole-o no contexto da sua IA de desenvolvimento ou
leia-o inteiro antes de integrar. Ele descreve **tudo que o whatzam faz e como**.

## O que é

whatzam é um gateway de WhatsApp (sobre whatsmeow) que roda na sua rede interna.
Ele mantém **uma instância por cliente do seu SaaS** (um número de WhatsApp cada)
e oferece uma API HTTP para:

1. criar instâncias e parear o número (QR ou código);
2. acompanhar o status da conexão;
3. **enviar mensagens de texto e anexos** (imagem, vídeo, áudio, documento) para
   contatos individuais (sem grupos);
4. receber, por **webhook assinado**, confirmações de envio/entrega/leitura e
   mudanças de status.

Ele **não** recebe mensagens dos clientes, não tem chatbot e não agenda envios:
quem decide *quem*, *quando* e *o quê* é o seu sistema. O whatzam garante que o
envio saia em ordem, com intervalo entre mensagens, e que nada se perca em restart.

Base URL: a do serviço na rede interna, ex. `http://whatzam:8080`.

## Autenticação

Toda rota `/v1` exige `Authorization: Bearer <token>`.

| Token | Onde vem | Pode |
|---|---|---|
| **admin** | variável `WA_ADMIN_TOKEN` do servidor | tudo (é o do painel de gestão) |
| **de provisão** | variável `WA_PROVISION_TOKENS` (um por sistema integrador) | **só** `POST /v1/instances`; recebe o token da instância criada |
| **de instância** (`wai_…`) | resposta do `POST /v1/instances` (mostrado uma vez) | tudo **da própria instância**: conectar, QR, enviar, status, contadores, webhook, novo token, desconectar, excluir |

**Modelo recomendado para integrar** (menor privilégio):

1. O operador do whatzam configura um token de provisão para o seu sistema
   (`WA_PROVISION_TOKENS=django:…`). Esse token não alcança nenhuma instância.
2. Ao cadastrar um cliente, seu sistema chama `POST /v1/instances` com o token de
   provisão e **guarda o `id` e o `token` da instância** devolvidos.
3. Daí em diante, tudo daquele cliente é feito com o token da instância. Cada
   cliente tem o seu; um token vazado só expõe aquela instância, e
   `POST /v1/instances/{id}/token` (com o próprio token) invalida-o e gera outro.
4. O token de admin fica só no painel de gestão.

Se o token for de instância (ou de provisão) e o `{id}` for de outra instância
ou não existir, a resposta é `401` (não revela IDs).

## Convenções

- Requisições JSON: `Content-Type: application/json`, corpo ≤ 64 KB, **campos
  desconhecidos são rejeitados** (`400 invalid_json`).
- Erros: `{"error": {"code": "snake_case", "message": "texto"}}`.
- Telefones: **só dígitos, com DDI, sem `+`**, 8–15 dígitos, sem zero inicial.
  Ex.: `5569999999999`. Formatação é rejeitada com `400 invalid_phone`.
- Datas: RFC 3339 em UTC.
- Enquanto o servidor inicia, `/v1` responde `503 not_ready` com `Retry-After`.
  **Trate `503` e `429` como "tente de novo depois de `Retry-After`"**, nunca
  como falha definitiva da mensagem.
- Rate limit: por instância (padrão 5 req/s, burst 20) e global (300 req/s,
  burst 600) → `429 rate_limited`.

## Instâncias

### Criar (admin)

```http
POST /v1/instances
{"tenant_id": "clinica-42", "webhook_url": "http://django:8000/wa/webhook", "webhook_secret": "<32-256 chars>"}
```
`201`:
```json
{"id":"0199…","tenant_id":"clinica-42","status":"disconnected","connected_at":null,"queued":0,
 "created_at":"…","token":"wai_…"}
```
Guarde `id`, `token` e o `webhook_secret` (você escolhe o secret; a API nunca o devolve).

- `tenant_id`: `[A-Za-z0-9._:-]{1,128}`, seu identificador do cliente.
- `webhook_url`: http(s) absoluto, sem usuário/senha nem `#fragmento`.
- `webhook_secret`: 32–256 caracteres; usado no HMAC dos webhooks.

### Listar (admin) — `GET /v1/instances?tenant_id=` → `{"instances":[…]}`

### Status — `GET /v1/instances/{id}`

```json
{"id":"…","tenant_id":"…","status":"connected","reason":"","jid":"5569999999999:12@s.whatsapp.net",
 "connected_at":"…","queued":0,"created_at":"…","created_by":"provision:django","webhook_url":"http://django:8000/wa/webhook",
 "pair_code":"ABCD-EFGH","pair_code_expires_at":"…"}
```
`status`:

| status | significado |
|---|---|
| `disconnected` | sem conexão (nunca pareada, desconectada manualmente ou caiu); `reason` explica |
| `pairing` | aguardando QR/código no celular |
| `connecting` | conectando/reconectando (o whatsmeow reconecta sozinho em queda de rede) |
| `connected` | pronta para enviar |
| `logged_out` | o aparelho foi desvinculado no celular; precisa parear de novo |

`reason` (quando houver): `manual`, `reconnecting`, `logged_out`, `device_missing`,
`stream_replaced`, `temporary_ban`, `connect_failure`, `connect_failed`,
`client_outdated`, `pairing_timeout`, `pairing_error`, `pair_phone_failed`,
`passkey_unsupported`.

`created_by` diz qual token criou a instância (`admin` ou `provision:<nome>`);
o painel mostra isso na lista e permite filtrar. `pair_code`/`pair_code_expires_at`
só existem durante pareamento por telefone.

### Alterar webhook — `PATCH /v1/instances/{id}` com `webhook_url` e/ou `webhook_secret` (token da instância ou admin).

### Novo token — `POST /v1/instances/{id}/token` → `{"token":"wai_…"}` (o anterior morre; token da instância ou admin).

### Excluir — `DELETE /v1/instances/{id}` → `200`

```json
{"deleted": true, "whatsapp_logged_out": true}
```
Desloga no WhatsApp (o celular remove o aparelho de *Aparelhos conectados*),
apaga device, mensagens, token e registro. Se a instância estiver desconectada,
o servidor conecta brevemente só para deslogar. Quando o WhatsApp não confirma o
logout (sem rede, número banido…), a instância é excluída mesmo assim e a
resposta traz `"whatsapp_logged_out": false` com um `note`: nesse caso o
aparelho continua listado no celular até o usuário removê-lo por lá.

## Pareamento e conexão

### `POST /v1/instances/{id}/connect`

| Situação | Corpo | Resposta |
|---|---|---|
| já pareada | nenhum | `{"status":"connecting"}` → vira `connected` |
| não pareada, **QR** | nenhum | `{"status":"pairing","qr":{"code":"…","png_base64":"…","expires_at":"…"}}` |
| não pareada, **código** | `{"phone":"5569999999999"}` (número do celular a conectar) | `{"status":"pairing","pair_code":"ABCD-EFGH"}` |

- O QR rotaciona a cada ~20 s: renove com `GET /v1/instances/{id}/qr` ou use o
  evento `qr` do webhook. Renderize `code` você mesmo ou use o `png_base64`.
- O WhatsApp fecha o socket de pareamento ~160 s depois; o whatzam **reinicia
  sozinho até 4 vezes** (~13 min). Um QR/código novo aparece (`GET /qr`,
  `pair_code` no status, eventos `qr`/`pair_code`). Depois disso:
  `disconnected` com `reason=pairing_timeout` → chame `connect` de novo.
- Quando o celular conclui: evento `pair_success`, o WhatsApp reconecta a
  sessão e chega `status=connected`. **Só envie após `connected`** (antes: `409 not_paired`).
- `connect` **durante** um pareamento: sem corpo, responde `200` com `status=pairing`
  e o QR atual (útil para renovar a tela); com `phone`, responde
  `409 pairing_in_progress` — faça `disconnect` para recomeçar por código.
- `connect` com `phone` numa instância **já pareada**: `409 already_paired`.
- `502 pairing_failed`: o WhatsApp não iniciou o pareamento ou recusou o número.

### `POST /v1/instances/{id}/disconnect`

Fecha a conexão **sem deslogar**: o pareamento fica e **o celular continua
mostrando o aparelho como vinculado** (igual a fechar a aba do WhatsApp Web). A
instância não reconecta no boot até um novo `connect`. Durante um pareamento,
cancela-o. Para desvincular de verdade, use `DELETE`.

## Mensagens

### Enviar — `POST /v1/instances/{id}/messages/text`

```json
{"to": "5569999999999", "text": "Sua consulta é amanhã às 9h."}
```
`202`:
```json
{"message_id": "3EB0…", "queued_at": "…"}
```
- `text`: UTF-8, não vazio, ≤ 4096 caracteres.
- `202` significa **na fila**, não enviado. Acompanhe por webhook ou por
  `GET /v1/instances/{id}/messages/{message_id}`.
- `409 not_paired`: instância nunca pareada ou deslogada. Instância pareada mas
  desconectada **aceita** a mensagem: ela sai quando reconectar (ou expira após
  24 h → `failed/expired`).
- `429 queue_full`: a fila da instância (padrão 1000) está cheia; respeite `Retry-After`.
- `message_id` é único por instância.

### Enviar anexo — `POST /v1/instances/{id}/messages/media`

`multipart/form-data` (não JSON):

| campo | | |
|---|---|---|
| `file` | obrigatório | o arquivo, com `filename` e `Content-Type` na parte |
| `to` | obrigatório | destinatário, só dígitos |
| `caption` | opcional | legenda (≤ 1024 caracteres); áudio não aceita |
| `type` | opcional | `image`, `video`, `audio` ou `document`; inferido do `Content-Type` quando ausente |

```sh
curl -X POST $WA/v1/instances/$ID/messages/media -H "Authorization: Bearer $TOKEN" \
  -F to=5569999999999 -F caption="Seu exame" -F file=@exame.pdf
```
`202 {"message_id": "…", "queued_at": "…"}` — depois segue o mesmo fluxo do texto
(webhooks `message.sent/failed/ack`, `GET …/messages/{id}` com `kind`).

Regras:
- Tipos renderizados inline: imagem `image/jpeg|png|webp`, vídeo `video/mp4|3gpp`,
  áudio `audio/ogg|mpeg|mp4|aac|amr|wav`. **Qualquer outro tipo vai como
  documento** (o destinatário recebe um arquivo) e precisa de `filename`.
  `Content-Type` ausente ou `application/octet-stream` é detectado pelo conteúdo.
- Tamanho máximo: `WA_MEDIA_MAX_BYTES` (padrão 16 MB) → `413 media_too_large`.
- **O arquivo é enviado ao WhatsApp no ato** (criptografado, para o CDN deles) e
  só a referência entra na fila; por isso a instância precisa estar
  `connected` — caso contrário `409 not_connected` (diferente do texto, que
  aceita desconectada). Se o upload falhar: `502 upload_failed`, repita.
- O whatzam nunca guarda o arquivo: nem em disco, nem no banco.
- `400 invalid_media` explica o problema (tipo não aceito, documento sem nome,
  legenda em áudio, legenda longa); `415` se o corpo não for multipart.

Python (requests):
```python
requests.post(f"{WA}/v1/instances/{ID}/messages/media",
              headers={"Authorization": f"Bearer {TOKEN}"},
              data={"to": "5569999999999", "caption": "Seu exame"},
              files={"file": ("exame.pdf", open("exame.pdf", "rb"), "application/pdf")}, timeout=90)
```

### Status — `GET /v1/instances/{id}/messages/{message_id}`

```json
{"message_id":"…","kind":"text","to":"5569999999999","status":"delivered","error":"",
 "queued_at":"…","sent_at":"…","delivered_at":"…","read_at":null}
```
`status`: `queued` → `sending` → `sent` → `delivered` → `read`, ou `failed` com `error`:

| `error` | significado | reenviar? |
|---|---|---|
| `expired` | ficou > 24 h na fila (instância desconectada) | sim, se ainda fizer sentido |
| `not_on_whatsapp` | o número não tem WhatsApp | não |
| `resolve_failed` | não conseguiu consultar o número (3 tentativas) | sim |
| `send_failed` | o WhatsApp recusou/erro no envio | avaliar |
| `timeout` | o servidor não confirmou a tempo; **pode ter chegado** | não imediatamente; um recibo posterior corrige para `delivered` |
| `logged_out` | o número foi desvinculado | não, até parear de novo |
| `interrupted` | o processo reiniciou no meio do envio; **pode ter chegado** | idem `timeout` |
| `invalid_media` | a referência do anexo não pôde ser reconstruída (não deve ocorrer) | reenviar o anexo |

Registros finalizados são apagados após 7 dias (`WA_MESSAGE_RETENTION`).

### Contadores — `GET /v1/instances/{id}/stats`

```json
{"sent_total":1240,"delivered_total":1198,"read_total":902,"failed_total":12,
 "last_sent_at":"…","queued":3,"sending":1,
 "last_24h":{"queued":3,"sent":4,"delivered":60,"read":150,"failed":1}}
```
Os `*_total` são duráveis desde a criação da instância (não dependem da retenção
do outbox); `last_24h` conta as mensagens criadas nas últimas 24 h pelo status
atual. Útil para painéis e para conferir volume por cliente.

### Como o envio funciona (o que esperar)

- Um worker **por instância**, sequencial, com intervalo aleatório entre
  mensagens (padrão 1–3 s; 0,3–0,8 s para o mesmo destinatário). Instâncias
  diferentes enviam em paralelo.
- O destinatário é resolvido via `IsOnWhatsApp` (com cache de 24 h): isso corrige
  o **9º dígito** de contas brasileiras antigas — mande o número como o cliente
  informa.
- **At-most-once**: nada que possa ter chegado ao WhatsApp é reenviado. Se você
  precisa de "pelo menos uma vez", reenvie do seu lado só nos casos marcados
  "sim" na tabela acima.

## Webhooks

`POST` JSON para o `webhook_url` da instância:

```json
{"id":"5f0c…","type":"message.ack","instance_id":"0199…","tenant_id":"clinica-42",
 "created_at":"2026-09-14T23:19:40Z","data":{…}}
```

Headers: `X-Signature: sha256=<hex>`, `X-Timestamp: <unix>`, `X-Event-Id`,
`X-Event-Type`, `X-Instance-Id` (use-o para buscar o secret antes de ler o corpo).

| `type` | `data` |
|---|---|
| `status` | `{status, reason, jid}` a cada mudança |
| `qr` | `{code, expires_at}` — novo QR (~20 s) |
| `pair_code` | `{code, expires_at}` — código inicial e renovações |
| `pair_success` | `{jid, business_name, platform}` |
| `temporary_ban` | `{code, reason, expire_seconds}` — pare de enfileirar; `connect` após o prazo |
| `message.sent` | `{message_id, kind, to, chat, sent_at}` — o servidor do WhatsApp aceitou |
| `message.failed` | `{message_id, kind, to, reason}` (`to`/`kind` ausentes em `interrupted`) |
| `message.ack` | `{message_ids:[…], status:"delivered"\|"read", at, chat}` |

Regras de entrega:
- Responda **2xx rápido** (enfileire e processe depois). `408`, `429`, `5xx` e
  erros de rede são reenviados com backoff (até 5 tentativas); outros `4xx` e
  `3xx` não (redirects não são seguidos).
- Use `id` para **deduplicar**: retries podem repetir.
- A fila de webhooks é em memória: eventos pendentes se perdem se o whatzam
  reiniciar. O estado autoritativo é `GET …/messages/{message_id}`.

### Validar a assinatura

`hex(HMAC-SHA256(webhook_secret, "<X-Timestamp>.<corpo bruto>"))`. Rejeite
timestamps com mais de 5 min de diferença; compare em tempo constante.

```python
import hashlib, hmac, json, time
from django.http import HttpResponse, HttpResponseForbidden
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST

@csrf_exempt
@require_POST
def wa_webhook(request):
    ts = request.headers.get("X-Timestamp", "")
    sig = request.headers.get("X-Signature", "")
    instance = WhatsAppInstance.objects.filter(wa_instance_id=request.headers.get("X-Instance-Id", "")).first()
    if instance is None or not ts.isdigit() or abs(time.time() - int(ts)) > 300 or not sig.startswith("sha256="):
        return HttpResponseForbidden()
    expected = hmac.new(instance.webhook_secret.encode(), ts.encode() + b"." + request.body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(sig.removeprefix("sha256="), expected):
        return HttpResponseForbidden()
    event = json.loads(request.body)
    handle_event.delay(event)          # dedupe por event["id"] no worker
    return HttpResponse(status=204)
```

Vetor de teste: secret `secret`, timestamp `1700000000`, corpo `{"id":"x"}` →
`2f7852138f9dbd8d61c07c2cfb0b8ac96a46a32d78d4527788fb42fcb409a493`.

## Fluxo recomendado no seu sistema

1. **Onboarding do cliente**: `POST /v1/instances` → guarde `id`, `token`,
   `webhook_secret`. Chame `connect` (QR ou código) e mostre o QR/código ao
   cliente; renove com `GET /qr`/`pair_code` até `status=connected`.
2. **Envio**: só quando `status=connected`. `POST …/messages/text` por mensagem;
   trate `202` como "aceita", `429/503` como "tentar depois de `Retry-After`",
   `409 not_paired` como "cliente precisa parear".
3. **Confirmação**: processe `message.sent`/`message.failed`/`message.ack`
   deduplicando por `id`; para reconciliar, consulte `GET …/messages/{id}`.
4. **Saúde da conexão**: reaja a `status` (`logged_out` → pedir novo
   pareamento; `temporary_ban` → pausar e reconectar depois).
5. **Restart do whatzam**: nada a fazer; sessões e fila voltam sozinhas. Só
   trate os `503` de boot e os `message.failed reason=interrupted`.

## Códigos de erro (lista completa)

Formato: `{"error": {"code": "...", "message": "..."}}`. A mensagem é para
humanos e pode mudar; programe pelo `code`. O idioma da mensagem vem de
`WA_LANG` no servidor (`en` padrão ou `pt-BR`) e pode ser escolhido por
requisição com `Accept-Language: pt-BR` (ou `en`).

| HTTP | `code` | Quando | O que fazer |
|---|---|---|---|
| 400 | `invalid_json` | JSON malformado, campo desconhecido, tipo errado, mais de um objeto | corrigir a requisição |
| 400 | `invalid_tenant_id` | fora de `[A-Za-z0-9._:-]{1,128}` | corrigir |
| 400 | `invalid_webhook_url` | não é http(s) absoluto, ou tem credenciais/fragmento | corrigir |
| 400 | `invalid_webhook_secret` | fora de 32–256 caracteres | corrigir |
| 400 | `invalid_phone` | `to`/`phone` fora de 8–15 dígitos, com `+` ou zero inicial | normalizar o número |
| 400 | `invalid_text` | vazio, > 4096 caracteres, UTF-8 inválido ou NUL | corrigir |
| 400 | `invalid_instance_id` | `{id}` não é UUID (só com token admin; com token de instância é `401`) | corrigir |
| 400 | `invalid_message_id` | `{message_id}` fora de `[A-Za-z0-9]{1,64}` | corrigir |
| 400 | `empty_update` | `PATCH` sem `webhook_url` nem `webhook_secret` | enviar ao menos um |
| 401 | `unauthorized` | token ausente/inválido, ou token de instância usado em outra instância ou inexistente | conferir token e `{id}` |
| 404 | `instance_not_found` | `{id}` não existe (só com token admin) | conferir `{id}` |
| 404 | `not_found` | mensagem (`{message_id}`) não existe ou já foi apagada pela retenção | tratar como desconhecida |
| 409 | `not_paired` | envio para instância nunca pareada ou deslogada | parear (`connect`) |
| 409 | `not_connected` | anexo com a instância desconectada (o upload é imediato) | `connect`, esperar `connected` |
| 400 | `invalid_media` | tipo não aceito, documento sem nome, legenda em áudio/longa, sem `file` | corrigir |
| 400 | `invalid_multipart` | corpo multipart malformado, campo longo, dois arquivos | corrigir |
| 413 | `media_too_large` | arquivo acima de `WA_MEDIA_MAX_BYTES` | reduzir |
| 502 | `upload_failed` | o WhatsApp não aceitou o upload | repetir |
| 409 | `already_paired` | `connect` com `phone` em instância já pareada | chamar `connect` sem corpo |
| 409 | `pairing_in_progress` | `connect` com `phone` durante um pareamento | `disconnect` e repetir |
| 409 | `no_qr` | `GET /qr` fora de pareamento por QR | `connect` sem corpo |
| 409 | `conflict` | violação de unicidade (ex.: o mesmo número já está pareado em outra instância) | investigar |
| 413 | `body_too_large` | corpo > 64 KB | reduzir |
| 415 | `unsupported_media_type` | `Content-Type` diferente de `application/json` | corrigir o header |
| 429 | `rate_limited` | limite por instância ou global | esperar `Retry-After` e repetir |
| 429 | `queue_full` | fila da instância cheia | esperar `Retry-After` e repetir |
| 500 | `internal_error` | erro inesperado (fica no log do servidor com `request_id`) | repetir depois; reportar |
| 502 | `pairing_failed` | o WhatsApp não iniciou o pareamento / recusou o número | repetir; conferir o número |
| 503 | `not_ready` | servidor iniciando | esperar `Retry-After` e repetir |
| 504 | `timeout` | a operação estourou o tempo (ex.: logout no `DELETE`) | repetir |

Regra prática: `429`/`503`/`504`/`500` são **transitórios** (repita com espera);
`400`/`401`/`404`/`409`/`413`/`415` são **definitivos** para aquela
requisição (corrija antes de repetir).

## Referência rápida de rotas

| Método | Rota | Token |
|---|---|---|
| `POST` | `/v1/instances` | provisão/admin |
| `GET` | `/v1/instances?tenant_id=` | admin |
| `GET` | `/v1/instances/{id}` | instância/admin |
| `PATCH` | `/v1/instances/{id}` | instância/admin |
| `DELETE` | `/v1/instances/{id}` | instância/admin |
| `POST` | `/v1/instances/{id}/token` | instância/admin |
| `POST` | `/v1/instances/{id}/connect` | instância/admin |
| `GET` | `/v1/instances/{id}/qr` | instância/admin |
| `POST` | `/v1/instances/{id}/disconnect` | instância/admin |
| `POST` | `/v1/instances/{id}/messages/text` | instância/admin |
| `POST` | `/v1/instances/{id}/messages/media` | instância/admin |
| `GET` | `/v1/instances/{id}/messages/{message_id}` | instância/admin |
| `GET` | `/v1/instances/{id}/stats` | instância/admin |

Swagger interativo em `/docs`; spec em `/ui/openapi.yaml`. Página de operação
(criar, parear, enviar teste) em `/`.

Licença: AGPL-3.0.
