FluxoAPI v1
Erros Limites Painel

Referência da API

A FluxoAPI envia e recebe mensagens pelo WhatsApp Cloud API oficial da Meta. Seu número continua funcionando no aplicativo do celular — o que você faz por aqui aparece lá, e o que o dono responde por lá aparece aqui.

Baixar especificação OpenAPI

Especificação 3.1 completa, com todas as referências resolvidas e pronta para uso — importe no Postman, Insomnia, ou gere um cliente com o generator da sua linguagem.

Baixar especificação
API SERVERS
https://api.fluxoapi.com.br/v1API pública — mensagens, conversas e funil
https://mcp.fluxoapi.com.brServidor MCP, para agentes de IA
https://auth.fluxoapi.com.brAuthorization Server (OAuth 2.1)
Base

https://api.fluxoapi.com.br/v1

Autenticação

Chave + assinatura HMAC-SHA256 por requisição.

Formato

JSON em tudo. UTF-8. Datas em ISO 8601 com fuso.

Ambientes

Chaves fxk_test_ não enviam mensagem de verdade.

Todo endpoint é escopado à instância. A empresa nunca vai no caminho nem no corpo — ela vem da chave que assinou a requisição.

Autenticação

Cada requisição carrega quatro cabeçalhos. A chave identifica quem chama. O segredo nunca é enviado — ele assina. O carimbo de tempo e o nonce impedem que uma requisição capturada seja reenviada depois.

Cabeçalhos
X-Fluxo-Keyobrigatóriostring
Sua chave pública. Começa com fxk_live_ ou fxk_test_. Pode aparecer em log.
X-Fluxo-Timestampobrigatóriointeger
Unix em segundos. Aceito numa janela de ±300 s. Relógio adiantado é a causa nº 1 de 401 — sincronize por NTP.
X-Fluxo-Nonceobrigatóriostring
Valor único por requisição, 16 a 64 caracteres. Repetir um nonce dentro de 10 minutos devolve 409.
X-Fluxo-Signatureobrigatóriostring
HMAC-SHA256 em hexadecimal, prefixado por v1=. Veja a string assinada ao lado.

A string assinada

Concatene exatamente nesta ordem, separando por quebra de linha. O corpo entra como hash, não inteiro — assim a assinatura funciona com streaming.

Assine o corpo bruto, byte a byte. Se o seu framework fizer parse do JSON e re-serializar antes de assinar, a ordem das chaves muda e a assinatura não bate.
String assinada
"v1"            // versão do esquema
timestamp       // 1756220400
nonce           // 01JB8XQ2M5K7VN9F
method          // POST
path            // /v1/messages?limit=50 — COM a query string
sha256(body)    // hex do corpo bruto
Assinar
import crypto from 'node:crypto';

const ts    = Math.floor(Date.now() / 1000);
const nonce = crypto.randomUUID();
const body  = JSON.stringify(payload);

const signing = [
  'v1', ts, nonce, 'POST', path,
  crypto.createHash('sha256').update(body).digest('hex')
].join('\n');

const sig = crypto
  .createHmac('sha256', process.env.FLUXO_SECRET)
  .update(signing)
  .digest('hex');

await fetch(base + path, {
  method: 'POST',
  headers: {
    'X-Fluxo-Key':       process.env.FLUXO_KEY,
    'X-Fluxo-Timestamp': String(ts),
    'X-Fluxo-Nonce':     nonce,
    'X-Fluxo-Signature': `v1=${sig}`,
    'Content-Type':      'application/json'
  },
  body
});
import hmac, hashlib, time, uuid, json, requests

ts    = int(time.time())
nonce = uuid.uuid4().hex
body  = json.dumps(payload, separators=(',', ':'))

signing = "\n".join([
    "v1", str(ts), nonce, "POST", path,
    hashlib.sha256(body.encode()).hexdigest()
])

sig = hmac.new(
    secret.encode(), signing.encode(), hashlib.sha256
).hexdigest()

requests.post(base + path, data=body, headers={
    "X-Fluxo-Key":       key,
    "X-Fluxo-Timestamp": str(ts),
    "X-Fluxo-Nonce":     nonce,
    "X-Fluxo-Signature": f"v1={sig}",
    "Content-Type":      "application/json",
})
$ts    = time();
$nonce = bin2hex(random_bytes(16));
$body  = json_encode($payload);

$signing = implode("\n", [
  'v1', $ts, $nonce, 'POST', $path,
  hash('sha256', $body)
]);

$sig = hash_hmac('sha256', $signing, $secret);

// headers: X-Fluxo-Key, -Timestamp, -Nonce,
//          X-Fluxo-Signature: "v1={$sig}"

Listar instâncias

GET /v1/instances assinada

Todos os números conectados à sua conta, com o estado de cada um. Use no início do seu app para descobrir por qual instância enviar.

Query
statusenum
connected, pending, disconnected, suspended.
modeenum
coexistence ou api_only. Instâncias em coexistência também recebem mensagens enviadas pelo celular do dono.
Resposta 200
{
  "data": [
    {
      "id": "inst_01JB8XR4KP2M9TVQ",
      "name": "Central de Reservas",
      "phone": "+5519996155951",
      "status": "connected",
      "mode": "coexistence",
      "quality": "high",
      "throughput_mps": 80,
      "messaging_limit_24h": 10000,
      "access_expires_at": "2026-10-12T00:00:00-03:00"
    }
  ]
}

Detalhar instância

GET /v1/instances/{instance_id} assinada

Estado completo de um número, incluindo consumo das últimas 24 horas e quando o acesso vence. Consulte no máximo uma vez por minuto — o resto vem por notificação, que é mais rápido e não gasta seu limite.

Não faça polling deste endpoint em laço curto. Assine instance.status_changed e instance.access_expiring.
Resposta 200
{
  "id": "inst_01JB8XR4KP2M9TVQ",
  "name": "Central de Reservas",
  "phone": "+5519996155951",
  "verified_name": "Kallytur Turismo",
  "status": "connected",
  "mode": "coexistence",
  "quality": "high",
  "usage_24h": {
    "conversations": 1284,
    "limit": 10000,
    "peak_mps": 62
  },
  "access_expires_at": "2026-10-12T00:00:00-03:00",
  "webhook_url": "https://kallytur.com.br/hooks/fluxo"
}

Enviar mensagem

POST /v1/instances/{instance_id}/messages assinada

Envia texto, mídia, botões, lista ou carrossel para um número. Só funciona dentro da janela de 24 horas — se o contato não escreveu nos últimos um dia, use um modelo aprovado.

Corpo
toobrigatóriostring
Telefone no formato E.164, com + e código do país. Ex.: +5519991802264.
typeobrigatórioenum
text, image, document, audio, video ou interactive.
interactiveobject
Obrigatório quando type é interactive. interactive.type escolhe o formato: button, list, cta_url ou carousel.
text.bodystring
Até 4096 caracteres. Obrigatório quando type é text.
media.urlstring
URL pública (HTTPS) do arquivo. Quem baixa é a Meta, no momento do envio — o endereço precisa responder sem login. Limites da Meta: imagem 5 MB, áudio e vídeo 16 MB, documento 100 MB.
media.captionstring
Legenda, até 1024 caracteres. Vale para imagem, vídeo e documento; em áudio é ignorada, porque a Meta não aceita.
media.filenamestring
Só para document: o nome que aparece no celular do contato, como contrato-setembro.pdf.
idempotency_keystring
Envie sempre. Se a conexão cair e você repetir a chamada, a mesma chave devolve a mesma mensagem em vez de enviar — e cobrar — duas vezes. Vale por 7 dias.
reply_tostring
wamid da mensagem que está sendo respondida. Aparece como citação no WhatsApp.
Sem idempotency_key, um timeout de rede seguido de retry envia a mensagem duas vezes. O cliente recebe duplicado e a Meta cobra duas conversas.
Requisição
curl -X POST "$BASE/v1/instances/$INST/messages" \
  -H "X-Fluxo-Key: $KEY" \
  -H "X-Fluxo-Timestamp: $TS" \
  -H "X-Fluxo-Nonce: $NONCE" \
  -H "X-Fluxo-Signature: v1=$SIG" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+5519991802264",
    "type": "text",
    "text": { "body": "Sua reserva está confirmada!" },
    "idempotency_key": "reserva-8842-confirmacao"
  }'
const res = await fluxo.messages.send({
  instance: 'inst_01JB8XR4KP2M9TVQ',
  to:       '+5519991802264',
  type:     'text',
  text:     { body: 'Sua reserva está confirmada!' },
  idempotencyKey: 'reserva-8842-confirmacao'
});

console.log(res.id);      // msg_01JB8Y7QK2M4NPXV
console.log(res.status);  // queued
res = fluxo.messages.send(
    instance="inst_01JB8XR4KP2M9TVQ",
    to="+5519991802264",
    type="text",
    text={"body": "Sua reserva está confirmada!"},
    idempotency_key="reserva-8842-confirmacao",
)

print(res.id)      # msg_01JB8Y7QK2M4NPXV
print(res.status)  # queued
Resposta
{
  "id": "msg_01JB8Y7QK2M4NPXV",
  "status": "queued",
  "wamid": "wamid.HBgNNTUxOTk5MTgwMjI2NBUCABEYEjc…",
  "conversation_id": "conv_01JB8Y2K7PMD4XRA",
  "billable": false,
  "created_at": "2026-08-26T14:29:03-03:00"
}
{
  "error": {
    "code": "window_closed",
    "message": "O contato não escreve há mais de 24h.",
    "window_expired_at": "2026-08-25T09:12:44-03:00",
    "hint": "Use POST …/messages/template"
  }
}
// Retry-After: 3
{
  "error": {
    "code": "rate_limited",
    "message": "Você atingiu o limite do seu plano. Respeite o Retry-After.",
    "retry_after": 3
  }
}
Status possíveis
202aceita e enfileirada
401assinatura, carimbo ou nonce inválido
409nonce já usado nos últimos 10 min
422janela de 24h fechada
429vazão ou plano no limite
503instância reconectando — nada foi perdido

Listar modelos

GET /v1/instances/{instance_id}/templates assinada

Os modelos aprovados da conta WhatsApp Business (WABA) a que este número pertence. O modelo pertence ao WABA, não ao número: dois números do mesmo WABA devolvem a mesma lista.

