docs

Webhook signing

Verificação HMAC do header Zhex-Signature — Zhex.webhooks.constructEvent do SDK ou helper standalone com timing-safe compare e tolerância de timestamp.

A Zhex assina cada webhook com HMAC-SHA256 sobre timestamp + "." + body. O header chega assim:

Zhex-Signature: t=1714060000,v1=a3f2d1e8b...
  • t=… — Unix timestamp em segundos (quando o evento foi assinado).
  • v1=… — assinatura HMAC-SHA256 hex.

Você precisa (1) validar o HMAC com timing-safe compare e (2) rejeitar timestamps fora da tolerância (default 5min) — ambos defendem contra ataques diferentes (forgery vs replay).

Caminho 1 — SDK

Zhex.webhooks.constructEvent faz tudo numa linha. Não precisa instanciar um cliente — verificação só depende do secret.

import Zhex, { ZhexWebhookSignatureError } from '@zhexio/node';

import express from 'express';
const app = express();

app.post(
  '/webhooks/zhex',
  // CRÍTICO: raw body. express.json() quebra a assinatura.
  express.raw({ type: 'application/json' }),
  (req, res) => {
    let event;
    try {
      event = Zhex.webhooks.constructEvent(
        req.body,                                    // Buffer
        req.header('zhex-signature') ?? '',
        process.env.ZHEX_WEBHOOK_SECRET!,
      );
    } catch (err) {
      if (err instanceof ZhexWebhookSignatureError) {
        return res.status(400).send(`Invalid: ${err.message}`);
      }
      throw err;
    }

    switch (event.type) {
      case 'payment_intent.succeeded':
        // event.data.object é o PaymentIntent
        break;
      case 'payment_intent.payment_failed':
        break;
      case 'charge.dispute.created':
        break;
    }

    res.json({ received: true });
  },
);

Tolerância default: 5 minutos. Para sobrescrever, passe segundos como 4º argumento:

Zhex.webhooks.constructEvent(req.body, sig, SECRET, 600);  // 10min

Caminho 2 — Helper standalone

Quando você não quer SDK ou está em outra linguagem.

Helper canônico (Node)

import { createHmac, timingSafeEqual } from 'node:crypto';

export class SignatureVerificationError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'SignatureVerificationError';
  }
}

export function constructEvent(
  payload: Buffer | string,
  header: string | undefined,
  secret: string,
  toleranceSeconds = 300,
): { type: string; data: any; id: string; created: number } {
  if (!header) throw new SignatureVerificationError('Header Zhex-Signature ausente');

  // Parse "t=…,v1=…"
  const parts = Object.fromEntries(
    header.split(',').map((kv) => {
      const [k, v] = kv.split('=');
      return [k.trim(), v?.trim() ?? ''];
    }),
  ) as { t?: string; v1?: string };

  if (!parts.t || !parts.v1) {
    throw new SignatureVerificationError('Header malformado');
  }

  // Tolerância de timestamp (anti-replay)
  const now = Math.floor(Date.now() / 1000);
  const ts = Number(parts.t);
  if (Math.abs(now - ts) > toleranceSeconds) {
    throw new SignatureVerificationError(
      `Timestamp fora da tolerância: ${Math.abs(now - ts)}s > ${toleranceSeconds}s`,
    );
  }

  // Re-computa HMAC
  const body = Buffer.isBuffer(payload) ? payload.toString('utf8') : payload;
  const signedPayload = `${parts.t}.${body}`;
  const expected = createHmac('sha256', secret).update(signedPayload).digest('hex');

  // Timing-safe compare
  const a = Buffer.from(parts.v1, 'hex');
  const b = Buffer.from(expected, 'hex');
  if (a.length !== b.length || !timingSafeEqual(a, b)) {
    throw new SignatureVerificationError('Assinatura inválida');
  }

  return JSON.parse(body);
}

Express

import express from 'express';
import { constructEvent } from './zhex-webhook';

const app = express();

