Sudrel API
Panel Crear clave de API

Documentación › Notificaciones

Guías

Webhooks

Avisos firmados con HMAC, reintentos durante 72 horas, reenvío, cambio de secreto y verificación en 4 lenguajes.

Implementación mínima

Toda entrega es un POST a tu dirección, con cuerpo { "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"}}

Tu receptor hace, en este orden: verifica la firma y la hora, ignora el X-Sudrel-Event-Id ya procesado, guarda el evento en una cola propia y responde 2xx. El procesamiento viene después, fuera de la solicitud.

Regístralo en el panel (Painel → Webhooks (Panel → Webhooks)) o con POST /v1/webhooks. El secreto del webhook se muestra una sola vez, al crearlo o al cambiarlo: guárdalo con el mismo cuidado que una contraseña.

Encabezados

Encabezado Contenido
X-Sudrel-Event El mismo valor de tipo del cuerpo.
X-Sudrel-Event-Id Id del evento. El MISMO en todos los intentos y reenvíos: deduplica por él. Es el id de GET /v1/eventos.
X-Sudrel-Delivery-Id Id de esta entrega (una por evento y webhook; un reenvío es una entrega nueva). Aparece en GET /v1/webhooks/{id}/entregas.
X-Sudrel-Timestamp Momento de este intento, en segundos desde 1970 (UTC).
X-Sudrel-Signature-V2 t=<timestamp>,v1=<assinatura>. La firma es el HMAC-SHA256 (hex) de "<timestamp>.<corpo bruto>" con el secreto del webhook. Usa esta.
X-Sudrel-Signature HMAC-SHA256 (hex) solo del cuerpo en bruto. Se mantiene para quien ya integró; no protege contra el reenvío de un cuerpo capturado.

Eventos disponibles

Evento Cuándo se envía
NOTA_RECEBIDA, NFE_RECEBIDA, CTE_RECEBIDA Se capturó un documento nuevo.
EVENTO_FISCAL_RECEBIDO Se capturó un evento (cancelación, sustitución, etc.) para un documento ya conocido.
NFSE_AUTORIZADA, NFSE_REJEITADA, NFSE_CANCELADA Una NFS-e emitida por tu cuenta cambió de situación.
NFSE_PROCESSAMENTO_PROLONGADO La nota fiscal lleva 30 minutos transmitiéndose (aviso, sin cambio de status). Una vez.
NFSE_AGUARDANDO_CONFIRMACAO El período de competencia terminó antes de la transmisión, y la nota fiscal espera tu confirmación.
NFSE_FALHA_TRANSMISSAO La nota fiscal llegó a falha_transmissao, estado final. Revisa falha.dps_enviada antes de emitir de nuevo.
LOTE_EMISSAO_CONCLUIDO Un lote terminó (ninguna nota fiscal suya sigue transmitiéndose). Una vez por lote.
EMPRESA_PRONTIDAO_ALTERADA Un ítem de la preparación de una empresa cambió de situación (por ejemplo, el certificado entró en sus últimos 30 días de validez). Se verifica cada 6 horas, solo en producción.

Qué viene en dados

Eventos dados
NFSE_AUTORIZADA, NFSE_REJEITADA, NFSE_CANCELADA, NFSE_PROCESSAMENTO_PROLONGADO, NFSE_AGUARDANDO_CONFIRMACAO, NFSE_FALHA_TRANSMISSAO La nota fiscal completa, igual que GET /v1/nfse/{id}
LOTE_EMISSAO_CONCLUIDO El lote, igual que GET /v1/lotes/{id}
EMPRESA_PRONTIDAO_ALTERADA La preparación, igual que GET /v1/companies/{id}/prontidao, más alterados ([{ item, de, para }])
NOTA_RECEBIDA, NFE_RECEBIDA, CTE_RECEBIDA Un resumen: { notaId, empresaId, chaveAcesso, nsu }. El documento completo está en GET /v1/documents/{id}, con el notaId. En la captura por la Alcaldía de São Paulo, viene origem: "PREFEITURA_SP" en lugar de nsu.
EVENTO_FISCAL_RECEBIDO { eventoId, notaId, empresaId, tipoEvento, nsu }. El documento, con los eventos, está en GET /v1/documents/{id}, con el notaId.
webhook.teste (solo en la prueba manual) { mensagem, testeId }

