---
title: "DFe Inbound: Webhook de NF-e e CT-e Recebidas"
description: "Público-alvo: Desenvolvedores de sistemas que integram com a NFE.io"
source_url: https://nfe.io/docs/documentacao/distribuicao/dfe-inbound-webhook-nfe-cte
last_updated: 2026-06-23
---

# Documentação Técnica — Webhook NF-e/CT-e Inbound (Devs de Clientes)

> **Público-alvo:** Desenvolvedores de sistemas que integram com a NFE.io
> para receber automaticamente NF-e e CT-e capturadas via DistribuicaoDFe
> (SEFAZ Ambiente Nacional).
> **Objetivo:** Referência técnica completa para integrar via webhook:
> payload, campos, tipos, eventos, validação de assinatura, boas práticas.
> **Atualizado em:** 2026-05-22
>
> 📑 Este documento cobre **apenas o webhook do NF-e/CT-e Recebidas**. Para
> a referência REST (consulta, download, manifestação) consulte
> [02-doc-tecnica-clientes-nfe-cte-dev-pt.md](/documentacao/distribuicao/dfe-inbound-documentacao-tecnica-desenvolvedores).
> Para o webhook do NFS-e Recebidas, consulte
> 02-doc-tecnica-clientes-dev-nfse-inbound-webhook.md.
> Para a visão geral, abra README.md.

## Índice

