Pular para o conteúdo
LeztyPay docs
Painel

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 2xx em menos de 10 s, com o trabalho pesado fora do handler.
  • [ ] Deduplicação por X-Event-Id gravada antes do efeito colateral.
  • [ ] Tipo de evento desconhecido responde 2xx em vez de 400.
  • [ ] Segredo fora do repositório, lido do ambiente.