Verificar a assinatura do webhook (HMAC)
Atualizado em
Nesta página
Toda entrega de webhook é assinada com o segredo do endpoint. Verifique a assinatura antes de confiar no conteúdo: sem isso, qualquer pessoa que descubra a sua URL consegue enviar eventos falsos.
Headers de cada entrega#
| Header | Exemplo | Uso |
|---|---|---|
X-Emitra-Event |
nfse.authorized |
Tipo do evento (o mesmo de type no corpo). |
X-Emitra-Delivery |
f1e2d3c4-b5a6-4978-8a9b-0c1d2e3f4a5b |
ID desta entrega. Uma nova tentativa da mesma entrega mantém o ID. |
X-Emitra-Timestamp |
1762866933 |
Momento da assinatura, em segundos Unix. |
X-Emitra-Signature |
sha256=5d1f... |
Assinatura HMAC-SHA256 em hexadecimal. |
X-Emitra-Test |
true |
Presente só em eventos de chaves de teste (sandbox). |
Como a assinatura é calculada#
Fórmula
assinatura = "sha256=" + hex( HMAC_SHA256( segredo, timestamp + "." + corpo_bruto ) )segredoé o segredo do endpoint, gerado pela Emitra e mostrado quando você cria o webhook.timestampé o valor exato do headerX-Emitra-Timestamp.corpo_brutosão os bytes do corpo da requisição como chegaram, antes de qualquer parse de JSON.
Passo a passo#
- Leia os headers
X-Emitra-TimestampeX-Emitra-Signature. Se faltar algum, responda400. - Recuse timestamps fora de uma janela de tolerância. Recomendamos 5 minutos para os dois lados, o que bloqueia a reutilização de uma entrega capturada.
- Calcule o HMAC-SHA256 de
timestamp + "." + corpo_brutocom o seu segredo. - Compare com o valor do header usando uma comparação em tempo constante. Nunca use
==em strings de assinatura. - Só então faça o parse do JSON, responda
2xxe processe o evento.
Código#
Os exemplos leem o segredo de EMITRA_WEBHOOK_SECRET.
Node (Express)
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.EMITRA_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 5 * 60;
function isValidSignature(rawBody, timestamp, signatureHeader) {
if (!timestamp || !signatureHeader) return false;
const ts = Number.parseInt(timestamp, 10);
if (!Number.isFinite(ts)) return false;
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - ts) > TOLERANCE_SECONDS) return false;
const expected =
"sha256=" +
crypto
.createHmac("sha256", SECRET)
.update(`${timestamp}.`)
.update(rawBody)
.digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(signatureHeader, "utf8");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// express.raw mantém o corpo como Buffer, sem parse
app.post(
"/webhooks/emitra",
express.raw({ type: "application/json" }),
(req, res) => {
const ok = isValidSignature(
req.body,
req.get("X-Emitra-Timestamp"),
req.get("X-Emitra-Signature"),
);
if (!ok) return res.status(400).send("assinatura inválida");
const event = JSON.parse(req.body.toString("utf8"));
// Enfileire o processamento e responda rápido
queue.add(event, { jobId: req.get("X-Emitra-Delivery") });
res.status(200).send("ok");
},
);
app.listen(3000);Python (FastAPI)
import hashlib
import hmac
import json
import os
import time
from fastapi import FastAPI, Header, HTTPException, Request
app = FastAPI()
SECRET = os.environ["EMITRA_WEBHOOK_SECRET"].encode()
TOLERANCE_SECONDS = 5 * 60
def is_valid_signature(raw_body: bytes, timestamp: str | None, signature: str | None) -> bool:
if not timestamp or not signature:
return False
try:
ts = int(timestamp)
except ValueError:
return False
if abs(time.time() - ts) > TOLERANCE_SECONDS:
return False
digest = hmac.new(SECRET, timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(f"sha256={digest}", signature)
@app.post("/webhooks/emitra")
async def receive(
request: Request,
x_portalnfse_timestamp: str | None = Header(default=None),
x_portalnfse_signature: str | None = Header(default=None),
x_portalnfse_delivery: str | None = Header(default=None),
):
raw_body = await request.body() # bytes, antes do parse
if not is_valid_signature(raw_body, x_portalnfse_timestamp, x_portalnfse_signature):
raise HTTPException(status_code=400, detail="assinatura inválida")
event = json.loads(raw_body)
enqueue(event, delivery_id=x_portalnfse_delivery) # processe fora da request
return {"ok": True}PHP
<?php
$secret = getenv('EMITRA_WEBHOOK_SECRET');
$tolerance = 5 * 60;
$rawBody = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_PORTALNFSE_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_PORTALNFSE_SIGNATURE'] ?? '';
$delivery = $_SERVER['HTTP_X_PORTALNFSE_DELIVERY'] ?? '';
function isValidSignature(string $raw, string $ts, string $sig, string $secret, int $tol): bool
{
if ($ts === '' || $sig === '' || !ctype_digit($ts)) {
return false;
}
if (abs(time() - (int) $ts) > $tol) {
return false;
}
$expected = 'sha256=' . hash_hmac('sha256', $ts . '.' . $raw, $secret);
return hash_equals($expected, $sig);
}
if (!isValidSignature($rawBody, $timestamp, $signature, $secret, $tolerance)) {
http_response_code(400);
exit('assinatura inválida');
}
$event = json_decode($rawBody, true);
// Grave na sua fila e responda rápido
enqueue($event, $delivery);
http_response_code(200);
echo 'ok';Teste a verificação#
Use o botão Testar do webhook no painel ou a rota POST /integracao/webhooks/{id}/testar (veja a referência). A entrega de teste é assinada com o mesmo segredo, então ela valida o seu código de ponta a ponta.
Se a assinatura falhar:
- confira se você está lendo o corpo bruto, e não o JSON reserializado;
- confira se o segredo é o do endpoint certo (cada endpoint tem o seu);
- confira o relógio do servidor (NTP), por causa da janela de tolerância;
- se você girou o segredo no painel, atualize a variável
EMITRA_WEBHOOK_SECRET.
Próximos passos#
Esta página foi útil?