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
- Your server calls
POST /checkout/sessionswithuiMode: "custom"and the line items. XPay returns a session that includesclientSecret. - Your server ships the
clientSecretto your frontend. - Your frontend loads the SDK, initializes the checkout with the
clientSecret, and mounts the payment element inside your form. - The customer picks a payment method and (for cards) fills the card form. Your form collects everything else.
- Your code calls
checkout.confirm({ customerDetails }). The Promise resolves with the result, or the page navigates away if you setredirect: "always". - XPay posts a
checkout.session.completedwebhook 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, orcancelUrl. 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 frontendimport 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 frontendA few rules worth knowing up front:
- CUSTOM-type prices are rejected for
uiMode: "custom". Build the session with a fixedunitAmountinstead. CUSTOM-type prices need our hosted amount-entry input, which you don't get with Elements. paymentMethodTypesandpaymentMethodConfigurationIdstill work for restricting which methods the payment element shows.brandingSettingsstill works as a baseline. Your runtimeappearanceoverrides it.
3. Set up the SDK
Install the JS SDK:
npm install @xpay/sdk-jsLoad 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-reactWrap 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.completefrom the payment element. Tracks whether the customer's filled the form enough to attempt a payment. Use it to disable your pay button.checkout.canConfirmis XPay's own gate. Both flags need to be true before you callconfirm().checkout.on("change", session => ...)fires every time the session changes (promo code applied, quantity updated, fee recalculated). Use it to refresh your DOM. WithinitCheckout, thecheckoutobject's fields aren't reactive on their own, so subscribe tochangefor 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.typenarrowing.loadingwhile the session loads,errorif it fails to load (network, invalid secret, expired session),successonce everything's ready.checkout.status.typenarrowing. Withinsuccess, the session itself can beopen,expired, orcomplete. Render different UI for each. Onlyopenshould let the customer try to pay.event.completefrom<PaymentElement />. Tracks whether the customer's filled the form enough to attempt a payment. Use it to disable your pay button. There's alsoevent.empty,event.collapsed,event.value.type(selected method), andevent.session(full session snapshot).checkout.canConfirmis XPay's own gate. Both flags need to be true before you callconfirm().- No manual
changelistener needed.useCheckout()re-renders automatically when totals, line items, or discounts change, so readingcheckout.amountTotalalways 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.sessionis the updated Checkout SessionAPI withstatus: { type: "complete", paymentStatus: "paid" }.{ type: "error", error }: the payment failed.errorcarriestype,code,message, and decline-specific fields likedeclineCodefor 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.
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. |
// 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.appin your CSP. The payment element iframe and the SDK script both load from there. If you setframe-srcorscript-srcdirectives, 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. IfPOST /checkout/sessionstimes 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
Object model
How Checkout Session, Payment Intent, Charge, and Customer relate, and which IDs to keep on your order record.
Refunds
Reverse a successful payment using the pi_* from result.session.paymentIntent.id.
Branding & locale
Every field appearance accepts, plus locale resolution rules.
Webhooks setup
Add an endpoint and grab the whsec_* signing secret.
Test mode and test cards
The test card list and the outcomes you can simulate.
Want a different pattern?
Compare Elements against Hosted Checkout, Drop-in, and Payment Links.