Webhook Best Practices for Payment Events with XPay

How to securely receive, verify, and process XPay webhook events: HMAC-SHA256 signature verification, idempotency, retries, and local development.

١٩ فبراير ٢٠٢٦11 دقائق قراءة

TL;DR

A production-grade XPay webhook handler does six things and only six things. It reads the raw request body (not parsed JSON). It verifies the XPay-Signature header using HMAC-SHA256 against the endpoint's whsec_* secret. It rejects deliveries older than 5 minutes to prevent replay. It dedupes on the top-level event.id for idempotency. It returns a 2xx within 30 seconds. And it runs the actual fulfillment work (order updates, emails, downstream calls) in a queue worker rather than in the request handler. The full signature verification recipe is documented at docs.xpay.app/integrate/webhooks/verifying-signatures; this guide walks through each of the six requirements with working code, common failure modes, and how to use the Workbench webhook replay to debug them in production.

Why Webhooks Are the Source of Truth

The confirm() callback in Drop-in, the redirect return in Hosted Checkout, the onComplete event in Elements: all of them are UX courtesies. They're useful for navigating the user to a success page or showing an animation. They are not authoritative confirmation that the payment succeeded.

The authoritative confirmation is the checkout.session.completed webhook delivered to your server. A customer can close the tab between paying and the redirect. The network can drop the SDK response. Your onComplete handler can throw an exception before persisting state. None of those scenarios change what XPay knows: the money moved, the Charge succeeded, the Payment Intent transitioned to succeeded. The webhook is how XPay tells your server about that ground truth, signed cryptographically so you can trust it without trusting the network.

Everything in this guide flows from that premise. If webhook handling breaks, your system will lose orders, double-fulfill orders, or fail signature checks silently and accept forged ones. The six requirements below exist to prevent each of those outcomes.

The Six Requirements

1. Read the raw body, not the parsed JSON

The single most common reason webhook verification fails is a framework that already parsed the JSON body and re-serialized it before the handler ran. HMAC-SHA256 is computed over bytes. JSON re-serialization changes whitespace, key order, and number formatting, so the recomputed HMAC won't match the one XPay signed, and verification fails on every request.

In Express, this means using express.raw({ type: "application/json" }) on the webhook route specifically, before any global JSON parser:

app.post(
  "/webhooks/xpay",
  express.raw({ type: "application/json" }),  // before the global JSON parser
  (req, res) => {
    const rawBody = req.body.toString("utf8");
    // verify, then JSON.parse(rawBody) only after the signature checks
  }
);

In Fastify, register a content-type parser that hands you the raw bytes:

fastify.addContentTypeParser(
  "application/json",
  { parseAs: "string" },
  (_req, body, done) => done(null, body),
);

In Next.js App Router, call req.text() before any req.json():

export async function POST(req: NextRequest) {
  const rawBody = await req.text();  // read once, before any .json()
  // verify the signature against rawBody
}

In Python with Flask, read request.get_data() (returns bytes) rather than request.json (which parses). In Django, use request.body rather than request.POST. The pattern is universal: parse JSON only after the signature check, never before.

2. Verify HMAC-SHA256 against the timestamped payload

XPay sends one header on every delivery: XPay-Signature: t=1730000000,v1=a1b2c3d4.... The t is a Unix timestamp (seconds) at the moment XPay computed the signature, and v1 is the hex-encoded HMAC-SHA256 of ${timestamp}.${rawBody} signed with the endpoint's whsec_* secret.

To verify, you recompute the HMAC and compare it in constant time against the v1 value. The Node.js recipe:

import crypto from "node:crypto";

const TOLERANCE_SECONDS = 300;

export function verifyXPaySignature(rawBody, header, secret) {
  if (!header) return { valid: false };

  const parts = Object.fromEntries(
    header.split(",").map(p => {
      const [k, ...rest] = p.split("=");
      return [k, rest.join("=")];
    }),
  );

  const timestamp = Number.parseInt(parts.t ?? "", 10);
  const received = parts.v1;
  if (!Number.isFinite(timestamp) || !received) return { valid: false };

  // Replay protection
  if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > TOLERANCE_SECONDS) {
    return { valid: false };
  }

  const computed = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  const a = Buffer.from(computed);
  const b = Buffer.from(received);
  if (a.length !== b.length) return { valid: false };
  if (!crypto.timingSafeEqual(a, b)) return { valid: false };

  return { valid: true, event: JSON.parse(rawBody) };
}

