docs
IntegrateIntegration patterns

Hosted Checkout

Server-only integration. Create a Checkout Session, redirect the customer to XPay's hosted page, confirm with a webhook.

Hosted Checkout is the simplest integration that involves any code. Your server creates a Checkout Session, redirects the customer to XPay's hosted checkout page, and your webhook receives checkout.session.completed when the payment lands. You don't write any frontend code.

Pick this pattern when you want a backend-only integration and you're fine with the customer redirecting away from your site for the few seconds it takes to pay.

This guide shows the minimum body needed to start a redirect-style session. For the full set of fields you can set on POST /checkout/sessions (line items, customer collection, branding, payment-method restrictions, fees, metadata, etc.), see the Checkout Session reference.

How it works

  1. Your server calls POST /checkout/sessions with the line items and where to send the customer after payment. XPay returns a session object that includes a url.
  2. Your server redirects the customer's browser to that url.
  3. The customer lands on https://checkout.xpay.app/c/cs_test_..., fills in their card, completes 3D Secure if required, and the payment runs.
  4. XPay redirects the customer back to the URL you set in afterCompletion.redirect.url.
  5. XPay posts a checkout.session.completed webhook to your endpoint so your server can confirm and fulfill the order.

The customer's experience is a 5-to-15-second redirect to XPay's hosted page and back. Your server is the only thing that holds state across the flow.

Build it

1. Get a test API key

Open the dashboard, go to Developer → API Keys, and copy a secret key that starts with sk_test_. Keep it on your server. Never put a secret key in frontend code or commit it to a repo.

You'll use the same code path with sk_live_* once your account is approved for live payments.

2. Create a Checkout Session on your server

The only field you have to send is afterCompletion, which tells XPay where to redirect the customer after payment. In practice you'll also send lineItems so XPay knows what's being charged.

curl -X POST https://api.xpay.app/checkout/sessions \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{
    "afterCompletion": {
      "type": "redirect",
      "redirect": { "url": "https://yourshop.example/order/{CHECKOUT_SESSION_ID}" }
    },
    "lineItems": [
      {
        "priceData": {
          "currency": "EGP",
          "unitAmount": 149900,
          "productData": { "name": "Test product" }
        },
        "quantity": 1
      }
    ]
  }'
const res = await fetch("https://api.xpay.app/checkout/sessions", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.XPAY_SECRET_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    afterCompletion: {
      type: "redirect",
      redirect: { url: "https://yourshop.example/order/{CHECKOUT_SESSION_ID}" },
    },
    lineItems: [
      {
        priceData: {
          currency: "EGP",
          unitAmount: 149900, // 1,499.00 EGP, in minor units
          productData: { name: "Test product" },
        },
        quantity: 1,
      },
    ],
  }),
});

const session = await res.json();
import os, requests

res = requests.post(
    "https://api.xpay.app/checkout/sessions",
    headers={
        "Authorization": f"Bearer {os.environ['XPAY_SECRET_KEY']}",
        "Content-Type": "application/json",
    },
    json={
        "afterCompletion": {
            "type": "redirect",
            "redirect": {"url": "https://yourshop.example/order/{CHECKOUT_SESSION_ID}"},
        },
        "lineItems": [
            {
                "priceData": {
                    "currency": "EGP",
                    "unitAmount": 149900,  # 1,499.00 EGP, in minor units
                    "productData": {"name": "Test product"},
                },
                "quantity": 1,
            }
        ],
    },
)
session = res.json()

The response includes the fields you need next:

{
  "id": "cs_test_AbC123...",
  "object": "checkout.session",
  "status": "open",
  "url": "https://checkout.xpay.app/c/cs_test_AbC123...",
  "amountTotal": 149900,
  "currency": "EGP",
  "afterCompletion": { "type": "redirect", "redirect": { "url": "..." } }
}

A few notes:

  • unitAmount is in minor units. 149900 means 1,499.00 EGP. Always send the full integer, not a decimal.
  • productData.name is required when you use priceData to create an ad-hoc PriceAPI. If you've already created products in the dashboard, you can pass price: "price_..." instead and skip priceData. Full reference: Line items & pricing.
  • {CHECKOUT_SESSION_ID} in redirect.url is replaced server-side with the session's id, so your return page can read the session ID off the path without you threading it through state.
  • The session expires after 24 hours if the customer never pays. You can also expire it manually with POST /checkout/sessions/:id/expire.
  • Test mode is decided by the API key, not the URL. sk_test_* produces cs_test_* sessions; sk_live_* produces cs_live_* sessions. The hosted URL hostname is the same.

3. Redirect the customer

Send an HTTP 302 to the url field of the response.

app.post("/start-checkout", async (req, res) => {
  const session = await createCheckoutSession(req.body); // your code from step 2
  res.redirect(303, session.url);
});
from flask import redirect

@app.post("/start-checkout")
def start_checkout():
    session = create_checkout_session(request.json)  # your code from step 2
    return redirect(session["url"], code=303)

Use a 303 See Other if you're redirecting from a POST request, otherwise 302 Found is fine.

4. Handle the customer's return

