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

{
  "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)
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.

{
  "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}:

{
  "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

$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)

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)

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)

{"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 é:

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:

{
  "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.