NoviuzNoviuz Docs
API Nexiuz

Webhooks e Notificações em Tempo Real

Especificação e guia de integração de Webhooks da API Nexiuz — headers, verificação de assinatura Standard Webhooks, catálogo de eventos e retries.

Os webhooks da API Nexiuz usam o formato de assinatura Standard Webhooks. A assinatura prova origem e integridade do corpo; seu sistema ainda precisa deduplicar eventos e tratar entregas repetidas.


1. Headers de Entrega

Toda requisição enviada pela Noviuz para seu endpoint de callback contém os seguintes cabeçalhos HTTP:

CabeçalhoDescriçãoExemplo
webhook-idIdentificador único da mensagem para deduplicação.<uuid>
webhook-timestampTimestamp Unix em segundos em que a entrega foi despachada.<unix-seconds>
webhook-signatureAssinatura HMAC-SHA256 no formato v1,<base64>.v1,g0hM...
user-agentIdentificação do emissor.Nexiuz-Webhooks/1.0
content-typeTipo de conteúdo (sempre JSON).application/json

2. Catálogo de Eventos

Tipo de EventoEscopo NecessárioDescrição
pix.settledpix:readCobrança PIX liquidada.
pix.failedpix:readCobrança PIX falhou.
pix.expiredpix:readCobrança PIX expirou. Um pagamento tardio dentro da janela suportada pode gerar pix.settled.
withdrawal.settledwithdrawals:readSolicitação de saque foi liquidada com sucesso pela rede bancária.
withdrawal.failedwithdrawals:readSaque falhou ou foi devolvido pelo banco de destino (saldo estornado).

Formato do evento

{
  "id": "<event-id>",
  "type": "pix.settled",
  "schema_version": 1,
  "timestamp": "<timestamp-utc>",
  "environment": "TEST",
  "data": {"object": {"<campos do recurso>": "…"}}
}

3. Verificação de Assinatura

Para garantir que o webhook foi emitido legitimamente pela Noviuz e não foi alterado em trânsito:

Obtenha o Segredo do Endpoint

Ao cadastrar o endpoint (POST /api/public/v1/webhook-endpoints), você recebe uma chave com o prefixo whsec_ (ex: whsec_Mf2K8vL...). Decodifique a parte em base64 após o prefixo para obter os bytes da chave simétrica.

Monte o Conteúdo Canônico

O payload assinado é a concatenação exata:

content = f"{webhook_id}.{webhook_timestamp}." + raw_request_body_bytes

Calcule o HMAC-SHA256

Gere o HMAC-SHA256 dos bytes com a chave decodificada, converta para base64 e compare em tempo constante com uma assinatura v1,<base64> do header. Durante rotação, podem chegar várias assinaturas separadas por espaço; aceite qualquer uma válida com um segredo vigente.

Implementação de Validação

import crypto from 'node:crypto';

export function verifyWebhookSignature({
  secret,
  payload,
  headers,
  toleranceSeconds = 300,
}: {
  secret: string; // "whsec_..."
  payload: string | Buffer; // corpo bruto (raw body) da requisição
  headers: Record<string, string | undefined>;
  toleranceSeconds?: number;
}): boolean {
  const msgId = headers['webhook-id'];
  const timestamp = headers['webhook-timestamp'];
  const headerSignature = headers['webhook-signature'];

  if (!msgId || !timestamp || !headerSignature || !secret.startsWith('whsec_')) {
    return false;
  }

  // 1. Rejeite timestamps fora da tolerância configurada no seu receptor.
  const now = Math.floor(Date.now() / 1000);
  const ts = parseInt(timestamp, 10);
  if (Math.abs(now - ts) > toleranceSeconds) {
    return false;
  }

  // 2. Chave simétrica
  const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
  const body = Buffer.isBuffer(payload) ? payload : Buffer.from(payload, 'utf-8');

  // 3. String de verificação
  const toSign = Buffer.concat([
    Buffer.from(`${msgId}.${timestamp}.`, 'utf-8'),
    body,
  ]);

  const expectedDigest = crypto
    .createHmac('sha256', key)
    .update(toSign)
    .digest('base64');

  const expectedSignature = `v1,${expectedDigest}`;

  // 4. Comparação em tempo constante (rotação pode enviar mais de uma assinatura)
  const signatures = headerSignature.split(' ');
  return signatures.some((sig) => {
    try {
      return crypto.timingSafeEqual(
        Buffer.from(sig, 'utf-8'),
        Buffer.from(expectedSignature, 'utf-8')
      );
    } catch {
      return false;
    }
  });
}

