> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mx.ntxpay.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook Implementation

> HMAC validation and idempotent processing in Node.js, Python, Java, and Go

## Principles

Every webhook implementation needs to cover 3 things:

1. **HMAC validation** with the `secret` received when the webhook was created
2. **Fast response** (`200 OK` in ≤10s)
3. **Idempotency** via the `x-event-id` header

## Code Examples

<CodeGroup>
  ```typescript Node.js theme={"system"}
  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 });
  });
  ```

  ```python Python theme={"system"}
  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)
  ```

  ```java Java theme={"system"}
  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();
      }
  }
  ```

  ```go Go theme={"system"}
  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)
  }
  ```
</CodeGroup>

## 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

The `event` field identifies the flow (`transaction.cash_in.*` / `transaction.cash_out.*`) and `status` the outcome. Filter before processing:

<CodeGroup>
  ```typescript Node.js theme={"system"}
  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;
  }
  ```

  ```python Python theme={"system"}
  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)
  ```

  ```java Java theme={"system"}
  // `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);
  }
  ```

  ```go Go theme={"system"}
  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)
  }
  ```
</CodeGroup>

## 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.

<Warning>
  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.
</Warning>

## 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-id`** for auditing
