# whatzam full — 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 full faz e
como**.

Este é o **whatzam full**: o gateway light (conectar, enviar texto, anexo e
enquete, receber mensagens) mais um conjunto de **funcionalidades opcionais**
(grupos, canais, status, reações, edição, presença, contatos, perfil,
chamadas…). Cada funcionalidade é ligada **por instância** e todas nascem
**desligadas**: com nenhuma ligada, a instância aceita as mesmas requisições,
emite os mesmos eventos e faz o mesmo trabalho do whatzam light. Quem integrou
com o light não precisa mudar nada. Veja "Funcionalidades".

## 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, anexos** (imagem, vídeo, áudio, documento) **e
   enquetes** (pergunta com opções para a pessoa tocar, ex. "Sim"/"Não") para
   contatos individuais (grupos, canais e status só com a funcionalidade
   correspondente ligada);
4. receber, por **webhook assinado**, confirmações de envio/entrega/leitura e
   mudanças de status;
5. opcionalmente, **receber as mensagens** que os contatos mandam para o número
   (desligado por padrão; cada instância escolhe os tipos — veja
   "Receber mensagens"), inclusive a **resposta de uma enquete** que você enviou;
6. opcionalmente, tudo o que está atrás de uma **funcionalidade**: mensagens
   ricas (localização, contato, figurinha, reação, citação, menção), editar e
   apagar mensagens, marcar como lida, "digitando…", presença, contatos e
   fotos de perfil, grupos e comunidades, canais, status, perfil e privacidade
   da conta, organização das conversas e chamadas.

Ele **não** é caixa de entrada nem histórico (uma mensagem recebida fica guardada
só até o seu webhook confirmar o recebimento), 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.
O que fica de fora de propósito está em "O que não está incluído".

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, funcionalidades (ligar, desligar e usar as rotas delas), 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`.
- Com funcionalidades ligadas, uma conversa também pode ser um grupo, um canal
  ou a lista de status, e uma pessoa pode ser um LID: veja "Funcionalidades" →
  "Como endereçar conversas e pessoas".

## Funcionalidades

Tudo o que o whatzam light não faz está atrás de uma **funcionalidade**
(`feature`). Regras:

- São **por instância** e nascem **desligadas**. Com a lista vazia a instância
  é o gateway light: mesmas rotas, mesmos eventos, mesmo trabalho.
- Liga-se e desliga-se no painel (Detalhes da instância → aba
  **Funcionalidades**, um interruptor por funcionalidade; também em "Nova
  instância") ou pela API:
  ```http
  PATCH /v1/instances/{id}
  {"features": ["groups", "rich_messages"]}
  ```
  `200` devolve a instância com o `features` atual. **A lista substitui o
  conjunto inteiro**: mande sempre todas as que devem ficar ligadas.
  `{"features": []}` desliga tudo (volta ao light). Vale na hora, para a
  próxima requisição e o próximo evento, com a instância conectada ou não.
  Também pode vir no `POST /v1/instances` (campo `features`).
- Nome desconhecido, repetido ou não permitido pelo operador →
  `400 invalid_features`.
- O operador do servidor limita o que as instâncias podem ligar com a variável
  `WA_FEATURES`: `all` (padrão), `none` ou uma lista separada por vírgula
  (ex. `WA_FEATURES=groups,rich_messages,contacts`).
- A rota de uma funcionalidade desligada responde **`403 feature_disabled`**
  (a mensagem diz qual). O mesmo vale para um campo opcional de uma rota light
  que dependa de funcionalidade (ex. `reply_to` em `…/messages/text` sem
  `rich_messages`) e para um destinatário que dependa dela (ex. `to` com
  `…@g.us` sem `groups`).
- Desligar uma funcionalidade não apaga nada: só fecha as rotas e para os
  eventos dela.

### Consultar — `GET /v1/instances/{id}/features`

```sh
curl -sS $WA/v1/instances/$ID/features -H "Authorization: Bearer $TOKEN"
```
```json
{"enabled": ["rich_messages", "groups"],
 "allowed": ["rich_messages", "message_actions", "presence", "contacts", "groups", "newsletters",
             "status", "profile", "chats", "calls", "own_messages"],
 "available": ["rich_messages", "message_actions", "presence", "contacts", "groups", "newsletters",
               "status", "profile", "chats", "calls", "own_messages"]}
```
`enabled` é o que esta instância ligou; `allowed` o que o operador permite
ligar (`WA_FEATURES`); `available` tudo o que esta versão do whatzam tem. Esta
rota funciona sempre, mesmo sem nenhuma funcionalidade ligada. O mesmo
`enabled` vem no campo `features` de `GET /v1/instances/{id}`.

### As 11 funcionalidades

| `feature` | Rotas que libera | Eventos e efeitos que libera |
|---|---|---|
| `rich_messages` | `POST …/messages/location`, `…/messages/contact`, `…/messages/reaction`; em `…/messages/text`: `reply_to` e `mentions`; em `…/messages/media`: `type=sticker`, `view_once`, `mentions`, `reply_to`, `reply_from_me`, `reply_participant` | — |
| `message_actions` | `POST …/messages/edit`, `…/messages/revoke`, `…/chats/read`, `…/chats/typing` | — |
| `presence` | `POST …/presence`, `…/presence/subscribe` | `presence`, `chat_presence` |
| `contacts` | `POST …/contacts/check`; `GET …/contacts`, `…/contacts/{user}`, `…/contacts/{user}/picture`, `…/contacts/{user}/business` | `picture` (de pessoas), `contact.name`, `contact.about` |
| `groups` | tudo em `…/groups…` (listar, criar, alterar, foto, participantes, pedidos de entrada, convite, entrar, sair, comunidades); enviar para `<id>@g.us` | `group.update`, `group.joined`, `picture` (de grupos); mensagens de grupos em `message.received`; `message.ack` de mensagens enviadas a grupos; `chat_presence` de grupos (com `presence`) |
| `newsletters` | tudo em `…/newsletters…` (listar, criar, consultar, seguir, silenciar); enviar **texto** para `<id>@newsletter` | `newsletter.join`, `newsletter.leave`, `newsletter.mute`; publicações dos canais em `message.received` |
| `status` | `GET …/status/privacy`; enviar texto e anexo para `status@broadcast` | status dos contatos em `message.received` |
| `profile` | `GET`/`PATCH …/profile`, `PUT`/`DELETE …/profile/picture`, `GET`/`PATCH …/privacy`, `GET`/`POST …/blocklist` | `blocklist.update` |
| `chats` | `POST …/chats/state`, `…/chats/star`, `…/chats/disappearing` | `chat.update`, `message.star` |
| `calls` | `POST …/calls/reject` | `call.offer`, `call.accept`, `call.terminate` |
| `own_messages` | — | em `message.received`, também o que a própria conta envia pelo celular ou por outro aparelho vinculado (`from_me: true`) |

Mensagens de grupos, status, canais e da própria conta só chegam ao webhook com
a funcionalidade **e** com o recebimento ligado para aquele tipo (`receive`,
veja "Receber mensagens"): a funcionalidade abre a porta, o `receive` continua
dizendo quais tipos passam.

### Dois tipos de requisição: envio na fila e ação síncrona

**Envios na fila** — tudo o que vira uma mensagem numa conversa:
`…/messages/text`, `…/messages/media`, `…/messages/poll`,
`…/messages/location`, `…/messages/contact`, `…/messages/reaction`,
`…/messages/edit` e `…/messages/revoke`.

- Respondem `202 {"message_id": "…", "queued_at": "…"}`: **na fila**, não
  enviado.
- Usam a **mesma fila** do texto: um worker por instância, mesmo intervalo
  entre mensagens, mesmo at-most-once, mesmos erros (`409 not_paired`,
  `429 queue_full`…). Instância pareada mas desconectada **aceita** o envio,
  que sai quando reconectar (a exceção continua sendo o anexo, que exige
  `connected`).
- O resultado chega pelos mesmos eventos `message.sent` / `message.failed` /
  `message.ack` e por `GET …/messages/{message_id}`, com o `kind` do envio
  (`text`, `image`, `video`, `audio`, `document`, `poll`, `sticker`,
  `location`, `contact`, `reaction`, `edit`, `revoke`).

**Ações síncronas** — todo o resto das funcionalidades (marcar como lida,
"digitando…", presença, contatos, grupos, canais, perfil, privacidade,
conversas, chamadas):

- São respondidas pelo WhatsApp **no ato**; a resposta HTTP já é o resultado
  (`200` com o JSON descrito em cada rota; as que não devolvem nada respondem
  `{"ok": true}`).
- **Não passam pela fila**, então a instância precisa estar `connected`: caso
  contrário **`409 not_connected`** (nada fica guardado para depois).
- Cada uma é limitada por `WA_ACTION_TIMEOUT` (padrão `15s`, de `1s` a `1m`);
  estourou → `504 timeout`.
- As falhas do lado do WhatsApp têm códigos próprios: `404 whatsapp_not_found`,
  `403 whatsapp_forbidden`, `422 whatsapp_rejected`,
  `429 whatsapp_rate_limited` (com `Retry-After: 60`) e `502 whatsapp_failed`.
  Argumento ausente ou inválido → `400 invalid_request` (a mensagem diz o
  campo). Veja "Códigos de erro".
- Não geram `message.sent` nem ficam em `GET …/messages/{id}`.
- Contam no rate limit da instância como qualquer requisição.

### Como endereçar conversas e pessoas

**Conversa** — campos `to` (envios) e `chat` (ações):

| Formato | O que é | Precisa de |
|---|---|---|
| `5569999999999` | conversa direta, pelo telefone (só dígitos, com DDI) | nada |
| `120363012345678901@g.us` | grupo (também o formato antigo `<criador>-<timestamp>@g.us`) | `groups` |
| `120363098765432101@newsletter` | canal | `newsletters` |
| `status@broadcast` | a lista de status da conta | `status` |

- Em `to`, um texto sem `@` é sempre validado como telefone
  (`400 invalid_phone`); um texto com `@` que não seja um dos JIDs acima também
  dá `400 invalid_phone`. JID válido com a funcionalidade desligada →
  `403 feature_disabled`.
- Em `chat` (ações) também valem as formas de pessoa abaixo, para conversas
  diretas (`5569999999999@s.whatsapp.net`, `123456789012345@lid`). Fora disso:
  `400 invalid_chat`.
- Os JIDs de grupo e de canal vêm das rotas de listagem (`GET …/groups`,
  `GET …/newsletters`) e dos eventos (`data.chat` de `message.received`,
  `data.group`…). Nunca monte um JID a partir de texto livre.
- Rotas com `{group}` ou `{newsletter}` no caminho só aceitam o JID daquele
  tipo (`400 invalid_chat`).

**Pessoa** — campos `user`, `participant`, `sender`, `from`, itens de
`mentions` e `participants`, e `{user}` no caminho:

| Formato | O que é |
|---|---|
| `5569999999999` | telefone, só dígitos (8–15, com DDI) |
| `5569999999999@s.whatsapp.net` | o mesmo telefone em forma de JID |
| `123456789012345@lid` | LID: o identificador que o WhatsApp usa quando esconde o telefone |

- Qualquer outra coisa → `400 invalid_user` (em alguns campos o erro vem como
  `400 invalid_request` ou `400 invalid_message` nomeando o campo).
- Nas **respostas e eventos**, uma pessoa vem como **telefone só em dígitos**
  quando o WhatsApp o revela e como `<id>@lid` quando só o LID é conhecido.
  Devolva o valor como veio: os dois formatos são aceitos na entrada.
- O mesmo vale para `chat` nos eventos: dígitos para conversa direta por
  telefone, o JID para todo o resto.

**Mensagem já existente** — citar, reagir, favoritar, apagar. O whatzam não
guarda histórico, então é você quem diz de quem é a mensagem:

| Campo | Significado |
|---|---|
| `message_id` | o ID da mensagem (`[A-Za-z0-9._-]{1,64}`): o `message_id` devolvido pelo seu envio ou o `data.message_id` de um `message.received` |
| `from_me` | `true` quando a mensagem foi enviada por esta conta. Com `from_me: true`, `participant` não é aceito |
| `participant` | quem enviou a mensagem, quando não foi esta conta. **Obrigatório em grupo** para mensagem de outra pessoa (use o `data.from` do `message.received`; se vier vazio, `data.from_lid` + `@lid`). Em conversa direta pode ser omitido: a mensagem é tomada como do outro lado |

Violou a regra → `400 invalid_message` (a mensagem explica: ID inválido,
`participant` faltando ou sobrando).

## 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,
 "inbound_pending":0,"created_at":"…","created_by":"provision:django",
 "webhook_url":"http://django:8000/wa/webhook","receive":{"enabled":false,"types":[]},
 "features":[],"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`. O
  whatzam só entrega em **endereço público**; se o seu receptor está na rede
  interna (ex.: `http://django:8000`), o operador precisa liberar a faixa em
  `WA_WEBHOOK_ALLOWED_CIDRS`. IP interno literal ou `localhost` → `400`; nome que
  resolve para IP interno não liberado → a entrega é descartada (sem retry).
  Um domínio público (inclusive atrás do Cloudflare) funciona sem configuração.
