# Ruvz API v1 — contexto para implementação com IA

Este arquivo é uma referência autocontida para fornecer a uma LLM ao implementar uma integração com a API da Ruvz. A API é REST, usa JSON UTF-8 e a base é `https://api.ruvz.com.br/v1`.

## Regras essenciais

1. Envie `Authorization: Bearer <token>` em toda chamada. Tokens começam com `rvz_live_` e são criados no painel da Ruvz.
2. Sempre informe `from`: o número WhatsApp conectado ou o usuário Instagram de origem.
3. Quando uma rota endereça uma pessoa, informe exatamente um de `to` (telefone), `bsuid` (contato Meta sem telefone) ou `instagram_id`.
4. Nunca use `flow_id`, `account_id`, `conversation_id` ou `contact_id` como entrada. Eles aparecem nas respostas e nos eventos apenas como informação; nenhuma rota os aceita. Endereça sempre por `from` + `to`/`bsuid`/`instagram_id`.
5. `POST /messages` retornar `200` significa **registrado e enfileirado**, não entregue. A confirmação chega em `message.status`.
6. Texto livre só pode ser enviado até 24h após a última mensagem do contato, nos dois canais. Fora da janela, no WhatsApp use um modelo aprovado; no Instagram não há modelos, então é preciso esperar o contato escrever de novo.
7. Em todos os envios, envie uma `Idempotency-Key` única e estável para a mensagem de negócio.
8. Antes de enviar um modelo, descubra o nome, a quantidade e a ordem dos parâmetros em `GET /templates`. Nunca deduza `parameters` do texto do corpo nem fixe a contagem no código: o modelo pode ser alterado na Meta sem passar por você.

## Autenticação

```http
Authorization: Bearer rvz_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```

Tokens podem ter escopo `messages:read`, `messages:write`, ou ambos. Token inválido, revogado ou expirado responde `401 invalid_token`.

## Endereçamento

| Campo          | Uso                                                                                             |
| -------------- | ----------------------------------------------------------------------------------------------- |
| `from`         | Obrigatório. Número conectado ou `@usuario` do Instagram. Formatação de telefone é normalizada. |
| `to`           | Telefone do contato. Canal WhatsApp.                                                            |
| `bsuid`        | ID Meta de um contato WhatsApp sem telefone exposto.                                            |
| `instagram_id` | ID do contato no Instagram.                                                                     |

Erros de resolução: `404 unknown_sender`, `404 unknown_recipient`, `404 no_conversation`, `409 ambiguous_recipient`.

## Rotas

| Método | Rota             | Escopo           | Finalidade                                          |
| ------ | ---------------- | ---------------- | --------------------------------------------------- |
| POST   | `/messages`      | `messages:write` | Envia texto, mídia ou modelo.                       |
| GET    | `/conversations` | `messages:read`  | Lista conversas ou retorna uma conversa endereçada. |
| GET    | `/messages`      | `messages:read`  | Histórico de uma conversa.                          |
| GET    | `/contacts`      | `messages:read`  | Lista contatos ou retorna um contato endereçado.    |
| GET    | `/templates`     | `messages:read`  | Modelos do número, com contagem e ordem dos parâmetros. |
| POST   | `/templates`     | `messages:write` | Envia um modelo novo para aprovação da Meta.        |
| POST   | `/templates/sync`| `messages:write` | Repuxa os modelos da Meta para o número.            |
| POST   | `/media`         | `messages:write` | Upload multipart e criação de `media_id`.           |
| GET    | `/media/:id`     | `messages:read`  | Gera nova URL assinada de uma mídia.                |

Listagens aceitam `limit` (padrão 50, máximo 200) e `cursor`. Uma resposta paginada tem a forma `{ "data": [...], "next_cursor": "..." }`; ausência de `next_cursor` significa fim da lista.

## Enviar mensagens

`POST /messages` aceita exatamente um modo de conteúdo: `text`, `media`/`media_id` ou `template_name`. Dois modos, ou `media` junto de `media_id`, retornam `400 invalid_request`.

### Texto