4. Criar e administrar endpoints

  • A API aceita até 10 endpoints por aplicação. A URL precisa usar HTTPS e resolver somente para endereços públicos. Endpoints ativos com a mesma URL não podem ser duplicados.
  • Cada evento assinado exige o escopo de leitura correspondente (pix:read ou withdrawals:read) além de webhooks:write para cadastrar o endpoint.
  • O segredo whsec_… aparece apenas na resposta de criação ou rotação. Armazene-o imediatamente; GET não o retorna.
  • A rotação mantém o segredo anterior válido por até 24 horas. Durante a sobreposição, o header pode conter assinaturas separadas por espaço; aceite qualquer assinatura válida e atualize o segredo dentro da janela.
  • Criação de endpoint na API pública não tem replay idempotente: se a resposta se perder, repetir com nonce novo pode retornar 409 ENDPOINT_ALREADY_EXISTS. Liste endpoints e confirme o resultado antes de tentar criar novamente.

5. Política de Retentativas e Confiabilidade

O despachador de webhooks opera sob semântica de entrega pelo menos uma vez (at-least-once delivery). Isso significa que eventos podem ser entregues mais de uma vez devido a timeouts transitórios de rede ou reinicializações.

Grade de Retentativas Automáticas

Caso seu endpoint responda com código HTTP fora da faixa 2xx ou ultrapasse o timeout de 10 segundos, o sistema agenda automaticamente até 8 tentativas com recuo exponencial progressivo:

TentativaIntervalo de EsperaTempo Acumulado Aproximado
1ªImediata0s
2ª60 segundos~1 min
3ª5 minutos~6 min
4ª30 minutos~36 min
5ª2 horas~2,6 horas
6ª6 horas~8,6 horas
7ª12 horas~20,6 horas
8ª24 horas~44,6 horas

Se todas as 8 tentativas falharem, a entrega transiciona para FAILED (Dead-Letter). Você pode consultar entregas falhas via GET /api/public/v1/webhook-endpoints/{id}/deliveries e solicitar reenvio manual via POST .../retry.


6. Tratamento de Eventos Fora de Ordem e Concorrência

Em arquiteturas distribuídas, a ordem física de chegada dos pacotes HTTP não é garantida. Por exemplo:

  1. Seu backend cria um PIX via POST /api/public/v1/pix.
  2. O pagador realiza a transferência imediatamente no app bancário.
  3. O webhook pix.settled pode chegar ao seu servidor antes de a resposta síncrona do POST ter sido gravada no seu banco de dados local.

Melhores Práticas Recomendadas

  • Idempotência por webhook-id: Armazene o webhook-id em uma tabela de controle com restrição UNIQUE. Se uma notificação com o mesmo ID for recebida novamente, responda 200 OK imediatamente sem reprocessar a lógica de negócio.
  • Transições Monotônicas: Nunca reverta o estado de um recurso local para um estado anterior. Se sua base de dados já marcou a cobrança como SETTLED, um eventual evento tardio pix.expired deve ser ignorado.
  • Processamento Assíncrono com Fila: Responda 200 OK ao webhook em até 2 segundos após validar a assinatura e coloque o payload em uma fila interna (RabbitMQ, SQS, Redis BullMQ) para processamento em background.

7. Reconciliação Financeira Periódica (Batimento)

Nenhum webhook substitui um processo formal de reconciliação. Falhas de infraestrutura, interrupções no provedor de nuvem ou rotações de DNS podem causar perda de eventos.

Recomenda-se executar um Job de Reconciliação Contínua (a cada 1 hora ou no fechamento diário) consultando o extrato oficial via GET /api/public/v1/accounts/{id}/transactions com paginação por cursor:

/**
 * Job de reconciliação de extrato da Conta Nexiuz via cursor pagination.
 */
export async function reconcileAccountTransactions(
  accountId: string,
  token: string,
  lastReconciledCursor?: string
) {
  let afterCursor = lastReconciledCursor;
  let hasMore = true;

  while (hasMore) {
    const url = new URL(`${process.env.NOVIUZ_BASE_URL}/api/public/v1/accounts/${accountId}/transactions`);
    url.searchParams.set('limit', '50');
    if (afterCursor) {
      url.searchParams.set('after', afterCursor);
    }

    const res = await fetch(url.toString(), {
      headers: { Authorization: `Bearer ${token}` },
    });
    if (!res.ok) throw new Error(`Falha no extrato: ${res.status}`);

    const { data: transactions, paging } = await res.json();

    for (const tx of transactions) {
      // Reconcilie cada transação com o seu livro interno
      await matchInternalTransaction({
        txId: tx.id,
        code: tx.code,
        amount: tx.amount,
        direction: tx.direction, // "CREDIT" ou "DEBIT"
        status: tx.status,
      });
    }

    afterCursor = paging.after;
    hasMore = transactions.length === 50 && Boolean(paging.after);
  }
}

On this page