Cada item traz fields — exatamente o que precisa ser preenchido para enviar aquele modelo, já com o exemplo aprovado. É o que evita descobrir na Meta, em forma de código numérico, que faltava o parâmetro do botão de URL.

Query
statusenum
APPROVED, PENDING, REJECTED, PAUSED, DISABLED. Só APPROVED pode ser enviado.
categoryenum
MARKETING, UTILITY, AUTHENTICATION.
languagestring
Código exato, por exemplo pt_BR. O mesmo nome existe uma vez por idioma.
namestring
Busca por trecho do nome.
refreshboolean
Ignora o cache de 5 minutos e busca de novo na Meta. Use só quando acabou de aprovar um modelo: a chamada consome o orçamento horário da Graph, que é compartilhado por toda a plataforma.
A lista fica em cache por 5 minutos. Um modelo recém-aprovado que ainda não apareceu é encontrado assim mesmo no envio — quando o nome não está em cache, buscamos de novo antes de recusar.
Resposta 200
{
  "data": [
    {
      "name": "lembrete_embarque",
      "language": "pt_BR",
      "status": "APPROVED",
      "category": "UTILITY",
      "parameterFormat": "POSITIONAL",
      "headerFormat": "TEXT",
      "bodyText": "Oi {{1}}, seu voo sai {{2}} às {{3}}.",
      "cards": 0,
      "sendable": true,
      "fields": [
        { "slot": "body", "kind": "text",
          "ref": "1", "example": "Marina",
          "required": true }
      ]
    }
  ],
  "waba_id": "102290129340398",
  "cached": true
}
Campos de fields
slot   onde entra: header, body, button,
       card_header, card_body, card_button, offer
kind   text, image, video, document, location,
       url_suffix, coupon_code, flow_token,
       otp_code, expiration_ms
ref    "1" (posicional), "nome" (nomeado)
       ou o índice do botão
example o valor de exemplo aprovado na Meta

Enviar modelo

POST /v1/instances/{instance_id}/messages/template assinada

Único jeito de iniciar conversa com quem não escreveu nas últimas 24 horas. O modelo precisa estar aprovado pela Meta — aprovação leva de minutos a algumas horas.

Corpo
template.nameobrigatóriostring
Nome exato do modelo aprovado, como aparece no painel.
template.languageobrigatóriostring
Código do idioma. Para português do Brasil, pt_BR.
template.bodyobject
Variáveis do corpo. A chave é a posição ("1") ou o nome ("first_name"), conforme o parameterFormat que GET /templates informa. Também aceito como template.variables, a forma antiga, só posicional.
template.headerobject
Cabeçalho. {"text":{"1":"…"}} para texto, {"media":{"link":"https://…"}} para imagem, vídeo ou documento (documento aceita filename), e {"location":{"latitude":"…","longitude":"…"}} para localização.
template.buttonsobject
Chave = índice do botão, contando todos os botões a partir de zero. Um botão de telefone não pede nada, mas ocupa índice: se a URL variável é o segundo botão, a chave é "1". Valor = trecho final da URL, código do cupom ou token do fluxo.
template.codestring
Modelos de autenticação. O mesmo código vai no corpo e no botão — isso é feito aqui, não repita.
template.cardsobject
Carrossel. Chave = índice do cartão ("0", "1"…), valor = {header, body, buttons} do cartão.
template.offer_expiration_msinteger
Fim da oferta, em milissegundos. Só para modelos de oferta por tempo limitado.
template.componentsarray
Escotilha: o array components da Meta já montado. Pula a validação e vai como veio. Existe para não travar recurso novo da Meta atrás de um deploy nosso.
Antes de enviar, conferimos o que você mandou contra a definição real do modelo. Falta de parâmetro volta como 422 missing_parameter dizendo qual campo faltou — a Meta responderia Number of parameters does not match, sem dizer qual.
Modelo sempre abre uma conversa cobrada, mesmo que o contato não responda. Confira billable na resposta.
Requisição
{
  "to": "+5519991802264",
  "template": {
    "name": "lembrete_embarque",
    "language": "pt_BR",
    "variables": {
      "1": "Marina",
      "2": "5 de setembro",
      "3": "06h40"
    }
  },
  "idempotency_key": "lembrete-8842-d1"
}
Resposta 202
{
  "id": "msg_01JB8YB3TC7K9WQD",
  "conversation_id": "conv_01JB8Y…",
  "status": "queued",
  "billable": true,
  "template": "lembrete_embarque",
  "preview": "Oi Marina, seu voo sai 5 de setembro às 06h40."
}

Listar mensagens

GET /v1/messages assinada

Mensagens de todas as conversas, das mais recentes para as mais antigas. Útil para auditoria e para reconciliar o que sua base perdeu.

Query
conversation_idstring
Restringe a uma conversa.
directionenum
inbound ou outbound.
statusenum
queued, sent, delivered, read, failed. Filtre por failed para achar o que não entregou.
since · untilstring
Intervalo em ISO 8601. Janela máxima de 90 dias por consulta.

O campo sent_from_phone distingue o que saiu pela API do que o dono digitou no celular. Sua automação precisa dele para não responder por cima de uma resposta humana.

media é null em mensagem de texto. Em foto, áudio, vídeo ou documento traz mime_type, filename e download_path — veja Receber mídia.

Resposta 200
{
  "data": [
    {
      "id": "msg_01JB8Y7QK2M4NPXV",
      "conversation_id": "conv_01JB8Y2K7PMD4XRA",
      "direction": "outbound",
      "type": "text",
      "text": "Por pessoa, Marina. No casal fica 6.960…",
      "status": "read",
      "sent_from_phone": true,
      "wamid": "wamid.HBgNNTUxOTk5MTgwMjI2NBUCABEYEjc…",
      "at": "2026-08-26T14:29:03-03:00"
    }
  ],
  "next_cursor": null
}

Marcar como lida

POST /v1/messages/{message_id}/read assinada

Os dois traços azuis no celular do contato. Sem isto, você responde pela API e a pessoa do outro lado continua vendo a mensagem dela como não lida — o que parece defeito do WhatsApp e chega como reclamação para você.

Só vale para mensagem recebida (direction: inbound) que já tenha wamid. Zera também o contador de não lidas da conversa, para o painel e o celular do contato não discordarem.

A chamada vai direto à Meta, sem passar pela fila: recibo atrasado não serve para nada, e ele não disputa a vazão do número com mensagem de verdade.

Resposta 200
{
  "id": "msg_01JB8YB3TC7K9WQD",
  "read": true
}
Erros
404 not_found            mensagem inexistente
422 not_readable         é de saída, ou sem wamid
503 instance_reconnecting o número caiu agora

Listar conversas

GET /v1/conversations assinada

Devolve conversas ordenadas pela última mensagem. Paginação por cursor — nunca por offset, que pula ou repete registros quando chegam mensagens durante a leitura.

Query
instance_idstring
Filtra por instância. Sem ele, devolve de todas as suas instâncias.
statusenum
open, pending, closed. Aceita múltiplos separados por vírgula.
sincestring
ISO 8601. Use para reconciliar depois de uma queda de conexão em tempo real.
cursorstring
Valor de next_cursor da página anterior.
limitinteger
1 a 100. Padrão 25.
Requisição
GET /v1/conversations
      ?instance_id=inst_01JB8XR4KP2M9TVQ
      &status=open,pending
      &limit=25
Resposta 200
{
  "data": [
    {
      "id": "conv_01JB8Y2K7PMD4XRA",
      "instance_id": "inst_01JB8XR4KP2M9TVQ",
      "contact": {
        "phone": "+5519991802264",
        "name": "Marina Rocha"
      },
      "status": "open",
      "unread": 3,
      "window_expires_at": "2026-08-27T14:32:11-03:00",
      "last_message": {
        "text": "Perfeito! Pode reservar pra mim então",
        "direction": "inbound",
        "at": "2026-08-26T14:32:11-03:00"
      },
      "lead_id": "lead_01JB8Y3PQR6C8VNM"
    }
  ],
  "next_cursor": "eyJvIjoxNzU2MjIwNDAwfQ"
}

Histórico da conversa

GET /v1/conversations/{conversation_id}/messages assinada

Mensagens de uma conversa em ordem cronológica, com o histórico que já existia no celular antes de você conectar — a coexistência importa o passado, não só o futuro.

Query
sincestring
Traz só o que chegou depois deste instante. É assim que você reconcilia após uma queda: guarde o horário da última mensagem que processou e peça daí em diante.
orderenum
asc (padrão) ou desc.
limitinteger
1 a 200. Padrão 50.
Ordene sempre pelo campo at, nunca pela ordem de chegada das notificações — elas podem chegar fora de sequência.
Resposta 200
{
  "conversation_id": "conv_01JB8Y2K7PMD4XRA",
  "window_expires_at": "2026-08-27T14:32:11-03:00",
  "data": [
    {
      "id": "msg_01JB8Y5A1BC2DEFG",
      "direction": "inbound",
      "text": "Oi! Vi o pacote de Bonito no Instagram…",
      "at": "2026-08-26T14:02:07-03:00"
    },
    {
      "id": "msg_01JB8Y6B2CD3EFGH",
      "direction": "outbound",
      "text": "Oi Marina, tudo bem? Temos sim…",
      "status": "read",
      "sent_from_phone": false,
      "at": "2026-08-26T14:02:44-03:00"
    }
  ],
  "next_cursor": null
}

Listar oportunidades

GET /v1/leads assinada

Oportunidades do funil com etapa, valor e a conversa de origem. Cada lead aponta para a conversa que o gerou — é assim que seu CRM mostra o histórico junto do negócio.

Query
stagestring
Chave da etapa. Aceita várias separadas por vírgula.
ownerenum
ai, human ou unassigned — de quem é a bola agora.
min_value · max_valuenumber
Valor em reais.
stale_hoursinteger
Só oportunidades sem movimento há mais de N horas. Use para achar o que está esfriando.
Resposta 200
{
  "data": [
    {
      "id": "lead_01JB8Y3PQR6C8VNM",
      "contact": { "name": "Marina Rocha",
                    "phone": "+5519991802264" },
      "stage": "proposta",
      "value_brl": 6960.00,
      "probability": 0.85,
      "owner": "human",
      "conversation_id": "conv_01JB8Y2K7PMD4XRA",
      "source": "instagram",
      "hours_in_stage": 3.4,
      "updated_at": "2026-08-26T14:29:03-03:00"
    }
  ],
  "summary": {
    "count": 47,
    "open_value_brl": 214380.00,
    "weighted_forecast_brl": 87640.00
  }
}