```bash
curl -X POST https://api.ruvz.com.br/v1/messages \
  -H "Authorization: Bearer $RUVZ_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: pedido-8842-status" \
  -d '{
    "from": "5511988880000",
    "to": "5511999990000",
    "text": "Seu pedido saiu para entrega."
  }'
```

### Mídia por URL ou base64

`media` pode ser uma URL pública `https://` (até 16MB) ou um data URI base64 (até 5MB decodificado). Campos opcionais: `media_type` (`image`, `video`, `audio`, `document`), `caption`, `file_name`.

```json
{
  "from": "5511988880000",
  "to": "5511999990000",
  "media": "https://exemplo.com/nota-fiscal.pdf",
  "caption": "Segue a nota fiscal",
  "file_name": "nota-fiscal.pdf"
}
```

```json
{
  "from": "5511988880000",
  "to": "5511999990000",
  "media": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg...",
  "media_type": "image"
}
```

Para URL, uma falha de download ou tipo inválido é informada posteriormente por `message.status` com `status: "failed"`.

### Upload e `media_id`

```bash
curl -X POST https://api.ruvz.com.br/v1/media \
  -H "Authorization: Bearer $RUVZ_TOKEN" \
  -F "from=5511988880000" \
  -F "file=@./nota-fiscal.pdf;type=application/pdf"
```

Resposta:

```json
{
  "media_id": "YWNjXzEvZmxvdy...",
  "mime_type": "application/pdf",
  "file_name": "nota-fiscal.pdf",
  "size_bytes": 184320
}
```

Use o ID em um envio:

```json
{
  "from": "5511988880000",
  "to": "5511999990000",
  "media_id": "YWNjXzEvZmxvdy...",
  "file_name": "nota-fiscal.pdf"
}
```

### Modelo aprovado

Modelos aprovados são obrigatórios fora da janela do WhatsApp e podem iniciar uma nova conversa com `to`. `name` só é necessário quando esse telefone ainda não é um contato.

**Descubra primeiro.** `GET /templates` é a única fonte de qual modelo existe e quantos valores ele recebe.

```bash
curl -G https://api.ruvz.com.br/v1/templates \
  -H "Authorization: Bearer $RUVZ_TOKEN" \
  --data-urlencode "from=5511988880000"
```

```json
{
  "data": [
    {
      "name": "confirmacao_pedido",
      "language": "pt_BR",
      "category": "utility",
      "status": "approved",
      "body": "Olá {{1}}, seu pedido {{2}} foi confirmado.",
      "parameter_count": 2,
      "parameters": [
        { "index": 1, "name": "nome", "example": "Ana" },
        { "index": 2, "name": "pedido", "example": "8842" }
      ],
      "buttons": [{ "type": "QUICK_REPLY", "text": "Confirmar" }],
      "sendable": true,
      "updated_at": "2026-08-14T12:03:11Z"
    }
  ],
  "next_cursor": "2026-08-14T12:03:11Z/9f7c3e02-4a18-4bd5-91e6-2c840fb7a361"
}
```

Filtros: `status` (lista de `approved`, `pending`, `rejected`, `failed`, `paused`, `disabled`, ou `all`; **padrão `approved`**), `category` (`utility`, `marketing`, `authentication`), `search` (trecho do nome), `limit`, `cursor`. Valor desconhecido em `status` ou `category` retorna `400 invalid_request`.

Quatro pontos que decidem a implementação:

- `body` vem sempre **posicional** (`{{1}}`, `{{2}}`) — é a forma que `parameters` preenche por posição. O `name` de cada parâmetro é o nome com que o modelo foi escrito e serve para acertar a ordem; não é chave de envio e vem vazio em modelo criado direto na Meta. Não monte um objeto por nome.
- `parameter_count` conta **marcadores distintos**, não ocorrências: um corpo que repete `{{1}}` recebe um único valor. É o mesmo número que o envio valida.
- `sendable: false` diz que este modelo não pode ser enviado por esta API, e `unsupported_reason` diz por quê — `not_approved`, ou `header_unsupported` (cabeçalho de mídia criado na Meta, ou cabeçalho de texto com variável). Um modelo **aprovado** pode ter `sendable: false`; filtre por `sendable` ao montar um seletor.
- O `next_cursor` desta rota é uma string composta (`created_at/id`), como o de `/contacts` — repasse inteiro, sem interpretar. A listagem vem em ordem de criação, da mais recente para a mais antiga.

