Webhooks
A MadNotas avisa o seu sistema a cada mudança importante com um POST em JSON, assinado com HMAC-SHA256. Cadastre os endereços em Integração › Webhooks ou pela API.
O que chega
MadNotas-Assinatura: t=1761757392,v1=5f0c1e9a8b…
MadNotas-Evento: nfe.autorizada
MadNotas-Evento-Id: evt_01jbx9q2w3e4r5t6y7u8i9o0pa
MadNotas-Entrega-Id: 18342
MadNotas-Tentativa: 1
Content-Type: application/json
{
"id": "evt_01jbx9q2w3e4r5t6y7u8i9o0pa",
"tipo": "nfe.autorizada",
"criado_em": "2026-10-29T14:03:12-03:00",
"ambiente": "producao",
"api_versao": "v1",
"dados": {
"id": "nfe_01jbx4k2m9q7t3v8w5z6y1r0ac",
"ref": "pedido-1042",
"status": "autorizada"
}
}O campo dados traz o mesmo objeto que GET /nfe/{id} devolveria. Use id do evento para ignorar repetições: a mesma entrega pode chegar mais de uma vez.
Como responder
- Responda 2xx em até 10 segundos. Processe o trabalho pesado depois, numa fila sua.
- Qualquer outro status, timeout ou erro de rede vira nova tentativa: 1 min, 5 min, 15 min, 1 h, 3 h, 6 h, 12 h, 24 h e 24 h (10 tentativas, cerca de 3 dias).
- Responder 410 desliga o endpoint na hora. Sem nenhuma entrega com sucesso por 3 dias, ele também é desligado e a conta recebe e-mail.
- Redirecionamentos não são seguidos. Em produção (e no endpoint de todos os ambientes), só
https://; em simulação e homologação,http://também vale.
Toda entrega fica listada por 30 dias em Integração › Webhooks › Entregas, com a resposta do seu servidor e o botão de reenvio.
Verificar a assinatura
O cabeçalho MadNotas-Assinatura tem o formato t=<unix>,v1=<hex>. Para conferir:
- Separe
te os valoresv1(pode haver dois durante a troca de segredo). - Calcule
HMAC-SHA256(segredo, t + "." + corpo)sobre o corpo bruto, antes de qualquer parse de JSON. - Compare em tempo constante com cada
v1; basta um bater. - Recuse se
testiver a mais de 5 minutos do seu relógio (proteção contra reenvio malicioso).
function madnotasAssinaturaValida(string $cabecalho, string $corpo, string $segredo, int $tolerancia = 300): bool
{
$t = null;
$assinaturas = [];
foreach (explode(',', $cabecalho) as $parte) {
[$nome, $valor] = array_pad(explode('=', trim($parte), 2), 2, '');
if ($nome === 't' && ctype_digit($valor)) {
$t = (int) $valor;
} elseif ($nome === 'v1') {
$assinaturas[] = strtolower($valor);
}
}
if ($t === null || ! $assinaturas || abs(time() - $t) > $tolerancia) {
return false;
}
$esperado = hash_hmac('sha256', $t . '.' . $corpo, $segredo);
foreach ($assinaturas as $a) {
if (hash_equals($esperado, $a)) {
return true;
}
}
return false;
}
// Laravel: use o corpo bruto
$ok = madnotasAssinaturaValida(
$request->header('MadNotas-Assinatura', ''),
$request->getContent(),
env('MADNOTAS_WEBHOOK_SEGREDO')
);import crypto from 'node:crypto';
export function madnotasAssinaturaValida(cabecalho, corpoBruto, segredo, tolerancia = 300) {
let t = null;
const assinaturas = [];
for (const parte of String(cabecalho || '').split(',')) {
const [nome, valor = ''] = parte.trim().split(/=(.*)/s);
if (nome === 't' && /^\d+$/.test(valor)) t = Number(valor);
else if (nome === 'v1') assinaturas.push(valor.toLowerCase());
}
if (t === null || !assinaturas.length || Math.abs(Date.now() / 1000 - t) > tolerancia) return false;
const esperado = crypto.createHmac('sha256', segredo).update(`${t}.${corpoBruto}`).digest();
return assinaturas.some((a) => {
const recebido = Buffer.from(a, 'hex');
return recebido.length === esperado.length && crypto.timingSafeEqual(recebido, esperado);
});
}
// Express: express.raw({ type: 'application/json' }) para ter o corpo bruto
app.post('/madnotas', express.raw({ type: 'application/json' }), (req, res) => {
if (!madnotasAssinaturaValida(req.get('MadNotas-Assinatura'), req.body.toString('utf8'), process.env.MADNOTAS_WEBHOOK_SEGREDO)) {
return res.sendStatus(400);
}
res.sendStatus(200);
});import hashlib, hmac, time
def madnotas_assinatura_valida(cabecalho: str, corpo: bytes, segredo: str, tolerancia: int = 300) -> bool:
t, assinaturas = None, []
for parte in (cabecalho or "").split(","):
nome, _, valor = parte.strip().partition("=")
if nome == "t" and valor.isdigit():
t = int(valor)
elif nome == "v1":
assinaturas.append(valor.lower())
if t is None or not assinaturas or abs(time.time() - t) > tolerancia:
return False
esperado = hmac.new(segredo.encode(), f"{t}.".encode() + corpo, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(esperado, a) for a in assinaturas)
# Flask: request.get_data() devolve o corpo bruto
# ok = madnotas_assinatura_valida(request.headers.get("MadNotas-Assinatura"), request.get_data(), SEGREDO)Segredo e rotação
Cada endpoint tem um segredo whsec_…, revelado no painel. Ao rotacionar, o segredo anterior continua assinando junto com o novo por 24 horas (dois v1 no cabeçalho), tempo para você trocar no seu sistema sem perder entregas. Pela API, POST /webhooks/{id}/rotacionar-segredo devolve o segredo novo e segredo_anterior_valido_ate, a hora em que o antigo deixa de valer. Para remover um endpoint, DELETE /webhooks/{id} responde 200 com {"id": "whk_…", "excluido": true}.
Eventos
Inscreva o endpoint em eventos específicos, em grupos (nfe.*, nfce.*…) ou em todos (["*"]). A lista abaixo é a mesma que GET /webhooks/eventos devolve; evento fora dela é recusado com 422 evento_desconhecido, e lista ausente ou vazia, com 422 eventos_obrigatorio.
| Evento | Grupo | Quando |
|---|---|---|
nfe.autorizada | NF-e | A SEFAZ autorizou a NF-e. |
nfe.rejeitada | NF-e | A SEFAZ rejeitou a NF-e (corrija e envie de novo com a mesma ref). |
nfe.denegada | NF-e | A NF-e foi denegada (irregularidade cadastral de emitente ou destinatário). |
nfe.cancelada | NF-e | O cancelamento da NF-e foi homologado. |
nfe.carta_correcao | NF-e | Uma carta de correção da NF-e foi registrada. |
nfe.contingencia | NF-e | A NF-e foi emitida em contingência (SVC) por indisponibilidade da SEFAZ. |
nfe.evento_rejeitado | NF-e | A SEFAZ rejeitou um evento da NF-e (cancelamento ou carta de correção); a nota continua como estava. |
nfce.autorizada | NFC-e | A SEFAZ autorizou a NFC-e. |
nfce.rejeitada | NFC-e | A SEFAZ rejeitou a NFC-e. |
nfce.denegada | NFC-e | A NFC-e foi denegada. |
nfce.cancelada | NFC-e | O cancelamento da NFC-e foi homologado. |
nfce.contingencia | NFC-e | A NFC-e foi emitida em contingência offline e será transmitida quando a SEFAZ voltar. |
nfce.evento_rejeitado | NFC-e | A SEFAZ rejeitou um evento da NFC-e (cancelamento); a nota continua como estava. |
nfse.autorizada | NFS-e | A Sefin Nacional gerou a NFS-e. |
nfse.rejeitada | NFS-e | A Sefin Nacional recusou a DPS (corrija e envie de novo com a mesma ref). |
nfse.cancelada | NFS-e | O cancelamento da NFS-e foi homologado (inclusive o feito fora do MadNotas). |
nfse.substituida | NFS-e | A NFS-e substituta foi autorizada e a nota antiga passou a substituída. |
nfse.evento_rejeitado | NFS-e | A Sefin Nacional recusou um evento da NFS-e (cancelamento); a nota continua como estava. |
dfe.recebido | DF-e recebido | Chegou uma NF-e emitida contra o seu CNPJ, ou uma que já estava aqui mudou: XML completo disponível ou evento do fornecedor (cancelamento, carta de correção). |
dfe.manifestacao_registrada | DF-e recebido | O Ambiente Nacional registrou a sua manifestação (ciência, confirmação, desconhecimento ou operação não realizada). |
dfe.manifestacao_rejeitada | DF-e recebido | O Ambiente Nacional recusou a sua manifestação; a nota continua como estava. |
inutilizacao.homologada | Inutilização | A SEFAZ homologou a inutilização da faixa de números. |
inutilizacao.rejeitada | Inutilização | A SEFAZ rejeitou o pedido de inutilização. |
emitente.certificado_vencendo | Emitente | O certificado digital do emitente vence em 30, 15, 7 ou 1 dia (ou já venceu). |
emitente.numeracao_conflito | Emitente | A SEFAZ (ou a Sefin, na NFS-e) já conhecia o número com outra nota; o documento foi renumerado. |
emitente.nfse_convenio_alterado | Emitente | O município do emitente deixou de ser conveniado ao Sistema Nacional da NFS-e, ou voltou a ser. |
emitente.numeracao_buraco | Emitente | Há números sem uso na série de produção há mais de 48 horas; inutilize a faixa até o prazo. |
emitente.contingencia_ativada | Emitente | O emitente passou a emitir em contingência (automática ou pedida). |
emitente.contingencia_encerrada | Emitente | O emitente voltou a emitir normalmente na SEFAZ. |
sefaz.status_alterado | SEFAZ | O serviço da SEFAZ de uma UF que você usa ficou instável, caiu ou voltou. |
conta.franquia_80 | Conta | A empresa usou 80% da franquia de notas do mês. |
conta.franquia_100 | Conta | A franquia do mês acabou; as próximas notas em produção entram como excedente. |
Os grupos nfse.* e dfe.* usam o mesmo endpoint e a mesma assinatura. Nos eventos nfse.*, dados é a NFS-e (e, no cancelamento, no cancelamento recusado e na substituição, um bloco a mais com o evento ou a nota substituta); veja o guia da NFS-e. Em dfe.recebido, dados traz o motivo (novo, xml_completo ou evento_emissor) e a nota recebida; veja o guia do DF-e.
Fora do catálogo existe só webhook.teste, enviado pelo botão Enviar teste (ou POST /webhooks/{id}/teste) a um único endpoint, para conferir a assinatura. Ele não é assinável: chega mesmo sem estar na lista de eventos do endpoint.
Números sem nota na produção
Quando sobra um número da série sem nota (uma emissão abandonada, por exemplo), a SEFAZ espera que ele seja inutilizado até o dia 10 do mês seguinte. A inutilização é um ato fiscal, então na produção a MadNotas não faz isso sem você autorizar:
- Padrão (desligado): nada vai à SEFAZ. Com a faixa parada há 48 horas, você recebe
emitente.numeracao_buraco, um aviso por faixa, e decide: inutilizar comPOST /v1/inutilizacoesou pela tela Notas › Inutilizar numeração, ou usar o número numa nota. - Ligado: em Emitentes › Inutilizar números parados, um processo diário inutiliza sozinho as faixas paradas há 48 horas. Cada uma aparece na lista de inutilizações com a origem automática, e o resultado chega por
inutilizacao.homologadaouinutilizacao.rejeitada.
{
"emitente_id": 12,
"modelo": "55",
"ambiente": "producao",
"serie": 1,
"numero_inicial": 1041,
"numero_final": 1043,
"quantidade": 3,
"prazo_inutilizacao": "2026-11-10",
"mensagem": "Os números 1041 a 1043 da série 1 estão sem nota. Inutilize até 10/11/2026."
}Na simulação e na homologação, a numeração não tem efeito fiscal: a faixa parada é inutilizada sozinha, sem aviso.