Los eventos de documento recibido usan nombres en camelCase (notaId), como las rutas de documentos; los de emisión usan snake_case, como las rutas de emisión.

Cómo recibir un webhook

  1. Lee el cuerpo en bruto (los bytes exactos, antes de cualquier JSON.parse) y verifica el X-Sudrel-Signature-V2 con tu secreto. Recházalo si el t= está a más de 5 minutos de tu reloj: eso es lo que impide que alguien reenvíe un cuerpo capturado.
  2. Responde 2xx rápido (en hasta 10 segundos) y procesa después, en una cola propia. Cualquier cosa distinta de 2xx, o ninguna respuesta, cuenta como fallo y entra en reintento.
  3. Deduplica por el X-Sudrel-Event-Id. El mismo aviso puede llegar más de una vez (reintento después de un 2xx que se perdió, reenvío que tú pediste). Guarda los ids ya procesados.
  4. Después de una caída de tu lado, lee GET /v1/eventos a partir del último cursor que guardaste. El log tiene TODO evento de la cuenta, incluso los que no tenían un webhook suscrito.

Verificación de la firma

Durante el cambio de secreto, el encabezado trae dos firmas (t=...,v1=<nova>,v1=<antiga>); basta con que una coincida. Los ejemplos de abajo son los mismos archivos que nuestras pruebas ejecutan contra una entrega real.

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 o más reciente):

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

Reintentos

El primer intento es inmediato. Si falla, intentamos de nuevo con estos intervalos, contados desde el intento anterior:

Intento Espera Desde el primero
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 uno hasta 63 h 51
14 lo que falte 72 h

Después de 72 horas la entrega queda falhou y se detiene. Sigue listada en GET /v1/webhooks/{id}/entregas?status=falhou y se puede reenviar. Si TODAS las entregas de un webhook fallan durante más de 72 horas, el titular de la cuenta recibe un correo, una vez por caída. El webhook no se desactiva.

Reenvío

POST /v1/webhooks/{id}/reenvio con un evento (evento_id), un período (desde y ate) o un lote (lote_id). Cada evento se convierte en una entrega nueva, con el MISMO X-Sudrel-Event-Id. Si el evento ocurrió cuando nadie estaba suscrito, el cuerpo se arma en el momento del reenvío y muestra el recurso como está AHORA (por ejemplo, una nota fiscal autorizada y después cancelada llega con status: "cancelada" en el reenvío del NFSE_AUTORIZADA). Como máximo 500 eventos por llamada; la respuesta indica el total y, si falta algo, desde dónde continuar (continuar_desde).

Rotación del secreto

POST /v1/webhooks/{id}/rotacao-segredo devuelve el secreto nuevo. Durante 24 horas las entregas llevan las dos firmas en el X-Sudrel-Signature-V2, así que tienes tiempo de cambiar el secreto de tu lado sin perder nada. El X-Sudrel-Signature antiguo ya sale solo con el secreto nuevo.

Dirección, tiempo y redirección

  • La dirección debe ser https:// o http:// pública. Una dirección de red interna o reservada (localhost, 10.x, 192.168.x, 100.64.x, IPv6 privado o de enlace local, y similares) se rechaza en el registro y en cada entrega. En un dominio se revisan todas las IP a las que resuelve: basta una interna para rechazarlo.
  • Éxito es cualquier 2xx en hasta 10 segundos. La redirección (3xx) no se sigue y cuenta como fallo: registra la dirección final.
  • El secreto tiene 48 caracteres. La clave del HMAC es el secreto como texto, tal como llegó.

Entornos

Un webhook creado con clave sdr_test_ es de sandbox: recibe solo eventos de sandbox. El de producción (clave sdr_live_ o panel) recibe solo los de producción. Cada clave solo ve los webhooks de su entorno.

Tus propios encabezados

Hasta 5 encabezados por webhook (cabecalhos: [{ "nome", "valor" }]), por ejemplo un Authorization de tu gateway. Se guardan cifrados y nunca se devuelven (la API muestra solo los nombres). Content-Type, Content-Length, Host, Transfer-Encoding, Connection y cualquier X-Sudrel-* son nuestros y no se pueden cambiar (422).

Rutas de esta sección, con prueba en la página