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:
- Leia
tev1do cabeçalhoX-Webhook-Signature. - Calcule
HMAC-SHA256(segredo, "<t>.<corpo bruto>")em hexadecimal (o<t>é o texto do cabeçalho, um ponto, e o corpo exatamente como chegou). - Compare com
v1em tempo constante (nunca com==). - Recuse se
testiver 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
2xxem até 10 segundos e processe o evento depois (numa fila sua). Qualquer outra coisa conta como falha: erro4xx/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
falhoue você pode reenviá-la. - Todas as tentativas levam o mesmo
id: o seu sistema deve ser idempotente (se já processou aqueleid, responda200e ignore). - Não há garantia de ordem entre eventos diferentes (um
nfe.canceladapode chegar antes de umnfe.autorizadaatrasado). Quando o estado atual importar, usecriado_emou consulteGET /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(semhttp); - 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.