# Webhooks

Em vez de consultar `GET /nfe/{ref}` até a nota ficar pronta, você cadastra um endereço do seu sistema e a plataforma envia um `POST` **assinado** a cada mudança de estado das notas. É o jeito recomendado de saber do resultado da SEFAZ.

Webhooks avisam das notas emitidas **por esta API** (as que têm `ref`), das empresas da sua conta. A consulta continua sendo a fonte da verdade: use-a para conferir e para recuperar qualquer aviso que o seu sistema tenha perdido.

| Método | Rota | Para quê |
|---|---|---|
| `POST` | `/webhooks` | cadastrar um endereço |
| `GET` | `/webhooks` · `/webhooks/{id}` | listar · detalhar (o segredo nunca volta) |
| `PATCH` | `/webhooks/{id}` | alterar `url`, `eventos`, `cnpj_emitente`, `descricao`, `ativo` |
| `DELETE` | `/webhooks/{id}` | remover, junto com o histórico de entregas (`204`) |
| `POST` | `/webhooks/{id}/segredo` | gerar um segredo novo |
| `POST` | `/webhooks/{id}/teste` | enviar agora um evento de teste |
| `GET` | `/webhooks/{id}/entregas` | histórico de entregas (`?status=pendente\|entregue\|falhou`) |
| `POST` | `/webhooks/{id}/entregas/{entrega}/reenviar` | reenviar um evento |

## Cadastrar

<!-- exemplo:webhook -->
```json
{
  "url": "https://erp.exemplo.com.br/hooks/nfe",
  "eventos": ["nfe.autorizada", "nfe.rejeitada", "nfe.cancelada"],
  "descricao": "ERP producao"
}
```

