Documentación › Notificaciones
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
- Lee el cuerpo en bruto (los bytes exactos, antes de cualquier
JSON.parse) y verifica elX-Sudrel-Signature-V2con tu secreto. Recházalo si elt=está a más de 5 minutos de tu reloj: eso es lo que impide que alguien reenvíe un cuerpo capturado. - 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.
- 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. - Después de una caída de tu lado, lee
GET /v1/eventosa 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://ohttp://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
- GET/v1/webhooks · Lista los webhooks del entorno de la clave
- POST/v1/webhooks · Crea un webhook
- GET/v1/webhooks/{id} · Un webhook
- PATCH/v1/webhooks/{id} · Cambia eventos, dirección, estado activo o encabezados
- DELETE/v1/webhooks/{id} · Elimina el webhook
- POST/v1/webhooks/{id}/rotacao-segredo · Cambia el secreto, con 24 horas de convivencia
- POST/v1/webhooks/{id}/teste · Envía un evento `webhook.teste` ahora
- GET/v1/webhooks/{id}/entregas · Entregas del webhook, de las más nuevas a las más antiguas
- POST/v1/webhooks/{id}/reenvio · Reenvía eventos (uno, un período o un lote)