Sudrel API
Painel Criar chave de API

Documentação › Notificações

Guias

Webhooks

Avisos assinados com HMAC, retentativa por 72 horas, reenvio, troca de segredo e verificação em 4 linguagens.

Implementação mínima

Toda entrega é um POST no seu endereço, com corpo { "tipo", "dados" }:

POST /webhooks/sudrel HTTP/1.1
Content-Type: application/json
X-Sudrel-Event: NFSE_AUTORIZADA
X-Sudrel-Event-Id: 6f1d2c3b-4a59-4e8f-9c0d-1a2b3c4d5e6f
X-Sudrel-Delivery-Id: 0a9b8c7d-6e5f-4a3b-2c1d-0e9f8a7b6c5d
X-Sudrel-Timestamp: 1791043200
X-Sudrel-Signature-V2: t=1791043200,v1=<HMAC-SHA256 em hex>
X-Sudrel-Signature: <HMAC-SHA256 em hex>

{"tipo":"NFSE_AUTORIZADA","dados":{"id":"9d2f0c1e-3a4b-4c5d-8e9f-0a1b2c3d4e5f","referencia":"PED-1001","status":"autorizada", "...": "a nota inteira"}}

O seu recebedor faz, nesta ordem: confere a assinatura e o horário, ignora o X-Sudrel-Event-Id já processado, guarda o evento numa fila sua e responde 2xx. O processamento vem depois, fora da requisição.

Cadastre pelo painel (Painel → Webhooks) ou por POST /v1/webhooks. O segredo do webhook só é exibido uma vez, na criação ou na troca: guarde com o mesmo cuidado de uma senha.

Cabeçalhos

Cabeçalho Conteúdo
X-Sudrel-Event O mesmo valor de tipo do corpo.
X-Sudrel-Event-Id Id do evento. O MESMO em todas as tentativas e reenvios: deduplique por ele. É o id de GET /v1/eventos.
X-Sudrel-Delivery-Id Id desta entrega (uma por evento e webhook; um reenvio é uma entrega nova). Aparece em GET /v1/webhooks/{id}/entregas.
X-Sudrel-Timestamp Momento desta tentativa, em segundos desde 1970 (UTC).
X-Sudrel-Signature-V2 t=<timestamp>,v1=<assinatura>. A assinatura é o HMAC-SHA256 (hex) de "<timestamp>.<corpo bruto>" com o segredo do webhook. Use esta.
X-Sudrel-Signature HMAC-SHA256 (hex) só do corpo bruto. Mantida para quem já integrou; não protege contra reenvio de um corpo capturado.

Eventos disponíveis

Evento Quando sai
NOTA_RECEBIDA, NFE_RECEBIDA, CTE_RECEBIDA Um documento novo foi capturado.
EVENTO_FISCAL_RECEBIDO Um evento (cancelamento, substituição etc.) foi capturado para um documento já conhecido.
NFSE_AUTORIZADA, NFSE_REJEITADA, NFSE_CANCELADA Uma NFS-e emitida pela sua conta mudou de situação.
NFSE_PROCESSAMENTO_PROLONGADO A nota está há 30 minutos transmitindo (aviso, sem mudança de status). Uma vez.
NFSE_AGUARDANDO_CONFIRMACAO A competência acabou antes da transmissão, e a nota espera você confirmar.
NFSE_FALHA_TRANSMISSAO A nota chegou a falha_transmissao, estado final. Veja falha.dps_enviada antes de emitir de novo.
LOTE_EMISSAO_CONCLUIDO Um lote terminou (nenhuma nota dele está mais transmitindo). Uma vez por lote.
EMPRESA_PRONTIDAO_ALTERADA Um item da prontidão de uma empresa mudou de situação (por exemplo, o certificado entrou nos últimos 30 dias de validade). Conferido a cada 6 horas, só em produção.

O que vem em dados

Eventos dados
NFSE_AUTORIZADA, NFSE_REJEITADA, NFSE_CANCELADA, NFSE_PROCESSAMENTO_PROLONGADO, NFSE_AGUARDANDO_CONFIRMACAO, NFSE_FALHA_TRANSMISSAO A nota inteira, igual a GET /v1/nfse/{id}
LOTE_EMISSAO_CONCLUIDO O lote, igual a GET /v1/lotes/{id}
EMPRESA_PRONTIDAO_ALTERADA A prontidão, igual a GET /v1/companies/{id}/prontidao, mais alterados ([{ item, de, para }])
NOTA_RECEBIDA, NFE_RECEBIDA, CTE_RECEBIDA Um resumo: { notaId, empresaId, chaveAcesso, nsu }. O documento completo está em GET /v1/documents/{id}, com o notaId. Na captura pela Prefeitura de São Paulo, vem origem: "PREFEITURA_SP" no lugar de nsu.
EVENTO_FISCAL_RECEBIDO { eventoId, notaId, empresaId, tipoEvento, nsu }. O documento, com os eventos, está em GET /v1/documents/{id}, com o notaId.
webhook.teste (só no teste manual) { mensagem, testeId }

