Prévia: a API abre junto com o painel.
Webhooks
O NFSeIA avisa o seu sistema quando algo acontece com uma nota: autorizada, recusada, pendente,
cancelada. Você cadastra um endereço HTTPS, escolhe os eventos, e cada evento chega por POST, com
assinatura para você conferir que veio mesmo do NFSeIA.
Cadastrar
curl -X POST https://api.nfseia.com.br/v1/webhooks \
-H "Authorization: Bearer $NFSEIA_CHAVE" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"url": "https://meusistema.com.br/avisos/nfseia",
"eventos": ["nota.autorizada", "nota.rejeitada", "nota.pendente_resolvida", "nota.cancelada"]
}'A resposta traz o segredo (whsec_...) uma única vez. Guarde no cofre de segredos do seu servidor:
é com ele que você confere a assinatura. Também dá para cadastrar pelo painel.
Webhook criado com chave de teste só recebe eventos do ambiente de teste, e vice-versa.
Eventos
| Evento | Quando chega |
|---|---|
nota.autorizada |
A nota foi autorizada. Chega para toda nota autorizada, inclusive recorrentes, de gatilho e as que passaram por pendente. |
nota.rejeitada |
A SEFIN recusou a nota. Os erros traduzidos estão na nota. |
nota.pendente |
A nota ficou pendente, com o motivo em dados.nota.motivo_pendencia. Chega de novo se o motivo mudar para colisao_numeracao ou requer_intervencao, que pedem atenção. |
nota.pendente_resolvida |
A pendente teve resposta definitiva (autorizada ou rejeitada, em dados.nota.status). O evento do status final também chega. |
nota.cancelada |
A nota foi cancelada na SEFIN. |
nota_recorrente.emitida |
A nota do mês de uma recorrência foi autorizada. |
certificado.vencendo |
O certificado digital vence em 30, 15, 7 e 1 dia. |
cota.perto_do_fim |
A empresa usou 80% e depois 95% da cota de notas do mês. |
O que chega
{
"id": "evt_01JC8Z4P6R9T2W5Y8A1C4E7G0J",
"tipo": "nota.autorizada",
"criado_em": "2026-11-05T14:03:12-03:00",
"ambiente": "producao",
"dados": {
"nota": {
"id": "nota_01JC8Z4M3QK7T2V9X5B6N1R0PD",
"referencia_externa": "pedido-1042",
"status": "autorizada",
"numero": "18",
"chave_acesso": "41069021245723174000110000000000001826117319502647",
"links": {
"xml": "https://api.nfseia.com.br/v1/notas/nota_01JC8Z4M3QK7T2V9X5B6N1R0PD/xml",
"pdf": "https://api.nfseia.com.br/v1/notas/nota_01JC8Z4M3QK7T2V9X5B6N1R0PD/pdf"
}
}
}
}O corpo é mínimo de propósito. Os dados completos vêm de GET /notas/{id}. Exemplos de todos os
eventos estão na referência.
Cabeçalhos de cada entrega:
| Cabeçalho | Conteúdo |
|---|---|
NFSeIA-Assinatura |
t=1793898192,v1=5257a8...: carimbo de tempo e assinatura. |
NFSeIA-Evento-Id |
O id do evento, igual ao do corpo. |
NFSeIA-Evento-Tipo |
O tipo do evento. |
NFSeIA-Tentativa |
Número da tentativa, começando em 1. |
Conferir a assinatura
v1 é o HMAC-SHA256, em hexadecimal, de {t}.{corpo bruto} com o segredo do webhook. Confira
sempre sobre o corpo exatamente como chegou, antes de transformar em JSON. Contra reenvio de um
evento antigo:
- Recuse
ta mais de 5 minutos do seu horário, no passado ou no futuro. Cada tentativa de entrega sai com umtnovo, então uma entrega atrasada pela fila não é recusada à toa. - Depois de conferir a assinatura, descarte repetidos pelo
iddo corpo (já autenticado), não pelo cabeçalho.
Na troca normal de segredo, o cabeçalho traz dois v1; aceite se qualquer um conferir.
Em Node:
import { createHmac, timingSafeEqual } from 'node:crypto';
export function assinaturaValida(corpoBruto, cabecalho, segredo, agoraMs = Date.now()) {
const partes = cabecalho.split(',').map((parte) => parte.trim().split('='));
const t = partes.find(([nome]) => nome === 't')?.[1];
const assinaturas = partes.filter(([nome]) => nome === 'v1').map(([, valor]) => valor);
if (!t || !/^\d+$/.test(t) || assinaturas.length === 0) return false;
if (Math.abs(agoraMs / 1000 - Number(t)) > 300) return false;
const esperada = Buffer.from(createHmac('sha256', segredo).update(`${t}.${corpoBruto}`).digest('hex'));
return assinaturas.some((v) => v.length === esperada.length && timingSafeEqual(Buffer.from(v), esperada));
}
// Express: o corpo precisa chegar bruto, sem o parser de JSON.
app.post('/avisos/nfseia', express.raw({ type: 'application/json' }), (req, res) => {
const corpo = req.body.toString('utf8');
if (!assinaturaValida(corpo, req.get('NFSeIA-Assinatura') ?? '', process.env.NFSEIA_SEGREDO_WEBHOOK)) {
return res.sendStatus(400);
}
const evento = JSON.parse(corpo);
// Grave o evento.id; se já existe, é repetido: responda 200 e ignore.
res.sendStatus(200);
});Em Python:
import hashlib
import hmac
import time
def assinatura_valida(corpo_bruto: bytes, cabecalho: str, segredo: str) -> bool:
partes = [p.strip().split("=", 1) for p in cabecalho.split(",") if "=" in p]
t = next((valor for nome, valor in partes if nome == "t"), "")
assinaturas = [valor for nome, valor in partes if nome == "v1"]
if not t.isdigit() or not assinaturas or abs(time.time() - int(t)) > 300:
return False
esperada = hmac.new(segredo.encode(), t.encode() + b"." + corpo_bruto, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(v, esperada) for v in assinaturas)Entrega e novas tentativas
- Pelo menos uma vez. O mesmo evento pode chegar mais de uma vez: use o
iddo corpo para descartar repetidos. - Ordem não garantida. Um
nota.canceladapode chegar antes donota.autorizadaatrasado. Na dúvida, consulte a nota: o estado dela é sempre o certo. - Responda rápido. Qualquer
2xxem até 10 segundos conta como entregue; o corpo da sua resposta é ignorado. Grave o evento e processe depois. - Novas tentativas com espera crescente (1 min, 5 min, 30 min, 2 h, 6 h e depois a cada 6 h) até
completar 24 horas. Depois disso, a entrega fica
falhou. - Desativação. Um endereço que falha todas as entregas por 3 dias seguidos é desativado, e o e-mail da empresa recebe um aviso. Reative pelo painel ou pela API.
O histórico das entregas dos últimos 30 dias está em GET /webhooks/{id}/entregas, e qualquer uma
pode ser reenviada com POST /webhooks/{id}/entregas/{entrega_id}/reenviar.
Segurança
- Só endereço HTTPS público, conferido no cadastro, na edição, em cada entrega e em cada reenvio. A cada envio, o nome é resolvido no DNS, todos os IPs devolvidos precisam ser públicos (nada interno, privado, de rede local ou reservado) e a conexão vai para o IP aprovado nessa conferência. Redirecionamento não é seguido. Cada tentativa tem até 10 segundos e lê no máximo 64 KB da sua resposta.
- Troca de segredo com
POST /webhooks/{id}/rotacionar-segredo, em dois modos:{"modo": "normal"}deixa o anterior valendo por 24 horas, para dar tempo de atualizar o seu sistema (no máximo um anterior por vez);{"modo": "emergencia"}revoga o anterior na hora, para quando o segredo vazou. - Nunca confie só no conteúdo do evento para algo crítico, como liberar um produto: confira a
assinatura e, se quiser, a nota em
GET /notas/{id}.