The Python equivalent:

import hmac, hashlib, time, json

TOLERANCE_SECONDS = 300

def verify_xpay_signature(raw_body: str, header: str, secret: str):
    if not header:
        return None
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    try:
        timestamp = int(parts["t"])
    except (KeyError, ValueError):
        return None
    received = parts.get("v1", "")
    if not received:
        return None

    if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
        return None

    computed = hmac.new(
        secret.encode(),
        f"{timestamp}.{raw_body}".encode(),
        hashlib.sha256,
    ).hexdigest()
    if not hmac.compare_digest(computed, received):
        return None
    return json.loads(raw_body)

Three things matter in the implementation. The timingSafeEqual / hmac.compare_digest call prevents timing-attack leakage of the signature: a naive string comparison can leak byte-by-byte equality information through response time. The Number.isFinite(timestamp) / try/except guards reject malformed headers cleanly. And the JSON parse happens inside the verifier, only after the signature check, so a bad request never reaches your event-handling logic with a parsed payload that could be acted on accidentally.

3. Reject stale events (replay protection)

The 300-second tolerance in the recipe above is the replay-protection window. Without it, an attacker who captured one valid webhook delivery could replay the same body and signature an hour later: the HMAC would still verify because the secret and bytes haven't changed. The timestamp check binds the signature to a moment in time.

Five minutes is generous enough to accommodate normal network latency, retry behavior, and reasonable clock drift, while tight enough to make replay impractical. If your handler is rejecting valid deliveries with a stale-timestamp error, the cause is almost always clock drift on your server: sync via NTP and the problem clears.

4. Dedupe on the top-level event ID

The same event can be delivered more than once. XPay retries on every non-2xx response or timeout, and you may also see duplicates when a successful response failed to make it back to XPay across the network. A handler that runs fulfillOrder() twice for the same checkout.session.completed will double-ship, double-charge, or double-email, none of which the customer will appreciate.

The fix is idempotency keyed on event.id, which is unique per event and stable across retries:

async function handleEvent(event) {
  const inserted = await db.processedEvents.insertIfNew(event.id);
  if (!inserted) return;  // already processed

  switch (event.type) {
    case "checkout.session.completed":
      await fulfillOrder(event.data.object);
      break;
    case "refund.created":
      await markRefunded(event.data.object);
      break;
  }
}

The insertIfNew operation needs to be atomic: a unique index on the event_id column with INSERT ... ON CONFLICT DO NOTHING (Postgres) or equivalent. Without atomicity, two concurrent deliveries can both see "not yet processed" and both run the handler.

5. Acknowledge fast: work in the background

XPay considers a delivery successful only if your handler returns a 2xx within 30 seconds. Anything slower triggers a retry, even if your work eventually completed. Fulfillment that involves sending emails, calling external APIs, generating PDFs, or running database transactions across multiple tables can easily exceed 30 seconds under load, and a timed-out webhook handler that produced a side effect creates exactly the duplicate-delivery problem dedup is supposed to prevent.

The correct shape is to verify, dedupe, enqueue, and acknowledge:

app.post("/webhooks/xpay", express.raw({ type: "application/json" }), async (req, res) => {
  const result = verifyXPaySignature(
    req.body.toString("utf8"),
    req.header("XPay-Signature"),
    process.env.XPAY_WEBHOOK_SECRET,
  );
  if (!result.valid) return res.status(400).send("invalid signature");

  await queue.add("xpay-event", result.event);  // enqueue, don't process
  return res.status(200).send();
});

The actual fulfillment runs in a queue worker: BullMQ, RQ, Celery, Sidekiq, whatever fits your stack. The worker is also where retry-on-failure logic lives for downstream operations, and it's where you can apply backpressure if an upstream service is slow.

6. Return 400 on bad signatures, not 200

A handler that returns 200 on a failed signature check is silently broken. You'll never notice signing changed, and an attacker who finds your endpoint URL can forge events that your code will gladly process. Always return a non-2xx (400 is the right choice) when verification fails. XPay's delivery workflow treats that as a failed attempt and retries on the standard schedule, which gives you a recoverable error rather than a silent compromise.