Os eventos de documento recebido usam nomes em camelCase (notaId), como as rotas de documentos; os de emissão usam snake_case, como as rotas de emissão.

Como receber um webhook

  1. Leia o corpo bruto (os bytes exatos, antes de qualquer JSON.parse) e confira o X-Sudrel-Signature-V2 com o seu segredo. Recuse se o t= estiver a mais de 5 minutos do seu relógio: é isso que impede alguém de reenviar um corpo capturado.
  2. Responda 2xx rápido (em até 10 segundos) e processe depois, numa fila sua. Qualquer coisa diferente de 2xx, ou nenhuma resposta, conta como falha e entra na retentativa.
  3. Deduplique pelo X-Sudrel-Event-Id. A mesma notícia pode chegar mais de uma vez (retentativa depois de um 2xx que se perdeu, reenvio pedido por você). Guarde os ids já processados.
  4. Depois de uma queda do seu lado, leia GET /v1/eventos a partir do último cursor que você guardou. O log tem TODO evento da conta, mesmo os que não tinham webhook assinando.

Verificando a assinatura

Durante a troca de segredo o cabeçalho traz duas assinaturas (t=...,v1=<nova>,v1=<antiga>); basta uma bater. Os exemplos abaixo são os mesmos arquivos que os nossos testes executam contra uma entrega de verdade.

TypeScript (Node.js):

import { createHmac, timingSafeEqual } from 'crypto';

/** Tolerância recomendada: 5 minutos entre o `t=` e o seu relógio. */
export const TOLERANCIA_SEGUNDOS = 300;

/**
 * Confere o `X-Sudrel-Signature-V2` de uma entrega da Sudrel.
 * `corpoBruto` é o corpo exatamente como chegou, antes de qualquer JSON.parse.
 * Durante a troca de segredo vêm dois `v1=`: basta um bater.
 */
export function webhookSudrelValido(
  corpoBruto: string | Buffer,
  cabecalhoV2: string | undefined,
  segredo: string,
  agoraSegundos: number = Math.floor(Date.now() / 1000),
): boolean {
  if (!cabecalhoV2) return false;
  let timestamp = '';
  const assinaturas: string[] = [];
  for (const parte of cabecalhoV2.split(',')) {
    const [chave, valor = ''] = parte.trim().split('=', 2);
    if (chave === 't') timestamp = valor;
    else if (chave === 'v1' && valor) assinaturas.push(valor);
  }
  if (!/^\d+$/.test(timestamp)) return false;
  if (Math.abs(agoraSegundos - Number(timestamp)) > TOLERANCIA_SEGUNDOS) return false;
  const esperada = createHmac('sha256', segredo).update(`${timestamp}.`).update(corpoBruto).digest();
  return assinaturas.some((assinatura) => {
    const recebida = Buffer.from(assinatura, 'hex');
    return recebida.length === esperada.length && timingSafeEqual(recebida, esperada);
  });
}

PHP:

<?php
// Tolerância recomendada: 5 minutos entre o t= e o seu relógio.
const TOLERANCIA_SEGUNDOS = 300;

// $corpoBruto = file_get_contents('php://input');
// $cabecalhoV2 = $_SERVER['HTTP_X_SUDREL_SIGNATURE_V2'] ?? null;
function webhookSudrelValido(string $corpoBruto, ?string $cabecalhoV2, string $segredo, ?int $agora = null): bool
{
    if (!$cabecalhoV2) {
        return false;
    }
    $timestamp = '';
    $assinaturas = [];
    foreach (explode(',', $cabecalhoV2) as $parte) {
        [$chave, $valor] = array_pad(explode('=', trim($parte), 2), 2, '');
        if ($chave === 't') {
            $timestamp = $valor;
        } elseif ($chave === 'v1' && $valor !== '') {
            $assinaturas[] = $valor;
        }
    }
    if (!ctype_digit($timestamp)) {
        return false;
    }
    $agora = $agora ?? time();
    if (abs($agora - (int) $timestamp) > TOLERANCIA_SEGUNDOS) {
        return false;
    }
    $esperada = hash_hmac('sha256', $timestamp . '.' . $corpoBruto, $segredo);
    foreach ($assinaturas as $assinatura) {
        if (hash_equals($esperada, $assinatura)) {
            return true;
        }
    }
    return false;
}

Python:

import hashlib
import hmac
import time

# Tolerância recomendada: 5 minutos entre o t= e o seu relógio.
TOLERANCIA_SEGUNDOS = 300


def webhook_sudrel_valido(corpo_bruto: bytes, cabecalho_v2: str, segredo: str, agora: int = None) -> bool:
    """Confere o X-Sudrel-Signature-V2. corpo_bruto: o corpo exatamente como chegou (bytes)."""
    if not cabecalho_v2:
        return False
    timestamp, assinaturas = "", []
    for parte in cabecalho_v2.split(","):
        chave, _, valor = parte.strip().partition("=")
        if chave == "t":
            timestamp = valor
        elif chave == "v1" and valor:
            assinaturas.append(valor)
    if not timestamp.isdigit():
        return False
    agora = int(time.time()) if agora is None else agora
    if abs(agora - int(timestamp)) > TOLERANCIA_SEGUNDOS:
        return False
    esperada = hmac.new(segredo.encode(), timestamp.encode() + b"." + corpo_bruto, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(esperada, assinatura) for assinatura in assinaturas)

