docs

Webhook signing

Verificação HMAC do header x-zhex-signature — Zhex.webhooks.constructEvent do SDK ou helper standalone com timing-safe compare.

A Zhex assina cada webhook com HMAC-SHA256 sobre o body cru. O header chega assim:

x-zhex-signature: a3f2d1e8b...

Você precisa (1) validar o HMAC com timing-safe compare e (2) salvar event.id pra deduplicar entregas (at-least-once).

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('x-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 });
  },
);

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,
): { type: string; data: any; id: string; created: number } {
  if (!header) {
    throw new SignatureVerificationError('Header x-zhex-signature ausente');
  }

  const body = Buffer.isBuffer(payload) ? payload.toString('utf8') : payload;
  const expected = createHmac('sha256', secret).update(body).digest('hex');

  // Timing-safe compare
  const a = Buffer.from(header, '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('x-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('x-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['x-zhex-signature'] as string,
      process.env.ZHEX_WEBHOOK_SECRET!,
    );
    // ...
    reply.send({ received: true });
  } catch (err) {
    reply.code(400).send({ error: (err as Error).message });
  }
});

Headers extras

Além do x-zhex-signature, a Zhex envia:

HeaderSignificado
x-zhex-eventTipo do evento (mesmo valor de body.type)
x-zhex-attemptNúmero da tentativa (1 = primeira; >1 = retry)
user-agentZhex-Webhook/1.0

Use x-zhex-attempt > 1 pra logar retries e investigar handlers que falham na primeira tentativa.

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.
  • 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