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 OKEvents you'll care about
For hosted checkout, these are the events that matter:
| Event | When it fires | What to do |
|---|---|---|
payment_intent.succeeded | Payment captured | Fulfill the order |
payment_intent.payment_failed | Card declined or error | Show failure to customer |
checkout_session.completed | Customer finished checkout | Same as PI succeeded (redundant, but useful) |
checkout_session.expired | Session timed out | Optionally 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
- Parse the header — extract
t(timestamp) andv1(signature) - Check freshness — reject if timestamp is older than 5 minutes
- Compute —
HMAC-SHA256(webhook_secret, "{t}.{raw_body}") - 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.