Princípios
Toda implementação de webhook precisa cobrir 3 coisas:
- Validação HMAC com o
secret recebido na criação do webhook
- Resposta rápida (
200 OK em ≤10s)
- Idempotência via
X-NTXPay-Delivery ou transaction.id
Exemplos de Código
import express from 'express';
import crypto from 'crypto';
const app = express();
// CRITICAL: use raw body, not parsed JSON, so HMAC matches
app.use('/webhooks/ntxpay', express.raw({ type: 'application/json' }));
const SECRET = process.env.NTXPAY_WEBHOOK_SECRET!;
const seen = new Set<string>(); // production: Redis with TTL
app.post('/webhooks/ntxpay', async (req, res) => {
const sig = req.header('X-NTXPay-Signature') ?? '';
const deliveryId = req.header('X-NTXPay-Delivery') ?? '';
const expected = 'sha256=' + crypto
.createHmac('sha256', SECRET)
.update(req.body)
.digest('hex');
if (sig.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
return res.status(401).end();
}
// Dedupe
if (seen.has(deliveryId)) return res.json({ duplicate: true });
seen.add(deliveryId);
const event = JSON.parse(req.body.toString());
// Process async — don't block response
enqueue(event).catch(console.error);
res.json({ received: true });
});
Por que raw body?
O HMAC é calculado sobre os bytes exatos que o NTX Pay enviou. Se o framework parsear o JSON antes (rearranjando espaços, reordenando campos), a assinatura não bate. Sempre capture o body bruto em bytes antes de fazer parse.
Eventos por Status
Filtre antes de processar:
const event = JSON.parse(req.body.toString());
switch (event.event) {
case 'cash_in':
if (event.transaction.status === 'CONFIRMED') {
await markOrderPaid(event.transaction.externalId);
}
break;
case 'cash_out':
if (event.transaction.status === 'CONFIRMED') {
await markPayoutSettled(event.transaction.id);
} else if (event.transaction.status === 'FAILED') {
await markPayoutFailed(event.transaction.id);
}
break;
case 'refund_in':
case 'refund_out':
await processRefund(event);
break;
}
Re-Tentativas
Se você devolver status ≠ 2xx, o NTX Pay retenta até 5 vezes em backoff exponencial (~30s, 1m, 5m, 15m, 1h). Depois disso, o evento é descartado. Para reenviar manualmente, use o painel ou contate suporte.
Não use 429 para sinalizar rate-limit do seu próprio serviço — isso aciona retry e amplifica a carga. Responda 503 Service Unavailable se realmente não puder processar.
Boas Práticas
- Use Redis/banco para dedupe com TTL ≥ 24h (não memória in-process)
- Processe assíncrono: webhook handler só valida + enfileira
- Monitore latência do handler — alvo P95 < 500ms
- Logue
X-NTXPay-Delivery para auditoria
- Re-consulte
GET /api/spei/transaction/{externalId} se o webhook trouxer estado conflitante com seu banco