# API Unred — WhatsApp oficial > Conecte o seu sistema ao WhatsApp pela API oficial da Meta através da Unred. Base: https://api.unred.com.br/v1. Contrato OpenAPI: https://docs.unred.com.br/desenvolvedores/openapi.json ## Visão geral A API da Unred conecta o seu sistema (ERP, CRM, loja, sistema próprio) ao **WhatsApp pela API oficial da Meta** (WhatsApp Cloud API). A Unred cuida do número, da conexão com a Meta, dos arquivos, do reenvio de avisos e do histórico; o seu sistema só chama endpoints simples em JSON. - **Base:** `https://api.unred.com.br/v1` (também responde em `https://unred.com.br/api/v1`) - **Formato:** JSON em UTF-8, campos em `snake_case`, datas em ISO-8601 (UTC), telefones com DDI só com números (`5511999990000`). - **Contrato completo:** [OpenAPI 3.1](/desenvolvedores/openapi.json) — importe no Postman, Insomnia ou gere um cliente em qualquer linguagem. - **Tudo também aparece no Unred:** cada mensagem enviada ou recebida pela API fica no chat do Unred, então a sua equipe pode acompanhar e responder por lá. **Quem paga o quê.** A Meta cobra as mensagens dela direto no cartão cadastrado na sua conta do Facebook (Meta Business), pela tabela oficial dela, sem margem nossa. A Unred cobra a mensalidade do plano. A API mostra a categoria e se a Meta cobra cada mensagem (`pricing`). ## Começar em 5 minutos **1. Crie a chave.** No Unred: *Configurações › Conexão com seu sistema › Nova chave*. A chave aparece **uma vez** — guarde no cofre de segredos do seu sistema. **2. Mande a primeira mensagem.** Pra quem ainda não falou com você nas últimas 24h, o WhatsApp só aceita **template** aprovado: ```bash curl https://api.unred.com.br/v1/messages \ -H "Authorization: Bearer unr_live_SUA_CHAVE" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: pedido-48213-aviso" \ -d '{ "to": "5511999990000", "type": "template", "template": { "name": "pedido_confirmado", "language": "pt_BR", "components": [ { "type": "body", "parameters": [ { "type": "text", "text": "Maria" }, { "type": "text", "text": "48213" } ]} ] }, "client_reference": "pedido-48213" }' ``` A resposta volta na hora (**202**) com o id e `"status": "queued"`: ```json { "id": "cm2x8k1q50001", "object": "message", "direction": "outbound", "status": "queued", "type": "template", "contact": { "phone": "5511999990000", "name": null }, "client_reference": "pedido-48213", "wamid": null, "created_at": "2026-10-09T13:00:00.000Z" } ``` **3. Receba o resultado.** Cadastre um webhook (tela ou `POST /v1/webhooks`) e você recebe `message.sent`, `message.delivered`, `message.read` ou `message.failed` — e tudo que a pessoa responder, em `message.received`. **Quer testar sem enviar nada?** Use uma chave `unr_test_…`: as respostas e os webhooks são iguais (com `"livemode": false`), e nenhuma mensagem sai. ## Autenticação e chaves Toda chamada leva o cabeçalho: ``` Authorization: Bearer unr_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` | Prefixo | O que faz | |---|---| | `unr_live_` | Envia de verdade pela Meta. | | `unr_test_` | **Não envia nada e não lê nem mexe em dado real.** Simula o envio (`POST /v1/messages`, com webhooks `livemode: false`), consulta as próprias mensagens de teste, lista templates e números e simula o descadastro. O resto (listar mensagens reais, relatório, webhooks, mídia, criar ou apagar template) responde `403`: use a chave de produção. | - Guardamos só uma impressão (hash) da chave — se perder, crie outra e revogue a antiga. - Revogar é imediato: a próxima chamada com ela já recebe `401`. - Cada resposta traz `Request-Id`. Mande esse valor se precisar de suporte. ## Enviar mensagens `POST /v1/messages` aceita **todo tipo que a API oficial do WhatsApp aceita**, no mesmo formato da Meta: você informa `type` e o objeto de mesmo nome. Campos novos que a Meta lançar passam direto. **A janela de 24 horas.** Depois que a pessoa manda uma mensagem, você pode responder com qualquer tipo por 24h. Fora disso, só `template` aprovado — a API recusa na hora com `outside_customer_service_window`, em vez de deixar falhar depois. **Campos comuns:** `to` (obrigatório), `type` (obrigatório), `channel_id` (só se a conta tiver mais de um número), `reply_to` (id da mensagem citada), `client_reference` (seu identificador, volta em todo webhook), `contact_name` (nome, se a pessoa ainda não existir). **Texto** ```json { "to": "5511999990000", "type": "text", "text": { "body": "Seu pedido saiu para entrega 🚚", "preview_url": true } } ``` **Template com imagem no cabeçalho e botão de link** ```json { "to": "5511999990000", "type": "template", "template": { "name": "boleto_disponivel", "language": "pt_BR", "components": [ { "type": "header", "parameters": [ { "type": "image", "image": { "link": "https://seu-site.com/banner.png" } } ] }, { "type": "body", "parameters": [ { "type": "text", "text": "R$ 189,90" } ] }, { "type": "button", "sub_type": "url", "index": "0", "parameters": [ { "type": "text", "text": "abc123" } ] } ] } } ``` Parâmetros com nome (`"parameter_name": "cliente"`), botão de copiar código, carrossel e oferta por tempo limitado seguem exatamente o formato da Meta. **Imagem, vídeo, áudio, documento, figurinha** ```json { "to": "5511999990000", "type": "document", "document": { "link": "https://seu-site.com/nota-48213.pdf", "filename": "Nota 48213.pdf", "caption": "Sua nota fiscal" } } ``` Use o seu próprio link HTTPS ou suba o arquivo em `POST /v1/media`. Áudio com `"voice": true` (OGG/Opus) aparece como mensagem de voz. **Localização** ```json { "to": "5511999990000", "type": "location", "location": { "latitude": -23.5614, "longitude": -46.6559, "name": "Loja Paulista", "address": "Av. Paulista, 1000" } } ``` **Contato (cartão)** ```json { "to": "5511999990000", "type": "contacts", "contacts": [ { "name": { "formatted_name": "Suporte", "first_name": "Suporte" }, "phones": [ { "phone": "+551130000000", "type": "WORK" } ] } ] } ``` **Botões (até 3)** ```json { "to": "5511999990000", "type": "interactive", "interactive": { "type": "button", "body": { "text": "Confirma a entrega amanhã?" }, "action": { "buttons": [ { "type": "reply", "reply": { "id": "confirmar-48213", "title": "Confirmo" } }, { "type": "reply", "reply": { "id": "remarcar-48213", "title": "Remarcar" } } ] } } } ``` Quando a pessoa toca, chega `message.received` com `button_reply_id: "confirmar-48213"`. **Lista, botão de link e outros interativos** — `"type": "list"`, `"cta_url"`, `"flow"`, `"product"`, `"product_list"`, `"catalog_message"`, `"location_request_message"`: mesmo formato da Meta. **Responder citando** — `"reply_to": ""` em qualquer envio. **Reação** ```json { "to": "5511999990000", "type": "reaction", "reaction": { "message_id": "cm2x8k1q50001", "emoji": "👍" } } ``` Emoji vazio tira a reação. A resposta é `200` (reação não vira mensagem nova). **Marcar como lida** — `POST /v1/messages/{id}/read` com `{ "typing_indicator": true }` mostra o tique azul e "digitando…" até a sua resposta sair. ## Receber mensagens (webhooks) Cadastre a URL do seu sistema (HTTPS) e escolha os eventos — `"*"` assina todos. Cada evento chega como `POST`: ```json { "id": "evt_3f2a9c0e8b7d4c1a9e6f5b4a3c2d1e0f", "object": "event", "type": "message.received", "api_version": "v1", "created_at": "2026-10-09T13:05:12.000Z", "livemode": true, "data": { "id": "cm2x8m3r90007", "object": "message", "direction": "inbound", "status": "received", "channel_id": "cm1abc...", "type": "image", "contact": { "phone": "5511999990000", "name": "Maria" }, "text": "Segue o comprovante", "media": { "url": "https://media.unred.com.br/...", "mime_type": "image/jpeg", "file_name": null, "size_bytes": 182344 }, "button_reply_id": null, "reply_to": null, "wamid": "wamid.HBgN...", "meta": { "...": "o objeto ORIGINAL que a Meta mandou" } } } ``` - **Mídia com link permanente.** O link da Meta vence em 5 minutos; o `media.url` que mandamos não vence. O evento de uma mensagem com arquivo sai quando o arquivo já está guardado (normalmente segundos). - **Nada se perde.** `data.meta` traz o objeto original da Meta — formulário (Flow), pedido de catálogo, aviso de troca de número, tudo. - **Responda 2xx em até 10 segundos.** Processe depois (fila). Qualquer outra resposta faz a gente reenviar. | Evento | Quando chega | |---|---| | `message.received` | A pessoa mandou uma mensagem (texto, mídia, localização, contato, toque em botão, formulário, pedido…). Mídia chega com link permanente. | | `message.reaction` | A pessoa reagiu (ou tirou a reação) a uma mensagem. `emoji` vazio = tirou. | | `message.edited` | A pessoa editou uma mensagem que tinha mandado. `text` é a versão nova. | | `message.deleted` | A pessoa apagou uma mensagem para todos. | | `message.sent` | A Meta aceitou a mensagem e ela saiu. | | `message.delivered` | A mensagem chegou no celular da pessoa. | | `message.read` | A pessoa leu (só quando ela deixa a confirmação de leitura ligada). | | `message.failed` | A mensagem não foi entregue. `error.code` traz o código da Meta. | | `template.status_updated` | Um template foi aprovado, recusado, pausado ou desativado pela Meta. | | `template.category_updated` | A Meta mudou a categoria de um template — isso muda o preço dele. | | `template.quality_updated` | A nota de qualidade de um template mudou. | | `template.components_updated` | O conteúdo de um template foi alterado. | | `channel.updated` | Mudou algo no número: qualidade, nome de exibição, limite de envio ou alerta da conta. | | `contact.marketing_preference_updated` | A pessoa parou (ou voltou a aceitar) mensagens de marketing pelo próprio WhatsApp. | Eventos `message.*` trazem o objeto Mensagem em `data`. Os de template, número e preferência trazem `{ "meta": }`. ## Status e cobrança de cada mensagem `queued` (na fila) → `sent` (a Meta aceitou) → `delivered` (chegou no celular) → `read` (leu, se a pessoa deixa a confirmação ligada). Ou `failed`, com `error.code` da Meta (ex.: `131026` número sem WhatsApp, `131047` fora da janela de 24h, `131049` limite de marketing por pessoa). Toda mensagem traz `pricing`, informado pela própria Meta: ```json "pricing": { "category": "utility", "billable": true } ``` `billable: false` = a Meta não cobra (ex.: respostas dentro da janela de 24h). É a mesma conta da fatura da Meta. ## Conferir a assinatura do webhook Todo evento vem assinado com o segredo do webhook (`whsec_…`, mostrado só na criação): ``` Unred-Signature: t=1760015112,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd ``` `v1` = HMAC-SHA256 em hexadecimal de `"."`. Recuse se a conta não bater ou se `t` tiver mais de 5 minutos. **Node.js** ```js import crypto from "node:crypto" function assinaturaValida(corpoBruto, cabecalho, segredo) { const p = Object.fromEntries(cabecalho.split(",").map((x) => x.split("="))) if (Math.abs(Date.now() / 1000 - Number(p.t)) > 300) return false const esperado = crypto.createHmac("sha256", segredo).update(`${p.t}.${corpoBruto}`).digest("hex") const a = Buffer.from(esperado) const b = Buffer.from(p.v1 ?? "") return a.length === b.length && crypto.timingSafeEqual(a, b) } ``` **Python** ```python import hmac, hashlib, time def assinatura_valida(corpo_bruto: bytes, cabecalho: str, segredo: str) -> bool: p = dict(x.split("=", 1) for x in cabecalho.split(",")) if abs(time.time() - int(p["t"])) > 300: return False esperado = hmac.new(segredo.encode(), f'{p["t"]}.'.encode() + corpo_bruto, hashlib.sha256).hexdigest() return hmac.compare_digest(esperado, p.get("v1", "")) ``` **PHP** ```php function assinaturaValida(string $corpo, string $cabecalho, string $segredo): bool { parse_str(str_replace(',', '&', $cabecalho), $p); if (abs(time() - (int)$p['t']) > 300) return false; $esperado = hash_hmac('sha256', $p['t'] . '.' . $corpo, $segredo); return hash_equals($esperado, $p['v1'] ?? ''); } ``` Use o **corpo bruto** (os bytes exatos recebidos), não o JSON já convertido. ## Reenvio, repetição e duplicados - **A gente reenvia.** Se o seu servidor não responder 2xx, o evento volta em 1 min, 5 min, 25 min, 2 h e 10 h. O histórico fica em `GET /v1/webhooks/{id}/deliveries`, e dá pra reenviar na hora (`…/deliveries/{id}/retry`). - **Você pode repetir sem medo.** Mande `Idempotency-Key` em todo `POST`: a mesma chave (por 24h) devolve a MESMA resposta, sem enviar de novo — com o cabeçalho `Idempotent-Replayed: true`. Use algo do seu lado, como `pedido-48213-aviso`. - **Ignore repetidos.** Cada evento tem `id` único (`evt_…`) — guarde e descarte se vier de novo. - **Ordem não é garantida.** `delivered` pode chegar antes de `sent`. Use os horários (`sent_at`, `delivered_at`, `read_at`) do objeto, não a ordem de chegada. ## Templates - `GET /v1/templates` — lista direto da Meta (status, categoria, qualidade, componentes). - `POST /v1/templates` — submete pra aprovação, no formato da Meta. O resultado chega por `template.status_updated`. - `DELETE /v1/templates/{name}` — apaga (todos os idiomas, ou um com `hsm_id`). ```json { "name": "pedido_confirmado", "language": "pt_BR", "category": "UTILITY", "components": [ { "type": "BODY", "text": "Oi {{1}}, seu pedido {{2}} foi confirmado.", "example": { "body_text": [ [ "Maria", "48213" ] ] } } ] } ``` **Categoria decide o preço.** A Meta cobra marketing bem mais caro que utilidade (aviso de pedido, boleto, entrega). Se ela reclassificar um template, você recebe `template.category_updated`. ## Mídia `POST /v1/media` (`multipart/form-data`, campo `file`) guarda o arquivo e devolve um `url` permanente pra usar em qualquer envio — suba uma vez e mande pra quantas pessoas quiser. | Tipo | Formatos | Máximo | |---|---|---| | Imagem | JPEG, PNG | 5 MB | | Áudio | AAC, AMR, MP3, M4A, OGG (Opus) | 16 MB | | Vídeo | MP4, 3GP (H.264 + AAC) | 16 MB | | Documento | PDF, DOC(X), XLS(X), PPT(X), TXT | 50 MB pelo upload (até 100 MB pelo seu próprio link) | | Figurinha | WebP | 100 KB (animada: 500 KB) | ## Descadastro de marketing Quem pediu pra não receber propaganda não recebe **template de marketing** pela API (`recipient_opted_out`). Avisos de utilidade e autenticação continuam. - A marca nasce sozinha quando a pessoa toca em "parar promoções" no WhatsApp (evento `contact.marketing_preference_updated`). - `POST /v1/marketing-opt-outs` `{ "phone": "5511999990000" }` descadastra; `DELETE /v1/marketing-opt-outs/{phone}` desfaz; `GET` lista. ## Uso do mês `GET /v1/usage?month=2026-10` — a mesma conta da tela do Unred e da sua fatura. - **counted** = mensagens enviadas pela API que a Meta aceitou no mês (falhas não contam; mensagens recebidas e respostas da equipe também não). - **franchise / remaining / over_franchise** = quanto do plano já foi usado. - **meta_billable_by_category** = o que a Meta cobra, por categoria. - **by_day** e **top_errors** pra acompanhar o disparo. O mês é o do calendário, no horário de Brasília. ## Limites - **600 chamadas por minuto por chave.** Os cabeçalhos `RateLimit-Limit`, `RateLimit-Remaining` e `RateLimit-Reset` vêm em toda resposta; acima disso, `429` com `Retry-After`. - **1200 chamadas por minuto por conta**, somando todas as chaves. Upload de arquivo (`POST /v1/media`): 30 por minuto por chave e 1 GB de arquivo novo por dia (o mesmo arquivo enviado de novo não conta). - **Corpo do pedido:** até 256 KB de JSON. - **Fila de envio:** os envios saem em ordem, a até ~50 por segundo. Mensagem que espera mais de 15 minutos na fila não sai mais: vira `failed` e o seu sistema decide se reenvia. - **Até 20 chaves ativas e 10 webhooks** por conta. - **Limite da Meta por número:** quantas pessoas diferentes você pode chamar por template em 24h é definido pela Meta pra sua conta (aparece em `GET /v1/channels`, campo `messaging_limit`) e sobe sozinho com uso e boa qualidade. ## Erros Todo erro tem o mesmo formato: ```json { "error": { "code": "invalid_request", "message": "Alguns campos não passaram na validação.", "details": [ { "field": "to", "message": "Telefone com DDI, só números (10 a 15 dígitos)" } ], "request_id": "req_8d1c..." } } ``` | HTTP | `code` | O que significa | |---|---|---| | 400 | `invalid_request` | O corpo ou os parâmetros não passaram na validação. `details` diz qual campo. | | 401 | `unauthorized` | Chave ausente, inválida ou revogada. | | 402 | `account_suspended` | A conta está suspensa (pagamento pendente). Nada é enviado até regularizar. | | 403 | `forbidden` | A chave não pode fazer esta operação. | | 404 | `not_found` | O recurso não existe nesta conta. | | 409 | `conflict` | A operação conflita com o estado atual do recurso. | | 409 | `idempotency_conflict` | A mesma `Idempotency-Key` foi usada com um corpo diferente. | | 409 | `channel_not_connected` | O número não está conectado à API oficial agora. | | 422 | `channel_ambiguous` | A conta tem mais de um número: informe `channel_id`. | | 422 | `outside_customer_service_window` | Fora da janela de 24h desde a última mensagem da pessoa, a Meta só aceita template. Envie um `template`. | | 422 | `recipient_opted_out` | A pessoa pediu para não receber marketing. Templates de marketing não são enviados para ela. | | 429 | `rate_limited` | Muitas chamadas em pouco tempo. Respeite o cabeçalho `Retry-After`. | | 502 | `meta_error` | A Meta recusou a operação. `details` traz o código e a mensagem dela. | | 500 | `internal_error` | Erro do nosso lado. Pode repetir com a mesma `Idempotency-Key` sem risco de duplicar. | ## Migrando de outra plataforma? A troca é direta. O número continua o mesmo (a migração entre parceiros da Meta leva junto nome, qualidade, limites e templates aprovados). A tabela abaixo mostra a equivalência com um formato comum de API v2 de mensageria. | Plataforma anterior (API v2) | Unred (API v1) | |---|---| | `X-API-TOKEN: …` | `Authorization: Bearer unr_live_…` | | `POST /v2/channels/whatsapp/messages` | `POST /v1/messages` | | `from` (remetente) | `channel_id` (opcional com um número só) | | `contents: [{ "type": "text", "text": "…" }]` | `"type": "text", "text": { "body": "…" }` | | `contents: [{ "type": "template", "templateId", "fields" }]` | `"type": "template", "template": { "name", "language", "components" }` | | `contents: [{ "type": "file", "fileUrl", "fileCaption" }]` | `"type": "image" \| "document" \| …, "": { "link", "caption" }` | | `POST /v2/subscriptions` (`MESSAGE`, `MESSAGE_STATUS`) | `POST /v1/webhooks` (`message.received`, `message.sent`/`delivered`/`read`/`failed`) | | `messageStatus.code`: `SENT`, `DELIVERED`, `READ`, `REJECTED`, `NOT_DELIVERED` | `status`: `sent`, `delivered`, `read`, `failed` | | `/v2/templates` | `/v1/templates` | | `/v2/reports/message/entries` | `GET /v1/messages` e `GET /v1/usage` | Diferenças que ajudam: chave de teste que não envia nada, `Idempotency-Key`, mídia recebida com link permanente, reenvio automático de webhook e o objeto original da Meta em toda mensagem recebida. ## Implantando com IA Esta documentação foi escrita pra ser lida por pessoas **e** por assistentes de código. Dê ao seu assistente: - o guia em texto: [llms.txt](/desenvolvedores/llms.txt) - o contrato completo: [openapi.json](/desenvolvedores/openapi.json) Peça, por exemplo: *"Integre o meu sistema à API da Unred usando o openapi.json: envie o template X quando o pedido for faturado, com Idempotency-Key = id do pedido, e receba os webhooks em /webhooks/unred conferindo a assinatura."*