Documentação › Notificações
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
- Leia o corpo bruto (os bytes exatos, antes de qualquer
JSON.parse) e confira oX-Sudrel-Signature-V2com o seu segredo. Recuse se ot=estiver a mais de 5 minutos do seu relógio: é isso que impede alguém de reenviar um corpo capturado. - 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.
- 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. - Depois de uma queda do seu lado, leia
GET /v1/eventosa 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://ouhttp://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
- GET/v1/webhooks · Lista os webhooks do ambiente da chave
- POST/v1/webhooks · Cria um webhook
- GET/v1/webhooks/{id} · Um webhook
- PATCH/v1/webhooks/{id} · Altera eventos, endereço, ativo ou cabeçalhos
- DELETE/v1/webhooks/{id} · Remove o webhook
- POST/v1/webhooks/{id}/rotacao-segredo · Troca o segredo, com 24 horas de convivência
- POST/v1/webhooks/{id}/teste · Manda um evento `webhook.teste` agora
- GET/v1/webhooks/{id}/entregas · Entregas do webhook, das mais novas para as mais antigas
- POST/v1/webhooks/{id}/reenvio · Reenvia eventos (um, um período ou um lote)