Principles
Every webhook implementation needs to cover 3 things:- HMAC validation with the
secretreceived when the webhook was created - Fast response (
200 OKin ≤10s) - Idempotency via the
x-event-idheader
Code Examples
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'<your-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)
}
Why raw body?
The HMAC is computed over the exact bytes that NTX Pay sent. If your framework parses the JSON first (rearranging whitespace, reordering fields), the signature won’t match. Always capture the raw body as bytes before parsing.Routing by Event and Status
Theevent field identifies the flow (transaction.cash_in.* / transaction.cash_out.*) and status the outcome. Filter before processing:
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)
}
Retries
If you return a status ≠2xx (or exceed the 10s timeout), NTX Pay retries up to 5 times with exponential backoff starting at ~5 seconds. After that, the delivery is marked as failed — a manual redelivery can be requested from support.
Do not use
429 to signal rate limiting on your own service — it triggers retries and amplifies the load. Respond 503 Service Unavailable if you genuinely cannot process.Best Practices
- Use Redis/a database for dedupe with a TTL ≥ 24h (not in-process memory)
- Process asynchronously: the webhook handler should only validate + enqueue
- Monitor handler latency — target P95 < 500ms
- Log
x-event-idfor auditing