| Campo | Regra |
|---|---|
| `url` | obrigatória, até 500 caracteres. **Só `https`**, sem usuário e senha na URL, e o endereço tem de resolver para **IP público** ([segurança](#seguranca-do-endereco)) |
| `eventos` | lista dos eventos que você quer. Omitida ou `null` = **todos** |
| `cnpj_emitente` | restringe a uma empresa. Omitido ou `null` = todas as empresas da conta |
| `descricao` | texto livre para você se organizar |
| `ativo` | `false` pausa as entregas (no `PATCH`) |

Resposta `201`. **O `segredo` só aparece agora**: guarde-o, é com ele que você confere a assinatura de cada chamada.

```json
{
  "segredo": "whsec_t6dAiRDmbFjc54TPAmTdOVqVsDSq3N9njQ2dlED4",
  "id": "88507252-a869-4c69-80ad-6e9dbc5f2d84",
  "url": "https://erp.exemplo.com.br/hooks/nfe",
  "eventos": ["nfe.autorizada", "nfe.cancelada", "nfe.rejeitada"],
  "cnpj_emitente": null,
  "descricao": "ERP producao",
  "ativo": true,
  "criado_em": "2026-10-04T15:50:12+00:00"
}
```

Cada conta pode ter até **10** endereços (`422 limite_de_webhooks`). Perdeu o segredo? `POST /webhooks/{id}/segredo` gera outro e invalida o antigo na hora.

## Os eventos

| Evento | Quando | `dados` |
|---|---|---|
| `nfe.autorizada` | a SEFAZ autorizou a nota | a nota |
| `nfe.rejeitada` | a SEFAZ rejeitou (`status_sefaz` e `mensagem_sefaz` dizem por quê) | a nota |
| `nfe.pendente` | a nota parou por uma pendência de cálculo ou cadastro depois de aceita | a nota |
| `nfe.denegada` | uso denegado | a nota |
| `nfe.cancelada` | o cancelamento foi registrado | a nota |
| `nfe.cancelamento_recusado` | a SEFAZ recusou o cancelamento (a nota continua autorizada) | a nota; `evento_id` aponta o cancelamento em `dados.eventos` |
| `nfe.cce_registrada` · `nfe.cce_recusada` | resultado da carta de correção | a nota; `evento_id` aponta a carta em `dados.eventos` |
| `nfe.inutilizada` | o número de uma nota foi inutilizado (um aviso por nota afetada) | a nota |
| `inutilizacao.registrada` · `inutilizacao.recusada` | resultado do pedido de inutilização | a inutilização (como em `GET /nfe/inutilizacoes`) |
| `webhook.teste` | só pelo `POST /webhooks/{id}/teste`; não se assina, chega sempre | `{ "mensagem": "..." }` |

## O que chega ao seu endereço

Um `POST` com `Content-Type: application/json`. Nos eventos `nfe.*`, o `dados` é **a nota inteira**, no mesmo formato de `GET /nfe/{ref}`:

```json
{
  "id": "cb71def7-68e6-463e-82b0-df2b4605120f",
  "evento": "nfe.autorizada",
  "criado_em": "2026-10-04T15:50:12+00:00",
  "cnpj_emitente": "11222333000181",
  "ref": "PED-1007",
  "dados": {
    "cnpj_emitente": "11222333000181",
    "ref": "PED-1007",
    "ambiente": "homologacao",
    "status": "autorizado",
    "numero": "7",
    "serie": "1",
    "chave_nfe": "35261011222333000181550010000000071445505082",
    "protocolo_sefaz": "135260000000001",
    "status_sefaz": "100",
    "mensagem_sefaz": "Autorizado o uso da NF-e",
    "mensagem": null,
    "valor_total": 115,
    "autorizada_em": "2026-10-04T15:50:12+00:00",
    "caminho_xml_nota_fiscal": "https://srv1839254.hstgr.cloud/api/v2/nfe/PED-1007/xml?cnpj_emitente=11222333000181",
    "caminho_danfe": "https://srv1839254.hstgr.cloud/api/v2/nfe/PED-1007/danfe?cnpj_emitente=11222333000181",
    "eventos": []
  }
}
```

| Campo do envelope | Significado |
|---|---|
| `id` | identificador do **evento**. É o mesmo em todas as tentativas e em todos os reenvios: **deduplique por ele** |
| `evento` | o tipo, da tabela acima |
| `criado_em` | quando o evento foi gerado |
| `cnpj_emitente`, `ref` | a empresa e a nota (`ref` não vem nos eventos de inutilização) |
| `evento_id` | nos eventos de carta de correção e de cancelamento recusado, o `id` do evento em `dados.eventos` |
| `dados` | o conteúdo do evento |

Cabeçalhos:

| Cabeçalho | Valor |
|---|---|
| `X-Webhook-Id` | igual ao `id` do corpo |
| `X-Webhook-Event` | o tipo do evento |
| `X-Webhook-Timestamp` | instante do envio, em segundos Unix |
| `X-Webhook-Signature` | `t=<timestamp>,v1=<assinatura>` |
| `User-Agent` | `EmissorNFe-Webhooks/1.0` |

## Confira a assinatura (sempre)

Qualquer um que descubra a URL do seu webhook pode mandar um `POST` para ela. **Só a assinatura prova que a chamada veio da plataforma.** Antes de confiar no corpo:

1. Leia `t` e `v1` do cabeçalho `X-Webhook-Signature`.
2. Calcule `HMAC-SHA256(segredo, "<t>.<corpo bruto>")` em **hexadecimal** (o `<t>` é o texto do cabeçalho, um ponto, e o corpo exatamente como chegou).
3. Compare com `v1` em **tempo constante** (nunca com `==`).
4. Recuse se `t` estiver a mais de **5 minutos** de agora (isso barra quem grava e repete uma chamada antiga).

Use o **corpo bruto**, antes de decodificar o JSON: um espaço diferente muda a assinatura.

**PHP**

```php
$segredo = getenv('WEBHOOK_SECRET');               // whsec_...
$corpo = file_get_contents('php://input');
$cabecalho = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';

parse_str(str_replace(',', '&', $cabecalho), $partes);   // t=...&v1=...
$t = (int) ($partes['t'] ?? 0);
$esperada = hash_hmac('sha256', $t.'.'.$corpo, $segredo);

if (abs(time() - $t) > 300 || ! hash_equals($esperada, $partes['v1'] ?? '')) {
    http_response_code(401);
    exit;
}
$evento = json_decode($corpo, true);
// ...processe e responda 2xx rápido
http_response_code(200);
```

**Node.js (Express)**

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

// raw: precisamos do corpo exatamente como chegou
app.post('/hooks/nfe', express.raw({ type: 'application/json' }), (req, res) => {
  const [t, v1] = req.get('X-Webhook-Signature').split(',').map((p) => p.split('=')[1]);
  const esperada = crypto.createHmac('sha256', process.env.WEBHOOK_SECRET).update(`${t}.${req.body.toString('utf8')}`).digest('hex');
  const ok = Math.abs(Date.now() / 1000 - Number(t)) <= 300
    && v1.length === esperada.length && crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(esperada));
  if (!ok) return res.sendStatus(401);

  const evento = JSON.parse(req.body.toString('utf8'));
  // ...processe e responda 2xx rápido
  res.sendStatus(200);
});
```

**Python (Flask)**

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

app = Flask(__name__)

@app.post('/hooks/nfe')
def webhook():
    corpo = request.get_data()                                  # bytes, como chegou
    partes = dict(p.split('=', 1) for p in request.headers.get('X-Webhook-Signature', '').split(','))
    t = int(partes.get('t', 0))
    esperada = hmac.new(os.environ['WEBHOOK_SECRET'].encode(), f'{t}.'.encode() + corpo, hashlib.sha256).hexdigest()
    if abs(time.time() - t) > 300 or not hmac.compare_digest(esperada, partes.get('v1', '')):
        abort(401)
    evento = request.get_json()
    # ...processe e responda 2xx rápido
    return '', 200
```

**Vetor de teste** para o seu verificador: com o segredo `whsec_exemplo_de_teste`, `t = 1700000000` e este corpo (sem quebra de linha no fim)

```json
{"id":"11111111-1111-1111-1111-111111111111","evento":"webhook.teste","criado_em":"2023-11-14T22:13:20+00:00","dados":{"mensagem":"Evento de teste do emissor de NF-e."}}
```

a assinatura correta é:

```text
902528f43d51b4707756145ad95c0b38b3890f25a580ab1a274ec7d7a428ee4d
```

ou seja, o cabeçalho `X-Webhook-Signature: t=1700000000,v1=902528f43d51b4707756145ad95c0b38b3890f25a580ab1a274ec7d7a428ee4d`. (No teste, ignore a checagem dos 5 minutos, já que o `t` é antigo.)

## Como responder e o que acontece nas falhas

- **Responda `2xx` em até 10 segundos** e processe o evento depois (numa fila sua). Qualquer outra coisa conta como falha: erro `4xx`/`5xx`, tempo esgotado, falha de conexão e **redirecionamento** (`3xx`, que **não é seguido**).
- Em caso de falha a entrega é **repetida com espera crescente**: 30 segundos, 2 minutos, 10 minutos, 30 minutos, 1 hora, 3 horas, 6 horas e 12 horas. São **9 tentativas em cerca de 21 horas**. Esgotadas, a entrega fica `falhou` e você pode reenviá-la.
- Todas as tentativas levam o **mesmo `id`**: o seu sistema deve ser **idempotente** (se já processou aquele `id`, responda `200` e ignore).
- **Não há garantia de ordem** entre eventos diferentes (um `nfe.cancelada` pode chegar antes de um `nfe.autorizada` atrasado). Quando o estado atual importar, use `criado_em` ou consulte `GET /nfe/{ref}`.
- Pode haver **mais de um evento** por nota (rejeitada, depois autorizada; autorizada, depois cancelada).

### Histórico e reenvio

`GET /webhooks/{id}/entregas` mostra o que foi enviado e como foi:

```json
{
  "data": [
    { "id": 2, "evento": "webhook.teste", "evento_id": "038dbca4-548b-4d4e-8352-734bd237f94a", "status": "entregue", "tentativas": 1,
      "proxima_tentativa_em": null, "ultimo_status_http": 200, "ultimo_erro": null, "ultima_resposta": "ok",
      "entregue_em": "2026-10-04T15:50:12+00:00", "criado_em": "2026-10-04T15:50:12+00:00" }
  ],
  "links": { "first": "...?page=1", "last": "...?page=1", "prev": null, "next": null },
  "meta": { "current_page": 1, "last_page": 1, "per_page": 25, "total": 1 }
}
```

`status`: `pendente` (aguardando a próxima tentativa), `entregue` ou `falhou`. `ultimo_status_http`, `ultimo_erro` e o começo de `ultima_resposta` ajudam a diagnosticar o seu lado.

`POST /webhooks/{id}/entregas/{entrega}/reenviar` cria uma **nova entrega com o mesmo evento** (o mesmo `evento_id`), por exemplo depois de você corrigir o que fazia a entrega falhar.

`POST /webhooks/{id}/teste` envia um `webhook.teste` agora: o jeito mais rápido de validar o endereço e o seu código de conferência. A resposta é a entrega, já com o resultado (`entregue` ou o erro).

## Segurança do endereço

Como a plataforma chama um endereço **que você escolhe**, ela impõe regras para não poder ser usada contra redes internas. São checadas no cadastro **e em cada entrega** (um DNS pode mudar depois do cadastro):

- só `https` (sem `http`);
- sem usuário e senha na URL (a autenticidade se confere pela assinatura);
- o endereço precisa resolver **só para IPs públicos** e ter ao menos um IPv4. Redes privadas (`10.x`, `172.16-31.x`, `192.168.x`), `localhost`, endereços de link-local e de metadados de nuvem são recusados;
- a conexão é fixada no IP validado e **redirecionamentos não são seguidos**.

Por isso **não dá para apontar o webhook para `localhost`** em desenvolvimento. Use um túnel (ngrok, Cloudflare Tunnel...) com endereço `https` público para receber os eventos na sua máquina.

O histórico de entregas é guardado por 30 dias.
