# Webhooks

import { Tabs, TabsList, TabsTrigger, TabsContent } from "zudoku/ui/Tabs.js";

Webhooks são **URLs HTTP do seu servidor** que o Brasil NFe chama automaticamente quando algo acontece nas suas notas. Em vez de você ficar consultando a API em loop, é o Brasil NFe que avisa você — enviando um POST com o payload em JSON.

Use webhooks para integrar com **ERP, sistema de logística, CRM, dashboards internos** ou qualquer coisa que precise reagir a eventos fiscais em tempo (quase) real.

## Cadastro pelo painel

O cadastro é feito em **[api.brasilnfe.com.br](https://api.brasilnfe.com.br) → Painel → Webhooks**:

1. Clique em **Adicionar webhook**.
2. Informe um **nome** (ex.: "Integração ERP") e a **URL** que vai receber os POSTs (precisa ser HTTPS em produção).
3. Salve. **O secret é gerado uma única vez** — copie e guarde no seu cofre de segredos. Se perder, use **Regerar secret** (a integração antiga deixa de funcionar imediatamente).
4. Em **Empresa → Credenciais**, selecione qual webhook esta empresa específica utiliza.

### Resolução por empresa

Para cada disparo, o Brasil NFe escolhe o webhook assim:

1. Se a empresa tem um webhook **explicitamente vinculado** (campo `IdWebhook`), ele é usado.
2. Senão, se existir **exatamente um webhook ativo** sob a mesma conta, ele é usado como fallback.
3. Se houver mais de um ativo e nenhum explicitamente vinculado, **nenhum disparo acontece** — a configuração é ambígua.

## O envelope do payload

Cada disparo é um **POST** com `Content-Type: application/json` e o seguinte envelope:

```json
{
  "event": "nfe.lote.finalizado",
  "deliveryId": "9f86d081884c7d659a2feaa0c55ad015",
  "timestamp": "2026-04-28T17:32:11.0123456Z",
  "data": { /* específico do evento — ver Eventos suportados abaixo */ }
}
```

> O envelope (`event`, `deliveryId`, `timestamp`, `data`) é sempre **camelCase**. O conteúdo de `data` segue o casing da entidade no domínio: eventos de **lote** (`nfe.lote.*`) usam camelCase; eventos de **documento de entrada** (`documento.entrada.*`) usam **PascalCase** (ver schema de cada evento na seção [Eventos suportados](#eventos-suportados)).

| Campo | Descrição |
| --- | --- |
| `event` | Nome do evento (ex.: `nfe.lote.finalizado`). Veja a [tabela de eventos](#eventos-suportados). |
| `deliveryId` | Identificador único da tentativa de entrega. **Use como chave de idempotência** no seu lado. |
| `timestamp` | ISO-8601 em UTC, momento em que o disparo foi montado. |
| `data` | Carga específica do evento. |

## Headers enviados

| Header | Conteúdo |
| --- | --- |
| `User-Agent` | `BrasilNFe-Webhooks/1.0` |
| `X-Webhook-Event` | Nome do evento (mesmo valor de `event` no body). |
| `X-Webhook-Delivery` | Mesmo valor de `deliveryId`. **Idêntico em todas as tentativas** do mesmo evento. |
| `X-Webhook-Timestamp` | Mesmo valor de `timestamp`. Reflete o momento em que o **disparo original** foi montado, não a tentativa atual. |
| `X-Webhook-Signature` | `sha256=<hex>` — HMAC-SHA256 do **body bruto** com o secret do webhook. |
| `X-Webhook-Attempt` | Número da tentativa atual (`1` no disparo original, `2..5` em retentativas). |

### Headers extras por evento

Eventos de **documento de entrada** (`documento.entrada.recebida`, `documento.entrada.cancelada`) carregam dois headers adicionais para facilitar roteamento sem precisar parsear o body:

| Header | Conteúdo |
| --- | --- |
| `X-Document-Model` | Modelo fiscal — `10` (NFS-e), `55` (NF-e), `57` (CT-e). |
| `X-Document-Chave` | Chave de acesso (44 dígitos para NF-e/CT-e; código de verificação para NFS-e quando aplicável; pode vir vazio se a nota ainda não tem chave). |

## Verificação da assinatura

A assinatura é o **HMAC-SHA256 do corpo bruto da requisição**, codificada em hexadecimal e prefixada por `sha256=`. **Sempre compare em tempo constante** para evitar timing attacks.

<Tabs defaultValue="node">
  <TabsList>
    <TabsTrigger value="node">Node.js</TabsTrigger>
    <TabsTrigger value="php">PHP</TabsTrigger>
    <TabsTrigger value="python">Python</TabsTrigger>
    <TabsTrigger value="csharp">.NET</TabsTrigger>
  </TabsList>

  <TabsContent value="node">

```js
import crypto from "crypto";

function verificarAssinatura(req, secret) {
  const body = req.rawBody;
  const recebida = req.headers["x-webhook-signature"] || "";
  const esperada = "sha256=" + crypto.createHmac("sha256", secret).update(body).digest("hex");

  const a = Buffer.from(recebida);
  const b = Buffer.from(esperada);

  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

  </TabsContent>

  <TabsContent value="php">

```php
<?php
function verificarAssinatura(string $body, string $headerRecebido, string $secret): bool {
    $esperada = 'sha256=' . hash_hmac('sha256', $body, $secret);
    return hash_equals($esperada, $headerRecebido);
}
```

  </TabsContent>

  <TabsContent value="python">

```python
import hmac, hashlib

def verificar_assinatura(body: bytes, header_recebido: str, secret: str) -> bool:
    esperada = "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(esperada, header_recebido)
```

  </TabsContent>

  <TabsContent value="csharp">

```csharp
using System.Security.Cryptography;
using System.Text;

public static bool VerificarAssinatura(string body, string headerRecebido, string secret)
{
    using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
    var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(body));
    var esperada = "sha256=" + BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();

    return CryptographicOperations.FixedTimeEquals(
        Encoding.UTF8.GetBytes(esperada),
        Encoding.UTF8.GetBytes(headerRecebido ?? string.Empty));
}
```

  </TabsContent>
</Tabs>

> **Importante.** Calcule o HMAC sobre o **body exatamente como recebido** — sem reserializar o JSON. Frameworks que parseiam o body antes de você ler costumam reordenar chaves e quebram a assinatura. No Express, use `express.raw()`; no ASP.NET, leia o stream antes do model binding.

## Idempotência

Use o `X-Webhook-Delivery` (= `deliveryId` no body) como **chave de idempotência** com TTL de pelo menos 24h. Em caso de retentativas (veja abaixo) ou raros timeouts dos dois lados, **o mesmo `deliveryId` pode chegar mais de uma vez** — armazene-o e processe apenas a primeira ocorrência.

> **A idempotência via `deliveryId` é a defesa primária.** O Brasil NFe garante que o mesmo evento sempre carrega o mesmo `deliveryId`, em todas as tentativas.

### Janela anti-replay (defesa secundária)

Como o `X-Webhook-Timestamp` reflete o momento do **disparo original**, e retentativas podem chegar até **~1h12min depois**, use uma janela de **±2 horas** se quiser validar timestamp como camada adicional. A proteção primária contra replay deve ser a idempotência por `deliveryId`.

## Resposta esperada

Seu endpoint deve responder **`2xx` em até 15 segundos**. Qualquer outra coisa — erro HTTP (4xx/5xx), timeout, exceção de rede — marca a tentativa como falha. **A entrega é então re-tentada automaticamente** seguindo a tabela de backoff abaixo.

Se você precisa de garantia de entrega no seu lado, **devolva 2xx imediatamente ao receber** e processe de forma assíncrona do seu lado (fila interna). É o padrão recomendado.

## Retentativas e backoff

O Brasil NFe faz **até 5 tentativas** de entrega para cada evento, com backoff exponencial:

| Tentativa | Quando dispara | Header `X-Webhook-Attempt` |
| --- | --- | --- |
| 1 | Imediatamente, no momento do evento. | `1` |
| 2 | 30 segundos após a tentativa 1 falhar. | `2` |
| 3 | 2 minutos após a tentativa 2 falhar. | `3` |
| 4 | 10 minutos após a tentativa 3 falhar. | `4` |
| 5 | 1 hora após a tentativa 4 falhar. | `5` |

Tempo total máximo: **~1h12min** entre o evento e a quinta (e última) tentativa. Após a quinta tentativa falhar, o evento é considerado **perdido** — mas todas as tentativas ficam registradas em **Painel → Webhooks → Logs** para auditoria.

### O que se mantém entre tentativas

- **`deliveryId`** — idêntico em todas as tentativas. Use para idempotência.
- **`event`** — idêntico.
- **Body completo** — byte-a-byte idêntico (logo, **a `X-Webhook-Signature` também é idêntica**).
- **`X-Webhook-Timestamp`** — reflete o disparo original, não muda.

### O que muda entre tentativas

- **`X-Webhook-Attempt`** — incrementa de 1 a 5.
- **URL e secret** — usam a configuração **atual** no momento da tentativa. Se você atualizou o webhook entre o disparo original e a retentativa, a próxima tentativa vai para a nova URL com o novo secret. Webhook desativado entre tentativas → não há mais retentativas.

### Eventos de teste não são re-tentados

O evento `test.ping` (botão "Testar" no painel) **nunca é re-tentado** — é uma checagem manual e o resultado é mostrado no painel imediatamente.

## Eventos suportados

Mais eventos serão adicionados gradualmente. **O nome do evento é estável** — quando um evento entra na lista, o nome não muda.

### `test.ping`

Disparado pelo botão **Testar** no painel. **Nunca é re-tentado** (resultado mostrado imediatamente no painel).

**Headers extras:** nenhum. **Casing:** camelCase.

<Tabs defaultValue="campos">
  <TabsList>
    <TabsTrigger value="campos">Campos (`data`)</TabsTrigger>
    <TabsTrigger value="exemplo">Exemplo JSON</TabsTrigger>
  </TabsList>

  <TabsContent value="campos">

| Campo | Tipo | Descrição |
| --- | --- | --- |
| `test` | boolean | Sempre `true` neste evento. |
| `message` | string | Mensagem informativa. |
| `sentAt` | datetime | Momento do envio (ISO-8601 em UTC). |

  </TabsContent>

  <TabsContent value="exemplo">

```json
{
  "event": "test.ping",
  "deliveryId": "9f86d081884c7d659a2feaa0c55ad015",
  "timestamp": "2026-04-28T17:32:11.0123456Z",
  "data": {
    "test": true,
    "message": "Teste de webhook a partir do BrasilNFe.",
    "sentAt": "2026-04-28T17:32:11.0123456Z"
  }
}
```

  </TabsContent>
</Tabs>

### `nfe.lote.finalizado`

Disparado quando **todas** as notas de um envio em lote via [`/EnviarNotaFiscalLote`](/api#tag/nf-e-e-nfce/post/-enviarnotafiscallote) receberam resposta da SEFAZ (sucesso ou erro). É o evento de fechamento do lote — uma única notificação por lote, independente de quantas notas ele tem.

**Headers extras:** nenhum. **Casing:** camelCase.

<Tabs defaultValue="campos">
  <TabsList>
    <TabsTrigger value="campos">Campos (`data`)</TabsTrigger>
    <TabsTrigger value="exemplo">Exemplo JSON</TabsTrigger>
  </TabsList>

  <TabsContent value="campos">

| Campo | Tipo | Descrição |
| --- | --- | --- |
| `codLote` | string | Identificador do lote no Brasil NFe. |
| `tipoAmbiente` | int | `1` = produção, `2` = homologação. |
| `modeloDocumento` | int | `55` (NF-e) ou `65` (NFC-e). |
| `status` | int | `4` = lote finalizado com ao menos uma nota emitida; `5` = lote finalizado sem nenhuma nota emitida. |
| `qtdTotal` | int | Total de notas no lote. |
| `qtdEmitida` | int | Quantas foram autorizadas (`codStatus` 100 ou 150). |
| `qtdErro` | int | `qtdTotal - qtdEmitida`. |
| `notas[]` | array | Uma entrada por nota do lote (ver schema abaixo). |

Cada item de `notas[]`:

| Campo | Tipo | Descrição |
| --- | --- | --- |
| `id` | long | Id interno da nota no Brasil NFe. |
| `numero` | long | Número da nota. |
| `serie` | int | Série. |
| `chaveAcesso` | string | Chave de 44 dígitos (vazio quando a nota não foi autorizada). |
| `numeroProtocolo` | string | Protocolo SEFAZ (vazio em caso de erro). |
| `codStatus` | int | Código de status SEFAZ (ex.: `100` autorizado, `150` autorizado fora do prazo). |
| `dsStatus` | string | Descrição do status SEFAZ. |
| `error` | string &#124; null | Mensagem de erro quando a nota não foi autorizada. |

  </TabsContent>

  <TabsContent value="exemplo">

```json
{
  "event": "nfe.lote.finalizado",
  "deliveryId": "5d41402abc4b2a76b9719d911017c592",
  "timestamp": "2026-04-28T17:32:11.0123456Z",
  "data": {
    "codLote": "L-2026-0042",
    "tipoAmbiente": 1,
    "modeloDocumento": 55,
    "status": 4,
    "qtdTotal": 2,
    "qtdEmitida": 2,
    "qtdErro": 0,
    "notas": [
      {
        "id": 12345,
        "numero": 100,
        "serie": 1,
        "chaveAcesso": "35260400000000000000550010000001001000000017",
        "numeroProtocolo": "135260000000001",
        "codStatus": 100,
        "dsStatus": "Autorizado o uso da NF-e",
        "error": null
      },
      {
        "id": 12346,
        "numero": 101,
        "serie": 1,
        "chaveAcesso": "35260400000000000000550010000001011000000024",
        "numeroProtocolo": "135260000000002",
        "codStatus": 100,
        "dsStatus": "Autorizado o uso da NF-e",
        "error": null
      }
    ]
  }
}
```

  </TabsContent>
</Tabs>

### `documento.entrada.recebida`

Disparado quando uma **nova nota de entrada** é detectada para o CNPJ da empresa: NF-e/CT-e via manifestação automática na SEFAZ, ou NFS-e capturada pelo scraping do portal nacional/da prefeitura. Mesmo que a nota já chegue cancelada, o evento de criação é sempre `recebida` — o consumidor identifica pelo campo `Status` do payload.

**Headers extras:** `X-Document-Model`, `X-Document-Chave` (ver acima). **Casing:** PascalCase.

<Tabs defaultValue="campos">
  <TabsList>
    <TabsTrigger value="campos">Campos (`data`)</TabsTrigger>
    <TabsTrigger value="exemplo">Exemplo JSON</TabsTrigger>
  </TabsList>

  <TabsContent value="campos">

| Campo | Tipo | Descrição |
| --- | --- | --- |
| `Chave` | string | Chave de acesso (44 dígitos para NF-e/CT-e; código de verificação para NFS-e). |
| `IdentificadorInterno` | string | Identificador interno do emissor (apenas NFS-e, quando disponível). |
| `CodLote` | string | Código do lote (apenas NFS-e). |
| `Numero` | long | Número da nota (extraído da chave para NF-e/CT-e). |
| `ModeloDocumento` | int | `10` (NFS-e), `55` (NF-e), `57` (CT-e). |
| `Valor` | decimal | Valor total da nota. |
| `ValorIcms` | decimal &#124; null | Valor de ICMS (quando aplicável). |
| `CnpjEmissor` | string | CNPJ/CPF do emissor da nota. |
| `NomeEmissor` | string | Razão social/nome do emissor. |
| `IeEmissor` | string &#124; null | Inscrição estadual do emissor (apenas NF-e/CT-e). |
| `CnpjDestinatario` | string | CNPJ da empresa destinatária (a sua empresa). |
| `NomeDestinatario` | string | Razão social da destinatária. |
| `NumeroProtocolo` | string | Protocolo de autorização SEFAZ. |
| `Cfops` | string | CFOPs presentes nos itens (separados por vírgula). |
| `DigestValue` | string | Digest do XML assinado / código de verificação NFS-e. |
| `Status` | int | `1` = autorizado, `2` = cancelado, `3` = uso denegado. |
| `DtEmissao` | datetime | Data de emissão (ISO-8601). |
| `DtRecebimento` | datetime | Quando o Brasil NFe identificou e armazenou a nota. |

  </TabsContent>

  <TabsContent value="exemplo">

```json
{
  "event": "documento.entrada.recebida",
  "deliveryId": "7d793037a0760186574b0282f2f435e7",
  "timestamp": "2026-04-28T17:32:11.0123456Z",
  "data": {
    "Chave": "35260400000000000000550010000001001000000017",
    "IdentificadorInterno": null,
    "CodLote": null,
    "Numero": 100,
    "ModeloDocumento": 55,
    "Valor": 1250.00,
    "ValorIcms": 225.00,
    "CnpjEmissor": "00000000000000",
    "NomeEmissor": "Fornecedor Exemplo LTDA",
    "IeEmissor": "1234567890123",
    "CnpjDestinatario": "11111111111111",
    "NomeDestinatario": "Sua Empresa LTDA",
    "NumeroProtocolo": "135260000000001",
    "Cfops": "5102,5405",
    "DigestValue": "xYz123abc456def789ghi012jkl345mn",
    "Status": 1,
    "DtEmissao": "2026-04-28T14:00:00",
    "DtRecebimento": "2026-04-28T17:30:00"
  }
}
```

  </TabsContent>
</Tabs>

### `documento.entrada.cancelada`

Disparado quando uma nota de entrada **previamente recebida** (NF-e modelo 55 ou CT-e modelo 57) é marcada como cancelada — gatilho é o registro do evento SEFAZ **110111** (Cancelamento) associado à chave da nota.

> Cancelamentos de NFS-e **não** passam por aqui: o cancelamento de NFS-e do portal nacional vem como nova varredura e dispara `documento.entrada.recebida` com `Status: 2` (se a nota ainda não estava cadastrada). Notas NFS-e que já existiam no Brasil NFe não recebem evento adicional.

**Headers extras:** `X-Document-Model`, `X-Document-Chave` (ver acima). **Casing:** PascalCase.

O schema dos campos é **idêntico** ao de [`documento.entrada.recebida`](#documentoentradarecebida). A única diferença prática é o campo `Status`, que neste evento sempre vem como `2` (cancelado).

<Tabs defaultValue="exemplo">
  <TabsList>
    <TabsTrigger value="exemplo">Exemplo JSON</TabsTrigger>
  </TabsList>

  <TabsContent value="exemplo">

```json
{
  "event": "documento.entrada.cancelada",
  "deliveryId": "098f6bcd4621d373cade4e832627b4f6",
  "timestamp": "2026-04-29T10:15:42.0123456Z",
  "data": {
    "Chave": "35260400000000000000550010000001001000000017",
    "IdentificadorInterno": null,
    "CodLote": null,
    "Numero": 100,
    "ModeloDocumento": 55,
    "Valor": 1250.00,
    "ValorIcms": 225.00,
    "CnpjEmissor": "00000000000000",
    "NomeEmissor": "Fornecedor Exemplo LTDA",
    "IeEmissor": "1234567890123",
    "CnpjDestinatario": "11111111111111",
    "NomeDestinatario": "Sua Empresa LTDA",
    "NumeroProtocolo": "135260000000001",
    "Cfops": "5102,5405",
    "DigestValue": "xYz123abc456def789ghi012jkl345mn",
    "Status": 2,
    "DtEmissao": "2026-04-28T14:00:00",
    "DtRecebimento": "2026-04-28T17:30:00"
  }
}
```

  </TabsContent>
</Tabs>

## Boas práticas de segurança

- Use uma URL **dedicada** ao webhook (ex.: `/webhooks/brasilnfe`) — não compartilhe com outros endpoints públicos.
- **Sempre HTTPS** em produção. Endereços HTTP planos serão chamados, mas o secret e o payload trafegam em claro.
- Valide a **assinatura antes** de qualquer parsing custoso ou consulta a banco.
- Guarde o secret em **variável de ambiente ou cofre** (Vault, AWS Secrets Manager, etc.), nunca no código.
- Se o secret vazou, **regere imediatamente** pelo painel — o secret antigo é invalidado na hora.

## Teste local

Para testar sem precisar gerar uma nota real:

1. No painel, abra **Webhooks** e clique no ícone de **avião** (Testar) ao lado do webhook.
2. O Brasil NFe envia um POST com `event: "test.ping"` e payload mínimo.
3. Veja o resultado imediatamente (HTTP status, duração) e o request/response completo em **Logs**.

Para desenvolvimento local atrás de NAT, use um túnel como [ngrok](https://ngrok.com/) ou [localtunnel](https://localtunnel.github.io/www/) e cadastre a URL pública gerada.
