# Elements (/en/integrate/integration-patterns/elements)

Build your own checkout UI. Your form collects customer details, our PaymentElement handles cards and local methods, your code calls confirm(). Maximum control.

Elements lets you build the entire checkout UI on your own page: your contact form, your order summary, your buttons, your styling. XPay provides one drop-in component (`<PaymentElement />` or `paymentElement.mount(...)` in vanilla) that handles the payment-method picker, the card form, 3D Secure, and local methods like Valu and Fawry. Everything else is your code.

Pick this pattern when you need full control over the layout, want to interleave checkout fields with your own UX (steppers, progress bars, address autocomplete), or are integrating into an existing design system. If "looks great as a modal" is enough, [Drop-in](/integrate/integration-patterns/drop-in) is half the code.

The code samples below cover both **Vanilla JavaScript** (any framework, plain HTML) and **React**. The two SDKs share the same API; pick the tab that matches your stack. Every page on this section is dual-coverage so non-React frameworks (Vue, Svelte, Solid, Angular, Lit, plain HTML) get equal treatment.

<Callout type="info">
  Elements is the only pattern where `uiMode: "custom"` is required on the Checkout Session. That
  mode rejects every server-side collection toggle (`nameCollection`, `phoneNumberCollection`,
  `billingAddressCollection`, `shippingAddressCollection`), `submitType`, `cancelUrl`, and
  CUSTOM-type prices, because your form owns all of them. For the full session reference, see
  [Checkout Session](/integrate/checkout-session/overview).
</Callout>

## How it works [#how-it-works]

1. **Your server** calls `POST /checkout/sessions` with `uiMode: "custom"` and the line items. XPay returns a session that includes `clientSecret`.
2. **Your server** ships the `clientSecret` to your frontend.
3. **Your frontend** loads the SDK, initializes the checkout with the `clientSecret`, and mounts the payment element inside your form.
4. **The customer** picks a payment method and (for cards) fills the card form. Your form collects everything else.
5. **Your code** calls `checkout.confirm({ customerDetails })`. The Promise resolves with the result, or the page navigates away if you set `redirect: "always"`.
6. **XPay** posts a `checkout.session.completed` webhook to your endpoint independently.

The payment element runs in an iframe pointing at `https://checkout.xpay.app`. Card data never enters your DOM.

## Build it [#build-it]

### 1. Get test API keys [#1-get-test-api-keys]

Same as Drop-in: `sk_test_*` for your server, `pk_test_*` for your frontend. Find both in the dashboard under **Developer → API Keys**.

### 2. Create a Checkout Session with `uiMode: "custom"` [#2-create-a-checkout-session-with-uimode-custom]

The session looks like a normal one, with two key differences:

* **`uiMode: "custom"`** is required.
* **Don't set `nameCollection`, `phoneNumberCollection`, `billingAddressCollection`, `shippingAddressCollection`, `submitType`, or `cancelUrl`.** They're all rejected at create time. Your form is responsible for them.

<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 '{
        "uiMode": "custom",
        "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({
        uiMode: "custom",
        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 session = await res.json();
    // Send `session.clientSecret` to your frontend
    ```
  </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={
            "uiMode": "custom",
            "afterCompletion": {
                "type": "redirect",
                "redirect": {"url": "https://yourshop.example/order/{CHECKOUT_SESSION_ID}"},
            },
            "lineItems": [
                {
                    "priceData": {
                        "currency": "EGP",
                        "unitAmount": 149900,
                        "productData": {"name": "Test product"},
                    },
                    "quantity": 1,
                }
            ],
        },
        timeout=10,
    )
    session = res.json()
    # Send `session["clientSecret"]` to your frontend
    ```
  </Tab>
</Tabs>

A few rules worth knowing up front:

* **CUSTOM-type prices are rejected for `uiMode: "custom"`.** Build the session with a fixed `unitAmount` instead. CUSTOM-type prices need our hosted amount-entry input, which you don't get with Elements.
* **`paymentMethodTypes` and `paymentMethodConfigurationId` still work** for restricting which methods the payment element shows.
* **`brandingSettings` still works** as a baseline. Your runtime `appearance` overrides it.

### 3. Set up the SDK [#3-set-up-the-sdk]

