API da Ruvz
API REST para enviar e receber mensagens de WhatsApp e Instagram. A Ruvz é responsável pela integração com a Meta — habilitação do número, credenciais, formato de payload e reenvio.
A integração tem duas direções. O seu sistema chama esta API para enviar e consultar; a Ruvz chama a URL cadastrada por você a cada evento — mensagem recebida, mudança de status de entrega, conversa aberta ou encerrada. A segunda direção está descrita em Webhook.
Versão dos eventos: 2026-09-01 · Todos os corpos são JSON em UTF-8.
Duas restrições que definem a integração
1. Janela de atendimento de 24 horas
Restrição da Meta. No WhatsApp, mensagens de texto livre só são aceitas até 24 horas após a última mensagem recebida do contato. Fora da janela, o envio retorna 403 window_expired e o único envio possível é um modelo aprovado, pelo mesmo POST /v1/messages com template_name. No Instagram a janela é a mesma de 24 horas, mas não há modelos: fora dela, é preciso esperar o contato escrever de novo. Cada conversa retornada informa window_expires_at, o instante em que a janela se encerra.
2. O envio é assíncrono
200 em POST /v1/messages significa mensagem registrada e enfileirada, não entregue. A entrega é reportada posteriormente pelo evento message.status. Não use a resposta do envio como confirmação de entrega.
Autenticação
Autenticação por token, enviado no header Authorization em todos os requests. Tokens são criados em Configurações → Conta → API, no painel da Ruvz, e exibidos uma única vez no momento da criação.
Authorization: Bearer rvz_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxCada token é restrito aos fluxos selecionados na criação e às permissões de leitura e de envio, concedidas separadamente. A validade é definida na criação; após expirar, o token é recusado.
A revogação tem efeito imediato no painel e em até 60 segundos nos servidores da API. Token revogado, expirado ou inexistente retorna 401 invalid_token, sem distinção entre os três casos.
Não há endpoint de criação de tokens. Para rotação sem interrupção: crie o novo token no painel, publique-o no seu sistema, valide o funcionamento e revogue o anterior.
Endpoints
| POST | /v1/messages | envio — texto, mídia ou modelo, um modo por request |
| GET | /v1/conversations | lista conversas de um número; com destinatário, retorna uma |
| GET | /v1/messages | histórico de uma conversa, do mais recente para o mais antigo |
| GET | /v1/contacts | lista contatos de um número; com destinatário, retorna um |
| GET | /v1/templates | modelos aprovados do número, com a contagem e a ordem dos parâmetros |
| POST | /v1/templates | envia um modelo novo para aprovação da Meta |
| POST | /v1/templates/sync | repuxa os modelos da Meta para este número |
| POST | /v1/media | upload multipart; retorna media_id reutilizável |
| GET | /v1/media/:id | nova URL assinada para uma mídia |
Endereçamento
Nenhum endpoint recebe identificadores internos da Ruvz. Uma conversa é endereçada pelo par que as duas partes já possuem: o número conectado que envia e o identificador de quem recebe. Você não precisa armazenar identificadores da Ruvz no seu sistema.
| from | string · obrigatório | número conectado que envia, ou @usuario no Instagram. Aceita qualquer formatação: 5511988880000 e +55 11 98888-0000 são equivalentes |
| to | string | telefone do contato, em dígitos ou formatado |
| bsuid | string | identificador atribuído pela Meta quando o contato não expõe o telefone |
| instagram_id | string | identificador do contato no Instagram |
from é obrigatório em todos os requests, inclusive quando o token alcança um único fluxo. Exatamente um entre to, bsuid e instagram_id deve ser informado; o canal é derivado dessa escolha e nunca é declarado. Informar dois retorna 400 invalid_request.
Nas rotas de leitura, os mesmos campos são enviados como parâmetros de query. Os três identificadores são devolvidos nos eventos do webhook com os mesmos nomes.
Falhas de endereçamento
| 404 | unknown_sender | nenhum número conectado ao token corresponde a from |
| 404 | unknown_recipient | o destinatário nunca trocou mensagens com esse número |
| 404 | no_conversation | o contato existe, mas não há conversa neste canal — envie um modelo |
| 409 | ambiguous_recipient | mais de um cadastro corresponde ao identificador informado |
ambiguous_recipient ocorre quando dois cadastros do mesmo número existem na conta. O envio é recusado em vez de resolvido por escolha arbitrária. Solicite a unificação dos cadastros ao suporte.
Enviar uma mensagem
POST /v1/messages é o único endpoint de envio. Além do endereçamento, o corpo deve conter exatamente um modo de conteúdo. Dois modos no mesmo request retornam 400 invalid_request.
| text | string | texto livre. Sujeito à janela de 24 horas |
| media | string | URL https:// do arquivo, ou data URI em base64. Exclusivo com media_id |
| media_id | string | identificador devolvido por POST /v1/media. Exclusivo com media |
| template_name | string | nome de um modelo aprovado no fluxo. Único modo aceito fora da janela |
curl -X POST https://api.ruvz.com.br/v1/messages \
-H "Authorization: Bearer $RUVZ_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 4f3c2b1a-..." \
-d '{
"from": "5511988880000",
"to": "5511999990000",
"text": "Seu pedido saiu para entrega."
}'{ "message_id": "9f7c3e02-4a18-4bd5-91e6-2c840fb7a361", "status": "pending" }status na resposta é sempre pending. Os estados seguintes — sent, delivered, read, failed — chegam pelo evento message.status, correlacionados pelo message_id retornado aqui.
Enviar arquivos
Há três formas de informar o arquivo. Todas produzem o mesmo resultado: o arquivo é armazenado pela Ruvz antes do envio, o que garante que ele continue disponível na conversa, em reenvios e no media_id devolvido pelo webhook.
1. URL
Informe uma URL https:// pública. O download é feito pelo servidor, de forma assíncrona, após a resposta. Limite de 16MB.
curl -X POST https://api.ruvz.com.br/v1/messages \
-H "Authorization: Bearer $RUVZ_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: pedido-8842-nf" \
-d '{
"from": "5511988880000",
"to": "5511999990000",
"media": "https://seu-sistema.com.br/notas/8842.pdf",
"caption": "Segue a nota fiscal",
"file_name": "nota-fiscal.pdf"
}'Como o download ocorre após a resposta, o 200 não confirma o acesso ao arquivo. URL indisponível, endereço de rede interna, arquivo acima do limite ou tipo não suportado resultam em message.status com failed e o motivo em error_message. Apenas https:// é aceito.
2. Base64
Informe o arquivo como data URI. Limite de 5MB já decodificados — acima disso, use URL ou upload, já que a codificação em base64 aumenta o corpo em aproximadamente um terço e é retransmitida a cada nova tentativa.
curl -X POST https://api.ruvz.com.br/v1/messages \
-H "Authorization: Bearer $RUVZ_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: avatar-cliente-42" \
-d '{
"from": "5511988880000",
"to": "5511999990000",
"media": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg...",
"media_type": "image",
"caption": "Seu comprovante"
}'3. Upload prévio
Para o mesmo arquivo enviado a vários destinatários, faça o upload uma vez em POST /v1/media (multipart, campos file e from) e reutilize o media_id retornado. Limite de 16MB.
curl -X POST https://api.ruvz.com.br/v1/messages \
-H "Authorization: Bearer $RUVZ_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: pedido-8842-pdf" \
-d '{
"from": "5511988880000",
"to": "5511999990000",
"media_id": "YWNjXzEvZmxvdy...",
"file_name": "nota-fiscal.pdf",
"caption": "Segue a nota fiscal"
}'Campos e tipos
| media_type | string · opcional | image, video, audio ou document. Determinado pelo conteúdo do arquivo quando ausente |
| caption | string · opcional | legenda; não se aplica a áudio |
| file_name | string · opcional | nome exibido ao destinatário em documentos |
O tipo é determinado pelo conteúdo do arquivo, não pela extensão da URL nem pelo cabeçalho do data URI. Um media_type divergente do conteúdo retorna 400 media_type_mismatch nos modos com arquivo já disponível, e é corrigido silenciosamente no modo URL após o download.
Tipos aceitos
As listas dos modos upload e base64 e do modo URL não são a mesma, e a diferença não é decorativa: um .docx enviado por POST /v1/media funciona, e o mesmo arquivo informado por URL falha no download.
Upload e base64: image/jpeg, image/png, image/webp, image/gif, audio/aac, audio/amr, audio/mp4, audio/mpeg, audio/ogg, audio/webm, video/mp4, video/3gpp, application/pdf, application/msword, os formatos Office .docx, .xlsx, .pptx e text/csv.
Modo URL: image/jpeg, image/png, image/webp, image/gif, video/mp4, audio/ogg, audio/mpeg, audio/mp4 e application/pdf. Um tipo fora desta lista não é recusado no 200 — chega como message.status com failed, porque a verificação acontece depois do download.
Modelos
Modelos aprovados pela Meta são o único envio aceito fora da janela de 24 horas e a única forma de iniciar uma conversa com um número que nunca enviou mensagem. Quando não existe conversa, ela é criada pelo envio.
Descubra antes de enviar. O nome do modelo, a quantidade de parâmetros e a ordem deles vêm de GET /v1/templates — não os deduza do texto do corpo nem os mantenha fixos no seu código, porque o modelo pode ser alterado na Meta sem passar por você.
GET/v1/templates
Lista os modelos do número conectado. Por padrão retorna apenas os aprovados, que são os que podem ser enviados agora.
curl -G https://api.ruvz.com.br/v1/templates \
-H "Authorization: Bearer $RUVZ_TOKEN" \
--data-urlencode "from=5511988880000"{
"data": [{
"name": "confirmacao_pedido",
"language": "pt_BR",
"category": "utility",
"status": "approved",
"body": "Olá {{1}}, seu pedido {{2}} foi confirmado.",
"parameter_count": 2,
"parameters": [
{ "index": 1, "name": "nome", "example": "Ana" },
{ "index": 2, "name": "pedido", "example": "8842" }
],
"buttons": [{ "type": "QUICK_REPLY", "text": "Confirmar" }],
"sendable": true,
"updated_at": "2026-08-14T12:03:11Z"
}],
"next_cursor": "2026-08-14T12:03:11Z/9f7c3e02-4a18-4bd5-91e6-2c840fb7a361"
}| from | string · obrigatório | número conectado cujos modelos serão listados |
| status | lista · padrão approved | approved, pending, rejected, failed, paused, disabled ou all. Valor desconhecido retorna 400 |
| category | utility | marketing | authentication | lista separada por vírgula. Valor desconhecido retorna 400 |
| search | string | trecho do nome do modelo |
| limit | inteiro · padrão 50 | máximo 200 |
| cursor | string composta | next_cursor da página anterior. Não é um id — repasse inteiro |
| ordem | created_at DESC | do modelo mais novo para o mais antigo |
body vem sempre na forma posicional ({{1}}, {{2}}), que é a que parameters preenche por posição. O campo name de cada parâmetro é o nome com que o modelo foi escrito e serve para você acertar a ordem — não é uma chave de envio, e vem vazio em modelos criados direto na Meta.
parameter_count conta marcadores distintos, não ocorrências: um corpo que repete {{1}} recebe um único valor. É exatamente o número que o envio valida.
sendable responde se o modelo pode ser enviado por esta API. Quando é false, unsupported_reason diz por quê: not_approved, ou header_unsupported para um modelo cujo cabeçalho esta API não consegue preencher — cabeçalho de mídia criado na Meta, ou cabeçalho de texto com variável. Um modelo aprovado pode ter sendable: false; filtre por ele ao montar um seletor.
POST/v1/templates/sync
Repuxa os modelos da Meta para o número informado. Os modelos são sincronizados quando o número é conectado; use esta rota quando um modelo criado depois disso ainda não aparecer na listagem. Requer a permissão messages:write.
curl -X POST "https://api.ruvz.com.br/v1/templates/sync?from=5511988880000" \
-H "Authorization: Bearer $RUVZ_TOKEN"{ "created": 2, "updated": 3, "unchanged": 8, "failed": 0, "total": 13 }A operação é um upsert: repeti-la não duplica nada. unchanged são os modelos que a Meta devolveu iguais aos que já temos — eles não são reescritos, para que o updated_at que você lê continue significando uma mudança de verdade. failed conta os que não conseguimos gravar; um deles não impede os outros.
Falha na Meta retorna 502 meta_error e vale repetir; nenhum modelo gravado retorna 500 internal_error. Uma sincronização já em andamento neste número retorna 409 sync_in_progress — a passagem que você queria está acontecendo.
Esta rota é a única forma de um modelo criado no Gerenciador da Meta depois da conexão do número aparecer na listagem. Chame-a quando um modelo que você vê na Meta não estiver aqui.
POST/v1/templates
Envia um modelo novo para a revisão da Meta. Requer a permissão messages:write; mande Idempotency-Key como em qualquer escrita.
curl -X POST https://api.ruvz.com.br/v1/templates \
-H "Authorization: Bearer $RUVZ_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: modelo-pedido-enviado" \
-d '{
"from": "5511988880000",
"name": "pedido_enviado",
"category": "utility",
"body": "Olá {{1}}, seu pedido {{2}} saiu para entrega.",
"body_examples": ["Ana", "8842"],
"footer": "Responda PARAR para não receber mais",
"buttons": [
{ "type": "URL", "text": "Rastrear pedido", "url": "https://loja.com.br/rastreio" },
{ "type": "PHONE_NUMBER", "text": "Falar com a loja", "phone_number": "+5511999990000" },
{ "type": "QUICK_REPLY", "text": "Recebi" }
]
}'| from | string · obrigatório | número WhatsApp conectado que será dono do modelo |
| name | string · obrigatório | minúsculas, dígitos e _. Não é corrigido: é o template_name do envio |
| category | utility | marketing · obrigatório | authentication não é aceito: a Meta fixa o corpo e exige botão de código |
| language | string · padrão pt_BR | código de idioma do WhatsApp, como pt_BR ou en_US |
| body | string · obrigatório | até 1024 caracteres, variáveis de {{1}} a {{100}} |
| body_examples | lista de strings | um valor por variável, na ordem das posições. Obrigatório quando há variável |
| footer | string | uma linha, até 60 caracteres, sem variável |
| header_image_media_id | string | imagem de cabeçalho JPEG, PNG ou WebP enviada antes por POST /v1/media |
| buttons | lista | até 3 QUICK_REPLY, 2 URL e 1 PHONE_NUMBER; text até 25 caracteres |
Responde 201 com o modelo no formato de GET /v1/templates, em pending e com sendable: false. A aprovação é da Meta e chega depois, pelo evento template.status — não consulte a listagem em loop.
Botão URL precisa ser um link fixo http(s)://. Variável no link (https://loja.com/{{1}}) ainda não é aceita, porque o envio não preenche parâmetro de botão. PHONE_NUMBER leva o código do país.
Um modelo com o mesmo name e language no número responde 409 template_exists, exceto quando o anterior está failed — esse nunca chegou à Meta e pode ser criado de novo.
POST/v1/messages
Envio do modelo, com os valores na ordem da listagem.
curl -X POST https://api.ruvz.com.br/v1/messages \
-H "Authorization: Bearer $RUVZ_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"from": "5511988880000",
"to": "5511999990000",
"name": "Ana Souza",
"template_name": "confirmacao_pedido",
"parameters": ["Ana", "8842"]
}'| template_name | string · obrigatório | nome do modelo aprovado neste fluxo |
| parameters | array de string | valores das variáveis, na ordem. A quantidade deve ser igual a parameter_count |
| name | string | nome do contato. Obrigatório apenas no primeiro contato com o número |
Quantidade de parâmetros diferente da esperada retorna 400 invalid_template_parameters. Modelo inexistente ou não aprovado no fluxo retorna 403 template_not_approved. Modelo com cabeçalho que esta API não preenche retorna 422 template_header_unsupported.
Apenas to abre conversa. bsuid e instagram_id identificam participantes de conversas existentes e retornam 404 no_conversation quando não há uma.
Idempotência
Envie o header Idempotency-Key em todo envio. Ele é opcional no servidor — um envio sem ele é aceito e simplesmente não ganha proteção contra duplicidade, nem client_reference no evento. Uma chave repetida retorna a resposta original com 200 e o header Idempotent-Replay: true, sem novo envio. A chave é retornada no evento message.sent no campo client_reference.
Use uma chave distinta por mensagem. A validade é de 24 horas. Como o envio é um único endpoint, uma chave corresponde a um envio, independentemente do modo de conteúdo.
Requisição com a mesma chave ainda em processamento retorna 409 idempotency_in_flight. A mesma chave com um corpo diferente retorna 409 idempotency_key_reuse: uma chave corresponde a uma mensagem, e reaproveitá-la para outra resultaria no envio da segunda ser substituído pela resposta da primeira.
Em indisponibilidade do cache de idempotência, a verificação passa a aceitar todos os requests. Nesse intervalo, uma retransmissão pode resultar em envio duplicado — o comportamento é deliberado, já que recusar por precaução descartaria mensagens legítimas.
Consultar dados e arquivos
Todas as leituras usam a permissão messages:read e a mesma autenticação Bearer. Listagens retornam data e, quando houver outra página, next_cursor. Passe esse cursor na próxima chamada; a ordem é da mais recente para a mais antiga.
limit e cursor valem para as três listagens: limit vai de 1 a 200 e é 50 quando ausente — um valor acima de 200 é reduzido a 200, e um valor não numérico ou menor que 1 cai no padrão, sem erro. Nenhuma listagem aceita filtro por data.
GET/v1/conversations
Liste as conversas de um número conectado. Acrescente to, bsuid ou instagram_id para obter uma conversa específica.
| from | string · obrigatório | número conectado cujas conversas serão listadas |
| to · bsuid · instagram_id | string | no máximo um. Presente, a resposta é a conversa em si, não uma lista — sem data nem next_cursor |
| status | open | closed | ausente ou vazio retorna abertas e fechadas. Outro valor não é erro: a lista volta vazia |
| limit | inteiro · padrão 50 | máximo 200 |
| cursor | string | next_cursor da página anterior |
curl -G https://api.ruvz.com.br/v1/conversations \
-H "Authorization: Bearer $RUVZ_TOKEN" \
--data-urlencode "from=5511988880000" \
--data-urlencode "status=open" \
--data-urlencode "limit=50"{
"data": [{
"id": "c8e4b1f7-2d69-4a03-b5c1-7f80ea924d36",
"contact_id": "5b9d0a68-71c4-4f2e-8a37-e1c6b4902d5f",
"to": "5511999990000", "channel": "whatsapp", "status": "open",
"window_expires_at": "2026-09-02T14:22:31Z",
"last_message_at": "2026-09-01T14:22:31Z",
"created_at": "2026-08-27T09:00:00Z"
}],
"next_cursor": "c8e4b1f7-2d69-4a03-b5c1-7f80ea924d35"
}window_expires_at está sempre presente. Em uma conversa que nunca teve janela aberta ele vem como "0001-01-01T00:00:00Z" — uma data válida que significa nenhuma janela, não uma janela vencida em algum passado remoto. Compare com o instante atual e trate o ano 1 como ausência.
status e window_expires_at são independentes. status é o estado do atendimento na Ruvz: a conversa nasce open, é fechada por inatividade ou pelo atendente, e volta a open na próxima mensagem do contato. Uma conversa open pode estar com a janela vencida — nesse caso só um modelo é aceito.
A ordenação desta rota tem uma exceção: conversas que ainda não tiveram troca de mensagens vão para o fim da listagem inteira, e não para a posição que a data delas indicaria. Entre as demais, a ordem é da mensagem mais recente para a mais antiga. Conversas de contatos removidos não aparecem, em nenhum status.
GET/v1/messages
Obtenha o histórico de uma conversa. Além de from, informe exatamente um destinatário.
| from | string · obrigatório | número conectado da conversa |
| to · bsuid · instagram_id | string · obrigatório | exatamente um. Ao contrário das outras duas leituras, aqui o destinatário não é opcional — não existe listagem de mensagens de um número inteiro |
| limit | inteiro · padrão 50 | máximo 200 |
| cursor | string | next_cursor da página anterior |
curl -G https://api.ruvz.com.br/v1/messages \
-H "Authorization: Bearer $RUVZ_TOKEN" \
--data-urlencode "from=5511988880000" \
--data-urlencode "to=5511999990000" \
--data-urlencode "limit=50"{
"data": [{
"id": "9f7c3e02-4a18-4bd5-91e6-2c840fb7a361",
"conversation_id": "c8e4b1f7-2d69-4a03-b5c1-7f80ea924d36",
"direction": "outbound", "type": "text", "text": "Seu pedido saiu.",
"status": "delivered", "origin": "agent",
"created_at": "2026-09-01T14:23:02Z"
}]
}O origin desta rota não usa os mesmos valores do evento message.sent. Aqui os valores são contact, agent, ai, automated e system — e agent cobre tanto um envio desta API quanto um envio feito na inbox, porque são o mesmo tipo de autor na mesma linha. A distinção entre api e inbox existe somente no evento, que é publicado no momento do envio.
Esta rota devolve next_cursor sempre que a página vem cheia, inclusive quando ela é a última — nesse caso a chamada seguinte retorna data vazio. Pare quando data vier vazio, não quando o cursor sumir.
GET/v1/contacts
Liste contatos associados ao número conectado. Use search para filtrar por texto, ou um destinatário para consultar um contato específico.
| from | string · obrigatório | número conectado cujos contatos serão listados |
| to · bsuid · instagram_id | string | no máximo um. Presente, a resposta é o contato em si, não uma lista — e search é ignorado |
| search | string | nome, telefone e valores de campos personalizados, sem distinção de maiúsculas ou acentos. Com dígitos, casa o telefone ignorando pontuação dos dois lados: (11) 99999 encontra 5511999995555 |
| limit | inteiro · padrão 50 | máximo 200 |
| cursor | string | next_cursor da página anterior |
curl -G https://api.ruvz.com.br/v1/contacts \
-H "Authorization: Bearer $RUVZ_TOKEN" \
--data-urlencode "from=5511988880000" \
--data-urlencode "search=Ana" \
--data-urlencode "limit=50"{
"data": [{
"id": "5b9d0a68-71c4-4f2e-8a37-e1c6b4902d5f",
"name": "Ana Souza", "to": "5511999990000",
"phone": "5511999990000", "email": "ana@exemplo.com",
"blocked": false, "created_at": "2026-08-27T09:00:00Z"
}],
"next_cursor": "2026-08-27T09:00:00Z/5b9d0a68-71c4-4f2e-8a37-e1c6b4902d5f"
}O next_cursor desta rota é uma string composta, e não um identificador — assim como o de /v1/templates. Conversas e mensagens devolvem um id. Repasse o valor inteiro, sem interpretá-lo, e dimensione a coluna pela forma composta.
POST/v1/media
Envie o arquivo uma vez e guarde o media_id retornado para usar em múltiplos envios. Aceita os tipos listados em Enviar arquivos, até 16MB.
curl -X POST https://api.ruvz.com.br/v1/media \
-H "Authorization: Bearer $RUVZ_TOKEN" \
-F "from=5511988880000" \
-F "file=@./nota-fiscal.pdf;type=application/pdf"{
"media_id": "YWNjXzEvZmxvdy...",
"mime_type": "application/pdf",
"file_name": "nota-fiscal.pdf",
"size_bytes": 184320
}GET/v1/media/:id
Gere uma URL assinada nova para uma mídia recebida no webhook ou enviada por upload. A URL vale por 24 horas.
curl https://api.ruvz.com.br/v1/media/YWNjXzEvZmxvdy... \
-H "Authorization: Bearer $RUVZ_TOKEN"{
"media_id": "YWNjXzEvZmxvdy...",
"url": "https://storage.googleapis.com/...",
"expires_at": "2026-09-02T14:22:31Z"
}Webhook
Cadastre a URL de recebimento em Fluxos → [fluxo] → Integração. A cada evento no fluxo, a Ruvz executa um POST nessa URL com um corpo JSON assinado.
Todos os eventos compartilham o mesmo envelope:
{
"api_version": "2026-09-01",
"event_id": "evt_msg_message.received_9f7c3e02-4a18-4bd5-91e6-2c840fb7a361",
"delivery_id": "dlv_evt_msg_message.received_9f7c3e02-4a18-4bd5-91e6-2c840fb7a361_1",
"type": "message.received",
"occurred_at": "2026-09-01T14:22:31.412345678Z",
"account_id": "3f2a9c14-8b7d-4e61-9a02-5c1de7b40f88",
"flow_id": "a41c6d92-0e35-47b8-8f1a-6d2b9c73e015",
"data": { }
}| api_version | string | versão do formato dos eventos, hoje a mesma para todos os cadastros |
| event_id | string | estável entre tentativas do mesmo evento; use para descartar repetições |
| delivery_id | string | distinto a cada tentativa; informe em chamados de suporte |
| type | string | tipo do evento |
| occurred_at | ISO 8601 | instante do fato; ordene os eventos por este campo |
| data | object | conteúdo específico do tipo |
Três propriedades válidas para todos os eventos
data.fromaparece nos dez tipos, inclusivemessage.status,message.reactione nos eventos de contato. É o número conectado do fluxo (ou@usuariono Instagram), o mesmo valor que você devolve comofromao enviar. Não o trate como garantido: numa falha transitória ao resolver o número do fluxo o evento é entregue sem o campo — perder o evento inteiro seria pior. O que resta para se orientar depende do tipo: eventos de mensagem e de conversa levamconversation_id; os de contato levam o endereço do contato, mas não a conversa;message.status,message.deleted,message.editedemessage.reactionnão levam endereço algum.- Todos os identificadores da Ruvz —
account_id,flow_id,message_id,conversation_id,contact_id— são UUID de 36 caracteres, sem prefixo. event_idedelivery_idsão strings opacas: texto ASCII de tamanho variável, sem significado a derivar do formato. Odelivery_idé oevent_idcom o número da tentativa ao final, a partir de1, e é o mesmo valor do cabeçalhoX-Ruvz-Delivery-Id.
Responda 2xx em até 10 segundos e processe de forma assíncrona. Entregas sem resposta bem-sucedida são tentadas até 5 vezes, com backoff exponencial de 10s a 600s. Resposta 410 desativa o cadastro imediatamente. Após 100 falhas consecutivas, as entregas são pausadas e o estado é sinalizado no painel.
Catálogo de eventos
Estes são todos os eventos emitidos. Cada entrega usa o envelope de Webhook; os exemplos abaixo mostram apenas o objeto data para destacar o conteúdo específico.
message.receivedMensagem recebidamessage.sentMensagem enviadamessage.statusEntrega mudou de estadomessage.deletedMensagem apagadamessage.editedTexto alteradomessage.reactionReação adicionada ou removidacontact.createdContato criadocontact.updatedContato atualizadocontact.deletedContato excluídoconversation.openedConversa aberta ou reabertaconversation.closedConversa encerradatemplate.statusModelo aprovado, pausado ou reprovadonumber.limitLimite de envio do número mudouaccount.alertConta restrita, banida ou reativadamessage.received
Mensagem recebida do contato.
"data": {
"message_id": "9f7c3e02-4a18-4bd5-91e6-2c840fb7a361", // mensagem na Ruvz
"from": "5511988880000", // número conectado que recebeu
"to": "5511999990000", // contato; devolva este par para responder
"bsuid": "BR.1015844290901466", // ver nota abaixo
"conversation_id": "c8e4b1f7-2d69-4a03-b5c1-7f80ea924d36", // informativo; nenhum endpoint recebe este campo
"contact_id": "5b9d0a68-71c4-4f2e-8a37-e1c6b4902d5f", // idem
"direction": "inbound",
"type": "text", // text | image | audio | video | document | sticker | location | contacts | unsupported
"status": "delivered",
"origin": "contact",
"text": "Oi, meu pedido chegou?",
"created_at": "2026-09-01T14:22:31.412345678Z",
// Presentes quando aplicável:
"provider_message_id": "wamid.HBgN...", // identificador da Meta
"reply_to": "wamid.HBgN...", // mensagem citada pelo contato
"transcript": "...", // transcrição de áudio, quando habilitada
// Presentes em mensagens com mídia:
"media_id": "YWNjXzEvZmxvdy...",
"media_url": "https://storage.googleapis.com/...", // assinada, validade de 24h
"media_mime_type": "image/jpeg",
"media_file_name": "nota-fiscal.pdf"
}status em uma mensagem recebida é delivered — ela chegou ao número conectado. Não confunda com message.status, que reporta a entrega das mensagens que você envia.
to e bsuid não são excludentes neste evento: em coexistência com o aplicativo WhatsApp Business, os dois chegam juntos. Prefira to quando presente e use bsuid como alternativa; a regra de informar exatamente um vale para os requests que você faz, e não para o que o evento traz.
location e contacts chegam sem campos estruturados — sem coordenadas e sem vCard. O rótulo legível (nome do local, endereço ou nome do contato compartilhado) vem em text. unsupported é um tipo que a Meta não permite renderizar; text traz um marcador.
media_url é assinada e expira em 24 horas. Após a expiração, obtenha uma nova URL em GET /v1/media/:id a partir do media_id. Em falha de assinatura o evento é entregue sem media_url e media_id continua lá — é a razão de ele existir no payload. Os dois podem faltar juntos quando o arquivo em si não chegou: o type diz image e não há mídia armazenada. Trate a ausência em vez de supor que é impossível.
message.sent
Mensagem enviada ao contato. Abrange envios por esta API, envios feitos na inbox da Ruvz e envios feitos pelo aplicativo WhatsApp Business.
"data": {
"message_id": "9f7c3e02-4a18-4bd5-91e6-2c840fb7a361",
"from": "5511988880000",
"to": "5511999990000",
"conversation_id": "c8e4b1f7-2d69-4a03-b5c1-7f80ea924d36",
"contact_id": "5b9d0a68-71c4-4f2e-8a37-e1c6b4902d5f",
"direction": "outbound",
"type": "text", // text | image | audio | video | document | template
// em origin echo, também sticker | location | contacts | unsupported
"status": "pending", // ver nota abaixo; acompanhe em message.status
"origin": "api", // api | inbox | echo | campaign
"text": "Chegou sim!",
"created_at": "2026-09-01T14:23:02.100Z",
"client_reference": "pedido-8842", // Idempotency-Key informado no envio
"template_name": "confirmacao_pedido" // presente quando type é template
}| api | origin | envio feito por esta API |
| inbox | origin | envio feito por um usuário na inbox da Ruvz |
| echo | origin | envio feito pelo aplicativo WhatsApp Business |
| campaign | origin | disparo de campanha feito por um usuário na Ruvz |
status é pending nos envios que passam por nós — api, inbox e campaign. Em origin: "echo" ele chega como sent — a mensagem já saiu pelo aplicativo WhatsApp Business antes de nós sabermos dela — e pode chegar já como delivered ou read, quando o recibo da Meta nos alcança antes do próprio eco. Trate o campo como o estado no momento daquele evento, não como um valor inicial fixo.
O eco também amplia o type: o lojista pode enviar do aplicativo uma figurinha, uma localização ou um cartão de contato, e o evento chega com sticker, location, contacts ou unsupported — tipos que os seus próprios envios nunca produzem. Valem as mesmas limitações descritas em message.received: sem campos estruturados, rótulo legível em text.
client_reference preenchido indica que a mensagem foi originada pelo seu sistema; atualize o registro existente em vez de criar outro. A leitura inversa — ausente, logo originada na Ruvz — só vale se você sempre enviar Idempotency-Key: o header é opcional no servidor, e um envio seu feito sem ele produz um message.sent sem client_reference, indistinguível de um envio feito na inbox. Para não depender disso, use origin, que é sempre explícito.
No modo URL de mídia, o evento é emitido antes do download e não inclui media_id. Nos demais modos o campo está presente.
message.status
Mudança de estado de entrega. Emitido uma vez por estado; nem todos os estados ocorrem em toda mensagem.
"data": {
"message_id": "9f7c3e02-4a18-4bd5-91e6-2c840fb7a361",
"conversation_id": "c8e4b1f7-2d69-4a03-b5c1-7f80ea924d36",
"from": "5511988880000", // número conectado
"status": "delivered", // sent | delivered | read | failed
"occurred_at": "2026-09-01T14:23:04.900Z",
// Presentes em failed:
"error_code": 131047,
"error_message": "Re-engagement message"
}Este evento identifica a mensagem, não o contato: não há to nem contact_id. Correlacione pelo message_id devolvido no envio. occurred_at aparece duas vezes, no envelope e dentro de data, com o mesmo valor.
message.deleted
Mensagem apagada. O conteúdo não é retransmitido, pois deixa de existir também na Ruvz.
"data": {
"message_id": "9f7c3e02-4a18-4bd5-91e6-2c840fb7a361",
"conversation_id": "c8e4b1f7-2d69-4a03-b5c1-7f80ea924d36",
"from": "5511988880000",
"deleted_by": "contact" // "contact" | "whatsapp-business-app" | UUID de usuário da Ruvz
}message.edited
Texto alterado dentro da janela de edição do WhatsApp. Não gera mensagem nova: atualize o texto do registro existente. Cada edição produz um evento.
"data": {
"message_id": "9f7c3e02-4a18-4bd5-91e6-2c840fb7a361",
"conversation_id": "c8e4b1f7-2d69-4a03-b5c1-7f80ea924d36",
"from": "5511988880000",
"text": "Chegou sim, hoje de manhã!",
"edited_by": "contact" // "contact" | "whatsapp-business-app" | UUID de usuário da Ruvz
}message.reaction
Reação adicionada ou removida. emoji vazio indica remoção, conforme a sinalização do próprio WhatsApp.
"data": {
"message_id": "9f7c3e02-4a18-4bd5-91e6-2c840fb7a361",
"conversation_id": "c8e4b1f7-2d69-4a03-b5c1-7f80ea924d36",
"from": "5511988880000",
"emoji": "👍",
"by": "contact" // "contact" | "whatsapp-business-app" | UUID de usuário da Ruvz
}👍 → remoção → 👍 são três fatos distintos, e o contato e o atendente podem reagir ao mesmo tempo à mesma mensagem: cada uma dessas ações é um evento com event_id próprio. Não deduza a ação a partir do emoji — o mesmo emoji reaparece numa reação nova.
contact.created · contact.updated · contact.deleted
Criação, alteração ou exclusão lógica de um contato. Os três tipos usam o mesmo formato; faça upsert por contato e respeite o campo de estado.
"data": {
"contact_id": "5b9d0a68-71c4-4f2e-8a37-e1c6b4902d5f",
"from": "5511988880000", // número conectado do fluxo
"name": "Ana Souza",
"to": "5511999990000", // mesmo valor de "phone", sob o nome usado no envio
"bsuid": "BR.1015844290901466", // quando conhecido; pode vir junto com phone
"phone": "5511999990000", // WhatsApp
"instagram_id": "1784...", // Instagram; excludente com phone
"email": "ana@exemplo.com", // quando preenchido
"created_at": "2026-09-01T14:22:30.000Z",
"updated_at": "2026-09-01T15:10:00.000Z",
"is_active": true
}name e created_at, updated_at e is_active estão sempre presentes; os demais só quando preenchidos. Em contact.deleted, is_active é false. created_at é o instante de criação do cadastro — em contact.updated e contact.deleted ele não muda. Use updated_at para o instante persistido da alteração e occurred_at para o instante de publicação do evento.
conversation.opened · conversation.closed
Abertura, reabertura e encerramento de conversa. O encerramento é normalmente automático, ao fim da janela de 24 horas sem novas mensagens.
"data": {
"conversation_id": "c8e4b1f7-2d69-4a03-b5c1-7f80ea924d36",
"from": "5511988880000",
"to": "5511999990000", // e/ou "bsuid" / "instagram_id"
"contact_id": "5b9d0a68-71c4-4f2e-8a37-e1c6b4902d5f",
"channel": "whatsapp", // whatsapp | instagram
"status": "open", // open | closed
"window_expires_at": "2026-09-02T14:22:31.412Z" // ausente quando não há janela aberta
}conversation.opened também é emitido na reabertura de uma conversa encerrada.
template.status
Um modelo mudou de estado na Meta. Sem este evento, a primeira notícia de uma transição APPROVED → PAUSED é um 403 template_not_approved numa chamada que funcionava.
"data": {
"template_id": "b3d1c07a-52f8-4e19-9c64-8ad20e5f7361", // modelo na Ruvz
"template_name": "confirmacao_pedido",
"language": "pt_BR",
"status": "REJECTED", // vocabulário da Meta, repassado como veio
"category": "MARKETING", // MARKETING | UTILITY | AUTHENTICATION
"reason": "INVALID_FORMAT",
"rejection_reason": "O modelo tem parâmetros colados, sem texto entre eles.",
"rejection_recommendation": "Separe os parâmetros com texto descritivo.",
"pause_title": "FIRST_PAUSE", // só em pausa/despausa
"pause_description": "Seu modelo foi pausado.",
"disabled_at": "2026-07-01T00:00:00Z" // só quando desativado
}status é o verbo da Meta sem tradução, e a lista cresce do lado deles: APPROVED, PENDING, REJECTED, PAUSED, FLAGGED, DISABLED, ARCHIVED, REINSTATED e outros. Trate valor desconhecido como “não enviável” em vez de recusar o evento.
FAILED é o único valor nosso: a Meta recusou um modelo criado por POST /v1/templates antes da revisão, e rejection_reason traz o motivo.
number.limit
O limite diário de conversas iniciadas pelo número mudou. Use para ajustar o ritmo de disparo antes de a Meta recusar.
"data": {
"from": "5511988880000",
"event": "THROUGHPUT_UPGRADE", // ONBOARDING | THROUGHPUT_UPGRADE
"limit_tier": "TIER_2K",
"previous_limit_tier": "TIER_250", // ausente quando a Meta não informa o anterior
"max_daily_conversations": 2000 // ausente em TIER_UNLIMITED e TIER_NOT_SET
}Em TIER_UNLIMITED o campo max_daily_conversations é substituído por unlimited: true. Um número que nunca enviou vem como TIER_NOT_SET, sem número nenhum — não trate ausência como zero.
account.alert
Mudança de estado da conta WhatsApp Business inteira: restrição, violação de política, banimento e reativação. É o único evento que avisa antes de os envios pararem — os demais só mostram o sintoma, com message.status: failed em massa.
"data": {
"from": "5511988880000",
"waba_id": "102290129340398",
"event": "ACCOUNT_RESTRICTION",
"restrictions": [
{ "type": "RESTRICTED_BIZ_INITIATED_MESSAGING", "expires_at": "2026-09-10T12:00:00Z" }
],
"ban_state": "SCHEDULE_FOR_DISABLE", // só em DISABLED_UPDATE
"ban_date": "April 17, 2025",
"violation_type": "ADULT" // só em ACCOUNT_VIOLATION
}Uma conta WhatsApp Business hospeda vários números, e cada número é um fluxo com o seu próprio webhook. O mesmo aviso da Meta chega uma vez por fluxo, cada cópia com o seu event_id.
Coexistência com o aplicativo WhatsApp Business
Um número pode operar simultaneamente nesta API e no aplicativo WhatsApp Business. Respostas enviadas pelo aplicativo são entregues como message.sent com origin: "echo". Integrações que ignoram esse evento exibem a conversa sem as respostas enviadas pelo aplicativo.
Consequências para a integração:
contact.createdeconversation.openedpodem ser originados por um envio do aplicativo, antes de qualquermessage.received.- Conversa iniciada pelo aplicativo não abre a janela de 24 horas — apenas mensagens recebidas do contato abrem. Até a resposta do contato, somente modelos aprovados são aceitos.
- Exclusão, reação e edição feitas no aplicativo são entregues como
message.deleted,message.reactionemessage.edited, com o autor identificado como"whatsapp-business-app"ou"contact".
Mensagens enviadas por esta API não são duplicadas por esse caminho: são reconhecidas pelo identificador do WhatsApp e não geram um segundo evento.
Ordem e duplicatas
Os eventos não são entregues em ordem garantida. A escolha é deliberada: uma fila ordenada bloqueia na primeira falha, e uma indisponibilidade momentânea do endpoint interromperia as entregas seguintes daquela conversa.
Ordene os eventos por occurred_at antes de exibi-los e registre os event_id já processados para descartar repetições. A entrega é ao-menos-uma-vez: o mesmo event_id pode ser recebido mais de uma vez.
Eventos de mensagem são autossuficientes: carregam o endereçamento completo. Uma integração que indexa por contact_id deve criar o registro na primeira ocorrência, sem depender de ter recebido contact.created antes.
Verificar a assinatura
Cada entrega leva estes headers. O HMAC-SHA256 é sobre a string "<timestamp>.<corpo cru>" — o timestamp entra na assinatura, e não só no header, justamente para que uma entrega capturada não sirva de replay para sempre.
X-Ruvz-Event: message.received
X-Ruvz-Delivery-Id: dlv_...
X-Ruvz-Timestamp: 1756738951
X-Ruvz-Signature-256: sha256=<hex>Rejeite o request se a diferença entre X-Ruvz-Timestamp e o seu relógio passar de 5 minutos, e compare em tempo constante.
Node.js
const crypto = require("crypto");
// IMPORTANTE: use o corpo CRU, antes de qualquer JSON.parse.
// express.json() já consumiu o stream — configure
// express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } })
function verifyRuvz(rawBody, headers, secret) {
const ts = Number(headers["x-ruvz-timestamp"]);
if (!ts || Math.abs(Date.now() / 1000 - ts) > 300) return false;
const expected =
"sha256=" +
crypto
.createHmac("sha256", secret)
.update(ts + "." )
.update(rawBody)
.digest("hex");
const got = headers["x-ruvz-signature-256"] || "";
const a = Buffer.from(expected);
const b = Buffer.from(got);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}PHP
<?php
function verify_ruvz(string $rawBody, array $headers, string $secret): bool {
$ts = (int) ($headers['X-Ruvz-Timestamp'] ?? 0);
if ($ts === 0 || abs(time() - $ts) > 300) {
return false;
}
$expected = 'sha256=' . hash_hmac('sha256', $ts . '.' . $rawBody, $secret);
$got = $headers['X-Ruvz-Signature-256'] ?? '';
return hash_equals($expected, $got);
}Erros
Erros retornam um código estável, destinado ao tratamento programático. A mensagem é descritiva e pode mudar.
{ "error": { "code": "window_expired", "message": "The customer service window has expired. Send an approved template instead." } }| 400 | invalid_request | corpo inválido, nenhum modo de conteúdo ou mais de um |
| 400 | invalid_phone | to não é um número em formato aceitável |
| 400 | invalid_media | media não é URL https:// nem data URI válido |
| 400 | invalid_media_id | media_id malformado |
| 400 | media_type_mismatch | media_type diverge do conteúdo do arquivo |
| 400 | unsupported_media_type | tipo de arquivo não aceito |
| 400 | invalid_template_parameters | quantidade de parâmetros diferente da esperada pelo modelo |
| 400 | name_required | primeiro contato com o número exige name |
| 401 | missing_token | header Authorization ausente ou sem um token no formato rvz_live_ |
| 401 | invalid_token | token inválido, revogado ou expirado |
| 403 | account_inactive | conta desativada; vale para todas as rotas, inclusive leitura |
| 403 | trial_expired | período de teste encerrado sem assinatura ativa |
| 403 | plan_expired | assinatura vencida; renove no painel. Repetir o request não resolve |
| 403 | insufficient_scope | token sem a permissão exigida pelo endpoint |
| 403 | window_expired | fora da janela de atendimento; envie um modelo |
| 403 | template_not_approved | modelo inexistente ou não aprovado no fluxo |
| 403 | channel_disconnected | fluxo sem canal conectado |
| 422 | template_header_unsupported | modelo com cabeçalho de mídia criado na Meta, ou de texto com variável — veja sendable em GET /v1/templates |
| 404 | unknown_sender | from não corresponde a nenhum número do token |
| 404 | unknown_recipient | destinatário sem histórico com esse número |
| 404 | no_conversation | contato sem conversa neste canal |
| 404 | not_found | recurso inexistente ou fora dos fluxos do token |
| 409 | ambiguous_recipient | mais de um cadastro de contato corresponde ao identificador |
| 409 | ambiguous_sender | mais de um fluxo da conta está conectado ao número informado em from |
| 409 | idempotency_in_flight | request anterior com a mesma chave em processamento |
| 409 | idempotency_key_reuse | mesma chave com corpo diferente do request original |
| 409 | template_exists | POST /v1/templates com name e language que o número já tem |
| 409 | sync_in_progress | já há uma sincronização de modelos em andamento neste número |
| 413 · 400 | file_too_large | arquivo acima do limite do modo utilizado. O código é o mesmo nos dois status — trate por code, não por status |
| 400 | invalid_idempotency_key | Idempotency-Key acima de 255 caracteres |
| 403 | token_misconfigured | token sem fluxo algum atribuído; gere um novo no painel |
| 422 | unsupported_channel | operação não existe neste canal — modelos são exclusivos do WhatsApp |
| 429 | rate_limited | limite de requisições do token excedido |
| 500 | internal_error | falha nossa; a mensagem não foi registrada, pode repetir com a mesma Idempotency-Key |
| 502 | meta_error | a Meta não respondeu durante a sincronização de modelos; vale repetir |
| 503 | storage_unavailable | armazenamento de mídia indisponível; repita depois |
404 é também a resposta para recursos existentes em fluxos que o token não alcança: 403 confirmaria a existência do recurso.
Limites
500 requisições por segundo por token, com pico de 1000. O limite é por token e não por conta, o que permite isolar cargas: um token de relatórios não consome a capacidade do token de envio.
Esse limite protege a infraestrutura da API e não corresponde à capacidade de envio. A capacidade de envio é definida pela Meta, por número de WhatsApp, e é respeitada pela Ruvz antes da entrega. Contas com muitos números conectados têm capacidade agregada superior a esse limite; solicite ajuste ao suporte se necessário.
Excedido o limite, a resposta é 429 rate_limited e nenhuma mensagem é enviada.
| 16MB | upload e URL | POST /v1/media e media como URL |
| 5MB | base64 | tamanho já decodificado, em media como data URI |
| 24h | media_url | validade da URL assinada nos eventos |
| 24h | Idempotency-Key | janela de reconhecimento de chave repetida |
Listagens são paginadas por cursor. Informe limit (padrão 50, máximo 200) e repita a chamada com o next_cursor retornado. Ausência de next_cursor indica fim da coleção.
Changelog
- 2026-09-10
POST /v1/templatescria um modelo e o envia para aprovação da Meta, com botões de resposta rápida, link e telefone. O resultado chega emtemplate.status, que ganha o valorFAILED. Novo erro409 template_exists. Nenhuma mudança em rota existente.- 2026-09-02
- Três eventos novos no webhook:
template.status,number.limiteaccount.alert. Nenhuma mudança em evento existente. Um webhook cadastrado com “todos os eventos” passa a receber os três — ignore tipo desconhecido em vez de responder erro, ou o endpoint é pausado após falhas consecutivas. Um webhook com lista nomeada de eventos não recebe nada novo até você marcá-los. - 2026-08-31
GET /v1/templateslista os modelos do número com a contagem e a ordem dos parâmetros;POST /v1/templates/syncrepuxa da Meta. Novo erro422 template_header_unsupported. Nenhuma mudança em rota existente.- 2026-08-30
- Versão inicial. Endpoint único de envio para texto, mídia (URL, base64 ou upload) e modelos; leitura de conversas, mensagens e contatos; webhook com onze tipos de evento.
Existe uma única versão de eventos em produção, e todo cadastro recebe api_version igual. Não há, hoje, fixação de versão por webhook — uma versão nova passa a valer para todos os cadastros, e mudança de formato é comunicada antes de entrar. Compare o campo api_version de cada entrega e rejeite valores que você não conhece, em vez de assumir que o formato nunca muda sob um cadastro existente.