- `webhook_secret`: 32–256 caracteres; usado no HMAC dos webhooks.
- `receive` (opcional): `{"enabled": true, "types": ["text", "image"]}` já cria a
  instância recebendo mensagens. Sem ele, o recebimento fica **desligado**. Veja
  "Receber mensagens".
- `features` (opcional): lista de funcionalidades já ligadas na criação, ex.
  `["groups", "rich_messages"]`. Sem ele (ou `[]`), nenhuma: a instância é o
  gateway light. Nome desconhecido, repetido ou não permitido por
  `WA_FEATURES` → `400 invalid_features`. Veja "Funcionalidades".

### 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,"inbound_pending":0,"created_at":"…","created_by":"provision:django","webhook_url":"http://django:8000/wa/webhook",
 "receive":{"enabled":true,"types":["text","image"]},
 "features":["rich_messages","groups"],
 "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_required`, `passkey_unsupported`, `passkey_timeout`, `passkey_rejected`,
`passkey_error`, `passkey_interrupted`.

`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. `passkey` (`{stage, url, expires_at}`)
só existe enquanto o WhatsApp espera a chave de acesso do dono da conta (veja
abaixo).

`receive` vem sempre: `enabled` e a lista `types` (vazia quando nada foi escolhido).

`features` vem sempre: a lista das funcionalidades ligadas, vazia (`[]`) no modo
light (veja "Funcionalidades").

`queued` são as mensagens **a enviar** na fila. `inbound_pending` (só leitura) são
as mensagens **recebidas** que ainda esperam o seu webhook confirmar com `2xx`:
em operação normal fica em `0`; crescendo, o seu webhook está fora do ar ou
recusando os eventos (veja "Receber mensagens" → "Entrega garantida").

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

`receive`, quando presente, substitui a configuração inteira de recebimento
(veja "Receber mensagens"). `features`, quando presente, substitui o conjunto
inteiro de funcionalidades; `[]` desliga todas (veja "Funcionalidades"). Sem
nenhum dos quatro campos: `400 empty_update`. `200` devolve a instância como
ficou.

### 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.

### Contas que exigem chave de acesso (passkey)

Algumas contas só concluem o vínculo depois que o dono confirma com a chave de
acesso do WhatsApp. Com o operador tendo configurado `WA_PASSKEY_PUBLIC_URL`:

1. Depois do QR/código, a instância fica `pairing` com
   `reason=passkey_required`; chega o webhook `passkey_required`
   `{url, expires_at}` e o `GET` da instância traz
   `passkey: {"stage":"challenge","url":"https://web.whatsapp.com/#whatzam-passkey=…","expires_at":"…"}`.
   O QR para de rotacionar (`GET /qr` → `409 no_qr`).
2. Mostre `url` ao dono da conta (botão/link) junto com o link da extensão,
   `<WA_PASSKEY_PUBLIC_URL>/passkey/extension.zip`. Ele instala a extensão uma
   vez (Chrome/Edge: `chrome://extensions` → Modo do desenvolvedor → Carregar
   sem compactação) e abre `url` nesse navegador.
3. A extensão pede a chave de acesso e, se o celular mostrar um código, a
   confirmação. Depois: `pair_success` e `status=connected`.

O link vale 5 minutos. Finais sem sucesso (`status=disconnected`):
`passkey_timeout`, `passkey_rejected`, `passkey_error`, `passkey_interrupted` →
chame `connect` de novo. Sem `WA_PASSKEY_PUBLIC_URL`: `passkey_unsupported`.
Trate `url` como o QR (não logue; envie só ao dono da conta).

### `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.
- `to` é um telefone. Com a funcionalidade correspondente, também um grupo
  (`120363012345678901@g.us`, `groups`), um canal (`…@newsletter`,
  `newsletters`) ou `status@broadcast` (`status`): veja "Funcionalidades" →
  "Como endereçar conversas e pessoas".
- Com `rich_messages`, o texto aceita `reply_to` (citar uma mensagem) e
  `mentions` (mencionar pessoas): veja "Mensagens ricas". Sem a funcionalidade,
  mandar um desses campos dá `403 feature_disabled`.

### 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 (com `groups`, um grupo `…@g.us`; com `status`, `status@broadcast`; **canais não aceitam anexo**) |
| `caption` | opcional | legenda (≤ 1024 caracteres); áudio e figurinha não aceitam |
| `ptt` | opcional | `true` envia o áudio como **mensagem de voz** (microfone, com duração). Exige áudio Ogg Opus |
| `type` | opcional | `image`, `video`, `audio` ou `document`; inferido do `Content-Type` quando ausente. Com `rich_messages`, também `sticker` |
| `view_once`, `mentions`, `reply_to`, `reply_from_me`, `reply_participant` | opcionais | só com `rich_messages`: veja "Mensagens ricas" |

```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.
- Para imagem, vídeo e áudio, **o conteúdo precisa ser do tipo declarado**
  (assinatura do arquivo: JPEG, PNG, WebP, MP4/3GP/M4A, Ogg, MP3, AAC, AMR,
  WAV); um HTML chamado `.jpg` dá `400 invalid_media`. Documentos não são
  conferidos.
- Mensagem de voz (`ptt=true`): só Ogg Opus (o formato das notas de voz do
  WhatsApp; converta com `ffmpeg -i in.mp3 -c:a libopus -b:a 32k -ac 1 voz.ogg`).
  A duração é lida do arquivo. Áudio Ogg Opus sem `ptt` vai como arquivo de
  áudio, também com duração.
- Tamanho máximo: `WA_MEDIA_MAX_BYTES` (padrão 16 MB) → `413 media_too_large`.
- Tempo: o arquivo precisa chegar em `WA_MEDIA_UPLOAD_TIMEOUT` (padrão 60 s) e a
  resposta pode levar até o dobro disso + 10 s. Ajuste o timeout do seu cliente
  HTTP (o exemplo abaixo usa 150 s).
- No máximo **2 uploads simultâneos por instância** (e um limite global do
  servidor): além disso, `429 rate_limited` com `Retry-After`. Envie anexos da
  mesma instância em sequência — a fila de envio já é sequencial.
- **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.
- Imagens JPEG e PNG vão com as dimensões e uma prévia pequena (a miniatura
  borrada que aparece antes do download), gerada em memória no servidor. WebP e
  imagens acima de 16 MP vão sem prévia.
- O whatzam nunca guarda o arquivo: nem em disco, nem no banco (a prévia fica só
  na fila, até a mensagem ser enviada ou falhar).
- `400 invalid_media` explica o problema (tipo não aceito, conteúdo diferente do
  tipo, documento sem nome, legenda em áudio, legenda longa, `ptt` sem Ogg
  Opus); `415` se o corpo não for multipart.
- `to` com o JID de um canal (`…@newsletter`) → `400 invalid_message`: anexo
  para canal não é suportado.

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=150)
```

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

Uma pergunta com opções prontas: a pessoa só toca na resposta. É a **enquete
nativa do WhatsApp**, não botões (botões e listas não são suportados: em conta
comum o WhatsApp não os entrega de forma confiável). Caso típico, confirmação
de agenda:

```json
{"to": "5569999999999",
 "question": "Confirma sua consulta amanhã às 14h?",
 "options": ["Sim", "Não"],
 "multiple": false}
```
`202`, igual ao texto:
```json
{"message_id": "3EB0…", "queued_at": "…"}
```
**Guarde o `message_id`**: é por ele que você liga a resposta à enquete.

- `question`: 1–255 caracteres, não pode ser só espaços.
- `options`: de **2 a 12** opções; cada uma com 1–100 caracteres, não vazia;
  **sem repetição** (a comparação diferencia maiúsculas de minúsculas e ignora
  espaços nas pontas).
- `multiple` (opcional, padrão `false`): `false` = a pessoa escolhe **uma**
  opção; `true` = pode marcar várias.
- `question` e `options` têm os espaços das pontas removidos; precisam ser
  UTF-8 válido, sem NUL. Qualquer regra violada → `400 invalid_poll`.
- `to` é um telefone ou, com a funcionalidade `groups`, um grupo
  (`120363012345678901@g.us`). Canais e status não aceitam enquete
  (`400 invalid_message`).
- Passa pela **mesma fila** do texto: mesmo intervalo entre mensagens, mesmo
  at-most-once, mesmos erros (`400 invalid_phone`, `400 invalid_json`,
  `409 not_paired`, `429 queue_full`/`rate_limited`, `401`). Instância pareada
  mas desconectada aceita a enquete, que sai quando reconectar.
- `GET …/messages/{message_id}` e os webhooks `message.sent`/`message.failed`
  trazem `"kind": "poll"`; `message.ack` (entregue/lida) funciona como em
  qualquer mensagem.
- O que fica guardado: a **pergunta** é apagada quando a mensagem sai da fila
  (como o texto). As **opções** ficam no registro da mensagem até ele ser
  apagado por `WA_MESSAGE_RETENTION` (padrão 7 dias), porque a resposta chega
  só como hash das opções e precisa ser traduzida de volta. **Resposta que
  chega depois disso não pode ser traduzida e é descartada.**

#### Receita: confirmação de agenda

1. **Ligue o recebimento da resposta** na instância (uma vez):
   ```http
   PATCH /v1/instances/{id}
   {"receive": {"enabled": true, "types": ["poll_vote"]}}
   ```
   `receive` substitui a lista inteira: inclua também os outros tipos que você
   já recebe (ex. `["text", "poll_vote"]`). **Sem `poll_vote` na lista, a
   resposta é descartada.**
2. **Envie a enquete** (`POST …/messages/poll`) e grave o `message_id` devolvido
   junto do agendamento.
3. **No webhook**, trate `message.received` com `data.type == "poll_vote"`:
   ```json
   {"id":"…","type":"message.received","instance_id":"0199…","tenant_id":"clinica-42",
    "created_at":"…",
    "data":{"message_id":"3A1B…","from":"5569999999999","push_name":"Maria",
            "timestamp":"2026-10-06T14:10:03Z","type":"poll_vote",
            "poll":{"message_id":"3EB0…","selected":["Sim"]}}}
   ```
   - `data.poll.message_id` é o `message_id` do passo 2: ache o agendamento por ele.
   - `data.poll.selected` é a **seleção completa atual** da pessoa, com o texto
     das opções. Com `multiple: false` use `selected[0]`.
   - A pessoa pode **trocar** a resposta: chega um evento novo (outro
     `data.message_id`, mesmo `poll.message_id`) com a seleção nova. Fique com
     o de `data.timestamp` mais recente.
   - `selected` **vazio** = a pessoa desmarcou: trate como "sem resposta".
   - Responda `2xx` e deduplique por `data.message_id`, como em todo
     `message.received` (veja "Entrega garantida").
4. Quem não responde simplesmente não gera evento: decida no seu sistema o
   prazo e o lembrete.

### 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}
```
`kind`: `text`, `image`, `video`, `audio`, `document` ou `poll`; com as
funcionalidades, também `sticker`, `location`, `contact`, `reaction`, `edit` e
`revoke`. `to` é o destinatário como você mandou (telefone ou JID).

Em mensagem enviada a um **grupo**, `delivered` e `read` valem a partir do
**primeiro recibo** de qualquer participante (não significa que todos
receberam ou leram). Os recibos de grupo só são processados com `groups`
ligada.

`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},
 "received":{"total":530,"delivered_total":526,"expired_total":1,"dropped_total":0,
             "pending":3,"last_received_at":"…",
             "last_24h":{"total":42,"by_type":{"text":30,"image":6,"video":0,"audio":5,
                         "document":1,"sticker":0,"location":0,"contact":0,"reaction":0,
                         "poll_vote":0,"revoke":0,"poll":0}}}}