`POST /templates/sync?from=…` repuxa os modelos da Meta para o número. Os modelos são sincronizados quando o número é conectado, e esta rota é a **única** forma de um modelo criado no Gerenciador da Meta **depois** disso aparecer na listagem — chame-a quando um modelo que você vê na Meta não estiver aqui. É um upsert; repetir não duplica.

Responde `{ "created": 2, "updated": 3, "unchanged": 8, "failed": 0, "total": 13 }`. `unchanged` são os modelos que a Meta devolveu iguais aos que já temos: não são reescritos, para que o `updated_at` que você lê continue significando uma mudança de verdade. `failed` conta os que não conseguimos gravar (um deles não impede os outros). Falha na Meta responde `502 meta_error` e vale repetir; nenhum modelo gravado responde `500 internal_error`; sincronização já em andamento neste número responde `409 sync_in_progress`. Exige `messages:write`.

### Criar modelo

`POST /templates` envia um modelo novo para a revisão da Meta. Exige `messages:write`; mande `Idempotency-Key` como em qualquer escrita.

```json
{
  "from": "5511988880000",
  "name": "pedido_enviado",
  "language": "pt_BR",
  "category": "utility",
  "body": "Olá {{1}}, seu pedido {{2}} saiu para entrega.",
  "body_examples": ["Ana", "8842"],
  "footer": "Responda PARAR para não receber mais",
  "header_image_media_id": "YWNjXzEvZmxvdy...",
  "buttons": [
    { "type": "URL", "text": "Rastrear pedido", "url": "https://loja.com.br/rastreio" },
    { "type": "PHONE_NUMBER", "text": "Falar com a loja", "phone_number": "+5511999990000" },
    { "type": "QUICK_REPLY", "text": "Recebi" }
  ]
}
```

Responde `201` com o modelo no mesmo formato de `GET /templates`, com `status: "pending"` e `sendable: false`. A aprovação é da Meta e chega depois, pelo evento `template.status` — `APPROVED`, `REJECTED` (com `rejection_reason`) ou `FAILED`, quando a Meta recusa o envio do modelo antes da revisão. Não fique consultando `GET /templates` em loop.

- `name`: só minúsculas, dígitos e `_`. Não é corrigido — é o `template_name` que você vai usar no envio, então um nome fora do formato responde `400`.
- `category`: `utility` ou `marketing`. `authentication` não é aceito aqui: a Meta fixa o corpo desse tipo e exige um botão de código.
- `language`: padrão `pt_BR`.
- `body`: até 1024 caracteres. Variáveis em `{{1}}`, `{{2}}`… até `{{100}}` (ou nomeadas, `{{nome}}`, que a Meta recebe convertidas em posição). Número fora de 1 a 100 responde `400`.
- `body_examples`: um valor por variável, na ordem das posições — obrigatório quando há variável, porque a Meta revisa o modelo com esses valores no lugar. Quantidade diferente responde `400`.
- `footer`: uma linha, até 60 caracteres, sem variável.
- `header_image_media_id`: imagem de cabeçalho JPEG, PNG ou WebP. Suba antes por `POST /media` e passe o `media_id`.
- `buttons`: até 3 `QUICK_REPLY`, 2 `URL` e 1 `PHONE_NUMBER`; `text` até 25 caracteres. `URL` precisa ser um link fixo `http(s)://` — variável no link (`https://loja.com/{{1}}`) ainda não é aceita, porque o envio não preenche parâmetro de botão. `PHONE_NUMBER` leva código do país.

Um modelo com o mesmo `name` e `language` já existente no número responde `409 template_exists`, a não ser que o anterior esteja `failed` — esse nunca chegou à Meta e pode ser criado de novo.

**Depois envie**, com os valores na ordem da listagem.

```json
{
  "from": "5511988880000",
  "to": "5511999990000",
  "name": "Ana Souza",
  "template_name": "confirmacao_pedido",
  "parameters": ["Ana", "8842"]
}
```

