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.
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çãohttps://api.fluxoapi.com.br/v1API pública — mensagens, conversas e funilhttps://mcp.fluxoapi.com.brServidor MCP, para agentes de IAhttps://auth.fluxoapi.com.brAuthorization Server (OAuth 2.1)https://api.fluxoapi.com.br/v1
Chave + assinatura HMAC-SHA256 por requisição.
JSON em tudo. UTF-8. Datas em ISO 8601 com fuso.
Chaves fxk_test_ não enviam mensagem de verdade.
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.
fxk_live_ ou fxk_test_. Pode aparecer em log.401 — sincronize por NTP.409.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.
"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
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
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.
connected, pending, disconnected, suspended.coexistence ou api_only. Instâncias em coexistência também recebem mensagens enviadas pelo celular do dono.{
"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
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.
instance.status_changed e instance.access_expiring.{
"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
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.
+ e código do país. Ex.: +5519991802264.text, image, document, audio, video ou interactive.type é text.document: o nome que aparece no celular do contato, como contrato-setembro.pdf.wamid da mensagem que está sendo respondida. Aparece como citação no WhatsApp.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
{
"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 } }
Listar modelos
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.
APPROVED, PENDING, REJECTED,
PAUSED, DISABLED. Só APPROVED pode ser enviado.MARKETING, UTILITY, AUTHENTICATION.pt_BR. O mesmo nome existe uma vez por idioma.{
"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
}
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
Ú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.
pt_BR."1") ou o nome
("first_name"), conforme o parameterFormat que
GET /templates informa. Também aceito como
template.variables, a forma antiga, só posicional.{"text":{"1":"…"}} para texto,
{"media":{"link":"https://…"}} para imagem, vídeo ou documento
(documento aceita filename), e
{"location":{"latitude":"…","longitude":"…"}} para localização."1". Valor = trecho final da URL, código do cupom ou
token do fluxo."0", "1"…),
valor = {header, body, buttons} do cartão.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.422 missing_parameter dizendo qual campo
faltou — a Meta responderia
Number of parameters does not match, sem dizer qual.billable na resposta.{
"to": "+5519991802264",
"template": {
"name": "lembrete_embarque",
"language": "pt_BR",
"variables": {
"1": "Marina",
"2": "5 de setembro",
"3": "06h40"
}
},
"idempotency_key": "lembrete-8842-d1"
}
{
"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
Mensagens de todas as conversas, das mais recentes para as mais antigas. Útil para auditoria e para reconciliar o que sua base perdeu.
inbound ou outbound.queued, sent, delivered, read, failed. Filtre por failed para achar o que não entregou.
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.
{
"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
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.
{
"id": "msg_01JB8YB3TC7K9WQD",
"read": true
}
404 not_found mensagem inexistente 422 not_readable é de saída, ou sem wamid 503 instance_reconnecting o número caiu agora
Listar conversas
Devolve conversas ordenadas pela última mensagem. Paginação por cursor — nunca por offset, que pula ou repete registros quando chegam mensagens durante a leitura.
open, pending, closed. Aceita múltiplos separados por vírgula.next_cursor da página anterior.GET /v1/conversations
?instance_id=inst_01JB8XR4KP2M9TVQ
&status=open,pending
&limit=25
{
"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
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.
asc (padrão) ou desc.{
"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
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.
ai, human ou unassigned — de quem é a bola agora.{
"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
Avança ou recua uma oportunidade. Dispara lead.stage_changed e grava quem moveu no histórico.
perdido. Alimenta o relatório de motivos de perda.409 se não bater.{
"stage": "fechamento",
"expected_stage": "proposta",
"value_brl": 6960.00
}
{
"id": "lead_01JB8Y3PQR6C8VNM",
"stage": "fechamento",
"previous_stage": "proposta",
"moved_by": "api",
"moved_at": "2026-08-26T14:41:22-03:00"
}
{
"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.
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.
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.
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 } }
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.
| Evento | Quando dispara |
|---|---|
| message.received | algué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.status | enviada, entregue, lida ou falhou |
| message.sent_from_phone | o dono respondeu pelo app do celular — coexistência |
| conversation.window_closing | faltam 2 horas para a janela de 24h fechar |
| lead.stage_changed | oportunidade mudou de etapa |
| instance.status_changed | número caiu, voltou ou mudou de qualidade |
| instance.access_expiring | 7 dias antes de o acesso vencer |
| instance.suspended | envio 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.
{
"type": "message.status",
"data": {
"message_id": "msg_01JB8Y7QK2M4NPXV",
"status": "read",
"rank": 3,
"at": "2026-08-26T14:30:02-03:00"
}
}
sent — a Meta aceitoudelivered — chegou no aparelhoread — o contato abriufailed — não entregourank 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.
| Limite | Valor | Escopo |
|---|---|---|
| Vazão | 80 msg/s (até 1.000) | por número |
| Volume 24h | 250 → 1k → 10k → 100k → ilimitado | por número |
| Requisições | conforme seu plano | por chave |
| Corpo | 1 MB | por requisição |
| Mídia | 16 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.
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.X-RateLimit-Limit: 80 X-RateLimit-Remaining: 18 X-RateLimit-Reset: 1756220403 X-Fluxo-Window-Left: 85847 // seg. da janela 24h
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ódigo | HTTP | O que fazer |
|---|---|---|
| invalid_signature | 401 | confira a string assinada e o relógio do servidor |
| stale_timestamp | 401 | sincronize por NTP — passou dos 300 s |
| nonce_reused | 409 | gere um nonce novo a cada requisição |
| window_closed | 422 | use um modelo aprovado |
| template_not_found | 404 | o modelo não existe nesse idioma; confira em GET /templates |
| rate_limited | 429 | respeite Retry-After, com recuo |
| too_many_failures | 429 | muitas tentativas sem autenticar; a origem foi contida |
| payment_required | 402 | mensalidade em aberto — só o envio para; receber e ler seguem |
| instance_reconnecting | 503 | tente de novo — nada foi perdido |
| instance_not_connected | 409 | a instância não concluiu a conexão com a Meta |
| insufficient_scope | 403 | a chave não tem a permissão; o campo required_scope diz qual |
| invalid_input | 422 | corpo ou query fora do formato; a mensagem diz o campo |
| range_too_wide | 422 | a janela de GET /messages passa de 90 dias |
| not_readable | 422 | só mensagem recebida e com wamid pode ser marcada como lida |
| stage_mismatch | 409 | o lead saiu de expected_stage; releia e decida |
| not_found | 404 | não existe — ou está fora do alcance desta chave |
| server_error | 500 | falha 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.
{
"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.
Qualquer linguagem. Quatro cabeçalhos e um HMAC.
Você recebe os eventos assinados, com reenvio automático.
O número segue no celular e responde pela API ao mesmo tempo.
Conecte um agente de IA direto às suas conversas.
# 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.
| Estado | Posto | Significa |
|---|---|---|
| queued | 0 | aceita por nós, esperando a vez na fila do número |
| sent | 1 | entregue à Meta, que assumiu o envio |
| delivered | 2 | chegou ao aparelho do contato |
| read | 3 | o contato abriu |
| failed | 9 | nã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.
// 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.
| Endpoint | Limite por página | Como avançar |
|---|---|---|
| GET /conversations | 100 (padrão 25) | cursor |
| GET /messages | 200 (padrão 50) | estreite since e until |
| GET /conversations/{id}/messages | 200 (padrão 50) | since + order |
| GET /leads | 200 (padrão 50) | stale_hours, stage |
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.
| Prefixo | O 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.
{
"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.
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, })
$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);
// 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.
| Recurso | Ações |
|---|---|
| Mensagem | 16 — 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 |
| Conversa | 10 — listar, não lidas, com janela aberta, paradas, mensagens, buscar por telefone, contar, exportar transcrição, verificar janela, marcar lida |
| Instância | 4 — listar, obter, verificar saúde, testar conexão |
| Modelo | 5 — listar, obter, listar campos, pré-visualizar, validar preenchimento |
| Utilidade | 1 — 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.
# 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.
| Escopo | Dá acesso a |
|---|---|
| instances:read | seus números e o estado deles |
| docs:read | a especificação da API e a receita da assinatura |
| logs:read | erros 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.
{
"mcpServers": {
"fluxoapi": {
"type": "http",
"url": "https://mcp.fluxoapi.com.br"
}
}
}
# 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
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.
{
"status": "ok",
"db": true,
"redis": true,
"version": "1.0.0"
}
curl -s https://api.fluxoapi.com.br/health
# no seu monitor: alerta quando o código sair de 200
Métricas
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.
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
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.
{
"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
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.
{
"client_name": "Claude Desktop",
"redirect_uris": ["http://localhost:33418/callback"],
"token_endpoint_auth_method": "none"
}
{
"client_id": "mcp_01M1P6VCX987SHE7R5M1YWH3K5",
"client_id_issued_at": 1788525197,
"scope": "instances:read docs:read logs:read"
}
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
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.
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
https://localhost:33418/callback ?code=oac_… &iss=https://auth.fluxoapi.com.br &state=…
Trocar o código por token
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.
grant_type=authorization_code code=oac_… code_verifier=… redirect_uri=http://localhost:33418/callback
{
"access_token": "fxa_…",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "fxr_…"
}
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
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.
token=fxa_…
200 OK, sem corpo.
Metadata do recurso protegido
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.
{
"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
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.
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": { "name": "get_openapi" }
}
HTTP/1.1 401 WWW-Authenticate: Bearer resource_metadata= "https://mcp.fluxoapi.com.br/.well-known/oauth-protected-resource"
Tabela de preços
Faixas e preço por número. Sem autenticação — é o que a página de planos consome.
faixas, preço por número e desconto por volume
Dados do link de conexão
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.
{
"instance_name": "Central",
"tenant_name": "Gauss",
"already_connected": false
}
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étodo | Caminho | O que faz |
|---|---|---|
| POST | /webhooks/asaas | Entrada dos eventos de cobrança. Token no cabeçalho. |
| POST | /webhooks/fluxo | Endpoint de teste, para você conferir a nossa assinatura. |
| GET | /webhooks/meta | Verificação do endpoint, feita uma vez pela Meta. |
| POST | /webhooks/meta | Entrada 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étodo | Caminho | O que faz |
|---|---|---|
| POST | /panel/auth/login | Login. Limite por origem e por conta. |
| POST | /panel/auth/logout | Encerra a sessão e apaga o cookie. |
| GET | /panel/billing | Assinatura, cartões e cobranças. |
| POST | /panel/billing/card | Tokeniza um cartão. O número não passa pelo nosso banco. |
| DELETE | /panel/billing/card/:id | Remove o cartão. Barra se for o único de assinatura ativa. |
| PUT | /panel/billing/card/:id/default | Define o cartão padrão. |
| POST | /panel/billing/customer | Cria o cadastro de cobrança. |
| POST | /panel/billing/quota | Muda a cota de números. |
| GET | /panel/billing/quote | Simula o preço de uma cota antes de contratar. |
| POST | /panel/billing/refresh | Relê o estado da cobrança no Asaas. |
| POST | /panel/billing/reprice | Reaplica o preço vigente à assinatura. |
| POST | /panel/billing/subscribe | Assina um plano. |
| DELETE | /panel/billing/subscription | Cancela a assinatura. |
| GET | /panel/conversations | Conversas da conta, com busca. |
| GET | /panel/conversations/:id/messages | O fio. Abrir zera o não lidas. |
| POST | /panel/conversations/:id/messages | Responde pelo painel e enfileira o envio. |
| GET | /panel/conversations/unread-count | Quantas conversas esperam resposta. Só o número. |
| GET | /panel/credentials | Chaves da conta. Só o prefixo aparece. |
| POST | /panel/credentials | Cria uma chave. Chave e segredo aparecem uma única vez. |
| POST | /panel/credentials/:id/revoke | Revoga a chave na hora. |
| GET | /panel/funnel | Quadro do funil, com etapas e valores. |
| GET | /panel/health | Alertas da conta: acesso vencendo, webhook falhando, fila de falhas. |
| GET | /panel/instances | Números da conta, com estado e contadores. |
| POST | /panel/instances | Cria uma instância, respeitando a cota do plano. |
| DELETE | /panel/instances/:id | Apaga a instância e libera a vaga do plano. |
| POST | /panel/instances/:id/connect-link | Gera o link para o dono do número conectar. |
| POST | /panel/instances/:id/resubscribe | Reassina o app no WABA — conserta "conectou e não chega mensagem". |
| PUT | /panel/instances/:id/webhook | Define a URL do webhook. Validada contra SSRF antes de gravar. |
| POST | /panel/instances/:id/webhook/rotate | Segredo novo. Por 24 h as duas assinaturas valem. |
| POST | /panel/leads/:id/stage | Move a oportunidade de etapa. |
| GET | /panel/mcp/clients | Clientes MCP da conta, inclusive os que se registraram sozinhos. |
| POST | /panel/mcp/clients | Cria um cliente MCP com client_id fixo. |
| DELETE | /panel/mcp/clients/:clientId | Revoga o cliente — ou só os seus tokens, se ele for órfão. |
| PATCH | /panel/mcp/clients/:clientId | Troca escopo. Reduzir permissão derruba os tokens vivos. |
| GET | /panel/me | Quem está logado, a conta e o carimbo da foto. |
| GET | /panel/overview | Os números do topo da tela de instâncias. |
| GET | /panel/settings | Dados da conta, equipe e limites. |
| PUT | /panel/settings/profile | Nome e senha de quem está logado. |
| PUT | /panel/settings/tenant | Renomeia a conta — é o nome que a lateral mostra. |
| DELETE | /panel/settings/tenant/avatar | Remove a foto. |
| POST | /panel/settings/tenant/avatar | Envia a foto. O tipo é conferido pelos bytes, não pelo rótulo. |
| GET | /panel/signup/config | Parâmetros do Embedded Signup para o front montar o FB.login. |
| POST | /panel/signup/exchange | Troca o código da Meta por token, conferindo o dono do número. |
| GET | /panel/stream | Eventos em tempo real (SSE). Teto de 50 conexões por conta. |
| DELETE | /panel/templates | Exclui um modelo. A Meta apaga por nome, não por id. |
| GET | /panel/templates | Catálogo de modelos, com contagem por estado. |
| POST | /panel/templates | Cria um modelo e envia para aprovação da Meta. |
| GET | /panel/templates/instances | Números que têm WABA e portanto catálogo. |
| POST | /panel/templates/media | Sobe a mídia do cabeçalho e devolve o handle. |
| POST | /panel/templates/send | Envia um modelo pelo painel. |
| GET | /panel/tenant/avatar | Serve 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étodo | Caminho | O que faz |
|---|---|---|
| GET | /admin/audit | Trilha de auditoria. |
| GET | /admin/billing/events | Eventos crus recebidos do Asaas. |
| POST | /admin/billing/events/:id/replay | Reprocessa um evento guardado. |
| POST | /admin/billing/reconcile | Reconcilia assinaturas com o Asaas. |
| POST | /admin/billing/resync/:instance_id | Recalcula a cobrança de um número. |
| GET | /admin/charges | Todas as cobranças. |
| GET | /admin/errors | Trilha de erros da plataforma. |
| GET | /admin/health | Fila, tokens vencendo, webhooks falhando. |
| GET | /admin/instances | Todos os números da plataforma. |
| POST | /admin/instances/:id/refresh-billing | Reaplica o estado de cobrança a um número. |
| GET | /admin/overview | Resumo da plataforma: contas, receita, mensagens, erros. |
| GET | /admin/revenue | Receita por período. |
| GET | /admin/tenants | Todas as contas. |
| GET | /admin/tenants/:id | Uma conta em detalhe. |
Enviar imagem
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.
{
"to": "+5511999999999",
"type": "image",
"media": {
"url": "https://exemplo.com/arquivo",
"caption": "Segue a foto"
}
}
{
"id": "msg_01JB…",
"status": "queued",
"billable": false
}
Enviar documento
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.
{
"to": "+5511999999999",
"type": "document",
"media": {
"url": "https://exemplo.com/arquivo?id=93",
"filename": "contrato-setembro.pdf",
"caption": "Contrato de setembro"
}
}
{
"id": "msg_01JB…",
"status": "queued",
"billable": false
}
Enviar áudio
Á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.
{
"to": "+5511999999999",
"type": "audio",
"media": {
"url": "https://exemplo.com/arquivo"
}
}
{
"id": "msg_01JB…",
"status": "queued",
"billable": false
}
Enviar vídeo
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.
{
"to": "+5511999999999",
"type": "video",
"media": {
"url": "https://exemplo.com/arquivo",
"caption": "Como configurar"
}
}
{
"id": "msg_01JB…",
"status": "queued",
"billable": false
}
Enviar botões de resposta
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.
id (até 256) e title (até 20). Títulos e ids não podem se repetir.{ "type": "text", "text": "…" } (até 60) ou image, video, document com url pública ou id de mídia da Meta.id é o contrato com o seu código.{
"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"
}
{
"error": {
"code": "invalid_input",
"message": "Botões de resposta: de 1 a 3 em interactive.buttons."
}
}
Enviar lista
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.
title (até 24) e rows. Cada linha: id (até 200, único entre seções), title (até 24) e description opcional (até 72).{ "type": "text", "text": "…" }, até 60.{
"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" }
]}
]
}
}
}
Enviar botão de link
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.
{
"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
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.
header nem footer na mensagem.type image ou video, com url pública ou id.{ id, title } (título até 20), ou um link { display_text, url }.{
"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" }
]
}
]
}
}
{
"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.kind | De onde veio |
|---|---|
| button_reply | botão de resposta, ou resposta rápida de carrossel |
| list_reply | linha da lista — traz description se a linha tinha |
| template_button | resposta rápida de modelo; aqui type é button e id é o payload do botão |
{
"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
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.
| type | O que chega em media |
|---|---|
| image | mime_type (image/jpeg, image/png) e caption, se tiver legenda |
| audio | mime_type (em geral audio/ogg; codecs=opus) e voice: true quando é mensagem de voz gravada no app |
| video | mime_type (video/mp4) e caption |
| document | mime_type, filename e caption |
| sticker | mime_type image/webp |
| Campo | Para que serve |
|---|---|
| download_path | rota para baixar; some à base da API. Assine como qualquer GET — o path da assinatura é esse valor |
| id | identificador da mídia na Meta; útil para deduplicar |
| sha256 | hash 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.
| Status | Significado |
|---|---|
| 200 | o arquivo, no corpo |
| 302 | mídia que você enviou por link: redireciona para o seu próprio media.url |
404 no_media | a mensagem é de texto |
410 media_expired | a Meta apaga a mídia 30 dias depois do envio |
503 instance_reconnecting | o número está desconectado; tente depois |
download_path como link permanente.{
"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"
}
}
}
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
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ê?".
{
"to": "+5511999999999",
"type": "text",
"text": { "body": "Isso mesmo!" },
"reply_to": "wamid.HBgNNTUxMTk5…"
}
// GET /v1/conversations/{id}/messages { "id": "msg_01JB…", "wamid": "wamid.HBgN…" }
Enviar para vários contatos
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.
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
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.
// 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
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.
GET /v1/messages ?status=failed &direction=outbound &since=2026-09-04T00:00:00Z
{ "status": "failed", "error_code": "131047",
"error_message": "Re-engagement message" }
Entender uma falha
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.
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
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.
const abertas = r.data.filter((c) => c.window_expires_at && new Date(c.window_expires_at) > new Date());
Listar quem está esperando
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.
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
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ê.
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
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".
const so = (t) => (t || '').replace(/\D/g, ''); const achou = r.data.find((c) => so(c.phone) === so(alvo));
Exportar o fio de uma conversa
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.
const texto = r.data.map((m) => `${m.direction === 'inbound' ? 'Cliente' : 'Nós'}: ${m.text}` ).join('\n');
Marcar como lida
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.
{ "id": "msg_01JB…", "read": true }
Contar conversas
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.
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
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.
GET /v1/conversations?since=2026-09-04T11:30:00Z&limit=100
Descobrir o que um modelo pede
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.
{
"name": "lembrete_embarque",
"language": "pt_BR",
"fields": [
{ "em": "body", "chave": "1", "exemplo": "Marina" },
{ "em": "button.url", "chave": "1" }
]
}
Filtrar o catálogo
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.
GET /v1/instances/{id}/templates
?status=APPROVED
&language=pt_BR
Preencher as variáveis do corpo
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.
{
"to": "+5511999999999",
"template": {
"name": "lembrete_embarque",
"language": "pt_BR",
"body": { "1": "Marina", "2": "amanhã" }
}
}
Cabeçalho com mídia
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ê.
{
"template": {
"name": "promo_setembro",
"header": { "media": { "link": "https://exemplo.com/banner.jpg" } }
}
}
Botões com trecho variável
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.
{
"template": {
"name": "rastreio",
"buttons": { "1": "BR123456789" }
}
}
Carrossel
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.
{
"template": {
"cards": {
"0": { "header": { "link": "https://…/1.jpg" } },
"1": { "header": { "link": "https://…/2.jpg" } }
}
}
}
Montar os components à mão
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.
{
"template": {
"name": "meu_modelo",
"language": "pt_BR",
"components": [ … ]
}
}
Quando o modelo é cobrado
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.
if (janelaAberta) await enviarTexto(); // grátis else await enviarModelo(); // cobrado
Criar modelo
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.
_. Sem espaço, sem acento.pt_BR.MARKETING, UTILITY ou AUTHENTICATION.{ 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.{ format: "TEXT", text, example } (até 60), { format: "IMAGE" | "VIDEO" | "DOCUMENT", handle } ou { format: "LOCATION" }. O handle sai de subir mídia.type: QUICK_REPLY, URL (variável só no fim, com urlExample), PHONE_NUMBER, COPY_CODE, FLOW ou OTP. Texto até 25.header de mídia, body e buttons. Todos iguais em formato de mídia e quantidade de botões.AUTHENTICATION: codeExpirationMinutes (1 a 90) e addSecurityRecommendation.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.{
"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" }
]
}
{
"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
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.
{
"mime": "image/jpeg",
"data": "/9j/4AAQSkZJRgABAQ…"
}
{
"data": {
"handle": "4::aW1hZ2UvanBlZw==:ARb…",
"bytes": 184233
}
}