CX Pay API

Webhooks

Get notified when payments succeed, fail, or need attention.

How webhooks work

When something happens (payment succeeds, charge fails, etc.), we POST a JSON payload to your webhook URL. You verify the signature, process the event, and return 200.

CX Pay ──POST──▶ https://yoursite.com/webhooks/cxpay
                  Header: CXPay-Signature: t=1712345678,v1=abc123...
                  Body:   { "type": "payment_intent.succeeded", "data": { ... } }

Your Server ◀── 200 OK

Events you'll care about

For hosted checkout, these are the events that matter:

EventWhen it firesWhat to do
payment_intent.succeededPayment capturedFulfill the order
payment_intent.payment_failedCard declined or errorShow failure to customer
checkout_session.completedCustomer finished checkoutSame as PI succeeded (redundant, but useful)
checkout_session.expiredSession timed outOptionally notify customer

Start with payment_intent.succeeded. That's the one that means "you got paid." You can add others later.

Webhook payload

{
  "id": "evt_01JQX...",
  "type": "payment_intent.succeeded",
  "created_at": "2026-04-07T18:35:00.000Z",
  "livemode": false,
  "data": {
    "id": "pi_01JQX...",
    "object": "payment_intent",
    "status": "succeeded",
    "amount": 5000,
    "currency": "USD",
    "metadata": { "order_id": "ORD-123" }
  }
}

Verifying signatures

Every webhook includes a CXPay-Signature header:

CXPay-Signature: t=1712345678,v1=a3b2c1d4e5f6...

Verification steps

  1. Parse the header — extract t (timestamp) and v1 (signature)
  2. Check freshness — reject if timestamp is older than 5 minutes
  3. ComputeHMAC-SHA256(webhook_secret, "{t}.{raw_body}")
  4. Compare — your computed signature must match v1 (constant-time comparison)

Node.js

import crypto from "crypto";

function verifyWebhook(rawBody, signatureHeader, secret) {
  const parts = signatureHeader.split(",");
  const timestamp = parts.find(p => p.startsWith("t=")).slice(2);
  const signature = parts.find(p => p.startsWith("v1=")).slice(3);

  // Check freshness (5 min tolerance)
  const age = Math.abs(Date.now() / 1000 - parseInt(timestamp));
  if (age > 300) throw new Error("Webhook too old");

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

  // Constant-time comparison
  if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
    throw new Error("Invalid signature");
  }

  return JSON.parse(rawBody);
}

Python

import hashlib, hmac, time, json

def verify_webhook(raw_body: str, signature_header: str, secret: str):
    parts = dict(p.split("=", 1) for p in signature_header.split(","))
    timestamp, signature = parts["t"], parts["v1"]

    if abs(time.time() - int(timestamp)) > 300:
        raise ValueError("Webhook too old")

    expected = hmac.new(
        secret.encode(), f"{timestamp}.{raw_body}".encode(), hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(signature, expected):
        raise ValueError("Invalid signature")

    return json.loads(raw_body)

Didn't receive a webhook?

Webhooks are delivered within seconds, but networks aren't perfect. If you haven't received one after 5 minutes, poll the API:

GET /payment-intents/{pi_id}

This returns the current Payment Intent status. See Get Payment Intent.

Don't rely solely on webhooks. Always have a fallback: poll the Payment Intent status if you haven't heard back in 5 minutes. Belt and suspenders.

Retry behavior

If your endpoint returns a non-2xx status or times out:

  • We retry up to 3 times with exponential backoff
  • Retries happen at ~1 min, ~5 min, and ~30 min
  • After all retries fail, the delivery is marked as failed

Your endpoint should be idempotent — processing the same event twice shouldn't cause problems. Use the id field to deduplicate.

On this page