After the customer pays, XPay redirects them to afterCompletion.redirect.url with no extra query parameters. Your page at that URL is purely customer-facing: a thank-you message, an order summary, whatever you want.

Don't trust the redirect alone to confirm payment. A customer can close the tab before the redirect or hit the URL by accident. The single source of truth for payment success is the checkout.session.completed webhook your server receives.

If you also want to display the actual order on the return page, you can read it back with GET /checkout/sessions/:id using the session ID you stored before redirecting.

Confirm with a webhook

XPay sends checkout.session.completed as soon as a payment succeeds. The data.object inside the event is the same Checkout Session you'd get from POST /checkout/sessions or GET /checkout/sessions/:id, so your fulfillment code can be one function that reads a Checkout Session.

For every field on the session, see Checkout SessionAPI. The minimum your handler needs to do:

  1. Verify the XPay-Signature header.
  2. Parse the JSON body.
  3. If the event type is checkout.session.completed and the session status is complete, fulfill the order.

Verify the signature

XPay signs every webhook with HMAC-SHA256 using the signing secret you got when you created the webhook endpoint (it starts with whsec_). The header looks like this:

XPay-Signature: t=1730000000,v1=a1b2c3d4...

The signed payload is the timestamp, a literal ., and the raw JSON body. To verify:

import crypto from "node:crypto";

function verifyWebhook(rawBody: string, header: string, secret: string) {
  const parts = Object.fromEntries(
    header.split(",").map((kv) => kv.split("="))
  ); // { t: "1730000000", v1: "a1b2c3..." }

  const timestamp = parts.t;
  const signature = parts.v1;

  // Reject events older than 5 minutes (replay protection)
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
    throw new Error("Webhook timestamp out of tolerance");
  }

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

  if (
    !crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
  ) {
    throw new Error("Bad webhook signature");
  }
}
import hmac, hashlib, time

def verify_webhook(raw_body: bytes, header: str, secret: str):
    parts = dict(kv.split("=") for kv in header.split(","))
    timestamp = parts["t"]
    signature = parts["v1"]

    # Reject events older than 5 minutes (replay protection)
    if abs(time.time() - int(timestamp)) > 300:
        raise ValueError("Webhook timestamp out of tolerance")

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

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

Read the request body as a raw string (or bytes), not as parsed JSON. Re-serializing the JSON will not produce the same bytes XPay signed.

Read the payload

Inside checkout.session.completed, data.object is a full Checkout SessionAPI. The fields you usually care about:

FieldWhat it is
data.object.idThe Checkout Session ID (cs_test_... / cs_live_...). Use it as your idempotency key for fulfillment.
data.object.statuscomplete on a successful payment.
data.object.amountTotalTotal charged, in minor units.
data.object.currencyThree-letter ISO code, e.g. EGP.
data.object.customerThe CustomerAPI record (if you sent customerId or XPay created one from the form).
data.object.paymentIntent.idThe Payment IntentAPI ID (pi_*). Store it if you'll issue Refunds from code.
data.object.lineItemsWhat was bought, with prices and product info.
data.object.metadataAnything you attached when creating the session.

For the broader picture of how Checkout Session, Payment Intent, Charge, and Customer relate, and which IDs to keep on your order record, see Object model.

A short handler in Node.js / Express:

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

    const event = JSON.parse(req.body.toString("utf8"));

    if (
      event.type === "checkout.session.completed" &&
      event.data.object.status === "complete"
    ) {
      fulfillOrder(event.data.object); // idempotent on session.id
    }

    res.status(200).send();
  }
);

Webhook deliveries follow this retry schedule on non-2xx responses or timeouts (30 seconds): immediate, 1 minute, 5 minutes, 30 minutes, 2 hours. XPay gives up after 5 attempts.

Test it

In test mode, every Checkout Session you create returns a cs_test_* ID and a hosted checkout URL that uses XPay's sandbox processor. Use the success card 5123 4500 0000 0008 with expiry 01/39 to run the happy path. For the full test card list and the expiry-to-outcome matrix, see Test mode & test cards.

To exercise your webhook handler locally before deploying, see Local webhook development.

Production checklist

Before flipping to live mode:

  • Swap the keys. Replace sk_test_* with sk_live_* in your server environment. The API URL doesn't change.
  • Set up a live webhook endpoint. Test and live have separate webhook endpoints. Each gets its own whsec_* signing secret.
  • Use a real return URL. afterCompletion.redirect.url must be an https:// URL on a domain you control. Don't use localhost.
  • Dedupe webhook deliveries on event.id. XPay retries webhook deliveries on non-2xx or timeout, so checkout.session.completed for the same session can arrive more than once. Track event IDs you've already processed and skip duplicates.
  • Dedupe your own retries on session.id. If your POST /checkout/sessions request times out, don't blindly retry: a duplicate request creates a second open session. Store the session ID from the first successful response and look it up before retrying.
  • Consider a cancelUrl for failed payments. Set it on the session if you want to handle declined / 3DS-rejected / local-method-timeout failures on your own page instead of the XPay hosted retry. Full behavior in After completion → cancelUrl.
  • Verify signatures in production too. Don't disable signature verification "to debug." Use a webhook tunneling tool against test mode instead.

Where to next

On this page