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. |
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.
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}. |
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.
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"
}
}
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”.
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.