API v1https://api.ruvz.com.br/v1Baixar contexto para IA (.md)

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.

EXEMPLO
Authorization: Bearer rvz_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Cada 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/messagesenvio — texto, mídia ou modelo, um modo por request
GET/v1/conversationslista conversas de um número; com destinatário, retorna uma
GET/v1/messageshistórico de uma conversa, do mais recente para o mais antigo
GET/v1/contactslista contatos de um número; com destinatário, retorna um
GET/v1/templatesmodelos aprovados do número, com a contagem e a ordem dos parâmetros
POST/v1/templatesenvia um modelo novo para aprovação da Meta
POST/v1/templates/syncrepuxa os modelos da Meta para este número
POST/v1/mediaupload multipart; retorna media_id reutilizável
GET/v1/media/:idnova 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.

fromstring · obrigatórionúmero conectado que envia, ou @usuario no Instagram. Aceita qualquer formatação: 5511988880000 e +55 11 98888-0000 são equivalentes
tostringtelefone do contato, em dígitos ou formatado
bsuidstringidentificador atribuído pela Meta quando o contato não expõe o telefone
instagram_idstringidentificador 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

404unknown_sendernenhum número conectado ao token corresponde a from
404unknown_recipiento destinatário nunca trocou mensagens com esse número
404no_conversationo contato existe, mas não há conversa neste canal — envie um modelo
409ambiguous_recipientmais 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.

textstringtexto livre. Sujeito à janela de 24 horas
mediastringURL https:// do arquivo, ou data URI em base64. Exclusivo com media_id
media_idstringidentificador devolvido por POST /v1/media. Exclusivo com media
template_namestringnome de um modelo aprovado no fluxo. Único modo aceito fora da janela
EXEMPLO
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."
  }'
EXEMPLO
{ "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.

EXEMPLO
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
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 — REUTILIZAR O UPLOAD
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_typestring · opcionalimage, video, audio ou document. Determinado pelo conteúdo do arquivo quando ausente
captionstring · opcionallegenda; não se aplica a áudio
file_namestring · opcionalnome 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
curl -G https://api.ruvz.com.br/v1/templates \
  -H "Authorization: Bearer $RUVZ_TOKEN" \
  --data-urlencode "from=5511988880000"
RESPOSTA 200
{
  "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"
}
fromstring · obrigatórionúmero conectado cujos modelos serão listados
statuslista · padrão approvedapproved, pending, rejected, failed, paused, disabled ou all. Valor desconhecido retorna 400
categoryutility | marketing | authenticationlista separada por vírgula. Valor desconhecido retorna 400
searchstringtrecho do nome do modelo
limitinteiro · padrão 50máximo 200
cursorstring compostanext_cursor da página anterior. Não é um id — repasse inteiro
ordemcreated_at DESCdo 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
curl -X POST "https://api.ruvz.com.br/v1/templates/sync?from=5511988880000" \
  -H "Authorization: Bearer $RUVZ_TOKEN"
RESPOSTA 200
{ "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
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" }
    ]
  }'
fromstring · obrigatórionúmero WhatsApp conectado que será dono do modelo
namestring · obrigatóriominúsculas, dígitos e _. Não é corrigido: é o template_name do envio
categoryutility | marketing · obrigatórioauthentication não é aceito: a Meta fixa o corpo e exige botão de código
languagestring · padrão pt_BRcódigo de idioma do WhatsApp, como pt_BR ou en_US
bodystring · obrigatórioaté 1024 caracteres, variáveis de {{1}} a {{100}}
body_exampleslista de stringsum valor por variável, na ordem das posições. Obrigatório quando há variável
footerstringuma linha, até 60 caracteres, sem variável
header_image_media_idstringimagem de cabeçalho JPEG, PNG ou WebP enviada antes por POST /v1/media
buttonslistaaté 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.

EXEMPLO
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_namestring · obrigatórionome do modelo aprovado neste fluxo
parametersarray de stringvalores das variáveis, na ordem. A quantidade deve ser igual a parameter_count
namestringnome 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.

