Custom Checkout with the XPay SDK: Drop-in vs Elements

Build a branded payment experience using @xpayeg/sdk and @xpayeg/react. Compare Drop-in (modal/iframe) vs Elements (fully custom) integration patterns.

March 26, 202615 min read

TL;DR

XPay's JavaScript SDK ships two integration patterns built on the same Checkout Session: Drop-in (XPay's full checkout in a modal or iframe on your domain) and Elements (a single <PaymentElement /> component you embed inside a checkout UI you build yourself). Two packages cover both patterns: @xpayeg/sdk is the core browser SDK that works with any framework or vanilla JavaScript, and @xpayeg/react is a thin React wrapper that exposes the same API as hooks and components. The choice between them is purely framework fit. Drop-in is the right pick when you want to keep the customer on your domain without building a payment form yourself; Elements is the right pick when you need full control over the checkout layout, want to interleave payment fields with your own UX (steppers, address autocomplete, custom validation), or are integrating into an existing design system. This guide walks through both patterns end-to-end, the decision tree for choosing between them, the production checklist, and how to use the Workbench to debug when something doesn't work as expected.

The Two SDK Patterns

Before any code, the architectural distinction worth understanding: every XPay integration pattern is built on the same Checkout Session API. Payment Links, Hosted Checkout, Drop-in, and Elements all create the same cs_* session under the hood and emit the same checkout.session.completed webhook on success. The only thing that changes between them is how much frontend code you write to get the customer from "intent to pay" to "successful payment."

Drop-in opens XPay's full checkout inside an iframe on your domain. Your server creates a Checkout Session with uiMode: "embedded", your frontend loads the SDK and calls xpay.checkout({ ... }), and the customer pays without ever navigating away. You write roughly ten lines of frontend code; the iframe handles payment methods, the card form, 3D Secure, and local methods like ValU and Fawry.

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 component (<PaymentElement /> or paymentElement.mount(...) in vanilla) that handles the payment-method picker, the card form, 3D Secure, and local methods. Everything else is your code.

The decision tree is short. If "looks great as a modal" is enough, use Drop-in: it's half the code. If you need to interleave payment fields with your own UX, integrate into an existing design system, or maintain pixel-level control over the checkout layout, use Elements. If you don't want any frontend code at all, use Hosted Checkout and skip the SDK entirely.

Installation

Both SDK packages are on npm. Install whichever matches your stack, or both, if you're using React, since @xpayeg/react is a thin layer over @xpayeg/sdk.

# Vanilla JavaScript / any framework
npm install @xpayeg/sdk

# React
npm install @xpayeg/sdk @xpayeg/react

If you're not bundling, the SDK is also available via a CDN script tag:

<script src="https://checkout.xpay.app/sdk.js"></script>

Loading from the CDN exposes a global XPay factory equivalent to the loadXPay import. The CDN path is the right choice for static HTML pages, server-rendered templates without a build step, or environments where adding a bundler is friction you don't need.

Two keys are required for SDK integrations: your pk_test_* (or pk_live_*) publishable key for the SDK, and your sk_test_* (or sk_live_*) secret key for the server-side Checkout Session creation. The publishable key is safe to ship to the browser; the secret key is server-only. Find both in the dashboard under Developer → API Keys. If your session-creation code runs somewhere you'd rather not place a full secret key, issue a scoped restricted key (rk_test_* / rk_live_*) limited to just the checkout permissions it needs.

Pattern 1: Drop-in

The shortest custom-checkout path. Your customer never leaves your domain, and you write minimal frontend code.

Server: create an embedded Checkout Session

The session looks identical to a Hosted Checkout session with one difference: uiMode: "embedded" tells XPay to return a clientSecret you'll ship to the frontend instead of redirecting to a hosted page.

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: "embedded",
    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

The clientSecret scopes the SDK to this specific session and can't be reused: don't store it past the session's lifetime. The cancelUrl field is rejected for embedded sessions because Drop-in surfaces failures inside the iframe with a retry; there's no "send the customer to a failure URL" path.

Frontend: vanilla JavaScript

Load the SDK at module scope, create a checkout instance, wire it to a button. Roughly ten lines:

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

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

const checkout = xpay.checkout({
  clientSecret: "cs_test_..._secret_xyz789",  // from your server
  mode: "modal",
  onComplete: (result) => {
    if (result.redirectUrl) {
      window.location.href = result.redirectUrl;
    }
  },
  onClose: () => {
    console.log("Customer closed the modal");
  },
});

document.getElementById("pay-button")?.addEventListener("click", () => {
  checkout.open();
});

mode: "modal" shows the iframe as a full-screen overlay. mode: "inline" mounts it inside a container you provide:

const checkout = xpay.checkout({
  clientSecret,
  mode: "inline",
  container: "#checkout-container",  // CSS selector or HTMLElement
  onComplete: (result) => { /* ... */ },
});

Inline mode auto-resizes to content height: you don't need to set a fixed height in CSS. When you tear down the component that owns an inline checkout, call checkout.destroy() to release the iframe and its listeners. Modal mode handles its own cleanup on close.

Frontend: React

