Pular para o conteúdo
NFSeIA
Páginas da documentação

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

Terminal
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

JSON
{
  "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 t a mais de 5 minutos do seu horário, no passado ou no futuro. Cada tentativa de entrega sai com um t novo, então uma entrega atrasada pela fila não é recusada à toa.
  • Depois de conferir a assinatura, descarte repetidos pelo id do 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:

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:

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 id do corpo para descartar repetidos.
  • Ordem não garantida. Um nota.cancelada pode chegar antes do nota.autorizada atrasado. Na dúvida, consulte a nota: o estado dela é sempre o certo.
  • Responda rápido. Qualquer 2xx em 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}.