# Hosted Checkout (/en/integrate/integration-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.

<Callout type="info">
  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](/integrate/checkout-session/overview) reference.
</Callout>

## How it works [#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 [#build-it]

### 1. Get a test API key [#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 [#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.

<Tabs items="[&#x22;cURL&#x22;, &#x22;Node.js&#x22;, &#x22;Python&#x22;]">
  <Tab value="cURL">
    ```bash
    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
          }
        ]
      }'
    ```
  </Tab>

  <Tab value="Node.js">
    ```typescript
    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();
    ```
  </Tab>

  <Tab value="Python">
    ```python
    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()
    ```
  </Tab>
</Tabs>

The response includes the fields you need next:

```json
{
  "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 <ApiLink href="/api-reference/objects/price">Price</ApiLink>. If you've already created products in the dashboard, you can pass `price: "price_..."` instead and skip `priceData`. Full reference: [Line items & pricing](/integrate/checkout-session/line-items-and-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 [#3-redirect-the-customer]

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

<Tabs items="[&#x22;Node.js (Express)&#x22;, &#x22;Python (Flask)&#x22;]">
  <Tab value="Node.js (Express)">
    ```typescript
    app.post("/start-checkout", async (req, res) => {
      const session = await createCheckoutSession(req.body); // your code from step 2
      res.redirect(303, session.url);
    });
    ```
  </Tab>

  <Tab value="Python (Flask)">
    ```python
    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)
    ```
  </Tab>
</Tabs>

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

### 4. Handle the customer's return [#4-handle-the-customers-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 [#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 <ApiLink href="/api-reference/objects/checkout-session">Checkout Session</ApiLink>. 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 [#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:

<Tabs items="[&#x22;Node.js&#x22;, &#x22;Python&#x22;]">
  <Tab value="Node.js">
    ```typescript
    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");
      }
    }
    ```
  </Tab>

  <Tab value="Python">
    ```python
    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")
    ```
  </Tab>
</Tabs>

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 [#read-the-payload]

Inside `checkout.session.completed`, `data.object` is a full <ApiLink href="/api-reference/objects/checkout-session">Checkout Session</ApiLink>. The fields you usually care about:

| Field                          | What it is                                                                                                                                                        |
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `data.object.id`               | The Checkout Session ID (`cs_test_...` / `cs_live_...`). Use it as your idempotency key for fulfillment.                                                          |
| `data.object.status`           | `complete` on a successful payment.                                                                                                                               |
| `data.object.amountTotal`      | Total charged, in minor units.                                                                                                                                    |
| `data.object.currency`         | Three-letter ISO code, e.g. `EGP`.                                                                                                                                |
| `data.object.customer`         | The <ApiLink href="/api-reference/objects/customer">Customer</ApiLink> record (if you sent `customerId` or XPay created one from the form).                       |
| `data.object.paymentIntent.id` | The <ApiLink href="/api-reference/objects/payment-intent">Payment Intent</ApiLink> ID (`pi_*`). Store it if you'll issue [Refunds](/integrate/refunds) from code. |
| `data.object.lineItems`        | What was bought, with prices and product info.                                                                                                                    |
| `data.object.metadata`         | Anything 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](/integrate/object-model).

A short handler in Node.js / Express:

```typescript
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 [#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](/get-started/test-mode).

To exercise your webhook handler locally before deploying, see [Local webhook development](/integrate/webhooks/local-development).

## Production checklist [#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.
* **Send an `Idempotency-Key` on `POST /checkout/sessions`.** If the request times out, retrying with the same key returns the original session instead of opening a second one for the same cart. See [Idempotency](/integrate/idempotency).
* **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](/integrate/checkout-session/after-completion#cancelurl-where-to-send-the-customer-on-payment-failure).
* **Verify signatures in production too.** Don't disable signature verification "to debug." Use a webhook tunneling tool against test mode instead.

## Where to next [#where-to-next]

<Cards>
  <Card icon="<Network />" title="Object model" href="/integrate/object-model">
    How Checkout Session, Payment Intent, Charge, and Customer relate, and which IDs to keep on your
    order record.
  </Card>

  <Card icon="<RotateCcw />" title="Refunds" href="/integrate/refunds">
    Reverse a successful payment using the `pi_*` you stored from the webhook.
  </Card>

  <Card icon="<Webhook />" title="Webhooks setup" href="/integrate/webhooks/setting-up-an-endpoint">
    Add a webhook endpoint and grab the `whsec_*` signing secret.
  </Card>

  <Card icon="<ShieldCheck />" title="Verifying signatures" href="/integrate/webhooks/verifying-signatures">
    The signature-verification recipe in more detail, plus replay protection.
  </Card>

  <Card icon="<FlaskConical />" title="Test mode & test cards" href="/get-started/test-mode">
    The full test card list and the outcomes you can simulate by changing the expiry.
  </Card>

  <Card icon="<GitBranch />" title="Want a different pattern?" href="/get-started/choose-your-integration">
    Compare Hosted Checkout against Drop-in, Elements, and Payment Links.
  </Card>

  <Card icon="<SquareCode />" title="Vanilla HTML example" href="https://github.com/xpayeg/xpay-examples/tree/main/vanilla-html">
    `hosted-redirect.html`: create the session server-side and redirect, no SDK on the page. One
    runnable file plus a tiny Express server.
  </Card>

  <Card icon="<Atom />" title="Next.js example store" href="https://github.com/xpayeg/xpay-examples/tree/main/nextjs-react">
    The `/checkout?ui=hosted` route: session creation, redirect, and a signature-verified webhook
    receiver.
  </Card>
</Cards>