Quantidade diferente de `parameter_count` retorna `400 invalid_template_parameters`; modelo inexistente ou não aprovado, `403 template_not_approved`; cabeçalho que a API não preenche, `422 template_header_unsupported`.

Todo envio bem-sucedido responde:

```json
{ "message_id": "9f7c3e02-4a18-4bd5-91e6-2c840fb7a361", "status": "pending" }
```

## Leituras

```bash
# Listar conversas de um número
curl -G https://api.ruvz.com.br/v1/conversations \
  -H "Authorization: Bearer $RUVZ_TOKEN" \
  --data-urlencode "from=5511988880000" \
  --data-urlencode "status=open" \
  --data-urlencode "limit=50"

# Histórico de uma conversa
curl -G https://api.ruvz.com.br/v1/messages \
  -H "Authorization: Bearer $RUVZ_TOKEN" \
  --data-urlencode "from=5511988880000" \
  --data-urlencode "to=5511999990000"

# Procurar contatos
curl -G https://api.ruvz.com.br/v1/contacts \
  -H "Authorization: Bearer $RUVZ_TOKEN" \
  --data-urlencode "from=5511988880000" \
  --data-urlencode "search=Ana"

# Renovar URL de mídia
curl https://api.ruvz.com.br/v1/media/YWNjXzEvZmxvdy... \
  -H "Authorization: Bearer $RUVZ_TOKEN"
```

Objetos retornados incluem: conversa (`id`, `contact_id`, `to`/`bsuid`/`instagram_id`, `channel`, `status`, `window_expires_at`, `last_message_at`, `created_at`); mensagem (`id`, `conversation_id`, `direction`, `type`, `text`, `media_id`, `media_mime_type`, `media_file_name`, `status`, `origin`, `created_at`); contato (`id`, `name`, `to`, `phone`, `bsuid`, `instagram_id`, `email`, `blocked`, `created_at`). Todo `id` é UUID.

Duas diferenças entre a leitura e os eventos:

- O `origin` da mensagem lida vale `contact`, `agent`, `ai`, `automated` ou `system` — **não** os valores do evento `message.sent`. `agent` cobre tanto um envio desta API quanto um envio feito na inbox: são o mesmo tipo de autor na mesma linha, e só o evento (publicado no momento do envio) separa `api` de `inbox`.
- `window_expires_at` está sempre presente na conversa. Sem janela aberta ele vem como `"0001-01-01T00:00:00Z"` — uma data válida que significa "nenhuma janela", não uma janela vencida. Trate o ano 1 como ausência.

`GET /media/:id` retorna `{ "media_id", "url", "expires_at" }`. A URL é assinada e vale 24 horas.

## Idempotência

Use `Idempotency-Key` em cada `POST /messages`. O header é **opcional** no servidor: um envio sem ele é aceito, não ganha proteção contra duplicidade e chega no evento `message.sent` sem `client_reference` — indistinguível de um envio feito na inbox. Se você não enviar sempre, use `origin` para saber a procedência. Chave acima de 255 caracteres retorna `400 invalid_idempotency_key`. Repetir mesma chave e mesmo corpo retorna a resposta original com `Idempotent-Replay: true`. A mesma chave com outro corpo retorna `409 idempotency_key_reuse`; enquanto o primeiro request está em curso, retorna `409 idempotency_in_flight`. A janela é de 24h.

## Webhooks

Cadastre uma URL HTTPS no fluxo. A Ruvz faz POST com JSON assinado. Responda `2xx` em até 10 segundos e processe o evento de modo assíncrono. A entrega é ao-menos-uma-vez e não possui ordenação garantida: deduplique por `event_id` e ordene por `occurred_at` quando necessário.

Envelope comum:

```json
{
  "api_version": "2026-09-01",
  "event_id": "evt_msg_message.received_9f7c3e02-4a18-4bd5-91e6-2c840fb7a361",
  "delivery_id": "dlv_evt_msg_message.received_9f7c3e02-4a18-4bd5-91e6-2c840fb7a361_1",
  "type": "message.received",
  "occurred_at": "2026-09-01T14:22:31.412345678Z",
  "account_id": "3f2a9c14-8b7d-4e61-9a02-5c1de7b40f88",
  "flow_id": "a41c6d92-0e35-47b8-8f1a-6d2b9c73e015",
  "data": {}
}
```

