# Webhooks

> Prévia: a API abre junto com o painel.

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

```sh
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](https://nfseia.com.br/docs/referencia#eventos-de-webhook).

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:

```js
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:

```py
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}`.
