Documentation › Notifications
Webhooks
HMAC-signed notifications, retries for 72 hours, resending, secret rotation and verification in 4 languages.
Minimal implementation
Every delivery is a POST to your URL, with body { "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"}}
Your receiver does this, in this order: checks the signature and the time,
ignores an X-Sudrel-Event-Id already processed, stores the event in a queue
of your own and responds 2xx. Processing comes later, outside the request.
Register it in the dashboard (Painel → Webhooks (Dashboard → Webhooks)) or
with POST /v1/webhooks. The webhook secret is shown only once, at creation or
rotation: store it with the same care as a password.
Headers
| Header | Content |
|---|---|
X-Sudrel-Event |
The same value as tipo in the body. |
X-Sudrel-Event-Id |
Event id. The SAME across all attempts and resends: deduplicate by it. It is the id from GET /v1/eventos. |
X-Sudrel-Delivery-Id |
Id of this delivery (one per event and webhook; a resend is a new delivery). Shown in GET /v1/webhooks/{id}/entregas. |
X-Sudrel-Timestamp |
Time of this attempt, in seconds since 1970 (UTC). |
X-Sudrel-Signature-V2 |
t=<timestamp>,v1=<assinatura>. The signature is the HMAC-SHA256 (hex) of "<timestamp>.<corpo bruto>" with the webhook secret. Use this one. |
X-Sudrel-Signature |
HMAC-SHA256 (hex) of the raw body only. Kept for existing integrations; it does not protect against replay of a captured body. |
Available events
| Event | When it fires |
|---|---|
NOTA_RECEBIDA, NFE_RECEBIDA, CTE_RECEBIDA |
A new document was captured. |
EVENTO_FISCAL_RECEBIDO |
An event (cancellation, replacement etc.) was captured for a document already known. |
NFSE_AUTORIZADA, NFSE_REJEITADA, NFSE_CANCELADA |
An NFS-e issued by your account changed status. |
NFSE_PROCESSAMENTO_PROLONGADO |
The invoice has been transmitting for 30 minutes (warning, no status change). Once. |
NFSE_AGUARDANDO_CONFIRMACAO |
The accrual period (competência) ended before transmission, and the invoice waits for your confirmation. |
NFSE_FALHA_TRANSMISSAO |
The invoice reached falha_transmissao, a final state. Check falha.dps_enviada before issuing again. |
LOTE_EMISSAO_CONCLUIDO |
A batch finished (none of its invoices is still transmitting). Once per batch. |
EMPRESA_PRONTIDAO_ALTERADA |
A readiness item of a company changed status (for example, the certificate entered its last 30 days of validity). Checked every 6 hours, production only. |
What comes in dados
| Events | dados |
|---|---|
NFSE_AUTORIZADA, NFSE_REJEITADA, NFSE_CANCELADA, NFSE_PROCESSAMENTO_PROLONGADO, NFSE_AGUARDANDO_CONFIRMACAO, NFSE_FALHA_TRANSMISSAO |
The whole invoice, same as GET /v1/nfse/{id} |
LOTE_EMISSAO_CONCLUIDO |
The batch, same as GET /v1/lotes/{id} |
EMPRESA_PRONTIDAO_ALTERADA |
The readiness, same as GET /v1/companies/{id}/prontidao, plus alterados ([{ item, de, para }]) |
NOTA_RECEBIDA, NFE_RECEBIDA, CTE_RECEBIDA |
A summary: { notaId, empresaId, chaveAcesso, nsu }. The full document is at GET /v1/documents/{id}, using the notaId. For capture through the São Paulo city hall, origem: "PREFEITURA_SP" comes instead of nsu. |
EVENTO_FISCAL_RECEBIDO |
{ eventoId, notaId, empresaId, tipoEvento, nsu }. The document, with its events, is at GET /v1/documents/{id}, using the notaId. |
webhook.teste (manual test only) |
{ mensagem, testeId } |
Received-document events use camelCase names (notaId), like the document
routes; issuance events use snake_case, like the issuance routes.
How to receive a webhook
- Read the raw body (the exact bytes, before any
JSON.parse) and checkX-Sudrel-Signature-V2with your secret. Reject it ift=is more than 5 minutes away from your clock: this is what stops someone from replaying a captured body. - Respond 2xx fast (within 10 seconds) and process later, in a queue of your own. Anything other than 2xx, or no response, counts as a failure and goes to retry.
- Deduplicate by
X-Sudrel-Event-Id. The same notice can arrive more than once (a retry after a 2xx that got lost, a resend you requested). Store the ids already processed. - After an outage on your side, read
GET /v1/eventosfrom the last cursor you stored. The log has EVERY event of the account, even those with no webhook subscribed.
Verifying the signature
During secret rotation the header carries two signatures
(t=...,v1=<nova>,v1=<antiga>); one match is enough. The examples below
are the same files our tests run against a real delivery.
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 or newer):
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;
}
}
Retries
The first attempt is immediate. If it fails, we try again at these intervals, counted from the previous attempt:
| Attempt | Wait | Since the first |
|---|---|---|
| 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 to 13 | 12 h each | up to 63 h 51 |
| 14 | whatever remains | 72 h |
After 72 hours the delivery becomes falhou and stops. It stays
listed in GET /v1/webhooks/{id}/entregas?status=falhou and can be
resent. If ALL deliveries of a webhook fail for more than 72 hours, the
account owner receives an e-mail, once per outage. The webhook is not
disabled.
Resend
POST /v1/webhooks/{id}/reenvio with one event (evento_id), a period
(desde and ate) or a batch (lote_id). Each event becomes a new
delivery, with the SAME X-Sudrel-Event-Id. If the event happened when
nobody was subscribed, the body is built at resend time and shows the
resource as it is NOW (for example, an invoice authorized and later
cancelled arrives with status: "cancelada" in the resend of
NFSE_AUTORIZADA). At most 500 events per call; the response gives the
total and, if some remain, where to continue (continuar_desde).
Secret rotation
POST /v1/webhooks/{id}/rotacao-segredo returns the new secret. For 24
hours, deliveries carry both signatures in X-Sudrel-Signature-V2, so you
have time to change the secret on your side without losing anything. The
old X-Sudrel-Signature is already signed with the new secret only.
URL, timeout and redirects
- The URL must be a public
https://orhttp://address. Internal or reserved addresses (localhost,10.x,192.168.x,100.64.x, private or link-local IPv6, and similar) are refused at registration and on every delivery. For a domain, every IP it resolves to is checked: a single internal one is enough to refuse it. - Success is any 2xx within 10 seconds. Redirects (3xx) are not followed and count as failure: register the final URL.
- The secret has 48 characters. The HMAC key is the secret as text, exactly as received.
Environments
A webhook created with a sdr_test_ key is a sandbox webhook: it receives
only sandbox events. A production webhook (sdr_live_ key or dashboard)
receives only production events. Each key sees only the webhooks of its own
environment.
Your own headers
Up to 5 headers per webhook (cabecalhos: [{ "nome", "valor" }]), for
example an Authorization for your gateway. Stored encrypted and never
returned (the API shows only the names). Content-Type,
Content-Length, Host, Transfer-Encoding, Connection and any
X-Sudrel-* are ours and cannot be overridden (422).
Endpoints in this section, with in-page testing
- GET/v1/webhooks · List the webhooks of the key's environment
- POST/v1/webhooks · Create a webhook
- GET/v1/webhooks/{id} · A webhook
- PATCH/v1/webhooks/{id} · Change events, URL, active flag or headers
- DELETE/v1/webhooks/{id} · Remove the webhook
- POST/v1/webhooks/{id}/rotacao-segredo · Rotate the secret, with a 24-hour overlap
- POST/v1/webhooks/{id}/teste · Send a `webhook.teste` event now
- GET/v1/webhooks/{id}/entregas · Webhook deliveries, newest first
- POST/v1/webhooks/{id}/reenvio · Resend events (one, a period or a batch)