Mover de etapa

POST /v1/leads/{lead_id}/stage assinada

Avança ou recua uma oportunidade. Dispara lead.stage_changed e grava quem moveu no histórico.

Corpo
stageobrigatóriostring
Chave da etapa de destino.
reasonstring
Obrigatório ao mover para perdido. Alimenta o relatório de motivos de perda.
value_brlnumber
Atualiza o valor junto da mudança de etapa.
expected_stagestring
Se informado, a mudança só acontece se a etapa atual for exatamente esta. Evita que duas automações movam a mesma oportunidade ao mesmo tempo — devolve 409 se não bater.
Use expected_stage sempre que a chamada vier de uma automação. Sem ele, duas mensagens do mesmo contato chegando juntas podem avançar o lead duas etapas.
Requisição
{
  "stage": "fechamento",
  "expected_stage": "proposta",
  "value_brl": 6960.00
}
Resposta 200
{
  "id": "lead_01JB8Y3PQR6C8VNM",
  "stage": "fechamento",
  "previous_stage": "proposta",
  "moved_by": "api",
  "moved_at": "2026-08-26T14:41:22-03:00"
}
Conflito 409
{
  "error": {
    "code": "stage_mismatch",
    "message": "A oportunidade já saiu de 'proposta'.",
    "current_stage": "ganho"
  }
}

Receber notificações

Quando chega mensagem, muda status ou um lead avança, chamamos a URL que você cadastrou no painel. Verifique a assinatura antes de processar e responda 200 rápido — deixe o trabalho pesado para depois da resposta.

Verificar a assinatura

Mesmo esquema da API, na direção contrária: assinamos com o segredo whsec_ da sua instância.

Durante uma troca de segredo mandamos duas assinaturas separadas por vírgula, por 24 horas. Aceite se qualquer uma bater — assim você rotaciona sem downtime.

Reenvio e duplicatas

Se o seu endpoint não responder 200, tentamos de novo em 0 s, 30 s, 2 min, 10 min, 1 h, 6 h e 24 h. Depois disso o evento vai para a fila de falhas, visível no painel, com reenvio manual.

A mesma notificação pode chegar mais de uma vez. Guarde o X-Fluxo-Event-Id e ignore os repetidos — grave o id antes de executar o efeito, na mesma transação. É a única forma de não processar duas vezes.

Endpoint que falha 100% das entregas por 24 horas é desativado automaticamente e você é avisado por e-mail. Um endpoint morto consumindo tentativas atrasa as notificações que funcionam.

Requisição recebida
POST /hooks/fluxo
X-Fluxo-Event-Id:  evt_01JB8Y8M3RK5TQNC
X-Fluxo-Timestamp: 1756220400
X-Fluxo-Signature: v1=a71e…,v1=3f9c…

{
  "id": "evt_01JB8Y8M3RK5TQNC",
  "type": "message.received",
  "instance_id": "inst_01JB8XR4KP2M9TVQ",
  "created_at": "2026-08-26T14:32:11-03:00",
  "data": {
    "conversation_id": "conv_01JB8Y2K7PMD4XRA",
    "message_id": "msg_01JB8Y9F2QK7MRTX",
    "from": "+5519991802264",
    "type": "text",
    "text": "Perfeito! Pode reservar pra mim então",
    "sent_from_phone": false
  }
}
Verificar
app.post('/hooks/fluxo',
  express.raw({ type: 'application/json' }), // corpo BRUTO
  async (req, res) => {

  const ts  = req.get('X-Fluxo-Timestamp');
  const raw = req.body;

  // 1. carimbo dentro de 5 minutos
  if (Math.abs(Date.now()/1000 - ts) > 300)
    return res.sendStatus(401);

  // 2. alguma das assinaturas bate?
  const mine = crypto
    .createHmac('sha256', whsec)
    .update(ts + '\n' + raw)
    .digest();

  const ok = req.get('X-Fluxo-Signature')
    .split(',')
    .some(p => crypto.timingSafeEqual(
      mine, Buffer.from(p.slice(3), 'hex')));

  if (!ok) return res.sendStatus(401);

  // 3. dedup ANTES do efeito, na mesma transação
  const ev = JSON.parse(raw);
  const novo = await db.insertEventIfNew(ev.id);

  res.sendStatus(200);              // responda já
  if (novo) queue.push(ev);         // trabalhe depois
});
@app.post("/hooks/fluxo")
async def hook(request: Request):
    raw = await request.body()          # BRUTO
    ts  = request.headers["X-Fluxo-Timestamp"]

    if abs(time.time() - int(ts)) > 300:
        raise HTTPException(401)

    mine = hmac.new(
        whsec.encode(), (ts + "\n").encode() + raw,
        hashlib.sha256
    ).hexdigest()

    sigs = request.headers["X-Fluxo-Signature"].split(",")
    if not any(hmac.compare_digest(mine, s[3:]) for s in sigs):
        raise HTTPException(401)

    ev = json.loads(raw)
    novo = await db.insert_event_if_new(ev["id"])
    if novo:
        background.add_task(processar, ev)
    return Response(status_code=200)

Tipos de evento

Escolha quais você quer receber no painel, por instância.

EventoQuando dispara
message.receivedalguém escreveu para o seu número, mandou foto, áudio, vídeo ou documento (com media), ou tocou num botão ou lista (com reply)
message.statusenviada, entregue, lida ou falhou
message.sent_from_phoneo dono respondeu pelo app do celular — coexistência
conversation.window_closingfaltam 2 horas para a janela de 24h fechar
lead.stage_changedoportunidade mudou de etapa
instance.status_changednúmero caiu, voltou ou mudou de qualidade
instance.access_expiring7 dias antes de o acesso vencer
instance.suspendedenvio cortado por mensalidade em aberto

message.sent_from_phone só existe em instâncias com coexistência. É como seu sistema fica sabendo que o dono já respondeu pelo celular — sem ele, sua automação responde por cima e o cliente recebe duas respostas.

message.status
{
  "type": "message.status",
  "data": {
    "message_id": "msg_01JB8Y7QK2M4NPXV",
    "status": "read",
    "rank": 3,
    "at": "2026-08-26T14:30:02-03:00"
  }
}
Ordem dos status
1sent — a Meta aceitou
2delivered — chegou no aparelho
3read — o contato abriu
9failed — não entregou
Eventos podem chegar fora de ordem. Compare o rank e só aplique se for maior que o atual — assim um delivered atrasado não desfaz um read.

Limites e cobrança

Dois limites diferentes, que se confundem com facilidade: vazão é quão rápido, volume é quantas pessoas.

LimiteValorEscopo
Vazão80 msg/s (até 1.000)por número
Volume 24h250 → 1k → 10k → 100k → ilimitadopor número
Requisiçõesconforme seu planopor chave
Corpo1 MBpor requisição
Mídia16 MB (vídeo 100 MB)por arquivo

A Meta cobra por conversa de 24 horas, não por mensagem. Quem inicia define a categoria e o preço: conversa iniciada pelo cliente é mais barata que conversa iniciada por modelo. O campo billable na resposta diz se aquele envio abriu uma conversa cobrada.

Ao receber 429, respeite o Retry-After e aplique recuo exponencial com jitter. Repetir imediatamente derruba a sua vazão para todos os números da conta.
Cabeçalhos de limite
X-RateLimit-Limit:     80
X-RateLimit-Remaining: 18
X-RateLimit-Reset:     1756220403
X-Fluxo-Window-Left:   85847  // seg. da janela 24h
Recuo recomendado
const wait = Math.min(
  30_000,
  2 ** tentativa * 500 + Math.random() * 300
);

Erros

Todo erro devolve o mesmo formato, com code estável para você tratar em código e message em português para mostrar a quem opera.

CódigoHTTPO que fazer
invalid_signature401confira a string assinada e o relógio do servidor
stale_timestamp401sincronize por NTP — passou dos 300 s
nonce_reused409gere um nonce novo a cada requisição
window_closed422use um modelo aprovado
template_not_found404o modelo não existe nesse idioma; confira em GET /templates
rate_limited429respeite Retry-After, com recuo
too_many_failures429muitas tentativas sem autenticar; a origem foi contida
payment_required402mensalidade em aberto — só o envio para; receber e ler seguem
instance_reconnecting503tente de novo — nada foi perdido
instance_not_connected409a instância não concluiu a conexão com a Meta
insufficient_scope403a chave não tem a permissão; o campo required_scope diz qual
invalid_input422corpo ou query fora do formato; a mensagem diz o campo
range_too_wide422a janela de GET /messages passa de 90 dias
not_readable422só mensagem recebida e com wamid pode ser marcada como lida
stage_mismatch409o lead saiu de expected_stage; releia e decida
not_found404não existe — ou está fora do alcance desta chave
server_error500falha nossa; guarde o request_id e nos chame

Todo erro traz request_id no corpo, dentro de error. Guarde no seu log — é por ele que o suporte encontra a requisição exata.

Formato do erro
{
  "error": {
    "code": "invalid_signature",
    "message": "A assinatura não confere.",
    "request_id": "req_01JB8YC5KD3M7NPQ"
  }
}

Como integrar

Para n8n existe node oficial: n8n-nodes-fluxo, com 36 ações e a assinatura já resolvida — veja abaixo. Em qualquer outra linguagem a API é HTTP puro e a assinatura são seis linhas concatenadas. Copie o exemplo e adapte: os dois erros mais comuns são assinar o corpo já re-serializado e esquecer a query string no caminho.

HTTP direto

Qualquer linguagem. Quatro cabeçalhos e um HMAC.

Webhooks

Você recebe os eventos assinados, com reenvio automático.

Coexistência

O número segue no celular e responde pela API ao mesmo tempo.

MCP

Conecte um agente de IA direto às suas conversas.

Começar em 30 segundos
# o segredo assina a requisição; ele nunca vai num cabeçalho
INST=inst_01JB8XR4KP2M9TVQ
PATH_=/v1/instances/$INST/messages
TS=$(date +%s); NONCE=$(openssl rand -hex 12)
BODY='{"to":"+5519991802264","type":"text","text":{"body":"Oi! Tudo certo."}}'

HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $2}')
SIG=$(printf 'v1\n%s\n%s\nPOST\n%s\n%s' "$TS" "$NONCE" "$PATH_" "$HASH" \
  | openssl dgst -sha256 -hmac "$FLUXO_SECRET" -hex | awk '{print $2}')

curl https://api.fluxoapi.com.br$PATH_ \
  -H "X-Fluxo-Key: $FLUXO_KEY" \
  -H "X-Fluxo-Timestamp: $TS" \
  -H "X-Fluxo-Nonce: $NONCE" \
  -H "X-Fluxo-Signature: $SIG" \
  -H 'Content-Type: application/json' \
  -d "$BODY"

Postos de estado

O estado de uma mensagem enviada avança por posto e nunca retrocede. A Meta entrega os avisos fora de ordem, e é o posto — não a ordem de chegada — que decide o que vale.

EstadoPostoSignifica
queued0aceita por nós, esperando a vez na fila do número
sent1entregue à Meta, que assumiu o envio
delivered2chegou ao aparelho do contato
read3o contato abriu
failed9não vai ser entregue; error_code diz por quê

Compare sempre por rank. Um delivered atrasado chegando depois de um read não desfaz a leitura — do nosso lado ele é simplesmente ignorado, e do seu também deveria ser.

Comparando por posto
// certo: o estado só sobe
if (evento.data.rank > atual.rank) atual = evento.data;

// errado: confia na ordem de chegada
atual = evento.data;

Paginação e janelas

GET /conversations pagina por cursor. Mande de volta o next_cursor que veio na resposta anterior; null é fim da lista.

O cursor é um carimbo de tempo codificado, não um deslocamento. Isso importa: com offset, mensagens chegando no meio da paginação empurram a lista e fazem você pular ou repetir conversas. Com cursor, não.

GET /messages não pagina por cursor: ele limita a janela a 90 dias por consulta — a tabela é particionada por mês, e uma varredura sem limite atravessaria todas as partições de uma vez. Sem since, a consulta usa os últimos 7 dias. Passando de 90, a resposta é 422 range_too_wide.

EndpointLimite por páginaComo avançar
GET /conversations100 (padrão 25)cursor
GET /messages200 (padrão 50)estreite since e until
GET /conversations/{id}/messages200 (padrão 50)since + order
GET /leads200 (padrão 50)stale_hours, stage
Percorrendo tudo
let cursor = null;
do {
  const qs = cursor ? `?cursor=` + cursor : '';
  const r = await chamar('GET', '/v1/conversations' + qs);
  processar(r.data);
  cursor = r.next_cursor;
} while (cursor);

Identificadores e ambientes

Todo identificador é um ULID com prefixo. Diferente de UUID aleatório, o ULID é ordenável por tempo — ordenar por id é ordenar por criação, e o banco insere sempre à direita do índice em vez de fragmentar página.

PrefixoO que é
inst_instância — um número conectado
conv_conversa — o fio com um contato
msg_mensagem
cont_contato
lead_oportunidade no funil
cred_credencial de API
fxk_live_ / fxk_test_chave pública da credencial
fxs_segredo da credencial — some depois da criação
whsec_segredo que assina os webhooks que você recebe

O prefixo live ou test na chave existe para evitar o acidente mais caro que há: disparar uma campanha de teste contra a base real de clientes. Os dois prefixos são visíveis no painel e no log.

GET https://api.fluxoapi.com.br/health responde sem autenticação, com status, db, redis e version. Use no seu monitoramento; devolve 503 quando algo abaixo está fora.

GET /health
{
  "status": "ok",
  "db": true,
  "redis": true,
  "version": "1.0.0",
  "at": "2026-09-04T12:00:00.000Z"
}

Assinando em outras linguagens

A receita é a mesma em qualquer lugar: seis linhas unidas por quebra de linha, HMAC-SHA256 com o segredo, hexadecimal, prefixo v1=. O corpo entra pelo hash, e precisa ser exatamente os bytes que você vai enviar.

Requisição sem corpo usa o sha256 da string vazia: e3b0c442…7852b855. E o caminho assinado inclui a query string — /v1/messages?limit=10, não /v1/messages.

Python
import hmac, hashlib, time, uuid, json, requests

body = json.dumps({"to": "+5511999999999",
                   "type": "text",
                   "text": {"body": "Oi"}},
                  separators=(",", ":"))
ts    = str(int(time.time()))
nonce = str(uuid.uuid4())
path  = "/v1/instances/inst_01J.../messages"

assinada = "\n".join(["v1", ts, nonce, "POST", path,
                        hashlib.sha256(body.encode()).hexdigest()])
sig = hmac.new(SECRET.encode(), assinada.encode(), hashlib.sha256).hexdigest()

requests.post("https://api.fluxoapi.com.br" + path, data=body, headers={
    "content-type":      "application/json",
    "X-Fluxo-Key":       KEY,
    "X-Fluxo-Timestamp": ts,
    "X-Fluxo-Nonce":     nonce,
    "X-Fluxo-Signature": "v1=" + sig,
})
PHP
$body  = json_encode(['to' => '+5511999999999',
                      'type' => 'text',
                      'text' => ['body' => 'Oi']]);
$ts    = (string) time();
$nonce = bin2hex(random_bytes(16));
$path  = '/v1/instances/inst_01J.../messages';

$assinada = implode("\n", ['v1', $ts, $nonce, 'POST', $path,
                            hash('sha256', $body)]);
$sig = hash_hmac('sha256', $assinada, $secret);
Conferindo o webhook que você recebe
// O corpo BRUTO, nunca o re-serializado.
const esperado = 'v1=' + createHmac('sha256', whsec)
  .update(ts + '\n' + corpoBruto).digest('hex');

// Durante uma rotação vêm duas, separadas por vírgula.
const ok = header.split(',')
  .some((a) => timingSafeEqual(Buffer.from(a.trim()), Buffer.from(esperado)));

Node do n8n

Pacote oficial: n8n-nodes-fluxo. Instale em Configurações → Community nodes e informe o nome do pacote. Ele resolve a assinatura, a paginação e a janela de 24 h por você.

São 36 ações, e a credencial pede só dois campos: API Key (fxk_…) e HMAC Secret (fxs_…). O segredo nunca sai do n8n — só a assinatura calculada vai na requisição.

RecursoAções
Mensagem16 — texto, imagem, documento, áudio, vídeo, modelo, lote, texto longo, responder, marcar lida, baixar mídia, listar, contar por status, últimas do contato, aguardar entrega, reenviar falhadas
Conversa10 — listar, não lidas, com janela aberta, paradas, mensagens, buscar por telefone, contar, exportar transcrição, verificar janela, marcar lida
Instância4 — listar, obter, verificar saúde, testar conexão
Modelo5 — listar, obter, listar campos, pré-visualizar, validar preenchimento
Utilidade1 — validar número

Nem toda ação é um endpoint. Enviar Texto Longo divide em partes de 4096 caracteres e envia em série (em paralelo, a fila entregaria a parte 2 antes da 1). Pré-Visualizar, Validar Preenchimento e Validar Número resolvem dentro do node, sem chamar a Meta — conferir antes de gastar um envio cobrado é o ponto. Contar Por Status consulta status a status, com teto de 200 por consulta: ao bater o teto, a resposta marca amostra_limitada em vez de mentir um total. Baixar Mídia recebe o message_id do gatilho e devolve o arquivo como binário, pronto para Google Drive, S3 ou transcrição.

Instalação
# Configurações → Community nodes → Instalar
n8n-nodes-fluxo

# ou, num n8n auto-hospedado
npm install n8n-nodes-fluxo

Servidor MCP — para agentes de código

A FluxoAPI expõe um servidor MCP em https://mcp.fluxoapi.com.br. Ele serve para um agente de código ajudar você a integrar: ler a especificação, achar o instance_id, montar a assinatura, explicar um erro que apareceu ontem.

É somente leitura, e de propósito. O MCP não é caminho para um agente conversar com o seu cliente final — quem envia mensagem é a API pública, com chave e segredo, onde o dono da conta assina cada chamada.

EscopoDá acesso a
instances:readseus números e o estado deles
docs:reada especificação da API e a receita da assinatura
logs:readerros recentes e entregas de webhook

A autorização é OAuth 2.1 com PKCE obrigatório (S256), audiência ligada ao recurso (RFC 8707) e registro dinâmico de cliente (RFC 7591) — o cliente se cadastra sozinho, e o acesso só existe depois que você entra e aprova numa tela que mostra exatamente o que ele vai ler. Nenhuma senha chega ao agente.

Você revoga quando quiser em Configurações → MCP. Revogar derruba os tokens na hora.

Configuração do cliente
{
  "mcpServers": {
    "fluxoapi": {
      "type": "http",
      "url": "https://mcp.fluxoapi.com.br"
    }
  }
}
Descoberta automática
# o cliente encontra tudo sozinho por aqui
https://mcp.fluxoapi.com.br/.well-known/oauth-protected-resource
https://auth.fluxoapi.com.br/.well-known/oauth-authorization-server

Saúde da plataforma

GET https://api.fluxoapi.com.br/health pública

Feito para o seu monitoramento. Responde 200 quando banco e Redis respondem, e 503 quando algum deles está fora.

Um 503 aqui significa que os envios estão parados — e que o recebimento também. É o primeiro lugar para olhar antes de abrir chamado.

Resposta 200
{
  "status": "ok",
  "db": true,
  "redis": true,
  "version": "1.0.0"
}
Uso
curl -s https://api.fluxoapi.com.br/health

# no seu monitor: alerta quando o código sair de 200

Métricas

GET https://api.fluxoapi.com.br/metrics bearer

Formato de exposição do Prometheus. Exige Authorization: Bearer com o token de métricas da instalação.

Sem token configurado, a rota responde 404 em produção — e não 401. As métricas contam mensagens por conta, falhas de assinatura e profundidade de fila: é o mapa da operação, e um 401 já confirmaria que há algo do outro lado.

Resposta 200
fluxo_messages_outbound 128
fluxo_messages_inbound 91
fluxo_dlq_depth 0
fluxo_http_duration_ms_p95{route="/v1/messages"} 24.310

Metadata do Authorization Server

