Princípios
Toda implementação de webhook precisa cobrir 3 coisas:- Validação HMAC com o
secretrecebido na criação do webhook - Resposta rápida (
200 OKem ≤10s) - Idempotência via header
x-event-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 eventId = req.header('x-event-id') ?? '';
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(eventId)) return res.json({ duplicate: true });
seen.add(eventId);
const event = JSON.parse(req.body.toString());
// Process async — don't block response
enqueue(event).catch(console.error);
res.json({ received: true });
});
import hmac
import hashlib
from flask import Flask, request, abort, jsonify
app = Flask(__name__)
SECRET = b'<seu-webhook-secret>'
seen = set() # production: Redis with TTL
@app.post('/webhooks/ntxpay')
def webhook():
raw = request.get_data() # raw bytes — essential so HMAC matches
sig = request.headers.get('X-NTXPay-Signature', '')
event_id = request.headers.get('x-event-id', '')
expected = 'sha256=' + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected):
abort(401)
if event_id in seen:
return jsonify(duplicate=True)
seen.add(event_id)
event = request.get_json()
# enqueue asynchronously
enqueue(event)
return jsonify(received=True)
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
@RestController
public class NtxPayWebhook {
private static final byte[] SECRET =
System.getenv("NTXPAY_WEBHOOK_SECRET").getBytes();
// production: Redis with TTL
private final Set<String> seen = ConcurrentHashMap.newKeySet();
@PostMapping(value = "/webhooks/ntxpay", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<?> handle(
@RequestHeader("X-NTXPay-Signature") String sig,
@RequestHeader("x-event-id") String eventId,
@RequestBody byte[] raw // raw bytes — essential so HMAC matches
) throws Exception {
String expected = "sha256=" + hmacSha256Hex(SECRET, raw);
if (!MessageDigest.isEqual(sig.getBytes(), expected.getBytes())) {
return ResponseEntity.status(401).build();
}
if (!seen.add(eventId)) {
return ResponseEntity.ok(Map.of("duplicate", true));
}
// enqueue async processing
return ResponseEntity.ok(Map.of("received", true));
}
private static String hmacSha256Hex(byte[] secret, byte[] data) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret, "HmacSHA256"));
byte[] result = mac.doFinal(data);
StringBuilder sb = new StringBuilder(result.length * 2);
for (byte b : result) sb.append(String.format("%02x", b));
return sb.toString();
}
}
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"os"
"sync"
)
var (
secret = []byte(os.Getenv("NTXPAY_WEBHOOK_SECRET"))
seenMu sync.Mutex
seen = make(map[string]bool) // production: Redis with TTL
)
func handleWebhook(w http.ResponseWriter, r *http.Request) {
raw, err := io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
sig := r.Header.Get("X-NTXPay-Signature")
eventID := r.Header.Get("x-event-id")
h := hmac.New(sha256.New, secret)
h.Write(raw)
expected := "sha256=" + hex.EncodeToString(h.Sum(nil))
if !hmac.Equal([]byte(sig), []byte(expected)) {
w.WriteHeader(http.StatusUnauthorized)
return
}
seenMu.Lock()
if seen[eventID] {
seenMu.Unlock()
_ = json.NewEncoder(w).Encode(map[string]bool{"duplicate": true})
return
}
seen[eventID] = true
seenMu.Unlock()
// enqueue async processing
_ = json.NewEncoder(w).Encode(map[string]bool{"received": true})
}
func main() {
http.HandleFunc("/webhooks/ntxpay", handleWebhook)
_ = http.ListenAndServe(":8080", nil)
}
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.Roteando por Evento e Status
O campoevent identifica o fluxo (transaction.cash_in.* / transaction.cash_out.*) e o status o resultado. Filtre antes de processar:
const event = JSON.parse(req.body.toString());
switch (event.event) {
case 'transaction.cash_in.settled':
await markOrderPaid(event.transactionId, event.amount);
break;
case 'transaction.cash_out.settled':
await markPayoutSettled(event.transactionId);
break;
case 'transaction.cash_out.rejected':
await markPayoutFailed(event.transactionId);
break;
case 'transaction.cash_in.returned':
case 'transaction.cash_out.returned':
await processRefund(event);
break;
}
event = request.get_json()
match event["event"]:
case "transaction.cash_in.settled":
mark_order_paid(event["transactionId"], event["amount"])
case "transaction.cash_out.settled":
mark_payout_settled(event["transactionId"])
case "transaction.cash_out.rejected":
mark_payout_failed(event["transactionId"])
case "transaction.cash_in.returned" | "transaction.cash_out.returned":
process_refund(event)
// `raw` is the request body byte[], `mapper` is a Jackson ObjectMapper
Map<String, Object> event = mapper.readValue(raw, new TypeReference<>() {});
String evtType = (String) event.get("event");
String txId = (String) event.get("transactionId");
switch (evtType) {
case "transaction.cash_in.settled" ->
markOrderPaid(txId, ((Number) event.get("amount")).longValue());
case "transaction.cash_out.settled" -> markPayoutSettled(txId);
case "transaction.cash_out.rejected" -> markPayoutFailed(txId);
case "transaction.cash_in.returned", "transaction.cash_out.returned" ->
processRefund(event);
}
var event struct {
Event string `json:"event"`
TransactionID string `json:"transactionId"`
Amount int64 `json:"amount"`
Status string `json:"status"`
}
if err := json.Unmarshal(raw, &event); err != nil {
return err
}
switch event.Event {
case "transaction.cash_in.settled":
markOrderPaid(event.TransactionID, event.Amount)
case "transaction.cash_out.settled":
markPayoutSettled(event.TransactionID)
case "transaction.cash_out.rejected":
markPayoutFailed(event.TransactionID)
case "transaction.cash_in.returned", "transaction.cash_out.returned":
processRefund(event)
}
Re-Tentativas
Se você devolver status ≠2xx (ou estourar o timeout de 10s), o NTX Pay retenta até 5 vezes em backoff exponencial a partir de ~5 segundos. Depois disso, a entrega é marcada como falha — o reenvio manual pode ser solicitado ao 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-event-idpara auditoria