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:
| Header | Significado |
|---|---|
x-zhex-event | Tipo do evento (mesmo valor de body.type) |
x-zhex-attempt | Número da tentativa (1 = primeira; >1 = retry) |
user-agent | Zhex-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.rawoureq.text()no Next/Fastify. - Timing-safe compare (
crypto.timingSafeEqual).Buffer.compareou===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.
Atualizado em