```
Os campos do nível de cima são do **envio**; `received` é do **recebimento**.

Envio: 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.

Recebimento (`received`), tudo durável, mesmo depois de a mensagem ser entregue
e apagada:

| Campo | Significado |
|---|---|
| `total` | mensagens recebidas que passaram no filtro e foram aceitas para entrega |
| `delivered_total` | confirmadas pelo seu webhook com `2xx` |
| `pending` | esperando o webhook agora (igual a `inbound_pending` da instância) |
| `expired_total` | desistidas depois de `WA_INBOUND_RETENTION` sem `2xx`: **perdidas** |
| `dropped_total` | descartadas na chegada porque o acúmulo atingiu `WA_INBOUND_MAX_PENDING`: **perdidas** (não entram em `total`) |
| `last_received_at` | quando a última foi recebida (`null` se nenhuma) |
| `last_24h.total` / `last_24h.by_type` | recebidas na hora atual e nas 23 anteriores, no total e por tipo (todos os tipos aparecem, com zero) |

`total` = `delivered_total` + `expired_total` + `pending`. Mensagens descartadas
pelo filtro (recebimento desligado, tipo não escolhido, grupo sem a
funcionalidade `groups`…) não são contadas
em lugar nenhum. Ú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. Grupo, canal e `status@broadcast` não são consultados: o JID é
  usado como veio.
- Todos os envios na fila das funcionalidades (localização, contato, reação,
  edição, apagar para todos, figurinha) passam por este mesmo worker.
- **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.

## Receber mensagens

**Desligado por padrão.** Uma instância só recebe se você ligar e disser quais
tipos quer; todo o resto é descartado na chegada, antes de qualquer trabalho do
whatzam. O que passa no filtro chega pelo webhook como
`message.received`, com **entrega garantida**: o evento é gravado no banco do
whatzam ao chegar e só é apagado quando o seu webhook responde `2xx`. Depois
disso nada fica guardado (não há histórico nem caixa de entrada para consultar),
os anexos nunca são guardados e o conteúdo nunca vai para o log.

### Ligar, escolher os tipos, desligar

Na criação (campo `receive` do `POST /v1/instances`) ou depois:

```http
PATCH /v1/instances/{id}
{"receive": {"enabled": true, "types": ["text", "image", "audio"]}}
```
`200` devolve a instância com o `receive` atual. Para desligar:

```http
PATCH /v1/instances/{id}
{"receive": {"enabled": false}}
```

- `receive` **substitui a configuração inteira**: mande sempre a lista completa
  de tipos. `{"enabled": false}` sem `types` desliga e limpa a lista; mandando
  `types` junto, a seleção fica guardada para quando religar.
- `enabled: true` exige ao menos um tipo. Tipo desconhecido ou repetido →
  `400 invalid_receive`.
- Vale a partir da próxima mensagem, com a instância conectada ou não.
- No painel: o interruptor "Receber mensagens" em "Nova instância", ou
  Detalhes → Ações → "Recebimento de mensagens" → Alterar.

| Tipo | O que cobre |
|---|---|
| `text` | texto simples e texto com link ou citação |
| `image` | imagens (legenda em `text`) |
| `video` | vídeos, GIFs (`media.animated`) e mensagens de vídeo (legenda em `text`) |
| `audio` | áudios e mensagens de voz (`media.ptt: true`) |
| `document` | qualquer arquivo enviado como documento (`media.file_name`; legenda em `text`) |
| `sticker` | figurinhas (`media.animated` nas animadas) |
| `location` | localização fixa e em tempo real (`location.live`) |
| `contact` | um ou vários contatos compartilhados (vCard) |
| `reaction` | reações; `emoji` vazio = reação removida |
| `poll_vote` | resposta a uma **enquete enviada por esta instância pela API** (`POST …/messages/poll`): `poll.selected` |
| `revoke` | aviso de que o remetente **apagou para todos** uma mensagem: `revoke_of` traz o ID dela |
| `poll` | enquete **criada por outra pessoa**: `poll_created` traz pergunta e opções (os votos dela não são legíveis) |

`revoke` e `poll` não dependem de funcionalidade: basta escolhê-los em
`receive.types`. O que depende de funcionalidade é **de onde** a mensagem vem:

| Origem | Chega com | Como aparece no evento |
|---|---|---|
| conversa direta, enviada por outra pessoa | só `receive` | sem `chat` nem `chat_type` |
| grupo | `groups` | `chat` = JID do grupo, `chat_type: "group"`, `from` = quem escreveu |
| status de um contato | `status` | `chat: "status@broadcast"`, `chat_type: "status"` |
| publicação de um canal seguido | `newsletters` | `chat` = JID do canal, `chat_type: "newsletter"` |
| enviada pela própria conta, do celular ou de outro aparelho | `own_messages` (mais a funcionalidade do lugar, se for grupo, status ou canal) | `from_me: true`, `chat` = a conversa |

Em todos os casos o **tipo** da mensagem precisa estar em `receive.types`: uma
instância com `groups` ligada e `receive` só com `text` recebe os textos dos
grupos e descarta as imagens.

### O evento `message.received`

```json
{"id":"7b2e…","type":"message.received","instance_id":"0199…","tenant_id":"clinica-42",
 "created_at":"2026-10-06T14:03:22Z",
 "data":{
   "message_id":"3A5F0C2E9B7D41F08A6C",
   "from":"5569999999999",
   "from_lid":"123456789012345",
   "push_name":"Maria",
   "timestamp":"2026-10-06T14:03:21Z",
   "type":"image",
   "text":"Segue o comprovante",
   "media":{"mimetype":"image/jpeg","size":183422,"sha256":"9f2c…","width":1080,"height":1920,
            "download_token":"mJ3k…"},
   "reply_to":"3EB0ABCDEF0123456789"
 }}
```

| Campo de `data` | Significado |
|---|---|
| `message_id` | ID da mensagem no WhatsApp. Use para **idempotência**. |
| `from` | telefone de quem enviou, só dígitos. Pode vir **vazio** quando o WhatsApp só expõe o LID do remetente. |
| `from_lid` | LID do remetente (identificador interno do WhatsApp), quando conhecido. Estável por contato; use-o como chave quando `from` vier vazio. |
| `push_name` | nome de exibição configurado pelo remetente (opcional) |
| `timestamp` | quando a mensagem foi enviada |
| `type` | um dos tipos da tabela acima |
| `text` | corpo da mensagem, ou a legenda do anexo (opcional) |
| `media` | em `image`, `video`, `audio`, `document`, `sticker`: `mimetype`, `size` (bytes) e, quando houver, `sha256` (hex), `file_name` (documento), `seconds` (áudio/vídeo), `width`, `height`, `ptt`, `animated`, `download_token` |
| `location` | em `location`: `latitude`, `longitude` e, quando houver, `name`, `address`, `url`, `live` |
| `contacts` | em `contact`: lista de `{name, vcard}` |
| `reaction` | em `reaction`: `{message_id, emoji}` — a mensagem que recebeu a reação e o emoji (vazio = removida) |
| `poll` | em `poll_vote`: `{message_id, selected}` — `message_id` é o da enquete enviada (o devolvido por `POST …/messages/poll`); `selected` é a lista com o texto das opções marcadas agora (vazia = resposta removida). Cada troca de resposta gera um evento novo; vale o de `timestamp` mais recente. |
| `reply_to` | ID da mensagem citada, quando é uma resposta. Se for uma mensagem que você enviou, é o `message_id` do seu envio. |
| `edit_of` | presente quando o evento é uma **edição**: ID da mensagem original, cujo conteúdo este evento substitui |
| `view_once` | `true` em mensagem de visualização única |
| `chat` | onde a mensagem foi enviada, quando isso não é só o remetente: o JID do grupo, `status@broadcast`, o JID do canal ou, em mensagem da própria conta numa conversa direta, o outro lado (telefone em dígitos; `<id>@lid` quando o WhatsApp só expõe o LID). Ausente em mensagem direta recebida de outra pessoa |
| `chat_type` | `group`, `status` ou `newsletter`. Ausente em conversa direta |
| `from_me` | `true` quando a mensagem foi enviada pela própria conta, do celular ou de outro aparelho (só com `own_messages`). Nesse caso `from` é o número da própria conta e o destinatário está em `chat` |
| `mentions` | **só em grupo**: lista das pessoas mencionadas (telefone em dígitos ou `<id>@lid`). Compare com o número da instância para saber se ela foi mencionada |
| `revoke_of` | em `revoke`: ID da mensagem apagada (o `message_id` do evento que a trouxe) |
| `poll_created` | em `poll`: `{question, options, multiple}` da enquete criada por outra pessoa |

Campos opcionais só aparecem quando têm valor. `mimetype`, `size` e `file_name`
são **declarados por quem enviou**: trate como não confiáveis.

Os campos `chat`, `chat_type`, `from_me` e `mentions` nunca aparecem numa
instância sem funcionalidades: lá o evento tem exatamente a forma do light.

Mensagem de um **grupo** (`groups` ligada, `text` em `receive.types`):

```json
{"id":"a41c…","type":"message.received","instance_id":"0199…","tenant_id":"clinica-42",
 "created_at":"2026-10-06T14:20:02Z",
 "data":{
   "message_id":"3A7C19D2E4B8F0A61B22",
   "from":"5569999999999",
   "from_lid":"123456789012345",
   "push_name":"Maria",
   "timestamp":"2026-10-06T14:20:01Z",
   "type":"text",
   "text":"@5569888888888 pode confirmar o horário?",
   "chat":"120363012345678901@g.us",
   "chat_type":"group",
   "mentions":["5569888888888"]
 }}
```
Para responder no grupo, envie para `data.chat`; para citar essa mensagem, use
`reply_to` com `message_id` = `data.message_id` e `participant` = `data.from`.

Mensagem **da própria conta**, digitada no celular para um contato
(`own_messages` ligada):

```json
{"id":"c90e…","type":"message.received","instance_id":"0199…","tenant_id":"clinica-42",
 "created_at":"2026-10-06T14:31:10Z",
 "data":{
   "message_id":"3EB0F4A1C2D3E4F5A6B7",
   "from":"5569888888888",
   "timestamp":"2026-10-06T14:31:09Z",
   "type":"text",
   "text":"Pode vir às 15h.",
   "chat":"5569999999999",
   "from_me":true
 }}
```
Aqui `from` é o número da instância e `chat` é o contato que recebeu. **Sempre
confira `from_me`** antes de tratar um evento como mensagem de cliente: com
`own_messages` ligada, um robô que responde a tudo o que chega acabaria
respondendo a si mesmo. O que a instância envia **pela API** não volta por
aqui (acompanhe por `message.sent`).

Mensagem apagada pelo remetente (`revoke` em `receive.types`):

```json
{"message_id":"3A9D55E1B0C74F2288AA","from":"5569999999999","push_name":"Maria",
 "timestamp":"2026-10-06T14:40:00Z","type":"revoke","revoke_of":"3A5F0C2E9B7D41F08A6C"}
```

Enquete criada por outra pessoa (`poll` em `receive.types`):

```json
{"message_id":"3A0B7E66D1C24A9F3C10","from":"5569999999999","push_name":"Maria",
 "timestamp":"2026-10-06T14:45:00Z","type":"poll",
 "poll_created":{"question":"Qual dia fica melhor?","options":["Segunda","Quarta"],"multiple":false}}
