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

# Webhooks

> Receba eventos fiscais em tempo real na sua URL, sem polling, com assinatura HMAC e retentativas automáticas.

Webhooks são chamadas HTTP que a engineAPI faz para **sua URL** quando um evento acontece (nota autorizada, rejeitada, certificado vencendo). Não é preciso fazer polling.

<Info>
  Cada partner tem **uma única configuração** de webhook (uma URL + uma lista de eventos),
  não múltiplos webhooks cadastráveis. Configure via `PATCH /v1/webhooks/config`.
</Info>

***

## Configurando um Webhook

```bash theme={null}
curl -X PATCH https://api.engineapi.com.br/v1/webhooks/config \
  -H "x-api-key: SUA_CHAVE" \
  -H "Content-Type: application/json" \
  -d '{
    "webhookUrl": "https://seusite.com/webhooks/engine",
    "events": ["invoice.authorized", "invoice.rejected", "certificate.expiring"]
  }'
```

```typescript theme={null}
await fetch('https://api.engineapi.com.br/v1/webhooks/config', {
  method: 'PATCH',
  headers: {
    'x-api-key': apiKey,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    webhookUrl: 'https://seusite.com/webhooks/engine',
    events: ['invoice.authorized', 'invoice.rejected', 'certificate.expiring'],
  }),
});
```

