docs
IntegrateIntegration 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 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.

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.

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

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"

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.
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
      }
    ]
  }'
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
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

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

Install the JS SDK:

npm install @xpay/sdk-js

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.

<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>
import { loadXPay } from "@xpay/sdk-js";

// 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:

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

Install both SDK packages:

npm install @xpay/sdk-js @xpay/sdk-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.

import { loadXPay } from "@xpay/sdk-js";
import { XPayProvider } from "@xpay/sdk-react";

// 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.

4. Mount the payment element and gate the pay button

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.

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.

useCheckout() returns a tagged union. Narrow on type before reading checkout data or calling action methods.

"use client";

import { useState } from "react";
import { useCheckout, PaymentElement } from "@xpay/sdk-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.

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.

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}`;
});
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}`);
}

The result shape (same in both):

  • { type: "success", session }: the payment succeeded. session is the updated Checkout SessionAPI 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.

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

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.

// 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();

checkout is a plain object, so its fields don't auto-update. Subscribe to change to refresh anything you render from the session.

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.

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:

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

Customize appearance at runtime

appearance accepts the same shape as brandingSettings on the session. For the full field list, see Advanced configuration → Branding.

Pass appearance on initCheckout, or call checkout.changeAppearance(...) later.

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",
  });
});

Pass options.appearance on <XPayProvider> to set the initial look, or call checkout.changeAppearance(...) later.

<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.

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

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

  return /* ... */;
}

Confirm strategies

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

redirectWhat 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.
// Pattern A: handle success in your code
const result = await checkout.confirm({ customerDetails: { email, name } });
if (result.type === "success") {
  // navigate, show a success view, etc.
}

// Pattern B: let XPay redirect you
await checkout.confirm({
  customerDetails: { email, name },
  redirect: "always",
});
// ^ On success, the page navigates away. Code below only runs on error.

// Pattern B with a custom return URL (overrides the session's afterCompletion.redirect.url)
await checkout.confirm({
  customerDetails: { email, name },
  redirect: "always",
  returnUrl: "https://yourshop.example/checkout/done",
});

Pattern A is more common in single-page apps. Pattern B is simpler if you have a static success page.

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).

checkout.on("error", (error) => {
  bannerEl.textContent = error.message;
});
useEffect(() => {
  if (state.type !== "success") return;
  state.checkout.on("error", (error) => {
    setBanner(error.message);
  });
}, [state]);

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.

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:

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

The confirm() Promise resolving successfully is a UX courtesy. 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. The handler code is identical regardless of which integration pattern produced the session.

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.

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

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.
  • Dedupe your own session creates on session.id. If POST /checkout/sessions times out, store the session ID from the first successful response and reuse it.
  • Handle terminal states. checkout.status.type === "expired" and "complete" should both render dedicated views, not fall through to the form.

Where to next

On this page