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

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

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

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:

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.