<Tabs items="[&#x22;Vanilla JavaScript&#x22;, &#x22;React&#x22;]">
  <Tab value="Vanilla JavaScript">
    Install the JS SDK:

    ```bash
    npm install @xpayeg/sdk
    ```

    Load XPay at module scope (or with the CDN script tag if you're not bundling), then call `xpay.initCheckout({ clientSecret })` once you have a session. The returned `checkout` object carries both session data and action methods.

    ```html
    <form id="checkout-form">
      <!-- Your contact-info fields, your order summary, your styling -->
      <input id="email" type="email" required />
      <input id="name" type="text" required />

      <!-- The one piece you don't build -->
      <div id="payment-element"></div>

      <button id="pay-button" type="submit" disabled>Pay</button>
      <p id="error" role="alert"></p>
    </form>
    ```

    ```ts
    import { loadXPay } from "@xpayeg/sdk";

    // Module level: call once, share across the page.
    const xpayPromise = loadXPay("pk_test_...");

    async function start(clientSecret: string) {
      const xpay = await xpayPromise;
      if (!xpay) return; // SSR returns null on the server

      // Returns a single object with session fields and action methods merged together.
      const checkout = await xpay.initCheckout({ clientSecret });

      // Handle terminal session states before mounting anything.
      if (checkout.status.type === "expired") {
        showExpiredView();
        return;
      }
      if (checkout.status.type === "complete") {
        showAlreadyPaidView();
        return;
      }

      // Mount the payment element (next step).
      mountPaymentElement(checkout);
    }
    ```

    If you're not on a bundler, drop a script tag and use the global `XPay` factory the runtime exposes:

    ```html
    <script src="https://checkout.xpay.app/v1/sdk.js"></script>
    <script type="module">
      const xpay = XPay("pk_test_...");
      const checkout = await xpay.initCheckout({ clientSecret });
    </script>
    ```
  </Tab>

  <Tab value="React">
    Install both SDK packages:

    ```bash
    npm install @xpayeg/sdk @xpayeg/react
    ```

    Wrap the part of your app that renders the checkout in `<XPayProvider>`. Load the SDK at module scope, not inside a component. The Provider accepts the Promise directly.

    ```tsx
    import { XPayProvider } from "@xpayeg/react";
    import { loadXPay } from "@xpayeg/sdk";

    // Module level (runs once per page)
    const xpayPromise = loadXPay("pk_test_...");

    export default function CheckoutPage({ clientSecret }: { clientSecret: string }) {
      return (
        <XPayProvider xpay={xpayPromise} options={{ clientSecret }}>
          <CheckoutForm />
        </XPayProvider>
      );
    }
    ```

    `options.clientSecret` accepts both `string` and `Promise<string>`, so if you fetch the session from your own backend you can pass the Promise directly without managing a loading state.
  </Tab>
</Tabs>

### 4. Mount the payment element and gate the pay button [#4-mount-the-payment-element-and-gate-the-pay-button]

<Tabs items="[&#x22;Vanilla JavaScript&#x22;, &#x22;React&#x22;]">
  <Tab value="Vanilla JavaScript">
    Get an `Elements` instance from the checkout, create a `PaymentElement`, mount it into your form's container, and listen for `change` events to track form completeness.

    ```ts
    function mountPaymentElement(checkout) {
      const elements = checkout.getElements();
      const paymentElement = elements.create("payment");
      paymentElement.mount("#payment-element");

      const payButton = document.getElementById("pay-button") as HTMLButtonElement;
      let paymentReady = false;

      paymentElement.on("change", (event) => {
        paymentReady = event.complete;
        payButton.disabled = !paymentReady || !checkout.canConfirm;
      });

      // Update the button label when totals change (promo codes, quantities, etc.)
      checkout.on("change", (session) => {
        payButton.textContent = `Pay ${session.currency} ${(session.amountTotal / 100).toFixed(2)}`;
      });
    }
    ```

    A few things going on:

    * **`event.complete` from the payment element.** Tracks whether the customer's filled the form enough to attempt a payment. Use it to disable your pay button.
    * **`checkout.canConfirm`** is XPay's own gate. Both flags need to be true before you call `confirm()`.
    * **`checkout.on("change", session => ...)`** fires every time the session changes (promo code applied, quantity updated, fee recalculated). Use it to refresh your DOM. With `initCheckout`, the `checkout` object's fields aren't reactive on their own, so subscribe to `change` for any totals or summaries you render.
  </Tab>

  <Tab value="React">
    `useCheckout()` returns a tagged union. Narrow on `type` before reading `checkout` data or calling action methods.

    ```tsx
    "use client";

    import { useState } from "react";
    import { PaymentElement, useCheckout } from "@xpayeg/react";

    function CheckoutForm() {
      const state = useCheckout();
      const [paymentReady, setPaymentReady] = useState(false);

      if (state.type === "loading") return <Skeleton />;
      if (state.type === "error") return <ErrorView message={state.error.message} />;

      const { checkout } = state;

      // Handle terminal states
      if (checkout.status.type === "expired") return <ExpiredView />;
      if (checkout.status.type === "complete") return <AlreadyPaidView />;

      return (
        <form>
          {/* Your contact-info form, your order summary, your styling */}
          <YourContactFields />

          {/* The one piece you don't build */}
          <PaymentElement
            onChange={(event) => setPaymentReady(event.complete)}
            onLoadError={(err) => console.error(err.message)}
          />

          <PayButton
            disabled={!paymentReady || !checkout.canConfirm}
            amount={checkout.amountTotal}
            currency={checkout.currency}
          />
        </form>
      );
    }
    ```

    A few things going on:

    * **`state.type` narrowing.** `loading` while the session loads, `error` if it fails to load (network, invalid secret, expired session), `success` once everything's ready.
    * **`checkout.status.type` narrowing.** Within `success`, the session itself can be `open`, `expired`, or `complete`. Render different UI for each. Only `open` should let the customer try to pay.
    * **`event.complete` from `<PaymentElement />`.** Tracks whether the customer's filled the form enough to attempt a payment. Use it to disable your pay button. There's also `event.empty`, `event.collapsed`, `event.value.type` (selected method), and `event.session` (full session snapshot).
    * **`checkout.canConfirm`** is XPay's own gate. Both flags need to be true before you call `confirm()`.
    * **No manual `change` listener needed.** `useCheckout()` re-renders automatically when totals, line items, or discounts change, so reading `checkout.amountTotal` always gives you the latest value.
  </Tab>
</Tabs>

### 5. Confirm the payment [#5-confirm-the-payment]

Wire your pay button to `checkout.confirm()`. You provide the customer details your form collected; XPay handles 3D Secure, redirect-based methods, and the result.

<Tabs items="[&#x22;Vanilla JavaScript&#x22;, &#x22;React&#x22;]">
  <Tab value="Vanilla JavaScript">
    ```ts
    document.getElementById("checkout-form")!.addEventListener("submit", async (e) => {
      e.preventDefault();

      const errorEl = document.getElementById("error")!;
      errorEl.textContent = "";

      const result = await checkout.confirm({
        customerDetails: {
          email: (document.getElementById("email") as HTMLInputElement).value,
          name: (document.getElementById("name") as HTMLInputElement).value,
          // Optional billing/shipping details
        },
        // "if_required" (default): result returns to your code; navigate yourself
        // "always": redirects to afterCompletion.redirect.url on success
        redirect: "if_required",
      });

      if (result.type === "error") {
        errorEl.textContent = result.error.message;
        return;
      }

      // Success: navigate, show a success view, etc.
      window.location.href = `/success?session_id=${checkout.id}`;
    });
    ```
  </Tab>

  <Tab value="React">
    ```tsx
    async function handleSubmit() {
      setSubmitting(true);
      setError("");

      const result = await checkout.confirm({
        customerDetails: {
          email,
          name,
          phone,
          // Optional billing/shipping details
          billingDetails: { address: { line1, city, country } },
        },
        // "if_required" (default): result returns to your code; navigate yourself
        // "always": redirects to afterCompletion.redirect.url on success
        redirect: "if_required",
      });

      if (result.type === "error") {
        setError(result.error.message);
        setSubmitting(false);
        return;
      }

      // Success: navigate, show a success view, etc.
      router.push(`/success?session_id=${checkout.id}`);
    }
    ```
  </Tab>
</Tabs>

The `result` shape (same in both):

* **`{ type: "success", session }`**: the payment succeeded. `session` is the updated <ApiLink href="/api-reference/objects/checkout-session">Checkout Session</ApiLink> with `status: { type: "complete", paymentStatus: "paid" }`.
* **`{ type: "error", error }`**: the payment failed. `error` carries `type`, `code`, `message`, and decline-specific fields like `declineCode` for card errors. See [Payment errors](/integrate/errors/payment-errors).

3D Secure challenges, Valu confirm dialogs, Fawry reference popups: all handled inside the payment element while `confirm()` is awaiting. The Promise resolves only after the full payment flow completes (or fails).

## Handle session state changes [#handle-session-state-changes]

Action methods return `Promise<ActionResult>` with the same `success` / `error` shape as `confirm()`. The API is identical between vanilla and React; the difference is how your UI reacts.

```ts
// Promotion code
const result = await checkout.applyPromotionCode("SAVE20");
if (result.type === "error") setPromoError(result.error.message);

// Remove the applied code
await checkout.removePromotionCode();

// Update a line item's quantity
await checkout.updateLineItemQuantity({ lineItem: "li_test_abc", quantity: 3 });

// Re-fetch the session from the server (after a server-side change)
await checkout.fetchUpdates();
```

<Tabs items="[&#x22;Vanilla JavaScript&#x22;, &#x22;React&#x22;]">
  <Tab value="Vanilla JavaScript">
    `checkout` is a plain object, so its fields don't auto-update. Subscribe to `change` to refresh anything you render from the session.

    ```ts
    checkout.on("change", (session) => {
      totalEl.textContent = `${session.currency} ${(session.amountTotal / 100).toFixed(2)}`;
      // Re-render line items, discounts, fee breakdown, etc.
    });
    ```

    The handler receives the full updated `CheckoutSession` snapshot.
  </Tab>

  <Tab value="React">
    `useCheckout()` re-renders automatically after every action, so `checkout.amountTotal`, `checkout.totalDetails`, and `checkout.lineItems` all reflect the new server state on the next render. No `on("change", ...)` subscription is needed for the basic case.

    If you want to react to changes outside the render tree (logging, analytics), you can still subscribe via `useEffect`:

    ```tsx
    useEffect(() => {
      if (state.type !== "success") return;
      state.checkout.on("change", (session) => {
        analytics.track("checkout_updated", { amount: session.amountTotal });
      });
    }, [state]);
    ```
  </Tab>
</Tabs>

## Customize appearance at runtime [#customize-appearance-at-runtime]

`appearance` accepts the same shape as `brandingSettings` on the session. For the full field list, see [Advanced configuration → Branding](/integrate/checkout-session/advanced-configuration#branding).

<Tabs items="[&#x22;Vanilla JavaScript&#x22;, &#x22;React&#x22;]">
  <Tab value="Vanilla JavaScript">
    Pass `appearance` on `initCheckout`, or call `checkout.changeAppearance(...)` later.

    ```ts
    const checkout = await xpay.initCheckout({
      clientSecret,
      appearance: {
        colorMode: "dark",
        borderStyle: "rounded",
        colors: { primary: "#635bff" },
      },
      locale: "ar",
    });

    // Later, sync with your site's theme toggle.
    themeToggle.addEventListener("change", () => {
      checkout.changeAppearance({
        colorMode: themeToggle.checked ? "dark" : "light",
      });
    });
    ```
  </Tab>

  <Tab value="React">
    Pass `options.appearance` on `<XPayProvider>` to set the initial look, or call `checkout.changeAppearance(...)` later.

    ```tsx
    <XPayProvider
      xpay={xpayPromise}
      options={{
        clientSecret,
        appearance: {
          colorMode: "dark",
          borderStyle: "rounded",
          colors: { primary: "#635bff" },
        },
        locale: "ar",
      }}
    >
      <CheckoutForm />
    </XPayProvider>
    ```

    A common pattern: sync XPay's appearance with your site's theme toggle.

    ```tsx
    function CheckoutForm({ checkout }: { checkout: Checkout }) {
      const { resolvedTheme } = useTheme(); // your app's theme hook

      useEffect(() => {
        checkout.changeAppearance({
          colorMode: resolvedTheme === "dark" ? "dark" : "light",
        });
      }, [resolvedTheme, checkout]);

      return /* ... */;
    }
    ```
  </Tab>
</Tabs>

## Confirm strategies [#confirm-strategies]

`confirm()` has two redirect modes. Pick the one that matches your post-payment routing. The API is identical in vanilla and React.

| `redirect`                | What happens on success                                                                                 |
| ------------------------- | ------------------------------------------------------------------------------------------------------- |
| `"if_required"` (default) | The Promise resolves with `{ type: "success", session }`. Your code navigates / renders the success UI. |
| `"always"`                | The page navigates to `afterCompletion.redirect.url`. Code after `await` only runs on error.            |

Both use the same destination: the session's `afterCompletion.redirect.url`, set when your server creates the session.

```ts
// Handle success in your code
const result = await checkout.confirm({ customerDetails: { email, name } });
if (result.type === "success") {
  // navigate, show a success view, etc.
}

// Or let XPay navigate to the session's afterCompletion.redirect.url
await checkout.confirm({
  customerDetails: { email, name },
  redirect: "always",
});
// ^ On success, the page navigates away. Code below only runs on error.
```

The first is more common in single-page apps. The second is simpler if you have a static success page.

## Listen for unsolicited errors [#listen-for-unsolicited-errors]

Most errors come back through the action method you called (`confirm()`, `applyPromotionCode()`, etc.). A few situations produce errors that **didn't** come from a merchant action: a session expiring during a fee recalculation when the customer changes payment method, a BIN-detection failure, a backend hiccup mid-session. Subscribe to the `error` event to handle them.

The API is identical in vanilla and React: `checkout.on("error", handler)`.

<Tabs items="[&#x22;Vanilla JavaScript&#x22;, &#x22;React&#x22;]">
  <Tab value="Vanilla JavaScript">
    ```ts
    checkout.on("error", (error) => {
      bannerEl.textContent = error.message;
    });
    ```
  </Tab>

  <Tab value="React">
    ```tsx
    useEffect(() => {
      if (state.type !== "success") return;
      state.checkout.on("error", (error) => {
        setBanner(error.message);
      });
    }, [state]);
    ```
  </Tab>
</Tabs>

The error object has the same shape that action methods return: `type`, `code`, `message`, and decline-specific fields like `declineCode` for card errors. See [Payment errors](/integrate/errors/payment-errors).

## Pre-validate before confirming [#pre-validate-before-confirming]

Call `checkout.submit()` to validate every field in the payment element before committing to `confirm()`. Useful when you want to gate a confirmation dialog or a multi-step flow. Same API in both worlds:

```ts
const { error, selectedPaymentMethod } = await checkout.submit();
if (error) {
  setError(error.message);
  return;
}

// Fields are valid; show your confirm dialog or continue
const confirmed = await showDialog(`Pay with ${selectedPaymentMethod}?`);
if (!confirmed) return;

const result = await checkout.confirm({ customerDetails: { email, name } });
```

`submit()` resolves with the validated payment method type, so you can mention it in your dialog ("Pay with card?", "Pay with Valu?").

## Confirm with a webhook [#confirm-with-a-webhook]

The `confirm()` Promise resolving successfully is a UX courtesy. &#x2A;*Your server must still listen for `checkout.session.completed`** to mark the order paid. Customers can lose connection between `confirm()` and your `setState`, your handler can throw, the SDK can drop the result. The webhook is the source of truth.

For the full handler walkthrough (signature verification, payload field table, retry behavior), see [Hosted Checkout → Confirm with a webhook](/integrate/integration-patterns/hosted-checkout#confirm-with-a-webhook). The handler code is identical regardless of which integration pattern produced the session.

## Test it [#test-it]

In test mode, the success card `5123 4500 0000 0008` with expiry `01/39` runs the happy path inside the payment element. The card form, 3D Secure challenge, and result all flow through `confirm()`. For the full test card list and the expiry-to-outcome matrix, see [Test mode and 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.** `sk_live_*` on the server, `pk_live_*` on the frontend. The API URL doesn't change.
* **Set up a live webhook endpoint.** Test and live have separate endpoints, each with its own `whsec_*` signing secret.
* **Verify on the webhook, not on the `confirm()` result.** Treat the resolved success as a render hint, not authorization.
* **Don't ship the secret key.** `sk_*` is server-only. The SDK takes a publishable key (`pk_*`).
* **Allow `https://checkout.xpay.app` in your CSP.** The payment element iframe and the SDK script both load from there. If you set `frame-src` or `script-src` directives, list it.
* **Dedupe webhook deliveries on `event.id`.** XPay retries on non-2xx or timeout, so the same event can arrive twice.
* **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. See [Idempotency](/integrate/idempotency).
* **Handle terminal states.** `checkout.status.type === "expired"` and `"complete"` should both render dedicated views, not fall through to the form.

## 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_*` from `result.session.paymentIntent.id`.
  </Card>

  <Card icon="<Paintbrush />" title="Branding & locale" href="/integrate/checkout-session/advanced-configuration#branding">
    Every field `appearance` accepts, plus locale resolution rules.
  </Card>

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

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

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

  <Card icon="<SquareCode />" title="Vanilla HTML example" href="https://github.com/xpayeg/xpay-examples/tree/main/vanilla-html">
    `elements.html` runs this exact pattern (custom form, promo codes, and a mounted payment
    element) in one runnable file, no build step.
  </Card>

  <Card icon="<Atom />" title="Next.js example store" href="https://github.com/xpayeg/xpay-examples/tree/main/nextjs-react">
    The `/checkout?ui=custom` route builds this page's pattern with `<XPayProvider>` +
    `<PaymentElement />` in a full storefront.
  </Card>
</Cards>