Sudrel API
Dashboard Create API key

Documentation › Notifications

Guides

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

  1. Read the raw body (the exact bytes, before any JSON.parse) and check X-Sudrel-Signature-V2 with your secret. Reject it if t= is more than 5 minutes away from your clock: this is what stops someone from replaying a captured body.
  2. 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.
  3. 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.
  4. After an outage on your side, read GET /v1/eventos from 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:// or http:// 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