The React SDK ships <XPayProvider> and <CheckoutButton /> so the common modal pattern is one component:

import { loadXPay } from "@xpayeg/sdk";
import { XPayProvider, CheckoutButton } from "@xpayeg/react";

// Load once at module scope, not inside a component
const xpayPromise = loadXPay("pk_test_...");

export function PayPage({ clientSecret }: { clientSecret: string }) {
  return (
    <XPayProvider xpay={xpayPromise}>
      <CheckoutButton
        clientSecret={clientSecret}
        checkoutOptions={{
          onComplete: (result) => {
            if (result.redirectUrl) window.location.href = result.redirectUrl;
          },
        }}
      >
        Pay now
      </CheckoutButton>
    </XPayProvider>
  );
}

<XPayProvider> accepts both an XPayInstance and a Promise<XPayInstance>, so passing xpayPromise directly works without managing the loading state yourself. Don't call loadXPay() inside a component body: the SDK loads from the CDN once per page and caches the instance, and module-scope loading keeps that contract clean. The React <CheckoutButton /> is modal-only; for inline mode in React, drop down to useXPay() and call xpay.checkout({ mode: "inline" }) directly, remembering to call destroy() in a cleanup useEffect.

Handle the result

Drop-in fires five events over the lifetime of one checkout. ready when the session loads and the iframe renders, confirmed when the customer hits Pay, complete on a successful payment, close when the modal closes, and error on a fatal SDK error. The complete event payload includes the paymentIntentId and optionally a redirectUrl if afterCompletion was configured.

The critical point (and this applies equally to Elements below) is that the complete event is a UX courtesy, not authoritative confirmation. Your server must still listen for checkout.session.completed to mark the order paid. A customer can close the iframe early, the network can drop, your onComplete handler can throw. The webhook is the source of truth. The webhook best practices guide walks through the signature verification recipe, idempotency, and the retry semantics.

Pattern 2: Elements

Full control over the checkout layout. Your form, your styling, your buttons. XPay provides one <PaymentElement /> (or paymentElement.mount(...) in vanilla) that handles the payment-method picker, card form, 3D Secure, and local methods. Everything else is your code.

Server: create a custom-UI Checkout Session

Two differences from the Drop-in session: uiMode: "custom" is required, and the server-side customer-collection toggles (nameCollection, phoneNumberCollection, billingAddressCollection, shippingAddressCollection) plus submitType, cancelUrl, and CUSTOM-type prices are all rejected at create time. Your form owns those fields entirely.

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

Frontend: vanilla JavaScript

Initialize the checkout once you have the clientSecret, mount the payment element inside your form, gate your pay button on the form's completeness, and call checkout.confirm() on submit:

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

const xpayPromise = loadXPay("pk_test_...");

async function start(clientSecret) {
  const xpay = await xpayPromise;
  const checkout = await xpay.initCheckout({ clientSecret });

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

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

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

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

  document.getElementById("checkout-form").addEventListener("submit", async (e) => {
    e.preventDefault();

    const result = await checkout.confirm({
      customerDetails: {
        email: document.getElementById("email").value,
        name: document.getElementById("name").value,
      },
      redirect: "if_required",
    });

    if (result.type === "error") {
      document.getElementById("error").textContent = result.error.message;
      return;
    }
    window.location.href = `/success?session_id=${checkout.id}`;
  });
}

Two flags gate confirmation. event.complete from the payment element tracks whether the customer's filled the form enough to attempt payment. checkout.canConfirm is XPay's own gate: both need to be true before you call confirm(). The 3D Secure challenge, Valu confirm dialogs, and Fawry reference popups all run inside the payment element while confirm() is awaiting; the Promise resolves only after the full payment flow completes or fails.

Frontend: React

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

"use client";

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

const xpayPromise = loadXPay("pk_test_...");

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

function CheckoutForm() {
  const state = useCheckout();
  const [paymentReady, setPaymentReady] = useState(false);
  const [email, setEmail] = useState("");
  const [name, setName] = useState("");
  const [error, setError] = useState("");

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

  const { checkout } = state;
  if (checkout.status.type === "expired") return <ExpiredView />;
  if (checkout.status.type === "complete") return <AlreadyPaidView />;

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    const result = await checkout.confirm({
      customerDetails: { email, name },
      redirect: "if_required",
    });
    if (result.type === "error") {
      setError(result.error.message);
      return;
    }
    window.location.href = `/success?session_id=${checkout.id}`;
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={email} onChange={(e) => setEmail(e.target.value)} type="email" required />
      <input value={name} onChange={(e) => setName(e.target.value)} type="text" required />

      <PaymentElement
        onChange={(event) => setPaymentReady(event.complete)}
        onLoadError={(err) => console.error(err.message)}
      />

      <button type="submit" disabled={!paymentReady || !checkout.canConfirm}>
        Pay {checkout.currency} {(checkout.amountTotal / 100).toFixed(2)}
      </button>
      {error && <p role="alert">{error}</p>}
    </form>
  );
}

The React version doesn't need a manual change listener for totals: useCheckout() re-renders automatically when totals, line items, or discounts change, so reading checkout.amountTotal always gives you the latest value.

Confirm strategies