The same logic applies to body-shape errors. If event.type isn't something your handler knows, log it and return 200 (no useful retry semantics for "unknown event type"). But anything that smells like an authentication failure should be a 400.

The Common Failure Modes

After two years of merchants integrating XPay webhooks, the failure-mode distribution is fairly stable.

Framework parsed and re-serialized the body. Verification fails on every request. Fix: raw-body wiring on the webhook route, before any global JSON middleware. This is the failure mode in roughly half of all webhook bug reports.

Wrong endpoint secret. Test vs. live secret mixed up, or a stale value from a deleted endpoint still living in an environment variable. Fix: check the dashboard's webhook endpoint list and confirm the whsec_* matches.

Endpoint recreated. Deleting and recreating a webhook endpoint generates a new whsec_*: the old one stops working. Fix: update your env var to the new secret.

Clock drift. Replay-window rejection on otherwise-valid requests. Fix: NTP sync the server clock.

Header rewritten. A reverse proxy or ingress modified the XPay-Signature header (lowercased it, stripped it, split values across multiple headers). Fix: allow the header through unmodified.

Body content modified. A WAF or proxy modified the body (added a BOM, changed line endings, normalized whitespace). Same effect as the parsed-and-re-serialized case. Fix: bypass the modifier on the webhook route.

When a delivery fails, the XPay Workbench is the first debugging stop. Open the webhook delivery in the Workbench, see the exact bytes XPay sent, see what your endpoint returned, replay the delivery against a fresh handler version. The webhook replay capability specifically (resend any past delivery without re-triggering the original payment) turns a "this worked yesterday and now doesn't" investigation from a fishing expedition into a five-minute fix.

The Retry Schedule

XPay retries every non-2xx response or timeout, with exponential backoff. The retry window differs by mode. In test mode, deliveries are retried up to five times over roughly two and a half hours (immediate, 1 minute, 5 minutes, 30 minutes, 2 hours), fast enough to iterate against during development. In live mode, XPay retries up to 13 times across roughly three days, matching the Tier-1 (~3-day) recovery window so a brief production outage doesn't cost you events.

If a live endpoint exhausts its retry window without a single successful (2xx) delivery, XPay automatically disables it and emits an endpoint-disabled event to alert you, so a permanently broken or misconfigured endpoint surfaces as a notification rather than silently burning retry budget forever. Re-enable it from the dashboard once the handler is fixed.

The implication for handler design: transient errors (a downstream API timing out, a database temporarily unreachable) are fine to surface as 500s, because XPay will retry. But persistent errors (your handler throws on a specific event shape) need to be caught and 200'd, because the retry will fail the same way and just consume retry budget. The pattern is to wrap the actual fulfillment in a try/catch, log the error, return 200 to XPay, and surface the failure through your own monitoring rather than through the delivery channel.

Testing Locally

Two paths.

For pure handler logic, you don't need a tunnel. Write a unit test that constructs a request body, signs it with your endpoint secret, and runs your verifier: the signature verification docs include code you can lift directly.

For end-to-end testing against your actual handler, use a tunneling tool (ngrok, Cloudflare Tunnel, or localtunnel) to expose your local port through a public HTTPS URL, point a test-mode webhook endpoint at it, and create test payments. XPay's local webhook development guide walks through the setup.

The Workbench webhook replay also works against your tunneled local handler, which means you can debug a specific past payment by replaying its webhook against your laptop without having to recreate the conditions that produced it.

Final Thoughts

Webhook handling is the part of a payments integration that engineers tend to underweight when comparing platforms and overweight as soon as something breaks in production. The six requirements in this guide (raw body, signature verification, replay protection, idempotency, fast acknowledgement, correct error codes) are not optional. Each one prevents a specific category of production failure, and each one is small enough to live in a handler in under a hundred lines of code.

The XPay Workbench in particular is worth investing time in early. The Inspector, webhook replay, per-request logs, and health monitoring tools turn webhook debugging from a category of work into a couple of clicks. Most of the failure modes in this guide become trivial to diagnose once you've used the Workbench against a real delivery.

You can take a first test payment in five minutes at app.xpay.app: no code required.

Sources

ابدأ قبول المدفوعات

أنشئ حسابك في XPay واقبل البطاقات ووسائل الدفع المحلية، مع التسوية إلى حسابك البنكي بالجنيه المصري.