Assinatura dos webhooks
Cadastre com POST /webhooks e guarde o segredo da resposta: ele não aparece de novo (para trocar, rotacione). Cada entrega é um POST com dois headers:
Emissao-Signature
t=<unix>,v1=<hex>, em que v1 é o HMAC-SHA256, com o segredo, de "<t>.<corpo>".Emissao-Event-IdO id do evento. Use para ignorar entregas repetidas.
Calcule o HMAC sobre o corpo cru, antes de qualquer parse, compare em tempo constante e recuse t a mais de 5 minutos do seu relógio.
Responda 2xx em até 10 s. Senão, o motor tenta de novo com espera crescente por cerca de 3 dias. O histórico e o reenvio ficam em GET /webhooks/{id}/entregas; para testar a sua URL, use POST /webhooks/{id}/testar.
Eventos
nfse.autorizadaNota autorizada pela SEFIN ou pela prefeituranfse.rejeitadaNota recusada, com erros [{codigo, original, mensagem, sugestao}]nfse.falhouO motor desistiu depois das tentativas (ex.: SEFIN fora do ar)nfse.canceladaCancelamento homologadonfse.cancelamento_negadoCancelamento recusado; o motivo vem em cancelamento.erronfse.substituidaNota original cancelada por substituição; o vínculo vem em substituicaoexportacao.concluidaZIP do período pronto: consulte GET /v1/exportacoes/{id} para o linkcertificado.vencendoCertificado A1 vence em 30, 15, 7 e 1 diawebhook.testeDisparado por POST /v1/webhooks/{id}/testarimport crypto from "node:crypto";
// corpo: o corpo cru da requisição (Buffer), antes do JSON.parse
function valido(corpo, cabecalho, segredo) {
const partes = Object.fromEntries(
cabecalho.split(",").map((p) => p.split("=", 2))
);
if (Math.abs(Date.now() / 1000 - Number(partes.t)) > 300) return false;
const esperado = crypto
.createHmac("sha256", segredo)
.update(`${partes.t}.`)
.update(corpo)
.digest("hex");
const a = Buffer.from(esperado), b = Buffer.from(partes.v1 ?? "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// valido(req.rawBody, req.get("Emissao-Signature"), process.env.WEBHOOK_SEGREDO)