# Mensagens

> Identidade do contato, envio idempotente, estados do envio, leitura e janela de atendimento.

Fonte: https://metahub.matratecnologia.com/docs/mensagens

## Identidade do contato

A Meta identifica cada pessoa pelo BSUID (business-scoped user id), que o hub expõe como `userId`. O BSUID vale dentro do portfólio de negócios do seu cliente final (o mesmo usuário tem outro BSUID em outro portfólio). Formato: código do país, ponto e até 128 alfanuméricos, por exemplo `BR.13491208655302741918`.

- **`userId` é a chave primária do contato.** Guarde e procure pelo `userId`; o telefone (`waId`, só dígitos) pode faltar, porque a Meta omite o telefone de quem usa nome de usuário.
- `parentUserId` só vem quando a conta do cliente tem parent BSUID; `username` só quando o contato tem nome de usuário.
- Identidade nos eventos: `{ userId, parentUserId, waId, username, name }`, com `null` no que a Meta não mandou.
- **Troca de identidade** chega como `whatsapp.identity.updated` (o contato trocou de número ou de BSUID). Use `data.previous.userId` para achar o contato e grave `data.current`. O hub já move a janela de atendimento para a identidade nova.



Todas as rotas desta seção estão sob `/v1/connections/:connectionId`. Conexão de outro app responde `404 CONNECTION_NOT_FOUND`; enviar exige conexão `active` (senão `409 CONNECTION_NOT_ACTIVE`).

As recusas destas rotas (mensagens, mídia, templates) seguem o formato `{ statusCode, error, code, message, ...detalhes }`. Decida pelo `code`.

## Enviar

`POST /v1/connections/:connectionId/messages` com o header `Idempotency-Key` (1 a 128 caracteres entre `A-Z a-z 0-9 . _ : -`, obrigatório; sem ele, `400 IDEMPOTENCY_KEY_REQUIRED`). Responde `202 { "message": ... }`: o envio entra numa fila e o resultado chega por evento.

Corpo estrito (campo extra é rejeitado), um formato por `type`:

| `type` | Campo | Conteúdo |
|---|---|---|
| `text` | `text` | `{ body, previewUrl? }`, `body` até 4096 |
| `image`, `video` | `image` / `video` | `{ mediaId, caption? }` (legenda até 1024) |
| `document` | `document` | `{ mediaId, caption?, filename? }` |
| `audio` | `audio` | `{ mediaId, voice? }`; `voice: true` (mensagem de voz) só com `audio/ogg` |
| `sticker` | `sticker` | `{ mediaId }` |
| `location` | `location` | `{ latitude, longitude, name?, address? }` |
| `contacts` | `contacts` | lista de 1 a 257 contatos no formato da Meta em camelCase (`name.formattedName`, `phones[]`, `emails[]`, `urls[]`, `addresses[]`, `org`, `birthday`) |
| `interactive` | `interactive` | objeto plano com `kind`, `header?`, `body`, `footer?` (até 60) e os campos do `kind`, sem o aninhamento da Meta (`action`, `parameters`): `kind: "buttons"` com `buttons: [{ id, title }]` (1 a 3, título até 20 e único; `body` até 1024; header de texto `{ type: "text", text }` ou de mídia do hub `{ type: "image" \| "video" \| "document", mediaId, filename? }`), `kind: "list"` com `button` (texto do botão, até 20) e `sections: [{ title?, rows: [{ id, title, description? }] }]` (até 10 seções e 10 linhas no total; `body` até 4096; só header de texto) ou `kind: "cta_url"` com `displayText` (até 20) e `url` https (`body` até 1024; só header de texto) |
| `reaction` | `reaction` | `{ wamid, emoji }`; `emoji: ""` remove a reação. Reação não aceita `replyTo` |
| `template` | `template` | `{ templateId, parameters? }` (id do hub) ou `{ name, language, parameters? }` (ver [Parâmetros de template](https://metahub.matratecnologia.com/docs/mensagens.md#parâmetros-de-template)) |

Todo tipo leva `to: { userId?, phone? }` (pelo menos um) e, fora `reaction`, `replyTo?` com o `wamid` da mensagem citada. Com os dois, o telefone vale. `mediaId` é sempre o id de uma mídia que você subiu pelo hub ([Mídia](https://metahub.matratecnologia.com/docs/midia.md)), da mesma conexão.

```json
{
  "to": { "userId": "BR.13491208655302741918" },
  "type": "text",
  "text": { "body": "Seu pedido saiu para entrega." }
}
```

**Idempotência.** A `Idempotency-Key` é única por app. Repetir o pedido com a mesma chave e o mesmo corpo devolve `200` com o estado atual da mensagem, sem novo envio, mesmo que a conexão tenha caído depois. A mesma chave com corpo diferente responde `409 IDEMPOTENCY_KEY_REUSED`. O telefone é normalizado antes da comparação (`+5543999990000` e `5543999990000` são o mesmo pedido). A chave vale enquanto a mensagem existe no hub, ou seja, 30 dias; depois disso a mesma chave cria um envio novo.

**Rota de marketing.** Template `MARKETING` de uma conta que aceitou os termos do Marketing Messages API (`connection.updated` com `meta.accountEvent: "MM_LITE_TERMS_SIGNED"`) sai pela rota `marketing_messages` da Meta; o resto sai por `messages`. A rota usada aparece em `message.route`. Sem os termos, o marketing sai pelo Cloud API comum até a Meta encerrar esse caminho (contas criadas a partir de 13/04/2027 não podem; todas param em 13/10/2027).

Recusas na hora do pedido (nada é enviado):

| Status | `code` | Quando |
|---|---|---|
| 400 | `IDEMPOTENCY_KEY_REQUIRED` | header ausente ou fora do formato |
| 409 | `IDEMPOTENCY_KEY_REUSED` | chave já usada com outro corpo |
| 404 | `CONNECTION_NOT_FOUND` | conexão inexistente ou de outro app |
| 409 | `CONNECTION_NOT_ACTIVE` | conexão não está `active` ou está sem credencial |
| 404 | `MEDIA_NOT_FOUND` | mídia inexistente, de outra conexão ou ainda não pronta |
| 410 | `MEDIA_EXPIRED` | a mídia venceu na Meta (29 dias depois do upload): suba de novo |
| 400 | `MEDIA_KIND_MISMATCH` | a mídia não é da família que o tipo exige (por exemplo um PDF em `image`, ou `voice` fora de `audio/ogg`), inclusive no header de interativo e de template |
| 404 | `TEMPLATE_NOT_FOUND` | template fora do cache da conta da conexão |
| 422 | `TEMPLATE_NOT_APPROVED` | template não está `APPROVED` |
| 400 | `TEMPLATE_PARAMETERS_MISMATCH` | parâmetros não batem com o template (traz `component`, nunca os valores) |
| 400 | `TEMPLATE_COMPONENT_UNSUPPORTED` | o template tem um componente que o hub não preenche (traz `component`) |
| 400 | `RECIPIENT_PHONE_REQUIRED` | template `AUTHENTICATION` sem `to.phone` (a Meta não aceita BSUID nele) |
| 422 | `OUTSIDE_SERVICE_WINDOW` | mensagem livre (não template) para um contato com a janela de atendimento vencida |
| 400 | (validação) | corpo fora do schema |

## Estados do envio

`message` (no `202`, no `200` idempotente e nas leituras):

```json
{
  "id": "...",
  "connectionId": "...",
  "type": "text",
  "state": "accepted",
  "route": "messages",
  "wamid": "wamid.HBgM...",
  "pacing": null,
  "errorCode": null,
  "metaErrorCode": null,
  "deliveryState": "delivered",
  "deliveryStateAt": "2026-10-23T15:00:05.000Z",
  "createdAt": "2026-10-23T15:00:00.000Z",
  "updatedAt": "2026-10-23T15:00:05.000Z"
}
```

Nenhuma resposta nem evento traz o conteúdo ou o destinatário.

| `state` | Significado | O que fazer |
|---|---|---|
| `pending` | Na fila do hub (inclusive esperando a vazão do número ou um retry por limite da Meta) | Esperar |
| `sending` | O hub começou a submissão à Meta | Esperar |
| `accepted` | A Meta aceitou (`wamid` preenchido). A entrega segue em `deliveryState` | Acompanhar `whatsapp.message.status` |
| `failed` | Recusado, pelo hub ou pela Meta, com `errorCode` (e `metaErrorCode` quando veio da Meta) | Corrigir a causa antes de enviar de novo |
| `uncertain` | O hub não sabe se a Meta recebeu (timeout, queda de rede, 5xx, `131000`/`131016`/`133004`) | **Não reenvie às cegas.** Espere: se a Meta mandar um status com a referência do envio, a mensagem vira `accepted` sozinha. Se nada chegar, a equipe Matra encerra como `abandoned` |
| `abandoned` | Entrega desconhecida, encerrada pela equipe Matra | Reenviar é decisão humana (pode duplicar). Não é recusa da Meta |

`pacing` vem no `accepted` quando a Meta informa o ritmo: `accepted`, `held_for_quality_assessment` (a Meta segura a mensagem para avaliar qualidade) ou `paused` (entrega pausada).

`deliveryState` é o melhor status de entrega visto: `sent < delivered < read < played`. `failed` só aparece se nada melhor foi visto, e `delivered`/`read`/`played` substituem um `failed` anterior (entrega provada vale mais). `failed` em `deliveryState` não muda o `state` (a Meta aceitou a submissão e falhou depois).

**`uncertain` e a conciliação.** O hub manda o id da mensagem para a Meta em `biz_opaque_callback_data`, e o status da Meta o devolve. Quando o status chega para um envio `uncertain` (ou `abandoned`), a mensagem vira `accepted` e sai um `whatsapp.message.status` `source: "hub"` com `meta: { "reconciled": true }`. Se o envio já estava `abandoned`, o evento traz também `"overridesAdminResolution": true`: a evidência da Meta vale sobre a decisão da equipe Matra, e quem já reenviou pode ter uma duplicata. Um `accepted` com `overridesAdminResolution: true` vale sobre qualquer `abandoned` do mesmo `messageId`, qualquer que seja o `createdAt` dos dois: se a conciliação chega no instante em que o admin encerra o envio, o `abandoned` pode sair depois dele. Em testes com o app real, o campo voltou nos status de texto, interativo, reação, imagem, documento e template.

Status de um envio feito por outro workspace (o dono anterior de um número que mudou de workspace) não chega ao seu: o hub reconhece o `biz_opaque_callback_data` de outra conexão e, sem correspondência pelo `wamid` na sua, descarta o item (nenhum `whatsapp.message.status`, e a referência do outro envio não é guardada). O evento `source: "hub"` de uma transição sai logo depois dela; se essa emissão falhar, uma varredura emite o evento em até 1 h, com `timestamp` igual à hora da última alteração gravada do envio (em geral a da transição; um status da Meta que chegou no meio a adianta).

`errorCode` de envio recusado (trate valores desconhecidos como erro genérico):

| `errorCode` | Causa |
|---|---|
| `outside_service_window` | Meta `131047`: fora da janela de 24 h, só template |
| `user_opted_out` | Meta `131050`: o contato não quer receber marketing; não repita |
| `bsuid_not_supported` | Meta `131062`: o tipo não aceita BSUID; envie por telefone |
| `marketing_disabled_on_cloud_api` | Meta `131063` |
| `template_not_found_at_meta` | Meta `132001`; o hub ressincroniza os templates da conta |
| `template_paused` | Meta `132015` |
| `credential_invalid` | Meta `190`/`0`: o cliente precisa reconectar |
| `permission_denied` | Meta `10`/`200`/`131005` |
| `payment_method_required` | Meta `131042`: problema no método de pagamento da conta. Passado o nível gratuito, sem método válido a Meta não entrega mensagens de serviço; o cliente acerta o pagamento no Gerenciador do WhatsApp antes de enviar de novo |
| `rate_limited` | 8 recusas seguidas por limite da Meta |
| `connection_inactive`, `credential_unavailable`, `template_not_approved`, `media_expired` | A situação mudou entre o pedido e o envio |
| `meta:<código>` | Outra recusa da Meta, com o código dela (`meta:unknown` sem código) |
| `resolved_by_admin` | Em `abandoned` |

## Ler envios

| Método e rota | O que faz | Respostas |
|---|---|---|
| `GET …/messages` | Lista (paginada); filtros `idempotencyKey` e `state` | 200, 404 |
| `GET …/messages/:messageId` | Busca um envio | 200, 404 `MESSAGE_NOT_FOUND` |

## Parâmetros de template

`parameters` tem `header?`, `body?` e `buttons?`:

- `header`: `{ type: "text", value }`, `{ type: "image" | "video" | "document", mediaId, filename? }` (mídia do hub) ou `{ type: "location", latitude, longitude, name?, address? }`. Tem de ser do formato do header do template.
- `body`: lista de `{ name?, value }`. Template posicional: os `n` valores em ordem (ou todos com `name` de `1` a `n`). Template nomeado: todo valor com `name`, o mesmo conjunto do template, em qualquer ordem.
- `buttons`: lista de `{ index, type, value }` com `type` `quick_reply` (payload), `url` (sufixo da URL dinâmica; o hub faz o encode) ou `copy_code` (cupom até 20). Em template `AUTHENTICATION`, o código vai no botão de índice dele com `type: "url"`.

O hub recusa com `TEMPLATE_COMPONENT_UNSUPPORTED` o que não preenche: carrossel, oferta por tempo limitado, botões de Flow, catálogo e MPM, botão OTP fora do preset copy code, URL com variável fora do fim, e parâmetro `currency` ou `date_time`. A detecção de `currency`/`date_time` é pelo template em cache; se escapar, a Meta recusa no envio (`failed` com `meta:<código>`).

## Confirmação de leitura

`POST …/read-receipts` com `{ wamid, typingIndicator? }`: marca a mensagem recebida (e as anteriores) como lida; com `typingIndicator: true` mostra também "digitando". Síncrono, no limite de envio.

| Status | `code` | Quando |
|---|---|---|
| 200 | | `{ "success": true }` |
| 400 | `INVALID_MESSAGE_ID` | a Meta não reconhece o `wamid` (`131009`) |
| 422 | `META_READ_REJECTED` | outra recusa da Meta, com `metaErrorCode` |
| 502 | `META_UNAVAILABLE` | resposta incerta; repetir é seguro |

## Janela de atendimento

`GET …/windows?userId=<BSUID>` ou `?phone=<dígitos>` → `{ "window": { "open": true, "expiresAt": "...", "freeEntryPoint": null } }`, ou `{ "window": null }` sem registro. `freeEntryPoint` é `{ "expiresAt": "..." }` enquanto vale uma janela de ponto de entrada gratuito (ver [Custos](https://metahub.matratecnologia.com/docs/custos.md)); ela só muda o preço, não libera mensagem livre fora das 24 h. A janela abre ou estende por 24 h a cada mensagem recebida do contato (`data.window` do `whatsapp.message.received` traz o `expiresAt`); eco, histórico, mensagem de sistema e de grupo não contam.

Lacuna conhecida: a Meta também reabre a janela quando o contato **liga** para o número, e o hub não vê ligações. Para quem usa Calling, um `422 OUTSIDE_SERVICE_WINDOW` pode recusar um envio que a Meta aceitaria. Sem registro de janela o hub não bloqueia: o envio segue e a Meta decide.