```

**Nunca são encaminhados**, com qualquer funcionalidade: o que a instância envia
pela API, o que a própria conta manda para uma lista de transmissão, respostas a enquetes
que não foram enviadas por esta instância pela API (ex. criadas no celular ou
por outra pessoa) ou cujo registro já foi apagado pela retenção, chamadas (veja
os eventos `call.*`) e respostas de botão/lista.

**Só com a funcionalidade ligada**: mensagens de grupos (`groups`), status dos
contatos (`status`), publicações de canais (`newsletters`) e o que a própria
conta envia de outro aparelho (`own_messages`). Sem ela, são descartados na
chegada, como no light.

**Só com o tipo escolhido** em `receive.types`: apagamentos (`revoke`) e
enquetes criadas por outras pessoas (`poll`), além dos demais tipos.

Também não há histórico: o que chega com o recebimento desligado, de um tipo
não escolhido ou de uma origem cuja funcionalidade está desligada é
**descartado de vez**. Respostas de enquete descartadas não entram nas
estatísticas.

### Entrega garantida (diferente dos outros eventos)

`message.received` é o único evento **durável**. Os demais (`status`, `qr`,
`message.sent`, `message.ack`… e todos os eventos das funcionalidades, como
`presence`, `call.offer` ou `group.update`) continuam na fila em memória
descrita em "Webhooks". As regras deste evento:

- **Só `2xx` confirma.** Qualquer outra coisa (erro de rede, timeout, `3xx`,
  `4xx`, `5xx`) é uma falha, e o evento é tentado de novo até completar
  `WA_INBOUND_RETENTION` (padrão `24h`) desde a chegada. Aí é descartado.
  Atenção: aqui `4xx` **também** é reenviado (nos outros eventos `4xx` é
  definitivo).
- **Webhook fora do ar** (nada é aceito): os eventos ficam guardados na ordem
  em que chegaram. O whatzam testa de novo com espera crescente de 1 s até
  60 s, poucos eventos por rodada, e quando o webhook volta entrega tudo **em
  ordem**, um por vez por instância.
- **Webhook no ar que recusa um evento específico** (aceita outros na mesma
  rodada): o recusado é posto de lado e tentado de novo depois de 1, 2, 4 e
  8 min, e então a cada 10 min, até ser aceito ou expirar. Os demais eventos da
  instância **seguem normalmente**, então um evento problemático não trava a
  conversa, mas, se for aceito mais tarde, chega **fora de ordem** (use
  `data.timestamp` para ordenar). Oito ou mais eventos recusados em sequência,
  sem nenhum aceito entre eles, são tratados como webhook fora do ar.
- Responda `2xx` para tudo o que você já guardou ou decidiu ignorar: duplicatas,
  tipos que não interessam, remetentes desconhecidos. Um `4xx`/`5xx` não
  descarta a mensagem: ela volta por até 24 h.
- **Pelo menos uma vez**: o mesmo evento pode chegar repetido (um restart entre
  a entrega e a baixa no banco, ou a sua resposta `2xx` que se perdeu na rede).
  As repetições têm o **mesmo corpo** e o mesmo `X-Event-Id` (o `id` do
  envelope), com `X-Timestamp` e `X-Signature` novos a cada tentativa.
  **Deduplique** por `id` do envelope ou por `data.message_id`.
- **Sobrevive a restart e queda do whatzam**: o que estava pendente é entregue
  depois do boot. `created_at` do envelope é o momento da chegada, não da
  tentativa.
- **Isolado por instância**: o webhook parado de uma instância não atrasa nem
  derruba as outras.
- **Limite de acúmulo**: cada instância guarda até `WA_INBOUND_MAX_PENDING`
  eventos esperando (padrão `10000`). Com o limite atingido, as mensagens novas
  daquela instância são descartadas até a fila andar.
- Desligar `receive` (ou tirar um tipo) **não** apaga o que já foi aceito: esses
  eventos ainda são entregues. Excluir a instância apaga os pendentes.
- Trocar `webhook_url` ou `webhook_secret` vale na hora também para os eventos
  pendentes: eles vão para a URL nova, assinados com o secret novo.
- `inbound_pending`, em `GET /v1/instances/{id}`, diz quantos eventos esperam.

Como escrever o receptor: valide a assinatura, grave o evento (ou enfileire no
seu sistema) de forma idempotente por `data.message_id`, responda `2xx` na hora
e processe depois. Baixar anexo, chamar IA ou responder ao cliente **dentro** da
requisição do webhook atrasa todas as mensagens seguintes da instância.

O que ainda pode se perder: mensagem que chegou com o recebimento desligado ou
de tipo não escolhido; evento que passou de `WA_INBOUND_RETENTION` sem `2xx`;
mensagens que chegaram com o limite de acúmulo atingido; e casos raros de falha
do próprio whatzam (queda do processo no instante exato da chegada, ou banco e
webhook fora do ar ao mesmo tempo).

Outros detalhes:
- Uma edição chega como **evento novo** (outro `message_id`) com `edit_of`.
- Depois de uma reconexão, o WhatsApp entrega o que chegou enquanto a instância
  estava fora: confira `timestamp` antes de reagir a mensagens antigas.
- O whatzam **não marca as mensagens como lidas** nem fica "online": o celular
  do dono continua notificando normalmente. Isso só muda se você pedir, com
  `POST …/chats/read` (`message_actions`) ou `POST …/presence` (`presence`).

### Baixar o anexo — `POST /v1/instances/{id}/media/download`

O evento traz só os metadados. Para o arquivo, devolva o `media.download_token`:

```bash
curl -sS -X POST http://whatzam:8080/v1/instances/$ID/media/download \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"token": "mJ3k…"}' --output comprovante.jpg
```
`200`: o arquivo, já descriptografado, sempre como `application/octet-stream`
(`Content-Disposition: attachment`). O tipo real é o `media.mimetype` do evento;
confira a integridade com `media.sha256` se quiser.

- O arquivo é buscado no WhatsApp **na hora** e não fica no whatzam; a instância
  precisa estar `connected` (`409 not_connected`).
- O WhatsApp guarda a mídia por tempo limitado: **baixe logo** ao receber o
  evento. Depois disso, `410 media_gone`.
- Acima de `WA_MEDIA_MAX_BYTES` (padrão 16 MB) → `413 media_too_large`; `size`
  no evento permite decidir antes de pedir.
- Divide com os uploads o limite de 2 transferências simultâneas por instância
  e o global (`WA_MEDIA_MAX_CONCURRENT`) → `429 rate_limited` com `Retry-After`.
- O token é opaco, só serve na instância que recebeu a mensagem, e deixa de
  valer se o operador trocar o `WA_ADMIN_TOKEN` (`400 invalid_media_token`).
- `download_token` ausente = não há arquivo para baixar (visualização única).
  A edição de uma legenda chega sem `media`: só `text` e `edit_of`.
- Falha transitória do WhatsApp → `502 download_failed` (repita); demora além
  de `WA_MEDIA_UPLOAD_TIMEOUT` → `504 timeout`.

## Rotas das funcionalidades

Uma seção por funcionalidade. Todas as rotas abaixo usam o token da instância
(ou o admin), respondem `403 feature_disabled` com a funcionalidade desligada e
seguem as regras de "Funcionalidades" (envio na fila × ação síncrona, formatos
de conversa e de pessoa). Nos exemplos, `$WA` é a base URL, `$ID` o ID da
instância e `$TOKEN` o token dela.

### Mensagens ricas — `rich_messages`

Tudo aqui é **envio na fila** (`202` com `message_id`).

#### Citar e mencionar num texto — `POST …/messages/text`

```sh
curl -sS -X POST $WA/v1/instances/$ID/messages/text \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"to": "120363012345678901@g.us",
       "text": "@5569999999999 confirmado para amanhã às 9h.",
       "reply_to": {"message_id": "3A7C19D2E4B8F0A61B22", "participant": "5569999999999"},
       "mentions": ["5569999999999"]}'
```
`202 {"message_id": "3EB0…", "queued_at": "…"}`

- `reply_to`: `{message_id, from_me, participant}`, a mensagem citada (regras
  em "Como endereçar conversas e pessoas" → "Mensagem já existente"). Citando
  algo que a instância enviou: `{"message_id": "3EB0…", "from_me": true}`.
  **Em grupo, citar mensagem de outra pessoa exige `participant`.**
- `mentions`: de 1 a 50 pessoas (telefone ou JID de usuário); fora disso
  `400 invalid_message`. Serve em qualquer conversa, mas só faz sentido em
  grupo. O whatzam **não altera o texto**: escreva você o `@5569999999999` no
  ponto em que a menção deve aparecer.
- O texto citado não é guardado nem reenviado: o WhatsApp do destinatário
  mostra a mensagem original a partir do ID.

#### Figurinha, visualização única, citação e menção num anexo — `POST …/messages/media`

Campos extras do multipart (os do light continuam valendo):

| campo | |
|---|---|
| `type=sticker` | envia o arquivo como **figurinha**. Só **WebP** (`Content-Type: image/webp`, conteúdo conferido); sem legenda |
| `view_once` | `true` = visualização única. Só para imagem, vídeo e áudio (`400 invalid_message` nos demais) |
| `mentions` | pessoas mencionadas, **separadas por vírgula** (até 50) |
| `reply_to` | ID da mensagem citada |
| `reply_from_me` | `true` quando a mensagem citada foi enviada por esta conta |
| `reply_participant` | quem enviou a mensagem citada (obrigatório em grupo para mensagem de outra pessoa) |

```sh
curl -sS -X POST $WA/v1/instances/$ID/messages/media -H "Authorization: Bearer $TOKEN" \
  -F to=5569999999999 -F type=sticker -F "file=@figurinha.webp;type=image/webp"

curl -sS -X POST $WA/v1/instances/$ID/messages/media -H "Authorization: Bearer $TOKEN" \
  -F to=120363012345678901@g.us -F view_once=true \
  -F reply_to=3A7C19D2E4B8F0A61B22 -F reply_participant=5569999999999 \
  -F "file=@foto.jpg;type=image/jpeg"
```
`202 {"message_id": "…", "queued_at": "…"}`

- `view_once` e `reply_from_me` aceitam `true`/`false` (ou `1`/`0`); outro
  valor → `400 invalid_media`.
- Uma figurinha não é inferida: sem `type=sticker`, um WebP vai como imagem.
- Continuam valendo as regras do anexo: instância `connected`
  (`409 not_connected`), tamanho, 2 uploads simultâneos.
- **Canais não aceitam anexo** por esta API: `to` com `…@newsletter` →
  `400 invalid_message`.

#### Localização — `POST …/messages/location`

```sh
curl -sS -X POST $WA/v1/instances/$ID/messages/location \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"to": "5569999999999", "latitude": -8.7612, "longitude": -63.9004,
       "name": "Clínica Exemplo", "address": "Av. Sete de Setembro, 1000 - Porto Velho"}'
```
`202 {"message_id": "…", "queued_at": "…"}`

- `latitude` (−90 a 90) e `longitude` (−180 a 180) são obrigatórios.
- `name` e `address` são opcionais, até 256 caracteres cada.
- `reply_to` (opcional): como no texto.

#### Contato — `POST …/messages/contact`

```sh
curl -sS -X POST $WA/v1/instances/$ID/messages/contact \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"to": "5569999999999",
       "contacts": [{"name": "Recepção", "phone": "5569988888888"}]}'
```
`202 {"message_id": "…", "queued_at": "…"}`

- `contacts`: de 1 a 10 cartões. Cada um tem `name` (obrigatório, uma linha,
  até 256 caracteres) e **ou** `phone` (só dígitos; o whatzam escreve o vCard,
  já ligando o número à conta de WhatsApp dele) **ou** `vcard` (um vCard pronto: começa
  com `BEGIN:VCARD`, até 8 KB). `phone` e `vcard` juntos não são aceitos.
- `reply_to` (opcional): como no texto.

#### Reação — `POST …/messages/reaction`

```sh
curl -sS -X POST $WA/v1/instances/$ID/messages/reaction \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"to": "120363012345678901@g.us", "message_id": "3A7C19D2E4B8F0A61B22",
       "participant": "5569999999999", "emoji": "👍"}'