<Warning>
  Não há campo `secret` no `PATCH /webhooks/config`: o secret HMAC é gerado pela API
  (ver [Onde pego o secret](#onde-pego-o-secret) abaixo), nunca fornecido por você.
</Warning>

Consultar a configuração atual:

```bash theme={null}
curl https://api.engineapi.com.br/v1/webhooks/config \
  -H "x-api-key: SUA_CHAVE"
```

## Quem configura o webhook

A **mesma `x-api-key`** com que você emite documentos (NF-e, NFC-e, NFS-e) basta para
consultar, configurar e testar o webhook: `GET`/`PATCH /v1/webhooks/config` e
`POST /v1/webhooks/test` aceitam `x-api-key` de integração, além do login do dashboard
(JWT). Não é preciso um token separado nem um convite específico para webhooks.

<Warning>
  **Configurar e rotacionar o secret exige chave `ek_live_`** (ou o login do dashboard).
  Uma chave `ek_test_` recebe `403 AMBIENTE_DE_TESTE_SEM_ESCRITA_WEBHOOK` em
  `PATCH /v1/webhooks/config`, `POST /v1/webhooks/secret/regenerate` e no retry/purge da
  DLQ — o secret é único por parceiro, não por ambiente, e uma integração de
  homologação rotacionando o secret invalidaria a assinatura da sua produção.
  Leitura (`GET /config`, `GET /logs`, `GET /dlq`) e `POST /test` continuam abertos pra
  `ek_test_`.
</Warning>

O secret HMAC (`whsec_...`) é gerado pela engineAPI no momento em que você configura a
`webhookUrl` pela primeira vez. Ele aparece **completo uma única vez** (padrão
Stripe/GitHub) — guarde-o assim que a resposta chegar; veja
[Onde pego o secret](#onde-pego-o-secret) abaixo.

***

## Eventos disponíveis

Qualquer valor fora desta lista em `events[]` é rejeitado com `400`:

| Evento                 | Quando dispara                                                 |
| ---------------------- | -------------------------------------------------------------- |
| `invoice.authorized`   | NF-e ou NFC-e autorizada pela SEFAZ                            |
| `invoice.rejected`     | NF-e ou NFC-e rejeitada pela SEFAZ                             |
| `invoice.canceled`     | NF-e ou NFC-e cancelada                                        |
| `mdfe.authorized`      | MDF-e autorizado                                               |
| `mdfe.closed`          | MDF-e encerrado                                                |
| `cte.authorized`       | CT-e autorizado                                                |
| `cte.canceled`         | CT-e cancelado                                                 |
| `cte.cce`              | Carta de Correção de CT-e                                      |
| `dfe.received`         | Novo documento recebido na distribuição (DF-e)                 |
| `certificate.expiring` | Certificado a 30/15/7/3/1 dia(s) de expirar                    |
| `sefaz.status.changed` | Mudança real de status de uma UF na SEFAZ (up→down ou down→up) |

<Warning>
  `invoice.correction` e `nfse.authorized` **não existem** neste enum: CC-e de NF-e não
  dispara webhook próprio hoje, e NFS-e autorizada entra em `invoice.authorized` (com
  `model: "NFSe"` no `data`). Não assine eventos fora desta tabela.
</Warning>

<Note>
  Os eventos `cte.*` e `mdfe.*` existem no enum, mas CT-e e MDF-e estão fora da superfície
  pública da engineAPI hoje (roadmap, sem previsão de data; ver [CT-e](/guides/cte)
  e [MDF-e](/guides/mdfe)). Na prática, esses eventos são inalcançáveis: não há
  endpoint público para emitir um CT-e ou MDF-e que os dispare.
</Note>

<Note>
  `dfe.received` também existe no enum, mas o módulo DF-e está fora do contrato público
  hoje: não há endpoint público para configurar a distribuição de DF-e que dispararia
  este evento. Não assine até a funcionalidade entrar na superfície pública.
</Note>

***

## Estrutura do Payload

O campo é **`type`**, não `event`:

```json theme={null}
{
  "id": "9f1c2b3a-4c5d-4e6f-8a9b-0c1d2e3f4a5b",
  "type": "invoice.authorized",
  "timestamp": "2026-04-26T23:00:00.000Z",
  "data": {
    "invoiceId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "model": "55",
    "number": 1,
    "series": 1,
    "accessKey": "35260211222333000181550010000000011000000019",
    "status": "AUTHORIZED",
    "amount": "119.8"
  }
}
```

<Warning>
  `amount` é **string decimal** (`"119.8"`), nunca `number` cru: evita imprecisão de
  ponto flutuante em valor monetário. Não trate como número sem converter explicitamente
  no seu código. A resposta HTTP de `POST /v1/nfe` usa o mesmo formato.
</Warning>

`id` é um UUID cru, sem prefixo `evt_`.

No `invoice.rejected`, o `data` traz `status: "REJECTED"` e o campo `erros[]`
com o retorno **verbatim** da SEFAZ/SEFIN (mesmo shape do `400` da emissão
síncrona); ramifique pelo `codigo`. Na NFS-e, o identificador em `data` é
`nfseId` (não `invoiceId`) e `model: "NFSe"`:

```json theme={null}
{
  "id": "9f1c2b3a-...-uuid",
  "type": "invoice.rejected",
  "timestamp": "2026-07-05T11:06:14.453Z",
  "data": {
    "invoiceId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "model": "55",
    "number": 321,
    "series": 1,
    "accessKey": null,
    "status": "REJECTED",
    "amount": 10,
    "erros": [
      {
        "codigo": "696",
        "descricao": "Rejeicao: Obrigatoria as informacoes do responsavel tecnico pela emissao do DF-e"
      }
    ]
  }
}
```

<Info>
  O campo `id` do evento é único e pode ser usado para **garantir idempotência**. Se o mesmo evento chegar duas vezes, ignore a duplicata.
</Info>

***

## `sefaz.status.changed`: status real da SEFAZ por UF

A engineAPI monitora as 27 UFs com certificado digital próprio (não depende do seu
certificado) a cada 5 minutos. Quando uma UF **muda de estado de verdade** (a SEFAZ
respondeu que ficou indisponível, ou voltou a operar), o evento dispara para quem
assinou. Nunca dispara numa consulta isolada, só na **transição**.

```json theme={null}
{
  "id": "9f1c2b3a-...-uuid",
  "type": "sefaz.status.changed",
  "timestamp": "2026-07-13T14:05:00.000Z",
  "data": {
    "uf": "GO",
    "status": "down",
    "cStat": 108,
    "xMotivo": "Serviço Paralisado Temporariamente",
    "ambiente": "producao",
    "checkedAt": "2026-07-13T14:05:00.000Z"
  }
}
```

| Campo       | Descrição                                                                            |
| ----------- | ------------------------------------------------------------------------------------ |
| `uf`        | UF autorizadora que mudou de estado                                                  |
| `status`    | `"up"` ou `"down"`, sempre um estado real confirmado pela SEFAZ, nunca fabricado     |
| `cStat`     | Código de status cru da SEFAZ (verbatim, ex.: `107` operacional, `108`/`109` parado) |
| `xMotivo`   | Mensagem cru da SEFAZ (verbatim)                                                     |
| `ambiente`  | `"producao"` ou `"homologacao"`, ambiente monitorado                                 |
| `checkedAt` | Timestamp ISO8601 da consulta que detectou a transição                               |

<Warning>
  Falha **nossa** ao consultar (rede, timeout, certificado de monitoramento vencido)
  nunca vira `status: "down"`: isso seria inventar uma indisponibilidade da SEFAZ que
  não aconteceu. Sem resposta real confirmada, nenhum evento é disparado.
</Warning>

<Info>
  **Opt-in**: assine `sefaz.status.changed` em `events[]` (`PATCH /v1/webhooks/config`)
  como qualquer outro evento, não chega automaticamente.
</Info>

***

## Verificando a Autenticidade (HMAC)

A engineAPI assina cada webhook com HMAC SHA-256 usando o **secret gerado pela API**
(prefixo `whsec_`). **Sempre valide a assinatura** antes de processar o evento.

Os headers enviados são:

| Header                | Conteúdo                                       |
| --------------------- | ---------------------------------------------- |
| `X-Webhook-Signature` | `sha256=<hash>`                                |
| `X-Webhook-Event`     | O `type` do evento (ex.: `invoice.authorized`) |
| `X-Webhook-Delivery`  | ID da tentativa de entrega                     |

<Warning>
  Use exatamente o nome de header da tabela acima.
</Warning>

<CodeGroup>
  ```typescript Node.js theme={null}
  import { createHmac, timingSafeEqual } from 'crypto';
  import express from 'express';

  const app = express();
  app.use(express.raw({ type: 'application/json' }));

  app.post('/webhooks/engine', (req, res) => {
    const signature = req.headers['x-webhook-signature'] as string;
    const secret = process.env.WEBHOOK_SECRET!; // whsec_...

    // Calcular HMAC esperado
    const expected = 'sha256=' + createHmac('sha256', secret)
      .update(req.body)
      .digest('hex');

    // Comparação segura contra timing attacks
    const valid = timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expected)
    );

    if (!valid) {
      return res.status(401).json({ error: 'Assinatura inválida' });
    }

    const { id, type, data } = JSON.parse(req.body.toString());

    // Processar evento
    handleEvent(id, type, data);

    res.status(200).json({ received: true });
  });
  ```

  ```python Python (FastAPI) theme={null}
  import hashlib
  import hmac
  import os
  from fastapi import FastAPI, Request, HTTPException

  app = FastAPI()

  @app.post('/webhooks/engine')
  async def handle_webhook(request: Request):
      body = await request.body()
      signature = request.headers.get('x-webhook-signature', '')
      secret = os.environ['WEBHOOK_SECRET'].encode()  # whsec_...

      # Calcular HMAC esperado
      expected = 'sha256=' + hmac.new(secret, body, hashlib.sha256).hexdigest()

      # Comparação segura
      if not hmac.compare_digest(signature, expected):
          raise HTTPException(status_code=401, detail='Assinatura inválida')

      import json
      payload = json.loads(body)
      event_id = payload['id']
      event_type = payload['type']
      data = payload['data']

      # Processar evento
      await process_event(event_id, event_type, data)

      return {'received': True}
  ```

  ```php PHP theme={null}
  $body = file_get_contents('php://input');
  $signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
  $expected = 'sha256=' . hash_hmac('sha256', $body, getenv('WEBHOOK_SECRET'));
  if (!hash_equals($expected, $signature)) {
      http_response_code(401);
      exit('Assinatura inválida');
  }
  $event = json_decode($body, true, flags: JSON_THROW_ON_ERROR);
  // Deduplicate por $event['id'] antes de processar.
  ```
</CodeGroup>

<Warning>
  **Nunca ignore a validação HMAC** em produção. Qualquer endpoint sem verificação pode ser chamado por agentes maliciosos, injetando eventos falsos no seu sistema.
</Warning>

***

<h2 id="onde-pego-o-secret">
  Onde pego o secret
</h2>

O secret HMAC (`whsec_...`) é gerado pela engineAPI, não escolhido por você. Ele
aparece **completo uma única vez**, exatamente como Stripe e GitHub fazem com chaves e
secrets: depois disso a API só devolve a versão mascarada, e o valor completo **não é
recuperável** — só regenerável (o que invalida o anterior e gera um novo).

Isso acontece em dois momentos:

**1. No `PATCH /v1/webhooks/config` que cria a `webhookUrl` pela primeira vez**
(partner ainda sem secret). A resposta traz `revealedOnce` como `true`:

A resposta de configuração sempre tem `webhookUrl`, `webhookSecret`, `events` e
`revealedOnce` (boolean). O secret só vem completo quando `revealedOnce` é `true`;
nas demais respostas ele vem mascarado.

```bash theme={null}
curl -X PATCH https://api.engineapi.com.br/v1/webhooks/config \
  -H "x-api-key: SUA_CHAVE" \
  -H "Content-Type: application/json" \
  -d '{"webhookUrl": "https://seusite.com/webhooks/engine", "events": ["invoice.authorized"]}'
```

```json theme={null}
{
  "webhookUrl": "https://seusite.com/webhooks/engine",
  "webhookSecret": "whsec_ab12cd34ef56ab12cd34ef56ab12cd34ef56ab12cd34ef56ab12cd34ef56ab",
  "events": ["invoice.authorized"],
  "revealedOnce": true
}
```

Qualquer `PATCH /v1/webhooks/config` seguinte (o secret já existe) devolve mascarado,
com `revealedOnce` como `false` — mesmo trocando a `webhookUrl` ou a lista de `events`:

```json theme={null}
{
  "webhookUrl": "https://seusite.com/webhooks/engine-v2",
  "webhookSecret": "whsec_ab...6ab",
  "events": ["invoice.authorized", "invoice.rejected"],
  "revealedOnce": false
}
```

**2. No `POST /v1/webhooks/secret/regenerate`**, sempre que você chamar — é o único
jeito de ver o secret de novo depois da primeira vez:

```bash theme={null}
curl -X POST https://api.engineapi.com.br/v1/webhooks/secret/regenerate \
  -H "x-api-key: SUA_CHAVE"
```

```json theme={null}
{
  "webhookSecret": "whsec_00112233445566778899aabbccddeeff00112233445566778899aabbccddee",
  "revealedOnce": true
}
```

Essa rota devolve somente `webhookSecret` e `revealedOnce`; `webhookUrl` e `events`
continuam na resposta de configuração.

Regenerar invalida o secret anterior imediatamente: atualize sua variável de ambiente
`WEBHOOK_SECRET` no mesmo momento em que regenerar.

`GET /v1/webhooks/config` **nunca** devolve o secret completo, só mascarado
(`whsec_ab...xy`).

***

## Recebendo e Processando Eventos

<CodeGroup>
  ```typescript Node.js (Express) theme={null}
  app.post('/webhooks/engine', (req, res) => {
    // ... validação HMAC acima ...

    const { id, type, data } = JSON.parse(req.body.toString());

    // Idempotência: ignore eventos já processados
    if (await isAlreadyProcessed(id)) {
      return res.status(200).json({ received: true });
    }

    switch (type) {
      case 'invoice.authorized':
        await db.invoices.update({
          where: { id: data.invoiceId },
          data: { status: 'AUTHORIZED', accessKey: data.accessKey },
        });
        await notifyCustomer(data.invoiceId);
        break;

      case 'invoice.rejected':
        await db.invoices.update({
          where: { id: data.invoiceId },
          data: { status: 'REJECTED' },
        });
        await alertSupport(data);
        break;

      case 'certificate.expiring':
        await sendEmail({
          to: 'admin@empresa.com',
          subject: `Certificado digital vencendo em ${data.daysUntilExpiry} dia(s)`,
          body: `Renove o certificado do CNPJ ${data.cnpj}`,
        });
        break;
    }

    await markAsProcessed(id);
    res.status(200).json({ received: true });
  });
  ```

  ```python Python (FastAPI) theme={null}
  @app.post('/webhooks/engine')
  async def handle_webhook(request: Request):
      # ... validação HMAC acima ...

      payload = json.loads(body)
      event_id = payload['id']
      event_type = payload['type']
      data = payload['data']

      # Idempotência
      if await is_already_processed(event_id):
          return {'received': True}

      if event_type == 'invoice.authorized':
          await db.invoices.update(data['invoiceId'], status='AUTHORIZED')
          await notify_customer(data['invoiceId'])

      elif event_type == 'invoice.rejected':
          await db.invoices.update(data['invoiceId'], status='REJECTED')
          await alert_support(data)

      elif event_type == 'certificate.expiring':
          await send_email(
              to='admin@empresa.com',
              subject=f"Certificado vencendo em {data['daysUntilExpiry']} dia(s)",
          )

      await mark_as_processed(event_id)
      return {'received': True}
  ```
</CodeGroup>

***

## Política de Retentativas

Se seu endpoint retornar qualquer status diferente de `2xx` ou não responder, a engineAPI
tenta novamente com o cronograma abaixo, **5 tentativas** no total:

| Tentativa | Aguarda    | Total desde o evento |
| --------- | ---------- | -------------------- |
| 1ª        | imediato   | 0s                   |
| 2ª        | 5 minutos  | 5min                 |
| 3ª        | 30 minutos | 35min                |
| 4ª        | 2 horas    | \~2h35min            |
| 5ª        | 24 horas   | \~26h35min           |

Após a 5ª tentativa falhar, o evento é movido para a **Dead Letter Queue**. Como o
cronograma escala até a DLQ, e como sair dela:

```mermaid theme={null}
flowchart TD
    A("Evento disparado") --> B("1ª tentativa<br/>imediata")
    B -->|falha| C("2ª tentativa<br/>após 5min")
    C -->|falha| D("3ª tentativa<br/>após 30min")
    D -->|falha| E("4ª tentativa<br/>após 2h")
    E -->|falha| F("5ª tentativa<br/>após 24h")
    F -->|falha| G("Dead Letter Queue<br/>GET /v1/webhooks/dlq")
    G --> H("POST .../dlq/{id}/retry<br/>ou .../dlq/retry-all")
    H --> I("2xx recebido")
    B -->|2xx| I
    C -->|2xx| I
    D -->|2xx| I
    E -->|2xx| I
    F -->|2xx| I

    classDef nucleo fill:#1E56B1,stroke:#0F2A5E,color:#fff
    classDef destaque fill:#2D7AF6,stroke:#0F2A5E,color:#fff
    classDef profundo fill:#0F2A5E,stroke:#0F2A5E,color:#fff
    classDef sucesso fill:#16a34a,stroke:#15803d,color:#fff
    class A,B,C,D nucleo
    class E,F destaque
    class G,H profundo
    class I sucesso
```

<h3 id="dead-letter-queue-dlq">
  Dead Letter Queue (DLQ)
</h3>

```bash theme={null}
# Listar itens na DLQ
curl https://api.engineapi.com.br/v1/webhooks/dlq \
  -H "x-api-key: SUA_CHAVE"

# Reenviar um item específico
curl -X POST https://api.engineapi.com.br/v1/webhooks/dlq/{deliveryId}/retry \
  -H "x-api-key: SUA_CHAVE"

# Reenviar todos
curl -X POST https://api.engineapi.com.br/v1/webhooks/dlq/retry-all \
  -H "x-api-key: SUA_CHAVE"

# Limpar a DLQ
curl -X DELETE https://api.engineapi.com.br/v1/webhooks/dlq \
  -H "x-api-key: SUA_CHAVE"
```

A DLQ não vive em `/admin/dead-letter-queue`: é escopada por partner em `/v1/webhooks/dlq`.

<Info>
  `GET /v1/webhooks/dlq` só aceita `limit` (1-100, default 50): não pagina por `page`.
  A resposta é `{ total, items[] }`, **não** um array puro. Ver
  [Paginação](/guides/paginacao#variante-ltimos-n-logs-e-dlq-de-webhooks).
</Info>

***

## Histórico de Entregas

```bash theme={null}
curl https://api.engineapi.com.br/v1/webhooks/logs?limit=50 \
  -H "x-api-key: SUA_CHAVE"
```

<Info>
  `GET /v1/webhooks/logs` também só aceita `limit` (1-100, default 50): a resposta aqui
  **é** um array puro dos deliveries (shape diferente do `/dlq` acima). Ver
  [Paginação](/guides/paginacao#variante-ltimos-n-logs-e-dlq-de-webhooks).
</Info>

### Testando o webhook configurado

```bash theme={null}
curl -X POST https://api.engineapi.com.br/v1/webhooks/test \
  -H "x-api-key: SUA_CHAVE"
```

***

## Debugging Local

Para testar webhooks em desenvolvimento, use o [webhook.site](https://webhook.site) ou o [ngrok](https://ngrok.com):

```bash theme={null}
# Com ngrok
ngrok http 3000

# Use a URL pública do ngrok ao cadastrar o webhook
# Ex: https://abc123.ngrok.io/webhooks/engine
```

***

## Entrega garantida

Cada entrega e o próximo retry são persistidos. Falhas usam backoff de 5 minutos, 30 minutos, 2 horas e 24 horas; deploys ou reinícios não perdem entregas agendadas. Após cinco tentativas, a entrega vai para `GET /v1/webhooks/dlq`; reagende-a por `POST /v1/webhooks/dlq/{deliveryId}/retry`.

Quando a reconciliação fiscal encontra o desfecho, ela emite o evento terminal normal (`invoice.authorized`, `invoice.rejected` ou `invoice.canceled`), sem um evento duplicado de reconciliação.

Documentos com chave e sem desfecho terminal podem ser consultados paginadamente em `GET /v1/nfe?situacao=indeterminada` ou `GET /v1/nfce?situacao=indeterminada`. `situacao` não pode ser combinado com `status`; cada item inclui `situacao`, `accessKey`, o status bruto, `updatedAt`, `proximaVerificacaoEm` e `motivo`.

```bash theme={null}
curl 'https://api.engineapi.com.br/v1/nfe?situacao=indeterminada&page=1&limit=20' \
  -H 'Authorization: Bearer SEU_TOKEN'
```

***

## Boas Práticas

<AccordionGroup>
  <Accordion title="Responda rápido, processe depois">
    Retorne `200 OK` imediatamente e processe o evento em background (filas, workers). Nunca faça operações lentas dentro da requisição do webhook.
  </Accordion>

  <Accordion title="Implemente idempotência">
    Use o campo `id` do evento como chave única. Se o mesmo evento chegar duas vezes (retentativa), não processe novamente.
  </Accordion>

  <Accordion title="Valide a assinatura HMAC sempre">
    Em produção, rejeite qualquer webhook sem assinatura válida (`X-Webhook-Signature`). Use `timingSafeEqual`/`hmac.compare_digest` para evitar timing attacks.
  </Accordion>

  <Accordion title="Monitore a Dead Letter Queue">
    Eventos que esgotaram as tentativas ficam em `GET /v1/webhooks/dlq`. Reenvie manualmente com `POST /v1/webhooks/dlq/{deliveryId}/retry` (ou `retry-all`).
  </Accordion>
</AccordionGroup>

***

## Veja também

* **[Erros e respostas](/guides/errors):** entenda os códigos de erro da SEFAZ e como tratá-los.
* **[Ambiente de testes](/guides/sandbox):** teste seus webhooks sem emitir notas reais.