Três propriedades válidas para todos os eventos:

- `data.from` aparece em todos os tipos — inclusive em `message.status`, `message.reaction`, `message.deleted`, `message.edited` e nos eventos de contato. É o número conectado do fluxo, o mesmo valor que se devolve como `from` ao enviar. Não é garantido: numa falha transitória ao resolver o número do fluxo o evento é entregue **sem** o campo, porque perder o evento inteiro seria pior. O que resta depende do tipo: mensagem e conversa levam `conversation_id`; contato leva o endereço mas não a conversa; `message.status`, `message.deleted`, `message.edited` e `message.reaction` não levam endereço algum.
- Todo identificador da Ruvz (`account_id`, `flow_id`, `message_id`, `conversation_id`, `contact_id`) é um UUID de 36 caracteres, sem prefixo.
- `event_id` e `delivery_id` são strings opacas de tamanho variável, em ASCII: não derive significado do formato. `delivery_id` é o `event_id` mais o número da tentativa, a partir de `1`, e é o mesmo valor do cabeçalho `X-Ruvz-Delivery-Id`.

Headers: `X-Ruvz-Event`, `X-Ruvz-Delivery-Id`, `X-Ruvz-Timestamp`, `X-Ruvz-Signature-256: sha256=<hex>`. Verifique HMAC-SHA256 de `"<timestamp>.<corpo cru>"`, comparação em tempo constante e desvio máximo de 5 minutos.

Há até 5 tentativas de entrega. `410` desativa o webhook; os demais erros ou timeout são repetidos com backoff exponencial (10s a 600s). Após 100 falhas consecutivas as entregas são pausadas e o estado aparece no painel.

## Todos os eventos

| Evento                | Ação esperada                                                                                                                                                                                                                                                                                                                                                                          |
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `message.received`    | Criar/atualizar mensagem inbound. `data` traz endereço, mensagem, tipo, texto e mídia quando aplicável. `status` é `delivered` (chegou ao número conectado), nunca `received`.                                                                                                                                                                                                         |
| `message.sent`        | Criar/atualizar mensagem outbound. `origin` é `api`, `inbox`, `echo` ou `campaign`; `client_reference` é a chave de idempotência quando o envio foi seu. `status` é `pending` em `api`/`inbox`/`campaign`; em `echo` é `sent`, ou já `delivered`/`read` se o recibo da Meta chegar antes do eco. Em `echo` o `type` pode ainda ser `sticker`, `location`, `contacts` ou `unsupported`. |
| `message.status`      | Atualizar status da mensagem: `sent`, `delivered`, `read` ou `failed`. Em falha, usar `error_code` e `error_message`.                                                                                                                                                                                                                                                                  |
| `message.deleted`     | Apagar/ocultar conteúdo local da mensagem identificada por `message_id`.                                                                                                                                                                                                                                                                                                               |
| `message.edited`      | Atualizar o texto existente, sem criar uma mensagem nova.                                                                                                                                                                                                                                                                                                                              |
| `message.reaction`    | Atualizar reação; `emoji` vazio significa remoção.                                                                                                                                                                                                                                                                                                                                     |
| `contact.created`     | Fazer upsert do contato.                                                                                                                                                                                                                                                                                                                                                               |
| `contact.updated`     | Atualizar dados e respeitar `is_active`.                                                                                                                                                                                                                                                                                                                                               |
| `contact.deleted`     | Marcar o contato como inativo (`is_active: false`). A exclusão é lógica.                                                                                                                                                                                                                                                                                                               |
| `conversation.opened` | Fazer upsert e marcar aberta; também pode indicar reabertura.                                                                                                                                                                                                                                                                                                                          |
| `conversation.closed` | Marcar a conversa como fechada.                                                                                                                                                                                                                                                                                                                                                        |
| `template.status`     | Marcar o modelo como enviável ou não e parar de usá-lo quando não for. `status` é o verbo da Meta sem tradução e a lista cresce do lado deles — trate valor desconhecido como não enviável.                                                                                                                                                                                             |
| `number.limit`        | Ajustar o ritmo de disparo ao novo limite diário de conversas iniciadas.                                                                                                                                                                                                                                                                                                               |
| `account.alert`       | Alertar o operador: a conta WhatsApp Business foi restrita, violou política, foi banida ou reativada. É o único aviso que chega antes de os envios pararem.                                                                                                                                                                                                                            |