app.post(
  '/webhooks/zhex',
  // CRÍTICO: raw body. express.json() modifica o body e quebra a assinatura.
  express.raw({ type: 'application/json' }),
  (req, res) => {
    let event;
    try {
      event = constructEvent(
        req.body,                                  // Buffer
        req.header('zhex-signature'),
        process.env.ZHEX_WEBHOOK_SECRET!,
      );
    } catch (err) {
      return res.status(400).send(`Invalid: ${(err as Error).message}`);
    }

    switch (event.type) {
      case 'payment_intent.succeeded':
        // ... processar
        break;
      case 'payment_intent.payment_failed':
        // ...
        break;
      case 'charge.dispute.created':
        // ...
        break;
    }

    res.json({ received: true });
  },
);

app.listen(3000);

Use raw body, sempre

express.json() (e qualquer middleware que modifica o corpo) quebra a assinatura. Use express.raw({ type: 'application/json' }) no endpoint de webhook — só nele. O resto da app pode usar express.json() normalmente.

Next.js (App Router)

// app/api/webhooks/zhex/route.ts
import { NextRequest } from 'next/server';
import { constructEvent } from '@/lib/zhex-webhook';

export async function POST(req: NextRequest) {
  const body = await req.text();                  // text() preserva bytes exatos
  const sig = req.headers.get('zhex-signature') ?? undefined;

  let event;
  try {
    event = constructEvent(body, sig, process.env.ZHEX_WEBHOOK_SECRET!);
  } catch (err) {
    return Response.json({ error: (err as Error).message }, { status: 400 });
  }

  switch (event.type) {
    case 'payment_intent.succeeded':
      // ...
      break;
  }

  return Response.json({ received: true });
}

req.text() é importante — req.json() parseia e re-stringifica, mudando bytes (ordem de chaves, espaços) e invalidando a assinatura.

Fastify

import Fastify from 'fastify';
import { constructEvent } from './zhex-webhook';

const app = Fastify();

// Adiciona content-type parser raw para o endpoint de webhook
app.addContentTypeParser(
  'application/json',
  { parseAs: 'buffer' },
  (_req, body, done) => done(null, body),
);

app.post('/webhooks/zhex', async (req, reply) => {
  try {
    const event = constructEvent(
      req.body as Buffer,
      req.headers['zhex-signature'] as string,
      process.env.ZHEX_WEBHOOK_SECRET!,
    );
    // ...
    reply.send({ received: true });
  } catch (err) {
    reply.code(400).send({ error: (err as Error).message });
  }
});

Tolerância de timestamp

Default: 300s (5min). A Zhex re-tenta entrega em casos de falha (5s, 30s, 5m, ...) — 5min é o ponto onde retries normais ainda passam mas replay attack fica inviável.

constructEvent(body, sig, secret, 600);          // 10min — só se tiver clock skew

Aumentar acima de 10min é antipattern.

Detalhes do header

Zhex-Signature: t=1714060000,v1=a3f2d1e8b...
ParteSignificado
t=…Unix timestamp (segundos) em que o evento foi assinado
v1=…HMAC-SHA256 hex de t.body, chave = whsec_*

Em rotações de secret, a Zhex pode mandar múltiplos v1= (chave nova + chave velha por uma janela). O helper acima precisa ser ajustado pra aceitar qualquer um — abrir issue se você precisar dessa feature antes da CLI.

Idempotência no seu lado

A Zhex retenta entrega até 7 vezes (5s → 24h). Seu handler deve ser idempotente:

// Use event.id como chave única
const exists = await db.processedEvents.findUnique({ where: { id: event.id } });
if (exists) return res.json({ received: true });

await db.$transaction([
  db.processedEvents.create({ data: { id: event.id, type: event.type } }),
  // ... seu processamento
]);

Sem isso, a mesma confirmação de pagamento pode acionar 7× emissão de produto / envio de email.

Boas práticas

  • Raw body, sempre. express.raw ou req.text() no Next/Fastify.
  • Timing-safe compare (crypto.timingSafeEqual). Buffer.compare ou === vazam timing.
  • Tolerância em 300s salvo se você tem clock skew documentado.
  • Idempotência por event.id — webhook delivery é at-least-once.
  • Responda 2xx rápido (< 5s). Se o processamento demorar, enfileire (BullMQ/SQS) e retorne 200 imediatamente.
Esta página foi útil?

Atualizado em

Nesta página