C# (.NET 6 ou mais novo):

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;

public static class WebhookSudrel
{
    // Tolerância recomendada: 5 minutos entre o t= e o seu relógio.
    public const int ToleranciaSegundos = 300;

    // corpoBruto: o corpo exatamente como chegou (leia o stream antes de desserializar).
    public static bool Valido(byte[] corpoBruto, string? cabecalhoV2, string segredo, long? agora = null)
    {
        if (string.IsNullOrEmpty(cabecalhoV2)) return false;
        var timestamp = "";
        var assinaturas = new List<string>();
        foreach (var parte in cabecalhoV2.Split(','))
        {
            var chaveValor = parte.Trim().Split('=', 2);
            if (chaveValor.Length != 2) continue;
            if (chaveValor[0] == "t") timestamp = chaveValor[1];
            else if (chaveValor[0] == "v1" && chaveValor[1].Length > 0) assinaturas.Add(chaveValor[1]);
        }
        if (!long.TryParse(timestamp, NumberStyles.None, CultureInfo.InvariantCulture, out var t)) return false;
        var momento = agora ?? DateTimeOffset.UtcNow.ToUnixTimeSeconds();
        if (Math.Abs(momento - t) > ToleranciaSegundos) return false;

        var prefixo = Encoding.UTF8.GetBytes(timestamp + ".");
        var dados = new byte[prefixo.Length + corpoBruto.Length];
        prefixo.CopyTo(dados, 0);
        corpoBruto.CopyTo(dados, prefixo.Length);
        using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(segredo));
        var esperada = hmac.ComputeHash(dados);
        foreach (var assinatura in assinaturas)
        {
            byte[] recebida;
            try { recebida = Convert.FromHexString(assinatura); }
            catch (FormatException) { continue; }
            if (CryptographicOperations.FixedTimeEquals(recebida, esperada)) return true;
        }
        return false;
    }
}

Retentativas

A primeira tentativa é na hora. Se falhar, tentamos de novo com estes intervalos, contados da tentativa anterior:

Tentativa Espera Desde a primeira
2 1 min 1 min
3 5 min 6 min
4 15 min 21 min
5 30 min 51 min
6 1 h 1 h 51
7 2 h 3 h 51
8 4 h 7 h 51
9 8 h 15 h 51
10 a 13 12 h cada até 63 h 51
14 o que faltar 72 h

Depois de 72 horas a entrega fica falhou e para. Ela continua listada em GET /v1/webhooks/{id}/entregas?status=falhou e pode ser reenviada. Se TODAS as entregas de um webhook falharem por mais de 72 horas, o titular da conta recebe um e-mail, uma vez por queda. O webhook não é desligado.

Reenvio

POST /v1/webhooks/{id}/reenvio com um evento (evento_id), um período (desde e ate) ou um lote (lote_id). Cada evento vira uma entrega nova, com o MESMO X-Sudrel-Event-Id. Se o evento aconteceu quando ninguém assinava, o corpo é montado na hora do reenvio e mostra o recurso como ele está AGORA (por exemplo, uma nota autorizada e depois cancelada chega com status: "cancelada" no reenvio do NFSE_AUTORIZADA). No máximo 500 eventos por chamada; a resposta diz o total e, se faltar, de onde continuar (continuar_desde).

Rotação do segredo

POST /v1/webhooks/{id}/rotacao-segredo devolve o segredo novo. Por 24 horas as entregas levam as duas assinaturas no X-Sudrel-Signature-V2, então dá tempo de trocar o segredo do seu lado sem perder nada. O X-Sudrel-Signature antigo já sai só com o segredo novo.

Endereço, tempo e redirecionamento

  • O endereço precisa ser https:// ou http:// público. Endereço de rede interna ou reservado (localhost, 10.x, 192.168.x, 100.64.x, IPv6 privado ou de link-local, e afins) é recusado no cadastro e em cada entrega. Num domínio, todos os IPs que ele resolve são conferidos: basta um interno para recusar.
  • Sucesso é qualquer 2xx em até 10 segundos. Redirecionamento (3xx) não é seguido e conta como falha: cadastre o endereço final.
  • O segredo tem 48 caracteres. A chave do HMAC é o segredo como texto, do jeito que veio.

Ambientes

Webhook criado com chave sdr_test_ é de sandbox: recebe só eventos de sandbox. O de produção (chave sdr_live_ ou painel) recebe só os de produção. Cada chave só enxerga os webhooks do seu ambiente.

Cabeçalhos seus

Até 5 cabeçalhos por webhook (cabecalhos: [{ "nome", "valor" }]), por exemplo um Authorization do seu gateway. Guardados criptografados e nunca devolvidos (a API mostra só os nomes). Content-Type, Content-Length, Host, Transfer-Encoding, Connection e qualquer X-Sudrel-* são nossos e não podem ser trocados (422).

Rotas desta seção, com teste na página