Campos relevantes por grupo:

- Eventos de mensagem: `message_id`, `conversation_id`, `contact_id`, `from`, `to`/`bsuid`/`instagram_id`, `direction`, `type`, `status`, `origin`, `text`, `created_at`; quando aplicável `provider_message_id` (wamid da Meta), `reply_to`, `transcript`, `template_name`, `client_reference`; mídia pode incluir `media_id`, `media_url`, `media_mime_type`, `media_file_name`.
- `message.status`: `message_id`, `conversation_id`, `from`, `status`, `occurred_at`, e em falhas `error_code`/`error_message`. Não traz `to` nem `contact_id` — correlacione pelo `message_id`.
- `message.deleted`: `message_id`, `conversation_id`, `from`, `deleted_by`.
- `message.edited`: `message_id`, `conversation_id`, `from`, `text`, `edited_by`.
- `message.reaction`: `message_id`, `conversation_id`, `from`, `emoji`, `by`.
- Eventos de contato: `contact_id`, `from`, `name`, `to`, `bsuid`, `phone`, `instagram_id`, `email`, `created_at`, `updated_at`, `is_active`. `created_at` não muda; `updated_at` é o instante persistido da alteração e `occurred_at` é o instante de publicação. Em `contact.deleted`, `is_active` é `false`.
- Eventos de conversa: `conversation_id`, `contact_id`, `from`, `to`/`bsuid`/`instagram_id`, `channel`, `status`, `window_expires_at`.
- `template.status`: `template_id`, `template_name`, `language`, `from`, `status`, e quando a Meta informa `category`, `reason`, `rejection_reason`, `rejection_recommendation`, `pause_title`, `pause_description`, `disabled_at`. Valores de `status` incluem `APPROVED`, `PENDING`, `REJECTED`, `PAUSED`, `FLAGGED`, `DISABLED`, `ARCHIVED`, `REINSTATED` e outros que a Meta acrescentar, mais `FAILED`, que é nosso: a Meta recusou um modelo de `POST /templates` antes da revisão, e `rejection_reason` traz o motivo.
- `number.limit`: `from`, `event` (`ONBOARDING` ou `THROUGHPUT_UPGRADE`), `limit_tier`, e quando aplicável `previous_limit_tier` e `max_daily_conversations`. Em `TIER_UNLIMITED` vem `unlimited: true` no lugar do número; em `TIER_NOT_SET` não vem nenhum dos dois.
- `account.alert`: `from`, `waba_id`, `event`, e conforme o caso `restrictions[]` (com `type` e `expires_at`), `ban_state`, `ban_date`, `violation_type`. Uma conta hospeda vários números e cada número é um webhook, então o mesmo aviso chega uma vez por fluxo, cada cópia com o seu `event_id`.

Notas sobre `data` que costumam derrubar integração:

- `to` e `bsuid` **não são excludentes nos eventos**. Em coexistência com o aplicativo WhatsApp Business os dois chegam juntos em `message.received`. A regra de "exatamente um" vale para os requests que você faz.
- `type` em mensagem recebida: `text`, `image`, `audio`, `video`, `document`, `sticker`, `location`, `contacts`, `unsupported`. Em `message.sent` os seus envios produzem `text`, `image`, `audio`, `video`, `document` ou `template`, mas um evento `origin: echo` pode trazer qualquer um dos tipos de entrada. `location` e `contacts` chegam **sem** campos estruturados (sem coordenadas, sem vCard) — o rótulo legível vem em `text`.
- `api_version` é hoje a **mesma** para todos os cadastros; não há fixação de versão por webhook. Compare o campo em cada entrega e rejeite um valor desconhecido em vez de assumir que o formato nunca muda sob um cadastro existente.
- Os três eventos de conta — `template.status`, `number.limit`, `account.alert` — repassam o vocabulário da Meta sem traduzir, e a Meta acrescenta valores. Ignore um valor desconhecido em vez de responder erro: respostas de erro repetidas pausam o seu webhook após 100 falhas consecutivas, e aí você perde também os eventos de mensagem.
- `deleted_by` / `edited_by` / `by` valem `"contact"`, `"whatsapp-business-app"` ou um UUID de usuário da Ruvz.
- Numa mensagem com mídia, `media_url` pode faltar (falha ao assinar) mantendo `media_id`, e os dois podem faltar juntos quando o arquivo não chegou. Trate a ausência; não suponha que `media_id` está sempre lá.