```
`202 {"message_id": "…", "queued_at": "…"}`

- `message_id`, `from_me` e `participant` apontam a mensagem que recebe a
  reação. Reagindo a algo que a instância enviou: `"from_me": true`. **Em
  grupo, reagir à mensagem de outra pessoa exige `participant`.**
- `emoji`: um emoji (até 16 caracteres, sem espaços). **Vazio (`""`) remove a
  reação.**
- O `message_id` da resposta é o da reação, não o da mensagem reagida.

Regras comuns das mensagens ricas:

- **Localização, contato e reação só vão para conversas diretas e grupos**
  (como a enquete). Para canal ou `status@broadcast`: `400 invalid_message`.
- Erro de validação → `400 invalid_message`, com a mensagem dizendo o campo.
- `kind` nos eventos e em `GET …/messages/{id}`: `sticker`, `location`,
  `contact`, `reaction`.

### Ações em mensagens — `message_actions`

#### Editar — `POST …/messages/edit` (envio na fila)

```sh
curl -sS -X POST $WA/v1/instances/$ID/messages/edit \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"to": "5569999999999", "message_id": "3EB0ABCDEF0123456789",
       "text": "Sua consulta é amanhã às 10h."}'
```
`202 {"message_id": "…", "queued_at": "…"}` (`kind: "edit"`)

- **Só mensagens de texto enviadas pela própria conta.** `to` é a conversa em
  que a original foi enviada; `message_id` é o ID da original.
- `text`: mesmas regras do texto (não vazio, ≤ 4096 caracteres).
- O WhatsApp aceita a edição por cerca de 15 minutos depois do envio original.
  O whatzam não confere o prazo nem se a original era texto e era sua: o
  `message.sent` do pedido diz que ele saiu, não que a edição foi aplicada.
- O `message_id` da resposta é o do pedido de edição; o da mensagem editada
  continua o mesmo.

#### Apagar para todos — `POST …/messages/revoke` (envio na fila)

```sh
curl -sS -X POST $WA/v1/instances/$ID/messages/revoke \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"to": "5569999999999", "message_id": "3EB0ABCDEF0123456789"}'
```
`202 {"message_id": "…", "queued_at": "…"}` (`kind: "revoke"`)

- Sem `participant`: apaga uma mensagem **enviada pela própria conta**.
- Com `participant` (**só num grupo**; em conversa direta, `400
  invalid_message`): apaga a mensagem **de outra pessoa**, o que o WhatsApp só
  permite a **administradores do grupo**:
  ```json
  {"to": "120363012345678901@g.us", "message_id": "3A7C19D2E4B8F0A61B22", "participant": "5569999999999"}
  ```

Editar e apagar só valem para conversas diretas e grupos (canal ou
`status@broadcast` → `400 invalid_message`).

#### Marcar como lida — `POST …/chats/read` (ação síncrona)

```sh
curl -sS -X POST $WA/v1/instances/$ID/chats/read \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"chat": "5569999999999", "message_ids": ["3A5F0C2E9B7D41F08A6C"]}'
```
`200 {"ok": true}`

- Envia o recibo de leitura (os tiques azuis) das mensagens **recebidas**.
- `message_ids`: de 1 a 100 IDs (os `data.message_id` dos eventos).
- `sender`: quem escreveu as mensagens. **Obrigatório em grupo**
  (`{"chat": "120363012345678901@g.us", "message_ids": ["…"], "sender": "5569999999999"}`);
  em conversa direta pode ser omitido.

#### "Digitando…" — `POST …/chats/typing` (ação síncrona)

```sh
curl -sS -X POST $WA/v1/instances/$ID/chats/typing \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"chat": "5569999999999", "state": "composing"}'
```
`200 {"ok": true}`

- `state`: `composing` ("digitando…"), `recording` ("gravando áudio…") ou
  `paused` (limpa o indicador).
- O WhatsApp só repassa o indicador enquanto a conta está **online**: ligue
  antes com `POST …/presence` (`presence`), se quiser que apareça.

### Presença — `presence`

Ações síncronas.

#### Ficar online ou offline — `POST …/presence`

```sh
curl -sS -X POST $WA/v1/instances/$ID/presence \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"available": true}'
```
`200 {"ok": true}`

- `available` é obrigatório (`true` = online, `false` = offline).
- O whatzam conecta sempre como **offline**, e **toda reconexão volta para
  offline**. Online faz o celular do dono **parar de notificar** as mensagens:
  use só enquanto precisar e volte para `false`.

#### Acompanhar a presença de alguém — `POST …/presence/subscribe`

```sh
curl -sS -X POST $WA/v1/instances/$ID/presence/subscribe \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"user": "5569999999999"}'
```
`200 {"ok": true}`

Depois disso chegam eventos `presence` daquela pessoa (online e "visto por
último", conforme a privacidade dela) **até a conexão cair**: refaça a
assinatura depois de cada `status=connected`. Com `presence` ligada chegam
também os eventos `chat_presence` ("digitando…") que o WhatsApp mandar. Veja
"Webhooks".

### Contatos — `contacts`

Ações síncronas.

#### Conferir números — `POST …/contacts/check`

```sh
curl -sS -X POST $WA/v1/instances/$ID/contacts/check \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"phones": ["5569999999999", "5569888888888"]}'
```
```json
{"numbers": [
  {"phone": "5569999999999", "exists": true, "jid": "556999999999@s.whatsapp.net", "business_name": "Clínica Exemplo"},
  {"phone": "5569888888888", "exists": false}
]}
```
- `phones`: de 1 a 50 telefones (só dígitos).
- `jid` é o endereço canônico e **pode diferir do número consultado** (o 9º
  dígito brasileiro, por exemplo). `jid` e `business_name` só aparecem quando
  existem.

#### Lista de contatos — `GET …/contacts`

```json
{"contacts": [
  {"user": "5569999999999", "name": "Maria Souza", "push_name": "Maria"},
  {"user": "123456789012345@lid", "push_name": "João"}
 ], "truncated": false}
```
A agenda sincronizada do celular mais todo mundo de quem a conta já teve
notícia, lida do armazenamento local da sessão (mesmo assim exige a instância
`connected`). `name` é o nome salvo na agenda do celular; `push_name`, o nome
que a pessoa deu a si mesma; `business_name`, o nome comercial. Ordenada por
`user`, no máximo 5000 itens (`truncated: true` quando há mais).

#### Perfil de uma pessoa — `GET …/contacts/{user}`

```sh
curl -sS $WA/v1/instances/$ID/contacts/5569999999999 -H "Authorization: Bearer $TOKEN"
```
```json
{"user": "5569999999999", "lid": "123456789012345", "about": "Disponível",
 "picture_id": "1712345678", "devices": 2, "name": "Maria Souza", "push_name": "Maria"}
```
`about` é o recado; `devices`, quantos aparelhos a conta tem; `business_name`
aparece em conta comercial. Campos sem valor são omitidos (menos `user` e
`devices`).

#### Foto de perfil — `GET …/contacts/{user}/picture`

```sh
curl -sS "$WA/v1/instances/$ID/contacts/5569999999999/picture?preview=true" \
  -H "Authorization: Bearer $TOKEN"
```
```json
{"id": "1712345678", "url": "https://pps.whatsapp.net/v/…", "type": "preview"}
```
- `?preview=true` pede a miniatura; sem ele, a imagem inteira (`type: "image"`).
- **`url` é um link público e de vida curta do CDN do WhatsApp**: baixe logo, a
  partir do seu sistema, e guarde a imagem (não o link). O whatzam não baixa
  nem guarda a foto.
- Sem foto → `404 whatsapp_not_found`; foto escondida pela privacidade da
  pessoa → `403 whatsapp_forbidden`.

#### Perfil comercial — `GET …/contacts/{user}/business`

```json
{"user": "5569999999999", "address": "Av. Sete de Setembro, 1000", "email": "contato@exemplo.com.br",
 "categories": ["Clínica médica"]}
```
Perfil público de uma conta WhatsApp Business. `categories` vem sempre (pode
ser `[]`); `address` e `email` só quando preenchidos. Pode vir também
`options`, um objeto de pares texto → texto com as opções de perfil que o
WhatsApp devolver, repassadas como vieram.

### Perfil e privacidade — `profile`

Ações síncronas sobre **a própria conta**.

#### Ler o perfil — `GET …/profile`

```json
{"user": "5569888888888", "lid": "987654321098765", "about": "Atendimento de seg. a sex.",
 "picture_id": "1712345678", "devices": 3, "push_name": "Clínica Exemplo",
 "jid": "5569888888888:12@s.whatsapp.net"}
```
Os mesmos campos de `GET …/contacts/{user}`, mais `jid` (o JID completo do
aparelho pareado).

#### Trocar nome e recado — `PATCH …/profile`

```sh
curl -sS -X PATCH $WA/v1/instances/$ID/profile \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"name": "Clínica Exemplo", "about": "Atendimento de seg. a sex., 8h às 18h"}'
```
`200 {"ok": true}`

- Mande `name`, `about` ou os dois (nenhum → `400 invalid_request`).
- `name`: o nome mostrado a quem não tem a conta salva; 1 a 25 caracteres, uma
  linha.
- `about`: o recado; até 139 caracteres. `""` limpa.
- Com os dois, o nome é aplicado primeiro: se o recado falhar, o nome já
  mudou.

#### Trocar a foto — `PUT …/profile/picture`

O corpo é **o próprio JPEG**, não JSON nem multipart:

```sh
curl -sS -X PUT $WA/v1/instances/$ID/profile/picture \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: image/jpeg" \
  --data-binary @foto.jpg
```
`200 {"picture_id": "1712345678"}`

- `Content-Type` precisa ser `image/jpeg` (`415 unsupported_media_type`).
- Até **2 MB** (`413 media_too_large`).
- Corpo vazio ou que não é JPEG → `400 invalid_request`; um JPEG que o
  WhatsApp não aceita → `422 whatsapp_rejected`.

`DELETE …/profile/picture` remove a foto → `200 {"ok": true}`.

#### Privacidade — `GET …/privacy` e `PATCH …/privacy`

```json
{"group_add": "all", "last_seen": "contacts", "status": "contacts", "profile": "all",
 "read_receipts": "all", "online": "match_last_seen", "call_add": "all"}
