Nuntis documentação Referência da API

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

bash
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"]
  }'
201 Created
{
  "id": "wh_42",
  "url": "https://api.seuproduto.com.br/hooks/nuntis",
  "events": ["message.received", "message.status"],
  "active": true,
  "secret": "<o-segredo-de-assinatura>"
}
O segredo sai uma única vez

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.

RotaEfeito
POST /v1/webhooksCria a subscription e devolve o segredo.
GET /v1/webhooksLista 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

EventoQuando 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.

A mensagem que você mesmo enviou não volta como eco

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:

json — message.received
{
  "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": []
  }
}
CampoO que é
eventUm dos cinco nomes acima.
eventIdIdentificador do fato. Estável entre reentregas do mesmo evento — é a sua chave de deduplicação.
timestampQuando o fato aconteceu, em ISO-8601 UTC.
timestampUnixO mesmo instante em segundos. Use para ordenar.
dataO recurso. Para os quatro eventos de mensagem, é byte a byte o mesmo objeto de GET /v1/messages/{id}.
Evento de protocolo do canal não vira mensagem

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.

Mensagens antigas podem trazer o telefone na grafia sem o nono dígito

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.

eventId não é messageId

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:

json — data (recortado)
{
  "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ço msg_… de todo identificador nosso. Serve direto em GET /v1/messages/{id} — é assim que você prende o emoji no balão certo, como o WhatsApp faz.
  • emoji vazio ("") significa que o remetente REMOVEU a reação. Não é “reação sem emoji”, e não existe um campo removed ao lado — a string vazia é a representação. O messageId continua 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 bloco reaction continua presente com o emoji: 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 ganha reaction — a presença do bloco é o significado, como em revoked, failure e group.
Reações anteriores a 03/09/2026 não têm o bloco

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.

O Nuntis publica o evento, não o acumulado

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

json
{
  "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:

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>"
Corpo cru, byte a byte

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)

javascript
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=….

Não processe dentro do handler

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 event ou em enum: ignore, não quebre.