Tipos MIME — as listas dos modos **upload/base64** e **URL** são diferentes. Upload e base64: `image/jpeg`, `image/png`, `image/webp`, `image/gif`, `audio/aac`, `audio/amr`, `audio/mp4`, `audio/mpeg`, `audio/ogg`, `audio/webm`, `video/mp4`, `video/3gpp`, `application/pdf`, `application/msword`, `.docx`, `.xlsx`, `.pptx`, `text/csv`. Modo URL, mais restrito: `image/jpeg`, `image/png`, `image/webp`, `image/gif`, `video/mp4`, `audio/ogg`, `audio/mpeg`, `audio/mp4`, `application/pdf` — fora dessa lista a falha chega em `message.status` com `failed`, não no `200`, porque a verificação é pós-download.

## Erros e limites

Erros têm corpo `{ "error": { "code": "...", "message": "..." } }`. Trate o `code`, não o texto. Erros frequentes: `invalid_request`, `window_expired`, `template_not_approved`, `invalid_template_parameters`, `unsupported_media_type`, `media_type_mismatch`, `file_too_large` (413 **ou** 400, mesmo `code` — trate por `code`, nunca por status), `rate_limited` e `channel_disconnected`.

Os menos frequentes, que ainda assim precisam de tratamento:

| Código                    | HTTP | Significado                                                                                                                                          |
| ------------------------- | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `missing_token`           | 401  | Header `Authorization` ausente ou sem um token `rvz_live_`.                                                                                          |
| `account_inactive`        | 403  | Conta desativada. Vale em toda a árvore `/v1`, inclusive leitura.                                                                                    |
| `trial_expired`           | 403  | Teste encerrado sem assinatura ativa.                                                                                                                |
| `plan_expired`            | 403  | Assinatura vencida. Repetir não resolve; renove no painel.                                                                                           |
| `ambiguous_sender`        | 409  | Mais de um fluxo da conta está conectado ao número em `from`. Remédio diferente de `ambiguous_recipient`: o duplicado está no fluxo, não no contato. |
| `invalid_idempotency_key` | 400  | `Idempotency-Key` fora do formato aceito.                                                                                                            |
| `token_misconfigured`     | 403  | O token não tem fluxo algum atribuído. Não adianta repetir: gere um novo no painel.                                                                  |
| `unsupported_channel`     | 422  | A operação não existe neste canal. Modelos são exclusivos do WhatsApp.                                                                               |
| `template_header_unsupported` | 422 | O modelo tem cabeçalho de mídia criado na Meta, ou cabeçalho de texto com variável. Consulte `sendable` em `GET /templates` antes de enviar.     |
| `meta_error`              | 502  | A Meta não respondeu (hoje, apenas em `POST /templates/sync`). Vale repetir.                                                                          |
| `template_exists`         | 409  | `POST /templates` com `name` e `language` que o número já tem. Escolha outro nome.                                                                   |
| `sync_in_progress`        | 409  | Já há uma sincronização em andamento neste número. Não repita — a passagem que você queria está acontecendo.                                          |
| `internal_error`          | 500  | Falha nossa. A mensagem não foi registrada; repetir com a **mesma** `Idempotency-Key` é seguro.                                                      |
| `storage_unavailable`     | 503  | Armazenamento de mídia indisponível. Repita depois.                                                                                                  |

Limites: 500 requests/s por token (pico 1000); upload e mídia por URL até 16MB; base64 até 5MB decodificado; URL de mídia e idempotência válidos por 24h.
