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

<Callout type="info">
  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.
</Callout>

## 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 &#x2A;*`clientSecret`** that scopes the SDK to this specific session. You'll ship it to the frontend.

<Tabs items="[&#x22;cURL&#x22;, &#x22;Node.js&#x22;, &#x22;Python&#x22;]">
  <Tab value="cURL">
    ```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
          }
        ]
      }'
    ```
  </Tab>

  <Tab value="Node.js">
    ```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
    ```
  </Tab>

  <Tab value="Python">
    ```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
    ```
  </Tab>
</Tabs>

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]

<Tabs items="[&#x22;Vanilla JavaScript&#x22;, &#x22;React&#x22;]">
  <Tab value="Vanilla JavaScript">
    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
    <script src="https://checkout.xpay.app/v1/sdk.js"></script>
    <script>
      // Global factory, NOT loadXPay. `loadXPay` only exists in the npm package.
      const xpay = XPay("pk_test_...");

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

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

  <Tab value="React">
    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 (
        <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.
  </Tab>
</Tabs>

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 <ApiLink href="/api-reference/objects/checkout-session">Checkout Session</ApiLink>. |
| `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 `<CheckoutButton />`: `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.

<Cards>
  <Card icon="<PanelTop />" title="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.
  </Card>

  <Card icon="<LayoutGrid />" title="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.
  </Card>
</Cards>

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

`<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 [#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).

<Tabs items="[&#x22;Vanilla JavaScript&#x22;, &#x22;React&#x22;]">
  <Tab value="Vanilla JavaScript">
    ```typescript
    const checkout = xpay.checkout({
      clientSecret,
      mode: "modal",
      locale: "ar",
      appearance: {
        colorMode: "dark",
        borderStyle: "rounded",
        colors: { primary: "#635bff" },
      },
    });
    ```
  </Tab>

  <Tab value="React">
    ```tsx
    <CheckoutButton
      clientSecret={clientSecret}
      checkoutOptions={{
        locale: "ar",
        appearance: {
          colorMode: "dark",
          borderStyle: "rounded",
          colors: { primary: "#635bff" },
        },
        onComplete: (result) => {
          /* ... */
        },
      }}
    >
      Pay now
    </CheckoutButton>
    ```
  </Tab>
</Tabs>

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]

<Cards>
  <Card icon="<Network />" title="Object model" href="/integrate/object-model">
    How Checkout Session, Payment Intent, Charge, and Customer relate, and which IDs to keep on your
    order record.
  </Card>

  <Card icon="<RotateCcw />" title="Refunds" href="/integrate/refunds">
    Reverse a successful payment using the `pi_*` from `result.paymentIntentId`.
  </Card>

  <Card icon="<Webhook />" title="Webhooks setup" href="/integrate/webhooks/setting-up-an-endpoint">
    Add an endpoint and grab the `whsec_*` signing secret.
  </Card>

  <Card icon="<ShieldCheck />" title="Verifying signatures" href="/integrate/webhooks/verifying-signatures">
    The full HMAC-SHA256 verification recipe with replay protection.
  </Card>

  <Card icon="<FlaskConical />" title="Test mode and test cards" href="/get-started/test-mode">
    The test card list and the outcomes you can simulate.
  </Card>

  <Card icon="<GitBranch />" title="Want a different pattern?" href="/get-started/choose-your-integration">
    Compare Drop-in against Hosted Checkout, Elements, and Payment Links.
  </Card>

  <Card icon="<SquareCode />" title="Vanilla HTML example" href="https://github.com/xpayeg/xpay-examples/tree/main/vanilla-html">
    `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.
  </Card>

  <Card icon="<Atom />" title="Next.js example store" href="https://github.com/xpayeg/xpay-examples/tree/main/nextjs-react">
    Drop-in modal at `/checkout?ui=embedded` and dynamic amounts at `/donate`, inside a full Next.js
    storefront.
  </Card>
</Cards>