```
Para mudar **uma** configuração por requisição:

```sh
curl -sS -X PATCH $WA/v1/instances/$ID/privacy \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"setting": "last_seen", "value": "none"}'
```
`200` devolve todas as configurações como ficaram (o mesmo JSON do `GET`).

| `setting` | O que controla | `value` aceitos |
|---|---|---|
| `group_add` | quem pode adicionar a conta a grupos | `all`, `contacts`, `contact_blacklist`, `none` |
| `last_seen` | quem vê o "visto por último" | `all`, `contacts`, `contact_blacklist`, `none` |
| `status` | quem vê o recado da conta (o público das **publicações** de status é outra coisa: `GET …/status/privacy`) | `all`, `contacts`, `contact_blacklist`, `none` |
| `profile` | quem vê a foto de perfil | `all`, `contacts`, `contact_blacklist`, `none` |
| `read_receipts` | confirmações de leitura | `all`, `none` |
| `online` | quem vê quando a conta está online | `all`, `match_last_seen` |
| `call_add` | quem pode ligar | `all`, `known` |

`contact_blacklist` é "meus contatos, exceto…" (a lista de exceções é a que já
está na conta; esta API não a altera). `setting` ou `value` fora da tabela →
`400 invalid_request`.

#### Bloqueados — `GET …/blocklist` e `POST …/blocklist`

```sh
curl -sS -X POST $WA/v1/instances/$ID/blocklist \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"user": "5569999999999", "blocked": true}'
```
```json
{"blocked": ["5569999999999", "123456789012345@lid"]}
```
`GET` devolve a lista; `POST` bloqueia (`blocked: true`) ou desbloqueia
(`false`) uma pessoa e devolve a lista nova. `blocked` é obrigatório.

### Conversas — `chats`

Ações síncronas que mudam como a conversa fica guardada **na conta**: valem
também no celular e nos outros aparelhos vinculados.

#### Arquivar, fixar, silenciar, marcar como lida — `POST …/chats/state`

```sh
curl -sS -X POST $WA/v1/instances/$ID/chats/state \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"chat": "5569999999999", "mute": true, "mute_seconds": 28800}'
```
`200 {"ok": true}`

- **Exatamente um** entre `archive`, `pin`, `mute` e `read` por requisição,
  cada um `true` ou `false` (nenhum ou mais de um → `400 invalid_request`).
- `mute_seconds` (só com `mute: true`): por quanto tempo silenciar, **no máximo
  uma semana** (`604800`). `0` ou ausente = até alguém tirar o silêncio.
- `read: true` marca a conversa inteira como lida **na lista de conversas**;
  `false`, como não lida. Não envia recibo de leitura a ninguém (para isso é
  `POST …/chats/read`).

#### Favoritar uma mensagem — `POST …/chats/star`

```sh
curl -sS -X POST $WA/v1/instances/$ID/chats/star \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"chat": "5569999999999", "message_id": "3A5F0C2E9B7D41F08A6C", "starred": true}'
```
`200 {"ok": true}`

`starred` é opcional (padrão `true`; `false` tira a estrela). `from_me` e
`participant` dizem de quem é a mensagem: **em grupo, mensagem de outra pessoa
exige `participant`** (`400 invalid_request`).

#### Mensagens temporárias — `POST …/chats/disappearing`

```sh
curl -sS -X POST $WA/v1/instances/$ID/chats/disappearing \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"chat": "5569999999999", "seconds": 604800}'
```
`200 {"ok": true}`

`seconds` só aceita os prazos que o WhatsApp oferece: **`0`** (desliga),
**`86400`** (1 dia), **`604800`** (7 dias) ou **`7776000`** (90 dias). Outro
valor → `400 invalid_request`.

### Chamadas — `calls`

O whatzam não atende nem faz chamadas: ele **avisa** (eventos `call.offer`,
`call.accept`, `call.terminate`, veja "Webhooks") e deixa **recusar**.

#### Recusar — `POST …/calls/reject` (ação síncrona)

```sh
curl -sS -X POST $WA/v1/instances/$ID/calls/reject \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"from": "5569999999999", "call_id": "A1B2C3D4E5F60718"}'
```
`200 {"ok": true}`

`from` e `call_id` são os do evento `call.offer`. Como o evento é best effort
e a chamada toca por pouco tempo, recuse assim que ele chegar.

### Grupos e comunidades — `groups`

Com `groups` ligada:

- os **envios** aceitam `to` com o JID do grupo (texto, anexo, enquete e, com
  as outras funcionalidades, o resto);
- as **mensagens dos grupos** chegam em `message.received` (com `receive`
  ligado para o tipo), com `chat` e `chat_type: "group"`;
- os **recibos** das mensagens enviadas a grupos passam a gerar `message.ack`;
- chegam os eventos `group.update`, `group.joined` e `picture` (de grupos).

As rotas abaixo são **ações síncronas**. `{group}` é o JID do grupo
(`120363012345678901@g.us`); outra coisa → `400 invalid_chat`. Mudar um grupo
exige que a conta seja administradora dele: sem permissão,
`403 whatsapp_forbidden`; grupo que não existe ou do qual a conta não
participa, `404 whatsapp_not_found` ou `403 whatsapp_forbidden`.

O objeto **grupo**, devolvido por várias rotas:

```json
{"jid": "120363012345678901@g.us", "name": "Equipe Clínica", "description": "Avisos internos",
 "owner": "5569888888888", "created_at": "2026-09-01T12:00:00Z",
 "announce": false, "locked": false, "join_approval": false, "member_add": "all_member_add",
 "disappearing_seconds": 0, "community": false, "participant_count": 2,
 "participants": [
   {"user": "5569888888888", "lid": "987654321098765", "admin": true, "super_admin": true},
   {"user": "5569999999999", "admin": false, "super_admin": false}
 ]}
```

| Campo | Significado |
|---|---|
| `announce` | `true` = só administradores enviam mensagens |
| `locked` | `true` = só administradores editam os dados do grupo |
| `join_approval` | `true` = entrar exige aprovação de um administrador |
| `member_add` | quem adiciona participantes: `admin_add` ou `all_member_add` |
| `disappearing_seconds` | prazo das mensagens temporárias (`0` = desligado) |
| `community` | `true` quando é uma comunidade; `parent` é o JID da comunidade de um grupo que está dentro de uma |
| `owner`, `description`, `member_add`, `parent`, `participants` | omitidos quando vazios |
| `participants[].user` | telefone quando o WhatsApp revela, `<id>@lid` caso contrário; `lid` vem à parte quando conhecido |
| `participants[].admin` / `super_admin` | administrador / criador do grupo |
| `participants[].error` | só em respostas de mudança: o código do WhatsApp quando a alteração **daquele** participante falhou (`403`: a pessoa só aceita convite; `409`: já é participante) |

#### Listar — `GET …/groups`

```sh
curl -sS $WA/v1/instances/$ID/groups -H "Authorization: Bearer $TOKEN"
```
`200 {"groups": [ {grupo}, … ]}` — os grupos de que a conta participa, **sem**
`participants`.

#### Consultar — `GET …/groups/{group}` → o grupo, **com** `participants`.

#### Criar — `POST …/groups`

```sh
curl -sS -X POST $WA/v1/instances/$ID/groups \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"name": "Equipe Clínica", "participants": ["5569999999999", "5569977777777"]}'
```
`200`: o grupo criado, com `participants`. **Confira `participants[].error`**:
a criação dá certo mesmo que o WhatsApp não tenha adicionado alguém.

- `name`: 1 a 25 caracteres, uma linha.
- `participants`: de 1 a 256 pessoas (obrigatório, exceto em comunidade).
- Opcionais, todos `false` por padrão: `announce`, `locked`, `join_approval`.
- `community: true` cria uma **comunidade** (aí `participants` pode faltar).
- `parent`: JID de uma comunidade, para criar o grupo **dentro** dela (não
  combina com `community: true`).

#### Alterar — `PATCH …/groups/{group}`

```sh
curl -sS -X PATCH $WA/v1/instances/$ID/groups/120363012345678901@g.us \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"name": "Equipe Clínica 2026", "announce": true}'
```
`200`: o grupo como ficou, com `participants`.

| Campo | |
|---|---|
| `name` | 1 a 25 caracteres, uma linha |
| `description` | até 2048 caracteres |
| `announce`, `locked`, `join_approval` | `true`/`false` |
| `member_add` | `admin_add` ou `all_member_add` |
| `disappearing_seconds` | `0`, `86400`, `604800` ou `7776000` |

Mande só o que quer mudar (nenhum campo → `400 invalid_request`). As mudanças
são aplicadas **uma a uma, nessa ordem**, e a requisição **para na primeira
que o WhatsApp recusar**: as anteriores ficam aplicadas. Depois de um erro,
consulte o grupo para ver o que valeu.

#### Foto do grupo — `GET`, `PUT` e `DELETE …/groups/{group}/picture`

Iguais às da conta e dos contatos: `GET` (com `?preview=true` para a
miniatura) devolve `{"id", "url", "type"}` com o link de vida curta do CDN;
`PUT` recebe o **JPEG no corpo** (`Content-Type: image/jpeg`, até 2 MB) e
devolve `{"picture_id": "…"}`; `DELETE` remove e devolve `{"ok": true}`.

```sh
curl -sS -X PUT $WA/v1/instances/$ID/groups/120363012345678901@g.us/picture \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: image/jpeg" --data-binary @grupo.jpg
```

#### Participantes — `POST …/groups/{group}/participants`

```sh
curl -sS -X POST $WA/v1/instances/$ID/groups/120363012345678901@g.us/participants \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"action": "add", "participants": ["5569999999999"]}'
```
```json
{"participants": [{"user": "5569999999999", "admin": false, "super_admin": false, "error": 403}]}
```
- `action`: `add`, `remove`, `promote` (torna administrador) ou `demote`.
- `participants`: de 1 a 256 pessoas.
- A resposta é `200` mesmo com falhas individuais: olhe o `error` de cada
  item (ausente = deu certo). `403` num `add` quer dizer que a pessoa só entra
  por convite: mande a ela o link de `GET …/groups/{group}/invite`.

#### Pedidos de entrada — `GET` e `POST …/groups/{group}/requests`

Para grupos com `join_approval`:

```json
{"requests": [{"user": "5569999999999", "requested_at": "2026-10-06T13:00:00Z"}]}
```
```sh
curl -sS -X POST $WA/v1/instances/$ID/groups/120363012345678901@g.us/requests \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"action": "approve", "participants": ["5569999999999"]}'
```
`200 {"participants": [ … ]}`, no formato da rota de participantes. `action`:
`approve` ou `reject`.

#### Convite — `GET …/groups/{group}/invite` e `POST …/groups/{group}/invite/reset`

```json
{"link": "https://chat.whatsapp.com/AbCdEfGhIjKlMnOpQrStUv"}
```
`GET` devolve o link atual. `POST …/invite/reset` (sem corpo) **revoga o
atual** e devolve um novo: quem tinha o link antigo não entra mais.

#### Ver um convite e entrar — `POST …/groups/preview` e `POST …/groups/join`

```sh
curl -sS -X POST $WA/v1/instances/$ID/groups/join \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"invite": "https://chat.whatsapp.com/AbCdEfGhIjKlMnOpQrStUv"}'
```
`200 {"jid": "120363012345678901@g.us"}`

- `invite`: o link inteiro ou só o código.
- `…/groups/preview` devolve o grupo (sem `participants`) **sem entrar**.
- Em grupo com `join_approval`, o `join` só registra o pedido: a conta vira
  participante quando um administrador aprovar.
- Convite inválido ou revogado → `404 whatsapp_not_found`.

#### Sair — `POST …/groups/{group}/leave` (sem corpo) → `200 {"ok": true}`

#### Comunidades — `GET` e `POST …/groups/{group}/subgroups`

Aqui `{group}` é o JID da **comunidade**.

```json
{"groups": [{"jid": "120363011111111111@g.us", "name": "Avisos", "default": true},
            {"jid": "120363012345678901@g.us", "name": "Equipe Clínica", "default": false}]}
```
`default: true` é o grupo de avisos da comunidade. Para pôr um grupo existente
na comunidade, ou tirá-lo:

```sh
curl -sS -X POST $WA/v1/instances/$ID/groups/120363010000000000@g.us/subgroups \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"group": "120363012345678901@g.us", "link": true}'
```
`200 {"ok": true}`. `link` é opcional (padrão `true`); `false` desvincula.

### Canais — `newsletters`

Com `newsletters` ligada:

- `POST …/messages/text` aceita `to` com o JID do canal
  (`120363098765432101@newsletter`) para **publicar texto**. O WhatsApp só
  aceita publicação de quem administra o canal;
- as publicações dos canais seguidos chegam em `message.received`, com
  `chat_type: "newsletter"`;
- chegam os eventos `newsletter.join`, `newsletter.leave` e `newsletter.mute`.

**Só texto**: anexo, enquete, localização, contato, reação, edição e apagar
para um canal respondem `400 invalid_message`.

As rotas abaixo são ações síncronas. `{newsletter}` é o JID do canal; outra
coisa → `400 invalid_chat`. O objeto **canal**:

```json
{"jid": "120363098765432101@newsletter", "name": "Clínica Exemplo", "description": "Novidades e avisos",
 "invite": "0029VaAbCdEfGhIjKlMnOp", "subscribers": 1280, "verified": false, "state": "active",
 "created_at": "2026-08-15T10:00:00Z", "role": "owner", "muted": false}
```
`invite` é o código de `https://whatsapp.com/channel/<código>`; `state` é
`active`, `suspended` ou `geosuspended`; `role` (`subscriber`, `guest`,
`admin` ou `owner`) e `muted` descrevem esta conta no canal.

| Rota | Corpo | Resposta |
|---|---|---|
| `GET …/newsletters` | — | `{"newsletters": [ {canal}, … ]}`: os que a conta segue ou possui |
| `POST …/newsletters` | `{"name": "…", "description": "…"}` | o canal criado, de que a conta é dona |
| `POST …/newsletters/preview` | `{"invite": "…"}` | o canal, sem seguir |
| `GET …/newsletters/{newsletter}` | — | o canal |
| `POST …/newsletters/{newsletter}/follow` | `{"follow": true}` | `{"ok": true}` |
| `POST …/newsletters/{newsletter}/mute` | `{"mute": true}` | `{"ok": true}` |

```sh
curl -sS -X POST $WA/v1/instances/$ID/newsletters \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"name": "Clínica Exemplo", "description": "Novidades e avisos"}'

curl -sS -X POST $WA/v1/instances/$ID/newsletters/120363098765432101@newsletter/follow \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"follow": true}'
```

