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); // 10minCaminho 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 skewAumentar acima de 10min é antipattern.
Detalhes do header
Zhex-Signature: t=1714060000,v1=a3f2d1e8b...| Parte | Significado |
|---|---|
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.rawoureq.text()no Next/Fastify. - Timing-safe compare (
crypto.timingSafeEqual).Buffer.compareou===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.
Atualizado em