Principios
Toda implementación de webhook debe cubrir 3 cosas:- Validación HMAC con el
secretrecibido al crear el webhook - Respuesta rápida (
200 OKen ≤10s) - Idempotencia vía header
x-event-id
Ejemplos de Código
import express from 'express';
import crypto from 'crypto';
const app = express();
// CRITICAL: usa el raw body, no el JSON parseado, para que el HMAC coincida
app.use('/webhooks/ntxpay', express.raw({ type: 'application/json' }));
const SECRET = process.env.NTXPAY_WEBHOOK_SECRET!;
const seen = new Set<string>(); // producción: Redis con 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());
// Procesa async — no bloquees la respuesta
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'<tu-webhook-secret>'
seen = set() # producción: Redis con TTL
@app.post('/webhooks/ntxpay')
def webhook():
raw = request.get_data() # bytes crudos — esencial para que el HMAC coincida
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()
# encola de forma asíncrona
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();
// producción: Redis con 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 // bytes crudos — esencial para que el HMAC coincida
) 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));
}
// encola el procesamiento async
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) // producción: Redis con 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()
// encola el procesamiento async
_ = json.NewEncoder(w).Encode(map[string]bool{"received": true})
}
func main() {
http.HandleFunc("/webhooks/ntxpay", handleWebhook)
_ = http.ListenAndServe(":8080", nil)
}
¿Por qué raw body?
El HMAC se calcula sobre los bytes exactos que NTX Pay envió. Si el framework parsea el JSON antes (reacomodando espacios, reordenando campos), la firma no coincide. Captura siempre el body crudo en bytes antes de hacer el parse.Enrutar por Evento y Status
El campoevent identifica el flujo (transaction.cash_in.* / transaction.cash_out.*) y el status el resultado. Filtra antes de procesar:
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` es el byte[] del cuerpo del request, `mapper` es un ObjectMapper de Jackson
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)
}
Reintentos
Si devuelves un status ≠2xx (o excedes el timeout de 10s), NTX Pay reintenta hasta 5 veces con backoff exponencial a partir de ~5 segundos. Después de eso, la entrega se marca como fallida — el reenvío manual puede solicitarse a soporte.
No uses
429 para señalar el rate-limit de tu propio servicio — eso activa el retry y amplifica la carga. Responde 503 Service Unavailable si realmente no puedes procesar.Buenas Prácticas
- Usa Redis/base de datos para el dedupe con TTL ≥ 24h (no memoria in-process)
- Procesa de forma asíncrona: el webhook handler solo valida + encola
- Monitorea la latencia del handler — objetivo P95 < 500ms
- Registra
x-event-iden los logs para auditoría