- `name`: 1 a 100 caracteres, uma linha. `description`: opcional, até 2048.
- `invite`: o código ou o link `https://whatsapp.com/channel/…`.
- `follow` e `mute` são obrigatórios; `false` deixa de seguir / tira o
  silêncio.

### Status — `status`

Com `status` ligada:

- os envios de **texto** e de **anexo** aceitam `to: "status@broadcast"` para
  **publicar um status**. Vai para quem a conta já configurou como público dos
  status;
  ```sh
  curl -sS -X POST $WA/v1/instances/$ID/messages/text \
    -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
    -d '{"to": "status@broadcast", "text": "Atendemos neste sábado, das 8h às 12h."}'
  ```
  `202 {"message_id": "…", "queued_at": "…"}`
- os status dos contatos chegam em `message.received`, com
  `chat: "status@broadcast"`, `chat_type: "status"` e `from` = quem publicou.

O anexo de um status é **imagem, vídeo ou áudio**: documento e figurinha, assim
como enquete, localização, contato, reação, edição e apagar, não valem para
`status@broadcast` (`400 invalid_message`).

#### Quem vê os status — `GET …/status/privacy` (ação síncrona)

```json
{"audiences": [
  {"type": "contacts", "users": [], "default": true},
  {"type": "blacklist", "users": ["5569999999999"], "default": false}
]}
```
`type`: `contacts` (todos os contatos), `blacklist` (contatos exceto `users`)
ou `whitelist` (só `users`). `default: true` marca a opção em uso. Esta API só
lê; a escolha é feita no celular.

### Mensagens da própria conta — `own_messages`

Não tem rotas. Ligada, o `message.received` passa a trazer também o que a
própria conta envia **pelo celular ou por outro aparelho vinculado**, com
`from_me: true` e a conversa em `chat` (exemplo em "O evento
`message.received`"). Serve para manter o seu sistema em sincronia com o que o
atendente respondeu direto no WhatsApp.

- Vale o filtro de `receive.types` e, para grupo, status ou canal, também a
  funcionalidade do lugar.
- O que a instância envia **pela API** não volta: para isso existem
  `message.sent` e `GET …/messages/{id}`.

## 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 |
| `passkey_required` | `{url, expires_at}` — a conta exige chave de acesso; envie `url` ao dono |
| `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. `to` é o destinatário como você mandou; `chat` é o JID completo para onde foi (`556999999999@s.whatsapp.net`, `…@g.us`…) |
| `message.failed` | `{message_id, kind, to, reason}` (`to`/`kind` ausentes em `interrupted`) |
| `message.ack` | `{message_ids:[…], status:"delivered"\|"read", at, chat}` — de mensagens enviadas a grupos, só com `groups` |
| `message.received` | mensagem recebida; só com `receive.enabled` e para os tipos escolhidos. **Entrega garantida**, com regras próprias (veja "Receber mensagens") |

Com funcionalidades ligadas, chegam também (detalhes em "Eventos das
funcionalidades"):

| `type` | Funcionalidade | Quando |
|---|---|---|
| `presence` | `presence` | alguém que você assinou ficou online/offline |
| `chat_presence` | `presence` (em grupo, também `groups`) | alguém está digitando ou gravando áudio numa conversa |
| `call.offer` | `calls` | chamada recebida |
| `call.accept` | `calls` | chamada aceita |
| `call.terminate` | `calls` | chamada encerrada |
| `group.update` | `groups` | mudou algo num grupo (dados ou participantes) |
| `group.joined` | `groups` | a conta entrou ou foi adicionada a um grupo |
| `picture` | `contacts` (pessoas) / `groups` (grupos) | foto trocada ou removida |
| `contact.name` | `contacts` | alguém mudou o nome de exibição ou o nome comercial |
| `contact.about` | `contacts` | alguém mudou o recado |
| `blocklist.update` | `profile` | a lista de bloqueados mudou |
| `newsletter.join` | `newsletters` | a conta passou a seguir um canal |
| `newsletter.leave` | `newsletters` | a conta deixou um canal |
| `newsletter.mute` | `newsletters` | um canal foi silenciado ou não |
| `chat.update` | `chats` | uma conversa foi arquivada, fixada, silenciada ou marcada |
| `message.star` | `chats` | uma mensagem foi favoritada ou não |

Regras de entrega (todos os eventos, **exceto** `message.received`, que tem as
suas em "Receber mensagens" → "Entrega garantida"):
- 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}`.
- **Todos os eventos das funcionalidades são best effort**, por essa mesma
  fila em memória: são avisos, não mensagens. Se um deles importa para o seu
  estado (participantes de um grupo, lista de bloqueados…), reconcilie de
  tempos em tempos pela rota de consulta correspondente.

### Eventos das funcionalidades

Todos vêm no envelope de sempre (`id`, `type`, `instance_id`, `tenant_id`,
`created_at`, `data`), assinados do mesmo jeito; abaixo, só o `data`. Pessoas
vêm como telefone em dígitos ou `<id>@lid`; `chat`, como dígitos (conversa
direta por telefone) ou JID. Campos de data (`at`, `last_seen`) são RFC 3339 e
vêm **`null`** quando o WhatsApp não informa.

#### `presence` — funcionalidade `presence`

```json
{"from": "5569999999999", "available": false, "last_seen": "2026-10-06T13:58:12Z"}
```
`available`: online ou não. `last_seen` é `null` quando o WhatsApp não informa
(a pessoa esconde o "visto por último", por exemplo). Chega para as pessoas
assinadas com `POST …/presence/subscribe`.

#### `chat_presence` — funcionalidade `presence`

```json
{"chat": "120363012345678901@g.us", "from": "5569999999999", "state": "composing"}
```
`state`: `composing` (digitando), `recording` (gravando áudio) ou `paused`
(parou). Em conversa direta, `chat` é a própria pessoa. Os de grupo só chegam
com `groups` também ligada; os da própria conta nunca chegam.

#### `call.offer`, `call.accept`, `call.terminate` — funcionalidade `calls`

```json
{"call_id": "A1B2C3D4E5F60718", "from": "5569999999999", "at": "2026-10-06T15:02:11Z"}
```
```json
{"call_id": "A1B2C3D4E5F60718", "from": "5569999999999", "at": "2026-10-06T15:02:11Z",
 "group": "120363012345678901@g.us", "media": "video", "group_call": true}
```
```json
{"call_id": "A1B2C3D4E5F60718", "from": "5569999999999", "at": "2026-10-06T15:02:40Z",
 "reason": "…"}
```

| Campo | Em | Significado |
|---|---|---|
| `call_id` | todos | ID da chamada: o mesmo nos três eventos e o que `POST …/calls/reject` recebe |
| `from` | todos | quem iniciou a chamada |
| `at` | todos | horário informado pelo WhatsApp |
| `group` | todos, quando houver | JID do grupo de uma chamada de grupo |
| `media` | `call.offer`, só na forma de aviso | `audio` ou `video` |
| `group_call` | `call.offer`, só na forma de aviso | `true` em chamada de grupo |
| `reason` | `call.terminate` | o motivo que o WhatsApp informar, repassado como veio |

O `call.offer` tem duas formas, conforme o WhatsApp anuncia a chamada: a
simples (primeiro exemplo, sem `media` nem `group_call`) e a de aviso (segundo
exemplo). Não conte com `media` estar presente.

#### `group.update` — funcionalidade `groups`

```json
{"group": "120363012345678901@g.us", "at": "2026-10-06T16:00:00Z", "by": "5569888888888",
 "name": "Equipe Clínica 2026", "joined": ["5569999999999"]}
```
**Só o que mudou aparece.** `group` e `at` vêm sempre.

| Campo | Significado |
|---|---|
| `by` | quem fez a mudança, quando o WhatsApp informa |
| `name` | novo nome |
| `description` | nova descrição |
| `announce` | `true`/`false`: só administradores enviam |
| `locked` | `true`/`false`: só administradores editam os dados |
| `join_approval` | `true`/`false`: entrada com aprovação |
| `disappearing_seconds` | novo prazo das mensagens temporárias (`0` = desligado) |
| `invite_link_reset` | `true` quando o link de convite foi trocado |
| `deleted` | `true` quando o grupo foi apagado |
| `joined`, `left`, `promoted`, `demoted` | listas de pessoas que entraram, saíram, viraram administradoras ou deixaram de ser |

#### `group.joined` — funcionalidade `groups`

```json
{"group": {"jid": "120363012345678901@g.us", "name": "Equipe Clínica", "owner": "5569888888888",
           "created_at": "2026-09-01T12:00:00Z", "announce": false, "locked": false,
           "join_approval": false, "disappearing_seconds": 0, "community": false,
           "participant_count": 2,
           "participants": [{"user": "5569888888888", "admin": true, "super_admin": true},
                            {"user": "5569777777777", "admin": false, "super_admin": false}]},
 "reason": "invite", "new": false}
```
`group` é o objeto grupo das rotas, com `participants`. `reason` é `"invite"`
quando a entrada foi por link de convite (vazio nos demais casos); `new` é
`true` quando o grupo acabou de ser criado.

#### `picture` — funcionalidade `contacts` (pessoas) ou `groups` (grupos)

```json
{"chat": "5569999999999", "by": "5569999999999", "removed": false,
 "picture_id": "1712345678", "at": "2026-10-06T16:10:00Z"}
```
`chat` é de quem é a foto (a pessoa ou o JID do grupo); `by`, quem trocou
(vazio quando o WhatsApp não informa); `removed: true` quando a foto foi removida. O evento não traz a imagem: busque
com `GET …/contacts/{user}/picture` ou `GET …/groups/{group}/picture`.

#### `contact.name` e `contact.about` — funcionalidade `contacts`

```json
{"user": "5569999999999", "push_name": "Maria S."}
```
```json
{"user": "5569999999999", "business_name": "Clínica Exemplo Ltda"}
```
`contact.name` traz **um dos dois**: `push_name` (o nome que a pessoa deu a si
mesma) ou `business_name` (o nome comercial).

```json
{"user": "5569999999999", "about": "Em atendimento", "at": "2026-10-06T16:20:00Z"}
```
`contact.about`: o novo recado.

#### `blocklist.update` — funcionalidade `profile`

```json
{"changes": [{"user": "5569999999999", "blocked": true}], "reload": false}
```
Com `changes` vazio vem `"reload": true`: a lista inteira mudou, leia de novo
com `GET …/blocklist`.

#### `newsletter.join`, `newsletter.leave`, `newsletter.mute` — funcionalidade `newsletters`

```json
{"newsletter": {"jid": "120363098765432101@newsletter", "name": "Clínica Exemplo",
                "subscribers": 1280, "verified": false, "state": "active",
                "created_at": "2026-08-15T10:00:00Z", "role": "subscriber", "muted": false}}
```
```json
{"newsletter": "120363098765432101@newsletter", "role": "subscriber"}
```
```json
{"newsletter": "120363098765432101@newsletter", "muted": true}
```
Atenção ao formato: em `newsletter.join`, `newsletter` é o **objeto canal**
das rotas; em `newsletter.leave` e `newsletter.mute`, é só o **JID**. `role`
em `newsletter.leave` é o papel informado pelo WhatsApp na saída.

#### `chat.update` e `message.star` — funcionalidade `chats`

```json
{"chat": "5569999999999", "action": "archive", "value": true, "at": "2026-10-06T16:30:00Z"}
```
`action`: `archive`, `pin`, `mute` ou `read`; `value` diz se ligou ou desligou
(ex. `"action": "read", "value": false` = marcada como não lida).

```json
{"chat": "120363012345678901@g.us", "message_id": "3A7C19D2E4B8F0A61B22", "from_me": false,
 "participant": "5569999999999", "starred": true, "at": "2026-10-06T16:31:00Z"}
```
`message.star`: `participant` é quem enviou a mensagem (vazio quando o
WhatsApp não informa, como em conversa direta).

Os dois contam o que o dono fez com a conversa **no celular ou em outro
aparelho**. A reprodução do estado antigo numa sincronização completa não gera
eventos.

### 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
   (ou `…/messages/media`, `…/messages/poll`);
   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. **Recebimento (se usar)**: ligue `receive` só nas instâncias e tipos que
   precisa. No webhook, grave `message.received` de forma idempotente por
   `data.message_id` e responda `2xx` na hora (só `2xx` confirma; qualquer
   outra resposta faz o evento voltar, por até 24 h).
   Baixe os anexos logo depois, fora da requisição do webhook, com o
   `download_token`. Se o seu sistema ficar fora do ar, nada a fazer: o que
   chegou é entregue quando ele voltar (até `WA_INBOUND_RETENTION`).
6. **Restart do whatzam**: nada a fazer; sessões, fila de envio e mensagens
   recebidas pendentes voltam sozinhas. Só
   trate os `503` de boot e os `message.failed reason=interrupted`.
7. **Funcionalidades (se usar)**: ligue só as que o cliente precisa
   (`PATCH` com `features`, sempre a lista completa). Trate
   `403 feature_disabled` como erro de configuração, não como falha
   transitória. Nas ações síncronas, trate `409 not_connected` como "tentar
   quando a instância voltar" (nada fica na fila) e `429
   whatsapp_rate_limited` como "esperar `Retry-After`". Os eventos das
   funcionalidades são best effort: use-os para reagir rápido e as rotas de
   consulta para reconciliar. Com `groups` ou `own_messages` ligadas, confira
   `chat_type` e `from_me` em todo `message.received` antes de responder.

## 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, tem credenciais/fragmento, ou aponta para IP interno/`localhost` | corrigir (ou o operador libera a faixa) |
| 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; `to` com `@` que não é um JID de grupo, de canal nem `status@broadcast` | normalizar o número; conferir o JID |
| 400 | `invalid_text` | vazio, > 4096 caracteres, UTF-8 inválido ou NUL | corrigir |
| 400 | `invalid_poll` | enquete: `question` vazia ou > 255 caracteres; menos de 2 ou mais de 12 `options`; opção vazia, > 100 caracteres ou repetida; 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` da instância sem `webhook_url`, `webhook_secret`, `receive` nem `features` | enviar ao menos um |
| 400 | `invalid_receive` | `receive.types` com tipo desconhecido ou repetido, ou `enabled: true` sem tipos | corrigir |
| 400 | `invalid_features` | `features` com nome desconhecido, repetido ou não permitido pelo operador (`WA_FEATURES`) | corrigir; `GET …/features` lista `allowed` e `available` |
| 403 | `feature_disabled` | rota, campo ou destinatário de uma funcionalidade que a instância não ligou (a mensagem diz qual) | ligar com `PATCH` `features` ou no painel; não repetir sem isso |
| 400 | `invalid_message` | envio das funcionalidades inválido: `reply_to`/`message_id` que não é um ID de mensagem; `participant` faltando (grupo, mensagem de outra pessoa) ou sobrando (com `from_me`); `mentions` fora de 1–50 pessoas; `view_once` fora de imagem/vídeo/áudio; latitude/longitude ausentes ou fora da faixa; `name`/`address` longos; `contacts` fora de 1–10, sem `name`, sem `phone`/`vcard` ou com os dois; `emoji` inválido; **tipo de mensagem que a conversa não aceita** (anexo para canal; enquete, localização, contato, reação, edição ou apagar para canal ou status) | corrigir |
| 400 | `invalid_chat` | `chat`, `{group}` ou `{newsletter}` não é um telefone nem um JID do tipo que a rota aceita | corrigir |
| 400 | `invalid_user` | `user`, `from` ou `{user}` não é um telefone (8–15 dígitos) nem um JID de usuário (`…@s.whatsapp.net`, `…@lid`) | corrigir |
| 400 | `invalid_request` | ação síncrona com campo ausente ou inválido; a mensagem diz qual (ex. `message_ids`, `sender`, `state`, `mute_seconds`, `seconds`, `phones`, `participants`, `action`, `invite`, `name`, `about`, `setting`, `value`, `picture`) | corrigir |
| 404 | `whatsapp_not_found` | o WhatsApp não tem o que foi pedido: grupo, canal, foto ou convite inexistente ou revogado | não repetir; conferir o identificador |
| 403 | `whatsapp_forbidden` | o WhatsApp não deixa esta conta fazer ou ver aquilo (não é administradora, não está no grupo, foto escondida pela privacidade) | não repetir sem mudar a permissão |
| 422 | `whatsapp_rejected` | o WhatsApp entendeu o pedido e recusou (requisição inaceitável, imagem em formato inválido, limite de recursos, recurso bloqueado) | corrigir; não repetir igual |
| 429 | `whatsapp_rate_limited` | o WhatsApp pediu para a conta ir mais devagar; vem com `Retry-After: 60` | esperar `Retry-After` e repetir; reduzir o ritmo das ações |
| 502 | `whatsapp_failed` | qualquer outra falha do pedido ao WhatsApp (fica no log do servidor) | repetir depois |
| 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 (envio ou download) ou **ação síncrona** de uma funcionalidade com a instância desconectada: a operação é imediata, nada fica na fila | `connect`, esperar `connected` e repetir |
| 400 | `invalid_media` | tipo não aceito, documento sem nome, legenda em áudio, figurinha ou longa, sem `file`, figurinha que não é WebP, `ptt`/`view_once`/`reply_from_me` diferente de `true`/`false` | corrigir |
| 400 | `invalid_multipart` | corpo multipart malformado, campo longo, dois arquivos | corrigir |
| 413 | `media_too_large` | arquivo (enviado ou a baixar) acima de `WA_MEDIA_MAX_BYTES`; foto de perfil ou de grupo acima de 2 MB | reduzir; no download, não repetir |
| 502 | `upload_failed` | o WhatsApp não aceitou o upload | repetir |
| 400 | `invalid_media_token` | `download_token` vazio, alterado, de outra instância ou anterior à troca do `WA_ADMIN_TOKEN` | usar o token do evento, sem alterar |
| 410 | `media_gone` | o WhatsApp não tem mais o arquivo | não repetir |
| 502 | `download_failed` | o download no WhatsApp falhou | 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` (ou de `multipart/form-data` no anexo, ou de `image/jpeg` no envio de foto) | 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`; ação síncrona além de `WA_ACTION_TIMEOUT`) | repetir |

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

