> ## Documentation Index
> Fetch the complete documentation index at: https://docs.invo.work/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Eventos nfe_*, verificação de assinatura HMAC-SHA256 e semântica de entrega.

Webhooks são entregues por endpoint configurado na empresa, assinados com **HMAC-SHA256**, de forma **assíncrona** — a entrega HTTP nunca bloqueia a requisição nem a transição de estado.

<Note>
  Webhooks cobrem a **NF-e** (eventos `nfe_*`). A **NFS-e** é acompanhada por polling em `GET /notas/{id}` — veja o [Ciclo de vida](/ciclo-de-vida).
</Note>

## Verificação de assinatura

Cada entrega traz dois cabeçalhos:

| Cabeçalho             | Conteúdo                                          |
| --------------------- | ------------------------------------------------- |
| `X-Webhook-Signature` | O HMAC-SHA256 em hexadecimal.                     |
| `X-Webhook-Timestamp` | Instante da assinatura (epoch Unix, em segundos). |

A string canônica assinada é o `X-Webhook-Timestamp`, um ponto (`.`) e o **corpo bruto** do POST, concatenados:

```
"{timestamp}.{body}"
```

Recompute o HMAC-SHA256 dessa string com a `chave_seguranca` do endpoint e compare com `X-Webhook-Signature` usando uma **comparação de tempo constante** (ex.: `Rack::Utils.secure_compare`) — nunca uma igualdade simples de string, que vaza informação por timing. O timestamp na string assinada também permite rejeitar entregas antigas (proteção contra replay).

```ruby theme={null}
require "openssl"
require "rack/utils"

def webhook_valido?(corpo_bruto, timestamp, assinatura, chave_seguranca)
  esperado = OpenSSL::HMAC.hexdigest("SHA256", chave_seguranca, "#{timestamp}.#{corpo_bruto}")
  Rack::Utils.secure_compare(esperado, assinatura)
end
```

## Semântica de entrega

A entrega é **não ordenada** e **at-least-once** (pelo menos uma vez): há um job independente por endpoint, com retries independentes. Portanto:

* **a ordem entre eventos não é garantida** (um `nfe_autorizada` rápido pode chegar antes de um `nfe_solicitacao_autorizacao` que sofreu retry);
* **o mesmo evento pode ser entregue mais de uma vez**.

Cada payload carrega `event_id` (UUID estável entre retries) e `occurred_at` (instante do evento). Os consumidores **devem**:

<Steps>
  <Step title="Deduplicar por event_id">
    Ignore um `event_id` já processado.
  </Step>

  <Step title="Ignorar eventos mais antigos">
    Descarte eventos com `occurred_at` anterior ao último já aplicado para aquela nota.
  </Step>
</Steps>

## Tipos de evento (`nfe_*`)

| Evento                         | Quando dispara                                     |
| ------------------------------ | -------------------------------------------------- |
| `nfe_solicitacao_autorizacao`  | nota criada, autorização enfileirada               |
| `nfe_autorizada`               | veredito terminal: autorizada pela SEFAZ           |
| `nfe_rejeitada`                | veredito terminal: rejeitada pela SEFAZ            |
| `nfe_denegada`                 | veredito terminal: denegada pela SEFAZ             |
| `nfe_solicitacao_cancelamento` | pedido de cancelamento enviado                     |
| `nfe_cancelamento_autorizado`  | cancelamento aceito pela SEFAZ                     |
| `nfe_cancelamento_rejeitado`   | cancelamento rejeitado (a nota segue `autorizada`) |
| `nfe_inutilizada`              | faixa/nota inutilizada com sucesso                 |

## `nfe_inutilizada` tem dois formatos de payload

O evento `nfe_inutilizada` cobre os dois modos de inutilização, com payloads distintos. **Diferencie pelo campo `modo`** (`"nota"` ou `"faixa"`) — todos os payloads carregam `modo`, então você nunca precisa inferir o formato pela presença de campos:

* **Modo nota** (`"modo": "nota"`) — carrega `uuid` e `chave_acesso` (uma nota específica).
* **Modo faixa** (`"modo": "faixa"`) — carrega `numero_inicial` e `numero_final` (uma faixa de numeração, sem nota associada).

<CodeGroup>
  ```json Payload de nota theme={null}
  {
    "event_id": "5f3c0c2e-1b9a-4e8c-9a1d-2b6f0c7d8e90",
    "occurred_at": "2026-06-08T13:20:31Z",
    "modo": "nota",
    "sandbox": false,
    "uuid": "8e6d9a2b-4c1f-4a7e-bb3d-1f2a9c0d4e55",
    "chave_acesso": "23260612345678000190550010000000011000000017",
    "numero": 1,
    "serie": 1,
    "situacao": "autorizada",
    "protocolo": "123456789012345",
    "xml_url": "https://invo.work/api/nfe/v1/notas/8e6d.../xml",
    "danfe_url": "https://invo.work/api/nfe/v1/notas/8e6d.../danfe"
  }
  ```

  ```json Payload de faixa (nfe_inutilizada) theme={null}
  {
    "event_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
    "occurred_at": "2026-06-08T13:25:10Z",
    "modo": "faixa",
    "sandbox": false,
    "serie": 1,
    "numero_inicial": 10,
    "numero_final": 12,
    "protocolo": "123456789012345",
    "justificativa": "Numeração saltada por falha no sistema de origem"
  }
  ```
</CodeGroup>

<Note>
  As URLs de `xml_url` / `danfe_url` são as rotas autenticadas da API (Basic auth), nunca links públicos a documentos fiscais.
</Note>