fromstring · obrigatórionúmero conectado cujas conversas serão listadas
to · bsuid · instagram_idstringno máximo um. Presente, a resposta é a conversa em si, não uma lista — sem data nem next_cursor
statusopen | closedausente ou vazio retorna abertas e fechadas. Outro valor não é erro: a lista volta vazia
limitinteiro · padrão 50máximo 200
cursorstringnext_cursor da página anterior
CURL
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"
RESPOSTA 200
{
  "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.

fromstring · obrigatórionúmero conectado da conversa
to · bsuid · instagram_idstring · obrigatórioexatamente 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
limitinteiro · padrão 50máximo 200
cursorstringnext_cursor da página anterior
CURL
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"
RESPOSTA 200
{
  "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.

fromstring · obrigatórionúmero conectado cujos contatos serão listados
to · bsuid · instagram_idstringno máximo um. Presente, a resposta é o contato em si, não uma lista — e search é ignorado
searchstringnome, 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
limitinteiro · padrão 50máximo 200
cursorstringnext_cursor da página anterior
CURL
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"
RESPOSTA 200
{
  "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 — MULTIPART
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"
RESPOSTA 200
{
  "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
curl https://api.ruvz.com.br/v1/media/YWNjXzEvZmxvdy... \
  -H "Authorization: Bearer $RUVZ_TOKEN"
RESPOSTA 200
{
  "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:

EXEMPLO
{
  "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_versionstringversão do formato dos eventos, hoje a mesma para todos os cadastros
event_idstringestável entre tentativas do mesmo evento; use para descartar repetições
delivery_idstringdistinto a cada tentativa; informe em chamados de suporte
typestringtipo do evento
occurred_atISO 8601instante do fato; ordene os eventos por este campo
dataobjectconteúdo específico do tipo

Três propriedades válidas para todos os eventos

  • data.from aparece nos dez tipos, inclusive message.status, message.reaction e nos eventos de contato. É o número conectado do fluxo (ou @usuario no Instagram), o mesmo valor que você devolve como from ao 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 levam conversation_id; os de contato levam o endereço do contato, mas não a conversa; message.status, message.deleted, message.edited e message.reaction nã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_id e delivery_id são strings opacas: texto ASCII de tamanho variável, sem significado a derivar do formato. O delivery_id é o event_id com o número da tentativa ao final, a partir de 1, e é o mesmo valor do cabeçalho X-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.received

Mensagem recebida do contato.

EXEMPLO
"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.

EXEMPLO
"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
}
apioriginenvio feito por esta API
inboxoriginenvio feito por um usuário na inbox da Ruvz
echooriginenvio feito pelo aplicativo WhatsApp Business
campaignorigindisparo 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.

EXEMPLO
"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.

EXEMPLO
"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.

EXEMPLO
"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.

EXEMPLO
"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.

EXEMPLO
"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.

EXEMPLO
"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.

EXEMPLO
"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.

EXEMPLO
"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.

EXEMPLO
"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.created e conversation.opened podem ser originados por um envio do aplicativo, antes de qualquer message.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.reaction e message.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.

EXEMPLO
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

EXEMPLO
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

EXEMPLO
<?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.

EXEMPLO
{ "error": { "code": "window_expired", "message": "The customer service window has expired. Send an approved template instead." } }
400invalid_requestcorpo inválido, nenhum modo de conteúdo ou mais de um
400invalid_phoneto não é um número em formato aceitável
400invalid_mediamedia não é URL https:// nem data URI válido
400invalid_media_idmedia_id malformado
400media_type_mismatchmedia_type diverge do conteúdo do arquivo
400unsupported_media_typetipo de arquivo não aceito
400invalid_template_parametersquantidade de parâmetros diferente da esperada pelo modelo
400name_requiredprimeiro contato com o número exige name
401missing_tokenheader Authorization ausente ou sem um token no formato rvz_live_
401invalid_tokentoken inválido, revogado ou expirado
403account_inactiveconta desativada; vale para todas as rotas, inclusive leitura
403trial_expiredperíodo de teste encerrado sem assinatura ativa
403plan_expiredassinatura vencida; renove no painel. Repetir o request não resolve
403insufficient_scopetoken sem a permissão exigida pelo endpoint
403window_expiredfora da janela de atendimento; envie um modelo
403template_not_approvedmodelo inexistente ou não aprovado no fluxo
403channel_disconnectedfluxo sem canal conectado
422template_header_unsupportedmodelo com cabeçalho de mídia criado na Meta, ou de texto com variável — veja sendable em GET /v1/templates
404unknown_senderfrom não corresponde a nenhum número do token
404unknown_recipientdestinatário sem histórico com esse número
404no_conversationcontato sem conversa neste canal
404not_foundrecurso inexistente ou fora dos fluxos do token
409ambiguous_recipientmais de um cadastro de contato corresponde ao identificador
409ambiguous_sendermais de um fluxo da conta está conectado ao número informado em from
409idempotency_in_flightrequest anterior com a mesma chave em processamento
409idempotency_key_reusemesma chave com corpo diferente do request original
409template_existsPOST /v1/templates com name e language que o número já tem
409sync_in_progressjá há uma sincronização de modelos em andamento neste número
413 · 400file_too_largearquivo acima do limite do modo utilizado. O código é o mesmo nos dois status — trate por code, não por status
400invalid_idempotency_keyIdempotency-Key acima de 255 caracteres
403token_misconfiguredtoken sem fluxo algum atribuído; gere um novo no painel
422unsupported_channeloperação não existe neste canal — modelos são exclusivos do WhatsApp
429rate_limitedlimite de requisições do token excedido
500internal_errorfalha nossa; a mensagem não foi registrada, pode repetir com a mesma Idempotency-Key
502meta_errora Meta não respondeu durante a sincronização de modelos; vale repetir
503storage_unavailablearmazenamento 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.

16MBupload e URLPOST /v1/media e media como URL
5MBbase64tamanho já decodificado, em media como data URI
24hmedia_urlvalidade da URL assinada nos eventos
24hIdempotency-Keyjanela 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/templates cria um modelo e o envia para aprovação da Meta, com botões de resposta rápida, link e telefone. O resultado chega em template.status, que ganha o valor FAILED. Novo erro 409 template_exists. Nenhuma mudança em rota existente.
2026-09-02
Três eventos novos no webhook: template.status, number.limit e account.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/templates lista os modelos do número com a contagem e a ordem dos parâmetros; POST /v1/templates/sync repuxa da Meta. Novo erro 422 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.