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.

Which should I use? Session-first (this guide) is the recommended integration: create the session on your server, then mount the element with its clientSecret. Use deferred mode only when the final amount is not settled until the customer clicks pay. Deferred mode has no promotion codes, no line item updates, and no fee display before the session exists, and the payment is refused with amount_reconfirmation_required if the session total differs from the displayed amount.

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

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

<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 "@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:

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

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.

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

The result shape (same in both):

  • { type: "success", session }: the customer finished checkout. session is the updated Checkout SessionAPI with status: { type: "complete", paymentStatus }. paymentStatus is "paid" for cards and ValU and "unpaid" for Fawry, where the customer pays a reference afterwards. See Asynchronous payments.
  • { 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's page, and the Fawry reference all open in an overlay on your page while confirm() is awaiting. The Promise resolves when the customer is done with that step.

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.

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

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.

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

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

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

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

Deferred mode: create the session at pay time

Use deferred mode when the final amount is not settled until the customer clicks pay. The element mounts from an amount and currency alone, with no session. Your server creates the session at pay time with the final total, and the browser confirms with its clientSecret.

One rule governs the whole flow: the customer pays exactly the amount the element displayed, or nothing happens. If the session your server creates has a different total, the confirmation fails with amount_reconfirmation_required and nothing is charged. Update the element's amount to the real total and confirm again.

At pay time, call elements.submit() first, directly from the customer's click. It checks the form, and when the customer picked Apple Pay it opens the Apple Pay sheet. Stop if it returns an error. Then create the session and confirm. See Apple Pay.

const xpay = await loadXPay("pk_test_...");

// No session yet: mount from the amount alone.
const elements = xpay.elements({
  mode: "payment",
  amount: 149900, // minor units
  currency: "EGP",
});

const paymentElement = elements.create("payment");
paymentElement.mount("#payment-element");

// When the displayed total changes (cart updates), update the element.
await elements.update({ amount: 179900 });

// At pay: your server creates the session with the final total,
// then the browser confirms with its clientSecret.
document.getElementById("pay-button")!.addEventListener("click", async () => {
  // Validate first. For Apple Pay, this opens the Apple Pay sheet.
  const { error: submitError } = await elements.submit();
  if (submitError) {
    document.getElementById("error")!.textContent = submitError.message;
    return;
  }

  const res = await fetch("/api/create-checkout", { method: "POST" });
  const { clientSecret, error } = await res.json();
  if (!res.ok || !clientSecret) {
    document.getElementById("error")!.textContent = error ?? "Could not start the payment.";
    return; // never confirm without a valid clientSecret
  }

  const result = await xpay.confirmPayment({ elements, clientSecret });
  if (result.type === "error") {
    document.getElementById("error")!.textContent = result.error.message;
    return;
  }
  window.location.href = "/thank-you";
});

Retries: one session per checkout

Create the session on the first Pay click and keep it. A retry confirms with the same clientSecret, so the whole transaction lifecycle (declines, retry history, the final charge) stays on one Payment Intent. If the cart changed between attempts, your server updates the existing session's line items (PATCH /checkout/sessions/:id) to the new total, then the browser confirms with the same secret. Never create a session per attempt: that mints a new Payment Intent for every retry and splits one purchase across many. Drop the session and create a fresh one only when it dies, which the confirm reports as checkout_session_expired.

Restrict payment methods

Pass paymentMethodTypes to render only specific methods:

const elements = xpay.elements({
  mode: "payment",
  amount: 149900,
  currency: "EGP",
  paymentMethodTypes: ["card"],
});

The list is narrow-only: it is intersected with the methods enabled for your account, so a type you have not enabled is never rendered. If nothing survives the intersection the element fails to load with a loaderror instead of rendering empty. The list is fixed for the element's lifetime. Pass the same values as paymentMethodTypes when your server creates the session, so what you display is what the server accepts.

One method per element (["card"]) is the pattern for rendering each method as its own row in your own selector. Pair it with elements.create("payment", { layout: "tabs" }): with exactly one method the tabs layout renders only the method's content, with no logo or title, so your own row's logo and title are not repeated. The default accordion layout instead shows a static logo-and-name header above the content when there is one method. See layout for both presentations.

What deferred mode does not do:

  • No promotion codes or line item updates from the browser. The session exists only at pay time; total changes go through your server's PATCH.
  • No fee display before the session exists. Fee pass-through needs a session-first flow.
  • confirmPayment requires clientSecret as a plain string. Await your own fetch before calling.

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