# XPay docs
The XPay payments documentation as a single markdown file.
- Machine-readable API specification: https://docs.xpay.app/openapi.json
- Page index: https://docs.xpay.app/llms.txt
- Any page also serves markdown directly. Append `.md` or `.mdx` to its URL.
This file contains the guides and SDK reference. The per-endpoint and per-object
API reference is NOT inlined below: fetch it as OpenAPI JSON from the link above,
or fetch a single page as markdown, for example
`https://docs.xpay.app/en/api-reference/objects/payment-intent.md`.
Pages included: 56.
# Introduction (/en)
Payments for Egypt businesses. Take cards and local methods via API, hosted checkout, or your own UI.
XPay is a payment platform for online businesses across Egypt. We take card payments and local methods, handle the 3D Secure challenge, and pay out to your bank account when the money settles. There are four ways to integrate, and you pick based on how much code you want to write.
## What is XPay? [#what-is-xpay]
Take Visa and Mastercard alongside local methods like Valu and Fawry.
Stripe-shaped resources, real SDKs, and a hosted checkout page that looks like a polished
consumer product. If you've integrated Stripe before, you'll feel at home in an afternoon.
Take a payment, manage the customer, issue refunds, and pay yourself out. All in one dashboard
and one API, from sale to bank.
Hosted forms in English or Arabic, mobile-first, themed with your logo and colors. No design
work required to launch.
## What XPay handles for you [#what-xpay-handles-for-you]
Visa, Mastercard, Valu, Fawry, and more. One API for every method on offer.
The 3DS challenge runs inside the hosted checkout. We deal with the bank redirect, the iframe,
and the result.
Subscribe once. We notify you the moment a payment succeeds, fails, or refunds. Signed payloads,
automatic retries, replayable from the dashboard.
Issue full or partial refunds from the dashboard or the API. The customer gets their money back
through the original payment method.
A fully separate environment with its own keys, customers, and transactions. Use the test card
list to simulate any outcome you need.
Settled funds land in your bank account on the schedule you configure. Every cent is tracked
through the balance ledger.
## Quick start [#quick-start]
Five minutes from a fresh signup to a successful test payment, no code involved. You'll create a product, generate a Payment Link, pay yourself with a test card, and watch the transaction land in your dashboard.
The fastest path through XPay end to end. Five steps, all from the dashboard.
## Pick your integration path [#pick-your-integration-path]
There are four ways to take a payment with XPay. They share the same underlying Checkout Session, so the API call and the webhook you receive are identical across all four. The only thing that changes is how much frontend code you write.
If you're not sure which to pick, start with Payment Links. You can move to a richer integration later without rewriting your backend.
Share a URL. No code at all. Best for invoices, social media checkouts, and proving the
integration works before you write a line of it.
Your server creates a Checkout Session and redirects the customer to XPay's hosted page. The
simplest path that involves any code.
The hosted checkout opens as a modal on top of your site, or embeds inline as an iframe. The
customer never leaves your domain.
Build a fully custom checkout UI using XPay's `PaymentElement` component. Most control, most
code.
## Built for builders [#built-for-builders]
When you outgrow the basics, every layer is exposed.
Paste any XPay ID and see the full graph of related resources, the events that fired against it,
and the request logs that touched it.
Click **Resend** on any past delivery. Your handler gets a fresh, signed event without you
having to re-trigger the original payment.
Every API call your account made: method, path, status, headers, body, response. Filter by error
code, drill in by request ID.
Stable error codes paired with deep-link doc URLs. The dashboard tooltip and your own error logs
both link straight to the explanation.
Recent failures grouped by root cause, sorted by frequency. Spot a deploy regression in seconds.
`@xpayeg/sdk` for vanilla, `@xpayeg/react` for React. Lightweight, typed, framework-agnostic.
## Where to next [#where-to-next]
Take your first test payment in five minutes, no code required.
A side-by-side comparison of the four patterns with a decision tree at the top. Pick once and
don't look back.
The mental map: how Checkout Session, Payment Intent, Charge, Refund, and Customer fit together,
and which IDs to keep on your order record.
Every endpoint, every field, every status code. Generated from the live OpenAPI spec, never out
of date.
# Introduction (/en/api-reference)
Complete reference for every XPay endpoint and resource.
- [Create a checkout session](/en/api-reference/checkout-sessions/createCheckoutSession) — Creates a new checkout session for hosted or embedded checkout. Returns a session with a URL to redirect customers for payment.
- [Retrieve a checkout session](/en/api-reference/checkout-sessions/getCheckoutSession) — Retrieves an existing checkout session by ID. Returns full session data including clientSecret.
- [Update a checkout session](/en/api-reference/checkout-sessions/updateMerchantCheckoutSession) — Updates an open checkout session. Immutable fields (mode, uiMode, submitType, currency, expiresAfterMinutes) cannot be changed after creation.
- [Expire a checkout session](/en/api-reference/checkout-sessions/expireCheckoutSession) — Expires an open checkout session. Expired sessions cannot be used for payment.
- [List customers](/en/api-reference/customers/listCustomers) — Lists customers with search and pagination. Returns only essential fields: id, createdAt, name, phone, email, and latestPaymentMethod. Search supports customer ID, name, email, or phone (supports comma-separated terms).
- [Create a customer](/en/api-reference/customers/createCustomer) — Creates a new customer record. At least one contact method (email or phone) is required. Customers are environment-specific (test vs live database).
- [Get customer by ID](/en/api-reference/customers/getCustomer) — Retrieves customer lifecycle information (id, type, name, email, phone, address, metadata). Response shape is identical to the `customer.*` webhook payload. For transaction counts + payment methods, call `GET /customers/:id/stats`.
- [List payment links](/en/api-reference/payment-links/listPaymentLinks) — Lists payment links with filtering, pagination, and search. Features: - Filter by active status - Search by product name, description, or unit label - Filter by amount (equal, between, greater, less) - Filter by date (last period, equal, between, after, before) - Pagination support
- [Create a payment link](/en/api-reference/payment-links/createPaymentLink) — Creates a new payment link — a shareable URL you can send to customers. Features: - Supports multiple line items with adjustable quantities - Custom fields for collecting additional customer information - After-completion settings (redirect or hosted confirmation) - Automatic amount calculation from line items - Validates currency consistency across line items
- [Get a payment link by ID](/en/api-reference/payment-links/getPaymentLink) — Retrieves a payment link with all related data including: - Line items with prices and products - Custom fields with dropdown options - After-completion settings
- [Update a payment link](/en/api-reference/payment-links/updatePaymentLink) — Partially updates an existing payment link. Only provided fields will be updated. Features: - Partial updates: only send fields you want to update - Updates line items with adjustable quantities (if provided, replaces all existing line items) - Updates custom fields (if provided, replaces all existing custom fields) - Updates after-completion settings (if provided, replaces existing settings) - Recalculates amount from line items when line items are updated - Validates currency consistency across line items when line items are updated Notes: - If `lineItems` is provided, all existing line items are replaced - If `customFields` is provided, all existing custom fields are replaced - If `customFields` is an empty array, all custom fields are removed - `expiresAt` can be set to `null` to remove expiration
- [Get product by ID](/en/api-reference/products/getProduct) — Retrieves product information including all associated prices. Response shape is identical to the `product.*` webhook payload. For per-price usage counts, call `GET /products/:id/stats`.
- [Update product](/en/api-reference/products/updateProduct) — Updates product information. Can update product data fields, default price, and image. - The `defaultPrice` field should be the ID of the Price object to set as the default price for this product - The `image` field should be a URL string (use the `/upload-url` endpoint to get a one-time upload URL first)
- [Delete product](/en/api-reference/products/deleteProduct) — Deletes a product and cascade-deletes all of its prices in one transaction. Fails if any of the product's prices have ever been referenced by a line item — in that case the product must be archived instead.
- [List products](/en/api-reference/products/listProducts) — Lists products with search and filters. Supports: - Active filter (`active=true/false`) - Metadata filter (JSON string) - Search in `name`, `description`, and `unitLabel` fields - Pagination
- [Create a product](/en/api-reference/products/createProduct) — Creates a new product. Optionally includes: - An image URL (use the `/upload-url` endpoint to get a one-time upload URL first) - A default price object which will be set as the default price for this product
- [Create a price](/en/api-reference/prices/createPrice) — Creates a new Price for an existing Product.
- [Update a price](/en/api-reference/prices/updatePrice) — Updates the specified price by setting the values of the parameters passed. Any parameters not provided are left unchanged.
- [Delete a price](/en/api-reference/prices/deletePrice) — Deletes a price. Only prices that have never been referenced by a line item can be deleted — once a price has been used in any checkout session or payment link, archive it instead. If the price is set as the default price for a product, the default price reference will be cleared.
- [List refunds](/en/api-reference/refunds/listRefunds) — Lists refunds with optional filters for charge and pagination.
- [Create a refund](/en/api-reference/refunds/createRefund) — Creates a full or partial refund for a successful charge. Target: provide EXACTLY ONE of `chargeId` or `paymentIntentId`. When `paymentIntentId` is supplied the server resolves it to the unique succeeded charge under that intent; the request is rejected if the intent has zero or multiple succeeded charges. Features: - Full or partial refunds - Ledger reversal (double-entry bookkeeping) - Balance updates (debits available funds) - Updates `charge.amountRefunded` - Updates `paymentIntent.amountRefunded` Refund behaviour: - Hits available balance immediately (no hold period) - Creates negative balance transactions - Reverses the original charge's ledger entries - Platform keeps fees (business decision)
- [Get refund by ID](/en/api-reference/refunds/getRefund) — Retrieves a refund with all details including ledger info.
- [Balance Transaction](/en/api-reference/objects/balance-transaction) — A Balance Transaction records every movement of money in or out of your xpay balance: successful charges, refunds, payouts, processing fees, and adjustments. Each Balance Transaction links back to its `source` (the Charge, Refund, or Payout that produced it) and breaks down `gross`, `fee`, and `net` amounts. Use Balance Transactions to reconcile what was paid into your account against what was settled to your bank.
- [Charge](/en/api-reference/objects/charge) — A Charge represents a single attempt to move money from a customer's payment method to your account. It records what was paid, by whom, through which processor, and the final outcome: `succeeded`, `failed`, or `refunded`. A single PaymentIntent may produce multiple Charges if earlier attempts fail; only the most recent successful Charge actually moves funds, and any subsequent Refunds reference it directly.
- [Checkout Session](/en/api-reference/objects/checkout-session) — A Checkout Session represents your customer's session as they pay on an xpay-hosted checkout page. Create a new Session each time a customer begins a checkout: attach line items, customer details, or a PaymentIntent, then redirect the customer to the Session's `url` to begin payment. As the customer progresses, the Session's `status` and `paymentStatus` update; once payment succeeds, the Session contains references to the resulting Customer and PaymentIntent. Sessions expire 24 hours after creation if not completed.
- [Coupon](/en/api-reference/objects/coupon) — A Coupon defines a reusable discount you can apply to Checkout Sessions and PaymentIntents. Coupons can grant either a percentage off (`percentOff`) or a fixed amount off (`amountOff`), and can apply once, forever, or repeating for a number of billing cycles. You can also cap how many times a Coupon may be redeemed and when it expires. Coupons are the reusable template. Each concrete application of a Coupon to a customer's purchase is recorded as a Discount. To give customers a redeemable code (e.g. `SUMMER25`) instead of applying the Coupon directly, create one or more Promotion Codes that map to this Coupon — a single Coupon can have many Promotion Codes, each with its own per-code restrictions. You receive `coupon.*` and `promotion_code.*` webhook events for each as they are created or changed.
- [Customer](/en/api-reference/objects/customer) — A Customer represents a buyer you do business with. Use Customers to save payment methods for reuse, track lifetime spend, attach metadata for your CRM, and group related Charges, Refunds, and Checkout Sessions under a single record. You can identify a Customer by their email or by a stable external ID from your own system and reference them on any future payment.
- [Discount](/en/api-reference/objects/discount) — A Discount records a single application of a Coupon to a specific Checkout Session, PaymentIntent, or Customer. Where a Coupon is the reusable template, a Discount captures the concrete redemption: which Coupon was applied, when it became active, when it expires, and (when applicable) which Promotion Code the customer entered to claim it.
- [Payment Intent](/en/api-reference/objects/payment-intent) — A PaymentIntent tracks the full lifecycle of collecting one payment from your customer: from creation, through authorization and 3-D Secure, to capture and final outcome. We recommend creating one PaymentIntent per order: you can inspect its history of Charge attempts, watch its `status` as it transitions, and reference the successful Charge it ultimately produces. PaymentIntents are the object that the hosted Checkout and xpay.js drive on the client to complete a payment.
- [Payment Link](/en/api-reference/objects/payment-link) — A Payment Link is a shareable URL you can send to a customer to collect a single payment or set up a subscription, without writing any checkout code. Open the URL in any browser and we render the hosted checkout, collect payment details, and process the payment. Use Payment Links for one-off invoices, social-media checkout, in-person QR codes, or anywhere you need a checkout page without integrating the SDK.
- [Price](/en/api-reference/objects/price) — A Price defines what a Product costs: the unit amount, currency, and (for subscriptions) billing interval. A single Product can have many Prices, letting you offer the same item in multiple currencies, tiers, or billing models without duplicating your catalog. Reference a Price by ID when creating Checkout Sessions, Payment Links, or line items, and the customer is charged according to what the Price defines.
- [Product](/en/api-reference/objects/product) — A Product represents a good or service you sell. Products carry only descriptive information (name, description, images), while their pricing is described separately by Price objects. A single Product can have many Prices: for example, the same digital course offered in EGP and USD, or at different tiers. Use Products to model your catalog and Prices to model how each is sold.
- [Promotion Code](/en/api-reference/objects/promotion-code) — A Promotion Code is a customer-facing alphanumeric code (for example `SUMMER25`) that maps to a single Coupon. One Coupon can have many Promotion Codes, letting you run different campaigns (email vs. social, customer-specific codes, regional codes, …) against the same underlying discount. Customers enter the code at checkout to redeem the Coupon's discount. Promotion Codes can carry their own restrictions on top of the Coupon — a per-code redemption cap, an expiry date, or a binding to a specific Customer. You receive `promotion_code.created` and `promotion_code.updated` webhook events whenever a code is created or modified.
- [Refund](/en/api-reference/objects/refund) — A Refund returns funds from a successful Charge back to the customer's original payment method. Refunds can be full or partial, and a single Charge can be refunded multiple times up to its original amount. The Refund's `status` reflects the processor's progress as the money makes its way back: `pending` while in flight, `succeeded` once the funds have been returned, or `failed` if the processor rejected the request.
# Introduction (/en/sdk)
Reference for the XPay client SDKs. The integrate guides cover how to use them; this tab lists every public symbol.
XPay ships two official client libraries today: a JavaScript loader for the browser and a thin React wrapper that builds on top of it. This tab is a **reference** for what each package exports, the function signatures, and the option types. For walkthroughs of how to integrate each pattern (Drop-in, Elements, Hosted Checkout, Payment Links), see [Integration patterns](/integrate/integration-patterns/hosted-checkout) under the Integrate tab.
## Pick the right package [#pick-the-right-package]
`@xpayeg/sdk`. The browser SDK loader, factory, Elements, drop-in checkout, and types. Use this
directly if you're not on React.
`@xpayeg/react`. `XPayProvider`, hooks (`useCheckout`, `useXPay`, `useElements`,
`useConfirmPayment`), and the `PaymentElement` and `CheckoutButton` components. Wraps the JS SDK
for React apps.
iOS / Android / React Native SDKs are not yet shipped. Placeholder for when they land.
## What this tab is for [#what-this-tab-is-for]
The pages under this tab are reference pages. Every public export of each package gets one section, with its signature and a minimum example. They're optimized for lookup, not learning.
* For a guided walkthrough on **Drop-in checkout**: [Drop-in pattern](/integrate/integration-patterns/drop-in).
* For a guided walkthrough on **Elements**: [Elements pattern](/integrate/integration-patterns/elements).
* For the **Hosted Checkout** pattern (no SDK needed): [Hosted Checkout](/integrate/integration-patterns/hosted-checkout).
* For **Payment Links** (no code at all): [Payment Links](/integrate/integration-patterns/payment-links).
## Example apps [#example-apps]
Prefer reading working code? Both SDKs ship with a runnable example in the [xpay-examples](https://github.com/xpayeg/xpay-examples) repo. Clone, plug in your test keys, run.
Runnable plain-HTML pages for every pattern: CDN script tag + global `XPay()`, a tiny Express
server, no build step.
A working storefront covering hosted checkout, drop-in, inline, and Elements, plus a
signature-verified webhook receiver.
- [@xpayeg/sdk](/en/sdk/sdk-js) — The browser JavaScript SDK. Reference for every public export, method signature, and option type.
- [@xpayeg/react](/en/sdk/sdk-react) — The React SDK. XPayProvider, hooks, and components built on top of @xpayeg/sdk.
- [Mobile](/en/sdk/mobile) — Native iOS, Android, and React Native SDKs are not yet shipped.
# Mobile (/en/sdk/mobile)
Native iOS, Android, and React Native SDKs are not yet shipped.
Native mobile SDKs are not part of the current XPay release. This placeholder page reserves the slot in the navigation for when they ship.
## What works on mobile today [#what-works-on-mobile-today]
* **Hosted Checkout** works in any in-app browser or WebView. Create a Checkout Session server-side and redirect to `session.url` from your mobile client. See [Hosted Checkout](/integrate/integration-patterns/hosted-checkout).
* **Payment Links** are URLs and work anywhere, including QR codes scanned from a poster. See [Payment Links](/integrate/integration-patterns/payment-links).
* The **JavaScript SDK** runs in WebViews, so React Native apps can embed Drop-in or Elements through a `` until a native React Native package ships.
## When native mobile is needed [#when-native-mobile-is-needed]
If your product needs a native checkout UI on iOS or Android (no WebView), contact support. Native SDKs are on the roadmap but no public release date today.
## Where to next [#where-to-next]
The browser SDK works in WebViews. Reference for every export.
The React wrapper. Works in React Native through a WebView until a native package lands.
# @xpayeg/sdk (/en/sdk/sdk-js)
The browser JavaScript SDK. Reference for every public export, method signature, and option type.
The XPay JavaScript SDK is a tiny CDN-loaded library that gives you a typed `XPayInstance` from your publishable key. From there you can mount payment UI as Elements, open a drop-in checkout, or run a single-call `initCheckout` and confirm a payment.
```sh
pnpm add @xpayeg/sdk
# or: npm install @xpayeg/sdk / yarn add @xpayeg/sdk
```
The package ships ESM and CJS builds, plus full TypeScript types. The runtime itself is loaded on demand from `https://checkout.xpay.app/v1/sdk.js`.
There are two ways to load the SDK, and they expose the factory under different names. This is the single most common integration mistake, so get it right up front:
```ts
// Import loadXPay and await it. This is the path the rest of this page documents.
import { loadXPay } from "@xpayeg/sdk";
const xpay = await loadXPay("pk_test_...");
```
```html
```
`loadXPay` exists only in the npm package; the CDN runtime exposes only the global `XPay`. They are not interchangeable: calling `loadXPay(...)` after a script tag throws `ReferenceError: loadXPay is not defined`, and the global `XPay` isn't importable. Pick the row that matches your setup; everything after you obtain the `xpay` instance is identical. This page documents the npm entry point (`loadXPay`). On the plain-HTML path, swap `await loadXPay(k)` for `XPay(k)` in any example below.
Every pattern on this page as runnable plain-HTML files: CDN script tag + global `XPay()`, a tiny
Express server, no build step. Clone, add your test keys, open in a browser.
This page is the reference for every public export. For walkthroughs, see [Drop-in](/integrate/integration-patterns/drop-in) and [Elements](/integrate/integration-patterns/elements) under Integrate.
## Use this package when [#use-this-package-when]
`@xpayeg/sdk` is the **vanilla** SDK and is the right pick whenever you're not on React. That covers:
* Plain HTML and static sites (CDN script tag + the global `XPay()` factory; see the two load paths above).
* **Vue, Svelte, Solid, Angular, Lit, Qwik, Astro,** and any other framework. Call the SDK from inside that framework's component lifecycle (mount, onMount, useEffect equivalent, etc.).
* React Native via WebView until a native package ships.
* Server-side scripts that need to type their `clientSecret` payloads (the package's TypeScript types are framework-agnostic).
For React, [`@xpayeg/react`](/sdk/sdk-react) is a thin convenience wrapper on top of this package. It builds on `loadXPay`, `xpay.initCheckout`, and `xpay.elements`, so anything you read here applies under the hood there too.
## Quick start [#quick-start]
The recommended path is `loadXPay` at module level, then `xpay.initCheckout()` once you have a `clientSecret` from your server.
```ts
import { loadXPay } from "@xpayeg/sdk";
// Module-level: call once, share across the app.
const xpayPromise = loadXPay("pk_test_...");
async function startCheckout() {
const xpay = await xpayPromise;
if (!xpay) return; // SSR returns null on the server
// initCheckout accepts a Promise too, so you can pass the fetch directly.
const checkout = await xpay.initCheckout({
clientSecret: fetch("/api/create-checkout", { method: "POST" })
.then((r) => r.json())
.then((d) => d.clientSecret as string),
});
// Mount the payment element.
const elements = checkout.getElements();
const paymentElement = elements.create("payment");
paymentElement.mount("#payment-element");
// Listen for state changes (promo codes, quantity updates, fee recalcs).
checkout.on("change", (session) => {
document.getElementById("total")!.textContent =
`${session.currency} ${(session.amountTotal / 100).toFixed(2)}`;
});
// Confirm when the customer submits.
document.getElementById("pay")!.addEventListener("click", async () => {
const result = await checkout.confirm({
customerDetails: { email: "customer@example.com", name: "Aya Hassan" },
});
if (result.type === "error") {
console.error(result.error.message);
return;
}
window.location.href = "/thank-you";
});
}
```
## Exports at a glance [#exports-at-a-glance]
| Export | Kind | What it is |
| ---------------------------- | --------- | ------------------------------------------------------------------------------------------ |
| `loadXPay(publishableKey)` | function | Loads the SDK from CDN and returns an `XPayInstance` (or `null` on the server during SSR). |
| `XPayInstance` | interface | Factory for `Elements`, drop-in `Checkout`, `confirmPayment`, and `initCheckout`. |
| `Elements` | interface | Manages payment elements (`PaymentElement`) and the session lifecycle. |
| `PaymentElement` | interface | Mountable payment-method selector with the card form, BNPL, kiosk, and wallet methods. |
| `ConfirmPaymentOptions` | interface | Shape passed to `xpay.confirmPayment()` and `checkout.confirm()`. |
| `CheckoutOptions` | interface | Configuration for `xpay.checkout()`. Drop-in modal or inline embed. |
| `CheckoutInstance` | interface | Drop-in checkout handle: `open()`, `close()`, `destroy()`, plus events. |
| `CheckoutCompleteResult` | interface | Payload for the drop-in `onComplete` callback. |
| `InitCheckoutOptions` | interface | Options for `xpay.initCheckout()`. The modern single-call API. |
| `InitCheckoutResult` | type | Session data merged with action methods (`confirm`, promo codes, etc.). |
| `CheckoutActions` | interface | The action methods that ride on the checkout object. |
| `CheckoutSession` | type | The session data shape: amount, currency, status, payment methods, line items. |
| `ActionResult` | type | Tagged union: `{ type: "success", session }` or `{ type: "error", error }`. |
| `XPayError` | interface | Unified error type across every SDK action. |
| `Appearance` | type | Branding overrides for the embedded UI (color mode, border style, colors, font). |
| `PaymentElementChangeEvent` | interface | Payload for `PaymentElement` change events. |
| `ElementsReadyEvent` | interface | Payload for the `Elements` `"ready"` event. |
| `ElementsLoadErrorEvent` | interface | Payload for the `Elements` `"loaderror"` event. |
| `CustomerDetails`, `Address` | interface | Shapes for customer fields collected by your form and passed at confirm time. |
| `PaymentMethodInfo` | type | Information about an available payment method on the session. |
| `SessionStatus` | type | Tagged union: `open`, `expired`, or `complete`. |
## `loadXPay(publishableKey)` [#loadxpaypublishablekey]
Loads the SDK runtime from CDN and returns an `XPayInstance`. Call this once at module level, not inside a component.
```ts
import { loadXPay } from "@xpayeg/sdk";
const xpayPromise = loadXPay("pk_test_...");
// later, when you have a clientSecret:
const xpay = await xpayPromise;
const elements = xpay.elements({ clientSecret: "cs_test_..." });
```
| Parameter | Type | Description |
| ---------------- | ------ | ---------------------------------------------------------- |
| `publishableKey` | string | Your publishable API key (`pk_test_...` or `pk_live_...`). |
Returns `Promise`. `null` is returned during server-side rendering (no `window`). The `XPayProvider` from `@xpayeg/react` handles `null` gracefully by retrying on the client.
The runtime is fetched once; subsequent `loadXPay` calls reuse the same script tag. If a script with `sdk.js` is already on the page, it isn't re-injected.
## `XPayInstance` [#xpayinstance]
The factory you get back from `loadXPay`. Four methods.
```ts
interface XPayInstance {
elements(options: ElementsOptions): Elements;
checkout(options: CheckoutOptions): CheckoutInstance;
confirmPayment(options: ConfirmPaymentOptions): Promise;
initCheckout(options: InitCheckoutOptions): Promise;
}
```
### `elements(options)` [#elementsoptions]
Creates an Elements instance for custom checkout UI. Pass it the `clientSecret` from a Checkout Session.
```ts
const elements = xpay.elements({
clientSecret: "cs_test_...",
appearance: { colorMode: "dark", borderStyle: "rounded" },
locale: "en",
});
```
`ElementsOptions`:
| Field | Type | Description |
| -------------- | --------------------------- | ---------------------------------------------------------------------- |
| `clientSecret` | `string \| Promise` | Required. The Checkout Session client secret. Can be a Promise. |
| `appearance` | `Appearance` | Optional. UI overrides merged with the session's server-side branding. |
| `locale` | `"en" \| "ar"` | Optional. Defaults to `"en"`. |
### `checkout(options)` [#checkoutoptions]
Creates a drop-in checkout instance. See [`CheckoutOptions`](#checkoutoptions) below.
### `confirmPayment(options)` [#confirmpaymentoptions]
Submits a payment using the data collected by Elements. See [`ConfirmPaymentOptions`](#confirmpaymentoptions) below.
### `initCheckout(options)` [#initcheckoutoptions]
Modern single-call API: returns the session data and action methods together. See [`InitCheckoutOptions`](#initcheckoutoptions) below.
## `Elements` [#elements]
Returned by `xpay.elements()`. Manages one shared payment iframe and exposes session-mutation methods.
```ts
interface Elements {
create(type: "payment"): PaymentElement;
getElement(type: "payment"): PaymentElement | null;
fetchPaymentMethods(): Promise;
on(event: "ready" | "change" | "loaderror" | "error", handler): void;
off(event: string, handler): void;
applyPromotionCode(code: string): Promise;
removePromotionCode(): Promise;
updateLineItemQuantity(args: { lineItem: string; quantity: number }): Promise;
submit(): Promise<{ error?: XPayError; selectedPaymentMethod?: string }>;
fetchUpdates(): Promise;
changeAppearance(appearance: Appearance): void;
destroy(): void;
}
```
### Events [#events]
| Event | Handler signature | Fires when |
| ------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `"ready"` | `(data: ElementsReadyEvent) => void` | Session is loaded and elements are mountable. Fires immediately if already loaded. |
| `"change"` | `(session: CheckoutSession) => void` | Session data changes (payment method selection, promo codes, fee recalculations). |
| `"loaderror"` | `(data: ElementsLoadErrorEvent) => void` | Session fails to load (network error, invalid client secret, API error). |
| `"error"` | `(error: XPayError) => void` | Unsolicited error not triggered by a merchant action (e.g. session expired during a fee recalculation). |
### Methods [#methods]
* `create("payment", options?)`: returns a [`PaymentElement`](#paymentelement) you can mount.
* `getElement("payment")`: returns the existing element or `null` if none was created.
* `fetchPaymentMethods()`: returns the list of `PaymentMethodInfo` the session supports.
* `applyPromotionCode(code)`: applies a promotion code, returns `ActionResult`.
* `removePromotionCode()`: removes the applied code, returns `ActionResult`.
* `updateLineItemQuantity({ lineItem, quantity })`: updates a line item, returns `ActionResult`.
* `submit()`: validates element fields. Returns either an `error` or the `selectedPaymentMethod` type string.
* `fetchUpdates()`: re-fetches the session from the server.
* `changeAppearance(appearance)`: updates branding at runtime without recreating elements.
* `destroy()`: tears down the instance and frees resources.
## `PaymentElement` [#paymentelement]
The full payment-method selector with the card form, BNPL, kiosk, and wallet methods. Get one from `elements.create("payment")`.
```ts
interface PaymentElement {
mount(container: string | HTMLElement): void;
unmount(): void;
destroy(): void;
focus(): void;
blur(): void;
collapse(): void;
on(event: "ready" | "loaderstart" | "loaderror", handler): void;
off(event: string, handler): void;
}
```
`PaymentElementChangeEvent` is the payload for the parent `Elements` `"change"` event when triggered by a payment-method change:
| Field | Type | Description |
| ------------- | ------------------ | -------------------------------------------------------------- |
| `elementType` | `"payment"` | Always `"payment"` for this element. |
| `empty` | `boolean` | Whether all card fields are empty. |
| `complete` | `boolean` | Whether the form is complete and ready to submit. |
| `collapsed` | `boolean` | Whether the method selector is collapsed (no method selected). |
| `value` | `{ type: string }` | The currently selected payment method type. |
| `session` | `CheckoutSession` | Latest session snapshot. |
## `ConfirmPaymentOptions` [#confirmpaymentoptions-1]
Shape passed to `xpay.confirmPayment()` and `checkout.confirm()`.
| Field | Type | Description |
| ------------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `elements` | `Elements` | Required (for `xpay.confirmPayment`). The Elements instance managing the form. `checkout.confirm` injects this for you. |
| `customerDetails` | `CustomerDetails` | Optional. Customer fields collected by your form. |
| `customFields` | `Record` | Optional. Custom-field values for the session. |
| `deviceFingerprint` | `{ visitorId: string; confidence?: number }` | Optional. Device fingerprint for fraud detection. |
| `paymentMethod` | `string` | Optional. Override the selected payment method type. |
| `redirect` | `"if_required" \| "always"` | Default `"if_required"`. Controls post-payment navigation. |
`redirect` semantics:
* `"if_required"` returns the result to your code; only redirects when the payment method requires it (e.g. 3-D Secure, BNPL).
* `"always"` always redirects to the session's `afterCompletion.redirect.url` after payment. The page navigates away and the function never returns on success.
The destination is the session's `afterCompletion.redirect.url`, set when your server creates the session. XPay navigates to it unchanged.
## `CheckoutOptions` [#checkoutoptions-1]
Configuration for the drop-in `xpay.checkout()`.
| Field | Type | Description |
| -------------- | ------------------------------------------ | ------------------------------------------------------------------------- |
| `clientSecret` | `string` | Required. The Checkout Session client secret. |
| `mode` | `"modal" \| "inline"` | `"modal"` (default) renders an overlay; `"inline"` embeds in `container`. |
| `container` | `string \| HTMLElement` | Required for `inline` mode. CSS selector or DOM node to embed in. |
| `appearance` | `Appearance` | UI overrides merged with the session's branding. |
| `locale` | `"en" \| "ar"` | UI locale. Defaults to `"en"`. |
| `onComplete` | `(result: CheckoutCompleteResult) => void` | Called when payment completes successfully. |
| `onClose` | `() => void` | Called when the modal closes. |
| `onReady` | `(session: CheckoutSession) => void` | Called when the session loads and the UI is ready. |
| `onConfirmed` | `() => void` | Called when the customer confirms payment, before the final result. |
| `onError` | `(error: CheckoutError) => void` | Called when an error occurs. |
## `CheckoutInstance` [#checkoutinstance]
Returned by `xpay.checkout()`. Imperative handle.
```ts
interface CheckoutInstance {
open(): void; // modal mode
close(): void;
destroy(): void;
on(event: "complete" | "close" | "ready" | "confirmed" | "error", handler): void;
off(event: string, handler): void;
}
```
`CheckoutCompleteResult` (the payload for `onComplete`):
| Field | Type | Description |
| ----------------- | ------------- | ------------------------------------------------------ |
| `status` | `"succeeded"` | Always `"succeeded"` for this callback. |
| `paymentIntentId` | `string` | The Payment Intent ID for server-side verification. |
| `chargeId` | `string` | Optional. The Charge ID, when available. |
| `redirectUrl` | `string` | Optional. The session's after-completion redirect URL. |
## `InitCheckoutOptions` [#initcheckoutoptions-1]
Modern single-call API. Combines `elements()` with session loading and exposes session data plus action methods on a single object.
```ts
const checkout = await xpay.initCheckout({ clientSecret: "cs_test_..." });
console.log(checkout.amountTotal); // session field
const result = await checkout.confirm({ customerDetails: { email } });
```
| Field | Type | Description |
| -------------- | --------------------------- | ------------------------------------ |
| `clientSecret` | `string \| Promise` | Required. The session client secret. |
| `appearance` | `Appearance` | Optional. UI overrides. |
| `locale` | `"en" \| "ar"` | Optional. Defaults to `"en"`. |
`InitCheckoutResult` is `CheckoutSession & CheckoutActions`. The action methods on `CheckoutActions`:
| Method | What it does |
| ------------------------------------------------ | --------------------------------------------------------------- |
| `confirm(options?)` | Confirms the payment. Returns `ActionResult`. |
| `applyPromotionCode(code)` | Applies a code. Returns `ActionResult`. |
| `removePromotionCode()` | Removes the applied code. Returns `ActionResult`. |
| `updateLineItemQuantity({ lineItem, quantity })` | Updates a line item quantity. Returns `ActionResult`. |
| `submit()` | Validates fields. Returns `{ error?, selectedPaymentMethod? }`. |
| `fetchUpdates()` | Re-fetches the session from the server. Returns `ActionResult`. |
| `changeAppearance(appearance)` | Updates branding at runtime. |
| `on(event, handler)` | Listen for `"change"`, `"error"`, or a custom event. |
| `getElements()` | Returns the underlying `Elements` for element creation. |
## `CheckoutSession` (data type) [#checkoutsession-data-type]
Session data exposed to the merchant. Derived from the server's response, trimmed to the merchant-facing fields.
| Field | Type | Description |
| ---------------- | ---------------------- | -------------------------------------------------------- |
| `id` | `string` | The `cs_*` session ID. |
| `amountSubtotal` | `number` | Subtotal in minor units (e.g. `50000` for `500.00 EGP`). |
| `amountTotal` | `number` | Total in minor units after fees, taxes, and discounts. |
| `currency` | `string` | ISO currency code (e.g. `"EGP"`). |
| `merchantName` | `string` | Your business display name. |
| `livemode` | `boolean` | `true` for live keys; `false` for test mode. |
| `expiresAt` | `string` | ISO timestamp when the session expires. |
| `status` | `SessionStatus` | Tagged union: `open`, `expired`, `complete`. |
| `canConfirm` | `boolean` | Whether the session can currently be confirmed. |
| `paymentMethods` | `PaymentMethodInfo[]` | Available payment methods on this session. |
| `lineItems` | `CheckoutLineItem[]` | Line items. |
| `totalDetails` | `CheckoutTotalDetails` | Subtotal, tax, shipping, discount breakdown. |
| `fees` | `CheckoutFees` | Fee breakdown when `feesPassThrough` is on. |
| `discounts` | `CheckoutDiscount[]` | Applied promotion codes. |
`SessionStatus`:
```ts
type SessionStatus =
| { type: "open" }
| { type: "expired" }
| { type: "complete"; paymentStatus: "paid" | "unpaid" | "no_payment_required" };
```
`PaymentMethodInfo`:
| Field | Type | Description |
| ---------------- | ----------------------------------------- | ------------------------------------------------------------- |
| `type` | `string` | Method type (`"card"`, `"valu"`, `"fawry"`). |
| `displayName` | `string` | Localized label (`"Card"`, `"ValU"`, `"Fawry"`). |
| `category` | `"card" \| "bnpl" \| "kiosk" \| "wallet"` | Bucket for UI grouping. |
| `icon` | `string` | Optional icon URL. |
| `nextActionText` | `string` | Optional. Description of the next step shown to the customer. |
## `ActionResult` and `XPayError` [#actionresult-and-xpayerror]
Every action that mutates the session returns an `ActionResult`. It's a tagged union, so narrow by `result.type` before reading the payload.
```ts
type ActionResult =
| { type: "success"; session: CheckoutSession }
| { type: "error"; error: E };
```
`XPayError` is the unified error shape. Payment-specific fields are `null` for non-payment errors.
| Field | Type | Description |
| ------------------- | --------------------------------- | ------------------------------------------------------------------------------- |
| `type` | `string` | Error category: `"card_error"`, `"invalid_request_error"`, `"api_error"`. |
| `code` | `string \| null` | Machine-readable code (`"card_declined"`, `"promotion_code_not_found"`). |
| `message` | `string` | Human-readable message. |
| `param` | `string \| null` | Parameter that caused the error (e.g. `"promotionCode"`). |
| `docUrl` | `string \| null` | Documentation URL for this error code. |
| `declineCode` | `string \| null` | Decline detail like `"insufficient_funds"`. Null for non-payment errors. |
| `adviceCode` | `string \| null` | Retry advice: `"try_again_later"`, `"do_not_try_again"`, `"confirm_card_data"`. |
| `chargeId` | `string \| null` | Failed charge ID. |
| `paymentMethodId` | `string \| null` | Failed payment method ID. |
| `paymentMethodType` | `string \| null` | Payment method type (`"card"`, `"valu"`). |
| `paymentMethod` | `Record \| null` | Payment method snapshot at failure time. |
For a guide on handling API errors and the three error code spaces, see [Errors](/integrate/errors/api-errors), [API error codes](/integrate/errors/api-error-codes), [Payment error codes](/integrate/errors/payment-error-codes), and [Decline codes](/integrate/errors/decline-codes).
## `Appearance` [#appearance]
Branding overrides for the embedded UI. Mirrors a subset of the merchant-default fields you set in [Branding settings](/features/checkout-customization/branding).
| Field | Values |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `colorMode` | `"system" \| "light" \| "dark"` |
| `borderStyle` | `"rounded" \| "sharp" \| "pill"` |
| `spacing` | `"condensed" \| "normal" \| "spacious"` |
| `inputSize` | `"small" \| "medium" \| "large"` |
| `inputStyle` | `"outlined" \| "flat" \| "filled"` |
| `formLayout` | `"compact" \| "spacious"` |
| `colors` | Object with twelve semantic tokens (primary, foreground, background, muted, accent, border, input, ring, destructive, plus the foreground pairs). |
| `fontFamily` | CSS font-family string. |
Pass an `Appearance` to `xpay.elements()`, `xpay.checkout()`, or `xpay.initCheckout()`. Or call `elements.changeAppearance()` / `checkout.changeAppearance()` to update at runtime.
## `CustomerDetails` and `Address` [#customerdetails-and-address]
Shapes for customer fields you collect on your form and pass at confirm time.
```ts
interface CustomerDetails {
email?: string;
name?: string;
phone?: string;
billingDetails?: { name?: string; email?: string; phone?: string; address?: Address };
shipping?: { name?: string; phone?: string; address?: Address };
}
interface Address {
line1?: string;
line2?: string;
city?: string;
state?: string;
postalCode?: string;
country?: string;
}
```
For the developer-side cross-reference on what each Checkout Session field controls (collection toggles, custom fields, prefill priority), see [Customer lifecycle](/integrate/checkout-session/customer-lifecycle).
## Where to next [#where-to-next]
The React wrapper. `XPayProvider`, hooks, and components built on top of this package.
The integration walkthrough for `xpay.checkout()`.
The integration walkthrough for `xpay.elements()` and the `PaymentElement` component.
# @xpayeg/react (/en/sdk/sdk-react)
The React SDK. XPayProvider, hooks, and components built on top of @xpayeg/sdk.
The XPay React SDK is a thin wrapper around `@xpayeg/sdk` that exposes a provider, hooks, and a component for the payment element. The provider creates an `Elements` instance from a `clientSecret`, the hooks expose the live session state and action methods, and the components mount payment UI.
```sh
pnpm add @xpayeg/sdk @xpayeg/react
# Both packages are required. @xpayeg/react peer-depends on @xpayeg/sdk, react ^18 || ^19, react-dom ^18 || ^19.
```
This page is the reference for every export. For walkthroughs, see [Drop-in](/integrate/integration-patterns/drop-in) and [Elements](/integrate/integration-patterns/elements) under Integrate.
A working storefront built with this SDK: hosted checkout, drop-in, inline, and Elements, plus a
signature-verified webhook receiver. Clone, add your test keys, run.
## Quick start [#quick-start]
The recommended pattern is `loadXPay` at module level (or in a small loader file), then create a Checkout Session on your server, hold the returned `clientSecret` in state, and render `` once you have it. `useCheckout()` then reads the live session inside any child component.
```tsx title="lib/xpay.ts"
"use client";
import { loadXPay } from "@xpayeg/sdk";
// Module-level: load once, share across all components.
export const xpayPromise = loadXPay(process.env.NEXT_PUBLIC_XPAY_PUBLISHABLE_KEY!);
```
```tsx title="app/checkout/page.tsx"
"use client";
import { useState } from "react";
import { xpayPromise } from "@/lib/xpay";
import { PaymentElement, useCheckout, XPayProvider } from "@xpayeg/react";
export default function CheckoutPage() {
const [clientSecret, setClientSecret] = useState(null);
const [creating, setCreating] = useState(false);
const startCheckout = async () => {
setCreating(true);
const res = await fetch("/api/create-checkout", { method: "POST" });
const { clientSecret } = await res.json();
setClientSecret(clientSecret);
setCreating(false);
};
if (!clientSecret) {
return (
);
}
return (
);
}
function CheckoutForm() {
const state = useCheckout();
if (state.type === "loading") return
;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const result = await checkout.confirm({
customerDetails: { email: "customer@example.com" },
});
if (result.type === "error") {
alert(result.error.message);
}
};
return (
);
}
```
The `clientSecret` lives in state, so `XPayProvider` only mounts once and `/api/create-checkout` is called exactly once per checkout. If you create the session earlier in your flow (a click on a product page, a server component, a route param), pass the resulting string in any way you like: props, search params, context. The provider only needs the string.
`useCheckout()` updates reactively after every promo code, quantity change, or fee recalculation. There's no need for an `onChange` callback in React; the component re-renders with the latest `checkout` data on its own.
## Exports at a glance [#exports-at-a-glance]
| Export | Kind | What it is |
| -------------------------------------------- | --------- | --------------------------------------------------------------------------------------- |
| `XPayProvider` | component | Wraps your checkout UI and provides context to all child components. |
| `useCheckout` | hook | Returns the disjoint union state and the merged session-plus-actions object. |
| `useXPay` | hook | Returns the underlying `XPayInstance` (or `null`). |
| `useElements` | hook | Returns the underlying `Elements` instance (or `null`). |
| `useConfirmPayment` | hook | Convenience hook that exposes `confirmPayment` from the active checkout. |
| `PaymentElement` | component | Mounts the payment-method selector with the card form. |
| `CheckoutButton` | component | A pre-built button that opens the drop-in checkout modal on click. |
| `Checkout` | type | `CheckoutSession & CheckoutActions`. The merged shape `useCheckout` returns on success. |
| `UseCheckoutResult` | type | The disjoint union returned by `useCheckout`. |
| `PaymentElementProps`, `CheckoutButtonProps` | type | Component prop types. |
The package re-exports a few core types from `@xpayeg/sdk` for convenience: `CheckoutSession`, `CheckoutActions`, `PaymentMethodInfo`, `Appearance`, `ActionResult`, `XPayError`, `ConfirmPaymentOptions`, `PaymentElementChangeEvent`, `ElementsOptions`. Import additional types directly from `@xpayeg/sdk` when you need them.
## `` [#xpayprovider]
Wraps the part of your app that runs the checkout. Creates an `Elements` instance from `options.clientSecret` and provides three React contexts: the `XPayInstance`, the `Elements`, and the loading-state-aware checkout state.
```tsx
{children}
```
| Prop | Type | Description |
| ---------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| `xpay` | `XPayInstance \| Promise \| null` | The instance from `loadXPay()`. Pass the Promise; the provider awaits it on the client. |
| `options.clientSecret` | `string \| Promise` | The Checkout Session client secret. Required to create an Elements instance and load the session. |
| `options.appearance` | `Appearance` | Optional. Branding overrides merged with the session's server-side branding. |
| `options.locale` | `"en" \| "ar"` | Optional. Defaults to `"en"`. |
| `children` | `ReactNode` | The subtree that calls `useCheckout`, `useXPay`, `useElements`, `useConfirmPayment`. |
The provider handles SSR gracefully: `loadXPay` returns `null` on the server, and the provider waits for the client to load the SDK before creating the Elements instance.
## `useCheckout()` [#usecheckout]
The primary hook. Returns a disjoint union; narrow by `state.type` before reading session data or calling actions.
```ts
type UseCheckoutResult =
| { type: "loading" }
| { type: "error"; error: { message: string } }
| { type: "success"; checkout: Checkout };
type Checkout = CheckoutSession & CheckoutActions;
```
```tsx
const state = useCheckout();
if (state.type === "loading") return ;
if (state.type === "error") return
{state.error.message}
;
const { checkout } = state;
```
After narrowing to `success`, `checkout` carries every session field plus every action method.
### Session fields [#session-fields]
The session-data fields on `checkout` come straight from `CheckoutSession`. See the [`@xpayeg/sdk` reference](/sdk/sdk-js#checkoutsession-data-type) for the full schema. Most-used fields:
| Field | Type | Description |
| ---------------- | ---------------------- | ----------------------------------------------------------------------- |
| `id` | `string` | Session ID. |
| `amountTotal` | `number` | Total in minor units. |
| `amountSubtotal` | `number` | Subtotal in minor units. |
| `currency` | `string` | Currency code. |
| `merchantName` | `string` | Your business name. |
| `livemode` | `boolean` | Live vs test mode. |
| `status` | `SessionStatus` | `{type:"open"} \| {type:"expired"} \| {type:"complete", paymentStatus}` |
| `canConfirm` | `boolean` | Whether the session is ready to confirm. |
| `paymentMethods` | `PaymentMethodInfo[]` | Available methods on this session. |
| `lineItems` | `CheckoutLineItem[]` | Line items. |
| `totalDetails` | `CheckoutTotalDetails` | Subtotal, tax, shipping, discount, fee breakdown. |
| `discounts` | `CheckoutDiscount[]` | Applied promotion codes. |
### Action methods [#action-methods]
Action methods come from `CheckoutActions`. All return a `Promise` (a tagged union of `success` or `error`) except where noted.
| Method | Description |
| ------------------------------------------------------------ | --------------------------------------------------------------------------- |
| `confirm(options?: Omit)` | Confirms the payment. Handles 3DS challenges and BNPL redirects internally. |
| `applyPromotionCode(code: string)` | Applies a promo code; the session re-renders with the new total. |
| `removePromotionCode()` | Removes the applied code. |
| `updateLineItemQuantity({ lineItem, quantity })` | Updates a line item's quantity. |
| `submit()` | Validates fields. Returns `{ error?, selectedPaymentMethod? }`. |
| `fetchUpdates()` | Re-fetches the session from the server. |
| `changeAppearance(appearance: Appearance)` | Updates branding at runtime; returns `void`. |
| `on(event, handler)` | Listens for `"change"`, `"error"`, or a custom event. Returns `void`. |
| `getElements()` | Returns the underlying `Elements` instance for low-level access. |
The hook listens for the underlying `change` and `loaderror` events itself, so when promo codes are applied, line items update, or fees recalculate, the component re-renders with the latest data without you wiring an effect.
### Promo codes and quantities (example) [#promo-codes-and-quantities-example]
```tsx
function LineItems() {
const state = useCheckout();
if (state.type !== "success") return null;
const { checkout } = state;
return (
);
}
```
### Listening for unsolicited errors [#listening-for-unsolicited-errors]
`change` events drive React state automatically. The `error` event covers errors that fire outside any merchant action (e.g. session expired during a fee recalculation, BIN detection failure):
```tsx
useEffect(() => {
if (state.type !== "success") return;
state.checkout.on("error", (err) => {
console.error("[XPay] unsolicited error:", err.code, err.message);
});
}, [state]);
```
## `useXPay()` and `useElements()` [#usexpay-and-useelements]
Lower-level hooks that return the underlying instances. Useful when you need to call methods that aren't exposed through `useCheckout` (for example, mounting an extra element programmatically).
```ts
function useXPay(): XPayInstance | null;
function useElements(): Elements | null;
```
Both return `null` while the SDK is loading or before a `clientSecret` has been provided. Both must be called inside ``.
## `useConfirmPayment()` [#useconfirmpayment]
Convenience hook around `useCheckout`. Returns the same `confirm` function plus an `isConfirming` flag. Useful when you want a tiny API for a leaf component that only needs to submit:
```tsx
const { confirmPayment, isConfirming } = useConfirmPayment();
```
If the checkout isn't ready yet, `confirmPayment()` returns `{ type: "error", error: { message: "Checkout not ready" } }` immediately rather than throwing.
## `` [#paymentelement-]
Renders the payment-method selector and card form. Must be inside `` with a session.
```tsx
setReady(e.complete)}
onReady={() => console.log("element ready")}
/>
```
| Prop | Type | Description |
| --------------- | -------------------------------------------- | -------------------------------------------------------------- |
| `onReady` | `() => void` | Optional. Fires when the element's iframe is initialized. |
| `onChange` | `(event: PaymentElementChangeEvent) => void` | Optional. Fires on payment-method selection and field changes. |
| `onLoaderStart` | `() => void` | Optional. Fires synchronously when the iframe is created. |
| `onLoadError` | `(event: ElementsLoadErrorEvent) => void` | Optional. Fires when the element fails to load. |
| `className` | `string` | Optional. CSS class for the container `
`. |
| `id` | `string` | Optional. ID for the container `
`. |
The component renders a single `
` and mounts the underlying `PaymentElement` (from `@xpayeg/sdk`) into it. On unmount, the element is torn down automatically.
## `` [#checkoutbutton-]
Pre-built button that opens the drop-in checkout modal on click. Wraps `xpay.checkout({ mode: "modal" })` so you don't have to wire it yourself.
```tsx
router.push(`/orders/${result.paymentIntentId}`),
onClose: () => console.log("Customer closed checkout"),
}}
>
Pay Now
```
| Prop | Type | Description |
| ----------------- | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `clientSecret` | `string` | Required. The Checkout Session client secret. |
| `children` | `ReactNode` | Button label. Defaults to `"Pay"`. |
| `checkoutOptions` | `Omit` | Optional. Callbacks (`onComplete`, `onClose`, `onReady`, `onConfirmed`, `onError`), `appearance`, `locale`. |
| `className` | `string` | Optional. CSS class for the button. |
| `disabled` | `boolean` | Optional. Disables the button. |
The button is also disabled automatically when the SDK isn't loaded yet (`useXPay()` returns `null`). Must be inside ``.
## Common patterns [#common-patterns]
### Theme-aware appearance [#theme-aware-appearance]
Sync XPay's color mode with your app's theme using `changeAppearance` at runtime:
```tsx
function ThemeAwareCheckout() {
const state = useCheckout();
const { theme } = useTheme();
useEffect(() => {
if (state.type === "success") {
state.checkout.changeAppearance({ colorMode: theme as "light" | "dark" });
}
}, [theme, state]);
return ;
}
```
### Pre-validating before confirming [#pre-validating-before-confirming]
`submit()` validates all fields before `confirm()`. Useful when you want to gate the confirm step behind another UI step (a confirmation dialog, a Terms checkbox, etc.):
```tsx
const handlePay = async () => {
const { error, selectedPaymentMethod } = await checkout.submit();
if (error) return setError(error.message);
const ok = await showConfirmDialog(selectedPaymentMethod);
if (!ok) return;
const result = await checkout.confirm();
if (result.type === "error") setError(result.error.message);
};
```
### Redirect after success [#redirect-after-success]
By default `confirm()` returns the result to your code. Pass `redirect: "always"` to send the customer to your `afterCompletion.redirect.url` after success:
```tsx
await checkout.confirm({
customerDetails: { email },
redirect: "always",
});
// On success, the page navigates away. Any code below only runs on error.
```
Set the destination on the session as `afterCompletion.redirect.url`. XPay navigates to it unchanged, appending nothing. Put `{CHECKOUT_SESSION_ID}` in the URL if your return page needs to know which session it is.
## Where to next [#where-to-next]
The underlying JavaScript SDK. Reference for `loadXPay`, `XPayInstance`, `Elements`, drop-in checkout, and every type.
Walkthrough for the modal/inline checkout via `` or `xpay.checkout()`.
Walkthrough for `` + `` + `useCheckout`.
# Payment Links (/en/features/payment-links)
Create a shareable URL in the dashboard and accept payments without writing any code. Send the link by email or WhatsApp, post it on social, drop it on a button, or print a QR code.
A Payment Link is a URL you create once and accept payments through forever. You build it in the dashboard by picking products from your catalog, choosing what to collect from the customer, and setting what happens after they pay. XPay gives you back a link that any number of people can pay through.
Use Payment Links when you want to take a payment without setting up a website or writing code. Email an invoice to one customer, post the link on Instagram for a launch, print a QR code on a flyer, drop it in a button on Webflow. The link works anywhere a URL works.
## What you can do [#what-you-can-do]
Pick products and prices you've already created. One link can carry many products, with quantity
controls if you want.
Toggle on customer name, phone, billing or shipping address. Add your own custom fields (text,
number, dropdown, checkbox).
Allow customers to enter promotion codes at checkout, or attach a custom-amount item if you want
them to choose what to pay.
Customer-facing checkout uses your business name, logo, and brand color. The same link works in
test mode and live mode based on the API mode you're in when you create it.
## Create a payment link [#create-a-payment-link]
### Open the create page [#open-the-create-page]
In the dashboard sidebar, click **Payment links** under **Shortcuts**, then click **Create payment link** in the top right.
You can also open the page directly at **/payment-links/create**.
### Add products [#add-products]
In the **Payment** tab, search for products in your catalog or click **Add new product** to create one inline. Each product you add becomes a line item on the link.
For each product, pick how its quantity behaves with the **Quantity** control:
* **Fixed.** The customer buys exactly the quantity you set. They can't change it.
* **Adjustable.** The customer picks a quantity within a range. Set **Starts at**, **Min**, and **Max**. A **Min** of 0 lets the customer remove the item from the order on the checkout page.
* **Add-on.** An optional upsell. It starts out of the order, and the customer adds it on the checkout page if they want it. Set the **Up to** maximum.
You can also:
* Add a **custom-amount item** if you want the customer to enter the amount themselves (donations, "pay what you want"). Custom-amount items must be the only item on the link.
* Mix products freely, but every line item must share the same currency.
Make every product an **Add-on** and the link opens with an empty order. The customer adds at least one item before they can pay.
### Choose what to collect [#choose-what-to-collect]
Under **Options**, toggle the customer fields you want to ask for:
* **Collect customer name**
* **Collect billing address**
* **Collect shipping address**
* **Collect phone number**
* **Allow promotion codes**
Pick the **Submit button text** that suits the sale: **Pay**, **Subscribe**, **Book**, or **Donate**.
To collect anything else, check **Add custom fields**. Each custom field can be **Text**, **Number only**, **Dropdown**, or **Checkbox**, with optional character limits.
### Set what happens after payment [#set-what-happens-after-payment]
Open the **Confirmation** tab. You have two options:
* **Show hosted confirmation page** (default). XPay shows the customer a built-in success page. You can check **Replace default with custom message** to write your own thank-you message, and add a **Return to merchant URL** to put a "Return to..." button on the page.
* **Redirect to URL**. The customer is redirected to a URL you provide right after a successful payment. Use this when you have a return page on your own site.
### (Optional) Adjust advanced options [#optional-adjust-advanced-options]
Open **Advanced options** if you want to:
* Check **Override merchant fee defaults** to control fee handling for this specific link: **Customer pays platform fee** adds the platform fee to what the customer pays, and **Collect VAT from customer** adds your product VAT on top.
In compliance with regulations, **Customer pays platform fee** is gated on XPay approval. Contact
your account manager to enable fee pass-through on your account before turning it on for a link.
### Click Create [#click-create]
In the top right, click **Create**. You land on the link's detail page with a copy-ready URL at the top.
The link is active immediately. Anyone who opens the URL can pay.
## Share it [#share-it]
The detail page gives you four ways to put the link in front of customers.
Hit **Copy link** to grab the URL. Paste it anywhere: invoice email, Slack, WhatsApp, a button
on your site, an Instagram bio.
Click **Show QR code** to open a printable QR code. Useful for posters, cards at the register,
or restaurant tables.
Click **Send payment link**, choose **Email**, and enter the customer's address. XPay sends an
email with the link from your business name.
Same flow with **WhatsApp**: enter the customer's mobile number and XPay delivers the link in a
WhatsApp message.
The same link is accepted by any number of customers. Don't reuse one link to gate a single seat or a limited slot. If a sale is one-of-a-kind, deactivate the link after the customer pays.
## What customers see [#what-customers-see]
When a customer opens the link, they land on XPay's hosted checkout page. It shows your business name, logo, and brand color, the products you put on the link, and a payment form on the right.
Removable and **Add-on** items show up here too. A removable item has a remove button, and add-ons sit under an "Add to your order" section with an **Add** button. When every item is an add-on, the order starts empty and the pay button stays off until the customer adds something.
The form asks for the fields you toggled on (name, phone, address, custom fields). If you allowed promotion codes, the customer can enter one. They pick a payment method (card, Valu, Fawry, or anything else you've enabled), fill in the form, and pay.
If the payment needs 3D Secure, the bank's challenge runs inside the same page. Card data is captured on a secure form hosted by XPay; nothing card-related ever runs on your site.
After a successful payment, the customer either lands on your **Redirect URL** or sees the **hosted confirmation page** (whichever you picked under Confirmation), with the optional **Return to...** button if you set a return URL.
## Read a payment link [#read-a-payment-link]
Open any link from the **Payment links** list page. The detail page is split into a left two-thirds (Line Items, Payment methods, Details, Custom Fields) and a right one-third with a live preview of what customers see. Below both sits a **Payments** section.
### Header [#header]
The link's name (the first product, with "and N more" when there are multiple) and a status badge (**Active** or **Archived**) sit at the top. Two buttons on the right:
* **Edit payment link** opens a sheet to update products, collection toggles, confirmation behavior, and fees.
* **Send payment link** opens the email and WhatsApp send flow.
The link's URL appears below the header with a one-click **Copy link** button.
### Line Items [#line-items]
Every product on the link, with the formatted price, the interval (One time, Per month, Every N weeks, "Customers choose what to pay"), the quantity, and whether **Adjustable Quantity** is on (with the min and max bounds when set). An **Add-on** item carries an "Add-on" badge, its quantity reads "Customer adds it", and an adjustable item shows its bound as "Up to N".
### Payment methods [#payment-methods]
The list of payment methods currently enabled for this link, with a **Manage** button. See [Payment methods on a link](#payment-methods-on-a-link) below.
### Details [#details]
Every collection toggle and configuration option, surfaced as labeled rows: **Created**, **Collect customer name**, **Collect billing address**, **Collect shipping address**, **Collect Mobile Numbers**, **Allow promotion codes**, **Call to action button**, **Customer creation**, **Fee configuration** (with **Customer pays platform fee** and **VAT collection** sub-rows), and **Return to merchant URL** when set.
### Custom Fields [#custom-fields]
When you added custom fields, each one appears here with its label, type (Text, Number only, Dropdown, Checkbox), optional flag, character limits, and dropdown options.
### Payments [#payments]
Every successful payment that came through this link, with status, amount, customer, payment method, and date. Click any row to open the payment.
## Payment methods on a link [#payment-methods-on-a-link]
Every Payment Link inherits your **default** payment method configuration unless you pin it to a specific one. The configuration controls which payment methods (card, Fawry, Valu, mobile wallets, and so on) appear on the customer's checkout page.
On the link's detail page, the **Payment methods** section shows the currently-enabled methods as a row of icons and labels. Below the list, a small caption tells you which configuration is being used.
The caption reads **"Using your default configuration"** when the link follows your account default. If you change methods on the default later, those changes flow through to this link automatically.
The caption shows the specific configuration's name when the link is pinned to it. Changes to your default don't affect this link.
To switch the configuration, click **Manage** to the right of the section heading. A sheet opens listing every configuration on your account, with the current one marked. Click another to switch. A "Configuration updated" toast confirms; the section re-renders with the new methods.
Picking the **default** configuration in the sheet doesn't pin the link to that configuration's ID. It clears the override so the link follows whichever configuration is the default at any given time. Picking a non-default pins the link to that one specifically.
See [Payment methods](/features/checkout-customization/payment-methods) for how to create configurations and toggle methods within them.
## Manage your payment links [#manage-your-payment-links]
The **Payment links** list page shows everything you've created, with the product image, total price, status, and last-edit date. Click a row to open the link's detail page.
| Action | Where to do it |
| ----------------------- | -------------------------------------------------------------------------------------------------------- |
| Edit a link | On the detail page, click **Edit payment link**. Update products, collection, confirmation, fees. |
| Re-send a link | On the detail page, click **Send payment link** to email or WhatsApp it again. |
| Deactivate a link | On the list page, select one or more links, then click **Deactivate** in the bulk-action bar at the top. |
| Re-activate a link | Same place. Select deactivated links and click **Activate**. |
| See payments for a link | On the detail page, scroll to the **Payments** section to see the transactions that came through it. |
A deactivated link's URL keeps existing for reporting, but customers who open it see an "unavailable" state and can't pay.
## Where to next [#where-to-next]
Payment Links pull from your catalog. Create products and prices before you build a link.
Embed the URL on your site, hide it behind a button, listen for paid events, or look up specific
transactions. The developer-side companion to this page.
Set your business name, logo, and brand color in **Settings → Branding**. The same brand drives
Payment Links, hosted checkout, and email receipts.
# Choose your integration (/en/get-started/choose-your-integration)
Pick one of four integration patterns based on how much code you want to write.
There are four ways to take a payment with XPay. All four use the same underlying Checkout Session, so the API call your server makes and the webhook your server receives are identical. What changes is how much frontend code you write, and where the customer fills in their card.
If you're not sure which to pick, start with **Payment Links**. You can move to a richer integration later without rewriting your backend.
## Side by side [#side-by-side]
| Pattern | Code you write | Customer experience | Pick this if |
| ------------------- | ------------------------------- | --------------------------------------------- | ------------------------------------------------------------------------------- |
| **Payment Links** | None | A hosted XPay page reached through your link | You want zero code, or you sell over invoices, social, or chat |
| **Hosted Checkout** | Server only | A hosted XPay page reached through a redirect | You want zero frontend complexity and the redirect is fine |
| **Drop-in** | Server + \~10 lines of frontend | A modal on your site, or an inline iframe | You want the customer to stay on your domain with the form still hosted by XPay |
| **Elements** | Server + custom frontend | A form you build yourself, on your site | You want full control over the checkout UI |
## The four patterns [#the-four-patterns]
Create a Payment Link in the dashboard or via the API and share the URL. We host the checkout, your customer pays, your server gets the `checkout.session.completed` webhook. No frontend code at all.
Your server calls `POST /checkout/sessions` and redirects the customer to the `url` returned in the response. After payment the customer lands back on the URL you configured in `afterCompletion`. The simplest path that involves any code.
Your server creates a Checkout Session and your frontend opens it with `xpay.checkout({ clientSecret, mode: "modal" })` or embeds it inline with `mode: "inline"`. The customer never leaves your domain.
Your server creates a Checkout Session and your frontend builds a custom checkout UI around XPay's ``. The most code, the most control.
## What's the same across all four [#whats-the-same-across-all-four]
* **One API call.** Every pattern (except a no-code Payment Link, which still wraps a session under the hood) starts with `POST /checkout/sessions`.
* **One webhook.** Every successful payment fires `checkout.session.completed` carrying the same Checkout Session you'd get from the API.
* **One customer flow.** Cards, 3D Secure, and local methods like Valu and Fawry all work identically.
You aren't locking yourself into a pattern when you start. The Checkout Session is the spine; the integration patterns are just different ways of opening it.
## Where to next [#where-to-next]
Haven't taken a test payment yet? Run XPay end to end in five minutes via Payment Link.
Want to scan the API surface before picking? Every endpoint, generated from the live OpenAPI
spec.
Every pattern on this page as runnable plain-HTML files: CDN script tag + global `XPay()`, no
build step.
A working storefront covering hosted checkout, drop-in, inline, and Elements, plus a
signature-verified webhook receiver.
# Onboarding & KYC (/en/get-started/onboarding)
Submit your business for review and unlock live payments.
Onboarding is how you graduate from test mode to live payments. You give us the legal and financial information we need to verify the business, our team reviews it, and once you're approved, the live mode switch unlocks in your dashboard.
You don't have to onboard right after signup. Test mode is fully functional from day one, so you can build and test your integration before submitting anything for review.
## When to start [#when-to-start]
* **Day one is fine.** If you know you'll be taking live payments soon, start onboarding now. The review takes a few business days, and you can keep building in test mode while you wait.
* **You can also wait.** Onboarding doesn't block any test-mode work. Sign up, build, run test payments, decide how you'll integrate, then submit when you're ready.
* **Start early enough that approval lands before launch.** Don't submit on the day you want to go live.
## Individual or Business [#individual-or-business]
Pick the track that matches your legal setup. The track decides which steps you fill in.
| Track | When to pick it | Steps |
| -------------- | -------------------------------------------------- | ---------------------------------------------------------- |
| **Individual** | You operate as a person, freelancer, or sole owner | Type → Representative → Payout → Review |
| **Business** | You operate as a registered company | Type → Representative → Business details → Payout → Review |
You can change the track from the dashboard before you submit.
## What you'll need [#what-youll-need]
Have these ready before you start. The form lets you save and return, but having everything in hand is faster.
**Both tracks:**
* Your contact details as the **representative** authorized to act on the account's behalf.
* Your **national ID**, front and back, as image or PDF.
* A **payout destination**, one of:
* **Bank account**: account holder name, bank name, account number, IBAN, and SWIFT/BIC.
* **Card**: cardholder name, card number, expiry, and network (Visa, Mastercard, or American Express).
* **Mobile wallet**: wallet holder name and wallet number.
**Business track adds:**
* **Legal name** of the registered company.
* **Display name** (the brand customers see at checkout).
* **Business structure** (LLC, JSC, etc.).
* **Industry** and a short **business description**.
* A **business website** or social profile if you don't have a site yet.
* **Address** (country, city, full address).
* **Commercial Registry** document.
* **Tax Card** and **tax number**.
All documents are uploaded as PDF, JPG, JPEG, or PNG.
## What happens after you submit [#what-happens-after-you-submit]
Our team reviews your information. Reviews usually take a few business days. The dashboard always shows where you are.
| What you'll see | What it means | What you do |
| --------------------- | ----------------------------------------------------- | -------------------------------------------------------- |
| **Under review** | Your application is with our team | Keep using test mode while you wait |
| **Changes requested** | We need a few fields fixed, sometimes with a deadline | Update the flagged fields and resubmit |
| **Activated** | You're approved for live payments | Flip the live mode switch and start taking real payments |
| **Suspended** | Rare. Your account is temporarily disabled | Contact support |
Once you're activated, your test-mode setup stays exactly as it was. You switch by flipping the live mode toggle and using your `pk_live_*` / `sk_live_*` keys for API calls.
## Where to next [#where-to-next]
Keep building and testing while your account is in review.
Pick the integration pattern you'll ship with.
Wire up webhook endpoints for both test and live before you go live.
# Quickstart (/en/get-started/quickstart)
Take your first XPay payment in five minutes. No code required.
You'll sign up, create a product, share a Payment Link, pay yourself with a test card, and see the transaction in your dashboard. No code, about five minutes. New accounts start in Test mode by default, so nothing here touches real money.
Go to [app.xpay.app](https://app.xpay.app) and create an account with your phone number.
Click **Products** in the sidebar, then **Create product**. Give it a name (anything works, `Test
product` is fine) and a price like `1499` for 1,499 EGP. Leave everything else on the defaults and
click **Add product**.
Click **Payment Links** in the sidebar, then **Create payment link**. Add the product you just created. Click **Create**.
On the next page you'll see the hosted URL in this shape:
```
https://checkout.xpay.app/p/plink_test_...
```
Use the copy button to grab it.
Open the URL in a new browser tab. Fill the card form:
* **Card number**: `5123 4500 0000 0008`
* **Expiry**: `01/39`
* **CVV**: `100`
* **Cardholder name**: anything
Click **Pay**, then complete the 3D Secure challenge that pops up.
Switch back to the dashboard and click **Transactions** in the sidebar. Your test payment is at the top with status **Successful**.
Click into it to see the transaction page: a Timeline of what happened, the customer details, the payment method, and a **Refund** button you can use later.
That's an end-to-end XPay payment. From here, you can build the same thing into your own product.
## Where to next [#where-to-next]
Compare the four integration patterns side by side and pick one.
Stay no-code. Generate Payment Links from the dashboard or the API.
Install `@xpayeg/react` and wire up `` and `useCheckout()`.
Subscribe to `checkout.session.completed` and verify the signatures.
# Test mode & test cards (/en/get-started/test-mode)
Build and test your XPay integration end to end without moving real money.
Test mode is a fully isolated environment that mirrors live: same API surface, same webhook payloads, same dashboard UI, separate everything else. New accounts start in test mode and stay there until your business is approved for live payments.
Test mode is where you build, test, and break things. Nothing you do in test mode touches a real card, your live customers, or your live balance.
## What's separate, what's the same [#whats-separate-whats-the-same]
| In test mode | In live mode |
| ---------------------------------------------------- | ---------------------------------------- |
| `pk_test_...` and `sk_test_...` API keys | `pk_live_...` and `sk_live_...` API keys |
| Test customers, transactions, payouts | Live customers, transactions, payouts |
| Test balance ledger, separate from live | Live balance ledger |
| Test webhook endpoints (set up separately) | Live webhook endpoints |
| **Same** API request and response shapes | Same |
| **Same** webhook events and the data they carry | Same |
| **Same** dashboard UI, with a banner and orange tint | Same UI, no banner |
When you graduate to live mode, you don't change your code. You just swap your test keys for live keys and set up live webhook endpoints.
## What you can test [#what-you-can-test]
* **Card payments** with predictable success and failure outcomes (see the table below).
* **3D Secure flows**, including success, failure, cancellation, and timeout (via the ACS emulator).
* **Refunds**, full or partial, from the dashboard or the API.
* **Webhooks** to a real or local endpoint, including retries and replay from the dashboard.
* **The full hosted checkout, drop-in, and Elements UI** end to end.
* **Payouts**, simulated on the test schedule.
## Test cards [#test-cards]
XPay accepts four test cards in test mode. The card number determines the issuer and the brand; the **expiry date** determines the payment outcome.
| Brand | Issuer | Card number | Currency |
| ---------- | ---------------------- | --------------------- | -------- |
| Mastercard | National Bank of Egypt | `5123 4500 0000 0008` | EGP |
| Visa | Banque Misr | `4508 7500 1574 1019` | EGP |
| Mastercard | Credit Agricole Egypt | `5111 1111 1111 1118` | EGP |
| Mastercard | International | `2223 0000 0000 0007` | USD |
Any other card number is rejected in test mode. The international card is the one to use when you want to test the international fee tier.
## Payment outcome by expiry [#payment-outcome-by-expiry]
In test mode the **expiry date** decides whether the payment succeeds or fails, and how it fails. The card number itself does not.
| Expiry | Outcome |
| ------- | ----------------------- |
| `01/39` | APPROVED |
| `05/39` | DECLINED |
| `01/27` | INSUFFICIENT\_FUNDS |
| `04/27` | EXPIRED\_CARD |
| `08/28` | TIMED\_OUT |
| `01/37` | ACQUIRER\_SYSTEM\_ERROR |
| `02/37` | UNSPECIFIED\_FAILURE |
| `05/37` | UNKNOWN |
Use `01/39` for the happy path. Use the others to validate how your code handles real-world failures: the Payment Intent moves to `payment_failed`, the Charge gets a `failure_code`, and you receive the matching webhooks (`payment_intent.payment_failed`, `charge.failed`).
## CVV outcome by CVV value [#cvv-outcome-by-cvv-value]
The **CVV** controls the CVV match result reported on the resulting Charge.
| CVV | Result |
| ----- | -------------- |
| `100` | MATCH |
| `101` | NOT\_PROCESSED |
| `102` | NO\_MATCH |
Use `100` unless you're testing CVV-mismatch handling.
## 3D Secure in test mode [#3d-secure-in-test-mode]
Every test payment runs the full 3D Secure 2 challenge flow through XPay's sandbox emulator. After you submit the card form, an ACS (Access Control Server) page pops up where you choose the authentication outcome:
* **Success.** Finishes the payment along the happy path.
* **Rejected.** The issuer denies the challenge. The payment fails with `authentication_required` or `card_declined` depending on the bank's response.
* **Cancelled.** The customer dismisses the challenge.
* **Timeout.** No response within the issuer's window.
This is how you test the 3DS-failure branches of your integration without finding a real failing card.
## Where to next [#where-to-next]
Haven't run a test payment yet? Take one end to end in five minutes.
Pick the integration pattern you'll build with for real.
Subscribe to test events and verify the signature before you deploy.
Tunnel test webhooks to your laptop while you build.
# AI agents (/en/integrate/ai-agents)
Every page on this site is available as plain markdown, plus a full-site export and the OpenAPI spec. Point your coding assistant at these instead of scraping HTML.
If you build with a coding assistant, give it the markdown sources rather than the rendered pages. Every documentation page serves clean markdown at a predictable URL, and the whole site is available as a single file. No HTML parsing, no scraping.
## Get one page as markdown [#get-one-page-as-markdown]
Every page has a **Copy Markdown** button under its title. It copies the whole page as markdown, ready to paste into a chat with your assistant. The **Open** menu next to it links the raw markdown file, and can open the page as a prompt in ChatGPT or Claude.
Tools that fetch pages themselves can skip the buttons. Append `.md` or `.mdx` to any documentation URL:
```bash
curl https://docs.xpay.app/en/integrate/idempotency.md
```
This works on every page in both languages. Swap the `en` prefix for `ar` to get the Arabic version. Sending `Accept: text/markdown` to the normal URL returns the same content, and each page carries a `` tag in its head, so an agent can discover the markdown URL on its own.
## Get the whole site [#get-the-whole-site]
Three files cover the full documentation surface.
| File | What it contains |
| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| [`/llms.txt`](https://docs.xpay.app/llms.txt) | An index of every page with its title, description, and URL. Start here. |
| [`/llms-full.txt`](https://docs.xpay.app/llms-full.txt) | The guides and SDK reference as one markdown file. The API Reference is not inlined: use `/openapi.json` or a single page's `.md` for that. |
| [`/openapi.json`](https://docs.xpay.app/openapi.json) | The machine-readable API specification: every endpoint, object, field, and enum. |
The API Reference pages are generated from the OpenAPI specification, and their markdown carries the same parameters, request bodies, and response fields the rendered pages show. A field typed as a top-level resource (Charge, Refund, Price) appears as a link to that object's page; every other nested shape is expanded inline.
## Which source to use [#which-source-to-use]
Pick based on how much context your tool can hold.
* **Building an integration**: load `/llms-full.txt` once and let the assistant search it.
* **Answering one question**: fetch the single page with `.md`, using `/llms.txt` to find the right URL.
* **Generating a client or validating requests**: use `/openapi.json` directly. It is the same specification the API Reference renders from, so it never drifts from these pages.
## Where to next [#where-to-next]
# Idempotency (/en/integrate/idempotency)
Safely retry write requests with an Idempotency-Key. XPay records the first result and replays it, so a retried call never repeats the side effect.
Network calls fail in ways that leave you unsure whether the request went through. An `Idempotency-Key` lets you retry a write safely: XPay records the result of the first request that used a given key and returns that same result for every retry, so the operation runs once even if you send it five times.
Send an `Idempotency-Key` on any write you might retry. It matters most on `POST /refunds`, where
a blind retry after a timeout would otherwise refund the customer twice.
## How it works [#how-it-works]
1. You generate a unique key for one logical operation and send it in the `Idempotency-Key` header.
2. XPay records the response of the first request that used that key.
3. Every later request with the same key returns that stored response, with an `Idempotent-Replayed: true` header. Your operation runs once.
Keys are scoped to your account and mode (Test or Live), and they expire after 24 hours.
## Send an Idempotency-Key [#send-an-idempotency-key]
Add the header to any supported write. The key is yours to generate. A version-4 UUID is a good default.
```bash
curl -X POST https://api.xpay.app/refunds \
-H "Authorization: Bearer sk_test_..." \
-H "Idempotency-Key: 5f0c9b1a-4e2d-4a6f-9c3b-7d1e0a2f4b88" \
-H "Content-Type: application/json" \
-d '{
"paymentIntentId": "pi_test_xyz789",
"amount": 50000,
"reason": "requested_by_customer"
}'
```
```typescript
import { randomUUID } from "node:crypto";
const res = await fetch("https://api.xpay.app/refunds", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.XPAY_SECRET_KEY}`,
"Idempotency-Key": randomUUID(),
"Content-Type": "application/json",
},
body: JSON.stringify({
paymentIntentId: "pi_test_xyz789",
amount: 50000,
reason: "requested_by_customer",
}),
});
```
Reuse the same key when you retry the same operation. Generate a fresh key for a genuinely new operation.
## Choosing a key [#choosing-a-key]
* Use one key per logical operation: one refund attempt, one Checkout Session create. Store it next to the operation you are retrying so the retry sends the identical value.
* Make it unique and hard to guess. A version-4 UUID works well. A key is at most 255 characters.
* Do not reuse one key for unrelated operations. Two different refunds need two different keys.
## Reusing a key [#reusing-a-key]
What XPay returns on a repeat depends on whether the request matches the first one that used the key:
* **Same key, same request** returns the same response it sent the first time, with `Idempotent-Replayed: true`. The handler does not run again, so no second refund, charge, or record is created.
* **Same key, different request** returns `400` with `error.code` `idempotency_key_in_use`. A key is bound to the first request it was used with (method, path, query, and body). To send a different request, use a new key. See [`idempotency_key_in_use`](/integrate/errors/api-error-codes#idempotency_key_in_use).
## Requests still in progress [#requests-still-in-progress]
If a second request arrives with the same key while the first is still running, XPay returns `409` with a `Retry-After` header. Wait the number of seconds in `Retry-After`, then retry. By then the first request has finished, and the retry returns its stored result.
## What gets stored, and for how long [#what-gets-stored-and-for-how-long]
XPay stores the first **definitive** result for each key:
* A success, or an error the request produced after it started processing (for example a declined card), is stored and replayed. To run that operation again, use a new key.
* A request rejected before processing (a validation error), or one that hit a temporary failure, is **not** stored. Retry it with the same key and it runs fresh.
Stored results expire after 24 hours. After that, the same key is treated as new.
## Which requests support it [#which-requests-support-it]
Send `Idempotency-Key` on the write endpoints that create or change a resource:
* Refund creation, `POST /refunds`. The most
important one.
* Checkout Session create, update,
and expire.
* Product create and update, Price create and update.
* Payment Link create and update.
* Customer create.
`GET` and `DELETE` requests are already idempotent, so the header has no effect on them.
## Idempotency vs webhook events [#idempotency-vs-webhook-events]
This page is about requests you send to XPay. There is a separate idempotency concern for the webhooks XPay sends you: the same event can arrive more than once, so your handler must skip duplicates by `event.id`. The two are independent.
| Aspect | Request idempotency (this page) | Webhook event idempotency |
| ---------------------- | --------------------------------- | ------------------------- |
| Direction | You call XPay | XPay calls you |
| Key | `Idempotency-Key` header you send | `event.id` you read |
| Who handles duplicates | XPay | Your handler |
For the webhook side, see [Verifying signatures → Idempotency](/integrate/webhooks/verifying-signatures#idempotency).
## Where to next [#where-to-next]
The route where an Idempotency-Key matters most.
The `idempotency_key_in_use` error and how to resolve it.
Dedupe webhook deliveries on `event.id`.
Pick the integration pattern that fits your stack.
# Object model (/en/integrate/object-model)
How XPay's resources fit together. What you create, what you read, and the two IDs to store on your order record.
A successful payment on XPay is a small graph of related objects. You create the top one (a Checkout Session) and read the rest as nested fields. This page is the one-screen mental model of how those objects fit together, which IDs you'll see, and what to keep on your order record.
## The objects [#the-objects]
```
Checkout Session cs_*
├── customer cus_*
└── paymentIntent pi_*
├── charges[] ch_*
│ └── balanceTransaction txn_* (the receivable)
└── refunds[] re_*
└── balanceTransaction txn_* (the reversal)
```
| Object | What it represents |
| ---------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Checkout Session | **A configured checkout.** Line items, customer fields, and the integration surface (hosted page, drop-in, elements). One per customer attempt. |
| Customer | **The shopper's record.** Contact, billing, and shipping details. Captured during checkout, or linked by `customerId` if you already have one. |
| Payment Intent | **The transaction.** What money is meant to move, the method used, and the state it's in. The financial counterpart to the Checkout Session. |
| Charge | **A money-move attempt.** One try to authorize and capture money on the customer's payment method. A Payment Intent has more than one if the customer retried. |
| Refund | **A money reversal.** Sends money back to the customer, full or partial, against a captured Charge. |
| Balance Transaction | **A ledger row.** Every Charge and every Refund produces one. The signed value is what hits your balance. |
## What you create vs what you read [#what-you-create-vs-what-you-read]
You create **Checkout Sessions** and **Refunds**. Payment Intents, Charges, and Balance
Transactions are read-only resources. You encounter them as nested fields in responses and as IDs
in webhook payloads, but the API does not expose direct creation. Every payment flow starts at a
Checkout Session.
The two creation surfaces:
* `POST /checkout/sessions` produces a Checkout Session. Behind the scenes the customer's payment runs on a Payment Intent, which produces Charges, which produce Balance Transactions. You see all of them as nested fields on the session, but you don't `POST` any of them yourself.
* `POST /refunds` produces a Refund against a `pi_*` (the Payment Intent for the payment) or a `ch_*` (a specific Charge). Provide exactly one. The Refund produces its own Balance Transaction (the reversal).
Everything else is read-only. You retrieve a Checkout Session, you read its `paymentIntent`, you read its `charges`, you read their balance transactions. You never create them on your own.
Payment Links are a no-code feature created in the dashboard. When a customer opens a `/p/plink_...` URL, XPay creates the Checkout Session for that customer automatically. You still receive `checkout.session.completed` and the same nested object graph as any other integration.
## Which IDs to store [#which-ids-to-store]
Two IDs do different jobs. Keep both on your order record.
* **`cs_*` is your checkout reference.** It identifies the customer's checkout context: which line items they bought, what fields they filled in, which integration surface they used. Use it for support, debugging, and re-rendering the customer's order in your own UI.
* **`pi_*` is your transaction handle.** It identifies the money: what was charged, what was refunded, what settled into your balance. Use it to issue refunds and to reconcile against payouts. One per successful payment.
Both arrive on the `checkout.session.completed` payload, so capture them at the same time and store both. Each one is the right tool for its own job, and neither replaces the other.
`cus_*` (if present) is the third id worth keeping when you want to re-find a returning customer across orders. Everything else (`ch_*`, `txn_*`) is reachable on demand from `GET /checkout/sessions/:id` or `GET /refunds/:id` and rarely needs to live on your order record.
## How the objects connect [#how-the-objects-connect]
The session has at most one Payment Intent (created when the customer submits the form) and at
most one Customer (existing or created during checkout). Read the session, you have the rest.
Every payment attempt on the session goes through one Payment Intent. Retries reuse it; they
don't spawn a new one.
A Charge is "money came in." A Refund is "money went back." Each produces a Balance Transaction,
which is the row that settles into your balance and your payouts.
The signed value of a Balance Transaction (positive for charges, negative for refunds) is the
truth about your money. Charges and Refunds are how you describe the event; Balance Transactions
are how the books balance.
## When you reach into the deeper objects [#when-you-reach-into-the-deeper-objects]
For most integrations the answer is "rarely." The deeper objects exist so the data is consistent and traceable, not because you need to interact with them directly.
| You need to... | What to do |
| ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- |
| Fulfill an order after payment | Read `cs_*` on `checkout.session.completed`. That's the whole job for most integrations. |
| Issue a refund from code | `POST /refunds` with the `pi_*`. See the [Refunds](/integrate/refunds) integration page. |
| Look at the exact charge that ran | Read `paymentIntent.charges[]` on the session response. |
| Reconcile against your balance and payouts | Read `paymentIntent.charges[].balanceTransaction`. Each carries the settled amount, fees, and which payout (if any) it landed in. |
| Look up a returning customer | Read `cs.customer` (or fetch Customer by `cus_*`). |
## The events you receive [#the-events-you-receive]
The one event every integration listens for is `checkout.session.completed`. The `data.object` is a full Checkout Session, identical to `GET /checkout/sessions/:id`, with the full nested graph attached. That single event is enough to fulfill the order and capture every ID you might want to store.
Other events (`checkout.session.expired`, `refund.created`, `charge.refunded`) are useful when you want to react to specific lifecycle changes outside the success path. The full list is on the [Event reference](/integrate/webhooks/event-reference).
## Where to next [#where-to-next]
The fields on the session you create, the lifecycle it moves through, and what you can
configure.
How to issue a refund against a `pi_*` (or a specific `ch_*`). Single API call.
Receive `checkout.session.completed` and verify the signature.
Pick the surface that builds the Checkout Session: Payment Links, Hosted, Drop-in, or Elements.
# Refunds (/en/integrate/refunds)
Reverse a successful payment in full or in part. One POST against the Payment Intent or Charge, plus an optional GET to read it back.
A Refund reverses money from a successful payment back to the customer. You can refund the full amount or any partial amount, multiple times, until the payment has nothing left to refund. The API surface is small: one `POST /refunds` to issue, one `GET /refunds/:id` to read, one `GET /refunds` to list.
This page covers the developer path: when to use the API, what to send, what comes back, what events to listen for. For the dashboard-issue flow that merchants use day to day, see [Refunds](/features/transactions/refunds) under Features.
`POST /refunds` takes **`paymentIntentId`** (the transaction handle you stored on the order) or
**`chargeId`** (a specific charge). Provide exactly one. The `paymentIntentId` path is the primary
one and what most integrations should use; it works directly with the `pi_*` you already capture
from `checkout.session.completed`.
## How it works [#how-it-works]
1. **Your server** has the `pi_*` for the payment, captured from the `checkout.session.completed` webhook (or read on demand from `GET /checkout/sessions/:id`).
2. **Your server** calls `POST /refunds` with the `paymentIntentId`. Optionally include `amount` (for partial refunds), `reason`, `description`, or `metadata`.
3. **XPay** processes the refund through the original payment method.
4. **XPay** posts a `refund.created` and a `charge.refunded` webhook so your server can mark the order refunded and notify the customer.
The Refund's own Balance Transaction is what hits your XPay balance. The signed value (negative for refunds) is what reconciles against your payouts.
## Build it [#build-it]
### 1. Create the refund [#1-create-the-refund]
The only required field on `POST /refunds` is one of `paymentIntentId` or `chargeId`. Omit `amount` to refund the full remaining refundable amount.
```bash
curl -X POST https://api.xpay.app/refunds \
-H "Authorization: Bearer sk_test_..." \
-H "Content-Type: application/json" \
-d '{
"paymentIntentId": "pi_test_xyz789",
"amount": 50000,
"reason": "requested_by_customer",
"metadata": { "order_id": "ord_42" }
}'
```
```typescript
const res = await fetch("https://api.xpay.app/refunds", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.XPAY_SECRET_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
paymentIntentId: "pi_test_xyz789",
amount: 50000, // 500.00 EGP, in minor units
reason: "requested_by_customer",
metadata: { order_id: "ord_42" },
}),
});
const refund = await res.json();
```
```python
import os, requests
res = requests.post(
"https://api.xpay.app/refunds",
headers={
"Authorization": f"Bearer {os.environ['XPAY_SECRET_KEY']}",
"Content-Type": "application/json",
},
json={
"paymentIntentId": "pi_test_xyz789",
"amount": 50000,
"reason": "requested_by_customer",
"metadata": {"order_id": "ord_42"},
},
timeout=10,
)
refund = res.json()
```
### 2. Refund a specific charge (alternative) [#2-refund-a-specific-charge-alternative]
If you already work at the Charge level (for example, you key fulfillment off `charge.succeeded` webhooks), pass `chargeId` instead. Same body otherwise; `chargeId` and `paymentIntentId` are mutually exclusive.
```bash
curl -X POST https://api.xpay.app/refunds \
-H "Authorization: Bearer sk_test_..." \
-H "Content-Type: application/json" \
-d '{
"chargeId": "ch_test_AbC123",
"amount": 50000
}'
```
When `paymentIntentId` is rejected with a "zero or multiple succeeded charges" error, fall back to `chargeId` to target the specific charge you want to refund.
### 3. Read the response [#3-read-the-response]
```json
{
"id": "re_test_AbC123",
"object": "refund",
"amount": 50000,
"currency": "EGP",
"chargeId": "ch_test_AbC123",
"paymentIntentId": "pi_test_xyz789",
"status": "succeeded",
"reason": "requested_by_customer",
"balanceTransactionId": "txn_test_def456",
"createdAt": "2026-05-01T12:00:00.000Z"
}
```
Every refund you create through the merchant API today resolves to `status: "succeeded"` synchronously. The Refund object schema reserves additional values (`pending`, `requires_action`, `failed`) for future use; you won't see them in responses today.
The response carries both `chargeId` and `paymentIntentId` so you can wire the refund back into either side of your data model. The `balanceTransactionId` points to the ledger row that debited your available balance. For partial refunds, multiple Refund objects accumulate against one Charge; each has its own `txn_*`.
## Choosing between `paymentIntentId` and `chargeId` [#choosing-between-paymentintentid-and-chargeid]
The two fields are mutually exclusive: provide exactly one.
* **Use `paymentIntentId` by default.** The `pi_*` is the transaction handle for one successful payment; the server resolves it to the unique succeeded-and-captured charge on that intent. This is the right path for almost every integration that simply wants to refund "the payment."
* **Use `chargeId` when there's ambiguity.** A `paymentIntentId` is rejected when the Payment Intent has zero or more than one succeeded charges. That can happen on edge flows you may build later (multi-capture, partial captures across separate charges). `chargeId` always targets a specific charge.
Reach for `chargeId` only when you need that level of specificity. In the typical "customer paid once, you want to refund them" case, `paymentIntentId` is shorter, clearer, and uses the ID you already store.
## Partial vs full refunds [#partial-vs-full-refunds]
`amount` is in minor units, same as everywhere else (`50000` is 500.00 EGP).
* **Omit `amount`** to refund whatever is still refundable on the Charge. On the first refund this is the full charge total. On subsequent refunds it's whatever's left after previous partial refunds.
* **Send `amount`** to refund a specific portion. Must be at least 1 minor unit and at most the remaining refundable amount.
* You can issue **multiple partial refunds** against the same Charge as long as the total doesn't exceed the original amount. Each call creates its own `re_*` and its own Balance Transaction.
* Once the cumulative refunded amount equals the Charge total, further refund attempts return an error.
## Reason and metadata [#reason-and-metadata]
`reason` is an enumerated short code that describes why you refunded. Useful for analytics and support. Common values:
| Value | When to use |
| --------------------------- | ---------------------------------------------------------------------- |
| `requested_by_customer` | The customer asked for it. Your default for self-service refunds. |
| `duplicate` | The customer was charged twice for the same order. |
| `fraudulent` | The charge was disputed as fraud and you're refunding proactively. |
| `expired_uncaptured_charge` | An auth-only charge expired without capture. Rarely surfaced manually. |
`description` accepts up to 500 characters as a free-form note on the Refund. It's stored on the object and surfaces in the dashboard's refund timeline, useful for your own records and for support agents reviewing the refund later.
`metadata` is your own key-value bag, opaque to XPay, useful for cross-referencing with your order system.
## Listen for refund events [#listen-for-refund-events]
For dashboard-issued refunds, or to confirm async refunds completed, listen for refund webhooks. Two events fire on every successful refund:
| Event | When |
| ----------------- | --------------------------------------------------------------------------------- |
| `refund.created` | A new Refund object was created. Fires for both API and dashboard refunds. |
| `charge.refunded` | A Charge had a refund applied to it. Useful when you key fulfillment off Charges. |
The `data.object` on `refund.created` is a full Refund, identical to `GET /refunds/:id`. On `charge.refunded` it's a full Charge with the new Refund nested inside `refunds[]`.
For the signature-verification recipe and replay protection, see [Webhooks → Verifying signatures](/integrate/webhooks/verifying-signatures).
## Production checklist [#production-checklist]
* **Send an `Idempotency-Key` on `POST /refunds`.** A retry after a network timeout then returns the original refund instead of issuing a second one. See [Idempotency](/integrate/idempotency).
* **Idempotent fulfillment.** Track which Refund IDs you've already processed in your own database. `refund.created` can be redelivered.
* **Don't issue from the customer-facing app.** Refunds use a secret key (`sk_test_*` / `sk_live_*`). Issue them from your server, not from frontend code.
* **Decide who can issue refunds.** Limit refund permissions on the API key you use for this code path. For dashboard-issued refunds, restrict the **Refunds** permission on team roles.
* **Set expectations on the bank window.** A successful refund hits XPay's ledger immediately, but card refunds take 7 to 14 days to appear on the customer's statement. Tell the customer in your refund email so they don't assume it failed.
* **Reconcile against Balance Transactions.** The `balanceTransactionId` on each refund is what netted out your balance. Use it for accounting, not the Refund's `amount` alone.
## Where to next [#where-to-next]
How Payment Intents, Charges, and Refunds fit together, and which IDs to store.
Receive `refund.created` and `charge.refunded` and verify their signatures.
The merchant-facing flow for one-off refunds without writing code.
# API keys (/en/features/account-and-operations/api-keys)
Create publishable, secret, and restricted API keys, scope them with the permissions selector, and roll them when needed.
API keys are how your code talks to XPay. The dashboard manages two kinds: a fixed pair of **standard keys** (publishable + secret) for full access, and any number of **restricted keys** with a custom permission scope. Test mode and live mode have separate keys, so a key from one mode never works in the other.
This page covers where the keys live, how each kind works, the create-and-scope flow for restricted keys, how to roll a key, and the click-to-reveal flow.
## What you can do [#what-you-can-do]
Every account has one **Publishable key** (safe for the browser) and one **Secret key**
(server-side only) per mode.
Create a named key that's allowed to do exactly what you need, nothing more. Helpful for
read-only analytics or one-purpose integrations.
Rotate a leaked key with one click. The old value stops working immediately and the new value is
copied to your clipboard.
Test mode and live mode have independent keys. The dashboard surfaces the right set based on the
mode toggle.
## Open the API keys page [#open-the-api-keys-page]
Click **Developers** in the dashboard sidebar. The Developers page has three tabs: **Overview**, **API keys**, and **Webhooks**. Pick **API keys**.
The tab is split into two sections: **Standard keys** at the top (always two rows), and **Restricted keys** below (any number, with a **Create restricted key** button on the right).
If the tab is disabled in your dashboard, your role lacks the API-keys read permission. See [Team and roles](/features/account-and-operations/team-and-roles) for which roles can access this surface.
## Standard keys [#standard-keys]
Every merchant has a fixed pair of standard keys per mode:
| Key | What it's for | Where to use it |
| ------------------- | -------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
| **Publishable key** | Safe to embed in browser-side code. Identifies your account but can't read or modify private data. | The XPay JavaScript SDK, web checkout components, mobile clients. |
| **Secret key** | Full server-side access to the API. Treat it like a password. | Your backend, server-to-server jobs, scripts. |
Both standard keys exist by default. They can't be deleted; they can only be **rolled** (rotated).
The dashboard description on the section reads: "A key that unlocks full API access, enabling extensive interaction with your account." That applies to the secret key. The publishable key carries the same identifier prefix but with no write or read access to private resources, so it's safe to ship to the client.
## Restricted keys [#restricted-keys]
Restricted keys are the recommended pattern when you want to limit blast radius. Each restricted key has:
* A **name** that you set (3 to 255 characters).
* A **permission scope** chosen per resource type, with three levels: **None**, **Read**, or **Write**. Setting **Write** implies **Read** automatically.
You can create as many restricted keys as you like. They're listed under the standard keys with a permission count badge; hovering it shows which resources got Read access vs Read-and-Write access.
Common patterns:
* A read-only key for an internal analytics dashboard.
* A write-scoped key for a single integration partner that only manages payment links.
* A short-lived key for a contractor's local script.
## Create a restricted key [#create-a-restricted-key]
### Click Create restricted key [#click-create-restricted-key]
In the **Restricted keys** section, click **Create restricted key** in the top right. A side sheet opens, titled **Create restricted API key**.
### Name the key [#name-the-key]
Enter a **Key name** between 3 and 255 characters. Pick something that names the integration or use case ("Analytics", "Webflow site", "Contractor cleanup"). The name is shown in the dashboard but never appears in the API or to customers.
### Pick permissions [#pick-permissions]
The permissions selector is a two-column table. The left column lists the resource types you can scope this key against; the right column has a **None / Read / Write** toggle for each row.
A row at the top labeled **All resources** is a master toggle: clicking **None**, **Read**, or **Write** stamps that level on every row at once. Use it to set a baseline, then tune individual rows.
**Write implies Read** for the same resource type. Selecting **Write** sends both read and write capabilities to the API, so you don't need to set both manually.
At least one permission must be set (Read or Write on at least one row), otherwise the key can't be created.
### Click Create key [#click-create-key]
In the bottom of the sheet, click **Create key**. The new key is created and the full secret value is copied to your clipboard immediately. A "Restricted key created successfully" toast confirms.
**Save the value somewhere safe.** XPay will only show the prefix on the dashboard from this point on; the full value is never displayed again. To get a working key value back, click on the key's row to reveal it (see below).
## Read a key [#read-a-key]
The token column shows each key's prefix followed by `••••••••`. Click any row to reveal the full value:
* **Publishable keys** are revealed and copied immediately when you click. They're safe to expose.
* **Secret and restricted keys** require a 2-factor confirmation in live mode before the value is revealed and copied. In test mode, the click reveals immediately.
Once revealed, the value stays visible on that row until you navigate away. A "Copied to clipboard" toast confirms the copy each time you click.
## Roll a key [#roll-a-key]
Rolling a key revokes the old value and creates a new one in its place. Use roll when:
* You think a key has been leaked or exposed in client code.
* A teammate left and they had access to the secret.
* You want to rotate keys on a regular schedule.
Open the action menu on any key (the **⋯** icon at the right of the row) and pick the roll option. A confirmation dialog explains the rotation. After confirming:
* The old value stops working immediately. Any code still using it gets an authentication error.
* A new value is generated and copied to your clipboard automatically.
* The key keeps its name (for restricted keys) and permission scope.
Roll works on every key type, including standard keys. Plan the rotation: update your code with the new value before rolling, or be prepared for downtime while you swap.
## Edit and delete a restricted key [#edit-and-delete-a-restricted-key]
Standard keys can't be edited or deleted. Restricted keys carry two extra options on the action menu:
* **Edit** opens an **Edit restricted API key** sheet to update the name and permission scope. Saving applies immediately; existing requests using the key continue with the new scope.
* **Delete** opens a confirmation dialog. Deleting revokes the key permanently. There's no undo, and any code still using it gets an authentication error on the next request.
Both actions are gated on the API-keys write permission. If your role lacks it, the menu items are disabled.
## Test vs live mode [#test-vs-live-mode]
The keys you see depend on the mode toggle:
* In **test mode**, the dashboard shows your test-mode standard keys and any test-mode restricted keys you've created. Calls to the test-mode API only work with these keys.
* In **live mode**, the dashboard shows your live-mode standard keys and any live-mode restricted keys. Live-mode API calls only work with these keys.
A test-mode key never works against the live API and vice versa. Restricted keys live in one mode at a time; create a separate restricted key for each mode you need.
The 2-factor reveal step is enforced in live mode only. Test mode reveals immediately so you can grab keys without friction during development.
## Where to next [#where-to-next]
Roles control who can manage API keys in the dashboard. Restricted keys can have stricter scope
than the role of the person who created them.
Pick which notifications each team member receives by email or WhatsApp.
Webhook signing secrets are managed on the **Developers → Webhooks** tab, separately from API
keys.
# Notifications (/en/features/account-and-operations/notifications)
Communication preferences for your team. Pick which notifications each role receives by email and WhatsApp.
The **Communication preferences** page (in `Settings → Notifications`) controls which notifications XPay sends to your team and through which channels. Notifications cover account events (verification status, holds, team changes), money events (payments, refunds, payouts), and integration events (webhook delivery failures).
Two things shape what each teammate sees on this page: their role on your account (some notifications are restricted to specific roles) and the notification's own configuration (some channels are required, some are unavailable).
This page covers how the dashboard is laid out, the four channel states you may run into, what each notification reports, who receives it by default, and why test mode produces no notifications.
## What you can do [#what-you-can-do]
Each notification can be received by email, WhatsApp, both, or neither (depending on which
channels are available).
Recipients are scoped per role. Super Administrators and Administrators get account events;
Accountants get money events; Developers get webhook failures.
Critical risk and compliance notifications are locked on for the roles that receive them. They
can't be disabled.
Notifications fire only on live mode activity. Switch to live mode to receive them.
## Open the communication preferences [#open-the-communication-preferences]
In the dashboard sidebar, click your account menu and pick **Settings**, then click **Notifications**. The page is titled **Communication preferences** and described as "Choose which notifications you receive and how."
## How the page is organized [#how-the-page-is-organized]
The page is organized as three top-level tabs, each grouping related notifications into sections.
| Tab | Sections |
| ----------------------------- | ------------------------------------------------------------------ |
| **Account** | **Risk and Compliance**, **Account Updates**, **Team Management**. |
| **Transactions and Balances** | **Payments**, **Payouts**. |
| **API** | **Webhooks**. |
Each section lists its notifications as rows. To the right of every row are two columns of checkboxes: **Email** and **WhatsApp**. Click a checkbox to toggle that channel for that notification.
Saves are immediate; there's no save button.
## Channels and lock states [#channels-and-lock-states]
Every notification has up to two channels: **Email** and **WhatsApp**. The state of a channel checkbox falls into one of four cases:
| State | Visual | What it means |
| ------------------------- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Toggleable, on** | Checked, clickable | The channel is on for you. You can turn it off by unchecking. |
| **Toggleable, off** | Unchecked, clickable | The channel is available but currently off. You can turn it on by checking. |
| **Locked: required** | Checked, disabled | The channel is mandatory for this notification on your role. You can't turn it off. Hover for "Required for your account". |
| **Locked: not available** | Unchecked, disabled | The channel doesn't apply to this notification (the message has no template for it) or the notification isn't sent to your role. Hover for an explanation. |
There are two distinct flavors of "locked unavailable":
* **"Not available for this notification"** means the notification only ships on certain channels. For example, transaction-related notifications today only send by email; their WhatsApp checkbox is locked off for everyone.
* **"Not available for your role"** means your role isn't on the recipient list for this notification. For example, a Sales Representative looking at the Refunds notification sees both channels locked off, because Refunds notifications go to Super Administrators, Administrators, and Accountants only.
## What each notification covers [#what-each-notification-covers]
The pages below list every notification XPay sends today, what triggers it, and which roles receive it.
### Risk and Compliance [#risk-and-compliance]
| Notification | What triggers it | Who receives it | Channels |
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | ------------------------------------------------ |
| **Account status** | Your merchant is approved, suspended, restored. A payment is held for risk review, or a hold is captured, released, or expired. | **Super Administrator** only. | **Email** and **WhatsApp** required (locked on). |
| **Information requests** | The verification team asks you for additional documents or information, accepts your submission, rejects it, or cancels their request. | **Super Administrator** and **Administrator**. | **Email** and **WhatsApp** required (locked on). |
### Account Updates [#account-updates]
| Notification | What triggers it | Who receives it | Channels |
| -------------------------- | ------------------------------------------------------------------------------------------------- | ---------------------------------------------- | ----------------------------------------------------------- |
| **Payment method updates** | XPay enables or disables a payment processor on your account (e.g. card processing comes online). | **Super Administrator** and **Administrator**. | **Email** required (locked on). **WhatsApp** not available. |
### Team Management [#team-management]
| Notification | What triggers it | Who receives it | Channels |
| ----------------------- | -------------------------------------------------------- | ---------------------------------------------- | ----------------------------------------------------------------- |
| **Team member changes** | A teammate is added, removed, or has their role changed. | **Super Administrator** and **Administrator**. | **Email** toggleable (on by default). **WhatsApp** not available. |
### Payments [#payments]
| Notification | What triggers it | Who receives it | Channels |
| ----------------------- | -------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------ |
| **Successful payments** | A charge succeeds. | **Super Administrator**, **Administrator**, and **Accountant**. | **Email** toggleable (off by default). **WhatsApp** not available. |
| **Refunds** | A refund is created. | **Super Administrator**, **Administrator**, and **Accountant**. | **Email** toggleable (on by default). **WhatsApp** not available. |
### Payouts [#payouts]
| Notification | What triggers it | Who receives it | Channels |
| --------------- | ------------------------------------------------- | --------------------------------------------------------------- | ----------------------------------------------------------------- |
| **Payout sent** | A standard payout batch is paid out to your bank. | **Super Administrator**, **Administrator**, and **Accountant**. | **Email** toggleable (on by default). **WhatsApp** not available. |
### Webhooks [#webhooks]
| Notification | What triggers it | Who receives it | Channels |
| --------------------------- | -------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | ----------------------------------------------------------------- |
| **Webhook delivery failed** | A webhook endpoint exhausts its retry attempts and gives up. Helps you catch broken endpoints before events pile up. | **Super Administrator**, **Administrator**, and **Developer**. | **Email** toggleable (on by default). **WhatsApp** not available. |
If your role isn't listed for a given notification, the row appears greyed out for you with both channels locked off. **Support Agent**, **Sales Representative**, and **View Only** receive no notifications today; they see the page with every row locked.
## Test mode is silent [#test-mode-is-silent]
Notifications are only sent for **live mode** activity. While you're in test mode, no email or WhatsApp message is delivered, regardless of how your preferences are configured.
The dashboard surfaces a notice at the top of the page in test mode: "Notifications are only sent for live mode activity. Switch to live mode to receive notifications." If you're testing your integration and expecting to receive an email or WhatsApp message, switch to live mode first.
## Where to next [#where-to-next]
Roles control who appears on the recipient list for each notification.
The Payout sent notification reports paid batches; this is where you read the underlying methods
and schedule.
Webhooks deliver event data to your server; the failed-delivery notification fires when an
endpoint stops responding.
# Payouts and settlement (/en/features/account-and-operations/payouts-and-settlement)
How and when XPay disburses your balance to your bank, plus the settlement-method form, schedule view, and the payout batch detail page.
A **payout** is XPay sending your settled balance to your bank. The dashboard's settlement settings page is where you manage the bank account, wallet, or card the funds land on, and where you read your payout schedule. Once a payout has been generated, you can drill into it from the **Payouts** tab on Transactions, the Recent Payouts on Balance, or the Connected Payout link on any payment.
This page covers the settlement methods form, the approval gating that controls when you can edit it, the payout schedule, and how to read a payout batch detail.
## What you can do [#what-you-can-do]
Add a bank account, wallet, or card as your settlement method. Mark one as the default.
The payout schedule for your account is shown on the Balance page. Changes go through XPay
support.
Settlement edits are gated on account verification. Test mode is read-only; live mode opens up
once your account is approved.
Every payout has a detail page with timeline, method, summary, and the underlying charges and
refunds.
## Open the payouts settings [#open-the-payouts-settings]
In the dashboard sidebar, click your account menu and pick **Settings**, then **Payouts**. The page is titled **Settlement methods**.
## Approval gating [#approval-gating]
What you see on the page depends on your account's verification status. Four states, four behaviors:
| Status | What the page shows |
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| **Draft** | A "You must request account verification before editing these settings" panel with a **Request Verification** button to start onboarding. |
| **Pending review** | "Your application is under review. You'll be able to manage payout methods once your application is approved." |
| **Changes requested** | Same as pending review. The verification team has asked for additional information; the dashboard's onboarding flow will surface it. |
| **Approved** | Settlement methods and schedule are visible. Methods can be added and edited in live mode (subject to permissions). |
The **Add Settlement Method** button is disabled with a tooltip naming the reason it's blocked: missing payouts-write permission, account not approved, test mode, or pending submission.
## Test mode [#test-mode]
In test mode, settlement methods are auto-provisioned by XPay so you can simulate the full payment-to-payout cycle without real bank details. The methods can't be edited or removed; the dashboard surfaces a notice reading "Test mode payout methods are auto-provisioned for testing. Switch to live mode to manage your real payout methods."
The **Add** button is disabled in test mode regardless of approval status.
## Settlement methods [#settlement-methods]
A settlement method is the destination XPay sends payouts to. The page lists every method on your live account with the default one pinned to the top.
### Three method types [#three-method-types]
| Method | Fields you provide |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| **Bank account** | Account holder name, bank name (from a select), branch (from a select), account number (10 to 20 digits), IBAN, SWIFT code, currency. |
| **Wallet** | Wallet holder name, wallet number, currency. |
| **Card** | Card holder name, card number, expiry month, expiry year, card network (from a select), currency. |
Every method has a single currency. Your account's default settlement currency drives the dashboard's currency picker; pick another one if you want a method in a different currency.
### Default method [#default-method]
One method on the account is marked **Default**. Funds land in the default method on every payout unless XPay flags a method-specific issue (for example, a bank rejection).
The default appears at the top of the methods list with a **Default** badge. To change which method is the default, edit the method you want as default and toggle it on. There's only ever one default at a time.
### Add a settlement method [#add-a-settlement-method]
### Click Add Settlement Method [#click-add-settlement-method]
In the top right of the **Settlement methods** page, click **Add Settlement Method**. A side sheet opens.
### Pick a method type [#pick-a-method-type]
Pick **Bank account**, **Wallet**, or **Card**. The sheet's fields adjust based on what you pick.
### Fill in the details [#fill-in-the-details]
Fill in the holder name and the type-specific fields (see the table above). Optional **Default method** toggle at the bottom of the sheet pins this method as the default for future payouts.
### Click Add [#click-add]
Save the method. The new entry appears in the list. If there's already a pending submission, your changes go through admin review before becoming the active method.
### Edit and remove [#edit-and-remove]
Each method's row opens a sheet (the same one used for **Add**, in edit mode). Update fields, toggle the default, save. To remove a method, the sheet's footer carries a destructive action (gated on the same write permission and approval state).
### Pending submission [#pending-submission]
Edits to a settlement method don't take effect immediately on a live account. The change goes into a **pending submission** state for admin review. While that's pending, the dashboard shows a notice reading "You have payout method changes awaiting admin review" and disables further edits until the review completes.
## Payout schedule [#payout-schedule]
Your payout schedule is set per merchant by the XPay operations team and isn't editable from the dashboard. The current schedule is shown on the [Balance](/features/transactions/balance-and-activity) page in the **Payout Schedule** sidebar, with the next settlement date when one is queued.
If you're on a **Manual** schedule, the Balance page also shows a **Pay out funds** button so you can wire your available balance whenever you want.
To change the schedule, contact support.
## Read a payout batch [#read-a-payout-batch]
When XPay generates a payout, you can find it in three places: the **Payouts** tab on the Transactions page, the **Recent Payouts** list on the Balance page, or the **Connected Payout** link on any payment that's already settled. Click any payout to open its detail page.
### Header [#header]
The payout's amount, currency, and status (**Open**, **Processing**, **In Transit**, **Paid**, **Failed**, **Canceled**) sit at the top.
### Details [#details]
A right-side block listing **Payout ID**, **Created**, **Expected Arrival**, **Delivery Method** (bank, wallet, or card with an identifier), **Schedule** (Automatic or Manual), **Description**, and **Fee** when applicable. The settlement type is shown as **Standard** or **External**.
### Timeline [#timeline]
A reverse-chronological log of every step in the payout's life. Common entries:
* **Payout initiated** (automatic or manual).
* **Payout in transit** when the wire is submitted.
* **Payout completed** when the funds land.
* **Payout failed** with a failure code when the bank rejects.
* **Payout canceled** when the batch is canceled before the wire.
### Payout Method [#payout-method]
The bank account, wallet, or card the funds went to, with the relevant identifiers (bank name, account number, IBAN, wallet number, card number, card network).
### Summary [#summary]
The math behind the payout amount:
* **Charges.** Sum of payments included in this batch.
* **Refunds.** Sum of refunds processed against those payments, deducted from the batch.
* **Charges fees.** XPay's processing fees on the included charges.
* **Subtotal.** Charges minus refunds and charges fees.
* **Payout fees.** XPay's fee on the payout itself, when applicable.
* **Net payout.** The amount that lands in your bank.
### Failure Details [#failure-details]
When the payout failed, an extra block appears with the **Failure code** and any explanatory message returned by the bank.
### Transactions [#transactions]
A table of every charge, refund, and fee included in this payout batch. Click any row to open the underlying transaction.
## Where payouts show up [#where-payouts-show-up]
| Surface | What you see |
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| **Transactions → Payouts tab** | The full list of payout batches with status, amount, and arrival date. |
| **Balance → Recent Payouts** | The latest few payouts at the bottom of the Balance page. |
| **Balance → Total paid out** | The lifetime total of paid payouts across all batches. |
| **Transaction detail → Connected Payout** | A link from any settled payment to the payout batch its funds are part of. |
| **Transactions → All Activity** | Each payout shows up as an entry in the running balance ledger, alongside the charges and refunds it covers. |
## Where to next [#where-to-next]
Where the available and pending balance, and every entry that moves it, are summarized.
Click **Connected Payout** on any payment to land on the payout batch its funds are part of.
Account verification has to clear before you can edit settlement methods.
# Team and roles (/en/features/account-and-operations/team-and-roles)
Invite teammates by phone, assign one of seven roles, and manage members and pending invitations.
The **Team** settings page lets you invite people to your XPay account, assign each one a role, and manage the members and pending invitations that already exist. Roles control what each teammate can see and do across every section of the dashboard, from refunds and payouts to API keys and branding.
This page covers how to add a member, what each of the seven roles can do, how to manage members, and how invitations work.
## What you can do [#what-you-can-do]
Add a teammate with their phone number and a role. They get an invitation they can accept by
signing in with that number.
From **Super Administrator** down to **View Only**, each role is a curated set of capabilities.
No custom roles today.
See who hasn't accepted yet, resend the invite, or cancel it before they sign in.
Change a teammate's role or remove them from the team. The original account creator can't be
removed.
## Open the team settings [#open-the-team-settings]
In the dashboard, click your account menu and pick **Settings**, then click **Team**. The page lists every active member, with a **Pending invitations** section underneath if any are still outstanding.
## Add a member [#add-a-member]
### Click New member [#click-new-member]
In the top right of the **Team** page, click **New member**. A dialog opens.
If you don't see the button, your role lacks the team-write permission. Only the **Super Administrator** and **Administrator** roles can manage the team today.
### Enter their phone number [#enter-their-phone-number]
XPay invites teammates by phone, not email. Enter the country code and number in the phone field; the dashboard validates the format as you type.
### Pick a role [#pick-a-role]
Pick one of the six assignable roles: **Administrator**, **Accountant**, **Support Agent**, **Developer**, **Sales Representative**, or **View Only**. The seventh role, **Super Administrator**, is reserved for the account creator and isn't assignable from this dialog. See [Roles](#roles) below for what each role can do.
### Send the invitation [#send-the-invitation]
Click the send button at the bottom. An "Invitation sent" toast confirms. The invitee gets a notification they can accept by signing into XPay with the phone number you used.
The invitation appears in the **Pending invitations** section of the Team page until they accept.
## Manage members [#manage-members]
Each member's row carries a role badge and an action menu (the **⋯** icon on the right). The menu offers two actions, both gated on team-write permission:
* **Edit member** changes the role on an existing teammate. Pick a different role, save, and the change applies immediately. The teammate keeps the same login and account access; only their permissions change.
* **Remove member** removes the teammate from the merchant. They lose access to all merchant data on confirmation. Their XPay user account itself stays intact, so they can still sign in (just to other merchants they have access to).
The action menu does not appear on the **Super Administrator** row. The original account creator can't be edited or removed by anyone.
### Leave the team [#leave-the-team]
Every teammate except the **Super Administrator** has a **Leave team** button at the top of the page. Confirming reloads the dashboard and removes your access to the merchant account. The merchant itself isn't affected; only your seat is.
## Pending invitations [#pending-invitations]
Pending invitations appear under the active members list, with the role you picked, the phone number, and an expiration date. Each carries an action menu:
* **Resend invite** sends the invitation notification again with a fresh expiration window.
* **Cancel invite** revokes the invitation. The invitee can no longer accept.
If the recipient never signs in by the expiration date, the invitation expires automatically and they need a fresh one.
## Roles [#roles]
XPay ships seven roles. Each role is a curated set of permissions across the platform. There are no custom roles today; if none of the seven match your need, contact support.
| Role | Who it's for |
| ------------------------ | ----------------------------------------------------------------------------------- |
| **Super Administrator** | The account creator. Full access. Can't be removed or reassigned by anyone. |
| **Administrator** | Trusted operators who run the business: refunds, payouts, team, settings, API keys. |
| **Accountant** | Financial visibility plus the ability to issue refunds. No write access elsewhere. |
| **Support Agent** | Customer service: view and edit customers, look up payments, issue refunds. |
| **Developer** | Builds and operates the integration: API keys, webhooks, payment links, sessions. |
| **Sales Representative** | Customer acquisition and promotions: products, payment links, coupons, customers. |
| **View Only** | Read-only access across the dashboard. Can't change anything. |
### Super Administrator [#super-administrator]
The account creator. The role can't be reassigned, transferred, or removed from the dashboard. There's exactly one Super Administrator per merchant.
**What Super Administrator can do:** everything an **Administrator** can do, plus reserved capabilities the platform may add later.
### Administrator [#administrator]
Day-to-day operations across every part of the dashboard. The right pick for trusted operators who need to refund payments, manage payouts, invite teammates, and edit account settings.
**What Administrator can do:**
* View and manage payments, refunds, customers, products, prices, payment links, coupons.
* Manage the payout schedule and settlement methods.
* View the balance and all activity.
* Invite, edit, and remove team members (except the **Super Administrator**).
* Create and edit branding, payment method configurations, communication preferences.
* Create and revoke API keys.
* Create and edit webhook endpoints.
* Submit and edit account verification information.
### Accountant [#accountant]
Financial visibility with refund authority. Designed for finance and bookkeeping staff who reconcile and reimburse but don't run the rest of the business.
**What Accountant can do:**
* View payments, customers, products, payment links, coupons.
* View balance, payouts, and reporting.
* View team members.
* Issue refunds.
**What Accountant can't do:** create or edit anything outside refunds. Can't manage payouts, invite team, change settings, or touch API keys and webhooks.
### Support Agent [#support-agent]
Customer service operations. Designed for support staff who answer customer questions, edit customer records, and process refunds.
**What Support Agent can do:**
* View and edit customers (including create and delete).
* View payments and the linked checkout sessions, payment links, products.
* View refunds and issue refunds.
* View team members.
**What Support Agent can't do:** see balance or payouts, manage products or coupons, change settings, or touch API keys and webhooks.
### Developer [#developer]
Integration and operations focus. Designed for engineers building the integration to XPay.
**What Developer can do:**
* View payments, customers, products, payment links.
* Create and edit checkout sessions, payment intents, payment links, payment method configurations.
* View and create API keys.
* View and create webhook endpoints.
* View the event log.
* View team members and account settings.
**What Developer can't do:** issue refunds, manage payouts, invite team, edit branding, or change account-level settings.
### Sales Representative [#sales-representative]
Customer acquisition and promotions focus. Designed for sales staff building catalog content and running discount campaigns.
**What Sales Representative can do:**
* View and edit customers.
* Create and edit products and prices.
* Create and edit payment links.
* Create and edit coupons and promotion codes.
* View payments and reporting.
* View team members.
**What Sales Representative can't do:** issue refunds, see balance or payouts, change settings, manage payment method configurations, or touch API keys and webhooks.
### View Only [#view-only]
Read-only access across the entire dashboard. Designed for stakeholders who need visibility without the ability to change anything.
**What View Only can do:** see every list and detail page (payments, customers, products, payment links, coupons, refunds, balance, payouts, events, webhook endpoints, API keys, team, settings).
**What View Only can't do:** change anything, anywhere.
## Where to next [#where-to-next]
Create publishable, secret, and restricted keys for your integration. Restricted keys carry a
custom permission scope independent of team roles.
Pick which notifications each team member receives by email or WhatsApp.
The dashboard's developer panel. Some surfaces inside Workbench check the **Developer** role's
permissions.
# Branding (/en/features/checkout-customization/branding)
Set your default logo, locale, presets, appearance, colors, and font for every checkout your customers see.
The branding settings page is where you control how XPay's customer-facing surfaces look: the hosted checkout page, payment links, and email receipts. Every checkout session inherits these defaults, so a one-time setup applies to every payment your customers make. Per-session overrides are available through the API for cases where you want a single Checkout Session to look different (see the developer-side [Advanced configuration](/integrate/checkout-session/advanced-configuration)).
This page covers the full branding form: logo, default locale, presets, appearance primitives, semantic colors, font, and the live preview pane that updates as you make changes.
## What you can do [#what-you-can-do]
Four one-click presets stamp the full visual identity at once. **Default**, **Minimal**,
**Editorial**, **Neon**. Tweak afterward, then save.
Six primitives control color mode, border style, spacing, form layout, input size, and input
style.
Edit primary, foreground, background, muted, accent, border, input, and more. Auto-derive
contrasting foregrounds with one click.
Set English or Arabic as your default. Customers see the right language without you having to
set it on every session.
## Open the branding settings [#open-the-branding-settings]
In the dashboard sidebar, click your account menu and pick **Settings**, then click **Branding**. The page splits into two panes: the form on the left, a **live preview** on the right that updates with every change you make. Nothing is saved until you click **Save Changes**.
## Logo [#logo]
Click **Upload Logo** in the **Logo** section and pick an image. The logo appears on the hosted checkout page, on Payment Link pages, on customer receipts, and on invoices.
Use the **Replace** button to swap the file or **Remove** to delete the current logo.
## Default locale [#default-locale]
Pick the language customers see when they arrive at a hosted checkout. Three options:
| Option | What it does |
| ------------------ | -------------------------------------------------------------------------------------------------------------- |
| **Auto (English)** | The default. The checkout uses English unless a Checkout Session sets its own `locale`. |
| **English** | Force English on every session that doesn't override. |
| **Arabic** | Force Arabic on every session that doesn't override. The checkout layout flips to right-to-left automatically. |
For per-session overrides (e.g. a French-speaking customer on an otherwise-English account), use the `locale` field on the Checkout Session. See [Advanced configuration](/integrate/checkout-session/advanced-configuration) for the developer surface.
Today, the supported customer-facing languages are **English** and **Arabic**.
## Presets [#presets]
Four one-click presets give you a starting point. Each one stamps the full visual identity at once: appearance, colors, and font. After you click a preset, every field below updates to match. You can tweak any field afterward; nothing is saved until you click **Save Changes**.
| Preset | When to pick it |
| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Default** | A familiar Stripe-ish look. System color mode, moderate rounding, outlined fields. Reads on both light and dark customer devices. |
| **Minimal** | A modern, editorial single-pane look. Sharp borders, spacious layout, transparent primary. The checkout's hero gradient is removed so the page reads as one surface. |
| **Editorial** | A premium retail look. Light mode, cream surface, sharp edges, serif typography. Forced light because the warm palette is tuned for it. |
| **Neon** | A bold, attention-grabbing look for entertainment, gaming, creator tools, and developer products. Forced dark mode, pill borders, filled inputs, neon green primary, magenta accent, monospace type. |
The preview pane on the right shows what each preset looks like on a sample order with a couple of products and a price summary, so you can compare before saving.
## Appearance [#appearance]
Six primitives control the structural rhythm of the checkout. Every field can be left **Unset** to fall back to the platform default.
| Field | Options | What it controls |
| ---------------- | --------------------------- | ----------------------------------------------------------------------------------- |
| **Color mode** | System, Light, Dark | Whether the checkout follows the customer's OS theme, or always uses light or dark. |
| **Border style** | Rounded, Sharp, Pill | The corner radius on cards, buttons, and inputs. |
| **Spacing** | Condensed, Normal, Spacious | Vertical rhythm between sections. |
| **Form layout** | Compact, Spacious | Whether form fields sit close together or have breathing room. |
| **Input size** | Small, Medium, Large | Field height and padding. |
| **Input style** | Outlined, Flat, Filled | Whether fields use a visible border, no border, or a tinted fill. |
The dashboard hint reads: "Controls the shape and rhythm of the hosted checkout. Unset fields fall back to platform defaults."
## Colors [#colors]
Twelve semantic color tokens, each accepting a hex value (`#RRGGBB`, `#RRGGBBAA`, `#RGB`, or `#RGBA`). Leaving a token blank falls back to a default that's chosen to read in your selected color mode.
| Token | What it paints |
| ---------------------- | ----------------------------------------------------------------------------------- |
| **Primary** | The brand color. Used for the primary button and the checkout's hero gradient. |
| **Primary foreground** | Text and icons on top of the primary color (the **Pay** button label, for example). |
| **Background** | The page background. |
| **Foreground** | Body text and icons on the background. |
| **Muted** | Subtle surfaces (secondary cards, info backgrounds). |
| **Muted foreground** | Text on muted surfaces (less prominent labels). |
| **Accent** | A secondary brand color used for highlights. |
| **Accent foreground** | Text and icons on the accent color. |
| **Border** | Borders on cards and dividers. |
| **Input** | Borders and fills on form fields. |
| **Ring** | The focus ring around inputs and buttons. |
| **Destructive** | Red used for error states and destructive actions. |
### Auto-derive foreground colors [#auto-derive-foreground-colors]
Four foreground tokens (Primary foreground, Foreground, Muted foreground, Accent foreground) carry an auto-derive button. Click it and XPay computes a contrasting value from the matched base color (primary, background, muted, or accent), so labels stay readable without you having to pick a contrast match by hand.
### Transparent primary [#transparent-primary]
A primary color with full alpha-zero (`#00000000`) collapses the hosted checkout's hero gradient. The left pane stops painting its brand-colored background, so the whole page reads as a single neutral surface. Useful for minimalist looks where the brand voice is typography rather than color blocking. The **Minimal** preset uses this trick.
## Font [#font]
The **Advanced** section has a font-family selector with five options:
| Option | What it uses |
| ------------------- | -------------------------------------------------------------------------------------------- |
| **Session default** | No override. The platform's default font stack is used (clears the field). |
| **Inter** | A clean, modern sans-serif. Pre-loaded across XPay surfaces. |
| **System** | The customer's OS font (`system-ui`). Native-feeling on every device. |
| **Helvetica** | A classic Helvetica stack. Falls back to Arial on Windows. |
| **Editorial Serif** | The OS serif stack (`ui-serif`). New York on macOS, Cambria on Windows. Premium retail vibe. |
| **Monospace** | The OS monospace stack. Code-like, technical aesthetic. Used by the **Neon** preset. |
Only system-safe stacks are supported, so the preview always matches what your customers will see.
## Save and reset [#save-and-reset]
The bottom of the page has two buttons:
* **Save Changes** writes everything in the form to your account. The button is disabled until you've made at least one change. A "Saved" toast confirms.
* **Reset Branding** opens a confirmation dialog. Confirming wipes the appearance primitives, colors, and font back to "unset" so every checkout falls back to platform defaults. **Reset doesn't touch the logo or the default locale**, those have their own controls.
If you don't have permission to save, the button is disabled with a tooltip naming the missing permission.
## Where to next [#where-to-next]
Pick which payment methods appear at checkout, with multiple configurations and a default.
Branding applies to every Payment Link checkout you share.
Override branding, locale, and other defaults on a single Checkout Session through the API.
# Payment methods (/en/features/checkout-customization/payment-methods)
Configure which payment methods appear on your checkouts, with multiple configurations and a default per merchant.
The **Payment methods** settings page lists every payment method configuration on your account. A configuration is a named set of payment methods you've enabled for customers, like card, Fawry, Valu, or mobile wallets. One configuration is marked **Default** and applies to every checkout that doesn't ask for a specific one. You can have multiple configurations on the same account, each with its own enabled methods, and pick which one a Checkout Session uses through the API.
This page covers how to read your configurations, create a new one, toggle methods on or off, rename a configuration, and what determines the set of methods you can pick from.
## What you can do [#what-you-can-do]
Different configurations for different products, brands, or sales channels. Each carries its own
enabled methods.
Exactly one configuration is the default. It applies to every checkout that doesn't override.
Each available payment method can be toggled on or off per configuration. At least one method
must stay enabled.
The list shows only the payment methods your account is provisioned for. Contact support to add
more at the processor level.
## Open the payment methods settings [#open-the-payment-methods-settings]
In the dashboard sidebar, click your account menu and pick **Settings**, then click **Payment methods**.
## Configurations and the default [#configurations-and-the-default]
The list page shows every configuration on your account.
| Column | What it shows |
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| **Payment configuration** | The configuration's name. The default configuration carries an info tip: "This configuration is used by default for your payments." |
| **Methods** | The number of currently enabled methods (e.g. `5 methods`). Shows `-` when none are enabled. |
| **Configuration ID** | The `pmc_*` ID, in monospace. |
| **Last updated** | When the configuration was last edited. |
Click any row to open the configuration's detail page.
**Exactly one configuration is the default.** The dashboard enforces this at the database level, so the default never moves out from under you accidentally. To change which configuration is default, contact support today.
## Create a configuration [#create-a-configuration]
### Click Create [#click-create]
In the top right of the **Payment methods** page, click **Create**. A side sheet opens, titled **Create new configuration**.
### Name the configuration [#name-the-configuration]
Fill in **Name configuration**. The name is required and shows up across the dashboard, so pick something memorable that describes the configuration's purpose ("Online store", "Wholesale checkout", "Egypt-only").
### Click Create [#click-create-1]
In the bottom of the sheet, click **Create**. The configuration is created with no methods enabled, you land on the configuration's detail page, and a "Configuration created successfully" toast confirms.
### Enable the methods you want [#enable-the-methods-you-want]
The detail page lists every available method. Click the toggle on each method you want to accept. See the next section for the full flow.
If you don't see the **Create** button, your role lacks the payment-methods write permission. Ask an account admin to grant it.
## Toggle payment methods [#toggle-payment-methods]
The configuration detail page lists every payment method available on your account. Click any configuration on the list page to open its detail.
### Three filter tabs [#three-filter-tabs]
A row of three tabs across the top counts and filters the list:
* **All** shows every available method.
* **Enabled** shows only the ones currently turned on.
* **Disabled** shows the rest.
Each tab carries its current count, so you can see at a glance how many methods this configuration accepts.
### Enabled methods sort first [#enabled-methods-sort-first]
In the **All** view, methods are grouped: enabled methods on top in display order, then disabled methods. The order roughly tracks how prominent each method is at checkout, with the most common methods (card and the popular local options) at the top.
### The Enable / Disable toggle [#the-enable--disable-toggle]
Each row has a toggle. Click to flip the method on or off. The change saves immediately, with a "Configuration updated" toast.
**At least one method must stay enabled.** When a configuration has only one enabled method left, the toggle on that method is disabled to prevent you from saving an empty configuration. The toast on the protected method reads "At least one payment method must be enabled."
Click anywhere on a method's row (other than the toggle) to open a detail sheet with extra information about that method (currency support, processor restrictions, customer flow).
## Rename a configuration [#rename-a-configuration]
On the detail page, click **Rename** in the top right. A small sheet opens with the current name pre-filled. Update it and click **Save**.
**The default configuration can't be renamed.** The button is disabled with a tooltip explaining why. If you need to change the default's name, contact support.
## What payment methods are available [#what-payment-methods-are-available]
This page shows the methods your account can accept, not everything XPay supports. If a method isn't listed here, you can't accept it yet.
To add one, contact support. The toggles only turn your existing methods on and off.
## Where a configuration is used [#where-a-configuration-is-used]
The configuration that applies to a checkout is decided in one of three places.
| Surface | How a configuration is picked |
| -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Checkout Session, no configuration set** | The **default** configuration applies. The most common path for merchants running one set of methods. |
| **Checkout Session, explicit configuration** | The named configuration applies. Set through the API when you create the session. |
| **Payment Link** | Each Payment Link's detail page has a **Payment methods** section with a **Manage** button. The link follows your default configuration unless you pin it to a specific one. See [Payment methods on a link](/features/payment-links#payment-methods-on-a-link). |
Most integrations never set anything in the API. To stop offering a method, turn it off on the default configuration. That's the whole job.
Use the API only when different sessions need different methods. See the developer-side [Advanced configuration](/integrate/checkout-session/advanced-configuration#payment-methods).
## Where to next [#where-to-next]
Logo, colors, locale, and the rest of the customer-facing look.
Pin a Checkout Session to a specific payment method configuration through the API.
# Customer profile (/en/features/customers/customer-profile)
Find a customer in your dashboard, read their full profile, and act on it.
A Customer is your dashboard's record of someone who paid you. The profile carries their name, email, phone, address, every payment, every refund, every saved card, and signals XPay surfaces about how they shop. This page covers how to find a customer in your list, what each section of their profile means, and how to add or remove a record yourself.
Most customers appear here on their own. When someone pays through Hosted Checkout or a Payment Link without you specifying who they are, XPay creates a record so you can find them later. You can also create a record directly when you want a known account on file before any payment.
## What you can do [#what-you-can-do]
Search by ID, name, email, or phone. Filter to **Registered** or **Guest** customers. Page
through the list at any size.
Total spend, total refunded, and a monthly volume chart sit at the top of every profile.
Open any customer to see their transactions, payment methods, related accounts, and XPay's
customer signals.
Create a customer record yourself when you need one. Delete a record that has nothing linked to
it.
## Find a customer [#find-a-customer]
In the dashboard sidebar, click **Customers**. The list page shows everyone with payment history on your account, plus anyone you've created directly.
The columns at a glance:
| Column | What it shows |
| ------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| **Name** | The customer's name. A **Guest** badge appears next to the name when XPay created the record automatically during checkout. |
| **Email** | The primary email on the record. |
| **Phone** | The primary phone with country code. |
| **Latest Payment Method** | The most recent card or wallet they paid with. |
| **Country** | The country on the most recent billing address. |
| **Date Created** | When the record first appeared in your account. |
| **Total Spent** | Lifetime gross spend. |
| **Total Refunded** | Lifetime refunds against their payments. |
Above the table, the search box matches customer ID, name, email, and phone. Paste a comma-separated list to look for several at once. The **Type** filter narrows the list to **Registered** (records you or the API created) or **Guest** (records XPay created automatically). Click any row to open the profile.
## Create a customer [#create-a-customer]
Add a customer record yourself when you want a known account on file ahead of any payment. Click **Create customer** in the top right of the list page.
### Open the create sheet [#open-the-create-sheet]
Click **Create customer**. A side sheet opens.
### Fill in contact details [#fill-in-contact-details]
Enter **Name**, **Account email**, and **Mobile Number**. At least one contact method (email or phone) is required.
### (Optional) Add an address [#optional-add-an-address]
Under **Address**, enter **Country**, **City**, **Line1**, **Line2**, **Postal Code**, and **State**. The full address feeds into receipts and tax handling.
### Click Create customer [#click-create-customer]
The button at the bottom saves the record. You land back on the list with a "Customer created successfully" toast.
If you don't see the **Create customer** button, your role lacks the customer-write permission. Ask an account admin to grant it.
## Read a customer profile [#read-a-customer-profile]
Click any customer to open their profile. Sections appear or hide based on what's actually on the record. A guest with no payments shows you a thin profile; a registered customer with transactions, refunds, and saved cards shows you everything below.
### Header [#header]
The customer's name (or email, if no name) sits at the top, with a **Guest** badge when the record came from a checkout. The action menu in the top right holds **Delete Customer**.
### Insights [#insights]
The **Insights** section shows lifetime **Total Spend** with a monthly chart, plus **Refunds** and **Transactions** totals. Hover the chart to see a specific month's spend and refunds.
### Customer Details [#customer-details]
**Customer Details** lists the primary record fields: **Customer ID** (copy with one click), **Customer since**, **Email**, **Country**, **Address**, **Mobile Number**. Guest customers also show **Alternative Emails** and **Alternative Numbers**, the other contact info XPay has linked to the same person across visits.
### XPay Intelligence [#xpay-intelligence]
A panel of signals XPay derives from this customer's behavior, like **Top Spender**, **Frequent Buyer**, **High Refunds**, or **Multi-Card User**. Each signal carries a one-line detail (a percentile, a trend, a count) so you know what triggered it. See [Customer signals](/features/customers/customer-signals) for the full list and how to act on them.
### Transactions [#transactions]
Every payment this customer has made on your account, with status, amount, payment method, and date. Click any row to open the full transaction.
### Invoices and Subscriptions [#invoices-and-subscriptions]
If the customer has invoices or subscriptions on your account, those appear in their own tables below the transactions list, each filtered to this customer.
### Related accounts [#related-accounts]
XPay surfaces other records that share an email, phone, or card with this one:
* On a **registered** customer's profile: **Related Guest Payments** lists guest checkouts XPay matched to this person, and **Related Guest Customers** lists the guest records sharing identifiers.
* On a **guest** customer's profile: **Related Customer Accounts** lists the registered records sharing identifiers.
Use these to correlate a guest's checkout history with the registered account they later signed up for. See [Guest customers](/features/customers/guest-customers) for how the matching works.
### Payment Methods [#payment-methods]
Each card or wallet the customer has paid with sits in the **Payment Methods** list. Click a row to expand it. For cards you'll see the masked number, fingerprint, expiry, brand, cardholder name, and issuing country. For Valu you'll see the loan number and tenure.
## Delete a customer [#delete-a-customer]
Open the customer's profile, click the action menu in the top right, and choose **Delete Customer**. Confirm in the dialog.
Deletion only works for records with **nothing linked to them**. A customer with any transaction, refund, payment method, invoice, or subscription cannot be deleted, and the dashboard surfaces a "Failed to delete customer" toast when you try.
The right time to delete is when you created a record manually, never used it, and want it off the list. For records with payment history, leave them in place. The full history depends on the customer record staying.
## Where to next [#where-to-next]
Why guest records exist, how XPay groups them, and what the Related sections mean.
The signals on every customer profile and how to act on them.
The code-side companion. `customerId`, `customerCreation`, prefill, and how guest dedup behaves
under the API.
# Customer signals (/en/features/customers/customer-signals)
What each signal on a customer profile means, when it fires, and how to act on it.
Every customer profile carries a **XPay Intelligence** panel with a row of signals XPay derives from this customer's payment history. A signal fires when the customer crosses a specific threshold worth surfacing: top of your spend distribution, sudden drop in spend, payments from many different cards, and so on. Each signal carries a one-line detail (a percentile, a trend, a count) so you know exactly what triggered it.
This page lists every signal, when it fires, and what to do about it.
## How to read a signal [#how-to-read-a-signal]
Each signal sits in one of four sentiment buckets, used to color-code the panel:
| Sentiment | What it means |
| ------------ | -------------------------------------------------------------------------------------------- |
| **Positive** | A customer worth your attention as engaged, loyal, or high-value. |
| **Warning** | A pattern worth a closer look. Refunds, disputes, failed payments, or geographic mismatches. |
| **Info** | Neutral context that helps you understand who this customer is. |
| **Neutral** | Status without action implied (currently used for **Inactive**). |
Percentile-based signals fire when a customer is in the **top 10%** on your account for that metric. The percentile is computed across all your customers in the same database (test or live), so test-mode percentiles only compare against test-mode customers.
## Loyalty signals [#loyalty-signals]
The customers you want to pay close attention to as engaged or at risk of going quiet.
### Top Spender [#top-spender]
**Sentiment:** positive. **Detail line:** percentile + lifetime spend.
Triggers when this customer's lifetime gross spend is in the top 10% on your account.
**Use it for:** VIP outreach, priority support, referral asks, early access to new products.
### Frequent Buyer [#frequent-buyer]
**Sentiment:** positive. **Detail line:** percentile + total payments.
Triggers when this customer has made more successful payments than 90% of your customers, with at least two payments on record.
**Use it for:** Loyalty rewards, repeat-purchase discounts, subscription pitches if you sell on a recurring model.
### New Customer [#new-customer]
**Sentiment:** info. **Detail line:** "First purchase today" or "First purchase Nd ago".
Triggers within 30 days of their first successful payment.
**Use it for:** Welcome flows, onboarding follow-ups, first-month engagement.
### Inactive [#inactive]
**Sentiment:** neutral. **Detail line:** "Last seen Nd ago".
Triggers when their most recent successful payment was 90 or more days ago.
**Use it for:** Win-back campaigns, surveys, re-engagement offers.
## Spending change signals [#spending-change-signals]
How this customer's behavior is shifting over the last 60 days, comparing the most recent 30 days to the 30 days before that. These need data in both periods to fire.
### Spending Trend [#spending-trend]
**Sentiment:** positive when up, warning when down. **Detail line:** "Up X% vs last 30 days" or "Down X% vs last 30 days".
Triggers when total spend in the last 30 days is at least 30% higher or lower than the prior 30 days.
**Use it for:** Up means a candidate for upsell or expansion. Down is an early signal of churn risk worth investigating before they go quiet.
### Order Value Shift [#order-value-shift]
**Sentiment:** info. **Detail line:** "Up X% vs last 30 days" or "Down X% vs last 30 days".
Triggers when the customer's average payment amount in the last 30 days is at least double, or at most half, the average from the prior 30 days.
**Use it for:** Catching plan-tier upgrades, basket-size shifts, or migration between products. Often pairs with **Spending Trend**.
## Risk signals [#risk-signals]
Patterns that warrant a closer look. Each of these is worth a manual review, not a blanket action.
### High Refunds [#high-refunds]
**Sentiment:** warning. **Detail line:** percentile + amount refunded.
Triggers when their refund-to-spend ratio is in the top 10% on your account, with at least one refund processed.
**Use it for:** Look at refund reasons in the **Transactions** section of the profile. Product-fit problems, fulfillment issues, or coordinated abuse.
### High Disputes [#high-disputes]
**Sentiment:** warning. **Detail line:** percentile + amount lost to disputes.
Triggers when their dispute losses as a fraction of spend are in the top 10%, with at least one real loss recorded.
**Use it for:** Same review as **High Refunds**, plus cross-check **Card-Country Mismatch** and **Multi-Card User** for fraud-shaped behavior.
### Card-Country Mismatch [#card-country-mismatch]
**Sentiment:** warning. **Detail line:** "X% of payments from a different country than card".
Triggers when 30% or more of this customer's successful payments came from an IP address in a different country than the card's issuing country, across at least 3 charges where both countries are known.
**Use it for:** Travel, VPN usage, or fraud. Cross-reference with refunds, disputes, and **Multi-Card User**.
### High Failure Rate [#high-failure-rate]
**Sentiment:** warning. **Detail line:** "X% of payment attempts fail".
Triggers when 25% or more of all their charge attempts (succeeded plus failed) failed, across at least 3 attempts.
**Use it for:** Expired card on file, billing address change, or active card-testing. If failures cluster around the same card or IP, treat it as a fraud signal rather than a friction signal.
## Behavior signals [#behavior-signals]
Context about how this customer pays. Neutral on their own, telling when paired with a risk signal above.
### Multi-Card User [#multi-card-user]
**Sentiment:** info. **Detail line:** "N different cards used".
Triggers when they've paid with at least 3 distinct cards. For registered customers this counts cards saved as payment methods on the account; for guests it counts every card they've ever paid with.
**Use it for:** Often a power user mixing personal and business cards. Combined with **Card-Country Mismatch** or **High Failure Rate**, can mean card-testing.
### Multi-Device [#multi-device]
**Sentiment:** info. **Detail line:** "N different devices".
Triggers when this customer has paid from at least 3 distinct devices XPay has fingerprinted.
**Use it for:** Households, shared accounts, or workplace devices. Combined with risk signals, can mean unauthorized account use.
## When a profile shows no signals [#when-a-profile-shows-no-signals]
Empty signal panels are normal. A profile won't show signals when:
* The customer hasn't paid yet, or has only one or two payments. Most behavioral signals need at least 3 successful payments to compute meaningfully.
* They sit in the middle of every percentile (not in the top 10% on any spend, refund, dispute, or frequency metric).
* Their spending pattern is steady (no 30%+ swing in totals or 2x shift in average amount over the last 60 days).
* They're past day 30 since first purchase (so **New Customer** doesn't apply) and have paid in the last 90 days (so **Inactive** doesn't apply).
In short: signals are designed to surface a pattern only when there's enough data and enough deviation to act on. A quiet profile means the customer is acting like a typical customer for your account.
## Where to next [#where-to-next]
The full tour of the customer list and detail page, including where the signal panel sits.
How signals work on guest records, including the card and device counts that compound across
visits.
# Guest customers (/en/features/customers/guest-customers)
What guest records are, how XPay groups them, and how the Related sections on a profile work.
A guest Customer is a record XPay creates automatically the first time someone pays you without a customer record already attached to the checkout. They show up in your **Customers** list with a **Guest** badge and behave like a regular customer everywhere except a few places this page calls out.
Guests exist so two things just work:
1. You can find anyone who paid you, by name, email, or phone, even if you never built any signup or account flow on your side.
2. Lifetime spend, refunds, and payment history roll up under a single record per person, even when their contact info shifts a little across visits (capitalized email one time, lowercase the next, country prefix as `00` one time and `+` the next).
## What you'll see [#what-youll-see]
The first checkout creates the record. The next time the same person pays, XPay matches the
existing record instead of creating a duplicate.
On a guest profile, every email and phone the same person has used over time appears under the
primary one.
A guest's profile shows the registered customers that share their email, phone, or card. A
registered customer's profile shows the guests too.
No saved card on file, and you can't reference a guest's ID when creating a future Checkout
Session.
## How XPay groups guest customers [#how-xpay-groups-guest-customers]
When a checkout completes without a customer record attached, XPay looks for an existing guest on your account that matches and reuses it. If nothing matches, a new guest record is created. The match runs in two phases.
### At checkout, by contact info [#at-checkout-by-contact-info]
The moment a customer enters their email or phone on the checkout form, XPay looks for an existing guest with the same email or the same phone:
| Identifier | How it's matched |
| ---------- | ---------------------------------------------------------------------------------------------------------- |
| **Email** | Case and surrounding whitespace are ignored. `Aya@Example.com` and `aya@example.com` match the same guest. |
| **Phone** | Country prefix formatting is normalized. `0020...` and `+20...` match the same guest. |
A match on either field reuses the existing guest record. The latest contact info becomes the primary, and the previous values stay searchable on the profile under **Alternative Emails** and **Alternative Numbers**. Nothing is lost.
### After payment, by card [#after-payment-by-card]
Once the payment succeeds, XPay records the card on the guest's profile. If the same card has been used on another guest record on your account, the two are merged into one. All transactions, refunds, and payment methods from the absorbed records move onto the surviving record, and lifetime spend is recalculated.
This is what catches the case where a customer used a different email or phone number on two separate checkouts but paid with the same card both times.
### Same merchant only [#same-merchant-only]
Guest matching only ever happens within a single merchant account. Two different merchants on XPay never share guest records, even if the same person paid both. Test mode and live mode are separate too.
### Never crosses into registered customers [#never-crosses-into-registered-customers]
Guest matching only matches guests to other guests. A guest checkout never gets auto-attached to a registered customer, even when they share an email, phone, or card. The connection is shown to you in the **Related** sections on each profile, but XPay won't merge the two on its own. Only you can vouch for a customer's identity, by passing a `customerId` on a Checkout Session ([Customer lifecycle](/integrate/checkout-session/customer-lifecycle) covers the code-side).
## What changes on a guest profile [#what-changes-on-a-guest-profile]
Open a guest customer to see their profile. The shape is the same as a registered customer's, with three differences.
### Guest badge [#guest-badge]
A small **Guest** badge sits next to the name in the list and at the top of the detail page. Hover the badge for a one-line reminder of why the record exists.
### Alternative Emails and Alternative Numbers [#alternative-emails-and-alternative-numbers]
In the **Customer Details** column, two extra fields appear when the guest has used more than one email or phone over time. The primary email and phone are still on top. Everything else they've used is listed underneath, so you can correlate the same person across slight variations.
### Related sections [#related-sections]
Cross-link tables that show how this guest connects to other records. See the next section for what each one lists.
## Related accounts [#related-accounts]
On every customer profile, XPay surfaces records that share an email, phone, or card with the one you're looking at. Three sections can appear, depending on which side you're on:
| Section | Where it appears | What it lists |
| ----------------------------- | ------------------- | ------------------------------------------------------------------------------------------ |
| **Related Customer Accounts** | Guest profiles | Registered customers that share an email, phone, or card with this guest. |
| **Related Guest Customers** | Registered profiles | Other guest records that share an email, phone, or card with this customer. |
| **Related Guest Payments** | Registered profiles | Individual payments made by guests that share an email, phone, or card with this customer. |
Use these to see the full picture: a registered customer who once paid as a guest before creating an account, or a single customer whose checkouts ended up split across more than one guest record before they paid with a shared card.
The matching is one-way and read-only. The dashboard shows you the connection. It doesn't merge guests into registered customers automatically.
## What guests can't do [#what-guests-cant-do]
A guest record covers most of what you'll do day to day. Two things only registered customers can do:
**Saved card on file.** Every guest checkout asks for a fresh card. There's no way to charge a guest's card again later through the dashboard or through a future Checkout Session. If you want a card you can charge again (subscriptions, one-click reordering, off-session top-ups), create a registered customer instead. The developer-side guide on [Customer lifecycle](/integrate/checkout-session/customer-lifecycle) covers `customerCreation: "always"` and how to capture the resulting `cus_*` from the `checkout.session.completed` webhook.
**Pass them as `customerId` on a Checkout Session.** Guests are checkout-only records. You can browse them, see their history, and correlate them with registered customers, but you can't reference a guest's ID when creating the next Checkout Session. If a guest paid you and later signed up, the right move is to start a fresh registered customer on their first signed-in payment, then use that `cus_*` for everything after. The dashboard's **Related** sections keep the connection visible.
## Where to next [#where-to-next]
The full tour of the customer list and the detail page.
The signals XPay surfaces on every profile, including ones that fire on guests.
The code-side. `customerId`, `customerCreation`, and the patterns for guests vs registered
customers in your integration.
# Coupons and promotion codes (/en/features/products/coupons)
Define a discount, attach customer-facing promotion codes to it, and read what redeemed where.
A Coupon is a reusable discount you define once and apply to checkouts. A Promotion code is the customer-facing string you give to a customer to redeem a coupon ("FRIENDS20"). One coupon can carry many promotion codes, each with its own restrictions, so you can run several promotions off the same underlying discount.
This page covers how to find a coupon, how to create one, how to attach promotion codes to it, and what you can and can't change after the coupon is live.
## Coupons vs promotion codes [#coupons-vs-promotion-codes]
These are two different things even though most merchants use them together.
| Concept | What it is | What you set |
| ------------------ | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| **Coupon** | The discount itself: how much off, how long it applies, how many times it can be redeemed in total. | Discount type and amount, duration, redemption limits, minimum order value, max discount cap, products it applies to. |
| **Promotion code** | A customer-facing string that points at a coupon. Multiple codes can point at the same coupon with different restrictions. | The code text, first-time-transaction restriction, customer restriction, per-code redemption limit, expiration date, minimum amount. |
A merchant who wants "20% off everything for the summer" creates **one coupon** and **one or more promotion codes** that map to it. The coupon defines the 20%; the codes ("SUMMER20", "FRIENDS20") are what customers actually type at checkout.
You can create a coupon **without** any promotion code (apply it programmatically via the API), but for codes customers type into checkout, you need at least one promotion code.
## What you can do [#what-you-can-do]
Pick **Percentage off** or **Fixed amount off**, with optional caps and minimum-order rules.
One coupon can carry many promotion codes, each with its own expiration, customer restriction,
and redemption limit.
Apply a coupon to specific products in your catalog. Restrict a single promotion code to one
customer or first-time customers only.
Deactivate a coupon or promotion code to stop it from being redeemed. Historical redemption data
stays intact.
## Find a coupon [#find-a-coupon]
Click **Coupons** in the dashboard sidebar (under **Shortcuts**). The list shows every coupon on your account.
| Column | What it shows |
| --------------- | --------------------------------------------------------------------------------------------------------- |
| **Coupon** | The coupon's name, or its ID if you didn't set one. |
| **Terms** | The discount summarized in plain language: "20% off forever", "100 EGP off once", "15% off for 3 months". |
| **Redemptions** | Times the coupon has been redeemed. Shows `X/Y` when a maximum is set, just `X` otherwise. |
| **Expires** | The redeem-by date, or `-` if there's no expiration. |
| **Status** | A badge derived from `active` and `redeemBy`. |
Click any row to open the coupon's detail page.
## Create a coupon [#create-a-coupon]
Click **Create coupon** in the top right. A new screen opens with two sections: **Coupon details** and **Redemption limits**, plus a **Promotion codes** block at the bottom.
### (Optional) Name the coupon [#optional-name-the-coupon]
Enter a **Name**. The name appears on customer receipts and invoices, and on the coupon row in your dashboard. Skip this and the coupon shows up as "Untitled coupon".
### Pick the discount type [#pick-the-discount-type]
Choose **Percentage off** or **Fixed amount off**.
* **Percentage off** asks for a percentage between 1 and 100. You can optionally check **Limit the maximum discount value** to cap how much money the percentage can take off (e.g. 20% off, but no more than 100 EGP).
* **Fixed amount off** asks for an amount and a currency. Customers pay full price minus that amount.
### (Optional) Apply to specific products [#optional-apply-to-specific-products]
Check **Apply to specific products** to restrict the coupon to a subset of your catalog. A search-and-select picker lets you pick from existing products. Leave the box unchecked to apply the coupon to every product.
### Set the duration [#set-the-duration]
Pick how long the discount applies once redeemed:
| Duration | What it does |
| ------------------- | --------------------------------------------------------------------------------- |
| **Forever** | The discount applies as long as the coupon is active. |
| **Once** | The discount applies to a single payment, then stops. |
| **Multiple months** | For subscriptions, the discount applies for N months from redemption, then stops. |
The dashboard hint underneath reads: "For subscriptions, this determines how long this coupon will apply once redeemed." Subscriptions aren't yet shipped, so today the duration matters most for one-time purchases.
### (Optional) Set redemption limits [#optional-set-redemption-limits]
Under **Redemption limits**, four checkbox-gated fields control when and how often the coupon can be used:
| Field | What it does |
| ------------------------------------------------------------------------ | --------------------------------------------------------------------------------- |
| **Limit the date range when customers can redeem this coupon** | A **Redeem by** date, after which no new redemptions are allowed. |
| **Limit the total number of times this coupon can be redeemed** | A total cap across all customers. The coupon stops redeeming once the cap is hit. |
| **Limit the total number of times each customer can redeem this coupon** | A per-customer cap. Each customer can redeem up to N times, total can still grow. |
| **Require minimum order value** | A minimum order subtotal. Orders below it can't redeem the coupon. |
### (Optional) Add promotion codes inline [#optional-add-promotion-codes-inline]
The **Promotion codes** block at the bottom lets you add one or more customer-facing codes while you're creating the coupon. Click **Add another code** to add more.
For each code:
* **Code** (optional). The string customers type at checkout. Codes are case-insensitive and must be unique across all promotion codes on your account. Leave this blank to have one auto-generated.
* **Eligible for first-time order only**. Restricts the code to customers with no prior completed checkouts.
* **Limit to a specific customer**. Search for a customer and only that customer can redeem this code.
* **Limit times this code can be redeemed**. A per-code redemption cap, separate from the coupon's overall cap.
* **Add an expiration date**. A date after which the code can't be redeemed.
* **Require minimum order value**. A per-code minimum, separate from the coupon's overall minimum.
You can also skip this and add codes later from the coupon's detail page.
### Click Create coupon [#click-create-coupon]
In the top right, click **Create coupon**. The coupon is created, every promotion code you added is created against it, and you land on the coupon's detail page. A "Coupon created successfully" toast confirms.
If a promotion code fails to create (for example, the code string was already in use), the dashboard surfaces a warning toast naming the failed codes; the coupon itself still gets created.
## Read a coupon [#read-a-coupon]
The detail page is split into a header, a three-cell summary bar, a details grid, applied products (when set), the promotion codes table, and a transactions list at the bottom.
### Header [#header]
The coupon's name (or "Untitled coupon") sits at the top with a status badge. Two action buttons:
* **Rename** opens a sheet to update the name.
* **Change status** lets you flip `active` between **Active** and **Inactive**. Inactive coupons can't be redeemed.
### Summary bar [#summary-bar]
Three cells across the top of the body:
* **Type.** **Percentage off** or **Fixed amount off**.
* **Terms.** The same plain-language summary as the list: "20% off forever".
* **Usage.** Times redeemed, with the total cap when one is set.
### Details [#details]
A two-column grid listing every field on the coupon:
| Field | What it shows |
| --------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| **ID** | The `coupon_*` ID, with one-click copy. |
| **Valid** | **Yes** if the coupon can currently be redeemed. **No** if it's inactive, expired, or out of redemptions. |
| **Name** | The display name. |
| **Percentage discount** / **Discount amount** | The exact discount. |
| **Created** | When the coupon was created. |
| **Duration** | **Forever**, **Once**, or **N months**. |
| **Redeem by** | The expiration date, when set. |
| **Total redemption limit** | **Unlimited** or **N times**. |
| **Per-customer limit** | **N times** when set. |
| **Minimum purchase** | The minimum order value when set. |
| **Maximum discount** | The cap on percentage discounts when set. |
| **Applies to** | **All products** or **N products**. |
### Applied products [#applied-products]
When you restricted the coupon to specific products, this section lists each product with image and name. Click any to open it in the catalog.
### Promotion codes [#promotion-codes]
The table of every customer-facing code attached to this coupon. Each row shows the code, API ID, redemption count, expiration date, and creation date.
The **+** button opens a sheet for adding a new code. The action menu on each row lets you **Archive** or **Unarchive** that code (you can't delete a code, only deactivate it).
### Transactions [#transactions]
Every successful payment that redeemed this coupon. Click any row to open the payment detail.
## Add a promotion code later [#add-a-promotion-code-later]
Open the coupon, scroll to **Promotion codes**, click the **+** button. The same fields as the inline create form appear: code, first-time-transaction, customer restriction, max redemptions, expiration date, minimum amount.
Customers redeem promotion codes at checkout when you've enabled **Allow promotion codes** on the Checkout Session or Payment Link. The customer sees an "Add promotion code" affordance on the checkout page; if the code is valid for them, the discount applies before payment.
## What you can change later [#what-you-can-change-later]
Once a coupon is created, the **discount terms are locked**. You can't edit any of these:
* Discount type (percentage vs fixed amount)
* Percentage off or amount off
* Currency
* Duration (forever, once, or N months)
The only fields you can change on a live coupon are:
* **Name** (via **Rename**).
* **Active** (via **Change status**, to deactivate or reactivate).
To change a discount's value or duration, deactivate the old coupon and create a new one. Historical redemptions stay attached to the old coupon for reporting.
For promotion codes, the same rule applies: once created, only the **Active** flag is mutable. Code, customer restriction, redemption limit, expiration date, and minimum amount are all locked. Create a new code if you need different terms.
## Where to next [#where-to-next]
Apply a coupon to specific products from your catalog.
The price types coupons run on top of.
Enable **Allow promotion codes** to let customers enter a code at checkout.
# Prices (/en/features/products/prices)
How XPay models prices: one-time, recurring, customer-set amounts, stock, scheduling, and the seven price status states.
A Price is the configuration that determines what a customer pays for a product: the amount, the currency, whether it's charged once or repeatedly, how much stock is available, and when the price is valid. Every Product carries one or more prices, and each price is independent. You can run an old price and a new one side by side, archive an outdated one, or hold one in **Scheduled** state until a launch date.
This page covers the three price types, how currency and stock work, scheduling with start and end dates, and the seven price status states the dashboard can show.
## Price types [#price-types]
A price has one of three types. The type is set when the price is created and **cannot be changed afterward**. To switch a product from one type to another, add a new price.
### One Time [#one-time]
The customer pays the **amount** once. This is the default type and the most common choice for catalog products.
A One Time price needs an amount and a currency. Stock and start or expiration dates are optional.
### Recurring [#recurring]
A subscription-style price billed at a chosen interval (daily, weekly, monthly, yearly, or every N of those).
Recurring prices are **not yet active for checkout**. They show up in the dashboard with an **Unsupported** status until subscriptions ship for your account. Until then, create One Time or Custom prices instead.
When subscriptions become available, recurring prices will let you bill the same customer on a schedule you set: every month, every three months, once a year. The interval and interval count are set on the price itself.
### Custom (customers choose what to pay) [#custom-customers-choose-what-to-pay]
The customer enters the amount themselves at checkout. Use this for donations, "pay what you want", tipping, or any flow where the merchant doesn't set a fixed amount.
A Custom price has these configuration fields:
| Field | What it does |
| ------------ | --------------------------------------------------------------------------------------- |
| **Currency** | The currency the customer pays in. |
| **Minimum** | The smallest amount the customer can enter. Optional. Defaults to no floor. |
| **Maximum** | The largest amount the customer can enter. Optional. Defaults to no ceiling. |
| **Preset** | The amount the form pre-fills with. Optional. The customer can change it before paying. |
Custom prices don't carry stock. Inventory tracking applies to fixed-amount sales.
## Currency [#currency]
Each price carries one currency. A product can have several prices in different currencies, and each is independent (a 100 EGP price and a 5 USD price on the same product are two separate prices, with their own statuses, stock, and schedules).
Currency is set when the price is created and isn't editable later. The default currency for new prices is your account's settlement currency.
## Stock [#stock]
The **Stock** field tracks how many units are available at this price.
| Stock value | What it means |
| ----------- | ----------------------------------------------------------------------------------------- |
| Empty | Unlimited. The dashboard shows "Not limited" on the price detail. |
| A number | A finite quantity. Each successful payment that includes this price decrements the count. |
When stock reaches zero, the price's status flips to **Sold out** and new checkouts against it are blocked.
Stock is allowed to go negative briefly under heavy concurrent traffic. When that happens, the status flips to **Oversold**. New checkouts are still blocked, and you can resolve manually by editing the price (raise stock, archive the price, or reach out to affected customers).
Only items the customer actually buys count against stock. An optional add-on the customer never adds (quantity 0) doesn't decrement it. If the add-on is out of stock, the checkout shows it as sold out and the customer can't add it, without blocking the rest of the order.
Stock is not available on Custom prices.
## Scheduling [#scheduling]
Two optional fields control when a price is usable.
| Field | What it does |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| **Start Date** | Before this date, the price's status is **Scheduled** and checkout against it is blocked. Use it to queue a price for a launch. |
| **Expiration Date** | After this date, the price's status is **Expired** and checkout against it is blocked. Use it to retire a price automatically. |
Either field can be set on its own; both are optional. A price with neither is valid from creation until you archive it.
## Status states [#status-states]
The dashboard shows one badge per price. The status is computed from the price's `active` flag, type, stock, and schedule, in the precedence below (first match wins).
| Status | Color | When it shows | Checkout |
| --------------- | ------- | -------------------------------------------------------------------- | ------------------------------------------- |
| **Archived** | Warning | The price was deactivated by you or by archiving the parent product. | Blocked. The price isn't selectable. |
| **Unsupported** | Error | A Recurring price, until subscriptions ship for your account. | Blocked. |
| **Expired** | Warning | Today is on or after the price's **Expiration Date**. | Blocked. |
| **Scheduled** | Neutral | Today is before the price's **Start Date**. | Blocked. Will activate automatically. |
| **Oversold** | Error | Stock went negative under concurrent traffic. Resolve manually. | Blocked. |
| **Sold out** | Warning | Stock reached zero. | Blocked. Edit stock or archive to clean up. |
| **Active** | Success | None of the above. The price is fully usable. | Allowed. |
Only **Active** prices can be checked out against. Any other status returns a price-specific error to the customer's payment attempt and asks them to pick another option.
## Adding and editing prices [#adding-and-editing-prices]
Prices live on the product detail page (**Product catalog → click any product**). The first price is created when you create the product. To add another price to an existing product, click the **+** button next to the **Pricing** heading. To edit, archive, or delete a single price, use the action menu on the price's row.
A few constraints to keep in mind:
* **Type can't change.** To switch from One Time to Custom (or any other change), add a new price and archive the old one.
* **Currency can't change.** Same approach: add a new price in the new currency.
* **Delete is reserved for never-used prices.** A price with any successful transaction can't be deleted, only archived. Archive keeps the historical record.
* **The action menu is disabled** when you don't have catalog write permission. Ask an account admin to grant it.
## Where to next [#where-to-next]
The full product flow: create, read, archive, delete.
Apply percentage or fixed-amount discounts on top of any price.
The code-side companion: how to attach prices to a Checkout Session and the rules for
`priceData` overrides.
# Product catalog (/en/features/products/product-catalog)
Build your catalog, read a product's full detail page, and archive or delete products you no longer sell.
A Product is something you sell. It carries a name, an optional description and image, and one or more Prices. The dashboard lists every product you've created in your **Product catalog**, and each row drills into a detail page that shows the prices, lifetime revenue, and the transactions that closed against it.
This page covers how to find a product, how to create one, what each section of the detail page means, and how to archive, copy, or delete a product. For the pricing model itself (one-time, recurring, customer-set, stock, scheduling, statuses), see [Prices](/features/products/prices).
## What you can do [#what-you-can-do]
A multi-section form covers everything in one screen: image, name, description, default price,
currency, stock, and start or end dates.
The detail page carries a **Gross Revenue** chart and rolls up successful transactions and
active subscriptions per price.
A product can carry multiple prices side by side (different currencies, different intervals,
different stock).
Archive any product to hide it from new checkouts. its prices are archived alongside it in one
step. Delete is reserved for products that have never been sold.
## Find a product [#find-a-product]
Click **Product catalog** in the dashboard sidebar. The list shows every product on your account.
The columns:
| Column | What it shows |
| --------------- | ----------------------------------------------------------------------- |
| **Image** | The product image, if you set one. A placeholder icon shows when empty. |
| **Name** | The product name. |
| **Status** | **Active** or **Archived**. |
| **Pricing** | The number of prices on this product (`1 price` / `N prices`). |
| **Created** | When the product was first created. |
| **Last edited** | When the product was most recently updated. |
Click any row to open the product. The bulk-action bar at the top lets you archive or delete several at once when you select rows.
## Create a product [#create-a-product]
Click **Create product** in the top right of the catalog page. A new screen opens (the form replaces the catalog view; close it with the **X** in the top left to return).
### Name and description [#name-and-description]
Enter a **Name** (required, visible to customers at checkout). Add a **Description** if you want context on the receipt and at checkout. Description is capped at 250 characters.
### Image [#image]
Upload an **Image** (JPEG, PNG, or WEBP under 2MB). The image appears at checkout, on the customer's receipt, and on the product detail page in the dashboard. Optional but recommended.
### (Optional) More options [#optional-more-options]
Click **More options** to expand two extra fields:
* **Unit label.** A noun describing what one unit is (`unit`, `seat`, `ticket`). Shows on receipts, invoices, and at checkout.
* **Metadata.** Custom key-value pairs you can attach to the product for reporting and search. Each key must be unique.
### Pick a price type [#pick-a-price-type]
Below the divider, pick a price type:
| Type | When to pick it |
| ------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| **One Time** | The customer pays once for this product. The most common choice. |
| **Recurring** | A subscription-style price. Pick once subscriptions are enabled on your account. |
| **Custom** | The customer enters the amount themselves at checkout (donations, "pay what you want", tipping). You can set min and max bounds. |
The price type can't be changed after the price is created. To switch a product from one-time to custom (or any other change), add a new price.
### Set the amount and currency [#set-the-amount-and-currency]
For **One Time** and **Recurring** prices, enter the **Amount** in the major unit of the currency (e.g. `100` for `100 EGP`). Pick the **Currency** from the selector to the right.
For **Custom** prices, the amount field disappears. Set min and max bounds for what the customer can enter.
### (Recurring only) Set the billing period [#recurring-only-set-the-billing-period]
For **Recurring** prices, pick a **Billing period**: **Daily**, **Weekly**, **Monthly**, **Yearly**, or **Custom** (e.g. every 3 months, every 2 weeks).
### (Optional) Stock and scheduling [#optional-stock-and-scheduling]
The form's last section sets inventory and validity:
* **Stock.** How many units are available at this price. Leave blank for unlimited.
* **Start Date.** The date this price activates. Before then, the price shows as **Scheduled** and isn't selectable in checkout.
* **Expiration Date.** The date the price stops accepting new payments. After then, it shows as **Expired**.
A live preview on the right shows what customers see at checkout as you fill the form.
### Click Add product [#click-add-product]
In the top right, click **Add product**. The product is created and you land on its detail page. A "Product created" toast confirms.
## Read a product [#read-a-product]
The detail page is split into a left two-thirds (Pricing) and a right one-third (Insights, Details, Metadata), with the transaction list across the bottom.
### Header [#header]
The product image, name, and status badge sit at the top, with the price count beneath ("1 price" or "N prices"). To the right are **Edit** (opens the edit sheet) and the action menu (archive, copy to live mode, delete).
### Pricing [#pricing]
A list of every price on the product. Each row shows:
* The formatted price (`100.00 EGP`, `Customers choose what to pay`).
* The interval (`One time`, `Per month`, `Every 3 months`).
* A status badge (**Active**, **Archived**, **Scheduled**, **Expired**, **Sold out**, **Oversold**, **Unsupported**).
* Stock, start date, expiration date, transactions count, active subscriptions count.
The **+** button next to the section title (a price sheet) lets you add another price to the same product. Each price has its own action menu for editing, archiving, or deleting.
See [Prices](/features/products/prices) for what each status means and how stock, scheduling, and types behave.
### Insights [#insights]
A **Gross Revenue** chart showing this product's lifetime revenue contribution, scoped to your settlement currency.
### Details [#details]
The metadata fields: **Product ID** (with one-click copy), **Description**, **Unit Label**, **Created**. The **Updated** date appears separately when it differs from **Created**.
### Metadata [#metadata]
The custom key-value pairs you set on the product, listed verbatim. Empty when none are set.
### Transactions [#transactions]
A table of every successful payment that included this product, with status, amount, customer, payment method, and date. Click a row to open the payment.
## Manage a product [#manage-a-product]
The action menu in the top right of the detail page holds three options.
### Archive [#archive]
Archived products are hidden from new checkouts and Payment Links. The product's prices, transactions, and revenue history are kept. archive is a soft-disable, not a delete. Archive a product when you stop selling it but want the historical record intact.
**Archiving a product also archives all of its prices in one step.** Any existing Payment Link that references one of these prices stops accepting new payments. the customer sees the line marked as unavailable, and the merchant sees the same indication on the Payment Link detail page. This keeps the catalog and the live links consistent without needing to archive each price by hand.
You can unarchive a product at any time, but **unarchive does NOT automatically re-activate its prices**. re-enabling prices is one-way. After unarchiving the product, open the price you want back in service and unarchive it explicitly. This avoids accidentally re-enabling old prices that you'd intentionally retired before archiving the product.
### Copy to live mode [#copy-to-live-mode]
In test mode only, **Copy to live mode** clones the product (and its prices) into your live database so you don't have to recreate it by hand. The copy is independent of the test version after it's made.
### Delete [#delete]
Delete is reserved for products that have **never been used**: no successful transactions, no subscriptions, no payment links. The action is disabled with a tooltip when the product has any usage.
For products with usage, archive instead. The full transaction history depends on the product record staying.
## Where to next [#where-to-next]
The pricing model: one-time, recurring, customer-set, stock, scheduling, and the seven status
states.
Discount codes and customer-facing promotion codes that apply to your products.
Build a shareable URL that pulls products from this catalog.
# Balance and activity (/en/features/transactions/balance-and-activity)
Your available and pending balance, and the running record of every payment, refund, fee, and adjustment.
Your balance is the running total of money XPay holds on your account. Every payment you capture, every refund you issue, every fee XPay deducts, and every payout that lands in your bank moves the balance up or down. Two surfaces in the dashboard cover this:
* **Balance** (in the sidebar) shows the current totals: what's available to pay out, what's still pending settlement, and your payout schedule.
* **Transactions → All Activity** lists every individual entry that ever moved the balance, with type, fee, and date.
This page covers both: how to read the **Balance** dashboard, what each entry on the **All Activity** tab means, and how to drill from a balance entry back to the payment, refund, or payout it came from.
## What you can do [#what-you-can-do]
Available to pay out, pending settlement, and your active payout schedule sit on a single
dashboard.
Pending settlement shows the funds that aren't ready yet, with a per-entry **Available on**
date.
The **All Activity** tab is the running ledger. Every charge, refund, fee, payout, and
adjustment that ever touched your balance shows up as one row.
Click any entry to jump to the payment that produced it, or to the payout batch that disbursed
it.
## Read your balance [#read-your-balance]
Click **Balance** in the dashboard sidebar.
### Balance Overview [#balance-overview]
The headline card shows two numbers:
| Field | What it shows |
| ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Available to pay out** / **Upcoming payout** | Funds that have settled and are ready to be sent. The label is **Available to pay out** when your schedule is manual, **Upcoming payout** when your schedule is automatic. |
| **Pending settlement** | Funds from recent payments that have not yet settled. A count below shows how many transactions are still pending. |
If your schedule is **manual**, a **Pay out funds** button appears next to the headline so you can wire the available balance whenever you want.
If you've never received a payment, the dashboard shows a zero balance with the hint "Your balance will appear here once you receive payments".
### Payout Schedule sidebar [#payout-schedule-sidebar]
A right-hand card summarizes how funds leave your balance:
* The schedule itself: **Manual**, or automatic with the days of the week or month it runs on.
* **Next settlement** date when an automatic payout is queued.
* **Total paid out** lifetime, with the number of payouts completed.
The **Manage payouts** button at the top of the page opens **Settings → Payouts**, where you change the schedule, bank account, and other payout settings. See [Payouts and settlement](/features/account-and-operations/payouts-and-settlement) for the full payout view.
### External Balance (Direct Settlement) [#external-balance-direct-settlement]
A second card appears only when your account is configured for direct settlement. With direct settlement, certain payment methods route funds straight to your bank rather than through XPay's standard payout flow. The card shows:
* **Available (Settled)** funds that have already cleared to your bank.
* **Pending** funds the bank is still settling.
The hint underneath confirms these funds are paid to you automatically and don't require a payout from XPay.
### Recent Payouts [#recent-payouts]
The page closes with a list of the last few payouts: status, amount, and arrival date. Click **View all payouts** to open the full Payouts tab.
## All Activity tab [#all-activity-tab]
In **Transactions**, click the **All Activity** tab. This is the running record of every entry that ever moved your balance.
### Filters [#filters]
| Filter | What it does |
| ---------------- | ---------------------------------------------------------------------------------------------------------------------- |
| **Type** | Multi-select on entry type: charges, refunds, fees, payouts, adjustments. |
| **Currency** | Limit to one settlement currency. |
| **Amount** | Range or comparison on the entry amount. |
| **Date** | When the entry was created. |
| **Available on** | When the funds become or became available for payout. Different from **Date** for any entry that takes time to settle. |
| **Search** | Match by entry ID, related payment ID, or description. |
### Columns [#columns]
| Column | What it shows |
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| **Amount** | The gross amount of the entry, positive or negative. |
| **Fees** | The XPay fee on this entry, when there is one. A dash means no fee. |
| **Total** | The net amount this entry contributed to your balance (amount minus fees). |
| **Type** | The kind of entry. See **Entry types** below. |
| **Description** | Free-form description, when set on the underlying object. |
| **Created** | When the entry was recorded. |
| **Available on** | When the funds clear for payout. May be the same as **Created** for instant entries (like a fee), or later for entries that settle on a delay. |
### Click-through behavior [#click-through-behavior]
Click any row to drill into the entry's source:
* Entries tied to a payment (charges, refunds, reversals, reserves, recoveries) open the payment detail page.
* **Payout** entries open the payout batch.
Use this to answer "where did this fee come from?" or "which payment produced this refund?" without leaving the activity log.
## Entry types [#entry-types]
The **Type** column has a handful of values you'll meet most often:
| Type | What it represents |
| ------------------- | -------------------------------------------------------------------------------------------------------------------- |
| **Charge** | A successful payment, captured. Adds to your balance after fees. |
| **Refund** | A refund you issued against a charge. Subtracts the refunded amount from your balance. |
| **Payout** | Funds transferred to your bank as part of a payout batch. Subtracts from the available balance. |
| **Reserve hold** | A portion of funds temporarily held back from your available balance, often during a risk review or rolling reserve. |
| **Reserve release** | A previously held reserve being released back into your available balance. |
| **Adjustment** | A manual correction by XPay, used sparingly for chargeback handling, dispute outcomes, and similar cases. |
Charges and refunds are the entries you'll see daily; reserves and adjustments are exceptional and the XPay team will reach out before any are applied.
## Where to next [#where-to-next]
Click any **Charge** entry to read the originating payment.
Each refund you issue creates a balance entry; this is where you find them grouped together.
Schedule, bank account, and the full **Payouts** tab.
# Payments (/en/features/transactions/payments)
Find any payment, read its full lifecycle, refund it, and trace the funds to your payout.
A payment is a single attempt to charge a customer for a defined amount, surfaced in your dashboard as one row in the **Payments** tab and one detail page. Behind the scenes, it's a Payment Intent with one or more Charge attempts, but in the dashboard you just see the payment.
This page covers how to find a payment, what every section of the detail page tells you, and how the dashboard surfaces refund state, processor failures, and the connection from a payment to the payout that disbursed the funds.
## What you can do [#what-you-can-do]
Search by ID, customer email, phone, or name. Filter by status, amount range, date, country,
device, or payment method type.
A timeline shows every step from start to authorization, 3-D Secure, the customer's action on a
redirect or voucher, refunds, and the eventual payout.
Issue a full or partial refund from the detail page. Refund state shows up on the same payment
row, no separate refunds list.
Every successful payment carries a **Connected Payout** link that takes you to the payout batch
where the money landed.
## Find a payment [#find-a-payment]
Click **Transactions** in the dashboard sidebar. The page opens on the **Payments** tab.
### KPI cards [#kpi-cards]
Three totals sit above the table, scoped to the filters you have applied:
| Card | What it shows |
| ---------------- | ------------------------------------------------------------------------------------------ |
| **Gross Volume** | Total revenue from the visible payments, including refunds and XPay fees, in your balance. |
| **Net Volume** | What the visible payments contributed to your XPay balance, after XPay fees and refunds. |
| **Refunds** | Total refunds processed against the visible payments. |
### Search and filters [#search-and-filters]
The search box matches against payment ID, customer ID, customer email, name, and phone. The filter bar above the table holds:
| Filter | What it does |
| ------------------ | ------------------------------------------------------------------------------------------- |
| **Status** | Multi-select: **Successful**, **Failed**, **Refunded**, **Partially Refunded**, and others. |
| **Amount** | Range or comparison (e.g. greater than, between two values). In your settlement currency. |
| **Date** | Range or relative window. Uses your browser timezone. |
| **Country** | Filter by the customer's IP country at payment time. |
| **City** | Filter by IP-derived city. |
| **Device** | Mobile, tablet, desktop. |
| **Platform** | iOS, Android, Windows, macOS, and others. |
| **Browser** | Chrome, Safari, Firefox, and others. |
| **Payment method** | Card, Valu, Fawry, mobile wallet. |
### Columns [#columns]
| Column | What it shows |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Amount** | The amount in the customer's currency. A small **VAT** or **Fee** badge appears when those configs are on; hover the amount to see a payment breakdown. |
| **Status** | A color-coded badge. Hover for a one-line tooltip; on **Failed**, the tooltip carries the decline reason and a link to the matching error page. |
| **Customer** | Email and phone of the paying customer, with a click-through to their profile. |
| **Payment Method** | Card brand and last 4, Valu, Fawry, or wallet identifier. |
| **Date** | When the payment was created. |
| **Description** | Free-form text on the payment, when set. |
| **Refunded Date** | When the most recent refund was issued, when applicable. |
| **Failure Reason** | A short, plain-language reason on **Failed** rows. Hover for the full decline code, network code, and a link to the right error page. |
Click any row to open the payment.
## Read a payment [#read-a-payment]
The detail page is organized as a vertical narrative: who paid, what happened, where the money sits now. Sections appear in this order, with sections hiding when they don't apply.
### Header [#header]
The amount and currency are at the top, with the status badge next to them. A **Held** badge appears when the funds are temporarily frozen for a risk review (see below). When the payment is refundable, a **Refund** button sits to the right.
### XPay Intelligence [#xpay-intelligence]
A panel of signals XPay derives from this single payment, like a repeat purchase, a cross-border charge, or a payment retry. See [Transaction signals](/features/transactions/transaction-signals) for the full list and how to act on them.
### Timeline [#timeline]
A reverse-chronological log of every step in the payment's life. Common entries:
* **Payment started.** When the Checkout Session began.
* **3-D Secure** (succeeded, attempted, failed, abandoned). Card-only.
* **Customer action** (succeeded or abandoned). For BNPL redirects, kiosk vouchers, OTP, mobile wallet redirects, and bank transfers.
* **Authorized** or **Failed.** The processor's verdict on the charge attempt. Failed entries on the latest attempt carry the decline code, the network decline code, and a link to the matching page in [Decline codes](/integrate/errors/decline-codes).
* **Refund** (succeeded, pending, failed). One entry per refund.
* **Payout** (scheduled, in transit, paid, failed, canceled). Tracks the payout batch the funds were assigned to.
* **Canceled.** When the payment was canceled.
Multiple charge attempts on the same payment (a customer retrying with a different card) all show up here. The latest attempt's decline info is the one expanded by default.
### Checkout Summary [#checkout-summary]
The line items the customer paid for, drawn from the Checkout Session that created the payment. Useful when a customer asks "what did I buy?".
### Transaction Details [#transaction-details]
A right-column block (stacks below on mobile) listing the metadata fields:
| Field | What it shows |
| ------------------ | --------------------------------------------------------- |
| **Customer** | Name, email, phone with a click-through to their profile. |
| **Payment ID** | The `pi_*` ID, with one-click copy. |
| **Payment Method** | The card or wallet used. |
| **Description** | Whatever your integration set on the payment. |
| **Dates** | When the payment was created and last updated. |
### Payment Breakdown [#payment-breakdown]
The math behind the amount. Lines appear conditionally based on configuration:
| Line | When it shows |
| ------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| **Customer paid** + **Exchange rate** + **Settlement amount** | When the customer paid in a different currency than your settlement currency. |
| **Payment amount** | Always. The base amount before VAT and fees. |
| **VAT (X%)** | When VAT collection is on for this payment. |
| **XPay processing fees** (paid by customer) | When fee pass-through is on. Adds to the customer's total. |
| **Total charged to customer** | When VAT or fee pass-through is on. |
| **XPay processing fees** (deducted) | When fee pass-through is on. The amount XPay deducts. |
| **Processing fees** | When fee pass-through is off. The flat XPay fee taken from your settlement. |
| **Refunded amount** | When any refund has been issued. |
| **Net amount** | Always. What the payment ultimately contributed to your balance. |
### Connected Payout [#connected-payout]
Once the payment is settled and assigned to a payout batch, this section shows the payout ID (clickable through to its detail), amount, status, and arrival date. See [Payouts and settlement](/features/account-and-operations/payouts-and-settlement) for the full payout view.
### Payment Method Details [#payment-method-details]
For card payments: brand, masked number, fingerprint, expiry, issuing country, funding type (credit, debit, prepaid), and the **3D Secure** result. For Valu, Fawry, and wallets: the relevant identifiers (loan number, voucher reference, wallet ID).
### Client and device info [#client-and-device-info]
Geolocation, IP, browser, OS, and device type at payment time. Useful when correlating with **Card-Country Mismatch** or **Multi-Device** signals on the customer's profile.
### Related transactions [#related-transactions]
Other payments by the same customer, with status and amount. Click any to drill across.
## Statuses [#statuses]
Every payment carries a status. The ones you'll meet most often:
| Status | What it means |
| ---------------------- | --------------------------------------------------------------------------------------------------------------- |
| **Successful** | Payment authorized and captured. Funds are queued for payout. |
| **Partially Refunded** | At least one refund has been issued, but the full amount is not refunded. |
| **Refunded** | The full captured amount has been refunded. |
| **Failed** | The processor declined the charge. Hover the badge in the list, or open the payment, to see the decline reason. |
| **Pending** | The payment is in flight. Common for kiosk vouchers, BNPL redirects, and bank transfers awaiting confirmation. |
| **Requires Action** | The customer needs to complete a step, like a 3-D Secure challenge or a voucher payment. |
| **Canceled** | The payment was canceled before completion. |
| **Voided** | An authorized payment was released without capture. |
| **Expired** | The payment timed out without the customer completing it. |
A handful of additional statuses (like **Requires Confirmation** and **Requires Payment Method**) only appear on a payment that's mid-creation. The list view collapses these into the closest user-meaningful state.
## Refund a payment [#refund-a-payment]
Open the payment, click **Refund** in the top right, and a sheet opens. Choose a full refund or enter a partial amount, optionally pick a reason (Requested by customer, Duplicate, Fraudulent), and submit. The refund appears in the timeline immediately and the payment's status flips to **Partially Refunded** or **Refunded**.
The button is hidden when the payment isn't refundable: not yet captured, already fully refunded, on a payment method that doesn't support refunds, or under a risk hold.
See [Refunds](/features/transactions/refunds) for the full flow, partial refund rules, and what shows up where.
## The Held badge [#the-held-badge]
Sometimes a payment carries a **Held** badge in the header. This is a temporary risk review by XPay: the captured funds are frozen, and the **Refund** button is hidden until the review is resolved. Holds are placed and released by the XPay risk team. Contact support if you need clarification on a specific hold.
## Where to next [#where-to-next]
Issue a refund, partial vs full, and how refund state surfaces across the dashboard.
The signals XPay surfaces on each individual payment.
Where every captured charge, refund, fee, and adjustment shows up as a balance entry.
The payout batches that disburse settled funds to your bank.
# Refunds (/en/features/transactions/refunds)
Issue a refund from the dashboard, read refund state across the dashboard, and understand fees and timing.
A Refund returns money you captured from a customer back to their original payment method. The dashboard does not have a separate "Refunds" page. Instead, every refund lives on the payment it came from, and the running record of every refund is in the **All activity** tab on Transactions.
This page covers how to issue a refund from the payment detail page, when the dashboard hides the refund button, what the customer experiences, and where the refund shows up across the rest of the dashboard.
## What you can do [#what-you-can-do]
A **Refund** button sits on the detail page of any successful payment. Issue a full refund or,
when the processor supports it, a partial one.
Optionally label the refund as **Requested by customer**, **Duplicate**, or **Fraudulent** so
you can correlate later.
Refunds show up in the payment's timeline, on the payment's status, in the Payment Breakdown
math, and as their own row in **All activity**.
Each refund is a balance entry that reduces your available balance. Customer lifetime spend is
also adjusted automatically.
## Issue a refund [#issue-a-refund]
### Open the payment [#open-the-payment]
In **Transactions**, find the payment you want to refund and click it. The detail page opens.
### Click Refund [#click-refund]
In the top right of the header, click **Refund**. A side sheet opens, titled **Refund payment**.
If you don't see a **Refund** button, see [When the button is hidden or disabled](#when-the-button-is-hidden-or-disabled) below.
### Set the amount [#set-the-amount]
The amount field is prefilled with the **Available to refund** value at the bottom of the sheet (the original captured amount minus anything you've already refunded).
* For a full refund, leave the amount as is.
* For a partial refund, lower the value. Partial amounts must be greater than zero and at most the available-to-refund value.
If the payment processor doesn't support partial refunds, the field is locked at the full amount and the sheet shows "Only full refunds are supported for this payment processor".
### (Optional) Pick a reason [#optional-pick-a-reason]
The **Reason** select offers three values: **Requested by customer**, **Duplicate charge**, **Fraudulent**. Leave it blank if none apply. Reasons help when you filter or report on refunds later, but they don't change how the refund is processed.
### Submit [#submit]
Click the **Refund** button at the bottom of the sheet (the label shows the exact amount you're about to refund). On success the sheet closes and a "Refund processed successfully" toast confirms. The payment's status flips to **Partially Refunded** or **Refunded** and a refund entry appears at the top of the timeline.
## When the button is hidden or disabled [#when-the-button-is-hidden-or-disabled]
The dashboard hides or disables the **Refund** button when a refund can't go through.
**Hidden** when the payment isn't in a refundable state:
| Reason | Why |
| ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- |
| Status is not **Successful** or **Partially Refunded** | Failed, canceled, expired, and pending payments don't have captured funds to refund. |
| The payment is not yet captured | Authorized but not captured: there's no charge to reverse yet. |
| The payment is already fully refunded | Nothing left to refund. |
| The payment method doesn't support refunds | Some processors and methods route refunds out of band. The button is hidden when XPay can't issue the refund directly. |
**Disabled with a tooltip** when you have permission gaps or a hold:
| Tooltip | Why |
| ------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| "You don't have permission to perform this action" | Your role lacks refund-write permission. Ask an account admin to grant it. |
| "This charge is currently under an active risk hold..." | A risk hold is on the funds. Contact support to release the hold before refunding. |
If you submit and hit a server-side block, the dashboard surfaces a specific error toast. Common ones:
* "This charge cannot be refunded in its current state."
* "Refund amount is invalid or exceeds the refundable amount."
* "Insufficient merchant balance to process this refund."
## What happens after [#what-happens-after]
**Timing.** Refunds take 7 to 14 days to land on the customer's statement. The dashboard reflects the refund immediately; the customer's bank takes longer.
**Fees.** XPay's fees on the original payment are not returned with the refund, but XPay does not charge an additional fee on top of the refund itself.
**Destination.** Refunds always go back to the original payment method. There is no way to redirect a refund to a different card or account.
**Customer record.** The customer's lifetime spend on their profile updates: **Total Refunded** goes up, **Net Spend** goes down. **Total Spent** (gross) stays as the historical record of what they've ever been charged.
## Where refund state shows up [#where-refund-state-shows-up]
Across the dashboard, a refund is visible in seven places:
| Surface | What it shows |
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| **Payments tab → Status column** | The badge on the payment row flips to **Refunded** or **Partially Refunded**. Hover for the refunded amount. |
| **Payments tab → Refunded Date column** | When the column is on, this shows when the most recent refund was issued. |
| **Payment detail → Header status badge** | Same status badge on the detail page. |
| **Payment detail → Timeline** | One entry per refund (succeeded, pending, or failed) with the amount and reason. |
| **Payment detail → Payment Breakdown** | A **Refunded amount** line appears in the math, reducing **Net amount**. |
| **Transactions → All activity tab** | The refund is its own balance entry, alongside its parent charge, fees, and any adjustments. |
| **Customer profile → Insights and Customer Details** | **Total Refunded** updates; **Net Spend** is recalculated. |
If you also subscribe to webhooks, the refund fires `refund.created`, and `refund.failed` if the bank rejects it. See [Refunds](/integrate/refunds) for the developer-side companion (status fields, the Refund object, and webhook handling).
## Refund reasons [#refund-reasons]
The dashboard offers three reason codes when you issue a refund. They're optional and free-form text isn't supported.
| Reason | When to pick it |
| ------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| **Requested by customer** | The customer asked for the refund (returned a product, changed their mind, billing dispute resolved in their favor). |
| **Duplicate charge** | The same payment went through twice and you're reversing the duplicate. |
| **Fraudulent** | You believe the original payment was unauthorized or fraudulent. |
Reasons are surfaced on the timeline entry and on the Refund object in API responses and webhook payloads.
## Where to next [#where-to-next]
The list and detail page where the **Refund** button lives.
Where every refund shows up as its own row in the running ledger.
The code-side companion. The Refund object, status fields, and webhook events.
Disputes are not yet self-serve in the dashboard. If a customer disputes a charge with their
bank, XPay reaches out directly.
# Transaction signals (/en/features/transactions/transaction-signals)
What each signal on a payment means, when it fires, and how to act on it.
Every payment detail page in your dashboard carries a **XPay Intelligence** panel with signals XPay derives from this single payment. A signal fires when this payment is unusual in some way: an outlier amount, a country mismatch between the card and the customer's IP, a retry, a repeat, a refund, or the first ever from a new country. Each signal carries a one-line detail so you know exactly what triggered it.
This page lists every transaction signal, when it fires, and what to do about it. For signals derived from a customer's lifetime history rather than one payment, see [Customer signals](/features/customers/customer-signals).
## How to read a signal [#how-to-read-a-signal]
Each signal sits in one of three sentiment buckets, used to color-code the panel:
| Sentiment | What it means |
| ----------- | ------------------------------------------------------------------------------------------------------ |
| **Warning** | A pattern worth a closer look. Same-day duplicate purchases, several failed retries, or a full refund. |
| **Info** | Neutral context that helps you understand the payment. |
| **Neutral** | Pure status (currently used for routine repeat purchases beyond the same-day window). |
Where a signal fires across **all your customers** for context, the percentile is computed against successful payments on your account in the same database (test or live). Test-mode percentiles only compare against test-mode payments.
## Unusual Amount [#unusual-amount]
**Sentiment:** info. **Detail line:** "Larger than X% of transactions (Top X%)".
Triggers when this payment's amount is in the top 10% of all successful payments on your account. Needs at least two successful payments on the account to compute meaningfully.
**Use it for:** Spotting outliers worth a manual review (high-ticket purchases, plan upgrades, or a wrong-decimal-place mistake before it ships out).
## Cross-Border [#cross-border]
**Sentiment:** info. **Detail line:** "Card issued in `XX`, paid from `YY`" (ISO country codes).
Triggers when the card's issuing country and the customer's IP country don't match. Requires both countries to be known on the payment.
**Use it for:** Travel, VPN usage, or a fraud signal worth correlating with refund or dispute history. A single Cross-Border on its own isn't an alarm; combined with **Payment Retry** or a customer's **Multi-Card User** signal, it deserves a look.
## Payment Retry [#payment-retry]
**Sentiment:** warning when 3 or more failed attempts, info otherwise. **Detail line:** "Succeeded after N failed attempt(s)" or "N failed attempt(s)".
Triggers when this payment had at least one failed charge attempt before reaching its current state. The signal stays even after the payment eventually succeeds, so you can see how many tries it took.
**Use it for:** Friction signals on the customer's end (expired card, wrong CVC, bank rules), or fraud-shaped patterns (rapid retries with different cards, which combined with **Cross-Border** can mean card-testing).
## Repeat Purchase [#repeat-purchase]
**Sentiment:** warning when same-day duplicate, neutral when older. **Detail line:**
* "Same amount charged N time(s) earlier today" (same day)
* "Same amount charged N time(s) in the last X days" (within a week)
* "Same amount charged N time(s) in the last X weeks" (1 to 8 weeks)
* "Same amount charged N time(s) in the last X months" (older)
Triggers when the same customer paid the same exact amount on a different payment within the last 365 days. The same-day case is highlighted as a warning because it often means an accidental double charge.
**Use it for:** Catching duplicate charges before the customer raises them. Subscription-style merchants will see this signal often and can treat it as routine; one-time-purchase merchants should investigate.
## New Market [#new-market]
**Sentiment:** info. **Detail line:** "First transaction from ``".
Triggers when this is the first successful payment your account has ever taken from this IP country.
**Use it for:** A milestone marker for international expansion, or a sanity check on whether the country makes sense for your business. If you don't ship internationally and a New Market signal fires, treat it as a fraud signal.
## Refunded [#refunded]
**Sentiment:** warning when fully refunded, info when partially refunded. **Detail line:** "Fully refunded (X% of transaction amount)" or "Partially refunded (X% of transaction amount)".
Triggers when any refund has been issued against this payment. The percentage reflects how much of the original amount has been refunded.
**Use it for:** A quick read on a payment's outcome without scrolling to the timeline. The full-refund warning helps when triaging a long list of recently refunded payments.
## When a payment shows no signals [#when-a-payment-shows-no-signals]
Empty signal panels are normal. A payment won't show signals when:
* The amount sits in the middle of your merchant's distribution (not in the top 10%).
* The card and IP countries match (or one of them is unknown).
* The payment authorized on the first attempt, no retries.
* The customer has never paid this exact amount before within the last year.
* The country has been seen on a previous successful payment.
* No refund has been issued yet.
In short: signals fire only when this payment stands apart from the rest. A quiet panel means the payment fits the pattern of typical traffic on your account.
## Where to next [#where-to-next]
The detail page where the XPay Intelligence panel sits.
What the **Refunded** signal reflects, and how to issue a refund yourself.
The 12 signals that ride on a customer's full history, not one payment.
Where each captured charge and refund shows up as a balance entry.
# Advanced configuration (/en/integrate/checkout-session/advanced-configuration)
Submit button text, locale, branding, payment-method restrictions, promotion codes, fees, expiration, and metadata. Everything else you can set on a Checkout Session.
The other Checkout Session sub-pages cover the load-bearing fields: line items, the customer, what happens after payment. This page is the catch-all for the rest: how the page reads (`submitType`, `locale`, `brandingSettings`), what payment methods show (`paymentMethodTypes`, `paymentMethodConfigurationId`), what's added to the bill (`feeConfig`, `discounts`), how long the session stays open (`expiresAfterMinutes`), and the freeform key-value store you can attach (`metadata`).
Every field below is optional. Defaults come from your merchant settings or system defaults; sending nothing here is fine.
## Submit button text [#submit-button-text]
`submitType` controls the label on the pay button at the bottom of the hosted form. Default is `pay`.
| Value | Button label |
| ----------- | ------------ |
| `pay` | Pay |
| `subscribe` | Subscribe |
| `book` | Book |
| `donate` | Donate |
Use `donate` for fundraising pages, `book` for reservations or appointments, `subscribe` for any recurring sale framing. The default `pay` covers everything else.
`submitType` is rejected when `uiMode: "custom"` (Elements). In that mode you build your own button. It's also locked at session creation: `PATCH` doesn't accept it.
## Locale [#locale]
`locale` controls the language of the hosted checkout and the card-form iframe. Two values today.
| Value | Language |
| ----- | -------- |
| `en` | English |
| `ar` | Arabic |
Resolution at render time: the value on the session wins, then your merchant default locale (set in dashboard settings), then English. The SDK's runtime `locale` parameter (Drop-in / Elements) overrides everything when the page renders inside an SDK iframe.
```jsonc
{ "locale": "ar" }
```
## Branding [#branding]
`brandingSettings` overrides the dashboard-level brand defaults for one session. Anything you set here wins; anything you omit falls back to the merchant default. Colors merge key by key, so a session can override `primary` without erasing the merchant's `background`.
```jsonc
{
"brandingSettings": {
"colorMode": "light",
"borderStyle": "rounded",
"spacing": "normal",
"inputSize": "medium",
"inputStyle": "outlined",
"formLayout": "spacious",
"fontFamily": "Inter, sans-serif",
"colors": {
"primary": "#635bff",
"background": "#ffffff",
"foreground": "#0a0a0a",
},
},
}
```
The shape:
| Field | Values | Purpose |
| ------------- | ------------------------------------------- | ----------------------------------------------------- |
| `colorMode` | `light`, `dark`, `system` (default) | Light vs dark vs follow the customer's OS preference. |
| `borderStyle` | `rounded` (default), `sharp`, `pill` | Corner radius on inputs, buttons, cards. |
| `spacing` | `condensed`, `normal` (default), `spacious` | Vertical density of the page. |
| `inputSize` | `small`, `medium` (default), `large` | Form field height. |
| `inputStyle` | `flat`, `outlined` (default), `filled` | Visual treatment of inputs. |
| `formLayout` | `compact`, `spacious` (default) | Layout density for the form column. |
| `fontFamily` | CSS font-family value | Override the page font. Up to 512 characters. |
| `colors` | Object of hex colors (see below) | Brand colors. Hex only. |
`colors` accepts the following keys, each a hex string (`#RGB`, `#RGBA`, `#RRGGBB`, or `#RRGGBBAA`; `#0000` for transparent):
`primary`, `primaryForeground`, `background`, `foreground`, `border`, `input`, `ring`, `muted`, `mutedForeground`, `accent`, `accentForeground`, `destructive`.
Color values that aren't hex are rejected at session creation. `rgb(...)`, named colors, and CSS variables aren't accepted.
For the merchant-side branding setup (logo, business name, favicon), see **Settings → Branding** in the dashboard. Those fields aren't overridable per session.
## Payment methods [#payment-methods]
Every method enabled on your account is already offered to the customer. Most integrations send neither field below.
To change which methods customers see, edit your default configuration under **Settings → Payment methods**. It applies to every new session, no deploy needed. See [Payment methods](/features/checkout-customization/payment-methods).
Two fields override it for one session.
| Field | Use it when |
| ------------------------------ | --------------------------------------------------------------- |
| `paymentMethodConfigurationId` | You keep more than one saved configuration. Pass its `pmc_` ID. |
| `paymentMethodTypes` | You need a one-off list for a single session. |
Prefer the configuration ID. The method list inside the configuration stays editable in the dashboard, so changing it doesn't need a deploy.
The two are mutually exclusive: send one or the other, never both.
```jsonc
// Restrict this session to card and Valu only
{ "paymentMethodTypes": ["card", "valu"] }
```
Every value must already be enabled on your account, or the request fails with [`parameter_invalid`](/integrate/errors/api-error-codes#parameter_invalid). The error lists what you do have enabled. Check **Settings → Payment methods** before hardcoding an array.
The response returns each resolved method with its display name, category, refund support, and amount limits. If you passed an explicit list, methods whose limits exclude the session total are dropped from it. With a configuration, they appear at checkout but can't be selected.
## Promotion codes and discounts [#promotion-codes-and-discounts]
Two related fields. Both work with Coupon records you've created in the dashboard.
`allowPromotionCodes` (boolean, default `false`) shows an "Add promotion code" input on the hosted form. Customers type a promo code, the page validates it server-side, and the discount applies to the session totals immediately.
`discounts` (array, max 1) pre-applies a coupon or promotion code. Use this for personalized links or one-off discount campaigns where you don't want the customer to type anything.
```jsonc
// Customer enters their own code
{ "allowPromotionCodes": true }
// You apply a specific coupon
{ "discounts": [{ "coupon": "coupon_test_AbC123" }] }
// Or a specific promotion code
{ "discounts": [{ "promotionCode": "promo_test_xyz789" }] }
```
Either `coupon` or `promotionCode` per entry, never both. The session response carries the applied discount (with the coupon snapshot) under `discounts[]`, and `totalDetails.amountDiscount` reflects what came off.
Two restrictions worth knowing:
* **Custom-amount lines reject discounts.** A session with a `CUSTOM`-type line item can't combine with `allowPromotionCodes` or `discounts`. The customer chose the amount; layering a coupon breaks the contract.
* **Max one discount per session.** The current API rejects a second.
For the full coupon and promotion code surface (durations, redemption limits, customer restrictions), see the dashboard's **Catalog → Coupons** page.
## Fees and VAT pass-through [#fees-and-vat-pass-through]
In compliance with regulations, fee pass-through (`feesPassThrough: true`) is gated on XPay
approval. Contact your account manager to enable it on your account before using this setting.
`feeConfig` overrides your merchant-default fee handling for a single session. Three fields.
```jsonc
{
"feeConfig": {
"feesPassThrough": true,
"vatCollectionEnabled": true,
"vatCollectionRate": 1400,
},
}
```
| Field | Effect |
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `feesPassThrough` | When `true`, XPay's platform fee is added on top of the line-item totals so the customer pays it instead of you. When `false` (default), the fee comes out of the merchant payout. |
| `vatCollectionEnabled` | When `true`, your product VAT is added on top so the customer pays it. The amount is settled to you and tracked separately in your balance. |
| `vatCollectionRate` | VAT rate in basis points. `1400` = 14%. Required when `vatCollectionEnabled` is `true`. Range 0 to 10000. |
Resolution: the session's `feeConfig` wins, then your merchant default fee config (set in dashboard fee settings), then the system default (no pass-through, no VAT). The session response carries a resolved `feeConfig` object with a `source` field telling you which level provided the values.
When `feesPassThrough` or `vatCollectionEnabled` is on, the session response also carries a `fees` object with the exact platform fee amount, percentage, and (for cards) BIN-aware breakdown. That's what you'd display in your own UI if you wanted to surface platform fee or VAT as separate line items.
## Expiration [#expiration]
`expiresAfterMinutes` controls how long the session stays open before XPay marks it `expired`. Default `1440` (24 hours). Minimum `30`. Locked at creation; `PATCH` doesn't accept it.
```jsonc
{ "expiresAfterMinutes": 60 }
```
When the timer runs out, the session's `status` flips to `expired`, the hosted page shows a terminal "you're all done here" state, and a `checkout.session.expired` webhook fires. You can also expire a session early with `POST /checkout/sessions/:id/expire` (covered in [Overview → Expiration](/integrate/checkout-session/overview#expiration)).
## Metadata [#metadata]
`metadata` is a freeform key-value map you attach to the session. XPay stores it verbatim and never reads it. It travels with the session everywhere it goes.
```jsonc
{
"metadata": {
"user_id": "u_42",
"order_id": "ord_193",
"campaign": "spring-sale",
},
}
```
Where it shows up:
* On the session retrieve response (`GET /checkout/sessions/:id`).
* On the `checkout.session.completed` webhook payload, in `data.object.metadata`.
* Forwarded to the Payment Intent created at payment time, so it's also on `paymentIntent.metadata` and on every Charge under that PI.
Use it to thread your own IDs through the payment flow. Most useful pattern: set `metadata.user_id` so your webhook handler knows which of your users paid without joining tables.
Keep values to strings. Don't put PII or secrets here.
## Where to next [#where-to-next]
Receive `checkout.session.completed` and read the metadata you attached.
Configure where the customer lands once payment succeeds.
Existing prices vs inline `priceData`, quantities, custom amounts.
# After completion (/en/integrate/checkout-session/after-completion)
Send the customer back to your site, or let XPay show a hosted thank-you page. Plus how to handle failed payments and why the webhook is the source of truth.
`afterCompletion` decides what the customer sees the moment a payment succeeds. Two shapes: a **redirect** to a URL on your site, or a **hosted confirmation** page that XPay renders. Pick one per session; you can change it on `PATCH` while the session is still open.
A separate field, `cancelUrl`, decides where the customer goes if a payment **fails** on the hosted page (declined card, 3D Secure rejected, local-method timeout). It's optional and only valid for `uiMode: "hosted"`.
Whichever option you pick, **the redirect or confirmation page is not where you confirm the
payment.** Your server must listen for the `checkout.session.completed` webhook to actually mark
the order paid. Customers can close the tab, hit the URL by accident, or never reach the redirect
at all.
## The two options [#the-two-options]
| `afterCompletion.type` | What the customer sees on success | When to pick it | Available on |
| ---------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------ |
| `redirect` | XPay sends them to your `redirect.url`. You render the thank-you page yourself. | You have a return page that fits your brand and order context. Recommended for most sites. | Every `uiMode` |
| `hosted_confirmation` | XPay shows a built-in success page with an optional custom message and return button. | You don't have a return page (yet), or for one-off payments where building one is overkill. | `uiMode: "hosted"` and Payment Links |
`afterCompletion` itself is the only required field on `POST /checkout/sessions`. Sending neither field at all is a validation error.
For `uiMode: "embedded"` (Drop-in) and `uiMode: "custom"` (Elements), `type` must be `"redirect"`.
`hosted_confirmation` renders an XPay-hosted page, and those integrations run entirely on your own
site. See [SDK integrations need a return URL](#sdk-integrations-need-a-return-url).
## Redirect to your URL [#redirect-to-your-url]
Set `type: "redirect"` and provide an HTTPS URL on a domain you control. XPay sends the customer to that URL right after the payment lands.
```bash
curl -X POST https://api.xpay.app/checkout/sessions \
-H "Authorization: Bearer sk_test_..." \
-H "Content-Type: application/json" \
-d '{
"afterCompletion": {
"type": "redirect",
"redirect": { "url": "https://yourshop.example/order/{CHECKOUT_SESSION_ID}" }
},
"lineItems": [ { "price": "price_test_xyz", "quantity": 1 } ]
}'
```
```typescript
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({
afterCompletion: {
type: "redirect",
redirect: { url: "https://yourshop.example/order/{CHECKOUT_SESSION_ID}" },
},
lineItems: [{ price: "price_test_xyz", quantity: 1 }],
}),
});
```
```python
import os, requests
requests.post(
"https://api.xpay.app/checkout/sessions",
headers={
"Authorization": f"Bearer {os.environ['XPAY_SECRET_KEY']}",
"Content-Type": "application/json",
},
json={
"afterCompletion": {
"type": "redirect",
"redirect": {"url": "https://yourshop.example/order/{CHECKOUT_SESSION_ID}"},
},
"lineItems": [{"price": "price_test_xyz", "quantity": 1}],
},
timeout=10,
)
```
A few rules:
* **`redirect.url` is required** when `type: "redirect"`. Sending `redirect` together with `type: "hosted_confirmation"` is rejected.
* **HTTPS, on a domain you control.** `localhost` is fine in test mode but rejected in live.
* **No query parameters added by XPay.** The customer lands on your URL with whatever you put in it. If you need to know which session this is, use the template token below.
### The `{CHECKOUT_SESSION_ID}` template [#the-checkout_session_id-template]
Drop the literal token `{CHECKOUT_SESSION_ID}` anywhere in `redirect.url` and XPay substitutes the session's ID before saving the URL. Useful when you don't want to thread the ID through your own state.
```jsonc
{
"redirect": {
"url": "https://yourshop.example/order/{CHECKOUT_SESSION_ID}",
},
}
```
For a session with `id: "cs_test_AbC123..."`, the customer is redirected to `https://yourshop.example/order/cs_test_AbC123...`. Read the `cs_*` from the path on your return page if you want to display the order details (call `GET /checkout/sessions/:id` to fetch the latest state).
## Show a hosted confirmation page [#show-a-hosted-confirmation-page]
Set `type: "hosted_confirmation"` if you don't have a return page. XPay shows the customer a thank-you page with your business name and a check mark, and that's the end of the flow.
Valid for `uiMode: "hosted"` and for Payment Links (which always run as hosted). Sending it with `uiMode: "embedded"` or `"custom"` returns a `400` on both create and update.
```bash
curl -X POST https://api.xpay.app/checkout/sessions \
-H "Authorization: Bearer sk_test_..." \
-H "Content-Type: application/json" \
-d '{
"afterCompletion": {
"type": "hosted_confirmation",
"hostedConfirmation": {
"customMessage": "Thanks! Your order will ship within 2 business days.",
"returnUrl": "https://yourshop.example"
}
},
"lineItems": [ { "price": "price_test_xyz", "quantity": 1 } ]
}'
```
```typescript
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({
afterCompletion: {
type: "hosted_confirmation",
hostedConfirmation: {
customMessage: "Thanks! Your order will ship within 2 business days.",
returnUrl: "https://yourshop.example",
},
},
lineItems: [{ price: "price_test_xyz", quantity: 1 }],
}),
});
```
```python
import os, requests
requests.post(
"https://api.xpay.app/checkout/sessions",
headers={
"Authorization": f"Bearer {os.environ['XPAY_SECRET_KEY']}",
"Content-Type": "application/json",
},
json={
"afterCompletion": {
"type": "hosted_confirmation",
"hostedConfirmation": {
"customMessage": "Thanks! Your order will ship within 2 business days.",
"returnUrl": "https://yourshop.example",
},
},
"lineItems": [{"price": "price_test_xyz", "quantity": 1}],
},
timeout=10,
)
```
Both nested fields are optional.
| Field | Default | Notes |
| --------------- | --------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
| `customMessage` | A statement-style line ending with your business name (the display name from your dashboard branding settings). | Up to 500 characters. Replaces the default text. |
| `returnUrl` | None. The page renders without a return button. | When set, the page shows a "Return to *your business name*" button linking to this URL. |
`redirect` must not be provided when `type: "hosted_confirmation"`. Sending both is rejected.
## `cancelUrl`: where to send the customer on payment failure [#cancelurl-where-to-send-the-customer-on-payment-failure]
`cancelUrl` is the URL XPay redirects the customer to when a **payment attempt fails** on the hosted checkout page. It's optional, separate from `afterCompletion`, and only valid when `uiMode: "hosted"`.
```jsonc
{
"afterCompletion": { "type": "redirect", "redirect": { "url": "..." } },
"cancelUrl": "https://yourshop.example/checkout/declined",
"lineItems": [ ... ]
}
```
What triggers a redirect to `cancelUrl`:
* A card was declined.
* 3D Secure was rejected, cancelled, or timed out.
* A local payment method (Fawry, Valu, etc.) timed out or was rejected by the processor.
* The processor returned an error.
What does **not** trigger a redirect to `cancelUrl`:
* The customer closing the tab. There's no client-side cancellation hook.
* The customer hitting the back button. The hosted page has no "Cancel" or "Back" button.
* The session expiring while open. That just shows a terminal "you're all done here" state on the hosted page.
If `cancelUrl` is omitted, the customer sees the failure inline on the hosted checkout page and can retry there. Setting it gives you a chance to log the attempt, surface a custom retry UX, or offer a fallback like a different payment method.
## SDK integrations need a return URL [#sdk-integrations-need-a-return-url]
For `uiMode: "embedded"` and `uiMode: "custom"`, `afterCompletion.type` must be `"redirect"`, which makes `redirect.url` mandatory in practice. Three separate rules stack up here, so to be precise about which is which:
| Rule | Applies to |
| -------------------------------------------------- | ------------------------------------------------ |
| `afterCompletion` is required | Every Checkout Session and every Payment Link |
| `redirect.url` is required when `type: "redirect"` | Every `uiMode` |
| `type` must be `"redirect"` | `uiMode: "embedded"` and `uiMode: "custom"` only |
Payment Links always run as `uiMode: "hosted"`, so the third rule never touches them. They can use `hosted_confirmation`.
## When verification takes over the page [#when-verification-takes-over-the-page]
This applies to `uiMode: "hosted"` and Drop-in. A bank verification never takes over an Elements page.
Most card payments verify with the bank without leaving the page. Occasionally the bank's page cannot be shown there at all.
XPay then hands the bank the whole tab instead of failing the payment. The customer verifies on the bank's own page and comes back to your `afterCompletion.redirect.url`.
Write your return page for it:
* **It is not a success page.** The customer returns whatever the outcome, including declines and abandoned verifications.
* **The payment is often not confirmed yet when they arrive.** Read the session and check `paymentStatus`. If it is not `paid`, tell the customer the payment is being confirmed. Do not tell them it failed, or they will pay a second time for a payment that is going through.
* **Nothing is appended to your URL.** Put `{CHECKOUT_SESSION_ID}` in it so the page knows which session to read.
* **`onComplete` does not fire.** For Drop-in, that callback belongs to a page that is gone.
Fulfillment is unchanged. `checkout.session.completed` is what marks the order paid, on this path exactly as on every other.
## What about Drop-in and Elements? [#what-about-drop-in-and-elements]
`afterCompletion` is the same field on every `uiMode`, but the runtime behavior shifts.
XPay's hosted page navigates the browser to `redirect.url` (or shows the confirmation page).
Your server runs nothing client-side. `cancelUrl` works here.
The SDK opens the hosted checkout in an iframe. On success, the SDK fires your `onComplete`
callback with the `redirectUrl` from `afterCompletion.redirect.url`. Your code decides whether to
navigate, close the modal, or hand off to your own success view. XPay navigates the browser there
itself only when a bank verification takes over the tab. `cancelUrl` and `hosted_confirmation`
are both rejected at session create.
Your code calls `confirmPayment()` and handles the success/failure result yourself. XPay renders
nothing on your page except the payment element and the verification overlay. The only time the
SDK navigates is `redirect: "always"`, which sends the customer to `redirect.url` after a
successful payment. `cancelUrl` and `hosted_confirmation` are both rejected at session create.
The session payload that arrives in `checkout.session.completed` carries the same `afterCompletion` fields regardless of `uiMode`, so your server-side fulfillment code is identical across patterns.
## The webhook is the source of truth [#the-webhook-is-the-source-of-truth]
A redirect or hosted-confirmation render means the customer's browser saw the success state. It does not mean your server has the truth.
* **Customers close tabs.** The redirect never fires.
* **Networks drop.** The redirect arrives, but your return page can't reach your own backend.
* **People hit URLs by accident.** Anyone can craft a return URL with a stale `cs_*`.
Treat your return page (or hosted-confirmation page) as a UX courtesy, and your `checkout.session.completed` webhook handler as the single source of truth for fulfillment. The webhook payload carries the full Checkout Session, including the resolved `paymentIntent`, `customer`, and `lineItems`. See [Webhooks → Setting up an endpoint](/integrate/webhooks/setting-up-an-endpoint) for the handler recipe.
If you want to display the actual order on your return page, read it back with `GET /checkout/sessions/:id` from the path (assuming you used the `{CHECKOUT_SESSION_ID}` template).
## Where to next [#where-to-next]
Branding, locale, payment methods, fees, and metadata.
Receive `checkout.session.completed` and verify the signature.
The full handler walkthrough on the Hosted Checkout pattern page.
# Customer lifecycle (/en/integrate/checkout-session/customer-lifecycle)
Decide who the customer is, what the form collects, and how XPay's customer record evolves across one-time payers and returning customers.
A Customer is XPay's record of someone who paid you. It carries their name, email, phone, address, and the lifetime spend tied to those identifiers. This page is about how that record gets created, prefilled, and re-used across the three integration scenarios you're likely to build: returning authenticated customers, first-time authenticated customers, and guest checkouts.
The single rule that drives every decision below: **store the `cus_*` against your authenticated user, never against a guest.** XPay deduplicates guest checkouts server-side; if you pin a `cus_*` to a guest in your database you'll fight that dedup.
## The two customer types [#the-two-customer-types]
XPay tracks two kinds of Customer records.
| Type | When it's created | Use |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- |
| **Registered** | Either you set `customerCreation: "always"` on a Checkout Session and the customer pays, or the merchant explicitly attached a `customerId` you already had. The dashboard counts them as known customers; you can attach saved payment methods to them later (subscriptions, etc.). | Your authenticated user paid. Save the `cus_*` to their account row. |
| **Guest** | Auto-created during a checkout when no `customerId` is provided and `customerCreation` is `if_required` (the default). XPay groups guests by email, phone, and card fingerprint so the same person is recognized across visits. | A one-time payer or someone who hasn't signed in. **Don't store the `cus_*`**: XPay maintains the dedup, you'd just fight it. |
Both types appear in the dashboard. The guest's grouping fields (every email and phone they've used, every card they've paid with) build up automatically. The dashboard also surfaces "Related customers" so an operator can see when a guest record overlaps with a registered one.
## Three patterns by merchant scenario [#three-patterns-by-merchant-scenario]
The shape of `customerCreation`, `customerId`, and `customerDetails` you send depends on what you know about the person paying.
### Returning authenticated user [#returning-authenticated-user]
You have a `cus_*` stored on your user row. Pass it as `customerId`. The form prefills from the existing record. Don't pass `customerDetails` (it's mutually exclusive with `customerId`). Optionally set `customerUpdate` to write the form's changes back.
```bash
curl -X POST https://api.xpay.app/checkout/sessions \
-H "Authorization: Bearer sk_test_..." \
-H "Content-Type: application/json" \
-d '{
"afterCompletion": { "type": "redirect", "redirect": { "url": "https://yourshop.example/order/{CHECKOUT_SESSION_ID}" } },
"customerId": "cus_test_AbC123",
"customerUpdate": { "address": "auto", "shipping": "auto" },
"lineItems": [ { "price": "price_test_xyz", "quantity": 1 } ]
}'
```
```typescript
const session = 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({
afterCompletion: {
type: "redirect",
redirect: { url: "https://yourshop.example/order/{CHECKOUT_SESSION_ID}" },
},
customerId: user.xpay_customer_id, // stored on your user row
customerUpdate: { address: "auto", shipping: "auto" },
lineItems: [{ price: "price_test_xyz", quantity: 1 }],
}),
}).then((r) => r.json());
```
```python
import os, requests
session = requests.post(
"https://api.xpay.app/checkout/sessions",
headers={
"Authorization": f"Bearer {os.environ['XPAY_SECRET_KEY']}",
"Content-Type": "application/json",
},
json={
"afterCompletion": {
"type": "redirect",
"redirect": {"url": "https://yourshop.example/order/{CHECKOUT_SESSION_ID}"},
},
"customerId": user.xpay_customer_id, # stored on your user row
"customerUpdate": {"address": "auto", "shipping": "auto"},
"lineItems": [{"price": "price_test_xyz", "quantity": 1}],
},
timeout=10,
).json()
```
### First-time authenticated user [#first-time-authenticated-user]
No `cus_*` yet. Send what you know as `customerDetails` for prefill, and set `customerCreation: "always"` so a registered Customer is created at payment time. Capture `data.object.customer.id` from the `checkout.session.completed` webhook and store it on your user row.
```bash
curl -X POST https://api.xpay.app/checkout/sessions \
-H "Authorization: Bearer sk_test_..." \
-H "Content-Type: application/json" \
-d '{
"afterCompletion": { "type": "redirect", "redirect": { "url": "https://yourshop.example/order/{CHECKOUT_SESSION_ID}" } },
"customerCreation": "always",
"customerDetails": {
"name": "Aya Hassan",
"email": "aya@example.com",
"phone": "+201234567890"
},
"metadata": { "user_id": "u_42" },
"lineItems": [ { "price": "price_test_xyz", "quantity": 1 } ]
}'
```
```typescript
const session = 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({
afterCompletion: {
type: "redirect",
redirect: { url: "https://yourshop.example/order/{CHECKOUT_SESSION_ID}" },
},
customerCreation: "always",
customerDetails: {
name: user.name,
email: user.email,
phone: user.phone,
},
metadata: { user_id: user.id },
lineItems: [{ price: "price_test_xyz", quantity: 1 }],
}),
}).then((r) => r.json());
```
```python
import os, requests
session = requests.post(
"https://api.xpay.app/checkout/sessions",
headers={
"Authorization": f"Bearer {os.environ['XPAY_SECRET_KEY']}",
"Content-Type": "application/json",
},
json={
"afterCompletion": {
"type": "redirect",
"redirect": {"url": "https://yourshop.example/order/{CHECKOUT_SESSION_ID}"},
},
"customerCreation": "always",
"customerDetails": {
"name": user.name,
"email": user.email,
"phone": user.phone,
},
"metadata": {"user_id": user.id},
"lineItems": [{"price": "price_test_xyz", "quantity": 1}],
},
timeout=10,
).json()
```
### Guest checkout [#guest-checkout]
No authenticated user. Omit `customerId` and leave `customerCreation` as the default (`if_required`). XPay creates or matches a guest record by email, phone, and card fingerprint. You don't need to track the `cus_*` on your side. The dashboard groups guests automatically.
```bash
curl -X POST https://api.xpay.app/checkout/sessions \
-H "Authorization: Bearer sk_test_..." \
-H "Content-Type: application/json" \
-d '{
"afterCompletion": { "type": "redirect", "redirect": { "url": "https://yourshop.example/order/{CHECKOUT_SESSION_ID}" } },
"lineItems": [ { "price": "price_test_xyz", "quantity": 1 } ]
}'
```
```typescript
const session = 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({
afterCompletion: {
type: "redirect",
redirect: { url: "https://yourshop.example/order/{CHECKOUT_SESSION_ID}" },
},
lineItems: [{ price: "price_test_xyz", quantity: 1 }],
}),
}).then((r) => r.json());
```
```python
import os, requests
session = requests.post(
"https://api.xpay.app/checkout/sessions",
headers={
"Authorization": f"Bearer {os.environ['XPAY_SECRET_KEY']}",
"Content-Type": "application/json",
},
json={
"afterCompletion": {
"type": "redirect",
"redirect": {"url": "https://yourshop.example/order/{CHECKOUT_SESSION_ID}"},
},
"lineItems": [{"price": "price_test_xyz", "quantity": 1}],
},
timeout=10,
).json()
```
The webhook that fires on success (`checkout.session.completed`) carries the resolved Customer in `data.object.customer`. For the first-time-authenticated case, that's where you grab the new `cus_*` to save on your user row.
## `customerId` vs `customerDetails` [#customerid-vs-customerdetails]
These are the two mutually-exclusive ways to identify the customer on a session.
| Field | What it does | Required type |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- |
| `customerId` | References an existing Customer (`cus_*`). The form prefills name, email, phone, billing address, and shipping from that record. | Registered |
| `customerDetails` | Inline contact and address data (`name`, `email`, `phone`, `billingDetails.address`, `shipping`). Prefills the form. Used to create a new Customer at payment time. | n/a |
Sending both is rejected. Sending neither is fine: the form is blank and the customer fills it in.
`customerId` must point at a **registered** Customer. Passing a guest's `cus_*` fails with a clear error. Guests are checkout-only; there's no path to attach them to a future session.
### Prefill rules [#prefill-rules]
When the page renders, the form is pre-filled in priority order:
1. `customerDetails.{name, email, phone}` if provided.
2. The existing Customer's `name`, `email`, `phone`, `address`, `shipping` if `customerId` was set.
3. Empty otherwise. The customer fills it in.
The email and phone fields are **locked** (read-only) when `customerId` is set and the existing record carries them. The customer can't change someone else's email or phone mid-checkout.
## `customerCreation` [#customercreation]
Controls when XPay creates a Customer record at payment time. Two values; default is `if_required`.
| Value | What it does |
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `if_required` | Default. If the customer fills in an email or phone, a **guest** Customer is created or matched. If they fill in nothing and the merchant didn't set `customerId`, no Customer record is created at all. |
| `always` | A **registered** Customer is created from `customerDetails` at payment time, even if they only typed a name. Use this when you want a known account on file (returning visits, post-purchase emails, future subscriptions). |
`customerCreation` is mutually exclusive with `customerId: "cus_..."` for `always`. If you already have a Customer, you're not asking XPay to create another one.
## `customerUpdate` [#customerupdate]
When `customerId` is set, the form-collected data is **not** written back to the existing Customer by default. Set `customerUpdate` to opt-in per field. Each sub-flag is `auto` (write) or `never` (default, don't write).
```jsonc
{
"customerId": "cus_test_AbC123",
"customerUpdate": {
"name": "auto", // overwrite customer.name with the form value
"address": "auto", // overwrite customer.address with the billing address
"shipping": "auto", // overwrite customer.shipping with the shipping address
},
}
```
`customerUpdate` is only meaningful when `customerId` is provided. Sending it without `customerId` is a validation error.
`email` and `phone` aren't in `customerUpdate` because they're locked at the form level when the existing customer carries them. They can only be **backfilled** when missing (see [How XPay handles guest customers](#how-xpay-handles-guest-customers)).
## What the form collects [#what-the-form-collects]
Four boolean toggles control which fields the hosted checkout form asks the customer to fill. All default to `false`.
| Toggle | What it asks for | Required at submit? |
| --------------------------- | --------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
| `nameCollection` | Customer's full name. | Yes when on. |
| `phoneNumberCollection` | Customer's phone with country code. | Yes when on. Submit fails with `parameter_missing` on `customerDetails.phone` if blank. |
| `billingAddressCollection` | Cardholder name and full billing address (street, city, state). | Yes when on. Submit fails on missing `customerDetails.billingDetails.address.line1`. |
| `shippingAddressCollection` | Recipient name and shipping address. | Yes when on. Submit fails on missing `customerDetails.shipping.address.line1`. |
Email is always asked for and always required. It's the receipt destination and the primary identifier for guest dedup.
The submit button label is set separately by `submitType` (`pay` / `subscribe` / `book` / `donate`).
### Custom fields [#custom-fields]
Need to ask for a Tax ID, an order note, or a delivery preference? `customFields` is an array of up to 3 entries. Each one renders an input on the form and the customer's answer is returned back on the session response and the webhook payload.
| Field type | Input rendered | Validation |
| ---------- | ----------------------------------------------- | ------------------------------------------------------------------------- |
| `TEXT` | Single-line text input. | Optional `minCharacters` / `maxCharacters` via `hasLimits` + `limitType`. |
| `NUMBER` | Number-only input. | Same character limits apply. |
| `DROPDOWN` | Select with merchant-defined `dropdownOptions`. | At least 2 options required. |
| `CHECKBOX` | Single checkbox the customer ticks. | No character limits. |
```jsonc
{
"customFields": [
{
"label": "Tax ID",
"type": "TEXT",
"isOptional": false,
"hasLimits": true,
"limitType": "BETWEEN",
"minCharacters": 9,
"maxCharacters": 15,
},
{
"label": "Delivery preference",
"type": "DROPDOWN",
"dropdownOptions": [{ "label": "Leave at door" }, { "label": "Hand to recipient" }],
},
],
}
```
Each field gets a stable `key` server-side (derived from `label`) so the answer comes back at the same key in the response payload. The collected value lives in the matching `text` / `numeric` / `dropdown` slot on each entry.
## How XPay handles guest customers [#how-xpay-handles-guest-customers]
The default checkout flow (no `customerId`, `customerCreation: "if_required"`) creates a guest Customer the first time someone checks out. **The same guest is matched on subsequent visits when any identifier overlaps.** You don't write any code for this.
XPay matches by:
* **Email**, case-insensitive and trimmed.
* **Phone**, normalized (country code, `00` prefix converted to `+`).
* **Card fingerprint**, captured after a successful charge. If a guest pays with a card that's been seen on another guest record, XPay merges the two and consolidates spend, payment history, and devices on the survivor.
What this gives you in the dashboard:
* A guest's profile compounds every email, phone, and card they've used over time.
* A guest's spend, refunds, and payment count are accurate even when they checked out three times with three slightly different forms.
* The "Related customers" section on a registered customer's profile surfaces the guest records that share contact info with them, so an operator can correlate the two.
What guest customers can't do:
* They **can't have a saved payment method attached for future charges**. Every guest checkout is a fresh card entry. If your product needs saved cards (subscriptions, one-click reordering), you must set `customerCreation: "always"` to create a registered Customer instead.
## Avoiding duplicate customer records [#avoiding-duplicate-customer-records]
The biggest failure mode merchants hit: re-creating an XPay customer on every login or every order, instead of re-using the one you already have. The pattern below avoids that.
### Store the `cus_*` against your authenticated user [#store-the-cus_-against-your-authenticated-user]
When a logged-in user makes their first payment, set `customerCreation: "always"` and capture `data.object.customer.id` from the `checkout.session.completed` webhook. Save that `cus_*` on your user row in your database.
```typescript
// In your webhook handler
if (event.type === "checkout.session.completed") {
const session = event.data.object;
const userId = session.metadata?.user_id; // you set this on session create
const customerId = session.customer?.id; // cus_test_...
if (userId && customerId) {
await db.users.update(userId, { xpay_customer_id: customerId });
}
}
```
### Pass it as `customerId` on every subsequent session [#pass-it-as-customerid-on-every-subsequent-session]
For every following payment by that same user, set `customerId: "cus_test_..."`. The form prefills, no new Customer is created, and the spend rolls up under the same record.
```typescript
const session = 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({
afterCompletion: { type: "redirect", redirect: { url: returnUrl } },
customerId: user.xpay_customer_id, // re-use, don't recreate
lineItems: [...],
}),
});
```
### Don't store guest `cus_*` on user rows [#dont-store-guest-cus_-on-user-rows]
If a user paid as a guest first (before signing up) and then created an account, the right move is to start the `cus_*` story on their first **registered** payment. Don't try to back-fill the guest's `cus_*` onto their account. Guests dedup server-side; an operator can see the connection in the dashboard's "Related customers" section.
### Use `metadata` to thread your own user ID [#use-metadata-to-thread-your-own-user-id]
Set `metadata: { user_id: "..." }` on every session you create. It comes back on the webhook payload, on the session retrieve response, and on the Payment Intent. Makes your webhook handler trivial: the session payload tells you exactly which user paid.
The result: one XPay registered Customer per authenticated user, lifetime spend rolls up correctly, and guests are tracked separately without you having to manage them.
## Where to next [#where-to-next]
Configure where the customer lands after they pay.
Branding, locale, payment methods, fees, and metadata.
Receive `checkout.session.completed` so you can capture `data.object.customer.id`.
# Line items & pricing (/en/integrate/checkout-session/line-items-and-pricing)
How `lineItems` work on a Checkout Session: existing prices vs inline `priceData`, fixed and adjustable quantity, custom-amount lines, currency, and the availability gate.
A `lineItems` array on `POST /checkout/sessions` lists what the customer is paying for. Each item carries a price reference, a quantity, and optional rules for the customer to adjust quantity at checkout. The session aggregates them into the totals shown on the hosted page (`amountSubtotal`, `amountTotal`), and the same per-line shape is what the Checkout Session response carries back to you.
Two things drive every line item: which Price it points at, and how many of that price the customer is charged for. Everything else (currency, availability, presentment FX) flows from the price.
## Existing prices vs inline `priceData` [#existing-prices-vs-inline-pricedata]
A line item references a price one of two ways. Provide exactly one. Sending both, or neither, is rejected.
| Field | When to use |
| ----------- | ------------------------------------------------------------------------------------------------------------------------- |
| `price` | The Price's `price_*` ID. Use when the SKU exists in your catalog. |
| `priceData` | Ad-hoc product and price created on the fly for this session. Use for one-off invoices, custom orders, donation pages. |
```jsonc
// Catalog reference
{ "price": "price_test_AbC123", "quantity": 1 }
// Inline ad-hoc price
{
"priceData": {
"currency": "EGP",
"unitAmount": 149900,
"productData": { "name": "Custom order #4231" }
},
"quantity": 1
}
```
Inline prices are stored as one-off rows attached to the session. They don't enter your product catalog and don't show up in dashboard product lists. If the same item shows up across many sessions, create a real product and price once and reference it by ID. You'll get inventory tracking, analytics, and faster session creation.
`priceData.unitAmount` is in minor units. `149900` means 1,499.00 EGP. The full `priceData` shape:
| Field | Required | Notes |
| ------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------- |
| `currency` | yes | ISO 4217. Must match the session's customer-facing currency (see [Currency](#currency-and-multi-currency) below). |
| `unitAmount` | yes | Per-unit amount in minor units. Multiplied by `quantity` to produce the line subtotal. |
| `productData.name` | yes | Display name shown in the order summary on the hosted page. |
| `productData.description` | no | Subline shown beneath the product name. |
| `productData.image` | no | Public image URL. Fetched and stored server-side. |
| `productData.unitLabel` | no | Custom label for "qty 2" displays (e.g. "seat", "license"). |
| `productData.metadata` | no | Your own key-value bag, opaque to XPay. |
## Quantity and adjustable quantity [#quantity-and-adjustable-quantity]
A line item's role is derived from two fields: its `quantity` and its `adjustableQuantity`. There's no separate type flag. The combination decides whether the customer must buy the line, can remove it, or starts without it.
| Role | Starting `quantity` | `adjustableQuantity` | What the customer sees |
| ------------------- | ------------------- | -------------------------------------- | ---------------------------------------------------------------------------------- |
| **Required** | 1 or more | Off, or on with `minimum` of 1 or more | A fixed line they can't remove. A stepper appears only when adjustable. |
| **Removable** | 1 or more | On, `minimum` 0 | Starts in the order. They can lower it to 0 to drop it. |
| **Optional add-on** | 0 | On, `minimum` 0 | Starts outside the order, listed under "Add to your order" with an **Add** button. |
The common case is a fixed line: set `quantity` and leave `adjustableQuantity` off.
```jsonc
{ "price": "price_test_AbC123", "quantity": 1 }
```
To let the customer change the count at checkout, turn on `adjustableQuantity`:
```jsonc
{
"price": "price_test_AbC123",
"quantity": 1,
"adjustableQuantity": {
"enabled": true,
"minimum": 1,
"maximum": 10,
},
}
```
When `adjustableQuantity.enabled` is `true`, the hosted page renders a `−` / `+` stepper next to that line. The stepper respects `minimum` (default 1) and `maximum` (default 99). It also respects the price's remaining `stock`: if the price has finite stock, the stepper caps at whatever's left, and a "Only N left" hint appears below the stepper when stock is at or under 10.
### Removable and optional items [#removable-and-optional-items]
Set `adjustableQuantity.minimum` to `0` to let the customer take the line all the way out of the order.
* **Removable.** Start `quantity` at 1 or more with `minimum: 0`. The line sits in the order from the start, and at quantity 1 the `−` button becomes a remove button that drops it to 0.
* **Optional add-on.** Start `quantity` at `0` with `minimum: 0`. The line starts outside the order, listed under an "Add to your order" section. The customer brings it in at quantity 1 with the **Add** button.
```jsonc
// Optional add-on: starts out of the cart, customer can add up to 3
{
"price": "price_test_giftwrap",
"quantity": 0,
"adjustableQuantity": { "enabled": true, "minimum": 0, "maximum": 3 },
}
```
A `quantity` of `0` is valid only on a line where `adjustableQuantity.enabled` is `true` and `minimum` is `0`. Any other line sent with `quantity: 0` is rejected at create time.
You can make every line optional. The session is created with a total of 0 and an empty order, and the customer adds at least one item before paying. The hosted page keeps the pay button disabled while the order is empty, and a payment attempt with nothing in the order is rejected with `checkout_empty_cart`.
The hosted page handles every quantity change end-to-end: the totals update on the page and the session updates on the server. You don't write any code for this path. To change line items from your own backend, use `PATCH /checkout/sessions/:id` (full `lineItems` replacement, see [Overview → What's mutable](/integrate/checkout-session/overview#what-s-mutable)).
## Currency and multi-currency [#currency-and-multi-currency]
Every Price has a native currency. Within a single session, every line item must share that currency: mixing EGP and USD lines on the same session is rejected at create time.
XPay processes settlements in EGP today. When the price is in EGP, that single currency runs end to end. When the price is in another currency (the **presentment currency**), the session locks an exchange rate at creation and persists a parallel "what the customer sees" view alongside the EGP processing values.
Concretely:
* The session and each line item carry **two amount tracks** in the response: the processing track (`amountSubtotal`, `amountTotal`, in EGP minor units) and a presentment-mirror sidecar (`presentmentDetails.amountSubtotal`, `presentmentDetails.amountTotal`, in the customer's currency).
* The exchange rate is **locked at session creation**. Quantity changes, discounts, and updates all reuse it. The customer never sees the rate move during checkout.
* The hosted checkout app reads the presentment values when they exist and renders the entire page in one currency. Your server reads the processing values for ledger and reconciliation.
You don't need to do FX math on either side. The session's response carries every value already projected.
## Price availability [#price-availability]
Every price has lifecycle fields that gate whether a line item can run at checkout. The same gate applies at session creation, on every quantity update, and again when the customer submits payment. A line item that flips to unavailable mid-session disables the pay button on the hosted page and surfaces a per-line badge.
| State | Cause | What the customer sees |
| ----------------------- | -------------------------------------------------------------------------------- | --------------------------------- |
| `archived` | The price was archived in the dashboard (`active: false`). | "Unavailable" badge, line dimmed. |
| `scheduled` | The price has a `startDate` in the future. | "Not yet available" badge. |
| `expired` | The price has an `expirationDate` that has passed. | "Expired" badge. |
| `sold_out` | Finite `stock` is less than the requested quantity. | "Sold out" badge. |
| `recurring_unsupported` | The price's `type` is `RECURRING`. Recurring prices aren't accepted in checkout. | "Unsupported price type" badge. |
| `available` | None of the above. | Normal render. |
Stock is decremented atomically when the payment succeeds, not when the session is created. A session that opens with stock available can sell out by the time the customer pays. The gate at submit time catches that race and rejects the payment with a `price_sold_out` error rather than overselling.
`stock` of `null` (the default) means unlimited. Inline `priceData` lines never carry a stock cap.
An optional line sitting at `quantity` 0 isn't being purchased, so the availability gate skips it: a sold-out add-on never blocks payment for the rest of the order. It's only gated once the customer adds it and the quantity goes above 0.
## Custom-amount lines [#custom-amount-lines]
When a price has `type: CUSTOM`, the customer enters the amount themselves at checkout. Use this for donations, "pay what you want" pricing, or invoices where the merchant only knows the amount in the dashboard a moment before sending the link.
A custom-amount price carries a `customUnitAmount` configuration:
```jsonc
{
"id": "price_test_donate",
"type": "CUSTOM",
"currency": "EGP",
"customUnitAmount": {
"minimum": 5000, // 50.00 EGP
"maximum": 10000000, // 100,000.00 EGP
"preset": 50000, // 500.00 EGP shown by default
},
}
```
A line item that resolves to a CUSTOM price seeds the session amount from `customUnitAmount.preset` (or 0 if no preset is set). The hosted page renders a large amount with a **Change amount** button that opens an input field. When the customer commits, the hosted page validates the entered value against the bounds and the session totals refresh in place.
CUSTOM lines come with a few hard rules:
* **At most one CUSTOM line per session,** and it must be the **only** line item. Mixing it with other items is rejected.
* **`quantity` is forced to 1.** Adjustable quantity is not allowed.
* **Discounts and promotion codes are not supported** on sessions that contain a CUSTOM line. The whole point of the line is that the customer chose the amount; layering a coupon on top breaks the contract.
* **Inline `priceData` cannot be CUSTOM.** Only existing `price_*` references created in the dashboard or via the Prices API can resolve to CUSTOM. The bounds (`customUnitAmount`) are configured on the Price, not on the line item.
* **`uiMode: "custom"` (Elements SDK) is incompatible.** Build the session with a fixed `unitAmount` instead, or use `uiMode: "hosted"` / `"embedded"`, both of which use the hosted checkout's amount-entry UI.
## What's on each line item in the response [#whats-on-each-line-item-in-the-response]
Every line item in the Checkout Session response carries the same fields:
| Field | What it is |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | Line item ID (`li_*`). |
| `quantity` | Resolved quantity. `1` for CUSTOM lines; `0` for an optional add-on the customer hasn't added; otherwise the line's quantity, either the merchant's fixed value or whatever the customer set via the stepper on an adjustable line. |
| `price` | The full Price object, including its product, type (`ONE_TIME` / `CUSTOM`), `customUnitAmount` (if any), and lifecycle fields (`active`, `stock`, `startDate`, `expirationDate`). |
| `adjustableQuantity` | Echoed back when set on creation. Drives the hosted page's stepper rendering. |
| `amountSubtotal` | `unitAmount × quantity`, in the session's processing currency. |
| `amountTotal` | After any discount or tax allocated to this line. |
| `amountDiscount` | Allocated discount for this line. `0` when no discount applies. Sum across lines equals the session's discount. |
| `amountTax` | Allocated tax for this line. `0` when no tax applies. |
| `currency` | Currency of the `amount*` fields above. On a Checkout Session this is the processing currency. |
| `presentmentDetails` | Sidecar with `unitAmount`, `amountSubtotal`, `amountDiscount`, `amountTotal`, and `currency` in the customer's currency. Present only when the session is multi-currency. |
Optional items the customer never adds aren't part of a completed order. Once payment succeeds, the completed `GET /checkout/sessions/:id` and the `checkout.session.completed` event carry only the lines the customer bought. A line left at `quantity` 0 is dropped from both.
## Where to next [#where-to-next]
Decide who the customer is, what fields the form collects, and how the customer record is
treated.
Configure where the customer lands after payment.
Branding, locale, payment methods, fees, and metadata.
# Overview (/en/integrate/checkout-session/overview)
The Checkout Session is the central object every integration creates. The same shape comes back from POST, GET, the SDK client, and every checkout.session webhook.
A Checkout Session represents one customer's attempt to pay you. Your server creates it, XPay returns the session object, and from there every integration pattern is just a different way to render the same object: a hosted page, a payment link, an SDK drop-in, or fully custom Elements.
The session object you get back from `POST /checkout/sessions`, the one you `GET` later, and the `data.object` carried on every `checkout.session` webhook are all the same shape. There is one DTO and one mapper behind all three. If you build against the create response, your webhook handler reads the same fields by the same names.
For how Checkout Session relates to Payment Intent, Charge, Refund, Customer, and Balance Transaction, see [Object model](/integrate/object-model).
## How it fits [#how-it-fits]
Pick the integration pattern that matches how much UI control you want. Each pattern guide spells out the build, the test flow, the webhook handler, and the production checklist for that surface. They all create a Checkout Session the same way.
No-code. Share a `https://checkout.xpay.app/p/plink_...` URL. A session is created on the
customer's first interaction.
Server-only. Create a session, redirect the customer to `session.url`, confirm with a webhook.
Server plus a few lines of frontend. Open the checkout in a modal or inline iframe on your
domain.
Server plus custom UI. Build the form yourself with `` and `confirmPayment()`.
## Lifecycle [#lifecycle]
A session moves through two short state machines: `status` for the session itself and `paymentStatus` for the money.
### `status` [#status]
| State | When it's set | What you do |
| ---------- | ---------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- |
| `open` | Set at creation. The session accepts mutations and is renderable in the checkout UI. | Wait for the customer to pay, or for the session to expire. |
| `complete` | Set when payment succeeds. The session is locked: no more mutations, no more payment attempts. | Fulfill the order against `checkout.session.completed`. |
| `expired` | Set when `expiresAt` passes, or when you call `POST /checkout/sessions/:id/expire`. The session can't be reused. | Create a new session if the customer wants to try again. |
### `paymentStatus` [#paymentstatus]
| State | When it's set |
| --------------------- | -------------------------------------------------------------------------- |
| `unpaid` | Default. No successful charge has occurred. |
| `paid` | A charge has succeeded for the session's full amount. |
| `no_payment_required` | The session was created in `setup` mode (collecting payment details only). |
The `paymentIntent` field on the response is `null` until the customer submits the form for the first time. After that, it carries the full Payment Intent shape, identical to what `GET /payment-intents/:id` returns. Charges and Refunds are nested inside the Payment Intent.
## Creating a session [#creating-a-session]
The only required field on `POST /checkout/sessions` is `afterCompletion`. Everything else has a default. In practice you'll also send `lineItems` so the customer sees what's being charged. Lines can be fixed, adjustable, or optional add-ons the customer opts into, and an all-optional session is valid: it opens at a total of 0 and the customer adds items before paying. See [Line items & pricing](/integrate/checkout-session/line-items-and-pricing).
```bash
curl -X POST https://api.xpay.app/checkout/sessions \
-H "Authorization: Bearer sk_test_..." \
-H "Content-Type: application/json" \
-d '{
"afterCompletion": {
"type": "redirect",
"redirect": { "url": "https://yourshop.example/order/{CHECKOUT_SESSION_ID}" }
},
"lineItems": [
{
"priceData": {
"currency": "EGP",
"unitAmount": 149900,
"productData": { "name": "Test product" }
},
"quantity": 1
}
]
}'
```
```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({
afterCompletion: {
type: "redirect",
redirect: { url: "https://yourshop.example/order/{CHECKOUT_SESSION_ID}" },
},
lineItems: [
{
priceData: {
currency: "EGP",
unitAmount: 149900, // 1,499.00 EGP, in minor units
productData: { name: "Test product" },
},
quantity: 1,
},
],
}),
});
const session = await res.json();
```
```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={
"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()
```
The response carries the full session, including the two credentials your integration will use next:
```json
{
"id": "cs_test_AbC123...",
"object": "checkout.session",
"status": "open",
"paymentStatus": "unpaid",
"url": "https://checkout.xpay.app/c/cs_test_AbC123...",
"clientSecret": "cs_test_AbC123..._secret_xyz",
"amountTotal": 149900,
"currency": "EGP",
"afterCompletion": { "type": "redirect", "redirect": { "url": "..." } },
"paymentIntent": null,
"livemode": false
}
```
Use `url` to redirect the customer (Hosted Checkout, Payment Links). Use `clientSecret` to mount the SDK (Drop-in, Elements). The literal `{CHECKOUT_SESSION_ID}` token in `redirect.url` is replaced server-side with the session's `id` before the URL is stored, so your return page can read it off the path without you threading it through query parameters.
The full set of fields you can configure is grouped by purpose. Each group has its own page with the field-by-field detail.
Existing prices vs inline `priceData`, quantities, adjustable quantities, custom-amount lines,
currency.
`customerId` vs `customerDetails`, `customerCreation`, `customerUpdate`, name, phone, address,
shipping, custom fields.
`redirect` vs `hosted_confirmation`, `cancelUrl`, return-button URL, custom message.
Branding, locale, payment-method types, fees and VAT pass-through, expiration, metadata.
## Modes [#modes]
Two enums on the session decide what kind of payment it represents and how the customer interacts with it. Both are set at creation and immutable for the rest of the session's life.
### `mode` [#mode]
`mode` is the kind of money movement. It defaults to `payment`.
| Value | Meaning |
| -------------- | ------------------------------------------------------------------------------------------------ |
| `payment` | One-time charge. The default for nearly every integration. |
| `subscription` | Recurring billing. Not yet supported on the checkout API. |
| `setup` | Collect a payment method without charging it. `paymentStatus` resolves to `no_payment_required`. |
### `uiMode` [#uimode]
`uiMode` is how the customer reaches the form. It defaults to `hosted`. The choice is tied to the integration pattern you picked above; the session's behavior, the SDK call you make, and the fields you can set all change with it.
| Value | Where the form runs | Use it with |
| ---------- | ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `hosted` | XPay's hosted page at `https://checkout.xpay.app/c/cs_test_...`. Server returns a `url`. | [Hosted Checkout](/integrate/integration-patterns/hosted-checkout), [Payment Links](/integrate/integration-patterns/payment-links). |
| `embedded` | An iframe on your site, opened by the SDK as a modal or inline. | [Drop-in](/integrate/integration-patterns/drop-in). |
| `custom` | Your own form, using the Elements SDK and `confirmPayment()`. | [Elements](/integrate/integration-patterns/elements). |
A few rules fall out of this:
* `cancelUrl` is only valid when `uiMode: "hosted"`. It's the redirect XPay sends the customer to if a payment attempt fails on the hosted page (declined, 3DS rejected, local-method timeout). It is not a customer-cancel button: the hosted page does not have one.
* **`afterCompletion.type` must be `"redirect"` for `embedded` and `custom`.** `hosted_confirmation` renders an XPay-hosted page, and these integrations run on your own site. See [After completion](/integrate/checkout-session/after-completion#sdk-integrations-need-a-return-url).
* `uiMode: "custom"` means your code owns customer collection. The session rejects `nameCollection`, `phoneNumberCollection`, `billingAddressCollection`, `shippingAddressCollection`, and `submitType` in this mode.
* For `embedded` and `custom`, you authenticate the SDK with the session's `clientSecret`. The publishable key alone is not enough; the secret scopes the SDK to one specific session.
## Mutually exclusive fields [#mutually-exclusive-fields]
A handful of pairs on the create body are exclusive. The API rejects requests that send both, and the same rules apply on `PATCH`.
| Either | Or | Why |
| -------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `customerId` | `customerDetails` | An existing Customer's record is the source of prefill, or you provide raw details. Not both. |
| `customerId` | `customerCreation: "always"` | "Always create a new customer" contradicts "use this existing customer." |
| `paymentMethodTypes` | `paymentMethodConfigurationId` | Pick a literal list of methods, or reference a saved configuration. |
| `lineItem.price` | `lineItem.priceData` | Each line item references an existing Price by id, or it inlines product and price data. |
| A `CUSTOM`-type line | `allowPromotionCodes`, `discounts` | Custom-amount lines (customer enters the amount) are not compatible with discounts. |
`customerUpdate` (which controls whether checkout writes collected data back onto the customer) is only meaningful when `customerId` is provided. Sending it without `customerId` is a validation error.
## What's mutable [#whats-mutable]
`PATCH /checkout/sessions/:id` updates an open session. It accepts every field on the create body except the ones below, which are locked at creation:
* `mode`
* `uiMode`
* `submitType`
* `currency`
* `expiresAfterMinutes`
`lineItems` on `PATCH` is a full replacement: the new array overwrites the old one. To change a single item's quantity, send the entire desired array. Sessions in `complete` or `expired` status are read-only and reject `PATCH`.
## Expiration [#expiration]
Every session has an `expiresAt`. Default is 24 hours after creation; minimum is 30 minutes. Set `expiresAfterMinutes` on the create body to change it.
Two things mark a session as `expired`:
* `expiresAt` passes. The session is no longer usable for payment; the hosted page renders a terminal "you're all done here" state.
* You explicitly call `POST /checkout/sessions/:id/expire`. Useful when you want to stop accepting payment on a session you no longer intend to honor, or to free up redemption slots on a one-shot promotion code.
Both paths emit a `checkout.session.expired` webhook.
## Webhooks [#webhooks]
Two events are emitted for the session's own lifecycle. The `data.object` on each one is a full Checkout Session, identical in shape to what `GET /checkout/sessions/:id` returns.
| Event | When |
| ---------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `checkout.session.completed` | Payment succeeded. `status` is `complete`, `paymentStatus` is `paid` (or `no_payment_required` for `setup` mode). |
| `checkout.session.expired` | The session expired by time or by an explicit `/expire` call. |
A successful payment on Hosted Checkout emits four events. They are dispatched in this sequence:
1. `payment_intent.created`
2. `charge.succeeded`
3. `payment_intent.succeeded`
4. `checkout.session.completed`
Webhook delivery is asynchronous and runs with retries, so **do not rely on receipt order** in your handler. Key fulfillment off `checkout.session.completed` and treat the earlier events as supplementary context. The earlier ones are useful when you need to react to the underlying Payment Intent or Charge directly. See the [Event reference](/integrate/webhooks/event-reference) for the full list.
## Where to next [#where-to-next]
How Checkout Session relates to Payment Intent, Charge, Customer, and Refund. The IDs to keep on
your order record.
Build the cart: existing prices, inline `priceData`, quantities, custom amounts.
Decide how XPay treats the customer and which fields the form collects.
Configure where the customer lands after a successful payment.
Receive `checkout.session.completed` and verify the signature.
# API error codes (/en/integrate/errors/api-error-codes)
Every code returned in `error.code` on a failed API call, grouped by domain. Each entry's URL is the value of `error.doc_url`.
This page is the destination of every `error.doc_url` you receive on a failed API call. Anchors here match the `code` value, so `https://docs.xpay.app/integrate/errors/api-error-codes#parameter_missing` lands directly on the row below.
Each code is paired with the `error.type` it ships with. Branch on `type` first; use `code` for narrow special-cases. For the envelope and the handler shape, see [API errors](/integrate/errors/api-errors).
## Generic request [#generic-request]
`error.type`: `invalid_request_error`. The request was rejected because of something on your side. Don't retry; fix the request.
### `invalid_request` [#invalid_request]
The request was rejected and no more specific code applied. Read `error.message` for detail.
### `parameter_missing` [#parameter_missing]
A required parameter was missing from the request body. `error.param` names the field.
### `parameter_invalid` [#parameter_invalid]
A parameter has the wrong type or format. `error.param` names the field.
### `parameter_out_of_range` [#parameter_out_of_range]
A parameter value is outside the allowed range (for example a negative `quantity` or an `amount` exceeding the maximum).
### `parameter_unknown` [#parameter_unknown]
Your request included a parameter XPay doesn't recognize. Usually a typo or a stale field. Remove the unknown parameter.
### `parameters_exclusive` [#parameters_exclusive]
You sent two parameters that can't be used together (for example `customerId` and `customerDetails` on a Checkout Session). Pick one.
### `parameter_requires_another` [#parameter_requires_another]
You sent a parameter that requires another to also be present (for example `customerUpdate` requires `customerId`).
### `validation_error` [#validation_error]
A generic validation failure that didn't match any of the more specific codes above. Read `error.message`.
### `resource_missing` [#resource_missing]
The resource you referenced doesn't exist. Returned with HTTP 404. Check the ID and that you're using the right key (test vs live).
### `resource_invalid_state` [#resource_invalid_state]
The resource exists but isn't in a state that allows this operation (for example trying to expire an already-completed Checkout Session, or refund a charge that was never captured).
## Authentication [#authentication]
`error.type`: `authentication_error`. HTTP 401. Don't retry; fix the credential or the header.
### `authentication_required` [#authentication_required]
No API key was provided. The `Authorization` header is missing.
### `invalid_api_key` [#invalid_api_key]
The API key didn't match any known key. Common causes: typo, leading/trailing whitespace, using a key from a different account.
### `api_key_inactive` [#api_key_inactive]
The API key was found but is currently disabled. Re-enable it in **Developers → API keys** in the dashboard, or use a different key.
### `invalid_signature` [#invalid_signature]
For endpoints that require an HMAC signature, the signature in the request didn't match the expected value. Check the signing secret and that you're signing the canonical body.
## Authorization [#authorization]
`error.type`: `invalid_request_error`. HTTP 403.
### `merchant_not_activated` [#merchant_not_activated]
Your account hasn't completed live-mode activation. Test-mode calls work as expected; live-mode calls return this until you finish onboarding.
### `permission_denied` [#permission_denied]
The API key or session you're using lacks permission for this action. Check the key's scoped permissions in **Developers → API keys**.
### `two_factor_required` [#two_factor_required]
A live-mode action that needs a recent two-factor check, on a session that hasn't done one in the last 4 hours. Dashboard sessions only; API keys never see this. The dashboard handles it for you by asking for your authenticator code and retrying.
## Sessions [#sessions]
`error.type`: `invalid_request_error`.
### `checkout_session_expired` [#checkout_session_expired]
The Checkout Session is past its `expiresAt`. Create a new session if the customer still wants to pay.
### `invalid_client_secret` [#invalid_client_secret]
The `clientSecret` doesn't match the session it was paired with. Most often this means you pasted a stale secret or paired it with the wrong session ID.
### `creation_failed` [#creation_failed]
Session creation failed for an unexpected reason. Retry the request; if it persists, quote the `request_id` to support.
## Payment links [#payment-links]
`error.type`: `invalid_request_error`.
### `payment_link_inactive` [#payment_link_inactive]
The Payment Link is deactivated and won't accept new payments. Reactivate it in the dashboard or create a new link.
### `payment_link_expired` [#payment_link_expired]
The Payment Link is past its expiration date.
## Amount and currency [#amount-and-currency]
`error.type`: `invalid_request_error`.
### `amount_invalid` [#amount_invalid]
The `amount` is not a valid value (negative, zero where positive is required, or exceeds the per-call maximum).
### `currency_invalid` [#currency_invalid]
The currency code is not recognized. Use a 3-letter ISO 4217 code in lowercase (e.g. `egp`, `usd`).
## Prices and line items [#prices-and-line-items]
`error.type`: `invalid_request_error`. Returned when a line item references a Price that can't be used, at Checkout Session create or at Payment Link create or update.
### `product_archived` [#product_archived]
The Price's parent Product is archived. Archiving a Product cascades `active: false` to all of its Prices in one transaction, so an archived Product's Prices are unusable until the merchant un-archives the Product. Unarchive is one-way: re-activating the Product does NOT automatically re-activate its Prices. Each Price must be un-archived explicitly.
Fires from:
* `PATCH /prices/:id` with `{ "active": true }`: you tried to re-activate a Price whose Product is still archived. Un-archive the Product first, then the Price.
* `POST /payment-links` / `PATCH /payment-links/:id`: a referenced Price belongs to an archived Product. Use a Price under an active Product.
### `price_inactive` [#price_inactive]
The Price is archived (`active: false`). This happens either when the merchant archived the Price directly, or when the Price was cascade-archived as part of its parent Product being archived. Un-archive the Price (or its Product first, if the Product is also archived), or use a different Price.
### `price_not_yet_active` [#price_not_yet_active]
The Price has a future `startDate` and isn't yet active.
### `price_expired` [#price_expired]
The Price is past its `expirationDate`.
### `price_sold_out` [#price_sold_out]
The Price has finite stock and ran out before the customer paid. Remove the line, lower the requested quantity, or restock.
### `price_recurring_not_supported` [#price_recurring_not_supported]
The Price is `RECURRING` type, which isn't accepted in checkout today. You'll also get this when creating or updating a payment link that references one. Use a `ONE_TIME` price.
### `price_immutable_while_used` [#price_immutable_while_used]
You tried to change a field on a Price that's been used in at least one line item. Currency, amount, type, and `recurring` settings are locked once a price is in use.
### `price_date_range_invalid` [#price_date_range_invalid]
You tried to save a Price whose `startDate` is on or after its `expirationDate`.
### `price_stock_invalid` [#price_stock_invalid]
You tried to save a Price with a negative `stock` value.
### `line_item_missing_price` [#line_item_missing_price]
A line item on a Checkout Session was sent without a `price` reference or a `priceData` block. Provide one.
### `checkout_empty_cart` [#checkout_empty_cart]
Every line on the Checkout Session is at quantity 0, so there's nothing to pay for. This happens on an all-optional order when the customer tries to pay before adding an item. Add at least one item to the order, then submit payment again.
## Promotions and coupons [#promotions-and-coupons]
`error.type`: `invalid_request_error`.
### `promotion_codes_not_allowed` [#promotion_codes_not_allowed]
The customer entered a promotion code on a session where `allowPromotionCodes` is `false`.
### `promotion_code_not_found` [#promotion_code_not_found]
The promotion code string doesn't match any code on this account.
### `promotion_code_inactive` [#promotion_code_inactive]
The promotion code is deactivated.
### `promotion_code_expired` [#promotion_code_expired]
The promotion code is past its expiry date.
### `promotion_code_max_redemptions` [#promotion_code_max_redemptions]
The promotion code has reached its global redemption limit.
### `promotion_code_customer_mismatch` [#promotion_code_customer_mismatch]
The promotion code is restricted to a different customer than the one on this Checkout Session.
### `promotion_code_minimum_amount` [#promotion_code_minimum_amount]
The session amount doesn't meet the promotion's minimum.
### `promotion_code_first_time_only` [#promotion_code_first_time_only]
The promotion code is for first-time customers only, and this customer has paid before.
### `promotion_code_exists` [#promotion_code_exists]
You tried to create a promotion code with a code string that already exists. Use a different string.
### `coupon_invalid` [#coupon_invalid]
The coupon is invalid, deleted, or expired.
### `coupon_currency_mismatch` [#coupon_currency_mismatch]
The coupon's currency doesn't match the session's currency.
### `coupon_minimum_amount` [#coupon_minimum_amount]
The session subtotal doesn't meet the coupon's minimum.
### `coupon_customer_max_redemptions` [#coupon_customer_max_redemptions]
The customer has reached this coupon's per-customer redemption limit.
### `coupon_in_use` [#coupon_in_use]
You tried to delete a coupon that's still attached to an active promotion code or session.
### `too_many_discounts` [#too_many_discounts]
A Checkout Session can carry at most one discount; you tried to add a second.
## Payment methods [#payment-methods]
`error.type`: `invalid_request_error`.
### `payment_method_corrupted` [#payment_method_corrupted]
The stored payment method data can't be read. Have the customer re-enter their details.
### `payment_method_customer_mismatch` [#payment_method_customer_mismatch]
The payment method belongs to a different customer than the one on the request.
### `payment_intent_customer_mismatch` [#payment_intent_customer_mismatch]
The Payment Intent's customer doesn't match the customer you're trying to act on.
### `payment_method_mismatch` [#payment_method_mismatch]
The payment method type the customer chose isn't included in the session's allowed `paymentMethodTypes`.
## Charges and refunds [#charges-and-refunds]
`error.type`: `invalid_request_error`. Returned by `POST /refunds` and related charge operations.
### `charge_not_captured` [#charge_not_captured]
You tried to refund a charge that hasn't been captured.
### `merchant_no_balance` [#merchant_no_balance]
The merchant doesn't have a balance record. Quote the `request_id` to support.
### `insufficient_balance` [#insufficient_balance]
The refund would push the available balance negative.
### `charge_missing_balance_transaction` [#charge_missing_balance_transaction]
The charge is missing its underlying balance transaction. Quote the `request_id` to support.
### `charge_missing_fee_data` [#charge_missing_fee_data]
The charge is missing the fee data needed to compute the refund. Quote the `request_id` to support.
### `charge_incomplete_fee_data` [#charge_incomplete_fee_data]
The charge has fee data but it's incomplete. Quote the `request_id` to support.
## Payment method configurations [#payment-method-configurations]
`error.type`: `invalid_request_error`.
### `cannot_rename_default` [#cannot_rename_default]
You tried to rename the default payment-method configuration. Names of the default cannot be changed.
### `must_have_enabled_method` [#must_have_enabled_method]
A configuration must have at least one enabled payment method.
### `cannot_delete_default` [#cannot_delete_default]
You tried to delete the default payment-method configuration. Make a different configuration the default first.
### `configuration_in_use` [#configuration_in_use]
You tried to delete a configuration that's referenced by one or more active Checkout Sessions.
## Currency [#currency]
`error.type`: `invalid_request_error`.
### `unsupported_currency` [#unsupported_currency]
The currency isn't supported for this merchant or this operation.
### `exchange_rate_not_found` [#exchange_rate_not_found]
No exchange rate is configured for this currency pair. Multi-currency presentment requires the rate to be available before session creation.
## Rate limiting [#rate-limiting]
`error.type`: `rate_limit_error`. HTTP 429.
### `rate_limit` [#rate_limit]
Your account exceeded the request rate limit. Back off and retry with exponential delay.
## Idempotency [#idempotency]
`error.type`: `idempotency_error`. HTTP 400 or 409. For the full retry contract, see [Idempotency](/integrate/idempotency).
### `idempotency_key_in_use` [#idempotency_key_in_use]
Two cases share this code, told apart by the HTTP status:
* **`400`**: you reused an `Idempotency-Key` with a different request (a different method, path, query, or body). A key is bound to the first request it was used with. Reuse the original request, or generate a fresh key.
* **`409`**: a request with this key is still in progress. Wait the number of seconds in the `Retry-After` header, then retry. You'll get the original request's result.
## System [#system]
`error.type`: `api_error`. HTTP 500.
### `internal_error` [#internal_error]
An unexpected server-side error. Retry with backoff. If it persists, quote the `request_id` to support.
## Where to next [#where-to-next]
The envelope, the three error types, and the handler pattern.
The other surface: when a customer's card fails, the error sits on `lastPaymentError`, not in an
HTTP response.
The reference for `lastPaymentError.code`. Card validation, processing, fraud, and processor
codes.
Back to the top. The two-surfaces map.
# API errors (/en/integrate/errors/api-errors)
The error envelope returned by every failed API call. Three error types, the fields they carry, and the handler pattern.
This page covers the **synchronous** errors XPay returns: when your server makes an API call and gets a non-2xx response, what's in the body, and how to handle it.
For asynchronous payment failures (a card decline that lands on a Payment Intent's `lastPaymentError`), that's a different surface entirely. See [Payment errors](/integrate/errors/payment-errors).
## The envelope [#the-envelope]
Every API error response has the same shape:
```json
{
"error": {
"type": "invalid_request_error",
"code": "parameter_missing",
"message": "Missing required parameter: lineItems",
"param": "lineItems",
"doc_url": "https://docs.xpay.app/integrate/errors/api-error-codes#parameter_missing"
},
"request_id": "req_3STkwmFGhGHoO0IX13BRo5iU"
}
```
| Field | Type | Meaning |
| --------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `error.type` | string | One of three categories below. Branch on this first. |
| `error.code` | string \| null | The specific error identifier. Stable across messages and translations. Use this, not the message, for control flow. |
| `error.message` | string | Human-readable English. For your logs and your team's UI. Don't surface verbatim to your customers. |
| `error.param` | string \| null | The request body field that failed (e.g. `lineItems[0].price`, `customerDetails.email`). Present on validation errors. |
| `error.doc_url` | string \| null | Deep link to this code's row in the [API error codes](/integrate/errors/api-error-codes) reference. |
| `request_id` | string | The `req_*` ID for the failing request. Always log this. Use it to find the request in [Workbench → Logs](/integrate/workbench/logs-panel). |
`code`, `param`, and `doc_url` can be absent. The other three are always present.
If you can only afford one branch in your handler, branch on `type`, not on `code` or HTTP status.
The type list is stable across releases; the code list grows over time as new error reasons are
added.
## The three types [#the-three-types]
| Type | HTTP status | What it means | Typical handler |
| ----------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `invalid_request_error` | 400, 404, 409 | The request was rejected because of something on your side: missing or malformed field, resource not found, conflicting state. | Don't retry. Fix the request. Show the message to whoever caused it (your team, your form). |
| `authentication_error` | 401 | The API key or HMAC signature is missing, invalid, inactive, or wrong-format. | Don't retry. Check `XPAY_SECRET_KEY` in your env, the active state of the key in the dashboard, and that you're sending `Authorization: Bearer `. |
| `api_error` | 500 | An unexpected server-side problem on XPay's side. | Retry with exponential backoff. If it persists, quote `request_id` to support. |
When you retry a write (a `POST` or `PATCH`), send an `Idempotency-Key` so the retry returns the original result instead of repeating the operation. See [Idempotency](/integrate/idempotency).
## Handling pattern [#handling-pattern]
The handler is a single `try / catch`. Inside the catch, parse the response, branch on `error.type`, narrow on `error.code` for the small number of codes you care about, log everything, surface what's appropriate.
```typescript
async function callXPay(path: string, body: unknown): Promise {
const res = await fetch(`https://api.xpay.app${path}`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.XPAY_SECRET_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (res.ok) return res.json() as Promise;
// Read the canonical envelope. Every XPay error response has `error.type`.
const payload = await res.json().catch(() => null);
const xpayError = payload?.error;
const requestId = payload?.request_id;
if (!xpayError?.type) {
// Transport-level failure: not XPay-shaped. Treat as a client-side bug.
throw new Error(`Non-XPay response: ${res.status}`);
}
// Always log first, branch second.
log.error("xpay api error", {
requestId,
type: xpayError.type,
code: xpayError.code,
param: xpayError.param,
message: xpayError.message,
docUrl: xpayError.doc_url,
});
switch (xpayError.type) {
case "invalid_request_error":
// Your request is wrong. Don't retry. Bubble up to whoever sent it.
throw new BadRequest(xpayError);
case "authentication_error":
// Your secret key is wrong. Page yourself; don't retry.
throw new ConfigError(xpayError);
case "api_error":
// XPay is having a moment. Retry with backoff.
throw new RetryableError(xpayError);
default:
// Unknown type. Treat as retryable to be safe.
throw new RetryableError(xpayError);
}
}
```
```python
import os, requests
def call_xpay(path: str, body: dict) -> dict:
res = requests.post(
f"https://api.xpay.app{path}",
headers={
"Authorization": f"Bearer {os.environ['XPAY_SECRET_KEY']}",
"Content-Type": "application/json",
},
json=body,
timeout=30,
)
if res.ok:
return res.json()
payload = res.json() if res.content else {}
xpay_error = payload.get("error") or {}
request_id = payload.get("request_id")
if not xpay_error.get("type"):
# Transport-level failure: not XPay-shaped.
raise RuntimeError(f"Non-XPay response: {res.status_code}")
log.error(
"xpay api error",
extra={
"request_id": request_id,
"type": xpay_error.get("type"),
"code": xpay_error.get("code"),
"param": xpay_error.get("param"),
"message": xpay_error.get("message"),
"doc_url": xpay_error.get("doc_url"),
},
)
error_type = xpay_error["type"]
if error_type == "invalid_request_error":
raise BadRequest(xpay_error)
if error_type == "authentication_error":
raise ConfigError(xpay_error)
if error_type == "api_error":
raise RetryableError(xpay_error)
raise RetryableError(xpay_error) # unknown type, treat as retryable
```
## Narrowing on `code` [#narrowing-on-code]
`type` decides the broad branch. `code` decides the specific message you show. A handful of codes are worth special-casing in your UI; the rest can use `error.message` plus a "Learn more" link to `error.doc_url`.
```typescript
if (xpayError.type === "invalid_request_error") {
switch (xpayError.code) {
case "resource_missing":
// The ID you sent doesn't exist. Likely your code, not the user's.
return show("That record was deleted or never existed.");
case "checkout_session_expired":
// The session is past expiresAt. Create a new one.
return await createNewSession();
case "price_sold_out":
// Stock ran out between session creation and payment.
return show("Sorry, that item is sold out.");
case "merchant_not_activated":
// Your account isn't approved for live mode yet.
return show("Your account needs activation. Contact support.");
default:
// Generic invalid_request_error. Surface the message.
return show(xpayError.message);
}
}
```
For the full list of codes you might see, see [API error codes](/integrate/errors/api-error-codes).
## What not to do [#what-not-to-do]
* **Don't retry `invalid_request_error`.** The same request will fail the same way. Fix the input and try again, with a fresh `Idempotency-Key` if you're using one.
* **Don't retry `authentication_error` either.** A bad key is a bad key. Retrying ten times just produces ten log lines saying so.
* **Don't show `error.message` to your end customers verbatim.** It's English, technical, and may contain internal IDs. Use it for your logs and your support team's UI. Build customer-facing copy off `error.code`.
* **Don't ignore `error.code` and parse `error.message` instead.** The message text changes; the code is stable. Branching on the message is brittle.
* **Don't silently swallow errors.** Always log `request_id` next to every caught error. Without it, debugging in [Workbench → Logs](/integrate/workbench/logs-panel) becomes scrolling.
* **Don't assume the response is XPay-shaped.** A response without `error.type` is a transport-level failure (broken proxy, wrong content type, oversized payload). Treat it as a client-side bug to fix, not as a runtime branch.
## Where to next [#where-to-next]
The full list of `error.code` values, grouped by domain, with one-line descriptions.
The other surface: how to handle a customer's failed card payment via `lastPaymentError`.
Look up any failing call by `request_id` and see the full request, response, and error fields.
Back to the top of the Errors group. The two-surfaces map.
# Decline codes (/en/integrate/errors/decline-codes)
Issuer-level decline reasons returned in `lastPaymentError.declineCode`. Plus how to read the raw `networkDeclineCode` passthrough.
This page is the destination of every `lastPaymentError.docUrl` whose code came from `DeclineCode`. Anchors here match the `declineCode` value, so `https://docs.xpay.app/integrate/errors/decline-codes#insufficient_funds` lands directly on the row below.
The decline code is the **issuer's** reason for refusing the charge, normalized into XPay's vocabulary. It's set on card declines only; for non-card payment failures, `lastPaymentError.declineCode` is `null` and you read `code` instead. See [Payment error codes](/integrate/errors/payment-error-codes).
## Reading `networkDeclineCode` [#reading-networkdeclinecode]
Alongside `declineCode`, every card decline carries a `networkDeclineCode`: a 2-4 digit code returned by the card network (the literal code from the ISO 8583 message, ICCNetwork advice, or equivalent depending on the card brand).
Two things to know:
* **The meaning is brand-specific.** A `51` from a Visa means "insufficient funds" but the same code on a different network may map to something else entirely. Take the card's brand into account when interpreting the raw code.
* **It's a passthrough, not a normalization.** XPay does not translate `networkDeclineCode` into a customer-friendly message. Use it for support escalation or post-mortem analysis, not for runtime branching.
For runtime branching, use `declineCode` (XPay's normalized vocabulary, listed below) and `adviceCode` (the action token described in [Payment errors](/integrate/errors/payment-errors)). Treat `networkDeclineCode` as a breadcrumb for support tickets.
## Common declines [#common-declines]
The most frequent reasons issuers return.
### `generic_decline` [#generic_decline]
The issuer declined without a specific reason. Often a fallback when the issuer's risk model rejects but doesn't tell us why.
### `insufficient_funds` [#insufficient_funds]
The customer's available balance can't cover the charge.
### `lost_card` [#lost_card]
The card was reported lost. Don't retry; the issuer has flagged the card.
### `stolen_card` [#stolen_card]
The card was reported stolen. Don't retry.
### `expired_card` [#expired_card]
The card has expired. The customer needs to use a different card.
### `incorrect_cvc` [#incorrect_cvc]
The CVC/CVV the customer entered doesn't match what the issuer has on file.
### `incorrect_number` [#incorrect_number]
The card number passed format checks but the issuer says it's wrong.
### `incorrect_zip` [#incorrect_zip]
The postal code on the customer's billing address doesn't match what the issuer has on file.
### `incorrect_address` [#incorrect_address]
The street address didn't match. AVS check failed.
## Authentication [#authentication]
3D Secure and step-up authentication outcomes.
### `authentication_required` [#authentication_required]
The issuer is requiring 3D Secure authentication. The customer needs to complete the challenge.
### `authentication_not_handled` [#authentication_not_handled]
The customer skipped or didn't complete a required authentication step.
### `mobile_device_authentication_required` [#mobile_device_authentication_required]
The card requires the customer to re-authenticate on a mobile device (e.g. tap-to-pay biometric).
## Processing [#processing]
The issuer accepted the request but rejected at processing time.
### `processing_error` [#processing_error]
A generic processing error from the issuer's side. Often transient.
### `do_not_honor` [#do_not_honor]
The issuer returned "do not honor" without further detail. Usually means the customer should contact their bank.
### `call_issuer` [#call_issuer]
The issuer told the customer to contact them directly. Surface a clear "call your bank" prompt to the customer.
### `card_not_supported` [#card_not_supported]
The card doesn't support this type of purchase (for example, certain debit cards reject some merchant categories).
### `currency_not_supported` [#currency_not_supported]
The card's account doesn't support the transaction currency.
### `duplicate_transaction` [#duplicate_transaction]
The issuer detected a near-identical transaction recently and refused as a duplicate.
### `approve_with_id` [#approve_with_id]
A specific issuer response that effectively means "we'd approve this with extra ID." Usually safe to surface a "try again" prompt.
## Limits [#limits]
The card or account hit a limit.
### `card_velocity_exceeded` [#card_velocity_exceeded]
The card has been used too many times in a short window. The issuer is rate-limiting.
### `withdrawal_count_limit_exceeded` [#withdrawal_count_limit_exceeded]
The card hit its per-period withdrawal/transaction count limit.
## Fraud and risk [#fraud-and-risk]
The issuer or the network flagged the charge for risk.
### `fraudulent` [#fraudulent]
Suspected fraud. Don't retry.
### `merchant_blacklist` [#merchant_blacklist]
The card is blocked specifically for your merchant account.
### `pickup_card` [#pickup_card]
The issuer is asking that the card be physically picked up. Don't retry.
### `restricted_card` [#restricted_card]
The card has restrictions that prevent this transaction.
### `security_violation` [#security_violation]
A security check failed at the issuer or network level.
## Account issues [#account-issues]
The card account itself has a problem unrelated to this specific transaction.
### `invalid_account` [#invalid_account]
The card account is invalid as far as the issuer is concerned.
### `new_account_information_available` [#new_account_information_available]
The card has been reissued. The issuer is signaling that the customer has new card details.
### `transaction_not_allowed` [#transaction_not_allowed]
The issuer doesn't allow this kind of transaction on this card.
### `service_not_allowed` [#service_not_allowed]
The merchant or service isn't allowed for this card.
### `not_permitted` [#not_permitted]
A general "not permitted" response from the issuer.
## PIN [#pin]
PIN-related declines mostly surface for in-person or PIN-on-file transactions; rare in standard e-commerce flows but listed for completeness.
### `incorrect_pin` [#incorrect_pin]
The PIN entered didn't match.
### `invalid_pin` [#invalid_pin]
The PIN format is invalid.
### `pin_try_exceeded` [#pin_try_exceeded]
The cardholder has exceeded the maximum number of PIN attempts.
### `offline_pin_required` [#offline_pin_required]
The card requires offline PIN entry, which isn't available in this flow.
### `online_or_offline_pin_required` [#online_or_offline_pin_required]
The card requires PIN entry (online or offline) and didn't get one.
## Amount [#amount]
### `invalid_amount` [#invalid_amount]
The transaction amount itself was rejected by the issuer (often falls outside per-transaction limits configured on the card).
## Revocation [#revocation]
Customer-initiated stops on automatic charges.
### `revocation_of_authorization` [#revocation_of_authorization]
The cardholder has revoked authorization for this specific recurring charge.
### `revocation_of_all_authorizations` [#revocation_of_all_authorizations]
The cardholder has revoked authorization for all recurring charges from this merchant.
### `stop_payment_order` [#stop_payment_order]
The customer has issued a stop payment.
### `no_action_taken` [#no_action_taken]
The issuer didn't process the transaction at all. Usually a transient state; safe to retry.
### `reenter_transaction` [#reenter_transaction]
The issuer is asking the merchant to re-submit the transaction.
## Test mode [#test-mode]
### `testmode_decline` [#testmode_decline]
This decline only fires in test mode. Use the test card matrix on [Test mode and test cards](/get-started/test-mode) to drive specific decline outcomes during development.
## Where to next [#where-to-next]
The other reference for failed payments: `lastPaymentError.code` values.
The model: how `lastPaymentError` is shaped, plus the `adviceCode`-driven handler pattern.
The reference for errors returned in HTTP responses to your API calls.
The synchronous error envelope.
Back to the top. The two-surfaces map.
# Introduction (/en/integrate/errors/introduction)
XPay surfaces failures in two distinct places: the response body of an API call you made, and the lastPaymentError field on a payment that failed. Pick the surface that matches your problem.
XPay surfaces failures in **two distinct places**, and they're handled with two different patterns. Pick the one that matches what just went wrong:
* An **API call your server made returned a non-2xx**. The error is in the HTTP response body. You handle it where the call happens. → [API errors](/integrate/errors/api-errors).
* **A customer's payment failed**. The error sits on the `lastPaymentError` field of the Payment Intent for the rest of its life. You read it after the payment attempt, in your webhook handler or in the dashboard. → [Payment errors](/integrate/errors/payment-errors).
These two surfaces don't share a code list and don't share a documentation URL namespace. The rest of the Errors group is split along this line.
## The two surfaces at a glance [#the-two-surfaces-at-a-glance]
| | **API errors** | **Payment errors** |
| ---------------------------------- | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- |
| When you encounter it | Synchronously, as the response to an API call | Asynchronously, on the Payment Intent that failed |
| Where it lives | The HTTP response body: `{ error: { type, code, ... }, request_id }` | The Payment Intent's `lastPaymentError` field |
| What caused it | Your request was rejected (validation, auth, missing resource, conflict) | The customer's card was declined, or the processor failed |
| Who's the audience for the message | Your engineers | Your customer (sometimes), your support team (always) |
| Code spaces | One: `ApiErrorCode` | Three: `PaymentErrorCode`, `DeclineCode`, raw network code |
| Handler shape | `try / catch`, branch on `error.type` and `error.code` | Read `lastPaymentError`, branch on `adviceCode`, pick customer-safe copy |
| Reference | [API error codes](/integrate/errors/api-error-codes) | [Payment error codes](/integrate/errors/payment-error-codes), [Decline codes](/integrate/errors/decline-codes) |
## Two things every error gives you [#two-things-every-error-gives-you]
### A `request_id` [#a-request_id]
Every API response (whether 2xx or error) includes a `request_id` like `req_3STkwmFGhGHoO0IX13BRo5iU`. On error responses it's at the top level alongside `error`. Quote it in support tickets, log it next to every error your handler catches, and use it to look up the failing call in [Workbench → Logs](/integrate/workbench/logs-panel) where you can see the full request and response.
A failed payment also has a `request_id` on the underlying API call that triggered it (the `POST /checkout/sessions/.../pay` from the customer's browser). The `lastPaymentError.chargeId` plus the time of failure are usually enough to find that log if you need it.
### A `docUrl` [#a-docurl]
Every error code has a deep link to its row on a code reference page in this docs site:
* API error codes → `https://docs.xpay.app/integrate/errors/api-error-codes#`
* Payment error codes → `https://docs.xpay.app/integrate/errors/payment-error-codes#`
* Decline codes → `https://docs.xpay.app/integrate/errors/decline-codes#`
The dashboard surfaces this link in transaction tooltips and in [Workbench → Logs](/integrate/workbench/logs-panel) so you can click straight from a failed event to its explanation. In your own ops UI, render the `docUrl` as a "Learn more" link next to the error message.
## Pick where to go next [#pick-where-to-go-next]
The error envelope, the three error types, the handling pattern (try/catch + branch on `type`).
The full list of `error.code` values you can receive on a failed API call, grouped by domain.
What `lastPaymentError` looks like, the `adviceCode` that decides what to do next, and how to
split copy between your customer and your team.
Every value of `lastPaymentError.code`, with the customer-safe and merchant-facing copy XPay
returns.
The card issuer's reason for the decline, in `lastPaymentError.declineCode`. Plus the raw
network code that came back from the card brand.
Look up any failure by its `request_id` and see the full request and response.
# Payment error codes (/en/integrate/errors/payment-error-codes)
Every value of `lastPaymentError.code`, grouped by domain. Each entry's URL is the value of `lastPaymentError.docUrl`.
This page is the destination of every `lastPaymentError.docUrl` whose code came from `PaymentErrorCode`. Anchors here match the `code` value, so `https://docs.xpay.app/integrate/errors/payment-error-codes#card_declined` lands directly on the row below.
For the issuer's specific decline reason on a card payment (`lastPaymentError.declineCode`), see [Decline codes](/integrate/errors/decline-codes). For the model and the handler pattern, see [Payment errors](/integrate/errors/payment-errors).
## Card validation [#card-validation]
The customer entered card details that don't pass basic validation. `paymentMethodType: "card"`. Advice: `confirm_card_data` (have the customer re-enter their details).
### `invalid_number` [#invalid_number]
The card number isn't in a valid format (wrong length, fails Luhn check, etc.).
### `invalid_expiry_month` [#invalid_expiry_month]
The expiration month isn't a valid value.
### `invalid_expiry_year` [#invalid_expiry_year]
The expiration year isn't a valid value.
### `invalid_cvc` [#invalid_cvc]
The CVC/CVV isn't in a valid format (wrong length).
### `incorrect_number` [#incorrect_number]
The card number passed format validation but the issuer says it's wrong.
### `incorrect_cvc` [#incorrect_cvc]
The CVC/CVV doesn't match what the issuer has on file.
### `incorrect_zip` [#incorrect_zip]
The postal code doesn't match the billing address on file with the issuer.
### `expired_card` [#expired_card]
The card expired. The customer needs a different card.
## Processing [#processing]
Errors that came back during the actual charge attempt. Mix of card errors and processor outcomes.
### `card_declined` [#card_declined]
The issuer declined the charge without a more specific reason. Pair with `lastPaymentError.declineCode` for the issuer-level reason when present.
### `processing_error` [#processing_error]
An error occurred during processing, but the issuer didn't reject the charge outright. Often transient. Advice: `try_again_later`.
### `authentication_required` [#authentication_required]
The issuer requires additional authentication (3D Secure) to complete the payment. The customer needs to re-attempt and complete the challenge.
## Account and balance [#account-and-balance]
### `insufficient_funds` [#insufficient_funds]
The customer's card doesn't have enough available balance for the charge.
### `card_velocity_exceeded` [#card_velocity_exceeded]
The card has been used too many times in a short window. Advice: `try_again_later`.
## Fraud and risk [#fraud-and-risk]
The issuer rejected the charge for risk reasons. Advice: `do_not_try_again` for all three.
### `fraudulent` [#fraudulent]
The issuer (or XPay's own risk signals) flagged the payment as fraudulent. Don't retry; this attempt is logged on the issuer's side.
### `stolen_card` [#stolen_card]
The card was reported stolen. Don't retry. Notify your support team if the rate is unusual.
### `lost_card` [#lost_card]
The card was reported lost.
## Processor [#processor]
### `issuer_not_available` [#issuer_not_available]
XPay couldn't reach the card's issuing bank. Advice: `try_again_later`.
### `processor_timeout` [#processor_timeout]
The payment processor didn't respond in time. The request may have succeeded on their side; check the dashboard before retrying. Advice: `try_again_later`.
### `processor_declined` [#processor_declined]
The processor declined the charge for a reason that didn't translate into a more specific code.
## Request [#request]
### `amount_too_large` [#amount_too_large]
The charge amount exceeds the maximum allowed for this payment method.
### `amount_too_small` [#amount_too_small]
The charge amount is below the minimum required for this payment method.
### `currency_not_supported` [#currency_not_supported]
The currency isn't supported by this payment method.
### `duplicate_transaction` [#duplicate_transaction]
The processor detected a similar transaction recently. Wait, then retry.
## Refund [#refund]
### `charge_already_refunded` [#charge_already_refunded]
You tried to refund a charge that's already been fully refunded.
### `charge_disputed` [#charge_disputed]
You tried to refund a charge that's currently under dispute. Disputed charges are out of your control until the dispute resolves.
### `refund_disputed_payment` [#refund_disputed_payment]
You tried to refund a payment that's been disputed. Same rule.
## Non-card payment methods [#non-card-payment-methods]
### `payment_declined` [#payment_declined]
A non-card payment method (BNPL, kiosk, wallet, bank transfer) declined the payment. Specifics depend on the provider; check the dashboard for details.
## Generic [#generic]
When the processor or issuer doesn't return a specific reason, XPay falls back to one of these.
### `generic_decline` [#generic_decline]
The payment was declined without a specific reason.
### `do_not_honor` [#do_not_honor]
The issuer returned "do not honor", which usually means the customer should contact their bank. Advice: `try_again_later`.
### `call_issuer` [#call_issuer]
The issuer told the customer to call them. Show the customer a clear "contact your bank and try again" prompt.
## Where to next [#where-to-next]
The issuer's decline reason in `lastPaymentError.declineCode`. More granular than the codes on
this page.
The model: how `lastPaymentError` is shaped and how to handle it.
The other reference: codes returned in `error.code` on a failed API call.
The synchronous API error envelope.
Back to the top. The two-surfaces map.
# Payment errors (/en/integrate/errors/payment-errors)
When a customer's payment fails, the error sits on the Payment Intent's lastPaymentError field. The shape, the adviceCode, and how to split copy between your customer and your team.
This page covers the **asynchronous** failure surface. When a customer's payment fails (card declined, processor timeout, expired card, etc.), the error doesn't come back as an HTTP response to your server. It lives on the Payment Intent's `lastPaymentError` field for the rest of its life.
For synchronous errors returned by your own API calls, see [API errors](/integrate/errors/api-errors).
## Where the error lives [#where-the-error-lives]
A failed payment leaves three distinct breadcrumbs:
* **`paymentIntent.lastPaymentError`**: the full structured detail. Read this for everything you need to show in your ops UI, decide retry strategy, or build customer-facing copy.
* **`charge.failureCode` and `charge.failureMessage`**: the same headline error, attached to the specific Charge that failed. Useful when you key fulfillment off the Charge.
* **The `charge.failed` webhook**: fires the moment a charge attempt fails. Subscribe to it if you need to react to failures in real time.
The most complete data is on `lastPaymentError`. If your handler receives `charge.failed`, fetch the Payment Intent to get the structured fields described below.
## The `lastPaymentError` shape [#the-lastpaymenterror-shape]
```json
{
"type": "card_error",
"code": "card_declined",
"declineCode": "insufficient_funds",
"networkDeclineCode": "51",
"message": "Your card has insufficient funds.",
"merchantMessage": "Declined: insufficient funds. Customer should use a different payment method.",
"adviceCode": "try_again_later",
"docUrl": "https://docs.xpay.app/integrate/errors/decline-codes#insufficient_funds",
"param": null,
"chargeId": "ch_test_AbC123",
"paymentMethodType": "card",
"paymentMethod": { "card": { "brand": "mastercard", "last4": "0008" } },
"processorCode": "...",
"processorMessage": "..."
}
```
| Field | Meaning |
| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type` | Broad category: `card_error`, `payment_method_error` (BNPL, wallet, kiosk, bank transfer), `processor_error`, `api_error`. |
| `code` | The specific error reason. From the [Payment error codes](/integrate/errors/payment-error-codes) list. |
| `declineCode` | For card declines, the issuer's reason. From the [Decline codes](/integrate/errors/decline-codes) list. `null` for non-decline failures. |
| `networkDeclineCode` | The 2-4 digit code returned by the card network (e.g. `51` for insufficient funds in ISO 8583). The meaning depends on the card brand. `null` when the network didn't provide one. |
| `message` | Short message safe to surface to the customer. |
| `merchantMessage` | Detailed message for your support team, includes context the customer shouldn't see. |
| `adviceCode` | What to do next: `confirm_card_data`, `try_again_later`, or `do_not_try_again`. The single most actionable field. |
| `docUrl` | Deep link to the matching row in the codes reference. |
| `param` | The request body field that failed, if applicable. Usually `null` on this surface. |
| `chargeId` | The `ch_*` ID of the Charge that failed. |
| `paymentMethodType` | The kind of payment method (`card`, `valu`, `fawry`, etc.). |
| `paymentMethod` | Snapshot of the payment method at the time of failure (brand, last4 for cards). |
| `processorCode`, `processorMessage` | Raw codes and messages from the processor, untranslated. For deep debugging only. |
`code` is always set. `declineCode` and `networkDeclineCode` are set on card declines; the rest fill in based on what XPay can extract from the processor response.
## The three code spaces [#the-three-code-spaces]
`lastPaymentError` carries up to three different codes for the same failure. Each is the source of truth for a different question:
| Field | Question it answers | Where to find descriptions |
| -------------------- | ------------------------------------------ | ------------------------------------------------------------ |
| `code` | What kind of failure was this? | [Payment error codes](/integrate/errors/payment-error-codes) |
| `declineCode` | Why did the issuer decline? (cards only) | [Decline codes](/integrate/errors/decline-codes) |
| `networkDeclineCode` | What raw code did the card network return? | Brand-specific; pass through to support if needed |
For most handlers, branch on `adviceCode` first, surface `merchantMessage` in your ops UI, and use `code` or `declineCode` to pick customer-facing copy.
## The handler pattern [#the-handler-pattern]
The action you take is decided by `adviceCode`, not by `code`. The code list grows; the three advice values don't.
| `adviceCode` | What it means | What to do |
| ------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `confirm_card_data` | The card data was probably wrong (CVV mismatch, incorrect number, postal code mismatch). | Tell the customer to re-enter their card. Don't suggest a different card; the same card with corrected details is likely to work. |
| `try_again_later` | Transient: insufficient funds, processor timeout, network glitch. | Tell the customer to try again, or surface a "try a different payment method" option. Safe to retry once. |
| `do_not_try_again` | Hard reject: stolen card, lost card, fraud, expired card. | Don't retry the same card. Show the customer a "use a different payment method" prompt. Notify your team if the rate spikes. |
```typescript
function handlePaymentFailure(error: LastPaymentError) {
// Always log first. The merchantMessage is what your support team needs.
log.error("payment failed", {
chargeId: error.chargeId,
type: error.type,
code: error.code,
declineCode: error.declineCode,
networkDeclineCode: error.networkDeclineCode,
merchantMessage: error.merchantMessage,
docUrl: error.docUrl,
});
// Decide UX from adviceCode.
switch (error.adviceCode) {
case "confirm_card_data":
return showRetryWithCard(error.message);
case "try_again_later":
return showRetryWithFallback(error.message);
case "do_not_try_again":
return showUseDifferentMethod(error.message);
default:
// Missing adviceCode is rare. Treat as do_not_try_again to be safe.
return showUseDifferentMethod(error.message);
}
}
```
## Customer copy vs merchant copy [#customer-copy-vs-merchant-copy]
XPay returns two messages on every failure. They're not interchangeable.
| Field | Audience | Example |
| ----------------- | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| `message` | Your customer. Already filtered for what's safe to display. | "Your card has insufficient funds." |
| `merchantMessage` | Your team (support, ops dashboards). Includes context that's useful internally but inappropriate for customers. | "Declined: insufficient funds. Customer should use a different payment method." |
The dashboard's transaction tooltip uses `merchantMessage`. Build your own ops UI the same way. For the customer-facing surface (your checkout page, your transactional emails), use `message` or your own copy keyed off `code` / `declineCode`.
Don't show `merchantMessage` to customers. It can leak internal context. Don't show `processorCode` or `processorMessage` to customers either; those are raw processor passthrough, untranslated.
## Reading `lastPaymentError` in webhooks [#reading-lastpaymenterror-in-webhooks]
The `charge.failed` webhook carries a Charge in `event.data.object`. The Charge has the headline failure (`failureCode`, `failureMessage`) but not the full structured detail. To read the full `lastPaymentError`, fetch the parent Payment Intent:
```typescript
async function onChargeFailed(event: { data: { object: Charge } }) {
const charge = event.data.object;
// Quick action: the Charge alone has the headline.
log.warn("charge failed", {
chargeId: charge.id,
failureCode: charge.failureCode,
failureMessage: charge.failureMessage,
});
// Full detail: fetch the PI.
const pi = await getPaymentIntent(charge.paymentIntentId);
if (pi.lastPaymentError) {
handlePaymentFailure(pi.lastPaymentError);
}
}
```
`lastPaymentError` is set on the PI for the lifetime of the resource. It records the most recent failed attempt; subsequent retries on the same PI overwrite it.
## What not to do [#what-not-to-do]
* **Don't branch on `code` alone.** The code list grows over time. Branch on `adviceCode` for what to do, then use `code` for narrow copy decisions.
* **Don't show `merchantMessage` to your customers.** It can include details that are inappropriate for the consumer surface.
* **Don't ignore `adviceCode: "do_not_try_again"`.** Retrying after a stolen-card or fraud decline doesn't help and can flag your account to the issuer.
* **Don't blindly retry on `try_again_later`.** Retry once, then move the customer to a different method or end the flow. Looping on a `do_not_honor` decline produces the same result every time.
* **Don't use `networkDeclineCode` as your primary branch.** It's a raw passthrough that depends on the card brand. Use `declineCode` (XPay's normalized form) instead.
## Where to next [#where-to-next]
Every value of `lastPaymentError.code`, with the customer-safe and merchant-facing copy XPay
returns.
The issuer's reason for the decline (`lastPaymentError.declineCode`). The most specific code
XPay carries.
The other surface: synchronous errors returned in your API call's HTTP response.
See every `charge.failed` event in the dashboard. Resend, drill into the attempt, jump to the
Payment Intent.
The original API call that produced the failed Charge. Useful for cross-referencing.
Back to the top. The two-surfaces map.
# Drop-in (/en/integrate/integration-patterns/drop-in)
Server plus a few lines of frontend. Open XPay's checkout in a modal or inline iframe on your domain. The customer never leaves your site.
Drop-in opens XPay's full checkout inside an iframe on your domain. Your server creates a Checkout Session, your frontend 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.
Pick this pattern when you want the customer to stay on your domain but you don't want to build the payment form yourself. If you need a custom UI, use [Elements](/integrate/integration-patterns/elements). If you don't want any frontend code, use [Hosted Checkout](/integrate/integration-patterns/hosted-checkout) or a [Payment Link](/integrate/integration-patterns/payment-links).
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. Non-React frameworks (Vue, Svelte, Solid, Angular, Lit) use the vanilla path.
Drop-in is a thin SDK wrapper around the same Checkout Session every other pattern uses. For the
full set of fields you can set on `POST /checkout/sessions` (line items, customer collection,
branding, payment methods, fees, metadata, etc.), see the [Checkout
Session](/integrate/checkout-session/overview) reference.
## How it works [#how-it-works]
1. **Your server** calls `POST /checkout/sessions` with `uiMode: "embedded"` and the line items. XPay returns a session that includes `clientSecret` and `paymentMethodTypes`.
2. **Your server** ships the `clientSecret` to your frontend (typically inside an HTML page render or a fetch response).
3. **Your frontend** loads the SDK with your publishable key and calls `xpay.checkout({ clientSecret, mode: "modal" })`. Calling `checkout.open()` shows the iframe.
4. **The customer** picks a method, fills the form, completes 3D Secure if required, and pays. All without navigating away.
5. **The SDK** fires your `onComplete` callback with the Payment Intent ID. Your server independently receives `checkout.session.completed` to fulfill the order.
The whole interaction takes place 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]
You need both keys for Drop-in:
* **`sk_test_*`** for your server. Used to create the Checkout Session.
* **`pk_test_*`** for your frontend. Used to load the SDK. Safe to ship to the browser.
In the dashboard, go to **Developer → API Keys** and copy both. Live keys (`sk_live_*` / `pk_live_*`) work the same way once your account is approved.
### 2. Create a Checkout Session on your server [#2-create-a-checkout-session-on-your-server]
Two requirements specific to Drop-in:
* **`uiMode: "embedded"`** tells XPay this session is for an iframe.
* The session response includes a **`clientSecret`** that scopes the SDK to this specific session. You'll ship it to the frontend.
```bash
curl -X POST https://api.xpay.app/checkout/sessions \
-H "Authorization: Bearer sk_test_..." \
-H "Content-Type: application/json" \
-d '{
"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
}
]
}'
```
```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: "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
```
```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": "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,
}
],
},
timeout=10,
)
session = res.json()
# Send `session["clientSecret"]` to your frontend
```
The response includes the same Checkout Session shape Hosted Checkout returns. The fields you ship to your frontend:
```json
{
"id": "cs_test_AbC123...",
"clientSecret": "cs_test_AbC123..._secret_xyz789",
"amountTotal": 149900,
"currency": "EGP"
}
```
A few rules:
* **`cancelUrl` is rejected for `uiMode: "embedded"`.** Drop-in surfaces failures inside the iframe with a retry. There's no "send the customer to a failure URL" path.
* **`clientSecret` is single-session.** It scopes the SDK to this specific session and can't be reused. Don't store it past the session's lifetime.
* **`afterCompletion` is required, and must be `type: "redirect"`.** `hosted_confirmation` is rejected for `uiMode: "embedded"`: it shows an XPay-hosted page, and this integration runs on your own site. When the payment succeeds, the SDK passes your `redirect.url` into `onComplete` as `redirectUrl` and your code decides whether to navigate. The same URL is also where a customer returns if a bank verification has to take over the whole tab, so it has to exist before the payment starts.
### 3. Load the SDK on your frontend [#3-load-the-sdk-on-your-frontend]
If you bundle (Vite, webpack, etc.), install the loader from npm, then import `loadXPay` and `await` it:
```bash
npm install @xpayeg/sdk
```
```typescript
import { loadXPay } from "@xpayeg/sdk";
const xpay = await loadXPay("pk_test_..."); // your publishable key
const checkout = xpay.checkout({
clientSecret: "cs_test_..._secret_xyz789", // from your server
mode: "modal",
onComplete: (result) => {
// result.paymentIntentId is the pi_*
// result.redirectUrl is your afterCompletion.redirect.url (if set)
if (result.redirectUrl) {
window.location.href = result.redirectUrl;
}
},
onClose: () => {
console.log("Customer closed the modal");
},
});
document.getElementById("pay-button")?.addEventListener("click", () => {
checkout.open();
});
```
On plain HTML with no build step, don't import. Drop the CDN script tag and call the global `XPay` factory the runtime exposes. Same API, no `await`:
```html
```
Install both SDK packages:
```bash
npm install @xpayeg/sdk @xpayeg/react
```
```tsx
import { CheckoutButton, XPayProvider } from "@xpayeg/react";
import { loadXPay } from "@xpayeg/sdk";
// Load once at module level (not inside a component)
const xpayPromise = loadXPay("pk_test_...");
export function PayPage({ clientSecret }: { clientSecret: string }) {
return (
{
if (result.redirectUrl) window.location.href = result.redirectUrl;
},
}}
>
Pay now
);
}
```
`` accepts either an `XPayInstance` or `Promise`, so passing `xpayPromise` directly is the idiomatic pattern. Don't call `loadXPay()` inside a component. Load it once per page at module scope.
The SDK loads from `https://checkout.xpay.app/v1/sdk.js` once per page; subsequent `loadXPay()` calls return the same cached instance.
### 4. Handle the result [#4-handle-the-result]
`xpay.checkout(...)` returns a `CheckoutInstance` you can subscribe to via constructor callbacks or `.on(event, handler)`. Five events fire over the lifetime of one checkout.
| Event | When | Payload |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------- |
| `ready` | The session loaded and the iframe is ready to render. | The full Checkout Session. |
| `confirmed` | The customer hit Pay. Useful if you want to disable the close button or show a "processing" state. | None. |
| `complete` | The payment succeeded. The iframe auto-closes the modal 300ms later. | `{ status: "succeeded", paymentIntentId, chargeId?, redirectUrl? }`. |
| `close` | The modal closed. Fires for manual close, after `complete`, or if `checkout.close()` is called. | None. |
| `error` | The session failed to load or the SDK encountered a fatal error. Doesn't fire for user-recoverable errors like a card decline. | `{ message, code? }`. |
The `complete` event is a UX courtesy. **Your server must still listen for the `checkout.session.completed` webhook** to mark the order paid. The iframe can be closed early, the network can drop, or your `onComplete` handler can throw. The webhook is the source of truth.
In React, the same callbacks ride on the `checkoutOptions` prop of ``: `onComplete`, `onClose`, `onReady`, `onConfirmed`, `onError`. They map one-to-one to the events above.
## Modal vs inline [#modal-vs-inline]
`mode` controls whether the iframe overlays the page or sits inside a container.
Full-screen overlay with a centered iframe. You call `checkout.open()` to show it. Click
outside, press Escape, or call `checkout.close()` to dismiss. Best for "Pay now" buttons,
post-cart pages, anything event-driven.
Iframe mounted inside a container you provide. No open/close: it's there as soon as you create
the instance. Pass `container` as a CSS selector or an HTMLElement. Best for embedded "checkout"
pages where the form is the page.
```typescript
// Inline mode
const checkout = xpay.checkout({
clientSecret,
mode: "inline",
container: "#checkout-container", // or document.getElementById(...)
onComplete: (result) => {
/* ... */
},
});
// On unmount or before re-creating, release the iframe
checkout.destroy();
```
The inline iframe auto-resizes to its content height. You don't need to set a fixed height in your CSS.
**Always call `checkout.destroy()` when you tear down the component** that owns the inline checkout. Without it, the iframe stays in the DOM and a fresh `xpay.checkout({ mode: "inline" })` call would mount a second one. Modal mode handles its own cleanup on close, but you can still call `destroy()` to release listeners early.
`` is **modal-only**. For the inline pattern in React, drop down to the JS SDK via `useXPay()` and call `xpay.checkout({ mode: "inline" })` yourself. Remember to call `destroy()` in a cleanup `useEffect`.
## Customizing appearance and locale [#customizing-appearance-and-locale]
`appearance` accepts the same shape as the session's `brandingSettings`. The SDK runtime values win; the session's `brandingSettings` and your merchant defaults provide fallbacks. For the full field list see [Advanced configuration → Branding](/integrate/checkout-session/advanced-configuration#branding).
```typescript
const checkout = xpay.checkout({
clientSecret,
mode: "modal",
locale: "ar",
appearance: {
colorMode: "dark",
borderStyle: "rounded",
colors: { primary: "#635bff" },
},
});
```
```tsx
{
/* ... */
},
}}
>
Pay now
```
Useful when the same session needs to look different across surfaces (e.g. matching a dark-mode toggle on your page without persisting it on the session).
## When verification takes over the page [#when-verification-takes-over-the-page]
Card payments usually verify with the bank on your page, and `onComplete` fires as normal. Occasionally the bank's page cannot be shown there at all.
When that happens, XPay hands the bank the whole tab. Your page unloads, the customer verifies on the bank's own page, and they come back to your `afterCompletion.redirect.url`. This is why that URL is required for `uiMode: "embedded"`: it has to be on record before verification starts, because by then your page and the modal are gone.
What this means for your code:
* **`onComplete` never fires on this path.** The page navigated away. So did `onClose`.
* **The return is not a success signal.** The customer comes back whatever the outcome, and the payment is often not confirmed yet when they arrive. Read the session status on your return page rather than assuming.
* **Put `{CHECKOUT_SESSION_ID}` in the URL** so that page knows which session to read. XPay substitutes it before the customer gets there.
Your fulfillment does not change: the `checkout.session.completed` webhook is what marks the order paid, on this path exactly as on the normal one.
## Confirm with a webhook [#confirm-with-a-webhook]
Drop-in's `onComplete` callback is fast and convenient, but it's not authoritative. The Checkout Session your server creates is the same object every other pattern produces, so the `checkout.session.completed` webhook handler you'd write for Hosted Checkout works unchanged for Drop-in.
The minimum your handler needs to do:
1. Verify the `XPay-Signature` header.
2. Parse the JSON body.
3. If the event type is `checkout.session.completed` and the session `status` is `complete`, fulfill the order.
For the full handler walkthrough (signature verification recipe, payload field table, retry behavior), see [Hosted Checkout → Confirm with a webhook](/integrate/integration-patterns/hosted-checkout#confirm-with-a-webhook). The same code applies here.
## Test it [#test-it]
In test mode, use the success card `5123 4500 0000 0008` with expiry `01/39` to run the happy path inside the iframe. The card form, 3D Secure challenge, and post-payment success state all run in the iframe; you don't need to switch tabs or pages. 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.** Replace `sk_test_*` with `sk_live_*` on your server and `pk_test_*` with `pk_live_*` on your frontend. The API URL doesn't change.
* **Set up a live webhook endpoint.** Test and live have separate webhook endpoints. Each gets its own `whsec_*` signing secret.
* **Verify signatures on the webhook, not on `onComplete`.** Treat the `complete` event as a UX hint, not authorization.
* **Don't expose the secret key to the browser.** `sk_*` keys are server-only. The SDK takes a publishable key (`pk_*`) by design.
* **Dedupe webhook deliveries on `event.id`.** XPay retries deliveries on non-2xx or timeout, so the same `checkout.session.completed` for the same session can arrive more than once. Track event IDs you've already processed.
* **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).
* **Set a content-security-policy that allows `https://checkout.xpay.app`.** If your site uses a strict `frame-src` or `script-src` directive, the SDK script and the embedded iframe both need to be allowed.
## Where to next [#where-to-next]
How Checkout Session, Payment Intent, Charge, and Customer relate, and which IDs to keep on your
order record.
Reverse a successful payment using the `pi_*` from `result.paymentIntentId`.
Add an endpoint and grab the `whsec_*` signing secret.
The full HMAC-SHA256 verification recipe with replay protection.
The test card list and the outcomes you can simulate.
Compare Drop-in against Hosted Checkout, Elements, and Payment Links.
`drop-in-modal.html` and `drop-in-inline.html`: this page's pattern as runnable plain-HTML files
using the global `XPay()` factory, no build step.
Drop-in modal at `/checkout?ui=embedded` and dynamic amounts at `/donate`, inside a full Next.js
storefront.
# 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 (`` 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.
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).
## 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.
```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
}
]
}'
```
```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
```
```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
```
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]
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
```
```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
```
Install both SDK packages:
```bash
npm install @xpayeg/sdk @xpayeg/react
```
Wrap the part of your app that renders the checkout in ``. 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 (
);
}
```
`options.clientSecret` accepts both `string` and `Promise`, 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 [#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.
```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.
`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 ;
if (state.type === "error") return ;
const { checkout } = state;
// Handle terminal states
if (checkout.status.type === "expired") return ;
if (checkout.status.type === "complete") return ;
return (
);
}
```
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 ``.** 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 [#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.
```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}`;
});
```
```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}`);
}
```
The `result` shape (same in both):
* **`{ type: "success", session }`**: the payment succeeded. `session` is the updated Checkout Session 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` 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();
```
`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.
`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]);
```
## 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).
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",
});
});
```
Pass `options.appearance` on `` to set the initial look, or call `checkout.changeAppearance(...)` later.
```tsx
```
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 /* ... */;
}
```
## 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)`.
```ts
checkout.on("error", (error) => {
bannerEl.textContent = error.message;
});
```
```tsx
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](/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. **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]
How Checkout Session, Payment Intent, Charge, and Customer relate, and which IDs to keep on your
order record.
Reverse a successful payment using the `pi_*` from `result.session.paymentIntent.id`.
Every field `appearance` accepts, plus locale resolution rules.
Add an endpoint and grab the `whsec_*` signing secret.
The test card list and the outcomes you can simulate.
Compare Elements against Hosted Checkout, Drop-in, and Payment Links.
`elements.html` runs this exact pattern (custom form, promo codes, and a mounted payment
element) in one runnable file, no build step.
The `/checkout?ui=custom` route builds this page's pattern with `` +
`` in a full storefront.
# Hosted Checkout (/en/integrate/integration-patterns/hosted-checkout)
Server-only integration. Create a Checkout Session, redirect the customer to XPay's hosted page, confirm with a webhook.
Hosted Checkout is the simplest integration that involves any code. Your server creates a Checkout Session, redirects the customer to XPay's hosted checkout page, and your webhook receives `checkout.session.completed` when the payment lands. You don't write any frontend code.
Pick this pattern when you want a backend-only integration and you're fine with the customer redirecting away from your site for the few seconds it takes to pay.
This guide shows the minimum body needed to start a redirect-style session. For the full set of
fields you can set on `POST /checkout/sessions` (line items, customer collection, branding,
payment-method restrictions, fees, metadata, etc.), see the [Checkout
Session](/integrate/checkout-session/overview) reference.
## How it works [#how-it-works]
1. **Your server** calls `POST /checkout/sessions` with the line items and where to send the customer after payment. XPay returns a session object that includes a `url`.
2. **Your server** redirects the customer's browser to that `url`.
3. **The customer** lands on `https://checkout.xpay.app/c/cs_test_...`, fills in their card, completes 3D Secure if required, and the payment runs.
4. **XPay** redirects the customer back to the URL you set in `afterCompletion.redirect.url`.
5. **XPay** posts a `checkout.session.completed` webhook to your endpoint so your server can confirm and fulfill the order.
The customer's experience is a 5-to-15-second redirect to XPay's hosted page and back. Your server is the only thing that holds state across the flow.
## Build it [#build-it]
### 1. Get a test API key [#1-get-a-test-api-key]
Open the dashboard, go to **Developer → API Keys**, and copy a secret key that starts with `sk_test_`. Keep it on your server. Never put a secret key in frontend code or commit it to a repo.
You'll use the same code path with `sk_live_*` once your account is approved for live payments.
### 2. Create a Checkout Session on your server [#2-create-a-checkout-session-on-your-server]
The only field you have to send is `afterCompletion`, which tells XPay where to redirect the customer after payment. In practice you'll also send `lineItems` so XPay knows what's being charged.
```bash
curl -X POST https://api.xpay.app/checkout/sessions \
-H "Authorization: Bearer sk_test_..." \
-H "Content-Type: application/json" \
-d '{
"afterCompletion": {
"type": "redirect",
"redirect": { "url": "https://yourshop.example/order/{CHECKOUT_SESSION_ID}" }
},
"lineItems": [
{
"priceData": {
"currency": "EGP",
"unitAmount": 149900,
"productData": { "name": "Test product" }
},
"quantity": 1
}
]
}'
```
```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({
afterCompletion: {
type: "redirect",
redirect: { url: "https://yourshop.example/order/{CHECKOUT_SESSION_ID}" },
},
lineItems: [
{
priceData: {
currency: "EGP",
unitAmount: 149900, // 1,499.00 EGP, in minor units
productData: { name: "Test product" },
},
quantity: 1,
},
],
}),
});
const session = await res.json();
```
```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={
"afterCompletion": {
"type": "redirect",
"redirect": {"url": "https://yourshop.example/order/{CHECKOUT_SESSION_ID}"},
},
"lineItems": [
{
"priceData": {
"currency": "EGP",
"unitAmount": 149900, # 1,499.00 EGP, in minor units
"productData": {"name": "Test product"},
},
"quantity": 1,
}
],
},
)
session = res.json()
```
The response includes the fields you need next:
```json
{
"id": "cs_test_AbC123...",
"object": "checkout.session",
"status": "open",
"url": "https://checkout.xpay.app/c/cs_test_AbC123...",
"amountTotal": 149900,
"currency": "EGP",
"afterCompletion": { "type": "redirect", "redirect": { "url": "..." } }
}
```
A few notes:
* **`unitAmount` is in minor units.** `149900` means 1,499.00 EGP. Always send the full integer, not a decimal.
* **`productData.name` is required** when you use `priceData` to create an ad-hoc Price. If you've already created products in the dashboard, you can pass `price: "price_..."` instead and skip `priceData`. Full reference: [Line items & pricing](/integrate/checkout-session/line-items-and-pricing).
* **`{CHECKOUT_SESSION_ID}` in `redirect.url`** is replaced server-side with the session's `id`, so your return page can read the session ID off the path without you threading it through state.
* **The session expires after 24 hours** if the customer never pays. You can also expire it manually with `POST /checkout/sessions/:id/expire`.
* **Test mode is decided by the API key**, not the URL. `sk_test_*` produces `cs_test_*` sessions; `sk_live_*` produces `cs_live_*` sessions. The hosted URL hostname is the same.
### 3. Redirect the customer [#3-redirect-the-customer]
Send an HTTP 302 to the `url` field of the response.
```typescript
app.post("/start-checkout", async (req, res) => {
const session = await createCheckoutSession(req.body); // your code from step 2
res.redirect(303, session.url);
});
```
```python
from flask import redirect
@app.post("/start-checkout")
def start_checkout():
session = create_checkout_session(request.json) # your code from step 2
return redirect(session["url"], code=303)
```
Use a `303 See Other` if you're redirecting from a `POST` request, otherwise `302 Found` is fine.
### 4. Handle the customer's return [#4-handle-the-customers-return]
After the customer pays, XPay redirects them to `afterCompletion.redirect.url` with no extra query parameters. Your page at that URL is purely customer-facing: a thank-you message, an order summary, whatever you want.
**Don't trust the redirect alone to confirm payment.** A customer can close the tab before the redirect or hit the URL by accident. The single source of truth for payment success is the `checkout.session.completed` webhook your server receives.
If you also want to display the actual order on the return page, you can read it back with `GET /checkout/sessions/:id` using the session ID you stored before redirecting.
## Confirm with a webhook [#confirm-with-a-webhook]
XPay sends `checkout.session.completed` as soon as a payment succeeds. The `data.object` inside the event is the same Checkout Session you'd get from `POST /checkout/sessions` or `GET /checkout/sessions/:id`, so your fulfillment code can be one function that reads a Checkout Session.
For every field on the session, see Checkout Session. The minimum your handler needs to do:
1. Verify the `XPay-Signature` header.
2. Parse the JSON body.
3. If the event type is `checkout.session.completed` and the session `status` is `complete`, fulfill the order.
### Verify the signature [#verify-the-signature]
XPay signs every webhook with HMAC-SHA256 using the signing secret you got when you created the webhook endpoint (it starts with `whsec_`). The header looks like this:
```
XPay-Signature: t=1730000000,v1=a1b2c3d4...
```
The signed payload is the timestamp, a literal `.`, and the raw JSON body. To verify:
```typescript
import crypto from "node:crypto";
function verifyWebhook(rawBody: string, header: string, secret: string) {
const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("="))); // { t: "1730000000", v1: "a1b2c3..." }
const timestamp = parts.t;
const signature = parts.v1;
// Reject events older than 5 minutes (replay protection)
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
throw new Error("Webhook timestamp out of tolerance");
}
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
throw new Error("Bad webhook signature");
}
}
```
```python
import hmac, hashlib, time
def verify_webhook(raw_body: bytes, header: str, secret: str):
parts = dict(kv.split("=") for kv in header.split(","))
timestamp = parts["t"]
signature = parts["v1"]
# Reject events older than 5 minutes (replay protection)
if abs(time.time() - int(timestamp)) > 300:
raise ValueError("Webhook timestamp out of tolerance")
signed = f"{timestamp}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
if not hmac.compare_digest(signature, expected):
raise ValueError("Bad webhook signature")
```
Read the request body as a raw string (or bytes), not as parsed JSON. Re-serializing the JSON will not produce the same bytes XPay signed.
### Read the payload [#read-the-payload]
Inside `checkout.session.completed`, `data.object` is a full Checkout Session. The fields you usually care about:
| Field | What it is |
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `data.object.id` | The Checkout Session ID (`cs_test_...` / `cs_live_...`). Use it as your idempotency key for fulfillment. |
| `data.object.status` | `complete` on a successful payment. |
| `data.object.amountTotal` | Total charged, in minor units. |
| `data.object.currency` | Three-letter ISO code, e.g. `EGP`. |
| `data.object.customer` | The Customer record (if you sent `customerId` or XPay created one from the form). |
| `data.object.paymentIntent.id` | The Payment Intent ID (`pi_*`). Store it if you'll issue [Refunds](/integrate/refunds) from code. |
| `data.object.lineItems` | What was bought, with prices and product info. |
| `data.object.metadata` | Anything you attached when creating the session. |
For the broader picture of how Checkout Session, Payment Intent, Charge, and Customer relate, and which IDs to keep on your order record, see [Object model](/integrate/object-model).
A short handler in Node.js / Express:
```typescript
app.post("/webhooks/xpay", express.raw({ type: "application/json" }), (req, res) => {
try {
verifyWebhook(
req.body.toString("utf8"),
req.header("XPay-Signature")!,
process.env.XPAY_WEBHOOK_SECRET!,
);
} catch {
return res.status(400).send("invalid signature");
}
const event = JSON.parse(req.body.toString("utf8"));
if (event.type === "checkout.session.completed" && event.data.object.status === "complete") {
fulfillOrder(event.data.object); // idempotent on session.id
}
res.status(200).send();
});
```
Webhook deliveries follow this retry schedule on non-2xx responses or timeouts (30 seconds): immediate, 1 minute, 5 minutes, 30 minutes, 2 hours. XPay gives up after 5 attempts.
## Test it [#test-it]
In test mode, every Checkout Session you create returns a `cs_test_*` ID and a hosted checkout URL that uses XPay's sandbox processor. Use the success card `5123 4500 0000 0008` with expiry `01/39` to run the happy path. For the full test card list and the expiry-to-outcome matrix, see [Test mode & 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.** Replace `sk_test_*` with `sk_live_*` in your server environment. The API URL doesn't change.
* **Set up a live webhook endpoint.** Test and live have separate webhook endpoints. Each gets its own `whsec_*` signing secret.
* **Use a real return URL.** `afterCompletion.redirect.url` must be an `https://` URL on a domain you control. Don't use `localhost`.
* **Dedupe webhook deliveries on `event.id`.** XPay retries webhook deliveries on non-2xx or timeout, so `checkout.session.completed` for the same session can arrive more than once. Track event IDs you've already processed and skip duplicates.
* **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 for the same cart. See [Idempotency](/integrate/idempotency).
* **Consider a `cancelUrl` for failed payments.** Set it on the session if you want to handle declined / 3DS-rejected / local-method-timeout failures on your own page instead of the XPay hosted retry. Full behavior in [After completion → cancelUrl](/integrate/checkout-session/after-completion#cancelurl-where-to-send-the-customer-on-payment-failure).
* **Verify signatures in production too.** Don't disable signature verification "to debug." Use a webhook tunneling tool against test mode instead.
## Where to next [#where-to-next]
How Checkout Session, Payment Intent, Charge, and Customer relate, and which IDs to keep on your
order record.
Reverse a successful payment using the `pi_*` you stored from the webhook.
Add a webhook endpoint and grab the `whsec_*` signing secret.
The signature-verification recipe in more detail, plus replay protection.
The full test card list and the outcomes you can simulate by changing the expiry.
Compare Hosted Checkout against Drop-in, Elements, and Payment Links.
`hosted-redirect.html`: create the session server-side and redirect, no SDK on the page. One
runnable file plus a tiny Express server.
The `/checkout?ui=hosted` route: session creation, redirect, and a signature-verified webhook
receiver.
# Payment Links (no-code) (/en/integrate/integration-patterns/payment-links)
Use the URL your merchant created in the dashboard. Embed it on a site, drop it in an email, print a QR code, or listen for paid events with a webhook.
Payment Links are a no-code feature. They're created in the dashboard, not via the API. This page covers what to do with the link once it exists: how to drop it into your own surfaces, what the URL looks like, and how to confirm payments programmatically if you want to.
The full walkthrough (products, collection, confirmation, share) lives on the Payment Links
feature page. Come back here once you have a link in hand.
## The link URL [#the-link-url]
Every Payment Link has a URL of the form:
```
https://checkout.xpay.app/p/{paymentLinkId}
```
The `paymentLinkId` is the link's ID from the dashboard, prefixed `plink_test_*` in test mode and `plink_live_*` in live mode. The hostname is the same in both modes; the prefix tells you which environment a link belongs to.
Open the link's detail page in the dashboard and click **Copy link** to grab the URL, or **Show QR code** to download a printable QR.
## Use the link [#use-the-link]
The URL is a regular HTTPS URL. Use it anywhere a URL works.
`` opens the hosted checkout in the same tab. Add `target="_blank"` to open in a new tab.
The dashboard generates a printable QR. Stick it on a poster, a flyer, a restaurant table, or display it on a register screen.
Drop the URL into a transactional email, a Slack message, an Instagram bio, a WhatsApp text. The link works in any channel that renders URLs.
Most no-code site builders accept a custom URL on a button or image. Paste the link there. No script tag, no SDK, no embed.
## One link, many customers [#one-link-many-customers]
The same Payment Link is accepted by an unlimited number of customers. Each customer's interaction stands on its own: their own card, their own form, their own receipt.
This is useful for invoices you re-send, posters that hang for weeks, or evergreen "Buy now" buttons. It's a problem if the link gates a single sale or a limited slot. For one-of-a-kind sales, deactivate the link after the customer pays (list page, bulk action) so the URL stops accepting new payments.
## Confirm payments programmatically (optional) [#confirm-payments-programmatically-optional]
You don't need code to take payments through a Payment Link. The dashboard already shows you every transaction, every customer, and every payout that came through it.
Reach for code only when you need an automated reaction to each successful payment: granting access, sending a tracked-email receipt, posting to your warehouse system, updating an internal CRM. For that, set up a webhook endpoint and listen for `checkout.session.completed` events.
The event is the same one every other XPay integration emits. The session payload carries a `paymentLinkId` field telling you which Payment Link the customer paid through, so you can branch your handler based on the link.
The minimum your handler needs to do:
1. Verify the `XPay-Signature` header.
2. Parse the JSON body.
3. If `event.type === "checkout.session.completed"` and `event.data.object.paymentLinkId === "plink_..."`, do whatever fulfillment you want for that link.
For the full setup (creating an endpoint, getting the signing secret, the verification recipe in Node and Python), see the Webhooks group.
Add an endpoint in the dashboard and grab the `whsec_*` signing secret.
HMAC-SHA256 signature verification with replay protection.
## Where to next [#where-to-next]
The dashboard walkthrough: products, collection, confirmation, share.
Open a `plink_test_*` URL with a test card to run an end-to-end payment without spending money.
If you want full programmatic control over each session, look at Hosted Checkout, Drop-in, or
Elements.
# Event reference (/en/integrate/webhooks/event-reference)
Every webhook event XPay can deliver, when it fires, and the object it carries.
Events are how XPay tells your server something happened: a payment succeeded, a refund landed, a customer was created. Every event delivered to your endpoint shares the same JSON envelope; what changes is the value of `type` and the resource carried inside `data.object`.
This page is the canonical list of events you can subscribe to. For the click path to subscribe, see [Setting up an endpoint](/integrate/webhooks/setting-up-an-endpoint). For the verifier code, see [Verifying signatures](/integrate/webhooks/verifying-signatures).
## The event envelope [#the-event-envelope]
Every webhook delivery has the same top-level shape:
```json
{
"id": "evt_test_AbC123...",
"object": "event",
"api_version": "3.1.0",
"created": "2026-05-01T12:00:00.000Z",
"type": "checkout.session.completed",
"livemode": false,
"data": {
"object": { "id": "cs_test_...", "object": "checkout.session" }
}
}
```
| Field | Type | Meaning |
| ------------- | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | string | Event ID, prefixed `evt_test_*` or `evt_live_*`. Stable across retries. Use as your idempotency key. |
| `object` | string | Always `"event"`. |
| `api_version` | string | API version used to render `data.object`. |
| `created` | ISO 8601 string | When the event was first recorded. |
| `type` | string | What kind of event (see the tables below). |
| `livemode` | boolean | `true` for live-mode events, `false` for test-mode. |
| `data.object` | object | The resource the event is about. Identical shape to `GET //:id`. |
| `request` | object \| null | The API request that created the event: `{ id, idempotency_key }`. `request.idempotency_key` is the `Idempotency-Key` you sent on that request (see [Idempotency](/integrate/idempotency)), or `null` if you didn't send one. |
The `data.object` is identical to what the corresponding GET endpoint returns. If you can read a Checkout Session from `GET /checkout/sessions/:id`, your `checkout.session.completed` handler can read the same fields by the same names off `data.object`.
## Checkout Session events [#checkout-session-events]
Fire on the lifecycle of a Checkout Session. These are the events most integrations key off.
| Event | Fires when | `data.object` |
| ---------------------------- | --------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| `checkout.session.completed` | A customer's payment on the session succeeds. The session's `status` is now `complete`. | Checkout Session |
Subscribe to `checkout.session.completed` to fulfill the order. The payload carries the resolved `paymentIntent`, `customer`, and `lineItems`, so a single event is enough for most fulfillment logic. `lineItems` lists only what the customer bought; optional add-ons they didn't add aren't included.
## Charge events [#charge-events]
Fire on the lifecycle of a Charge. A Charge is one attempt to move money on the customer's payment method. A successful payment produces one Charge; a customer who retried after a decline produces several.
| Event | Fires when | `data.object` |
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- |
| `charge.succeeded` | A Charge captures successfully. Fires before `checkout.session.completed` for hosted-checkout payments. | Charge |
| `charge.failed` | A Charge attempt fails. Carries `failureCode` and `failureMessage` describing why. | Charge |
| `charge.refunded` | A successful Refund (full or partial) is applied to the Charge. The refunded amount is in `amountRefunded`. The Charge's `refunds.data` array carries every Refund applied so far. | Charge |
Use `charge.*` events when your data model tracks money at the charge level (e.g. multiple captures, retried payments). For most integrations, listening to `checkout.session.completed` and `refund.*` is enough.
## Refund events [#refund-events]
Fire on the lifecycle of a Refund.
| Event | Fires when | `data.object` |
| ---------------- | ---------------------------------------------------------- | -------------------------------------------------------------- |
| `refund.created` | A new Refund record is created. | Refund |
| `refund.failed` | A Refund attempt fails. The Refund's `status` is `failed`. | Refund |
Both API-issued and dashboard-issued refunds emit these. See [Refunds](/integrate/refunds) for the API path.
`charge.refunded` (above) carries the parent Charge with the new Refund nested inside `refunds.data`, so you can key fulfillment off either side depending on which entity your data model tracks.
## Customer events [#customer-events]
Fire on the lifecycle of a Customer.
| Event | Fires when | `data.object` |
| ------------------ | ------------------------------------------------------------ | ------------------------------------------------------------------ |
| `customer.created` | A Customer record is created (via the API or the dashboard). | Customer |
| `customer.updated` | A Customer record's properties change. | Customer |
| `customer.deleted` | A Customer record is deleted. | Customer |
For the difference between Registered and Guest customers, and how guest matching works, see [Customer lifecycle](/integrate/checkout-session/customer-lifecycle).
## Webhook monitoring [#webhook-monitoring]
When a delivery exhausts all retries, XPay emails your account's owners, admins, and developers. The email names the endpoint URL and the event type that couldn't be delivered, so you know which integration to look at. These alerts cover Live mode deliveries only. Failures come by email rather than a webhook on purpose, since a webhook to a broken endpoint would just fail too.
For the retry schedule behind that alert, and how to replay a delivery once you've fixed the handler, see [Replaying & retries](/integrate/webhooks/replaying-and-retries).
## Order on a successful payment [#order-on-a-successful-payment]
A single payment fires more than one event. They arrive close together but not necessarily in order; treat each event as independent and dedup on `event.id`.
| Order | Event | Why |
| ----- | ---------------------------- | --------------------------------------------------- |
| 1 | `charge.succeeded` | The money moved. |
| 2 | `checkout.session.completed` | The session is now `complete`. Fulfillment trigger. |
For a refund:
| Order | Event | Why |
| ----- | ----------------- | ------------------------------------------------- |
| 1 | `refund.created` | A Refund record was created. |
| 2 | `charge.refunded` | The parent Charge's `amountRefunded` was updated. |
You don't need to listen to all of them. Pick the events your data model needs and ignore the rest.
## Reading `data.object` [#reading-dataobject]
The `data.object` field is the same shape you'd get from the matching GET endpoint. Don't reach for the API to re-fetch a resource right after receiving its event; the payload already has everything that endpoint would return.
For the canonical shape of each resource, see the API Reference Object pages:
*
Checkout Session
*
Payment Intent
*
Charge
*
Refund
*
Customer
For how those resources fit together (and which IDs to keep on your order record), see [Object model](/integrate/object-model).
## Idempotency reminder [#idempotency-reminder]
Every event can be delivered more than once: XPay retries on non-2xx, you may manually replay from the Workbench, and a successful response that didn't make it back to XPay produces a duplicate. Use `event.id` as the dedup key in your handler.
For the full pattern, see [Verifying signatures → Idempotency](/integrate/webhooks/verifying-signatures#idempotency).
## Where to next [#where-to-next]
Add an endpoint, pick events, copy the signing secret.
The HMAC-SHA256 verifier and idempotency pattern.
Automatic retry schedule, manual resends, and the lifecycle of a delivery.
Tunnel deliveries to your laptop while you build the handler.
How Checkout Session, Payment Intent, Charge, Refund, and Customer relate.
# Local development (/en/integrate/webhooks/local-development)
Tunnel webhook deliveries to your laptop while you build. Build, fix, replay, repeat.
XPay can't deliver webhooks to `localhost`. To exercise your handler against real test-mode events while you build, expose your dev server through a tunnel that gives you a public HTTPS URL, point a test-mode webhook endpoint at that URL, and iterate.
The flow looks like this:
1. Run your handler locally on a port (say `3000`).
2. Run a tunnel that forwards a public HTTPS URL to that port.
3. In the dashboard, in test mode, create a webhook endpoint pointing at the tunnel URL.
4. Trigger events (test payment, refund, etc.) and watch them land on your laptop.
5. When something's broken, fix the handler and **Resend** the failed event from the Workbench. No need to re-trigger the original action.
## Set up a tunnel [#set-up-a-tunnel]
Any tool that forwards a public HTTPS URL to a local port works. Two common picks:
| Tool | Stable URL | Notes |
| ----------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| Cloudflare Tunnel | Yes, when you create a named tunnel | Free. A named tunnel keeps the same hostname across restarts. Best for long-running dev. CLI is `cloudflared`. |
| ngrok | Random per restart on the free plan | Fast to start. The free plan rotates the hostname every restart, so you re-edit the endpoint URL each time. |
Pick whichever you already have. The webhook flow doesn't care which one signs your TLS cert.
### Run your handler [#run-your-handler]
Start your dev server on whatever port your handler listens on. The examples below use `3000`.
### Start a quick tunnel [#start-a-quick-tunnel]
The simplest path: a one-shot tunnel that gives you a random `*.trycloudflare.com` URL.
```bash
cloudflared tunnel --url http://localhost:3000
```
The command prints a public URL in the form `https://random-words-1234.trycloudflare.com`. Copy it.
For a stable hostname across restarts, set up a named tunnel and config file ([Cloudflare's docs](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/get-started/create-local-tunnel/)). Once configured, your tunnel keeps the same URL forever.
### Add the URL as a webhook endpoint [#add-the-url-as-a-webhook-endpoint]
In the dashboard, switch to **Test mode**, open **Developers → Webhooks**, and click **Add endpoint**. Use:
* **Endpoint URL**: `https://random-words-1234.trycloudflare.com/webhooks/xpay` (your tunnel URL plus your handler's path).
* **Events**: pick the ones your handler cares about. `checkout.session.completed` is the usual starting point.
Click **Add endpoint**, copy the `whsec_*` from the dialog, and paste it into your dev env (`.env.local`, `direnv`, or whatever you use):
```bash
XPAY_WEBHOOK_SECRET=whsec_...
```
Restart your dev server so the env var loads.
### Run your handler [#run-your-handler-1]
```bash
# In the terminal where your dev server is
pnpm dev # or whatever you use
```
### Open a tunnel [#open-a-tunnel]
```bash
ngrok http 3000
```
The terminal shows a `Forwarding` line with a `https://*.ngrok-free.app` URL. Copy it.
### Add the URL as a webhook endpoint [#add-the-url-as-a-webhook-endpoint-1]
In the dashboard's **Test mode**, open **Developers → Webhooks → Add endpoint**, paste the tunnel URL, pick events, copy the `whsec_*` into your env, restart your handler.
### Update the URL each time you restart ngrok [#update-the-url-each-time-you-restart-ngrok]
The free plan rotates the public hostname every time `ngrok http` restarts. When that happens:
* Open the endpoint in **Developers → Webhooks**, click the `⋮` menu, pick **Edit webhook**, and replace the URL.
* The `whsec_*` does not change on edit, so your handler's env stays the same.
## Trigger events to test [#trigger-events-to-test]
Anything that fires a webhook in production fires the same event in test mode. The fastest paths to drive traffic at your handler:
* **Take a test payment.** Create a Payment Link in the dashboard, open it, pay with the success card `5123 4500 0000 0008` and expiry `01/39`. See [Test mode and test cards](/get-started/test-mode) for the full matrix and the failure expiries.
* **Issue a test refund.** From the test-mode dashboard, open a successful transaction and click **Refund**. The handler receives `refund.created` and `charge.refunded`.
* **Replay an event.** The Workbench's Events tab keeps every test-mode event for 90 days. Click **Resend** on any delivery to feed your handler that exact payload again. See [Replaying & retries](/integrate/webhooks/replaying-and-retries).
A typical loop while building: take one test payment, watch it hit your handler, fix what's broken, click **Resend**, repeat. You don't need to take a fresh payment for every iteration.
## Sign-off checklist before flipping to live [#sign-off-checklist-before-flipping-to-live]
Before you stop testing locally and move to live mode, verify each of these against your tunnel-delivered events:
* **Signatures are checked.** Your verifier returns 400 on a tampered body. Test it manually by mutating one byte of a captured request.
* **Replays are idempotent.** Click **Resend** on the same event five times. Your fulfillment runs once.
* **Bad signatures don't crash the handler.** Returning 400 is fine; throwing 500 because of a header parse error is a security smell.
* **The handler acks within 30 seconds.** If your work takes longer, push it to a queue and return 200 immediately. XPay considers anything slower a failure and retries.
* **The signing secret is not committed.** It lives in a local env file or a secret manager, not in your repo.
When all five pass against test events, you're ready to add a separate live-mode endpoint with its own URL and its own `whsec_*`. Keep both endpoints subscribed to the same events; develop and deploy independently.
## Where to next [#where-to-next]
Walkthrough of the dashboard fields, the events selector, and the signing-secret dialog.
The verifier code, framework-specific raw-body recipes, and the idempotency pattern.
The retry schedule and the Resend button you'll lean on while debugging.
Every event you can subscribe to, the envelope shape, and what `data.object` carries.
How Checkout Session, Payment Intent, Charge, Refund, and Customer relate.
# Replaying & retries (/en/integrate/webhooks/replaying-and-retries)
How XPay automatically retries failed deliveries, and how to manually resend an event from the Workbench.
A webhook delivery can reach your endpoint more than once for two distinct reasons. XPay **automatically retries** when your handler returns non-2xx or times out. You can **manually replay** a delivery yourself from the Workbench when you need to fix a handler bug and re-feed the event. Both arrive as ordinary POSTs your verifier already handles; the difference is who initiated them.
## Automatic retries [#automatic-retries]
A delivery is "failed" if any of these happen:
* Your handler returns a non-2xx HTTP response.
* Your handler doesn't respond within **30 seconds** per attempt.
* The TCP connection fails (DNS, refused, reset, TLS error).
When an attempt fails, XPay schedules the next one and continues until it succeeds or runs out of attempts. The schedule depends on the mode the event was created in. Live mode retries over a much longer window than [Test mode](/get-started/test-mode), so a customer-facing endpoint that's briefly down still receives the event once it recovers.
Live mode retries 13 times over roughly 3 days. The gap between attempts grows as failures persist.
| Attempt | Fires after the previous attempt by |
| ------- | ----------------------------------- |
| 1 | immediately |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 1 hour |
| 6 | 2 hours |
| 7 | 4 hours |
| 8 | 8 hours |
| 9 to 13 | 12 hours each |
Test mode retries 5 times over roughly 2 hours 35 minutes, so you see the full failure path quickly while testing.
| Attempt | Fires after the previous attempt by |
| ------- | ----------------------------------- |
| 1 | immediately |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
A delivery succeeds on the first 2xx response. As soon as one attempt returns 2xx, the delivery is `succeeded` and no further retries happen, even if you later return an error to a duplicate.
### When XPay gives up [#when-xpay-gives-up]
After the final attempt fails (the thirteenth in Live mode, the fifth in Test mode), the delivery's status is `failed`.
When a delivery exhausts all retries on an otherwise-healthy endpoint, XPay emails your account's owners, admins, and developers in Live mode so you can fix or replay it. The email names the endpoint URL and the event type. (Test mode deliveries never trigger emails.)
If the endpoint has had no successful delivery at all while that event was failing, XPay treats it as down: it disables the endpoint and stops sending it new events. This applies in both Test and Live mode. A single event that keeps failing won't disable an endpoint that's otherwise delivering fine, only a fully dark one. When XPay disables a Live mode endpoint it always emails you, even if you've muted the delivery-failure alert. A disabled endpoint shows a `Disabled` badge in the dashboard; re-enable it once your handler is fixed.
A failed delivery isn't lost. The Workbench's Events tab keeps deliveries for 90 days and you can replay any of them once your handler is fixed.
## Manual replay [#manual-replay]
The Workbench's **Events** tab shows every event your account has produced in the last 90 days, along with each delivery to each endpoint and every attempt within it. On any delivery row there's a **Resend** button that POSTs the same JSON payload to the same endpoint a fresh time.
Reach for replay when:
* You shipped a bug, your handler dropped a real event, and you need to re-feed it now that the bug is fixed.
* You're debugging a new handler against historical data.
* You added a new event type to your subscription and want to backfill recent events into the new handler.
Replays are not a substitute for fixing the underlying handler. If your handler still 500s, replay just produces another failed delivery.
### What's different from a retry [#whats-different-from-a-retry]
A manual replay is fire-and-forget: **single attempt, no retry schedule.** If your endpoint returns non-2xx to a replay, XPay does not retry it. Click **Resend** again from the Workbench when you're ready for another shot.
Replays also don't update the original delivery's status. They create a new sibling delivery alongside the original, and the Events tab groups them together by URL so you can see the whole timeline against one endpoint at once.
For the click-by-click walkthrough of the Events tab (filtering, drilling into attempts, reading request and response bodies), see [Workbench → Events panel](/integrate/workbench/events-panel).
## What replays look like on your end [#what-replays-look-like-on-your-end]
Your handler can't easily tell a replay from an original or from an automatic retry. They all arrive as POSTs to the same URL with the same JSON body.
What stays the same on every retry or replay of a single event:
* `event.id`, the unique event ID.
* `event.type`, the kind of event it is.
* `event.data.object`, the resource payload.
* The raw bytes of the JSON body.
What changes on every attempt or replay:
* The `t` value in `XPay-Signature` is the timestamp of **this** delivery attempt, not the original event.
* The `v1` signature in `XPay-Signature` is recomputed against that fresh `t` value.
Two consequences for your verifier:
1. **The replay window is per-attempt.** A replay arriving 3 days after the original event still has a `t` value within seconds of "now," so the `abs(now - t) > 300` check passes. Don't compare `t` against the resource's `created` timestamp; they diverge by design.
2. **Signature verification works the same.** Recompute against the fresh `t` and the raw body using your endpoint's signing secret. See [Verifying signatures](/integrate/webhooks/verifying-signatures).
## Build idempotency on `event.id` [#build-idempotency-on-eventid]
Because every retry and every replay carries the same `event.id`, the right key for "have I already handled this?" is `event.id`, not the timestamp or any field on the resource.
```typescript
async function handleEvent(event: { id: string; type: string; data: unknown }) {
// First-write-wins on event.id
const inserted = await db.processedEvents.insertIfNew(event.id);
if (!inserted) return; // already processed; no-op
// Real work happens here
}
```
`insertIfNew` is one row in a table of processed event IDs (or a Redis `SET NX` if you prefer). The work below it runs at most once per event, no matter how many times XPay retries or you replay.
For the full pattern (verify, dedup, ack fast, work in a queue), see [Verifying signatures → Idempotency](/integrate/webhooks/verifying-signatures#idempotency).
## Where to next [#where-to-next]
The full walkthrough of the Events tab. Filter, inspect attempts, click **Resend** on a
delivery.
Recompute the signature on every retry and replay. Idempotency on `event.id`.
Add an endpoint, pick events, copy the signing secret. XPay emails you if an endpoint keeps
failing.
Tunnel deliveries to your laptop while you debug.
Every event you can subscribe to, when it fires, and the object it carries.
# Setting up an endpoint (/en/integrate/webhooks/setting-up-an-endpoint)
Add a webhook endpoint in the dashboard, pick the events you care about, and store the signing secret on your server.
A webhook endpoint is a URL on your server that XPay POSTs JSON events to whenever something interesting happens: a Checkout Session completes, a Refund lands, a Customer is created. You add it once in the dashboard, pick the events you want, and copy the signing secret onto your server so you can verify every payload before acting on it.
Webhook endpoints are managed only in the dashboard, not over the API. The same is true for the
signing secret. Do everything below from `app.xpay.app`.
## Open the Webhooks page [#open-the-webhooks-page]
In the dashboard, open the **Developers** page and click the **Webhooks** tab. The list shows every endpoint configured for the current mode.
Test mode and live mode keep separate endpoint lists. Use the **Test** / **Live** switch at the bottom of the sidebar to flip between them. An endpoint created in test mode does not exist in live mode, and vice versa.
## Create an endpoint [#create-an-endpoint]
### Click **Add endpoint** [#click-add-endpoint]
Top-right of the Webhooks tab. If the list is empty, the same action is on the empty state as **Create webhook endpoint**.
A side panel opens with two required fields: the URL and the events.
### Enter the endpoint URL [#enter-the-endpoint-url]
Type a fully qualified URL into **Endpoint URL**. The form validates the shape, not reachability. The URL must be one your server actually serves.
A few rules:
* **Include the protocol** (`https://...`). The form rejects values that don't parse as URLs.
* **Use HTTPS in production.** Plain HTTP is accepted by the form, but a reachable HTTPS URL with a valid certificate is what you want once you ship. `http://localhost:...` is fine in test mode while you build (see [Local development](/integrate/webhooks/local-development) for tunnels).
* **One endpoint per URL.** If you need to fan out to two systems, create two endpoints with different URLs, or have one handler forward.
### Pick events to listen to [#pick-events-to-listen-to]
Below the URL field is **Select events to listen to**. The picker is split into two sections:
* A **Recommended** section at the top, surfacing the high-signal events most integrations subscribe to first (Checkout Session completion, refund lifecycle, customer lifecycle).
* An **Advanced** collapsible below it that holds the rest, grouped by resource.
Click any group to expand it and tick individual events. Each group has an in-group **Select all** checkbox if you want every event in that group at once. The same resource can appear in both sections when its events span both buckets. Checkout Sessions, for example, shows `checkout.session.completed` under Recommended and the rest of its lifecycle under Advanced.
You must pick at least one. A common starting set:
* `checkout.session.completed` for the lifecycle of the session you created.
* `refund.created` and `refund.failed` to track reversals.
* `customer.created`, `customer.updated`, `customer.deleted` to keep your customer table in sync.
Add `charge.succeeded` / `charge.failed` (under Advanced → Charges) if your data model needs to react at the charge level. Subscribe only to the events your handler actually reads. every subscription costs a delivery, a retry budget, and a signature verification on your server.
For the full list, see [Event reference](/integrate/webhooks/event-reference).
### Click **Add endpoint** to save [#click-add-endpoint-to-save]
XPay creates the endpoint and immediately surfaces a **Webhook signing secret** dialog with a `whsec_*` value.
**Copy it now.** The dashboard will not show it again. If you lose it, your only path is to delete the endpoint and create a new one.
Store it on the server that runs your handler, typically as an environment variable like `XPAY_WEBHOOK_SECRET` loaded once at boot. Click **Done** to close the dialog and return to the list.
## The signing secret [#the-signing-secret]
Every endpoint has its own `whsec_*` secret. XPay signs every event delivered to that endpoint with HMAC-SHA256 using this secret, and your handler uses the same secret to confirm that an incoming event really came from XPay.
Three rules:
* **Never put the secret in client code.** It belongs on the server. A leaked secret lets anyone forge events into your handler.
* **Keep test and live secrets separate.** Each endpoint has its own. Don't reuse one environment variable for both.
* **Treat the secret as opaque.** It's a random string with a `whsec_` prefix. Don't parse it, don't truncate it.
If you suspect a secret leaked, delete the endpoint and recreate it. The dashboard does not surface a rotate-secret action today.
## Test mode and live mode endpoints [#test-mode-and-live-mode-endpoints]
Test and live are isolated environments with their own data and their own webhook endpoints. The mode switch decides which list the dashboard shows; the API key prefix (`sk_test_*` vs `sk_live_*`) decides which list events fire against.
You'll typically want at least two endpoints:
* A **test endpoint** pointing at a tunnel into your dev box, or at a staging server.
* A **live endpoint** pointing at the URL your production server actually serves.
Each gets its own signing secret. Build and verify against the test endpoint first, then add the live endpoint right before you flip your account into live mode.
## Manage your endpoints [#manage-your-endpoints]
The actions menu on each row (the `⋮` button at the right) has three options.
| Action | What it does |
| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| **Edit webhook** | Change the URL, change the subscribed events, or both. The signing secret stays the same, so your verification code keeps working. |
| **Disable webhook** / **Enable webhook** | Stop or resume deliveries without losing the configuration. Useful while debugging a handler. |
| **Delete webhook** | Permanently removes the endpoint. The signing secret becomes invalid. Pending deliveries to that endpoint stop. |
Disabled endpoints stay in the list with a **Disabled** badge. They receive no events and run no retries while disabled. Re-enabling resumes new deliveries; events that fired during the disabled window are not back-filled.
## Where to next [#where-to-next]
Confirm every event came from XPay before you trust it. HMAC-SHA256 with replay protection, in
Node and Python.
The retry schedule when your handler returns non-2xx, and how to manually replay an event from
the dashboard.
Tunnel webhook deliveries to your laptop while you build the handler.
Every event you can subscribe to, when it fires, and the object it carries.
What each event's `data.object` contains, and which IDs to keep on your order record.
Run real test payments through your endpoint before going live.
# Verifying signatures (/en/integrate/webhooks/verifying-signatures)
Confirm every webhook is really from XPay before you trust it. HMAC-SHA256 over the raw body, with a 5-minute replay window.
Anyone with your endpoint URL can POST to it. The signing secret is what tells you which POSTs are actually XPay. Every webhook XPay delivers carries an `XPay-Signature` header. Your handler recomputes the signature using the endpoint's `whsec_*` secret and rejects any request where the value doesn't match.
There is no XPay-shipped library for this today. The verification is small enough to live in your handler in about 20 lines.
## The header [#the-header]
XPay sends one signature header on every delivery:
```
XPay-Signature: t=1730000000,v1=a1b2c3d4...
```
| Field | Meaning |
| ----- | ----------------------------------------------------------------------------------------------------- |
| `t` | Unix timestamp (seconds) at the moment XPay computed the signature. |
| `v1` | Hex-encoded HMAC-SHA256 of the timestamp, a `.`, and the raw body, signed with the endpoint's secret. |
Both fields are required. A malformed header, or a request with no header at all, should be rejected outright.
## How XPay signs [#how-xpay-signs]
XPay computes the signature as:
```
signedPayload = `${timestamp}.${rawRequestBody}`
signature = HMAC-SHA256(endpointSecret, signedPayload)
```
Where:
* `timestamp` is the same value sent in the `t` field.
* `rawRequestBody` is the **bytes** of the JSON body XPay sent. It is not a normalized form. Re-serializing the parsed JSON on your side will not produce the same bytes.
Hex-encode the digest and compare it, in constant time, against the `v1` value.
## How to verify [#how-to-verify]
The verification is five small steps:
1. Read the **raw body** of the request as a string or `Buffer`. Don't parse it as JSON yet.
2. Read the `XPay-Signature` header and split it on `,` into `t={...}` and `v1={...}`.
3. Reject if `abs(now - t) > 300` seconds. This is the replay window.
4. Compute `HMAC-SHA256(secret, "${t}.${rawBody}")` and hex-encode the digest.
5. Constant-time compare the digest against the `v1` value.
Only after all five pass should you `JSON.parse(rawBody)` and act on the event.
```typescript
import crypto from "node:crypto";
const TOLERANCE_SECONDS = 300;
export function verifyXPaySignature(
rawBody: string,
header: string | undefined,
secret: string,
): { valid: boolean; event?: unknown } {
if (!header) return { valid: false };
const parts = Object.fromEntries(
header.split(",").map((p) => {
const [k, ...rest] = p.split("=");
return [k, rest.join("=")];
}),
);
const timestamp = Number.parseInt(parts.t ?? "", 10);
const received = parts.v1;
if (!Number.isFinite(timestamp) || !received) return { valid: false };
// Replay protection
if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > TOLERANCE_SECONDS) {
return { valid: false };
}
const computed = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const a = Buffer.from(computed);
const b = Buffer.from(received);
if (a.length !== b.length) return { valid: false };
if (!crypto.timingSafeEqual(a, b)) return { valid: false };
return { valid: true, event: JSON.parse(rawBody) };
}
```
```python
import hmac, hashlib, time, json
TOLERANCE_SECONDS = 300
def verify_xpay_signature(raw_body: str, header: str, secret: str):
if not header:
return None
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
try:
timestamp = int(parts["t"])
except (KeyError, ValueError):
return None
received = parts.get("v1", "")
if not received:
return None
# Replay protection
if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
return None
computed = hmac.new(
secret.encode(),
f"{timestamp}.{raw_body}".encode(),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(computed, received):
return None
return json.loads(raw_body)
```
The function returns the parsed event when verification succeeds, and a not-valid signal otherwise. Always parse the JSON inside the verifier (after the signature check), not before.
## Wiring the raw body in your framework [#wiring-the-raw-body-in-your-framework]
The single most common reason verification fails is that the framework already parsed the body to JSON and re-serialized it before your handler ran. A re-serialized body has different whitespace and key order from the bytes XPay signed, and the HMAC won't match.
A few framework recipes:
```typescript
import express from "express";
const app = express();
// IMPORTANT: use express.raw on this exact route, before any global JSON parser.
app.post("/webhooks/xpay", express.raw({ type: "application/json" }), (req, res) => {
const rawBody = (req.body as Buffer).toString("utf8");
const result = verifyXPaySignature(
rawBody,
req.header("XPay-Signature"),
process.env.XPAY_WEBHOOK_SECRET!,
);
if (!result.valid) return res.status(400).send("invalid signature");
handleEvent(result.event);
res.status(200).send();
});
```
```typescript
import Fastify from "fastify";
const fastify = Fastify();
// Register a content-type parser that hands you the raw bytes.
fastify.addContentTypeParser("application/json", { parseAs: "string" }, (_req, body, done) =>
done(null, body),
);
fastify.post("/webhooks/xpay", async (req, reply) => {
const rawBody = req.body as string;
const result = verifyXPaySignature(
rawBody,
req.headers["xpay-signature"] as string,
process.env.XPAY_WEBHOOK_SECRET!,
);
if (!result.valid) return reply.code(400).send("invalid signature");
handleEvent(result.event);
return reply.code(200).send();
});
```
```typescript
// app/api/webhooks/xpay/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest) {
const rawBody = await req.text(); // read once, before any .json()
const result = verifyXPaySignature(
rawBody,
req.headers.get("XPay-Signature") ?? undefined,
process.env.XPAY_WEBHOOK_SECRET!,
);
if (!result.valid) {
return new NextResponse("invalid signature", { status: 400 });
}
handleEvent(result.event);
return new NextResponse(null, { status: 200 });
}
```
## What to do when verification fails [#what-to-do-when-verification-fails]
A signature mismatch isn't always an attack. The same code path catches plenty of routine misconfigurations.
| Cause | Symptom |
| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| Framework parsed and re-serialized the body | Verification fails on every request. Most common cause. |
| Wrong endpoint secret | Verification fails on every request. Test vs live secret mixed up, or you're using a stale value from a deleted endpoint. |
| Endpoint deleted and recreated | The new endpoint has a new `whsec_*`. Update your env var. |
| Clock drift on your server | Replay-window rejection. Sync the server clock via NTP. |
| Header rewritten | `t` or `v1` missing. A reverse proxy or ingress modified the header. Allow `XPay-Signature` through unmodified. |
When verification fails, return a non-2xx (`400` is the right choice). XPay's delivery workflow treats that as a failed attempt and retries on the standard schedule. See [Replaying and retries](/integrate/webhooks/replaying-and-retries).
A handler that returns 200 on a bad signature is silently broken: you'll never notice signing changed, and an attacker who finds your URL can forge events.
## Idempotency [#idempotency]
The same event can be delivered more than once. XPay retries on every non-2xx and timeout, and you may also see duplicates if a successful response failed to make it back to XPay. Your handler must be idempotent. This is the webhook side of idempotency; for the `Idempotency-Key` you send on writes, see [Idempotency](/integrate/idempotency).
Use the top-level `event.id` as the dedup key:
```typescript
async function handleEvent(event: { id: string; type: string; data: unknown }) {
// Skip if we've seen this id before
const inserted = await db.processedEvents.insertIfNew(event.id);
if (!inserted) return;
switch (event.type) {
case "checkout.session.completed":
await fulfillOrder(event.data);
break;
// other cases
}
}
```
The `event.id` is unique per event and stable across retries.
## Acknowledge fast, work in the background [#acknowledge-fast-work-in-the-background]
XPay considers a delivery successful only if your handler returns 2xx within 30 seconds. Anything slower triggers a retry, even if your work eventually finished.
The right shape is to verify, dedup, enqueue the event, and return 200 as fast as possible. Run fulfillment, email sending, downstream API calls, and database writes from a queue worker, not from inside the request handler.
```typescript
const result = verifyXPaySignature(rawBody, header, secret);
if (!result.valid) return res.status(400).send();
await queue.add("xpay-event", result.event);
return res.status(200).send();
```
## Where to next [#where-to-next]
Create the endpoint and grab the `whsec_*` secret you'll verify with.
The retry schedule when your handler returns non-2xx, and how to manually replay an event from
the dashboard.
Tunnel webhook deliveries to your laptop while you build the handler.
Every event you can subscribe to, when it fires, and the object it carries.
What each event's `data.object` contains, and the IDs to keep on your order record.
# Events panel (/en/integrate/workbench/events-panel)
Watch every webhook event your account has produced, drill into deliveries and attempts, and replay any of them.
The Events panel is the developer tool you'll spend the most time in once webhooks are wired up. It shows every event your account has produced, every delivery attempt to every endpoint, and the request and response of each attempt. Manual **Resend** lives here too.
## Open the panel [#open-the-panel]
Click the webhook icon in the dashboard's bottom-right dock. The Workbench opens on the **Events** tab. For the drawer mechanics (drag, full-screen, close) and a tour of the other tabs, see [Workbench introduction](/integrate/workbench/introduction).
The list lands pre-filtered to events from the last 24 hours. Change or clear the filter (next section) to look further back.
## Filter the list [#filter-the-list]
The header above the list has four filters and a clear button.
| Filter | What it accepts | Notes |
| -------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Event ID** | Exact `evt_*` ID | Use when you have an ID from a log line and want to jump straight to it. |
| **Date** | Last N hours / days, on a specific day, between two dates | Defaults to the last 24 hours. Widen the range to look further back. |
| **Event Type** | Exact event type or a wildcard | Open the popover, search, pick. Wildcards match a whole resource. |
| **Resource** | Exact resource ID like `pi_test_...` or `cs_test_...` | Optional **Include related resources** checkbox pulls in events for objects connected to that ID (e.g. the Charges and Refunds under a Payment Intent). |
Wildcards in the Event Type filter look like `customer.*`, `charge.*`, `refund.*`. Picking `customer.*` shows every Customer event regardless of subtype. The popover surfaces wildcards at the top of the list and the exact event types grouped by resource below.
Filters compose. `type=charge.*` + `date=last 24 hours` + `resourceId=pi_test_xyz` + **Include related** scopes the list to "every charge event in the last day on this Payment Intent or its descendants."
A **Clear** button appears once any filter is set. The total count on the right (e.g. `42 events`) reflects the current filter.
## The events list [#the-events-list]
Events are listed newest first, grouped under date headers. Each row carries:
* The event type (e.g. `checkout.session.completed`).
* A one-line description derived from the event payload.
* A small badge with a count and a webhook icon when the event has pending deliveries (in flight or retrying). Useful for spotting deliveries that haven't landed yet.
* A timestamp on the right. Hover for the absolute time.
Click a row to drill into the event details. Use **Load more** at the bottom of the list to page through history.
A red row title means the event was a failure (`*.failed` / `*.error`).
## Event details [#event-details]
The detail panel shows the same data you'd see on the wire, plus the delivery state for every endpoint subscribed to the event.
The header has the event type as a title and the `evt_*` ID as a mono-spaced subline.
Below that, a metadata strip with:
| Field | What it is |
| --------------- | --------------------------------------------------------------------------- |
| **Origin date** | When the event was first recorded. The exact second the entity emitted it. |
| **API version** | The API version used to render `data.object`. |
| **Source ID** | The `id` of the resource the event is about (`cs_*`, `pi_*`, `re_*`, etc.). |
| **Source type** | The resource kind. Same value as `data.object.object` in the payload. |
Then a **Deliveries to webhook endpoints** section with three filter tabs (**All**, **Succeeded**, **Failed**) and an accordion of every endpoint that received the event. Failed and retrying deliveries are auto-expanded.
At the bottom, a syntax-highlighted **Event data** viewer showing the full `data.object` exactly as it was delivered.
## Deliveries and attempts [#deliveries-and-attempts]
The Deliveries section groups rows by endpoint URL. A single row covers all deliveries to one URL, including the original delivery and any later **Resend** clicks against that endpoint. The row collapses the noise so you see one entry per endpoint per event.
Each delivery row carries:
* A status badge (success, retrying, or failed) plus the latest HTTP status code.
* The endpoint URL.
* The number of attempts.
* Either the next-retry time (if retrying) or the timestamp of the latest attempt.
* A **Resend** button.
Expand the row (or it auto-expands when failed/retrying) to see the individual attempts.
| Attempt field | What it is |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| HTTP status badge | Color-coded: green for 2xx, red otherwise. |
| Result text | "Delivered successfully" on success, the response body or error message on failure. |
| Attempt number | `Attempt #1`, `#2`, etc. The first attempt is the original send; later numbers are automatic retries against the same delivery. |
| Response time | Round-trip time in ms. |
| Annotation | "Retried automatically" for attempts past #1 on the original delivery, or "Resent manually" for attempts that came from a **Resend** click. |
| Timestamp | When the attempt was issued. |
Click an attempt to open the **Delivery attempt** panel: request headers, request body, response status, response headers, response body, response time. Useful for diffing what your handler actually received against what you expected.
Per-attempt request and response bodies are kept for 90 days. Older attempts still show their summary row (HTTP status, attempt number, timestamp) inside the delivery accordion, but clicking through no longer shows the full request and response payloads. The events themselves and the delivery records around them are not deleted; only the per-attempt detail bodies are pruned.
## Replay an event [#replay-an-event]
Click the **Resend** button on any delivery row. XPay creates a new delivery to the same endpoint URL with the same payload, fresh `t` timestamp, and a fresh signature. Your handler receives a new POST.
Manual replays are **single-shot**: if your handler returns non-2xx to a replay, XPay does not retry it. Click **Resend** again when you're ready for another shot. Replays don't change the original delivery's status; they appear as a new attempt grouped under the same endpoint URL.
For the lifecycle (automatic retries vs manual replays, idempotency on `event.id`), see [Replaying and retries](/integrate/webhooks/replaying-and-retries).
## A typical debug loop [#a-typical-debug-loop]
1. Trigger the action that produces the event (test payment, refund, etc.).
2. Open the Workbench, find the event in the Events list.
3. Open its detail panel and look at the failed delivery: the response body and headers tell you what your handler returned.
4. Fix the handler, redeploy.
5. Click **Resend** on the failed delivery.
6. Watch the new attempt land successfully.
You don't need to take a fresh test payment to verify the fix. Replays use the original event's stored payload, so any event still in the list can be re-fed into a fixed handler.
## Where to next [#where-to-next]
The retry schedule for automatic retries, the contract for manual replays, and the `event.id`
idempotency pattern.
Make every replay you click here pass verification. HMAC-SHA256 over the raw body, with the same
secret as the original.
Add or edit the endpoint URL the events panel is delivering to.
Every event you'll see in this list, when it fires, and the object it carries.
# Health panel (/en/integrate/workbench/health-panel)
Spot the patterns in your account's recent failures. Errors grouped by root cause, sorted by frequency, with a sample log per group.
The Health panel rolls up every failed API request from the last hour, day, or week and groups them by their underlying cause. Instead of scrolling through a thousand 4xx rows in the Logs panel, you see one row per distinct error group, sorted with the loudest at the top.
Use it after a deploy to catch regressions, before a release to verify everything's quiet, or when a merchant says "things have been weird today" and you want a quick read on what's actually broken.
For the drawer mechanics (open, drag, full-screen), see [Workbench introduction](/integrate/workbench/introduction).
## Open the panel [#open-the-panel]
Click the heart-pulse icon in the dashboard's bottom-right dock. The Workbench opens on the **Health** tab pre-filtered to the last 24 hours.
The dock icon turns orange when your account has recent errors, so a glance at the dock tells you whether it's worth opening at all.
## Filters [#filters]
Two filters at the top:
| Filter | What it does |
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Time** | Three options: **Last hour**, **Last 24 hours**, **Last 7 days**. Defaults to last 24 hours. |
| **API endpoint** | Free-text path filter, applied client-side after the groups load. Supports `*` wildcards. The popover shows a hint: "You can also use wildcards, like `/checkout/*`". |
A **Reset filters** link appears next to the filter row when the API endpoint filter is set. The total count on the right (e.g. `12 error groups`) reflects the filtered view.
## Reading the group list [#reading-the-group-list]
The left column is a list of error groups, sorted by count (highest first). Each row carries:
* A mono-spaced error identifier in red. This is what XPay grouped the errors by (see [How grouping works](#how-grouping-works) below).
* A count badge on the right, the number of errors in this group within the time window.
* The HTTP method and path the errors hit (when the group is by code or by param; absent for type-only groups).
* A sample error message from the most recent occurrence, truncated to one line.
Click a row to open the group's detail.
## Group detail: Summary + Logs [#group-detail-summary--logs]
When you select a group, the right panel splits in two on desktop, stacks on mobile.
### Summary [#summary]
The summary panel surfaces the latest occurrence's request body (when present) and two sub-sections.
**Impact**:
| Field | What it is |
| --------------- | ------------------------------------------------------------------------------------------------------ |
| **Occurrences** | The total count plus the resolved time-range label ("in the last 24 hours", "in the last hour", etc.). |
| **Path** | The API endpoint the errors hit. |
| **Source** | "Dashboard" (with the team-member name if available) or the API key flavor. |
**Error Details** (from the most recent occurrence in this group):
| Field | What it is |
| -------------- | --------------------------------------------------------------------------------------- |
| **Type** | The error's `errorType`, e.g. `invalid_request_error`, `api_error`. |
| **Code** | The `errorCode`: the specific identifier, e.g. `resource_missing`, `parameter_unknown`. |
| **Param** | The request body field that triggered the error, e.g. `lineItems[*][price]`. |
| **Status** | The HTTP status the response returned. |
| **IP Address** | The client IP. |
| **Origin** | The `Origin` header value. |
### Logs [#logs]
The right side shows up to 10 of the most recent failed requests in this group as compact rows. Click any row to drill into the full Logs-panel-style detail view (request, response, headers, body, error fields).
## How grouping works [#how-grouping-works]
Two failed requests are placed in the same group when they share the same root cause, even if every other detail differs. XPay picks the grouping key in this priority order:
1. **By param.** If the error has a `param` field (the failing request body field), the group key is ` `. Array indices in the param are normalized: `lineItems[0][price]` and `lineItems[5][price]` collapse into `lineItems[*][price]`. This is the most-specific grouping and catches "this one field is wrong everywhere".
2. **By code.** Otherwise, if the error has a `code`, the group key is ``. Catches "this endpoint keeps returning the same error code".
3. **By type.** Otherwise, the group key is just the `errorType`. The fallback for errors that lack code or param. Group rows of this type don't show a method or path.
Two consequences worth knowing:
* A single bug in your code that produces the same `param`-level rejection on every line item shows up as one group with a high count, not many small groups.
* An endpoint that fails for two different reasons (one missing field, one bad enum) shows up as two groups, even though they hit the same path.
## Retention [#retention]
The Health panel reads the same `api_request_logs` data the [Logs panel](/integrate/workbench/logs-panel) uses, so it's bounded by the same **30-day** retention. Errors older than 30 days disappear from the view because the source rows are physically pruned.
The Time filter caps at **last 7 days**, well inside the retention window.
## A typical use [#a-typical-use]
Three loops the panel is built for:
1. **Post-deploy regression check.** Open Health, set time to **Last hour**, scan the top of the list. A new group at the top means your deploy started producing a new class of error.
2. **Backlog grooming.** Set time to **Last 7 days**, sort is already by count. The top three groups are usually 80% of your error volume; fix those first.
3. **A merchant-reported issue.** Filter the API endpoint to the surface they mentioned (e.g. `/refunds`), set time to **Last 24 hours**. The list narrows to errors on that exact surface so you can see which group their case fell into.
## Where to next [#where-to-next]
The drawer mechanics, the four tabs at a glance, and how to open the Workbench.
The per-request view: response body, request body, headers. Where you land when you click a log
row from a group.
Drill into a specific resource and see its logs, events, and the graph around it.
The error envelope, the full list of `errorType` and `errorCode` values, and how to handle them
in code.
The webhook side: every event your account fired, every delivery attempt, every retry.
What every prefix means and how the resources fit together.
# Inspector (/en/integrate/workbench/inspector)
Paste any XPay ID and see the full graph of related resources, the JSON payload, the events that fired on it, and the request logs around it.
The Inspector is the "what is this thing?" tool. Hand it any XPay ID and it loads the resource, the resources around it, the events that fired against it, and the request logs that touched it, in one pane.
You'll reach for it when a payment looks weird in the dashboard, when you're tracing a webhook back to its source, or when an order ID on your side has a `pi_*` and you want to see where the money is.
For the drawer mechanics (open, drag, full-screen), see [Workbench introduction](/integrate/workbench/introduction).
## Open the Inspector [#open-the-inspector]
Two ways:
* **The dock icon** in the bottom-right (a binoculars). The icon is context-aware: when the page you're on contains an inspectable ID in its URL, the icon picks it up and shows the ID truncated next to the binoculars. Clicking opens the Inspector pre-loaded with that resource.
* **From any Workbench tab**, click **Inspector** in the tab bar.
If you open the Inspector with no ID detected, you land on an empty search screen titled **Peek under the hood with Inspector**. Type or paste any inspectable ID into the search and the panel populates.
## Auto-inspect: follow the page [#auto-inspect-follow-the-page]
By default, the Inspector follows the dashboard as you navigate. Open the Workbench once on a transaction page, click into a refund detail or a customer profile, and the Inspector updates to that new resource without you typing anything.
The behavior is controlled by a switch labeled **Auto-inspect** in the bottom of the Inspector sidebar. The description reads: "Inspector will automatically update based on the resource displayed in the dashboard." Toggle it off to pin the Inspector to one resource while you click around the rest of the dashboard.
The dock icon's ID detection is independent of this switch. The icon always shows the current page's ID when one is present, regardless of whether Auto-inspect is on or off.
## Inspectable IDs [#inspectable-ids]
The Inspector recognizes every prefixed ID XPay issues. The most common ones:
| Prefix | Resource |
| --------- | ---------------------------------------------------------------------------------------- |
| `cs_` | Checkout Session |
| `pi_` | Payment Intent |
| `ch_` | Charge |
| `re_` | Refund |
| `cus_` | Customer |
| `txn_` | Balance Transaction |
| `plink_` | Payment Link |
| `prod_` | Product |
| `price_` | Price |
| `coupon_` | Coupon |
| `promo_` | Promotion code |
| `pob_` | Payout Batch |
| `pm_` | Payment Method |
| `li_` | Line Item |
| `evt_` | Event |
Pasting an ID with a prefix the Inspector doesn't recognize returns "No object was found matching the ID provided."
## The Data map [#the-data-map]
The left sidebar of the Inspector is the **Data map**: a tree of the inspected entity and the resources connected to it. For a Checkout Session, the tree expands to show its Payment Intent, its Charges, its Refunds, its Balance Transactions, and the Customer attached to the session, all clickable.
The label at the top reads **Inspecting**, with a search box that lets you swap to a different ID without leaving the Inspector. Below the tree label (**Data map**), the resource graph renders. Click any node to switch the inspection target to that resource. The right pane updates immediately.
The sidebar collapses to an icon strip on desktop (use the toggle in its header) and slides over as a sheet on mobile.
## Sub-tabs [#sub-tabs]
The right pane has three tabs across the top, all scoped to the resource you currently have selected.
| Tab | What it shows |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Overview** | The full JSON payload of the resource. Same shape `GET //:id` returns. |
| **Logs** | Server-side request logs that touched this resource. See [Logs panel](/integrate/workbench/logs-panel) for the full reference. |
| **Events** | Webhook events emitted against this resource (and, when "Include related" is on, against the resources around it). See [Events panel](/integrate/workbench/events-panel). |
The header above the tabs shows the resource type (`PAYMENT INTENT`, `CHARGE`, etc.) and the matching API path so you can confirm at a glance which resource you're looking at.
## A typical use [#a-typical-use]
Three patterns merchants reach for the Inspector:
1. **A webhook fired and you don't know what it's about.** Copy the `evt_*` from your handler logs, paste it into the Inspector search, expand the Data map, and see the resource the event was about and what was around it at the time.
2. **A customer disputes a charge.** Open the transaction in the dashboard. The dock icon picks up the `pi_*`. Open the Workbench and the Inspector lands on the Payment Intent. Click the Refund node in the Data map to confirm whether you've already issued one.
3. **You're debugging a Payment Link that suddenly stopped working.** Paste the `plink_*` into the Inspector. The Data map shows the Checkout Sessions created from the link, each clickable. Drill into the most recent failed session, switch to **Events** to see why.
## Where to next [#where-to-next]
The drawer mechanics, the four tabs at a glance, and how to open the Workbench.
The full Events tab reference, including filters, deliveries, and Resend.
Per-request server logs scoped to the resource you have selected.
What every prefix means, how the resources fit together, and which IDs to keep on your order
record.
The webhook side: subscribe to events on the resources you'll inspect.
# Introduction (/en/integrate/workbench/introduction)
The Workbench is a slide-up drawer that lives in the dashboard. Inspect resources, watch events, read logs, and check health without leaving the page.
The Workbench is the developer pane built into the dashboard. It opens as a drawer at the bottom of any page, lets you drill into a specific resource or trail of events without context-switching, and stays out of the way the rest of the time.
This page covers the things that are the same across every Workbench tab: how to open it, the controls in the header, and what each tab is for. Each tab gets its own dedicated page below.
## Open the Workbench [#open-the-workbench]
Look at the bottom-right of the dashboard for a small dock with a webhook icon. Click it.
The drawer animates up and lands open at a comfortable height. The first time you open it from the dock's webhook icon, it lands on the **Events** tab pre-filtered to the last 24 hours. Other dock icons open other tabs (the **Inspector** dock icon opens Inspector, etc.).
Once it's open:
* A drag handle at the top of the drawer lets you resize it taller or shorter.
* The **Full Screen** button in the top-right expands the drawer to fill the page. Click again to restore.
* The **Close** button in the top-right closes the drawer entirely. Open it again any time from the dock.
## The tabs [#the-tabs]
Four tabs across the top of the Workbench. Click any of them to switch.
| Tab | What it's for |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Events** | Every webhook event your account has produced, with deliveries and attempts per endpoint. See [Events panel](/integrate/workbench/events-panel). |
| **Logs** | Server-side request and error logs. See [Logs panel](/integrate/workbench/logs-panel). |
| **Health** | Aggregated error groups and incident-style views. See [Health panel](/integrate/workbench/health-panel). |
| **Inspector** | Drill into a single resource (Checkout Session, Payment Intent, Charge, Refund, Customer, etc.) and see everything around it. See [Inspector](/integrate/workbench/inspector). |
## Where to next [#where-to-next]
Paste any XPay ID and see the full graph of related resources, events, and money movements.
Every webhook event, every delivery attempt, every retry. Resend any of them.
Per-request server logs scoped to your account.
Error groups and recent incidents.
# Logs panel (/en/integrate/workbench/logs-panel)
Every API request your account has made: method, path, status, headers, body, response. Filter by error, by resource, by source.
The Logs panel is the request-level view of everything that's hit your account: every API call, every dashboard mutation, with method, path, status, request body, response body, headers, and error details if the request failed. It's the tool you reach for when an integration call returned a 400 and you want to see exactly what XPay received and what it answered.
For the drawer mechanics (open, drag, full-screen), see [Workbench introduction](/integrate/workbench/introduction).
## Open the panel [#open-the-panel]
Click the up-down arrows icon in the dashboard's bottom-right dock. The Workbench opens on the **Logs** tab pre-filtered to the last 24 hours.
You can also open the Workbench any other way and switch to **Logs** in the tab bar.
## Filter the list [#filter-the-list]
Three filters are visible above the list, plus a **More filters** dialog for everything else.
| Filter | What it does |
| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| **Resource ID** | Free-text search. Find every request that touched a specific `pi_*`, `cs_*`, `cus_*`, etc. |
| **Date** | Last N hours / days, on a specific day, between two dates. Defaults to last 24 hours when you open the panel from the dock icon. |
| **Status** | **Succeeded** (2xx) or **Failed** (4xx and 5xx). Pick one to narrow the list. |
| **More filters** (dialog) | HTTP method, API endpoint path, IP address, Source (Dashboard / API), error code, error type, error param. |
A **Clear filters** button appears once any filter is set. The total count on the right (e.g. `42 logs`) reflects the filtered set.
The "More filters" button shows a count badge (`+3`, `+5`) when extra filters are active so you don't lose track of what's narrowing the view.
## Reading the list [#reading-the-list]
Logs are listed newest first, grouped under date headers (Today, Yesterday, then a date). Each row carries:
* A status badge: `200 OK` (green) or `4xx ERR` / `5xx ERR` (red) with the actual code.
* The HTTP method (color-coded per method).
* The path (truncated to fit).
* The time.
Click a row to drill into the request. Use **Load more** at the bottom to page through history.
## Reading a request [#reading-a-request]
The detail panel shows the full request and response. The header strip carries the method, the path, and the status badge. Below that:
| Field | What it is |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| **Request ID** | The `req_*` ID. Stable across retries. Quote this when reaching out for support. |
| **Time** | Date and time of the request, second-resolution. |
| **Duration** | Server-side response time in ms. |
| **IP Address** | The client IP. Useful for tracing automated calls. |
| **Origin** | The `Origin` header value, when present. |
| **Idempotency Key** | The `Idempotency-Key` header value from the request, if you sent one. |
| **API Key** | The first few characters of the secret key used, redacted. Lets you tell which key made the call without ever exposing it. |
| **Source** | "Dashboard" (and the team-member name) for dashboard-driven mutations, otherwise an API call. |
| **User Agent** | The client's `User-Agent` header. |
If the request returned an error, an **Error Insight** section follows the metadata: the human-readable error message, plus the structured `errorType`, `errorCode`, and `errorParam` (each clickable as a quick filter).
Below that, four payload sections render when present:
* **Response Body**: the JSON XPay returned. For successful `GET` requests, the panel shows the literal note: "XPay doesn't store the response body from successful GET requests." Successful POST / PATCH / DELETE responses are stored.
* **Request Body** (heading is `Request POST Body` / `Request PATCH Body` / `Request DELETE Body` depending on the request): the JSON you sent.
* **Query Parameters**: the parsed query string, key by key.
* **Request Headers**: every header XPay received, including the auth header (already redacted).
Each section uses the same JSON viewer as elsewhere in the Workbench: keys are clickable, IDs are linked, and large objects collapse by default.
## Retention [#retention]
API request logs are kept for **30 days**. After 30 days a row is physically pruned and disappears from the panel. The events panel and webhook deliveries have separate retention windows; see [Events panel](/integrate/workbench/events-panel).
If you need request history past 30 days, ship your own logs from your server. The Workbench is a real-time debugging tool, not a long-term archive.
## A typical use [#a-typical-use]
Three loops the Logs panel pays for:
1. **A live API call returned 400 and you want to know why.** Open the dock icon, the panel lands pre-filtered to the last 24 hours. Sort by `Status: Failed`. Click the row. The Error Insight section names the field that failed validation; the Request Body shows what you sent.
2. **Your retry logic doubled-up something.** Filter by `Idempotency Key` (or by `Resource ID` of the resource it affected). Compare the two rows side by side. The detail panel makes it obvious whether both succeeded or one was a no-op.
3. **A dashboard user changed something you didn't expect.** Filter `Source = Dashboard`. The Source field on each row tells you which team member made the change.
## Inspector context [#inspector-context]
When the Logs panel renders inside the **Inspector** tab as a sub-tab (instead of as the top-level Logs tab), it auto-scopes to the resource you're inspecting. The Resource ID filter is hidden, and "Include related" is implicit so you also see logs that touched the resources around it (e.g. for a Payment Intent, logs that touched its Charges and Refunds). Same filters otherwise. See [Inspector](/integrate/workbench/inspector).
## Where to next [#where-to-next]
The drawer mechanics, the four tabs at a glance, and how to open the Workbench.
Drill into a single resource and see its logs, events, and the graph around it.
Webhook events, deliveries, and attempts. The other half of the debugging story.
Aggregate error groups across recent requests.
The error envelope, the full list of `errorCode` and `errorType` values, and how to handle them.
What the `Resource ID` filter accepts and how the resources fit together.