GET https://auth.fluxoapi.com.br/.well-known/oauth-authorization-server pública

RFC 8414. Diz onde autorizar, onde trocar o código, onde revogar e onde se registrar.

É o primeiro lugar que um cliente MCP consulta. Cliente que não encontra registration_endpoint aqui aborta com does not support dynamic client registration — sem tentar mais nada.

Resposta 200
{
  "issuer": "https://auth.fluxoapi.com.br",
  "authorization_endpoint": "…/oauth/authorize",
  "registration_endpoint": "…/oauth/register",
  "code_challenge_methods_supported": ["S256"],
  "token_endpoint_auth_methods_supported": ["none"]
}

Registrar um cliente

POST https://auth.fluxoapi.com.br/oauth/register pública

RFC 7591. É aberto por definição do protocolo: quem se registra ainda não tem conta aqui.

Registrar não concede nada. O cliente nasce sem dono, sem token e sem conta ligada. O que ele ganha é o direito de pedir — e o pedido só vira acesso depois que uma pessoa entra, lê a tela de consentimento e aprova.

redirect_uris aceita https://, ou http:// apenas em localhost, 127.0.0.1 e ::1. Sem essa trava, o registro aberto viraria uma máquina de redirecionamento assinada por nós.

Só emitimos cliente público com PKCE — não há client_secret. Limite de 10 registros por hora por origem; cliente que nunca vira token é apagado em 7 dias.

Requisição
{
  "client_name": "Claude Desktop",
  "redirect_uris": ["http://localhost:33418/callback"],
  "token_endpoint_auth_method": "none"
}
Resposta 201
{
  "client_id": "mcp_01M1P6VCX987SHE7R5M1YWH3K5",
  "client_id_issued_at": 1788525197,
  "scope": "instances:read docs:read logs:read"
}
Erros
400 invalid_redirect_uri     não é https nem localhost
400 invalid_client_metadata  pediu cliente confidencial
429 too_many_requests        muitos registros desta origem

Tela de consentimento

GET https://auth.fluxoapi.com.br/oauth/authorize sessão

Devolve HTML, não JSON: quem está do outro lado é uma pessoa. Sem sessão, redireciona para o login e volta para cá depois.

Nada é concedido aqui. O pedido fica guardado por 5 minutos e só vira código depois do clique em Permitir — sem isso, bastaria um site levar o navegador logado até esta URL para um cliente qualquer receber um código que ninguém autorizou.

PKCE com S256 é obrigatório; plain é recusado. O redirect_uri é comparado por igualdade exata com os registrados — prefixo permitiria roubar o código com https://legitimo.com.evil.com.

resource (RFC 8707) é obrigatório e vira a audiência do token: token emitido para outro recurso não vale no nosso servidor MCP.

Parâmetros
client_id              obrigatório
redirect_uri           igualdade exata
response_type          code
code_challenge         43 a 128 caracteres
code_challenge_method  S256
resource               o servidor MCP
scope                  separados por espaço
state                  devolvido intacto
Volta pelo redirect
https://localhost:33418/callback
  ?code=oac_…
  &iss=https://auth.fluxoapi.com.br
  &state=…

Trocar o código por token

POST https://auth.fluxoapi.com.br/oauth/token pública

Aceita application/x-www-form-urlencoded. Dois fluxos.

authorization_code — troca o código, conferindo o code_verifier do PKCE em tempo constante. Código reutilizado revoga todos os tokens daquele cliente: se alguém interceptou e chegou primeiro, o legítimo derruba a sessão do atacante ao tentar.

refresh_token — rotaciona, e o antigo morre no uso. Apresentar um refresh já rotacionado é sinal de vazamento, e revoga a linhagem inteira.

Limite de 30 requisições por minuto por origem.

Requisição
grant_type=authorization_code
code=oac_…
code_verifier=…
redirect_uri=http://localhost:33418/callback
Resposta 200
{
  "access_token": "fxa_…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "fxr_…"
}
Erros
400 invalid_grant        código expirado, usado, ou PKCE errado
400 invalid_target       resource diferente do autorizado
429 slow_down            muitas requisições

Revogar um token

POST https://auth.fluxoapi.com.br/oauth/revoke pública

Aceita access token ou refresh token.

Responde 200 sempre, inclusive para token desconhecido — é o que a RFC 7009 manda, e a razão é a mesma de sempre: distinguir permitiria sondar quais tokens existem.

Requisição
token=fxa_…
Resposta
200 OK, sem corpo.

Metadata do recurso protegido

GET https://mcp.fluxoapi.com.br/.well-known/oauth-protected-resource pública

RFC 9728. É por aqui que o cliente descobre qual Authorization Server usar.

O 401 do servidor MCP aponta esta URL no cabeçalho WWW-Authenticate — junto, as duas coisas fazem a configuração ser automática: você informa só a URL do servidor.

Resposta 200
{
  "resource": "https://mcp.fluxoapi.com.br",
  "authorization_servers": ["https://auth.fluxoapi.com.br"],
  "scopes_supported": ["instances:read", "docs:read", "logs:read"]
}

Chamar o servidor MCP

POST https://mcp.fluxoapi.com.br/ bearer

JSON-RPC 2.0. Métodos: initialize, tools/list, tools/call.

É somente leitura, e de propósito. Serve para um agente de código ajudar você a integrar a API — ler a especificação, achar o instance_id, montar a assinatura, explicar um erro de ontem. Não é o caminho para um agente conversar com o seu cliente final: quem envia mensagem é a API pública, onde o dono da conta assina cada chamada.

A audiência do token é conferida contra o próprio endereço (RFC 8707). Cada ferramenta exige um escopo, e o erro diz qual falta.

Requisição
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": { "name": "get_openapi" }
}
Sem token
HTTP/1.1 401
WWW-Authenticate: Bearer resource_metadata=
  "https://mcp.fluxoapi.com.br/.well-known/oauth-protected-resource"

Tabela de preços

GET https://fluxoapi.com.br/public/pricing pública

Faixas e preço por número. Sem autenticação — é o que a página de planos consome.

Resposta 200
faixas, preço por número e desconto por volume

Dados do link de conexão

GET https://fluxoapi.com.br/public/connect/{token} token no caminho

O link que a empresa envia ao dono do número, para ele conectar o WhatsApp sem ter conta no painel.

Entrega só o necessário para a tela: nome da empresa, nome do número e a configuração do Embedded Signup. Nada de conversa, nada de identificador de outro número.

Vale uma vez e expira. Limite de 30 tentativas por minuto por origem — o token tem 43 caracteres aleatórios, mas força bruta barata é barata em qualquer tamanho de chave.

Resposta 200
{
  "instance_name": "Central",
  "tenant_name": "Gauss",
  "already_connected": false
}
Erros
404 link_invalid   vencido, já usado, ou revogado
429 slow_down      muitas tentativas

Webhooks de entrada · 4

Endpoints que recebem — da Meta e do Asaas. Você não chama estes; eles chamam você. Estão aqui para você reconhecê-los no log.

MétodoCaminhoO que faz
POST/webhooks/asaasEntrada dos eventos de cobrança. Token no cabeçalho.
POST/webhooks/fluxoEndpoint de teste, para você conferir a nossa assinatura.
GET/webhooks/metaVerificação do endpoint, feita uma vez pela Meta.
POST/webhooks/metaEntrada dos eventos da Meta. Assinatura X-Hub-Signature-256.

Rotas do painel · 51

Sessão por cookie, não assinatura HMAC. É o que o painel consome. Não têm contrato de compatibilidade: mudam sem aviso e sem virar /v2. Listadas para você entender o sistema — para integrar, use a API pública.

MétodoCaminhoO que faz
POST/panel/auth/loginLogin. Limite por origem e por conta.
POST/panel/auth/logoutEncerra a sessão e apaga o cookie.
GET/panel/billingAssinatura, cartões e cobranças.
POST/panel/billing/cardTokeniza um cartão. O número não passa pelo nosso banco.
DELETE/panel/billing/card/:idRemove o cartão. Barra se for o único de assinatura ativa.
PUT/panel/billing/card/:id/defaultDefine o cartão padrão.
POST/panel/billing/customerCria o cadastro de cobrança.
POST/panel/billing/quotaMuda a cota de números.
GET/panel/billing/quoteSimula o preço de uma cota antes de contratar.
POST/panel/billing/refreshRelê o estado da cobrança no Asaas.
POST/panel/billing/repriceReaplica o preço vigente à assinatura.
POST/panel/billing/subscribeAssina um plano.
DELETE/panel/billing/subscriptionCancela a assinatura.
GET/panel/conversationsConversas da conta, com busca.
GET/panel/conversations/:id/messagesO fio. Abrir zera o não lidas.
POST/panel/conversations/:id/messagesResponde pelo painel e enfileira o envio.
GET/panel/conversations/unread-countQuantas conversas esperam resposta. Só o número.
GET/panel/credentialsChaves da conta. Só o prefixo aparece.
POST/panel/credentialsCria uma chave. Chave e segredo aparecem uma única vez.
POST/panel/credentials/:id/revokeRevoga a chave na hora.
GET/panel/funnelQuadro do funil, com etapas e valores.
GET/panel/healthAlertas da conta: acesso vencendo, webhook falhando, fila de falhas.
GET/panel/instancesNúmeros da conta, com estado e contadores.
POST/panel/instancesCria uma instância, respeitando a cota do plano.
DELETE/panel/instances/:idApaga a instância e libera a vaga do plano.
POST/panel/instances/:id/connect-linkGera o link para o dono do número conectar.
POST/panel/instances/:id/resubscribeReassina o app no WABA — conserta "conectou e não chega mensagem".
PUT/panel/instances/:id/webhookDefine a URL do webhook. Validada contra SSRF antes de gravar.
POST/panel/instances/:id/webhook/rotateSegredo novo. Por 24 h as duas assinaturas valem.
POST/panel/leads/:id/stageMove a oportunidade de etapa.
GET/panel/mcp/clientsClientes MCP da conta, inclusive os que se registraram sozinhos.
POST/panel/mcp/clientsCria um cliente MCP com client_id fixo.
DELETE/panel/mcp/clients/:clientIdRevoga o cliente — ou só os seus tokens, se ele for órfão.
PATCH/panel/mcp/clients/:clientIdTroca escopo. Reduzir permissão derruba os tokens vivos.
GET/panel/meQuem está logado, a conta e o carimbo da foto.
GET/panel/overviewOs números do topo da tela de instâncias.
GET/panel/settingsDados da conta, equipe e limites.
PUT/panel/settings/profileNome e senha de quem está logado.
PUT/panel/settings/tenantRenomeia a conta — é o nome que a lateral mostra.
DELETE/panel/settings/tenant/avatarRemove a foto.
POST/panel/settings/tenant/avatarEnvia a foto. O tipo é conferido pelos bytes, não pelo rótulo.
GET/panel/signup/configParâmetros do Embedded Signup para o front montar o FB.login.
POST/panel/signup/exchangeTroca o código da Meta por token, conferindo o dono do número.
GET/panel/streamEventos em tempo real (SSE). Teto de 50 conexões por conta.
DELETE/panel/templatesExclui um modelo. A Meta apaga por nome, não por id.
GET/panel/templatesCatálogo de modelos, com contagem por estado.
POST/panel/templatesCria um modelo e envia para aprovação da Meta.
GET/panel/templates/instancesNúmeros que têm WABA e portanto catálogo.
POST/panel/templates/mediaSobe a mídia do cabeçalho e devolve o handle.
POST/panel/templates/sendEnvia um modelo pelo painel.
GET/panel/tenant/avatarServe a foto. Exige sessão.

