Enviar e receber
Webhooks
Tudo que chega de fora — mensagens recebidas, mudanças de estado de entrega, conexão de número — chega ao seu servidor por um webhook assinado. É o caminho principal; consultar a API é reconciliação.
1. Registrar o endpoint
POST /v1/webhooks
curl -X POST https://api.nuntis.com.br/v1/webhooks \
-H "x-api-key: $NUNTIS_API_KEY" \
-H "content-type: application/json" \
-d '{
"url": "https://api.seuproduto.com.br/hooks/nuntis",
"events": ["message.received", "message.status"]
}'
{
"id": "wh_42",
"url": "https://api.seuproduto.com.br/hooks/nuntis",
"events": ["message.received", "message.status"],
"active": true,
"secret": "<o-segredo-de-assinatura>"
}
O campo secret aparece apenas nesta resposta.
Não há rota que o releia, e ele nunca aparece em
GET /v1/webhooks. Guarde-o no seu cofre antes de fechar a
conexão. Se perder, apague a subscription e crie outra.
| Rota | Efeito |
|---|---|
POST /v1/webhooks | Cria a subscription e devolve o segredo. |
GET /v1/webhooks | Lista as suas subscriptions — sem o segredo. |
DELETE /v1/webhooks/{id} | Remove. A entrega para imediatamente. |
A URL precisa ser HTTPS e alcançável pela internet pública. URLs recusadas
devolvem 400 VALIDATION_FAILED.
2. Os cinco eventos
| Evento | Quando dispara |
|---|---|
message.received |
Uma mensagem de fora chegou para você. |
message.echo |
Alguém enviou pelo aparelho, fora do Nuntis — o operador respondeu direto pelo aplicativo. Só existe em canal que declara echoDeSaida: true (capacidade do canal). |
message.status |
Transição de entrega: sent, delivered, read, played, failed. |
message.revoked |
O remetente apagou a mensagem para todos. |
connection.updated |
O estado de conexão de um número seu mudou. |
message.echo costuma ser o esquecido, e é ele que mantém a sua
caixa coerente com a realidade: se um operador responde pelo celular, você
precisa saber. E message.revoked chega como evento próprio,
não como status: failed — mensagem apagada não é
mensagem que falhou.
message.echo não chega em todo canal
Este evento depende de o canal devolver ao Nuntis um eco do que saiu por ele
— e nem todo canal devolve. Quem responde isso é echoDeSaida, em
GET /v1/channels: onde o campo
é false, message.echo nunca chega,
e uma tela que fique esperando por ele espera para sempre.
Esse silêncio é contrato, não falha nossa. Decida pelo campo e não pelo canal que você tem hoje: canal novo entra de forma aditiva, e a integração que lê a declaração já nasce certa para ele.
Você já recebeu o messageId no 202 do
POST /v1/messages. Reenviar isso como webhook seria o eco do
seu próprio pedido. O ciclo de vida dela chega por
message.status.
3. O envelope
Todo POST tem exatamente cinco campos no corpo:
{
"event": "message.received",
"eventId": "evt_90311",
"timestamp": "2026-08-13T18:12:03.000Z",
"timestampUnix": 1786126323,
"data": {
"id": "msg_18273",
"conversationId": "conv_991",
"numberId": "num_4711",
"direction": "inbound",
"type": "text",
"status": "received",
"isSystemMessage": false,
"from": {
"phoneE164": "+5511999990000",
"pushname": "Ana Ribeiro"
},
"text": { "body": "Oi, ainda dá tempo de participar?" },
"mentionedIds": [],
"timestamp": "2026-08-13T18:12:03.000Z",
"timestampUnix": 1786126323,
"statusHistory": []
}
}
| Campo | O que é |
|---|---|
event | Um dos cinco nomes acima. |
eventId | Identificador do fato. Estável entre reentregas do mesmo evento — é a sua chave de deduplicação. |
timestamp | Quando o fato aconteceu, em ISO-8601 UTC. |
timestampUnix | O mesmo instante em segundos. Use para ordenar. |
data | O recurso. Para os quatro eventos de mensagem, é byte a byte o mesmo objeto de GET /v1/messages/{id}. |
O WhatsApp entrega, pela mesma tubulação das mensagens, eventos que são
encanamento do protocolo — rótulo de membro de grupo,
handshake de boas-vindas, sincronismo entre os aparelhos da própria conta.
Ninguém escreveu nada, e no aplicativo nativo nenhum deles aparece. A partir
de 04/09/2026 eles não geram
message.received: o Nuntis os descarta na fronteira do canal.
Você deve ver o volume de message.received cair — e é isso que
se espera.
Edição de mensagem ainda não é um recurso do contrato.
Enquanto não for, ela chega como mensagem com
isSystemMessage: true e sem bloco
text — descartá-la apagaria a evidência de um recurso que
pretendemos construir. Filtre por isSystemMessage se não quiser
exibi-la.
Não filtre por “sem conteúdo” no atacado. Uma mensagem sem
text e sem media não é necessariamente lixo, e a
regra genérica comeria eventos legítimos — a começar por
message.revoked, que é justamente o aviso de que alguém apagou
uma mensagem.
from.phoneE164 segue a forma canônica de celular brasileiro
(com o nono dígito). Mensagens recebidas antes de 14/08/2026
podem carregar o valor na grafia antiga, sem o nono dígito — o histórico é
preservado exatamente como foi gravado, nunca reescrito. A partir dessa
data, from.phoneE164 sai sempre na forma canônica.
São coisas diferentes, e confundi-las quebra o dedupe.
message.received e message.status da
mesma mensagem são dois fatos distintos, com
eventId distintos. Se você deduplicar pelo
data.id, vai descartar a atualização de status achando que é
repetição — e nunca saber que a mensagem foi entregue.
type: "reaction" — o emoji e a mensagem alvo
Quando alguém reage a uma mensagem, chega um message.received
(ou message.echo, se a reação partiu do seu próprio aparelho)
com type: "reaction" e um bloco reaction:
{
"id": "msg_18291",
"conversationId": "conv_991",
"type": "reaction",
"reaction": {
"emoji": "😂",
"messageId": "msg_18270"
}
}
-
reaction.messageIdé a mensagem que RECEBEU a reação, no mesmo espaçomsg_…de todo identificador nosso. Serve direto emGET /v1/messages/{id}— é assim que você prende o emoji no balão certo, como o WhatsApp faz. -
emojivazio ("") significa que o remetente REMOVEU a reação. Não é “reação sem emoji”, e não existe um camporemovedao lado — a string vazia é a representação. OmessageIdcontinua presente: você precisa saber qual balão perdeu o emoji. -
messageIdé omitido quando a mensagem reagida não pode ser resolvida nesta conversa — tipicamente porque ela é anterior ao pareamento do seu número. O blocoreactioncontinua presente com oemoji: conhecer o emoji não depende de conhecermos o alvo. O identificador do canal nunca aparece no lugar. -
O bloco só existe em
type: "reaction". Mensagem de texto ou mídia não ganhareaction— a presença do bloco é o significado, como emrevoked,failureegroup.
O conteúdo da reação passou a ser publicado a partir dessa data. Reações
mais antigas continuam chegando por GET /v1/messages com
type: "reaction" e sem o bloco
reaction, para sempre — não houve reprocessamento do acervo.
Foi decisão deliberada: um preenchimento parcial faria a ausência do bloco
significar duas coisas (“não havia emoji” e “não guardamos”) sem que você
pudesse distingui-las.
Cada reação e cada remoção chegam como um evento próprio. O Nuntis
não mantém “quais emojis estão atualmente na mensagem X” —
esse estado agregado é seu, montado a partir dos eventos. Enviar reação
(POST /v1/messages com type: "reaction") também
não existe nesta versão.
connection.updated
{
"event": "connection.updated",
"eventId": "evt_90355",
"timestamp": "2026-08-13T18:30:00.000Z",
"timestampUnix": 1786127400,
"data": {
"numberId": "num_4711",
"state": "disconnected",
"previousState": "connected",
"changedAt": "2026-08-13T18:30:00.000Z",
"reason": null
}
}
O numberId aqui é o mesmo identificador que
aparece nos eventos de mensagem. É por ele que você correlaciona “este número
caiu” com “estas conversas estão paradas”.
reason é sempre presente e null na esmagadora maioria
das transições — é o mesmo campo, com os mesmos valores, que
GET /v1/numbers/{id}/status publica. Quando vier preenchido, o
pareamento foi recusado por divergência de telefone:
o que fazer em cada caso.
Trate valor desconhecido como “sem motivo declarado”.
4. Verificar a assinatura
Cada POST leva três headers:
X-Webhook-Id: evt_90311
X-Webhook-Timestamp: 1786126323
X-Webhook-Signature: sha256=<hex>
A assinatura é o HMAC-SHA256 desta string, com o seu segredo:
"<X-Webhook-Timestamp>" + "." + "<corpo cru da requisição>"
Não é o JSON reserializado. Se você fizer
JSON.stringify(req.body) a assinatura não vai
bater — ordem de chaves, espaços e escapes mudam. O middleware de
JSON destrói o corpo original: você precisa do raw body
antes dele.
Implementação completa (Node/Express)
const crypto = require('crypto');
const express = require('express');
const app = express();
const SEGREDO = process.env.NUNTIS_WEBHOOK_SECRET;
const JANELA_SEGUNDOS = 300; // 5 minutos
/**
* Verifica a assinatura de um POST do Nuntis.
*
* @param {Buffer} corpoCru corpo exatamente como chegou
* @param {string} timestamp header X-Webhook-Timestamp
* @param {string} assinatura header X-Webhook-Signature ("sha256=...")
* @returns {boolean}
*/
function assinaturaValida(corpoCru, timestamp, assinatura) {
if (!corpoCru || !timestamp || !assinatura) return false;
// 1. Rejeite o que estiver fora da janela ANTES de qualquer coisa.
// Sem isto, um POST capturado continua válido para sempre.
const idade = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (!Number.isFinite(idade) || idade > JANELA_SEGUNDOS) return false;
// 2. Recalcule o HMAC sobre "<timestamp>.<corpo cru>".
const esperado =
'sha256=' +
crypto
.createHmac('sha256', SEGREDO)
.update(`${timestamp}.${corpoCru.toString('utf8')}`, 'utf8')
.digest('hex');
// 3. Compare em TEMPO CONSTANTE. Comparar com `===` vaza, pelo tempo de
// resposta, quantos caracteres do prefixo estavam certos — e isso é
// suficiente para forjar a assinatura byte a byte.
const a = Buffer.from(assinatura, 'utf8');
const b = Buffer.from(esperado, 'utf8');
if (a.length !== b.length) return false; // timingSafeEqual exige igual
return crypto.timingSafeEqual(a, b);
}
app.post(
'/hooks/nuntis',
// ⚠️ express.raw() ANTES de qualquer express.json() nesta rota.
express.raw({ type: 'application/json' }),
async (req, res) => {
const ok = assinaturaValida(
req.body, // Buffer, corpo CRU
req.get('X-Webhook-Timestamp'),
req.get('X-Webhook-Signature'),
);
if (!ok) return res.sendStatus(401);
const evento = JSON.parse(req.body.toString('utf8'));
// At-least-once: o mesmo evento pode chegar mais de uma vez.
if (await jaProcessado(evento.eventId)) return res.sendStatus(200);
// Responda rápido; o trabalho pesado vai para a SUA fila.
await enfileirar(evento);
await marcarProcessado(evento.eventId);
res.sendStatus(200);
},
);
O timestamp entra dentro do material assinado justamente para que ninguém possa trocar por um timestamp novo sem ter o segredo. Verificar a janela sem incluir o timestamp na assinatura não protege nada.
5. Garantias de entrega
At-least-once — deduplique por eventId
O mesmo evento pode chegar mais de uma vez: um retry depois
de timeout, uma reconciliação. Isso é escolha de desenho — perder mensagem é
pior que repetir. Guarde os eventId já processados e ignore
repetições. O valor é estável entre reentregas do mesmo fato.
Sem garantia de ordem — ordene por timestampUnix
read pode chegar antes de delivered. Confirmações
atravessam a rede fora de ordem por natureza, e depois de uma reconexão chega
uma rajada inteira de recibos atrasados. Nunca presuma sequência: ordene pelo
campo, e nunca deixe um estado regredir no seu banco.
Responda 2xx em menos de 15 segundos
Fora disso a entrega é considerada falha e é retentada com backoff crescente
por cerca de 6 horas. Depois disso o evento é perdido — e o
caminho de recuperação é GET /v1/messages?since=….
Valide a assinatura, enfileire e responda. Qualquer trabalho pesado dentro do handler vira timeout, que vira retry, que vira o mesmo evento chegando várias vezes enquanto o seu servidor tenta dar conta do primeiro.
Checklist do receptor
- Rota com raw body, antes de qualquer parser de JSON.
- Assinatura verificada em todo POST, com comparação em tempo constante.
- Janela de tempo rejeitada (~5 minutos).
- Deduplicação por
eventId, persistida. - Ordenação por
timestampUnix; estado nunca regride. - Resposta 2xx em menos de 15 s; processamento na sua fila.
- Segredo em cofre ou variável de ambiente, nunca em código.
- Valor desconhecido em
eventou em enum: ignore, não quebre.