## O que não está incluído

O whatzam full cobre o que está neste guia e **para aí**. Não existe, nem atrás
de funcionalidade:

- **Sincronização de histórico**: nenhuma mensagem anterior ao pareamento (ou
  recebida com o recebimento desligado) é importada ou consultável. Continua
  não havendo caixa de entrada.
- **Botões e listas**: nem envio nem a resposta deles. Para perguntas com
  opções, use a enquete.
- **Bots e Meta AI.**
- **Interoperabilidade com Messenger e Instagram.**
- **Pedidos e pagamentos.**
- **Anexo para canais**: canais só recebem texto por esta API.
- **Proxy por instância**: não há proxy configurável para cada instância.
- **Mídia por URL**: o anexo é sempre o arquivo no `multipart`, e a foto de
  perfil é sempre o JPEG no corpo; o whatzam não busca arquivos em links.

Também não há rota para atender ou iniciar chamadas, votar em enquetes de
outras pessoas, nem para alterar o público dos status ou as listas de exceção
de privacidade.

## 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 |
| `POST` | `/v1/instances/{id}/messages/poll` | instância/admin |
| `GET` | `/v1/instances/{id}/messages/{message_id}` | instância/admin |
| `POST` | `/v1/instances/{id}/media/download` | instância/admin |
| `GET` | `/v1/instances/{id}/stats` | instância/admin |
| `GET` | `/v1/instances/{id}/features` | instância/admin |

Rotas das funcionalidades (token de instância ou admin; `403 feature_disabled`
com a funcionalidade desligada). "Fila" = envio na fila (`202`); "Ação" = ação
síncrona (`200`, exige `connected`):

| Método | Rota | Funcionalidade | Tipo |
|---|---|---|---|
| `POST` | `/v1/instances/{id}/messages/location` | `rich_messages` | Fila |
| `POST` | `/v1/instances/{id}/messages/contact` | `rich_messages` | Fila |
| `POST` | `/v1/instances/{id}/messages/reaction` | `rich_messages` | Fila |
| `POST` | `/v1/instances/{id}/messages/edit` | `message_actions` | Fila |
| `POST` | `/v1/instances/{id}/messages/revoke` | `message_actions` | Fila |
| `POST` | `/v1/instances/{id}/chats/read` | `message_actions` | Ação |
| `POST` | `/v1/instances/{id}/chats/typing` | `message_actions` | Ação |
| `POST` | `/v1/instances/{id}/chats/state` | `chats` | Ação |
| `POST` | `/v1/instances/{id}/chats/star` | `chats` | Ação |
| `POST` | `/v1/instances/{id}/chats/disappearing` | `chats` | Ação |
| `POST` | `/v1/instances/{id}/presence` | `presence` | Ação |
| `POST` | `/v1/instances/{id}/presence/subscribe` | `presence` | Ação |
| `POST` | `/v1/instances/{id}/calls/reject` | `calls` | Ação |
| `POST` | `/v1/instances/{id}/contacts/check` | `contacts` | Ação |
| `GET` | `/v1/instances/{id}/contacts` | `contacts` | Ação |
| `GET` | `/v1/instances/{id}/contacts/{user}` | `contacts` | Ação |
| `GET` | `/v1/instances/{id}/contacts/{user}/picture` | `contacts` | Ação |
| `GET` | `/v1/instances/{id}/contacts/{user}/business` | `contacts` | Ação |
| `GET` | `/v1/instances/{id}/profile` | `profile` | Ação |
| `PATCH` | `/v1/instances/{id}/profile` | `profile` | Ação |
| `PUT` | `/v1/instances/{id}/profile/picture` | `profile` | Ação |
| `DELETE` | `/v1/instances/{id}/profile/picture` | `profile` | Ação |
| `GET` | `/v1/instances/{id}/privacy` | `profile` | Ação |
| `PATCH` | `/v1/instances/{id}/privacy` | `profile` | Ação |
| `GET` | `/v1/instances/{id}/blocklist` | `profile` | Ação |
| `POST` | `/v1/instances/{id}/blocklist` | `profile` | Ação |
| `GET` | `/v1/instances/{id}/status/privacy` | `status` | Ação |
| `GET` | `/v1/instances/{id}/groups` | `groups` | Ação |
| `POST` | `/v1/instances/{id}/groups` | `groups` | Ação |
| `POST` | `/v1/instances/{id}/groups/join` | `groups` | Ação |
| `POST` | `/v1/instances/{id}/groups/preview` | `groups` | Ação |
| `GET` | `/v1/instances/{id}/groups/{group}` | `groups` | Ação |
| `PATCH` | `/v1/instances/{id}/groups/{group}` | `groups` | Ação |
| `GET` | `/v1/instances/{id}/groups/{group}/picture` | `groups` | Ação |
| `PUT` | `/v1/instances/{id}/groups/{group}/picture` | `groups` | Ação |
| `DELETE` | `/v1/instances/{id}/groups/{group}/picture` | `groups` | Ação |
| `POST` | `/v1/instances/{id}/groups/{group}/participants` | `groups` | Ação |
| `GET` | `/v1/instances/{id}/groups/{group}/requests` | `groups` | Ação |
| `POST` | `/v1/instances/{id}/groups/{group}/requests` | `groups` | Ação |
| `GET` | `/v1/instances/{id}/groups/{group}/invite` | `groups` | Ação |
| `POST` | `/v1/instances/{id}/groups/{group}/invite/reset` | `groups` | Ação |
| `POST` | `/v1/instances/{id}/groups/{group}/leave` | `groups` | Ação |
| `GET` | `/v1/instances/{id}/groups/{group}/subgroups` | `groups` | Ação |
| `POST` | `/v1/instances/{id}/groups/{group}/subgroups` | `groups` | Ação |
| `GET` | `/v1/instances/{id}/newsletters` | `newsletters` | Ação |
| `POST` | `/v1/instances/{id}/newsletters` | `newsletters` | Ação |
| `POST` | `/v1/instances/{id}/newsletters/preview` | `newsletters` | Ação |
| `GET` | `/v1/instances/{id}/newsletters/{newsletter}` | `newsletters` | Ação |
| `POST` | `/v1/instances/{id}/newsletters/{newsletter}/follow` | `newsletters` | Ação |
| `POST` | `/v1/instances/{id}/newsletters/{newsletter}/mute` | `newsletters` | Ação |

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

Licença: AGPL-3.0.