Rotas de administração · 14

Dono da plataforma apenas. Respondem 404, não 403, para quem não é — um 403 confirmaria que a rota existe, e a existência de um /admin é justamente o que não interessa anunciar.

MétodoCaminhoO que faz
GET/admin/auditTrilha de auditoria.
GET/admin/billing/eventsEventos crus recebidos do Asaas.
POST/admin/billing/events/:id/replayReprocessa um evento guardado.
POST/admin/billing/reconcileReconcilia assinaturas com o Asaas.
POST/admin/billing/resync/:instance_idRecalcula a cobrança de um número.
GET/admin/chargesTodas as cobranças.
GET/admin/errorsTrilha de erros da plataforma.
GET/admin/healthFila, tokens vencendo, webhooks falhando.
GET/admin/instancesTodos os números da plataforma.
POST/admin/instances/:id/refresh-billingReaplica o estado de cobrança a um número.
GET/admin/overviewResumo da plataforma: contas, receita, mensagens, erros.
GET/admin/revenueReceita por período.
GET/admin/tenantsTodas as contas.
GET/admin/tenants/:idUma conta em detalhe.

Enviar imagem

POST /v1/instances/{instance_id}/messages assinada

A URL precisa ser pública e responder Content-Type de imagem — a Meta busca o arquivo por conta dela, e não enxerga o que está atrás do seu login.

JPEG e PNG, até 5 MB. A legenda é opcional e vai junto, na mesma bolha.

Requisição
{
  "to": "+5511999999999",
  "type": "image",
  "media": {
    "url": "https://exemplo.com/arquivo",
    "caption": "Segue a foto"
  }
}
Resposta 202
{
  "id": "msg_01JB…",
  "status": "queued",
  "billable": false
}

Enviar documento

POST /v1/instances/{instance_id}/messages assinada

PDF, planilha, o que for — até 100 MB. Use a legenda para dizer o que é: sem ela o contato vê só o nome do arquivo.

O nome que aparece no WhatsApp é o de media.filename. Sem ele, a Meta usa o final da URL — .../arquivo?id=93 chegaria como arquivo.

Requisição
{
  "to": "+5511999999999",
  "type": "document",
  "media": {
    "url": "https://exemplo.com/arquivo?id=93",
    "filename": "contrato-setembro.pdf",
    "caption": "Contrato de setembro"
  }
}
Resposta 202
{
  "id": "msg_01JB…",
  "status": "queued",
  "billable": false
}

Enviar áudio

POST /v1/instances/{instance_id}/messages assinada

Áudio não aceita legenda — a Meta ignora. Até 16 MB.

Para virar mensagem de voz (a bolha com a onda), o arquivo precisa ser .ogg com codec OPUS. Qualquer outro formato chega como arquivo de áudio anexado.

Requisição
{
  "to": "+5511999999999",
  "type": "audio",
  "media": {
    "url": "https://exemplo.com/arquivo"
  }
}
Resposta 202
{
  "id": "msg_01JB…",
  "status": "queued",
  "billable": false
}

Enviar vídeo

POST /v1/instances/{instance_id}/messages assinada

MP4 ou 3GPP, até 16 MB. Aceita legenda.

O limite é da Meta, não nosso. Vídeo maior: hospede e mande o link como texto.

Requisição
{
  "to": "+5511999999999",
  "type": "video",
  "media": {
    "url": "https://exemplo.com/arquivo",
    "caption": "Como configurar"
  }
}
Resposta 202
{
  "id": "msg_01JB…",
  "status": "queued",
  "billable": false
}

Enviar botões de resposta

POST /v1/instances/{instance_id}/messages assinada

Até 3 botões abaixo do texto. O contato toca em um e a escolha volta pelo webhook — é o jeito mais curto de fazer "confirmar ou remarcar" sem depender de a pessoa digitar certo.

Não precisa de modelo aprovado nem é cobrado à parte, mas só sai com a janela de 24 horas aberta.

interactive
typeobrigatório"button"
bodyobrigatóriostring
Até 1.024 caracteres.
buttons[]obrigatórioarray
De 1 a 3. Cada um com id (até 256) e title (até 20). Títulos e ids não podem se repetir.
headerobject
{ "type": "text", "text": "…" } (até 60) ou image, video, document com url pública ou id de mídia da Meta.
footerstring
Até 60 caracteres, em cinza abaixo do texto.
Decida pelo id, não pelo título. O título muda quando alguém revisa o texto; o id é o contrato com o seu código.
Requisição
{
  "to": "+5511999999999",
  "type": "interactive",
  "interactive": {
    "type": "button",
    "header": { "type": "image", "url": "https://exemplo.com/visita.jpg" },
    "body": "Confirma a visita amanhã às 9h?",
    "footer": "Responda por um botão",
    "buttons": [
      { "id": "confirmar", "title": "Confirmar" },
      { "id": "remarcar",  "title": "Remarcar" }
    ]
  },
  "idempotency_key": "visita-8842-confirmacao"
}
Resposta 422
{
  "error": {
    "code": "invalid_input",
    "message": "Botões de resposta: de 1 a 3 em interactive.buttons."
  }
}

Enviar lista

POST /v1/instances/{instance_id}/messages assinada

Um botão que abre um menu com até 10 opções, agrupadas em seções. Serve quando três botões não bastam: horários, unidades, planos.

O limite de 10 é de linhas somando todas as seções, não por seção — duas seções de 6 são recusadas.

interactive
typeobrigatório"list"
bodyobrigatóriostring
Até 4.096 caracteres.
list.buttonobrigatóriostring
Rótulo do botão que abre o menu. Até 20.
list.sections[]obrigatórioarray
De 1 a 10 seções, cada uma com title (até 24) e rows. Cada linha: id (até 200, único entre seções), title (até 24) e description opcional (até 72).
headerobject
Só texto: { "type": "text", "text": "…" }, até 60.
footerstring
Até 60 caracteres.
Requisição
{
  "to": "+5511999999999",
  "type": "interactive",
  "interactive": {
    "type": "list",
    "header": { "type": "text", "text": "Horários" },
    "body": "Escolha um horário para a visita:",
    "list": {
      "button": "Ver horários",
      "sections": [
        { "title": "Manhã", "rows": [
          { "id": "h09", "title": "09:00", "description": "Com o corretor Paulo" },
          { "id": "h11", "title": "11:00" }
        ]},
        { "title": "Tarde", "rows": [
          { "id": "h14", "title": "14:00" }
        ]}
      ]
    }
  }
}
POST /v1/instances/{instance_id}/messages assinada

Um botão que abre um endereço no navegador do celular, no lugar de um link cru no meio do texto. Boleto, rastreio, pagamento.

Aceita header (texto ou mídia) e footer como os botões de resposta. Tocar no link não gera webhook — a Meta não avisa.

Requisição
{
  "to": "+5511999999999",
  "type": "interactive",
  "interactive": {
    "type": "cta_url",
    "body": "Seu boleto está pronto.",
    "cta": {
      "display_text": "Abrir boleto",
      "url": "https://exemplo.com/b/123"
    }
  }
}

Enviar carrossel

POST /v1/instances/{instance_id}/messages assinada

De 2 a 10 cartões lado a lado, cada um com imagem ou vídeo, texto curto e botões. Sem modelo aprovado — dentro da janela de 24 horas. Fora dela, carrossel continua existindo, mas como modelo.

Cada cartão leva buttons (respostas rápidas) ou cta (um botão de link), nunca os dois. E todos os cartões precisam do mesmo formato: se o primeiro tem 2 respostas rápidas, todos têm 2.

interactive
typeobrigatório"carousel"
bodyobrigatóriostring
Texto acima dos cartões. Até 1.024. Carrossel não aceita header nem footer na mensagem.
cards[].headerobrigatórioobject
type image ou video, com url pública ou id.
cards[].bodystring
Até 160 caracteres e 2 quebras de linha.
cards[].buttons[] ou cards[].ctaarray | object
Respostas rápidas { id, title } (título até 20), ou um link { display_text, url }.
Requisição
{
  "to": "+5511999999999",
  "type": "interactive",
  "interactive": {
    "type": "carousel",
    "body": "Separei duas opções no seu orçamento:",
    "cards": [
      {
        "header": { "type": "image", "url": "https://exemplo.com/ap-101.jpg" },
        "body": "*Ap. 101*\n2 quartos, R$ 2.300",
        "buttons": [
          { "id": "visitar-101", "title": "Quero visitar" },
          { "id": "fotos-101",   "title": "Mais fotos" }
        ]
      },
      {
        "header": { "type": "image", "url": "https://exemplo.com/ap-204.jpg" },
        "body": "*Ap. 204*\n3 quartos, R$ 2.900",
        "buttons": [
          { "id": "visitar-204", "title": "Quero visitar" },
          { "id": "fotos-204",   "title": "Mais fotos" }
        ]
      }
    ]
  }
}
Resposta 422
{
  "error": {
    "code": "invalid_input",
    "message": "Carrossel: o cartão 1 tem botões diferentes do cartão 0 — todos precisam do mesmo tipo e da mesma quantidade de botões."
  }
}

