API Unred — WhatsApp oficial
Envie e receba mensagens de WhatsApp a partir do seu sistema, pela API oficial da Meta.
Feita para sistemas de gestão (ERP), inclusive de autopeças. A Unred é Tech Provider oficial da Meta e já tem integração com Jacsys, AutoBaze, TOTVS, LJ Sistemas, Z-PRO, TCar · Tecinco, CNE Sistemas. Ver integrações
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 emhttps://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 — 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:
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":
{ "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
{ "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
{ "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
{ "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
{ "to": "5511999990000", "type": "location", "location": { "latitude": -23.5614, "longitude": -46.6559, "name": "Loja Paulista", "address": "Av. Paulista, 1000" } }
Contato (cartão)
{ "to": "5511999990000", "type": "contacts", "contacts": [ { "name": { "formatted_name": "Suporte", "first_name": "Suporte" }, "phones": [ { "phone": "+551130000000", "type": "WORK" } ] } ] }
Botões (até 3)
{ "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": "<id da mensagem>" em qualquer envio.
Reação
{ "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:
{
"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.urlque mandamos não vence. O evento de uma mensagem com arquivo sai quando o arquivo já está guardado (normalmente segundos). - Nada se perde.
data.metatraz 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": <aviso original da 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:
"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 "<t>.<corpo bruto>". Recuse se a conta não bater ou se t tiver mais de 5 minutos.
Node.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
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
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-Keyem todoPOST: a mesma chave (por 24h) devolve a MESMA resposta, sem enviar de novo — com o cabeçalhoIdempotent-Replayed: true. Use algo do seu lado, comopedido-48213-aviso. - Ignore repetidos. Cada evento tem
idúnico (evt_…) — guarde e descarte se vier de novo. - Ordem não é garantida.
deliveredpode chegar antes desent. 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 portemplate.status_updated.DELETE /v1/templates/{name}— apaga (todos os idiomas, ou um comhsm_id).
{ "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;GETlista.
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-RemainingeRateLimit-Resetvêm em toda resposta; acima disso,429comRetry-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
failede 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, campomessaging_limit) e sobe sozinho com uso e boa qualidade.
Erros
Todo erro tem o mesmo formato:
{ "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" | …, "<tipo>": { "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
- o contrato completo: 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."