confirm() has two redirect modes. redirect: "if_required" (the default) returns the result to your code so you can navigate or render success UI yourself. redirect: "always" makes the page navigate to afterCompletion.redirect.url on success, and code after await only runs on error. Single-page apps usually want the first; static success pages usually want the second.

Runtime appearance and locale

Both Drop-in and Elements accept an appearance option matching the session's brandingSettings shape: colorMode, borderStyle, colors.primary. The SDK runtime values win; the session's brandingSettings and your merchant defaults provide fallbacks. A common pattern is to sync XPay's appearance with your site's theme toggle in real time using checkout.changeAppearance({ colorMode: "dark" }) (or the React useEffect equivalent). locale accepts "en", "ar", or "auto" for auto-detection.

3D Secure and Local Methods

Both patterns handle 3D Secure 2 authentication automatically. When the issuing bank requires a challenge (a one-time password, a biometric prompt, an in-app push) XPay surfaces it inside the iframe (Drop-in) or inside the payment element (Elements). The challenge UI is fully managed; your code doesn't need to handle redirects, intermediate states, or 3DS-specific error codes.

Local payment methods like ValU and Fawry follow the same pattern. ValU's confirm dialog and Fawry's reference-code popup both run inside the payment element while confirm() is awaiting; the Promise resolves with the final outcome once the customer completes (or abandons) the local-method flow. There's no Drop-in-specific or Elements-specific handling for local methods: the same component covers cards, wallets, BNPL, and Fawry under one API.

Drop-in vs Elements: When to Pick Which

Five factors usually determine the right pattern.

Layout control. If you need pixel-level control over the checkout layout, the order of fields, or the spacing between elements, Elements is the right pick. Drop-in's modal/iframe is a fixed layout that XPay manages.

Interleaved UX. If you want a multi-step checkout, a progress bar, address autocomplete, or any pattern that interleaves payment fields with non-payment fields, Elements is the right pick. Drop-in is one self-contained surface.

Design system fit. If you're integrating into an existing design system with components for inputs, buttons, errors, and modals that don't match XPay's defaults, Elements lets you keep your system. Drop-in adapts via appearance but doesn't replace its layout.

Time to ship. Drop-in is half the code. If you can ship the experience without pixel-level control and customer-acceptable modal UX is enough, Drop-in saves a meaningful amount of frontend work.

Maintenance budget. Drop-in updates automatically when XPay rolls out new payment methods or checkout improvements. Elements gives you control, which also means you own more of the integration over time.

A useful default: start with Drop-in. If you hit a layout or UX constraint that Drop-in can't accommodate, migrate to Elements: the server-side Checkout Session API is identical, so the migration is mostly frontend work and your webhook handler keeps working unchanged.

Production Checklist

Before flipping to live mode, regardless of pattern:

Swap pk_test_* for pk_live_* on the frontend and sk_test_* for sk_live_* on the server. 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 the webhook signature on the server, not the SDK's complete event. Don't ship the secret key: sk_* is server-only. Allow https://checkout.xpay.app in your Content Security Policy frame-src and script-src directives. Dedupe webhook deliveries on event.id (XPay retries on non-2xx or timeout). Send an Idempotency-Key header on POST /checkout/sessions so a timed-out create is safe to retry: XPay replays the original response instead of opening a second session; as a fallback, dedupe your own session creates on session.id. Handle the expired and complete terminal session states in your UI: don't fall through to the form. The pattern-specific checklists for Drop-in and Elements include the full set.

Debugging with the Workbench

The single biggest improvement to SDK integration debugging in XPay 3.0 is the Workbench. When a session won't complete, a confirm() call fails with a confusing error, or a payment element doesn't load, the Workbench is the first stop. The Inspector takes any XPay ID (a cs_*, pi_*, ch_*) and shows the full graph of related resources, events, and request logs. Per-request logs filter every API call by error code and request ID. Webhook replay lets you resend any past delivery against a fresh handler version. Health monitoring groups failures by root cause.

For a typical "the payment element won't load" investigation, the path is: open the session ID in the Inspector, check the request log for the SDK's initCheckout call, look at the error code, follow the structured-error deep link to the documentation page that explains what triggered it. What used to be a half-hour debugging session is usually thirty seconds. The @xpayeg/sdk package also exposes structured error codes through the result.error object on confirm(): same codes, same documentation links, same fix paths.

Final Thoughts

The decision between Drop-in and Elements isn't a permanent architectural commitment. Both run on the same Checkout Session, both emit the same webhook, and both ship in the same SDK packages. Starting with Drop-in and migrating to Elements later when a UX constraint demands it is a perfectly reasonable trajectory: the server-side code doesn't change at all, and the frontend migration is well-bounded.

What does matter, regardless of pattern, is webhook handling. The SDK's success callbacks are convenient and fast, but they're not authoritative. The checkout.session.completed webhook is. For the full webhook setup, signature verification, idempotency, and retry semantics, see the companion guide on webhook best practices.

You can take a first test payment in five minutes at app.xpay.app: no code required.

Sources

Start accepting payments

Create your XPay account and accept cards and local payment methods, with settlement to your bank in EGP.