docs
IntegrateIntegration 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. If you don't want any frontend code, use Hosted Checkout or a Payment Link.

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

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

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

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

{
  "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.redirect.url is still meaningful. When the payment succeeds, the SDK passes that URL into your onComplete callback as redirectUrl. Your code decides whether to navigate. If you used hosted_confirmation, redirectUrl is undefined: the iframe shows the success page briefly and auto-closes the modal.

3. Load the SDK on your frontend

Install the loader from npm:

npm install @xpay/sdk-js

Then load and call from your page:

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

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();
});
import { loadXPay } from "@xpay/sdk-js";
import { XPayProvider, CheckoutButton } from "@xpay/sdk-react";

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

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

<XPayProvider> accepts either an XPayInstance or Promise<XPayInstance>, 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/sdk.js once per page; subsequent loadXPay() calls return the same cached instance.

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.

EventWhenPayload
readyThe session loaded and the iframe is ready to render.The full Checkout SessionAPI.
confirmedThe customer hit Pay. Useful if you want to disable the close button or show a "processing" state.None.
completeThe payment succeeded. The iframe auto-closes the modal 300ms later.{ status: "succeeded", paymentIntentId, chargeId?, redirectUrl? }.
closeThe modal closed. Fires for manual close, after complete, or if checkout.close() is called.None.
errorThe 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 <CheckoutButton />: onComplete, onClose, onReady, onConfirmed, onError. They map one-to-one to the events above.

mode controls whether the iframe overlays the page or sits inside a container.

mode: modal

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.

mode: inline

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.

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

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

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.

const checkout = xpay.checkout({
  clientSecret,
  mode: "modal",
  locale: "ar",
  appearance: {
    colorMode: "dark",
    borderStyle: "rounded",
    colors: { primary: "#635bff" },
  },
});
<CheckoutButton
  clientSecret={clientSecret}
  checkoutOptions={{
    locale: "ar",
    appearance: {
      colorMode: "dark",
      borderStyle: "rounded",
      colors: { primary: "#635bff" },
    },
    onComplete: (result) => { /* ... */ },
  }}
>
  Pay now
</CheckoutButton>

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

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. The same code applies here.

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.

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

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.
  • Dedupe your own session creates on session.id. If your POST /checkout/sessions request times out, don't blindly retry. Store the session ID from the first successful response and reuse it.
  • 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

On this page