- [1. Visão geral](#1-visão-geral)
- [2. Webhook — Contrato técnico](#2-webhook--contrato-técnico)
- [3. Envelope de entrega](#3-envelope-de-entrega)
- [4. Payloads de NF-e](#4-payloads-de-nf-e)
  - [4.1. `product_invoice_inbound` / `issued_successfully`](#41-product_invoice_inbound--issued_successfully)
  - [4.2. `product_invoice_inbound` / `event_raised_successfully`](#42-product_invoice_inbound--event_raised_successfully)
  - [4.3. `product_invoice_inbound` / `input_event_raised_successfully` (manifestação)](#43-product_invoice_inbound--input_event_raised_successfully-manifestação)
  - [4.4. Resumos (`product_invoice_inbound_summary`)](#44-resumos-product_invoice_inbound_summary)
- [5. Payloads de CT-e](#5-payloads-de-ct-e)
  - [5.1. `transportation_invoice_inbound` / `issued_successfully`](#51-transportation_invoice_inbound--issued_successfully)
  - [5.2. `transportation_invoice_inbound` / `event_raised_successfully`](#52-transportation_invoice_inbound--event_raised_successfully)
- [6. Mapeamento `tpEvento` SEFAZ → `eventAction`](#6-mapeamento-tpevento-sefaz--eventaction)
- [7. Validação HMAC](#7-validação-hmac)
- [8. URLs de download do XML](#8-urls-de-download-do-xml)
- [9. Reprocessamento de webhook](#9-reprocessamento-de-webhook)
- [10. Idempotência](#10-idempotência)
- [11. Boas práticas de integração](#11-boas-práticas-de-integração)
- [12. Troubleshooting](#12-troubleshooting)
- [13. Anatomia da chave de acesso (NF-e/NFC-e e CT-e)](#13-anatomia-da-chave-de-acesso-nf-enfc-e-e-ct-e)
  - [13.1. NF-e (modelo 55) e NFC-e (modelo 65) — 44 dígitos](#131-nf-e-modelo-55-e-nfc-e-modelo-65--44-dígitos)
  - [13.2. Chave do evento NF-e — 51 dígitos (sem prefixo `ID`)](#132-chave-do-evento-nf-e--51-dígitos-sem-prefixo-id)
  - [13.3. CT-e (modelo 57) — 44 dígitos](#133-ct-e-modelo-57--44-dígitos)
  - [13.4. Chave do evento CT-e — 55 caracteres (com prefixo `ID`)](#134-chave-do-evento-ct-e--55-caracteres-com-prefixo-id)
  - [13.5. Validação do DV (módulo 11)](#135-validação-do-dv-módulo-11)

---

## 1. Visão geral

O serviço **Inbound** da NFE.io captura automaticamente NF-e e CT-e da
SEFAZ via DFe (Distribuição de DF-e) e entrega ao seu sistema via
**webhook HTTP POST**.

**Fluxo resumido:**

```text
SEFAZ (DFe) → NFE.io Inbound Worker → Webhook POST → Seu sistema
```

Você **não** precisa consultar a SEFAZ — o NFE.io faz isso automaticamente
em intervalos regulares por empresa ativa.

**Famílias de evento entregues por este serviço:**

| Família | `eventType` | `eventAction` | Quando ocorre |
|---|---|---|---|
| NF-e completa | `product_invoice_inbound` | `issued_successfully` | Nova NF-e autorizada recebida via DistribuicaoDFe, XML completo |
| Evento de NF-e | `product_invoice_inbound` | `event_raised_successfully` | Evento emitido pela própria origem da NF-e (cancelamento, EPEC, CC-e, registro de passagem, etc.) |
| Manifestação do destinatário | `product_invoice_inbound` | `input_event_raised_successfully` | Manifestação registrada pelo destinatário (confirmação, ciência, desconhecimento, operação não realizada) |
| Resumo de NF-e | `product_invoice_inbound_summary` | `issued_successfully` | Resumo de NF-e (metadados apenas — XML completo não disponível até manifestação ou via consulta on-demand) |
| Resumo de evento de NF-e | `product_invoice_inbound_summary` | `event_raised_successfully` | Resumo de evento — metadados do evento sem XML completo |
| CT-e completo | `transportation_invoice_inbound` | `issued_successfully` | Novo CT-e autorizado recebido via DistribuicaoDFe |
| Evento de CT-e | `transportation_invoice_inbound` | `event_raised_successfully` | Evento de CT-e (cancelamento, prestação em desacordo, etc.) |

**Resumo vs documento completo** (NF-e apenas):

A SEFAZ entrega via DFe **resumos** (`NFeSummary...`) com poucos metadados
(empresa, chave, NSU, descrição) ou **documentos completos**
(`NFeMetadata`, `NFeEventMetadata`) com participantes, valores e links
detalhados. O serviço NFE.io repassa cada um como webhook distinto
(`*_inbound` vs `*_inbound_summary`). Quando você recebe um resumo e
quer o documento completo, chame `GET .../inbound/{accessKey}/xml`.

---

## 2. Webhook — Contrato técnico

### Método e headers

```http
POST <sua-url-configurada>
Content-Type: application/json; charset=utf-8
```

### Resposta esperada do seu endpoint

| HTTP Status | Comportamento NFE.io |
|---|---|
| **2xx** (200, 201, 204) | Entrega confirmada — não reenvia |
| **4xx** (exceto 408, 429) | Marca falha definitiva — **não** reenvia |
| **408** (timeout) | Falha temporária — reenvia com backoff |
| **429** (rate limit) | Falha temporária — reenvia com backoff |
| **5xx** | Falha temporária — reenvia com backoff |
| **Timeout** (> 30s) | Falha temporária — reenvia |

### Política de retry

- Até **50 tentativas** em uma janela de **24 horas**.
- Backoff exponencial: 30s → 60s → 120s → ... → max 7200s (2h).
- Jitter de ±20% em cada delay.
- Após exaurir: documento permanece disponível via API
  (`GET /v2/companies/{companyId}/inbound/productinvoices/{accessKey}` ou
  `.../transportationinvoices/{accessKey}`) e pode ser **reprocessado**
  manualmente (ver §9).

---

## 3. Envelope de entrega

O payload entregue ao seu endpoint vem **dentro de um envelope**
`{ "body": { ... } }` adicionado pela camada de entrega de webhooks da
NFE.io (events-api). É o mesmo wrapper usado pelo webhook NFS-e Inbound
(ver `02-doc-tecnica-clientes-dev-nfse-inbound-webhook.md` §3) — os
campos do objeto enviado ao transporte são **espalhados** dentro de
`body`, e `body.action` reflete o `eventAction` da rota.

**Estrutura completa do envelope final entregue:**

```json
{
  "body": {
    "action": "issued_successfully",
    "accountId": "acc-123",
    "type": "productInvoice",
    "accessKey": "...",
    "company": { /* ... */ },
    "issuer": { /* ... */ },
    "buyer": { /* ... */ }
    /* demais campos do payload — ver §4 e §5 */
  }
}
```

- **`body.action`** — corresponde ao `eventAction` da rota interna
  (`issued_successfully`, `event_raised_successfully`,
  `input_event_raised_successfully`). Use-o para diferenciar **ação**.
- **`body.type`** — campo do próprio payload (ver `Type` em §4/§5).
  Valores: `productInvoice`, `productInvoiceEvent`, `productInvoiceSummary`,
  `productInvoiceEventSummary`, `transportationInvoice`,
  `transportationInvoiceEvent`. Use-o para diferenciar **variante**.
- **Demais campos** (`accessKey`, `company`, `issuer`, etc.) — espalhados
  diretamente em `body` (não há sub-objeto `data` ou `document` aninhado
  para NF-e/CT-e — diferente do NFS-e, cujo handler embrulha em
  `body.document`).

> **Roteamento por `eventType`/`eventAction`**: o filtro de webhook
> registrado no painel nfe.io usa o par `(eventType, eventAction)` para
> decidir entregar a notificação. Os 3 `eventType` (`product_invoice_inbound`,
> `product_invoice_inbound_summary`, `transportation_invoice_inbound`)
> aparecem na URL interna da events-api (`v2/events/{eventType}/{eventAction}`)
> e definem qual webhook do painel será disparado, mas **não são
> necessariamente propagados** dentro de `body`. Para discriminar o
> payload recebido, use `(body.action, body.type)`.
>
> **Discriminação no consumer**: use o par `(body.action, body.type)` —
> a matriz completa está na §12 ("Troubleshooting").
>
> **Nota de validação**: o wrapper acima reflete o padrão da events-api
> documentado no webhook NFS-e Inbound (mesma camada de entrega). Recomenda-se
> validar o shape contra uma entrega real de homologação antes de codar
> contratos rígidos de parser.

Os exemplos JSON nas seções 4 e 5 abaixo mostram apenas o **conteúdo
top-level do payload** (sem o wrapper `{ "body": { ... } }`) para clareza.
O wrapper é sempre o mesmo.

### Serialização: campos `null` são omitidos por padrão

⚠️ **Mudança em relação a versões anteriores deste documento.** O
serializador JSON da API **omite campos com valor `null`** — está
configurado globalmente com
`JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull`
(`Program.cs`). Consequências práticas:

1. **Não confie em chave presente como indicador.** Todo campo opcional
   documentado nas tabelas abaixo pode estar **ausente** quando o valor
   real seria `null`. Acesse com checagem defensiva (`obj?.field`,
   `obj.get("field")`, etc.), nunca assuma que a chave existe.
2. **Objetos com todas as propriedades internas `null` aparecem como
   `{}`.** Por exemplo, em um evento NF-e em que a NF-e pai ainda não foi
   indexada, `transportation` pode chegar como `{}` (objeto vazio) em vez
   de `{ "federalTaxNumber": null, "name": null }`. O container existe
   porque foi instanciado em código; as chaves internas foram omitidas
   pelo serializador.
3. **Containers que nem foram instanciados ficam totalmente ausentes.**
   Em resumos (§4.4.1, §4.4.2) e em CT-es que não declaram um participante
   opcional, o objeto pai não aparece na resposta — não vem como `null`
   nem como `{}`.
4. **Os exemplos das seções 4 e 5 refletem payloads reais observados em
   produção** (anonimizados). Use-os como contrato de referência; as
   tabelas listam quais campos podem aparecer, não garantem presença.

⚠️ **Diferença para a doc do webhook NFS-e:** o handler do NFS-e usa um
`JsonSerializerOptions` próprio que serializa `null` explicitamente. Não
extrapole o comportamento de um webhook para o outro.

### Estabilidade contra novos campos

A API pode adicionar **novos campos opcionais** ao payload sem aviso
prévio (mudança não-breaking). Seu parser deve **ignorar chaves
desconhecidas** em vez de falhar. Configurações recomendadas:

- TypeScript: evite `exactOptionalPropertyTypes` para o shape do webhook;
  trate todos os campos como `string | null | undefined`.
- C#: configure `JsonSerializer` com
  `JsonSerializerOptions { UnknownTypeHandling = ... }` permissivo, ou
  use `JsonElement` para campos não documentados.
- Go: use `json.RawMessage` ou structs com tags `json:",omitempty"` e
  não falhe em campos extras (comportamento default de `encoding/json`).

---

## 4. Payloads de NF-e

Todas as 5 variantes NF-e compartilham o mesmo **shape** de payload. O
que diferencia uma da outra é:

- o **campo `type`** dentro do `data` (`productInvoice`, `productInvoiceEvent`, `productInvoiceSummary`, `productInvoiceEventSummary`);
- e **quais campos opcionais vêm populados** (resumos têm muitos campos `null`; eventos têm campos de enrichment da NF-e referenciada que podem vir `null` durante in-flight).

Valores possíveis de `type`:

| `type` | Quando ocorre |
|---|---|
| `productInvoice` | NF-e completa recebida via DFe |
| `productInvoiceEvent` | Evento de NF-e (cancelamento, EPEC, etc.) |
| `productInvoiceSummary` | Resumo de NF-e (metadados parciais) |
| `productInvoiceEventSummary` | Resumo de evento de NF-e |

### 4.1. `product_invoice_inbound` / `issued_successfully`

Enviado quando uma **NF-e nova completa** é recebida via DistribuicaoDFe
e o XML é desempacotado com sucesso. Carrega os participantes, valores
e links de download.

#### Campos populados vs sempre `null`

Igual ao que descrevemos em §4.2 para eventos, o shape de `NFeMetadataResource`
é compartilhado com outros endpoints e contém campos que **este webhook não popula**.

**Sempre populados:**

| Campo | Origem |
|---|---|
| `accessKey` | `infNFe/@Id` (sem prefixo `NFe`) — chave de 44 dígitos. |
| `parentAccessKey` | `""` (string vazia — NF-e raiz não tem documento pai). |
| `createdOn` | Quando o documento entrou no nosso sistema. |
| `nsu` | NSU atribuído pela SEFAZ ao envelope DistribuicaoDFe (string). |
| `nsuParent` | `""` (string vazia para NF-e raiz). |
| `type` | Sempre `"productInvoice"`. |
| `description` | Sempre o literal `"Autorizado o uso da NF-e"`. |
| `nfeNumber`, `nfeSerialNumber` | `infNFe/ide/nNF` e `infNFe/ide/serie`. |
| `issuedOn` | `infNFe/ide/dhEmi`. |
| `totalInvoiceAmount` | `infNFe/total/ICMSTot/vNF` formatado como `"0.00"`. |
| `operationType` | `infNFe/ide/tpNF` — `"Incoming"` (`tpNF=0`) ou `"Outgoing"` (`tpNF=1`). |
| `company.id`, `company.federalTaxNumber` | Sua empresa nfe.io. |
| `issuer.federalTaxNumber`, `issuer.name` | `infNFe/emit/{CNPJ\|CPF}` e `xNome`. |
| `buyer.federalTaxNumber`, `buyer.name` | `infNFe/dest/{CNPJ\|CPF}` e `xNome`. |
| `transportation.federalTaxNumber`, `transportation.name` | `infNFe/transp/transporta/{CNPJ\|CPF, xNome}`. Se a NF-e não declarou transportadora, vêm **string vazia** (`""`), não null. |
| `links.xml` | URL para baixar o XML completo via API. |
| `links.pdf` | URL para baixar o DANFE (PDF) via API. |

**Containers `issuer`, `buyer`, `transportation`, `links`:** o código
sempre instancia esses objetos. Quando o XML da NF-e declara o
participante, todos os campos internos vêm preenchidos. Quando algum
campo interno é `null`, ele é **omitido** pela serialização global
(§3). Se a NF-e não declarou uma transportadora, por exemplo,
`transportation` pode chegar como `{ "federalTaxNumber": "", "name": "" }`
(strings vazias preenchidas pelo parser) ou apenas `{}` (objeto vazio
após filtro de `null`).

**Campos do shape que NÃO são populados (omitidos do JSON):**

| Campo | Comportamento real |
|---|---|
| `xmlUrl` (top-level) | **Ausente do JSON.** Use `links.xml`. Há `[JsonIgnore(WhenWritingNull)]` explícito no shape e o valor é `null` para NF-e completa. Só é populada em webhooks de resumo de evento (§4.4.2). |
| `federalTaxNumberSender` | **Ausente do JSON** (`null` no servidor, omitido pelo serializador global). Use `issuer.federalTaxNumber`. |
| `nameSender` | **Ausente do JSON.** Use `issuer.name`. |
| `environmentType` | `0` (default de `int`; presente no JSON porque tipos de valor não-nullable não são afetados pelo filtro de `null`). Não é confiável — para saber o ambiente SEFAZ, use a configuração do inbound (`EnvironmentSEFAZ`). |

#### Exemplo (payload real anonimizado)

Conteúdo top-level de `body` (sem o wrapper do §3):

```json
{
  "accessKey": "35260557622466000146550010011320981999999993",
  "createdOn": "2026-05-19T11:41:44.695Z",
  "parentAccessKey": "",
  "company": {
    "id": "9999999999999999999999999999997",
    "federalTaxNumber": "99999999999999"
  },
  "issuer": {
    "federalTaxNumber": "57622466000146",
    "name": "DISTRIBUIDORA DE CARNES VALE DO MOGI IMP EXP LTDA"
  },
  "buyer": {
    "federalTaxNumber": "99999999999999",
    "name": "CHURRASCARIA CACADOR LTDA"
  },
  "transportation": {
    "federalTaxNumber": "57622466000146",
    "name": "DISTRIBUIDORA VALE DO MOGI IMP E EXP LTDA"
  },
  "links": {
    "xml": "https://api.nfse.io/v2/companies/9999999999999999999999999999997/inbound/35260557622466000146550010011320981999999993/xml",
    "pdf": "https://api.nfse.io/v2/companies/9999999999999999999999999999997/inbound/35260557622466000146550010011320981999999993/pdf"
  },
  "type": "productInvoice",
  "nsu": "34691",
  "nsuParent": "",
  "nfeNumber": "1132098",
  "nfeSerialNumber": "1",
  "issuedOn": "2026-05-18T04:08:27Z",
  "description": "Autorizado o uso da NF-e",
  "totalInvoiceAmount": "980.72",
  "operationType": "Incoming",
  "environmentType": 0
}
```

**Notas sobre o exemplo:**

- `createdOn` traz milissegundos (`.695Z`). Não assuma resolução de
  segundos no parsing.
- `federalTaxNumberSender`/`nameSender` **não aparecem** — foram omitidos
  por serem `null` no servidor (ver §3 e tabela acima).
- A `accessKey` da NF-e tem 44 dígitos (sem prefixo `NFe`). Ver §13 para
  a anatomia completa.

### 4.2. `product_invoice_inbound` / `event_raised_successfully`

Enviado quando um **evento da SEFAZ** é recebido para uma NF-e
(cancelamento, Carta de Correção, EPEC, registro de passagem, etc.).
Para a lista completa de `tpEvento` e mapeamento para `eventAction`, ver §6.1.

#### Quais campos vêm populados

O payload reaproveita o shape de `NFeMetadataResource` da §4.1, mas para
eventos o servidor preenche um subconjunto específico:

**Sempre populados (vêm do próprio evento):**

| Campo | Origem |
|---|---|
| `accessKey` | **51 dígitos**: concatenação `{tpEvento}{chaveNFe}{sequência}` — **sem prefixo `ID`**. Ex.: `110111412605...41` (cancelamento). Diferente do CT-e (§5.2), que mantém o prefixo `ID` por usar o `Id` original do XML sem reescrita. |
| `parentAccessKey` | Chave da NF-e referenciada (44 dígitos). |
| `createdOn` | Quando o evento entrou no nosso sistema (com milissegundos). |
| `nsu` | NSU do evento (string). |
| `type` | Sempre `"productInvoiceEvent"`. |
| `description` | Texto literal vindo do XML do evento. Cancelamento → `"Cancelamento"`. CC-e → `"Carta de Correcao"` (sem cedilha/acento, vindo do `<descEvento>` da SEFAZ). EPEC → `"EPEC"`. |
| `company.id`, `company.federalTaxNumber` | Sua empresa. |
| `links.xml` | URL do **XML do evento** (não da NF-e). |
| `links.pdf` | Sempre **string vazia** (`""`). Eventos não geram PDF. |

**Condicionais — só populados quando a NF-e pai está indexada** (caso típico
após captura prévia via SEFAZ ADN; vêm `null` se o evento chegou antes do
documento — janela transiente):

| Campo | Tipo | Quando vem null |
|---|---|---|
| `nsuParent` | string | NF-e pai não indexada. |
| `nfeNumber`, `nfeSerialNumber` | string | NF-e pai não indexada. |
| `issuedOn` | DateTime | NF-e pai não indexada. |
| `totalInvoiceAmount` | string | NF-e pai não indexada. |
| `issuer.federalTaxNumber`, `issuer.name` | string | NF-e pai não indexada. |
| `buyer.federalTaxNumber`, `buyer.name` | string | NF-e pai não indexada. |
| `transportation.federalTaxNumber`, `transportation.name` | string | NF-e pai não indexada ou NF-e sem transportadora. |

> ⚠️ **`operationType` é sempre serializado**, mas se a NF-e pai **não**
> estiver indexada, o servidor não consegue determinar a operação real e
> emite `"Outgoing"` por default. Trate esse campo como confiável apenas
> quando os demais campos condicionais (`nfeNumber`, `issuer`, etc.) também
> vierem populados.

**Containers `issuer`, `buyer`, `transportation` e `links`:** o código
instancia esses objetos. Os campos internos podem vir omitidos quando
forem `null` (regra global de serialização, §3). É comum, por exemplo,
ver `"transportation": {}` (objeto vazio) num evento de uma NF-e que
não declarou transportadora.

**Campos do shape que NÃO são populados em eventos (omitidos do JSON):**

| Campo | Comportamento real |
|---|---|
| `xmlUrl` (top-level) | **Ausente do JSON.** Use `links.xml`. |
| `federalTaxNumberSender` | **Ausente do JSON.** Use `issuer.federalTaxNumber`. |
| `nameSender` | **Ausente do JSON.** Use `issuer.name`. |
| `environmentType` | `0` (presente no JSON; valor de tipo `int` não é afetado pelo filtro de `null`). Não é confiável. |

#### Exemplo — Cancelamento (`tpEvento=110111`)

Conteúdo top-level de `body` (sem o wrapper do §3) — payload real anonimizado:

```json
{
  "accessKey": "110111412605999999999999995500200059985119999999941",
  "createdOn": "2026-05-18T19:21:54.06Z",
  "parentAccessKey": "41260599999999999999550020005998511999999994",
  "company": {
    "id": "99999999999999999999",
    "federalTaxNumber": "99999999999999"
  },
  "issuer": {
    "federalTaxNumber": "99999999999999",
    "name": "TESTE DISTRIBUIDORA DE FERRO E ACO LTDA"
  },
  "buyer": {
    "federalTaxNumber": "99999999999999",
    "name": "TESTE E TESTE LTDA"
  },
  "transportation": {},
  "links": {
    "xml": "https://api.nfse.io/v2/companies/99999999999999999999/inbound/41260599999999999999550020005998511999999994/events/110111412605999999999999995500200059985119999999941/xml",
    "pdf": ""
  },
  "type": "productInvoiceEvent",
  "nsu": "3272",
  "nsuParent": "3271",
  "nfeNumber": "599851",
  "nfeSerialNumber": "2",
  "issuedOn": "2026-05-15T15:22:30Z",
  "description": "Cancelamento",
  "totalInvoiceAmount": "7723.76",
  "operationType": "Incoming",
  "environmentType": 0
}
```

**Notas sobre o exemplo:**

- `accessKey` começa com `110111` (tpEvento de cancelamento), **sem o
  prefixo `ID`**. Veja §13.1 para a anatomia.
- `transportation: {}` — a NF-e original não declarou transportadora, então
  os campos internos foram omitidos pela serialização global e o container
  ficou vazio.
- Campos como `issuer`, `buyer`, `nfeNumber`, `totalInvoiceAmount` vieram
  preenchidos porque a NF-e pai já estava indexada no momento da entrega
  do evento. Em janelas transientes (evento chega antes da NF-e),
  os containers de enrichment podem vir vazios e os campos escalares
  podem estar omitidos.

#### Exemplo — Carta de Correção (`tpEvento=110110`)

Mesmo shape; apenas `accessKey` (começa com `110110…`) e `description`
mudam. O **texto da correção** (`xCorrecao`, item alterado, etc.) **não**
vem no payload — baixe o XML do evento via `links.xml` e leia
`procEventoNFe/evento/infEvento/detEvento/xCorrecao`.

Payload real anonimizado:

```json
{
  "accessKey": "110110332605685839540001085500100010826019999999931",
  "createdOn": "2026-05-18T20:29:35.898Z",
  "parentAccessKey": "33260568583954000108550010001082601999999993",
  "company": {
    "id": "999999999999999999999999",
    "federalTaxNumber": "99999999999999"
  },
  "issuer": {
    "federalTaxNumber": "99999999999999",
    "name": "TESTE  LTDA"
  },
  "buyer": {
    "federalTaxNumber": "99999999999999",
    "name": "CASA DE SAUDE E TESTE SA"
  },
  "transportation": {},
  "links": {
    "xml": "https://api.nfse.io/v2/companies/999999999999999999999999/inbound/33260568583954000108550010001082601999999993/events/110110332605685839540001085500100010826019999999931/xml",
    "pdf": ""
  },
  "type": "productInvoiceEvent",
  "nsu": "62313",
  "nsuParent": "62305",
  "nfeNumber": "108260",
  "nfeSerialNumber": "1",
  "issuedOn": "2026-05-18T00:00:00Z",
  "description": "Carta de Correcao",
  "totalInvoiceAmount": "1967.55",
  "operationType": "Incoming",
  "environmentType": 0
}
```

> ⚠️ Observe que `description` chega como `"Carta de Correcao"` (sem
> cedilha/acento) — o valor é repassado verbatim do `<descEvento>` da
> SEFAZ. Não normalize/traduza no consumer.

### 4.3. `product_invoice_inbound` / `input_event_raised_successfully` (manifestação)

Enviado quando o **destinatário** registra uma **manifestação** sobre a
NF-e. A SEFAZ define 4 `tpEvento` para manifestações; este serviço usa
o `eventAction = input_event_raised_successfully` exclusivamente para
esses 4 códigos. Eventos do **emissor** (cancelamento, CC-e, EPEC, etc.)
saem como `event_raised_successfully` (§4.2).

**Mapeamento dos 4 `tpEvento`:**

| `tpEvento` | Manifestação | Significado |
|---|---|---|
| `210200` | Confirmação da Operação | O destinatário recebeu a mercadoria como descrito. |
| `210210` | Ciência da Operação | O destinatário está ciente, mas não confirma recebimento. |
| `210220` | Operação Desconhecida | O destinatário não reconhece a operação. |
| `210240` | Operação Não Realizada | A operação não foi concluída (devolução etc.). |

**Por que esse split existe?** A manifestação é uma ação **proativa do
tomador** (não do emissor). Muitos clientes querem filtrar manifestações
distintas de eventos do emissor (cancelamento, CC-e). O split por
`eventAction` permite filtros simples no seu consumer:

```python
if body['action'] == 'input_event_raised_successfully':
    # Manifestação do destinatário — processar fluxo de aceitação/rejeição
    ...
elif body['action'] == 'event_raised_successfully':
    # Evento do emissor — atualizar status da NF-e (cancelado, CC-e aplicada)
    ...
```

**Shape do payload é idêntico ao §4.2** — mesma estrutura, com
`type: "productInvoiceEvent"`. O que muda é apenas `body.action` no
envelope.

### 4.4. Resumos (`product_invoice_inbound_summary`)

A SEFAZ pode entregar via DFe **apenas metadados resumidos** (não o
documento completo). Isso acontece quando:

1. A empresa **ainda não está manifestada** sobre a operação — a SEFAZ
   restringe acesso ao XML completo até que o destinatário confirme
   ciência ou confirmação.
2. O documento tem mais de **3 meses** desde a emissão (limite de
   retenção do DFe para XMLs completos).

Nesses casos, você recebe um **webhook de resumo** ao invés do webhook
completo (§4.1/§4.2). Os campos vêm parcialmente populados (vide tabelas
abaixo).

#### 4.4.1. `product_invoice_inbound_summary` / `issued_successfully`

Compartilha o shape de `NFeMetadataResource` com §4.1, mas com `type: "productInvoiceSummary"` e um subconjunto bem menor de campos populados. Helper: `NFePersistenceService.GetNFeMetadataResource(NFeSummaryNFeMetadata)`.

**Sempre populados:**

- `accessKey`, `createdOn`, `nsu`.
- `type` = `"productInvoiceSummary"`.
- `nfeNumber` — extraído da `accessKey`.
- `issuedOn` — vem do `receiptOn` do resumo SEFAZ (não da emissão original — esse dado não está no resumo).
- `totalInvoiceAmount` — formato `"0.00"`.
- `company.id`, `company.federalTaxNumber`.
- `issuer.federalTaxNumber`, `issuer.name` — vêm do envelope DistribuicaoDFe.
- `links.xml` — URL para baixar o XML quando estiver disponível.

**Conditional:** `description` = `nfeObject.Reason` — o `xMotivo` do SEFAZ retornado junto com o resumo. No fluxo de sucesso típico vem `null` (não setado pelo gateway); pode trazer texto da SEFAZ em casos de erro/aviso.

**Não populados (vêm `null`/`0`):**

| Campo | Valor real |
|---|---|
| `parentAccessKey` | `null`. |
| `nsuParent` | `null`. |
| `nfeSerialNumber` | `null` (não está no resumo SEFAZ). |
| `operationType` | `"Outgoing"` (default do enum `int 0` — não é confiável aqui, **ignorar**). |
| `environmentType` | `0`. |
| `federalTaxNumberSender`, `nameSender` | `null`. |
| `xmlUrl` (top-level) | **Ausente do JSON** (`[JsonIgnore(WhenWritingNull)]` aplicado). Use `links.xml`. |
| `buyer`, `transportation` | objetos **não instanciados**, vêm `null` no JSON (diferente do §4.1, onde sempre vêm como objeto). |
| `links.pdf` | `null` (resumos não geram PDF). |

**Exemplo JSON (conteúdo top-level de `body`, sem o wrapper — ver §3):**

```json
{
  "accessKey": "35240112345678000195550010000012341234567890",
  "createdOn": "2026-05-13T14:42:15Z",
  "parentAccessKey": null,
  "company": {
    "id": "54244e0ee340420fdc94ad10",
    "federalTaxNumber": "98765432000100"
  },
  "issuer": {
    "federalTaxNumber": "12345678000195",
    "name": "FORNECEDOR LTDA"
  },
  "buyer": null,
  "transportation": null,
  "links": {
    "xml": "https://api.nfse.io/v2/companies/54244e0ee340420fdc94ad10/inbound/35240112345678000195550010000012341234567890/xml",
    "pdf": null
  },
  "federalTaxNumberSender": null,
  "nameSender": null,
  "type": "productInvoiceSummary",
  "nsu": "21825",
  "nsuParent": null,
  "nfeNumber": "1234",
  "nfeSerialNumber": null,
  "issuedOn": "2026-05-13T10:00:00Z",
  "description": null,
  "totalInvoiceAmount": "1500.00",
  "operationType": "Outgoing",
  "environmentType": 0
}
```

**Quando o cliente recebe summary e quer dados completos:** veja §6.4 — a SEFAZ libera o XML completo após o destinatário registrar a manifestação. O webhook subsequente virá como §4.1 (`type: "productInvoice"`).

#### 4.4.2. `product_invoice_inbound_summary` / `event_raised_successfully`

Mesmo shape de `NFeMetadataResource`, com `type: "productInvoiceEventSummary"`. ⚠️ **Diferença importante**: este é o **único webhook que usa `xmlUrl` (top-level) ao invés de `links.xml`** — o helper `GetMetadataResource(NFeSummaryEventMetadata)` emite o link do XML do evento em `xmlUrl`, e o objeto `links` vem `null`.

**Sempre populados:**

- `accessKey` (chave do evento), `parentAccessKey` (chave NF-e referenciada).
- `createdOn`, `nsu`.
- `type` = `"productInvoiceEventSummary"`.
- `description` — `xEvento` literal do SEFAZ (ex.: `"Cancelamento de NF-e homologado"`, `"Carta de Correção registrada"`).
- `nfeNumber` — extraído da `parentAccessKey`.
- `issuedOn` — vem do `receiptOn` do evento.
- `company.id`, `company.federalTaxNumber`.
- `xmlUrl` — URL para baixar o XML do evento.

**Não populados (vêm `null`/`0`):**

| Campo | Valor real |
|---|---|
| `nsuParent` | `null`. |
| `nfeSerialNumber` | `null`. |
| `totalInvoiceAmount` | `null`. |
| `operationType` | `"Outgoing"` (default — **ignorar**). |
| `environmentType` | `0`. |
| `federalTaxNumberSender`, `nameSender` | `null`. |
| `issuer`, `buyer`, `transportation`, `links` | objetos **não instanciados**, vêm `null` no JSON. |

**Exemplo JSON — Cancelamento (resumo):**

```json
{
  "accessKey": "ID110111352401123456780001955500100000123412345678901",
  "createdOn": "2026-05-13T15:10:00Z",
  "parentAccessKey": "35240112345678000195550010000012341234567890",
  "company": {
    "id": "54244e0ee340420fdc94ad10",
    "federalTaxNumber": "98765432000100"
  },
  "issuer": null,
  "buyer": null,
  "transportation": null,
  "links": null,
  "xmlUrl": "https://api.nfse.io/v2/companies/54244e0ee340420fdc94ad10/inbound/35240112345678000195550010000012341234567890/events/ID110111352401123456780001955500100000123412345678901/xml",
  "federalTaxNumberSender": null,
  "nameSender": null,
  "type": "productInvoiceEventSummary",
  "nsu": "21850",
  "nsuParent": null,
  "nfeNumber": "1234",
  "nfeSerialNumber": null,
  "issuedOn": "2026-05-13T15:00:00Z",
  "description": "Cancelamento de NF-e homologado",
  "totalInvoiceAmount": null,
  "operationType": "Outgoing",
  "environmentType": 0
}
```

---

## 5. Payloads de CT-e

CT-e usa um **shape distinto** do payload de NF-e — campos típicos do
transporte (`recipient`, `sender`, `taker`, `dispatcher`,
`productInvoices`) substituem `issuer`/`buyer`/`transportation`. O CT-e
também carrega um `id` próprio (GUID gerado pela API) e `nsu` como
**número** (em NF-e é string).

### 5.1. `transportation_invoice_inbound` / `issued_successfully`

Enviado quando um **CT-e novo autorizado** é recebido via DistribuicaoDFe. Helper: `CTeService.SendCTeWebhook`, que emite um `CTeMetadataWebhookResource`.

**Sempre populados (no servidor):**

| Campo | Tipo | Fonte | Descrição |
|---|---|---|---|
| `id` | string (GUID) | gerado API | Identificador interno NFE.io. |
| `createdOn` | DateTime? | gerado API | Quando o NFE.io recebeu o documento (com milissegundos). |
| `accessKey` | string (44 dígitos) | `infCte/@Id` (sem prefixo `CTe`) | Chave de acesso do CT-e. |
| `parentAccessKey` | string | gerado API | Sempre `""` (string vazia) para CT-e raiz. |
| `nsu` | long (number) | Envelope DistribuicaoDFe | NSU do registro. ⚠️ É `long`, não string (em NF-e é string). |
| `company.id` | string | gerado API | ID da empresa NFE.io. |
| `type` | string | gerado API | Sempre `"transportationInvoice"`. |
| `description` | string | gerado API | Sempre o literal `"Autorizado o uso do CT-e"`. |
| `xmlUrl` | string | gerado API | URL absoluta do XML do CT-e. |

**Campos opcionais (podem estar ausentes quando todos os internos forem
`null` — comportamento da serialização global, §3):**

| Campo | Fonte XML | Quando some |
|---|---|---|
| `company.federalTaxNumber` | gerado API | Se o servidor não populou (raro). Sempre presente em fluxo normal. |
| `recipient.federalTaxNumber`, `recipient.name` | `infCte/dest/{CNPJ\|CPF, xNome}` | Se o CT-e não declarou destinatário ou só um dos campos veio nulo. |
| `sender.federalTaxNumber`, `sender.name` | `infCte/rem/{CNPJ\|CPF, xNome}` | Idem para remetente. |
| `taker.federalTaxNumber`, `taker.name` | `infCte/ide/toma3` ou `toma4` | Idem para tomador. |
| `dispatcher.federalTaxNumber`, `dispatcher.name` | `infCte/exped/{CNPJ\|CPF, xNome}` | Expedidor é opcional na SEFAZ; ausente quando não declarado. |
| `issuedOn` | `infCte/ide/dhEmi` | Apenas se anomalia no XML. |
| `productInvoices` | NF-es referenciadas | Array com objetos `{ accessKey }`. Pode vir vazio (`[]`) ou ausente se o CT-e não declara NF-es. |
| `totalAmount` | `infCte/vPrest/vTPrest` (formato `"0.00"`) | Se o XML não tem o campo `vTPrest`. |

**Campos do shape NÃO populados (sempre ausentes):**

- `issuer` e `buyer` — herdados do `MetadataResource`, mas o helper de
  CT-e não os instancia. CT-e usa `sender`/`recipient` em vez disso.

> ⚠️ **Em produção, os payloads de CT-e podem chegar bastante enxutos.**
> Quando o XML não declara participantes opcionais e o NSU foi emitido
> sem `vPrest`, é normal o payload conter só os campos do primeiro bloco
> da tabela acima (Sempre populados) — sem `recipient`, `sender`,
> `productInvoices` ou `totalAmount`. Veja o exemplo abaixo.

**Exemplo JSON — payload real enxuto (anonimizado), conteúdo top-level
de `body` (sem o wrapper do §3):**

```json
{
  "issuedOn": "2026-05-19T07:48:34Z",
  "id": "94152fd4-07ec-4145-88c7-01c2595da531",
  "createdOn": "2026-05-19T11:51:53.896Z",
  "accessKey": "35260542584754000267570023199390221999999996",
  "parentAccessKey": "",
  "nsu": 53642910,
  "company": {
    "id": "99999999999999999999999"
  },
  "type": "transportationInvoice",
  "description": "Autorizado o uso do CT-e",
  "xmlUrl": "https://api.nfse.io/v2/companies/99999999999999999999999/inbound/35260542584754000267570023199390221999999996/xml"
}
```

**Notas sobre o exemplo:**

- `company.federalTaxNumber` está omitido neste payload — o servidor não
  o populou. Use o `id` para casar com a sua empresa.
- Nenhum dos participantes (`recipient`, `sender`, `taker`, `dispatcher`)
  aparece — todos foram omitidos pelo filtro de `null` da serialização.
  **Isso não significa que a CT-e não os teve no XML** — pode ser que o
  parser ainda não popule esses campos do XML completo. Para os dados
  fiscais detalhados, baixe o XML via `xmlUrl`.
- `productInvoices` e `totalAmount` também estão ausentes — caso típico.

**Exemplo JSON — payload completo (quando o XML declara todos os
participantes):**

```json
{
  "id": "c0b3a1f2-e340-420f-dc94-ad10c0b3a1f2",
  "createdOn": "2026-05-13T16:20:00.000Z",
  "accessKey": "35240187654321000111570010000045671234567890",
  "parentAccessKey": "",
  "nsu": 21900,
  "company": {
    "id": "54244e0ee340420fdc94ad10",
    "federalTaxNumber": "98765432000100"
  },
  "type": "transportationInvoice",
  "description": "Autorizado o uso do CT-e",
  "xmlUrl": "https://api.nfse.io/v2/companies/54244e0ee340420fdc94ad10/inbound/35240187654321000111570010000045671234567890/xml",
  "recipient": {
    "federalTaxNumber": "12345678000195",
    "name": "DESTINATARIO LTDA"
  },
  "sender": {
    "federalTaxNumber": "98765432000100",
    "name": "REMETENTE S.A."
  },
  "taker": {
    "federalTaxNumber": "55566677000188",
    "name": "TOMADOR DO SERVICO LTDA"
  },
  "dispatcher": {
    "federalTaxNumber": "11122233000144",
    "name": "EXPEDIDOR LOGISTICA LTDA"
  },
  "issuedOn": "2026-05-13T15:00:00Z",
  "productInvoices": [
    { "accessKey": "35240112345678000195550010000012341234567890" },
    { "accessKey": "35240112345678000195550010000012351234567891" }
  ],
  "totalAmount": "850.00"
}
```

> ⚠️ **Diferenças de tipos vs NF-e**: o CT-e tem `id` próprio (GUID) — a
> NF-e usa apenas `accessKey`. O CT-e tem `nsu` como **long**; a NF-e
> tem `nsu` como **string**.

### 5.2. `transportation_invoice_inbound` / `event_raised_successfully`

Enviado quando um **evento de CT-e** é recebido (cancelamento, prestação
em desacordo, etc.). Helper: `CTeService.SendCTeEventWebhook`, que emite um `CTeEventMetadataResource` — payload **bem mais enxuto** que CT-e completo: sem participantes, sem `productInvoices`, sem `totalAmount`.

**Campos do shape (10 ao todo):**

| Campo | Tipo | Fonte | Descrição |
|---|---|---|---|
| `id` | string (GUID) | gerado API | Identificador interno NFE.io. |
| `createdOn` | DateTime? | gerado API | Quando o NFE.io recebeu o evento (com milissegundos). |
| `accessKey` | string | `evento/infEvento/@Id` — **mantém o prefixo `ID`** vindo do XML | Chave do **evento** (não do CT-e pai). Ex.: `ID110111352605...0001`. |
| `parentAccessKey` | string | `evento/infEvento/chCTe` | Chave do CT-e referenciado (44 dígitos). |
| `nsu` | long | Envelope DistribuicaoDFe | NSU do registro do evento (numérico, não string). |
| `company.id` | string | gerado API | ID da empresa NFE.io. |
| `company.federalTaxNumber` | string (opcional) | gerado API | Pode estar ausente. |
| `type` | string | gerado API | Sempre `"transportationInvoiceEvent"`. |
| `description` | string | `procEventoCTe/retEventoCTe/infEvento/xEvento` | Texto curto do evento (lido apenas de `xEvento`, sem fallback para `descEvento` — ver `CTeGateway.cs` L342). **Cancelamento** chega como `"Cancelamento"` (não `"Cancelamento de CT-e homologado"` como versões anteriores deste documento afirmavam). |
| `xmlUrl` | string | gerado API | URL para baixar o XML do evento. |
| `receiptOn` | DateTime? | `procEventoCTe/retEventoCTe/infEvento/dhRegEvento` | Data/hora em que a SEFAZ registrou o evento. |

⚠️ **Diferença vs NF-e (§4.2):** o `accessKey` do evento de CT-e **preserva o
prefixo `ID`** (resultado da leitura direta do `Id` do XML pelo gateway),
enquanto o evento de NF-e tem o `ID` removido pelo serviço (`{tpEvento}{chave}{seq}`).

**Não há campos de enrichment para CT-e evento** — o shape
`CTeEventMetadataResource` não declara `recipient`, `sender`, etc. Se
você precisa dos dados do CT-e pai, consulte-o via
`GET /v2/companies/{companyId}/inbound/{parentAccessKey}` (mas note a §8.2
do doc combinado: aquele endpoint REST também não retorna participantes —
só o payload do webhook §5.1 carrega isso).

**Exemplo JSON — payload real anonimizado (conteúdo top-level de `body`,
sem o wrapper do §3):**

```json
{
  "receiptOn": "2026-05-16T00:03:27Z",
  "id": "493d2525-d9e8-4c3f-b30f-c70510147e06",
  "createdOn": "2026-05-16T03:21:04.943Z",
  "accessKey": "ID11011135260555442952000157570010168676041999999990001",
  "parentAccessKey": "35260555442952000157570010168676041999999990",
  "nsu": 55125,
  "company": {
    "id": "999999999999999999999999999999",
    "federalTaxNumber": "55271322000167"
  },
  "type": "transportationInvoiceEvent",
  "description": "Cancelamento",
  "xmlUrl": "https://api.nfse.io/v2/companies/999999999999999999999999999999/inbound/35260555442952000157570010168676041999999990/events/ID11011135260555442952000157570010168676041999999990001/xml"
}
```

**Notas sobre o exemplo:**

- `accessKey` começa com `ID110111` — prefixo `ID` + `tpEvento` (110111
  = cancelamento) + chave do CT-e (44 dígitos) + sequência (`001`,
  3 dígitos padded).
  Total: 55 caracteres (2 letras + 53 dígitos).
- `description` é apenas `"Cancelamento"` — texto curto vindo do XML do
  evento, **não** `"Cancelamento de CT-e homologado"`.
- A ordem dos campos no JSON pode variar — não dependa dela.

> Se você precisa de detalhes do CT-e parent (participantes, valores,
> NF-es transportadas), busque-o via
> `GET /v2/companies/{companyId}/inbound/transportationinvoices/{parentAccessKey}`.

---

## 6. Mapeamento `tpEvento` SEFAZ → `eventAction`

### 6.1. NF-e

A SEFAZ usa códigos `tpEvento` de 6 dígitos para identificar cada tipo de
evento. O serviço NFE.io mapeia os 4 códigos de manifestação do
destinatário (`21020x`/`21024x`) para `input_event_raised_successfully`
e demais para `event_raised_successfully`:

| `tpEvento` | Descrição | `eventAction` |
|---|---|---|
| `110110` | Carta de Correção (CC-e) | `event_raised_successfully` |
| `110111` | Cancelamento de NF-e | `event_raised_successfully` |
| `110140` | EPEC (Evento Prévio de Emissão em Contingência) | `event_raised_successfully` |
| `110150` | Registro de Passagem Eletrônico | `event_raised_successfully` |
| `110160` | Manifestação do Fisco | `event_raised_successfully` |
| `210200` | Confirmação da Operação (destinatário) | `input_event_raised_successfully` |
| `210210` | Ciência da Operação (destinatário) | `input_event_raised_successfully` |
| `210220` | Operação Desconhecida (destinatário) | `input_event_raised_successfully` |
| `210240` | Operação Não Realizada (destinatário) | `input_event_raised_successfully` |

Outros `tpEvento` da SEFAZ (não listados acima) caem por default em
`event_raised_successfully`.

### 6.2. CT-e

| `tpEvento` | Descrição | `eventAction` |
|---|---|---|
| `110110` | Carta de Correção CT-e | `event_raised_successfully` |
| `110111` | Cancelamento de CT-e | `event_raised_successfully` |
| `110140` | EPEC para CT-e | `event_raised_successfully` |
| `610110` | Prestação de Serviço em Desacordo | `event_raised_successfully` |

CT-e não tem split de manifestação como NF-e — todos os eventos saem com
`event_raised_successfully`.

---

## 7. Validação HMAC

Para garantir que o webhook realmente veio da nfe.io (e não de terceiros
mal-intencionados), valide a assinatura HMAC-SHA256 no header da
requisição:

**Python (Flask):**

```python
import hmac
import hashlib
from flask import Flask, request, abort

app = Flask(__name__)
WEBHOOK_SECRET = b"seu_secret_aqui"  # configurado no painel nfe.io

def validate_webhook(payload_bytes: bytes, received_signature: str) -> bool:
    expected = hmac.new(WEBHOOK_SECRET, payload_bytes, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, received_signature)

@app.route('/webhooks/nfeio', methods=['POST'])
def receive():
    signature = request.headers.get('X-NFe-Signature', '')
    if not validate_webhook(request.data, signature):
        abort(401, 'Assinatura inválida')

    body = request.json['body']
    event_action = body['action']                # ex.: issued_successfully
    document_type = body['type']                 # ex.: productInvoice / transportationInvoiceEvent
    access_key = body.get('accessKey')

    # Despache por (event_action, document_type) — ver matriz no §12.
    # Os campos do payload (accessKey, company, issuer, ...) estão
    # espalhados em `body`, não sob `body.data`.
    return '', 200
```

**Node.js (Express):**

```javascript
const crypto = require('crypto');
const express = require('express');
const app = express();

const WEBHOOK_SECRET = 'seu_secret_aqui';

app.post('/webhooks/nfeio', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-nfe-signature'] || '';
  const expected = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(req.body)
    .digest('hex');

  if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) {
    return res.status(401).send('Assinatura inválida');
  }

  const body = JSON.parse(req.body).body;
  const { action, type, accessKey } = body;
  // `action` é o eventAction (ex.: issued_successfully).
  // `type` é o tipo do documento (ex.: productInvoice, transportationInvoiceEvent).
  // Demais campos (`company`, `issuer`, ...) estão diretamente em `body`.
  // Despache por (action, type) — ver matriz no §12.
  res.status(200).end();
});
```

> Configure o **Webhook Secret** no painel nfe.io → Empresas → \{sua
> empresa\} → Webhooks. Sem ele, qualquer pessoa que descobrir sua URL
> pode simular notificações.

---

## 8. URLs de download do XML

O payload do webhook **não embute o XML** — em vez disso, carrega uma URL
para um endpoint público da API NFE.io. **Onde** essa URL fica depende do
tipo de payload, porque o shape compartilhado expõe dois campos
diferentes para essa finalidade:

| Webhook | Campo com a URL do XML |
|---|---|
| §4.1 NF-e completa | `links.xml` |
| §4.2 evento NF-e (cancelamento, CC-e, EPEC, etc.) | `links.xml` |
| §4.3 manifestação | `links.xml` |
| §4.4.1 resumo NF-e | `links.xml` |
| §4.4.2 resumo de evento NF-e | `xmlUrl` (top-level) |
| §5.1 CT-e completo | `xmlUrl` (top-level) |
| §5.2 evento CT-e | `xmlUrl` (top-level) |

> Para NF-e existe também o campo `links.pdf` com a URL do DANFE
> (PDF) — só em webhooks de NF-e completa (§4.1). Em todos os webhooks
> de evento NF-e, `links.pdf` vem como string vazia (`""`); em resumos,
> vem `null`.

> Os campos `xmlUrl` em NF-e completa/evento/manifestação/resumo-NF-e e
> `links` em CT-e/resumo-de-evento-NF-e **nunca** vêm populados. Esta é
> uma vestígio de polimorfismo no shape `NFeMetadataResource`, compartilhado
> entre vários consumidores REST e webhook — não há duplicação real no
> payload.

### Comportamento do endpoint apontado pela URL

Independentemente de qual campo carregue a URL (`links.xml` ou `xmlUrl`),
ela aponta para um destes endpoints públicos:

- **NF-e (completa)**: `GET /v2/companies/{companyId}/inbound/{accessKey}/xml`
- **NF-e (evento)**: `GET /v2/companies/{companyId}/inbound/{accessKey}/events/{eventId}/xml`
- **CT-e (completo)**: `GET /v2/companies/{companyId}/inbound/{accessKey}/xml`
- **CT-e (evento)**: `GET /v2/companies/{companyId}/inbound/{accessKey}/events/{eventId}/xml`

Roteamento por modelo é feito pelos dígitos 21–22 da chave (modelo do
documento), via `accessKey.slice(20, 22)`: `55` ⇒ NF-e, demais ⇒ CT-e.
Autenticação via header `Authorization: <API_KEY>`.

### Comportamento da resposta

A resposta NÃO é um redirect HTTP. O endpoint retorna:

```http
HTTP/1.1 200 OK
Content-Type: application/json

{
  "publicTemporaryUri": "https://<storage-host>/<path>?<query-de-acesso-temporário>"
}
```

A `publicTemporaryUri` é uma **URL assinada de acesso direto ao blob**
(armazenamento de objetos da NFE.io), com **TTL de ~10 minutos** por
default. Cliente faz um segundo `GET` nessa URL para baixar o XML
diretamente — sem precisar do header `Authorization` (a assinatura na
query string autoriza o acesso).

**Fluxo cliente típico:**

```python
import requests

# Passo 1 — pedir o link temporário (autenticado)
r1 = requests.get(
    f"https://api.nfse.io/v2/companies/{company_id}/inbound/{access_key}/xml",
    headers={"Authorization": API_KEY},
)
temp_uri = r1.json()["publicTemporaryUri"]

# Passo 2 — baixar o XML pelo link temporário (sem autenticação)
r2 = requests.get(temp_uri)
xml_bytes = r2.content
```

### PDF

CT-e **não tem PDF** — o endpoint
`GET .../inbound/{accessKey}/pdf` retorna `204 No Content` para
chaves CT-e (modelo `57`).

NF-e tem PDF (DANFE) gerado pela API: mesmo padrão de resposta
(`200 OK` com `publicTemporaryUri`). Acesse via
`GET /v2/companies/{companyId}/inbound/{accessKey}/pdf`.

### Após expiração da URL temporária

Quando a `publicTemporaryUri` expira (~10 min), o blob storage retorna
erro HTTP no segundo GET. O endpoint público da API permanece válido —
basta requisitar `/xml` novamente para obter nova `publicTemporaryUri`
fresca.

> **Recomendação:** baixe e armazene XML logo após receber o webhook.
> Para acesso posterior, sempre chame `/xml` para obter URL temporária
> nova — não cacheie a `publicTemporaryUri`.

---

## 9. Reprocessamento de webhook

Se você precisar **receber novamente** um webhook (porque seu endpoint
estava fora do ar, ou houve bug no consumer), use os endpoints:

**NF-e:**

```http
POST /v2/companies/{companyId}/inbound/productinvoices/{accessKey}/processwebhook
Authorization: {api_key}
```

**CT-e:**

```http
POST /v2/companies/{companyId}/inbound/transportationinvoices/{accessKey}/processwebhook
Authorization: {api_key}
```

Ambos retornam `200 OK` e disparam novamente o webhook para a URL
configurada da empresa.

---

## 10. Idempotência

Webhooks **podem ser entregues mais de uma vez** em caso de retry após
falha temporária. Seu consumer deve ser idempotente:

- **Use `accessKey` como chave única** (NF-e e CT-e completos). Se já
  processou, ignore.
- **Use `(parentAccessKey, accessKey)` como chave única para eventos** —
  o `accessKey` do evento inclui o `tpEvento` no prefixo, garantindo
  unicidade.
- **Não use `nsu`** como chave — o NSU pertence ao registro DFe, não ao
  documento; reprocessamento pode gerar novo NSU.

---

## 11. Boas práticas de integração

1. **Responda rápido**: retorne HTTP 200 em menos de 5 segundos e
   processe assincronamente. O timeout da NFE.io é 30 segundos.
2. **Persista primeiro**: salve o payload no seu banco antes de
   processar, para não perder dados em crash do consumer.
3. **Baixe os arquivos cedo**: chame `/xml` (e `/pdf` quando aplicável)
   logo após receber o webhook e persista o conteúdo localmente —
   `publicTemporaryUri` expira em ~10 minutos.
4. **Implemente idempotência por `accessKey`** — webhooks podem ser
   reentregues após retry. Ver §10.
5. **Roteie por `(body.type, body.action)`** — a combinação dos 2 é o
   discriminador estável. Não filtre apenas por um deles.
6. **Trate `productInvoices: []` em CT-e como normal** — alguns CT-es
   declaram só a operação de transporte sem listar NF-es individuais.
7. **Não cacheie URLs temporárias** — `publicTemporaryUri` expira;
   sempre re-chame `/xml` quando precisar de acesso novo.
8. **Trate o split `event_raised_successfully` vs `input_event_raised_successfully`**
   no consumer — manifestações do destinatário (§4.3) têm semântica
   distinta de eventos do emissor (§4.2).
9. **Configure alertas** para HTTP 5xx no seu endpoint de webhook —
   detecte problemas antes de esgotar o orçamento de retries (50x em 24h).

---

## 12. Troubleshooting

### Recebi um payload com vários campos ausentes ou objetos vazios. É bug?

Não. Veja §3 "Serialização: campos `null` são omitidos por padrão". O
serializador da API está configurado para omitir campos `null` no JSON.
Resultado prático:

- Em `product_invoice_inbound_summary`, muitos campos do shape vêm
  ausentes (porque o resumo da SEFAZ não os preenche).
- Em `product_invoice_inbound` / `event_raised_successfully`, campos de
  enrichment da NF-e pai podem estar ausentes quando a NF-e ainda não foi
  indexada.
- Em `transportation_invoice_inbound` / `issued_successfully`,
  containers como `recipient` ou `sender` podem estar ausentes ou chegar
  como `{}` quando o CT-e não declara o participante.

Codifique seu parser para tolerar tanto ausência da chave quanto valor
`null`.

### `productInvoices` veio vazio no CT-e — está certo?

Sim, é possível. Alguns CT-es declaram apenas a operação de transporte
sem listar NF-es individuais (raro mas válido pela SEFAZ). Trate
`productInvoices: []` como caso normal — não como erro.

### Estou recebendo `input_event_raised_successfully` para uma operação minha. Por quê?

Significa que **o destinatário** (não você) registrou uma manifestação
sobre a operação. Se você é o destinatário das NF-es recebidas via
Inbound, isso reflete suas próprias manifestações sendo replicadas no
webhook (ou de seu sistema, ou via painel nfe.io). Ver §4.3.

### Não recebo webhooks. Checklist:

1. URL configurada no painel nfe.io → Empresas → Webhooks? Pode levar
   alguns minutos para propagar após mudança.
2. Inbound ativo para a empresa? Ver `02-doc-tecnica-clientes-nfe-cte-dev-pt.md`
   §6 "Ativando o Serviço de Inbound".
3. `WebhookVersion: 2` configurado na ativação? V1 usa formato diferente
   e está deprecado.
4. Endpoint retornando 200 em até 30s? Status 4xx (exceto 408/429) marca
   falha definitiva e **não** reenvia.
5. NSU pendente? Existe gap de NSUs na fila? Verifique via
   `GET .../inbound/productinvoices?nsuInicial=...` (ver doc API).

### Chamei `/xml` mas o response não tem o XML — só uma URL. É bug?

Não. Diferente do NFS-e (que faz `302 Found` redirect), o endpoint
`/xml` de NF-e/CT-e retorna `200 OK` com JSON `{ "publicTemporaryUri":
"<URL temporária do blob>" }`. Faça um segundo `GET` na
`publicTemporaryUri` (sem header `Authorization`) para baixar o XML.
TTL ~10 min — não cacheie. Ver §8.

### Como diferencio resumo vs documento completo?

Pelo par `(body.action, body.type)` no envelope. `body.action` é o
`eventAction` da rota interna; `body.type` é o campo do payload
(ver §3 e §4/§5):

| `body.action` | `body.type` | Significado |
|---|---|---|
| `issued_successfully` | `productInvoice` | NF-e completa |
| `event_raised_successfully` | `productInvoiceEvent` | Evento NF-e completo |
| `input_event_raised_successfully` | `productInvoiceEvent` | Manifestação do destinatário |
| `issued_successfully` | `productInvoiceSummary` | Resumo NF-e |
| `event_raised_successfully` | `productInvoiceEventSummary` | Resumo evento NF-e |
| `issued_successfully` | `transportationInvoice` | CT-e completo |
| `event_raised_successfully` | `transportationInvoiceEvent` | Evento CT-e |

Use **`body.action`** para diferenciar a **ação** (emissão, evento,
manifestação) e **`body.type`** para diferenciar a **variante** do
documento (NF-e/CT-e, completa/resumo, documento/evento).

> O `eventType` da rota interna (`product_invoice_inbound`,
> `product_invoice_inbound_summary`, `transportation_invoice_inbound`)
> determina qual webhook do painel é disparado, mas **não é necessariamente
> propagado** dentro de `body`. Não dependa dele para parsing — use
> `body.type`.

---

## 13. Anatomia da chave de acesso (NF-e/NFC-e e CT-e)

A `accessKey` que aparece nos payloads de webhook é uma identificação
única atribuída pela SEFAZ a cada documento fiscal e a cada evento.
Esta seção descreve a composição posicional para você decodificar
campos diretamente da chave (sem precisar parsear o XML).

> 📚 **Fonte oficial.** As regras abaixo seguem o Manual de Orientação do
> Contribuinte (MOC) **NF-e 7.0**, item 5.4, e o MOC **CT-e 4.00**, item
> 2.1.4. Para NFS-e Nacional, ver o §11 do webhook NFS-e
> `02-doc-tecnica-clientes-dev-nfse-inbound-webhook.md`.

### 13.1. NF-e (modelo 55) e NFC-e (modelo 65) — 44 dígitos

```
35 2605 57622466000146 55 001 001132098 1 99999999 3
└─┬┘└─┬─┘└─────┬──────┘└┬┘└┬┘└────┬────┘└┬┘└───┬────┘└┬┘
  │   │       │        │  │      │      │     │      │
 cUF AAMM   CNPJ      mod sér   nNF   tpEmis cNF    cDV
```

| Pos. | Tam. | Campo | Descrição |
|---|---|---|---|
| 1–2 | 2 | `cUF` | Código IBGE da UF do emitente. Ex.: `35` = SP, `33` = RJ, `41` = PR. |
| 3–6 | 4 | `AAMM` | Ano (2 dígitos) + mês (2 dígitos) de emissão. Ex.: `2605` = maio/2026. |
| 7–20 | 14 | `CNPJ` do emitente | CNPJ com 14 dígitos (sem máscara). |
| 21–22 | 2 | `mod` | Modelo do documento: `55` = NF-e, `65` = NFC-e. |
| 23–25 | 3 | `serie` | Série do documento (0–999, com zeros à esquerda). |
| 26–34 | 9 | `nNF` | Número sequencial da NF-e/NFC-e. |
| 35 | 1 | `tpEmis` | Forma de emissão: `1` Normal, `2` Contingência FS-IA, `3` SCAN, `4` EPEC, `5` FS-DA, `6` SVC-AN, `7` SVC-RS, `9` Contingência off-line NFC-e. |
| 36–43 | 8 | `cNF` | Código numérico aleatório (8 dígitos). |
| 44 | 1 | `cDV` | Dígito verificador (módulo 11). |

**Exemplo decodificado** (chave `35260557622466000146550010011320981999999993`,
extraída do exemplo da §4.1):

| Campo | Valor | Leitura |
|---|---|---|
| `cUF` | `35` | São Paulo |
| `AAMM` | `2605` | Maio/2026 |
| `CNPJ` | `57622466000146` | Emitente |
| `mod` | `55` | NF-e (modelo 55) |
| `serie` | `001` | Série 1 |
| `nNF` | `001132098` | Número 1.132.098 |
| `tpEmis` | `1` | Emissão normal |
| `cNF` | `99999999` | Código aleatório |
| `cDV` | `3` | DV |

### 13.2. Chave do evento NF-e — 51 dígitos (sem prefixo `ID`)

Para eventos de NF-e/NFC-e (cancelamento, CC-e, EPEC, registros do
fisco, manifestações do destinatário), o NFE.io entrega no campo
`accessKey` uma chave de **51 dígitos**, gerada como:

```
{tpEvento}{chaveNFe}{nSeqEvento}
```

| Pos. | Tam. | Campo | Descrição |
|---|---|---|---|
| 1–6 | 6 | `tpEvento` | Código do evento SEFAZ (6 dígitos). Tabela em §6.1. Ex.: `110110` (CC-e), `110111` (cancelamento), `210210` (ciência). |
| 7–50 | 44 | `chaveNFe` | Chave de acesso da NF-e referenciada (§13.1). |
| 51 | 1 | `nSeqEvento` | Sequencial do evento para a mesma NF-e (1–99, normalmente `1`). |

> ⚠️ **Sem o prefixo `ID`.** No XML SEFAZ o atributo `infEvento/@Id` traz
> `"ID" + tpEvento + chNFe + nSeqEvento` (53 caracteres). O NFE.io
> **remove** o prefixo `ID` ao gravar o `EventId` na base e ao serializar
> o webhook (ver `NFeService.cs` linhas 1798–1799 — `nfeEventMetadata.EventId = $"{chNfe}{nfeEventMetadata.Sequence}"`).
> O `parentAccessKey` no mesmo payload contém apenas a `chaveNFe`
> (44 dígitos). Para o CT-e o prefixo `ID` é preservado — ver §13.4.

**Exemplo decodificado**
(chave `110111412605999999999999995500200059985119999999941`, extraída do
exemplo de cancelamento da §4.2):

| Campo | Valor | Leitura |
|---|---|---|
| `tpEvento` | `110111` | Cancelamento de NF-e |
| `chaveNFe` | `41260599999999999999550020005998511999999994` | NF-e referenciada (PR/maio/2026/...) |
| `nSeqEvento` | `1` | Primeiro evento de cancelamento |

### 13.3. CT-e (modelo 57) — 44 dígitos

A composição é idêntica à da NF-e, com `mod=57`:

```
35 2605 42584754000267 57 002 319939022 1 99999999 6
└─┬┘└─┬─┘└─────┬──────┘└┬┘└┬┘└────┬────┘└┬┘└───┬────┘└┬┘
  │   │       │        │  │      │      │     │      │
 cUF AAMM   CNPJ      mod sér   nCT   tpEmis cCT    cDV
```

| Pos. | Tam. | Campo | Descrição |
|---|---|---|---|
| 1–2 | 2 | `cUF` | Código IBGE da UF do emitente. |
| 3–6 | 4 | `AAMM` | Ano + mês de emissão. |
| 7–20 | 14 | `CNPJ` do emitente | CNPJ do prestador do serviço de transporte. |
| 21–22 | 2 | `mod` | Sempre `57` para CT-e. (`67` = CT-e OS, `63` = CT-e GTV — não tratados por este webhook.) |
| 23–25 | 3 | `serie` | Série do documento. |
| 26–34 | 9 | `nCT` | Número sequencial do CT-e. |
| 35 | 1 | `tpEmis` | Forma de emissão: `1` Normal, `4` EPEC, `5` FS-DA, `7` SVC-RS, `8` SVC-SP. |
| 36–43 | 8 | `cCT` | Código numérico aleatório. |
| 44 | 1 | `cDV` | Dígito verificador (módulo 11). |

**Exemplo decodificado**
(chave `35260542584754000267570023199390221999999996`, extraída do
exemplo da §5.1):

| Campo | Valor | Leitura |
|---|---|---|
| `cUF` | `35` | São Paulo |
| `AAMM` | `2605` | Maio/2026 |
| `CNPJ` | `42584754000267` | Prestador do transporte |
| `mod` | `57` | CT-e |
| `serie` | `002` | Série 2 |
| `nCT` | `319939022` | Número 319.939.022 |
| `tpEmis` | `1` | Normal |

### 13.4. Chave do evento CT-e — 55 caracteres (com prefixo `ID`)

```
ID{tpEvento}{chaveCTe}{nSeqEvento}
```

| Pos. | Tam. | Campo | Descrição |
|---|---|---|---|
| 1–2 | 2 | Prefixo literal | `ID` (preservado do XML SEFAZ). |
| 3–8 | 6 | `tpEvento` | Código do evento (ver §6.2). Ex.: `110111` (cancelamento), `110110` (CC-e CT-e), `610110` (prestação em desacordo). |
| 9–52 | 44 | `chaveCTe` | Chave de acesso do CT-e referenciado (§13.3). |
| 53–55 | 3 | `nSeqEvento` | Sequencial do evento (1–999), com zeros à esquerda. |

> ⚠️ **Diferença em relação ao evento NF-e**: o CT-e **preserva** o
> prefixo `ID` e usa 3 dígitos para o sequencial; o evento NF-e remove o
> prefixo e usa 1 dígito (ver §13.2).

**Exemplo decodificado**
(chave `ID11011135260555442952000157570010168676041999999990001`,
extraída do exemplo de cancelamento da §5.2):

| Campo | Valor | Leitura |
|---|---|---|
| Prefixo | `ID` | Literal |
| `tpEvento` | `110111` | Cancelamento de CT-e |
| `chaveCTe` | `35260555442952000157570010168676041999999990` | CT-e referenciado |
| `nSeqEvento` | `001` | Primeiro evento |

### 13.5. Validação do DV (módulo 11)

O último dígito (`cDV`) é calculado com módulo 11 base 2-9 sobre os
43 dígitos anteriores. Algoritmo de referência (MOC NF-e 7.0 §5.4 / MOC
CT-e 4.00 §7.3):

```python
def calc_dv(chave43: str) -> str:
    weights = (2, 3, 4, 5, 6, 7, 8, 9)
    total = sum(int(d) * weights[i % 8] for i, d in enumerate(reversed(chave43)))
    dv = 11 - (total % 11)
    return "0" if dv >= 10 else str(dv)

# Uso:
chave = "35260557622466000146550010011320981999999993"
assert calc_dv(chave[:-1]) == chave[-1]
```

Use esse cálculo para validar integridade da chave antes de persistir no
seu sistema. Se o DV não bater, o payload provavelmente sofreu corrupção
no transporte — rejeite e force reprocessamento via §9.
