Verificar em Node
O arquivo abaixo é o receptor inteiro em Node 22: verificação da assinatura, handler sem framework
e middleware de Express. Só usa node:crypto e node:http, e roda o próprio autoteste com
node verify-webhook.mjs (payload fixo assinado localmente, sem rede).
Os segredos saem do ambiente. Durante as 24 h de rotação você preenche os dois:
LM_WEBHOOK_SECRET=whsec_... # o segredo atual do endpoint
LM_WEBHOOK_SECRET_PREVIOUS=whsec_... # só durante a janela de rotação
// Verificação da assinatura `X-Signature` de um webhook da LeztyPay, em Node 22.
//
// Zero dependência: só `node:crypto`, `node:http` e `node:assert`. Rode
// `node verify-webhook.mjs` para o autoteste (assina um payload fixo localmente, sem rede)
// ou `node verify-webhook.mjs --serve` para subir um receptor de verdade na porta 3000.
//
// A regra que manda: o que é assinado é `<t>.<bytes crus do corpo>`. Guarde o corpo como
// chegou e só desserialize depois de a assinatura bater. Reserializar o JSON antes de
// conferir muda um byte e derruba o hash.
import { strict as assert } from 'node:assert';
import { createHmac, timingSafeEqual } from 'node:crypto';
import { createServer } from 'node:http';
import { pathToFileURL } from 'node:url';
/** Janela aceita para o `t=` do header, em segundos. */
export const TOLERANCE_SECONDS = 300;
/**
* Os secrets que o seu receptor aceita. Durante as 24 h de graça da rotação mantenha os
* dois: o emissor manda `v1=<novo>,v1=<antigo>` e basta um bater.
*/
const SECRETS = [process.env.LM_WEBHOOK_SECRET, process.env.LM_WEBHOOK_SECRET_PREVIOUS].filter(
(secret) => typeof secret === 'string' && secret.length > 0,
);
/**
* @returns `{ ok: true }` ou `{ ok: false, reason: 'malformed' | 'expired' | 'mismatch' }`.
*/
export function verifySignature(header, rawBody, secrets, options = {}) {
const nowSec = options.nowSec ?? Math.floor(Date.now() / 1000);
const toleranceSec = options.toleranceSec ?? TOLERANCE_SECONDS;
let timestamp = null;
const given = [];
for (const part of String(header ?? '').split(',')) {
const piece = part.trim();
if (piece.startsWith('t=')) {
const parsed = Number(piece.slice(2));
if (!Number.isFinite(parsed)) {
return { ok: false, reason: 'malformed' };
}
timestamp = parsed;
} else if (piece.startsWith('v1=')) {
given.push(piece.slice(3));
}
}
if (timestamp === null || given.length === 0) {
return { ok: false, reason: 'malformed' };
}
if (Math.abs(nowSec - timestamp) > toleranceSec) {
return { ok: false, reason: 'expired' };
}
let matched = false;
for (const secret of secrets) {
const expected = Buffer.from(
createHmac('sha256', secret).update(`${timestamp}.${rawBody}`, 'utf8').digest('hex'),
'ascii',
);
for (const candidate of given) {
const buffer = Buffer.from(candidate, 'ascii');
// Confere o comprimento antes: `timingSafeEqual` lança com tamanhos diferentes. E
// não sai do laço no primeiro acerto: avaliar todas as combinações evita vazar por
// tempo qual `v1=` bateu.
if (buffer.length === expected.length && timingSafeEqual(buffer, expected)) {
matched = true;
}
}
}
return matched ? { ok: true } : { ok: false, reason: 'mismatch' };
}
/**
* Handler cru, sem framework: junta os chunks, verifica e só então desserializa.
* `onEvent` tem que ser rápido (gravar numa fila, por exemplo): o emissor desiste da
* entrega em 10 s. Se ele lançar, o receptor devolve 500 e a entrega é retentada.
*/
export function rawHandler(onEvent, secrets = SECRETS) {
return (req, res) => {
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => {
const rawBody = Buffer.concat(chunks).toString('utf8');
const result = verifySignature(req.headers['x-signature'], rawBody, secrets);
if (!result.ok) {
res.writeHead(401).end(result.reason);
return;
}
try {
onEvent(JSON.parse(rawBody), {
eventId: req.headers['x-event-id'],
deliveryId: req.headers['x-delivery-id'],
attempt: Number(req.headers['x-attempt'] ?? '1'),
});
} catch {
res.writeHead(500).end();
return;
}
res.writeHead(200).end();
});
};
}
/**
* Mesma verificação como middleware de Express. Monte a rota com o corpo cru, nunca com
* `express.json()`, que joga fora os bytes originais:
*
* app.post(
* '/webhooks/leztypay',
* express.raw({ type: 'application/json' }),
* verifyMiddleware,
* (req, res) => { fila.push(req.event); res.sendStatus(200); },
* );
*/
export function verifyMiddleware(req, res, next) {
const rawBody = Buffer.isBuffer(req.body) ? req.body.toString('utf8') : '';
const result = verifySignature(req.headers['x-signature'], rawBody, SECRETS);
if (!result.ok) {
res.status(401).send(result.reason);
return;
}
req.event = JSON.parse(rawBody);
next();
}
/** Receptor mínimo sem framework nenhum. */
export function startServer(port = 3000) {
return createServer(
rawHandler((event, meta) => {
console.log(`${event.type} ${event.id} (tentativa ${meta.attempt})`);
}),
).listen(port);
}
function selfTest() {
const secret = 'whsec_exemploDeSecretDeEndpoint';
const rawBody = '{"id":"evt_1","object":"event","type":"transaction.paid"}';
const t = 1789041871;
const sign = (key) => createHmac('sha256', key).update(`${t}.${rawBody}`, 'utf8').digest('hex');
const header = `t=${t},v1=${sign(secret)}`;
assert.deepEqual(verifySignature(header, rawBody, [secret], { nowSec: t }), { ok: true });
// Um byte a mais no corpo já derruba o hash.
assert.equal(verifySignature(header, `${rawBody} `, [secret], { nowSec: t }).reason, 'mismatch');
// Fora da janela de 300 s, mesmo com assinatura boa.
assert.equal(verifySignature(header, rawBody, [secret], { nowSec: t + 301 }).reason, 'expired');
assert.equal(verifySignature(`v1=${sign(secret)}`, rawBody, [secret]).reason, 'malformed');
assert.equal(verifySignature(`t=${t}`, rawBody, [secret], { nowSec: t }).reason, 'malformed');
// Rotação: chegam duas assinaturas e o receptor só conhece a antiga.
const rotacao = `t=${t},v1=${sign('whsec_secretNovoQueVoceAindaNaoGuardou')},v1=${sign(secret)}`;
assert.deepEqual(verifySignature(rotacao, rawBody, [secret], { nowSec: t }), { ok: true });
console.log('verify-webhook: 6 asserções ok');
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
if (process.argv.includes('--serve')) {
startServer();
} else {
selfTest();
}
}
Pegando o corpo cru em cada stack
A assinatura cobre os bytes que chegaram. Quem entrega objeto já desserializado no lugar do buffer
quebra a verificação, e o sintoma é sempre o mesmo: mismatch em 100% das entregas, inclusive nas
que você mesmo disparou por ping.
Express. O express.json() global é o inimigo, e não basta pôr express.raw na rota: se o
parser global rodou antes, ele já marcou o corpo como consumido e o raw vira no-op. Registre a
rota do webhook antes do express.json() global e monte o express.raw nela:
app.post(
'/webhooks/leztypay',
express.raw({ type: 'application/json' }),
verifyMiddleware,
(req, res) => {
fila.push(req.event);
res.sendStatus(200);
},
);
Fastify. O parser de JSON é global e roda antes do handler. Guarde o buffer:
fastify.addContentTypeParser('application/json', { parseAs: 'buffer' }, (req, body, done) => {
req.rawBody = body;
done(null, JSON.parse(body.toString('utf8')));
});
Next.js (App Router). O Request já entrega o corpo cru, basta não usar await req.json()
antes de verificar:
export async function POST(req) {
const rawBody = await req.text();
const result = verifySignature(req.headers.get('x-signature'), rawBody, SECRETS);
if (!result.ok) return new Response(result.reason, { status: 401 });
await fila.push(JSON.parse(rawBody));
return new Response(null, { status: 200 });
}
Hono, Elysia e afins. Todo framework que expõe o Request da plataforma tem um text()
equivalente. A regra é a mesma: text() primeiro, JSON.parse depois da verificação.
Responder rápido
O emissor corta a conexão em 10 s e marca a tentativa como falha. Tudo que o seu handler faz antes de responder entra nesse orçamento: consulta ao banco, chamada a outro serviço, envio de e-mail.
O desenho que aguenta: verifique a assinatura, grave o evento numa fila ou numa tabela com o
X-Event-Id como chave única, responda 2xx, processe depois. Assim uma lentidão no seu
processamento nunca vira retentativa de webhook, e uma entrega duplicada esbarra na chave única em
vez de cobrar o cliente duas vezes.
Se o seu handler lançar antes de responder, devolva 500. A entrega será retentada de acordo com
a agenda de retries, que é exatamente o que você quer quando a falha é sua.
Checklist antes de ir para live
- [ ] Corpo cru chegando ao verificador, conferido com um ping real.
- [ ] Assinatura verificada com comparação em tempo constante e janela de 300 s.
- [ ] Aceita mais de um
v1=no header (senão a próxima rotação derruba tudo). - [ ] Resposta
2xxem menos de 10 s, com o trabalho pesado fora do handler. - [ ] Deduplicação por
X-Event-Idgravada antes do efeito colateral. - [ ] Tipo de evento desconhecido responde
2xxem vez de400. - [ ] Segredo fora do repositório, lido do ambiente.