Receber o clique

Quando o contato toca num botão de resposta, numa linha da lista ou numa resposta rápida do carrossel, chega um message.received com type: "interactive" e o objeto reply.

text traz o título escolhido — é o que aparece na Inbox. reply.id é o id que você definiu no envio. reply_to_wamid diz qual mensagem foi respondida.

reply.kindDe onde veio
button_replybotão de resposta, ou resposta rápida de carrossel
list_replylinha da lista — traz description se a linha tinha
template_buttonresposta rápida de modelo; aqui type é button e id é o payload do botão
Webhook recebido
{
  "type": "message.received",
  "data": {
    "conversation_id": "conv_01JB8Y2K7PMD4XRA",
    "message_id": "msg_01JB8YC4T7Q2MRZX",
    "from": "5511999999999",
    "type": "interactive",
    "text": "09:00",
    "sent_from_phone": false,
    "reply": {
      "kind": "list_reply",
      "id": "h09",
      "title": "09:00",
      "description": "Com o corretor Paulo"
    },
    "reply_to_wamid": "wamid.HBgNNTUxMTk5…"
  }
}

Receber mídia

GET /v1/messages/{message_id}/media assinada

Quando o contato manda foto, áudio, vídeo, documento ou figurinha, o message.received chega com o objeto media. O mesmo vale para message.sent_from_phone quando o dono manda mídia pelo celular.

A Meta não entrega o arquivo no evento — só um identificador, e o endereço que ele gera expira em 5 minutos e exige o token do número. Por isso você baixa por aqui: um GET assinado em media.download_path devolve os bytes do arquivo, com Content-Type e Content-Disposition certos. Não é JSON.

typeO que chega em media
imagemime_type (image/jpeg, image/png) e caption, se tiver legenda
audiomime_type (em geral audio/ogg; codecs=opus) e voice: true quando é mensagem de voz gravada no app
videomime_type (video/mp4) e caption
documentmime_type, filename e caption
stickermime_type image/webp
CampoPara que serve
download_pathrota para baixar; some à base da API. Assine como qualquer GET — o path da assinatura é esse valor
ididentificador da mídia na Meta; útil para deduplicar
sha256hash do arquivo informado pela Meta, para conferir o download

text traz a legenda. Sem legenda vem vazio — exceto em documento, que traz o nome do arquivo. Para decidir o que fazer, olhe type, não text.

Com ?download=1 a resposta sai sempre como anexo. Tipos que o navegador executaria (HTML, SVG) saem como anexo mesmo sem o parâmetro.

StatusSignificado
200o arquivo, no corpo
302mídia que você enviou por link: redireciona para o seu próprio media.url
404 no_mediaa mensagem é de texto
410 media_expireda Meta apaga a mídia 30 dias depois do envio
503 instance_reconnectingo número está desconectado; tente depois
Se o seu sistema precisa do arquivo depois de 30 dias, baixe ao receber o evento e guarde no seu armazenamento. Não use o download_path como link permanente.
Webhook recebido
{
  "type": "message.received",
  "data": {
    "conversation_id": "conv_01JB8Y2K7PMD4XRA",
    "message_id": "msg_01JB8YF2K9QX4TMA",
    "from": "5511999999999",
    "type": "document",
    "text": "comprovante.pdf",
    "sent_from_phone": false,
    "media": {
      "id": "1037543291543636",
      "mime_type": "application/pdf",
      "filename": "comprovante.pdf",
      "caption": null,
      "sha256": "Ofz1b8vT2k…",
      "download_path": "/v1/messages/msg_01JB8YF2K9QX4TMA/media"
    }
  }
}
Baixar e guardar
import crypto from 'node:crypto';

// GET assinado: corpo vazio, e o sha256 é o da string vazia
async function baixarMidia(downloadPath) {
  const ts    = Math.floor(Date.now() / 1000);
  const nonce = crypto.randomUUID();
  const signing = [
    'v1', ts, nonce, 'GET', downloadPath,
    crypto.createHash('sha256').update('').digest('hex')
  ].join('\n');
  const sig = crypto.createHmac('sha256', process.env.FLUXO_SECRET)
    .update(signing).digest('hex');

  return fetch('https://api.fluxoapi.com.br' + downloadPath, {
    headers: {
      'X-Fluxo-Key':       process.env.FLUXO_KEY,
      'X-Fluxo-Timestamp': String(ts),
      'X-Fluxo-Nonce':     nonce,
      'X-Fluxo-Signature': `v1=${sig}`,
    },
  });
}

// no seu receptor de webhook
const { media, message_id } = ev.data;
if (media) {
  const res = await baixarMidia(media.download_path);
  if (res.ok) {
    const bytes = Buffer.from(await res.arrayBuffer());
    await storage.put(message_id, bytes, res.headers.get('content-type'));
  }  // 410 = passou de 30 dias na Meta
}
import hmac, hashlib, time, uuid, os, requests

def baixar_midia(download_path):
    ts, nonce = int(time.time()), uuid.uuid4().hex
    signing = "\n".join([
        "v1", str(ts), nonce, "GET", download_path,
        hashlib.sha256(b"").hexdigest(),   # corpo vazio
    ])
    sig = hmac.new(os.environ["FLUXO_SECRET"].encode(),
                   signing.encode(), hashlib.sha256).hexdigest()
    return requests.get(
        "https://api.fluxoapi.com.br" + download_path,
        headers={
            "X-Fluxo-Key":       os.environ["FLUXO_KEY"],
            "X-Fluxo-Timestamp": str(ts),
            "X-Fluxo-Nonce":     nonce,
            "X-Fluxo-Signature": f"v1={sig}",
        },
    )

media = ev["data"].get("media")
if media:
    res = baixar_midia(media["download_path"])
    if res.ok:   # 410 = passou de 30 dias na Meta
        storage.put(ev["data"]["message_id"], res.content,
                    res.headers["content-type"])

Responder citando uma mensagem

POST /v1/instances/{instance_id}/messages assinada

Mande reply_to com o wamid da mensagem que você está respondendo — não com o nosso msg_….

O contato vê a citação acima da sua resposta, como no aplicativo. Num fio longo, é o que evita a pergunta "respondendo o quê?".

Requisição
{
  "to": "+5511999999999",
  "type": "text",
  "text": { "body": "Isso mesmo!" },
  "reply_to": "wamid.HBgNNTUxMTk5…"
}
Onde achar o wamid
// GET /v1/conversations/{id}/messages
{ "id": "msg_01JB…", "wamid": "wamid.HBgN…" }

Enviar para vários contatos

POST /v1/instances/{instance_id}/messages assinada

Não existe envio em lote num único pedido — e isso é de propósito: janela fechada de um contato não pode derrubar a lista inteira.

Faça uma chamada por destinatário, tratando cada resposta. Use idempotency_key derivada do telefone: se o seu laço quebrar no meio e você reprocessar, quem já recebeu não recebe de novo.

Respeite o limite do plano — ao ver 429, espere o Retry-After em vez de insistir.

Laço seguro
for (const tel of destinos) {
  const r = await enviar(tel, {
    idempotency_key: `campanha_2026_09_${tel}`
  });
  if (r.status === 429) await esperar(r.retry_after);
}

Enviar texto longo

POST /v1/instances/{instance_id}/messages assinada

O limite é 4096 caracteres por mensagem. Acima disso a chamada é recusada com 422 invalid_input.

Quebre o texto e envie em série, esperando cada resposta. Em paralelo, a fila pode entregar a parte 2 antes da 1 e o texto chega embaralhado.

Corte em parágrafo, depois em frase, depois em espaço — nunca no meio da palavra.

Ordem importa
// certo: uma de cada vez
for (const parte of partes) await enviar(parte);

// errado: chegam fora de ordem
await Promise.all(partes.map(enviar));

Acompanhar a entrega

GET /v1/messages assinada

O envio responde 202 com queued: nada foi entregue ainda. Quem diz o que aconteceu é o webhook message.status — ou esta consulta, se você preferir perguntar.

Filtre por status para achar o que falhou, e por since para não varrer o histórico inteiro. A janela máxima é de 90 dias.

Só as que falharam hoje
GET /v1/messages
  ?status=failed
  &direction=outbound
  &since=2026-09-04T00:00:00Z
Resposta
{ "status": "failed", "error_code": "131047",
  "error_message": "Re-engagement message" }

Entender uma falha

GET /v1/messages assinada

Mensagem com status: failed traz error_code e error_message — os dois vêm da Meta, não de nós.

Os mais comuns: 131047 a janela de 24 h fechou (use modelo); 131026 o número não tem WhatsApp; 132000 o modelo tem número de variáveis diferente do aprovado; 130429 vazão estourada — a fila já reenvia sozinha.

Reenviar não é automático para falha definitiva: corrija a causa e mande de novo, com idempotency_key nova.

Erros frequentes
131047  janela fechada — envie modelo
131026  número não tem WhatsApp
132000  variáveis não batem com o modelo
132001  modelo não existe nesse idioma
130429  vazão estourada — a fila reenvia

Ver quem ainda dá para responder

GET /v1/conversations assinada

Cada conversa traz window_expires_at. No futuro, você responde livremente; no passado ou null, só modelo aprovado — e modelo é cobrado.

É a consulta que decide o custo do seu dia: responder dentro da janela é grátis.

Filtrando no cliente
const abertas = r.data.filter((c) =>
  c.window_expires_at && new Date(c.window_expires_at) > new Date());

Listar quem está esperando

GET /v1/conversations assinada

unread conta as mensagens recebidas sem leitura. Maior que zero significa que alguém escreveu e ninguém respondeu.

Some com window_expires_at e você tem a fila de trabalho do dia, na ordem certa: quem espera há mais tempo e cuja janela fecha antes.

Fila de atendimento
const fila = r.data
  .filter((c) => c.unread > 0)
  .sort((a, b) =>
    new Date(a.window_expires_at) - new Date(b.window_expires_at));

Achar conversas paradas

GET /v1/conversations assinada

Use since ao contrário do habitual: em vez de pedir o que é novo, pegue a lista e separe o que não se mexe há horas.

Conversa aberta e parada é receita esfriando — e a janela de 24 h corre contra você.

Paradas há mais de 4 h
const corte = Date.now() - 4 * 3600 * 1000;
const paradas = r.data.filter((c) =>
  c.unread > 0 &&
  new Date(c.last_message_at).getTime() < corte);

Encontrar a conversa de um número

GET /v1/conversations assinada

Não há busca por telefone no servidor: a lista vem por last_message_at e você filtra pelo campo phone.

Compare só os dígitos. O mesmo contato aparece como +55 11 99999-9999 na exibição e 5511999999999 no dado — normalizar os dois lados evita o falso "não encontrei".

Comparação segura
const so = (t) => (t || '').replace(/\D/g, '');
const achou = r.data.find((c) => so(c.phone) === so(alvo));

Exportar o fio de uma conversa

GET /v1/conversations/{conversation_id}/messages assinada

Traz o histórico com direction, type, texto e horário. Use order=asc para ler de cima para baixo.

É o formato certo para jogar num modelo de linguagem, anexar num chamado ou arquivar: uma linha por mensagem, com quem falou.

Virando texto
const texto = r.data.map((m) =>
  `${m.direction === 'inbound' ? 'Cliente' : 'Nós'}: ${m.text}`
).join('\n');

Marcar como lida

POST /v1/messages/{message_id}/read assinada

Os dois traços azuis no celular do contato, e o unread da conversa zerado.

Sem isso, você responde pela API e a pessoa continua vendo a mensagem dela como não lida — o que parece defeito do WhatsApp e chega como reclamação para você.

Só vale para mensagem recebida que já tenha wamid.

Resposta 200
{ "id": "msg_01JB…", "read": true }

Contar conversas

GET /v1/conversations assinada

Não há endpoint de contagem: pagine e some. Com limit=100 e o cursor, cada volta traz cem.

Para número exibido em painel, guarde o resultado por alguns minutos — contar tudo a cada carregamento de tela gasta o limite do seu plano à toa.

Somando
let n = 0, cursor = null;
do {
  const r = await listar(cursor);
  n += r.data.length;
  cursor = r.next_cursor;
} while (cursor);

Só o que mudou desde a última vez

GET /v1/conversations assinada

since devolve apenas conversas com mensagem depois daquele instante. É o que transforma uma sincronização inteira numa consulta barata.

Guarde o last_message_at mais recente que você processou e mande de volta na próxima rodada. Prefira isso a repaginar tudo.

Se você recebe webhooks, esta consulta vira só a rede de segurança para quando o seu endpoint ficou fora do ar.

Sincronização incremental
GET /v1/conversations?since=2026-09-04T11:30:00Z&limit=100

Descobrir o que um modelo pede

GET /v1/instances/{instance_id}/templates assinada

Cada modelo da resposta traz fields: exatamente o que precisa ser preenchido, já com exemplo.

É o que evita descobrir na Meta, em forma de código numérico, que faltava o parâmetro do botão de URL. Leia fields antes de montar o envio.

Trecho da resposta
{
  "name": "lembrete_embarque",
  "language": "pt_BR",
  "fields": [
    { "em": "body", "chave": "1", "exemplo": "Marina" },
    { "em": "button.url", "chave": "1" }
  ]
}

Filtrar o catálogo

GET /v1/instances/{instance_id}/templates assinada

status, category, language e name filtram a lista. Só APPROVED pode ser enviado.

O catálogo é cacheado por 5 minutos. refresh=true relê da Meta na hora — use quando você acabou de aprovar um modelo, e não a cada carregamento: essa chamada consome o orçamento da Graph API, que é compartilhado pela plataforma.

Só os aprovados em português
GET /v1/instances/{id}/templates
  ?status=APPROVED
  &language=pt_BR

Preencher as variáveis do corpo

POST /v1/instances/{instance_id}/messages/template assinada

As variáveis do corpo são posicionais: {{1}}, {{2}}. Mande um objeto com a posição como chave.

Faltar uma, ou mandar a mais, faz a Meta recusar a mensagem inteira com 132000. Confira em fields quantas o modelo espera.

Requisição
{
  "to": "+5511999999999",
  "template": {
    "name": "lembrete_embarque",
    "language": "pt_BR",
    "body": { "1": "Marina", "2": "amanhã" }
  }
}

Cabeçalho com mídia

POST /v1/instances/{instance_id}/messages/template assinada

Modelo com cabeçalho de imagem, vídeo ou documento espera a mídia no envio — o desenho aprovado define o tipo, você fornece o arquivo.

Aceita link com URL pública, ou id de mídia já enviada à Meta. Documento aceita também filename, que é o nome que o contato vê.

Cabeçalho de imagem
{
  "template": {
    "name": "promo_setembro",
    "header": { "media": { "link": "https://exemplo.com/banner.jpg" } }
  }
}

Botões com trecho variável

POST /v1/instances/{instance_id}/messages/template assinada

Botão de URL dinâmica tem uma parte fixa, aprovada, e um trecho que você preenche. Mande em buttons, indexado pela posição do botão.

Botão de copiar código usa code, e oferta por tempo limitado usa offer_expiration_ms.

Esquecer o parâmetro de um botão recusa a mensagem inteira — não só o botão.

Botão de URL
{
  "template": {
    "name": "rastreio",
    "buttons": { "1": "BR123456789" }
  }
}

Carrossel

POST /v1/instances/{instance_id}/messages/template assinada

Modelo de carrossel leva vários cartões, cada um com o próprio cabeçalho de mídia, corpo e botões. Mande em cards, indexado pela posição.

A quantidade de cartões é a do modelo aprovado. Mandar menos, ou mais, é recusado.

Dois cartões
{
  "template": {
    "cards": {
      "0": { "header": { "link": "https://…/1.jpg" } },
      "1": { "header": { "link": "https://…/2.jpg" } }
    }
  }
}

Montar os components à mão

POST /v1/instances/{instance_id}/messages/template assinada

Se você já monta o array da Meta, mande template.components pronto e nós passamos adiante sem validar.

É escotilha de emergência: existe para não travar um recurso novo da Meta atrás de um deploy nosso. Em troca, você perde a conferência contra a definição do modelo — e o erro aparece como recusa da Meta, não como mensagem nossa.

Passando direto
{
  "template": {
    "name": "meu_modelo",
    "language": "pt_BR",
    "components": [ … ]
  }
}

Quando o modelo é cobrado

POST /v1/instances/{instance_id}/messages/template assinada

Sempre. Modelo abre conversa cobrada pela Meta mesmo que o contato nunca responda. A resposta traz billable: true para você saber antes da fatura.

Dentro da janela de 24 h, mensagem comum é grátis. Antes de disparar modelo, confira window_expires_at — se estiver aberta, use texto.

O preço varia por categoria (marketing, utilidade, autenticação) e por país. A tabela é da Meta.

Escolhendo o caminho
if (janelaAberta) await enviarTexto();   // grátis
else                  await enviarModelo();  // cobrado

Criar modelo

POST /v1/instances/{instance_id}/templates assinada

Submete um modelo novo à análise da Meta, no WABA deste número. Ele nasce PENDING e a Meta aprova, recusa ou muda a categoria em minutos ou horas — acompanhe por GET /templates?name=….

Você manda a receita, não os components da Meta. Ela passa pelo mesmo validador do painel: a Meta recusa criação com (#100) Invalid parameter sem dizer o quê, e aqui o erro volta em português, apontando o campo.

Corpo
nameobrigatóriostring
Letras minúsculas, números e _. Sem espaço, sem acento.
languageobrigatóriostring
Ex.: pt_BR.
categoryobrigatórioenum
MARKETING, UTILITY ou AUTHENTICATION.
bodyobject
{ text, examples }. Até 1.024. Variáveis {{1}}, {{2}}… em sequência, cada uma com exemplo. Em AUTHENTICATION não se escreve: o texto é da Meta.
headerobject
{ format: "TEXT", text, example } (até 60), { format: "IMAGE" | "VIDEO" | "DOCUMENT", handle } ou { format: "LOCATION" }. O handle sai de subir mídia.
footerstring
Até 60, sem variável.
buttons[]array
Até 10. type: QUICK_REPLY, URL (variável só no fim, com urlExample), PHONE_NUMBER, COPY_CODE, FLOW ou OTP. Texto até 25.
cards[]array
Carrossel, até 10 cartões com header de mídia, body e buttons. Todos iguais em formato de mídia e quantidade de botões.
authobject
Só AUTHENTICATION: codeExpirationMinutes (1 a 90) e addSecurityRecommendation.
Escopo templates:write — ou messages:send, que as chaves antigas já têm. Com chave test, valida tudo e devolve enviaria_para_a_meta sem submeter. No MCP, validate_template confere o mesmo rascunho.
Requisição
{
  "name": "pedido_enviado",
  "language": "pt_BR",
  "category": "UTILITY",
  "header": { "format": "TEXT", "text": "Pedido {{1}}", "example": "A-4471" },
  "body": {
    "text": "Olá {{1}}, seu pedido saiu e chega até {{2}}.",
    "examples": ["Marina", "sexta-feira"]
  },
  "footer": "Loja Exemplo",
  "buttons": [
    { "type": "URL", "text": "Rastrear",
      "url": "https://loja.exemplo.com/r/{{1}}", "urlExample": "A-4471" },
    { "type": "QUICK_REPLY", "text": "Falar com atendente" }
  ]
}
Resposta
{
  "data": {
    "id": "1284719950312245",
    "name": "pedido_enviado",
    "language": "pt_BR",
    "status": "PENDING",
    "category": "UTILITY"
  }
}
{
  "error": {
    "code": "url_variavel_no_meio",
    "message": "…"
  }
}

Subir mídia do cabeçalho

POST /v1/instances/{instance_id}/templates/media assinada

Para enviar um modelo, cabeçalho de imagem aceita link. Para criar, a Meta exige um handle — é esta rota que gera. Use-o em header.handle ou cards[].header.handle.

O arquivo vai em base64 dentro do JSON, até 5 MB. Não há busca por URL, de propósito. Com chave test, volta um handle SIMULADO_… que só vale para criação com chave test.

Requisição
{
  "mime": "image/jpeg",
  "data": "/9j/4AAQSkZJRgABAQ…"
}
Resposta 200
{
  "data": {
    "handle": "4::aW1hZ2UvanBlZw==:ARb…",
    "bytes": 184233
  }
}