> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ionicfi.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Embedded checkout

> Accept payments inside your own page. Buyers never leave your site.

Embedded checkout renders the Ionic-hosted payment form inside an iframe on
your page. Card details are collected inside the frame, so they never touch
your page or your servers, and your buyer completes the purchase without
leaving your site.

If a hosted redirect works for your product, start with
[Checkout](/guides/checkout) instead — it is less integration surface. Choose
embedded when the buyer staying on your domain matters.

## How it works

<Steps>
  <Step title="Register your website's domain">
    One-time setup. Ionic only allows registered domains to embed checkout.
  </Step>

  <Step title="Create a session on your server">
    `POST /v1/checkout/sessions` with `ui_mode: "embedded"`, using your secret
    key. Compute prices server-side.
  </Step>

  <Step title="Mount the checkout on your page">
    The browser SDK frames the session and manages the secure handshake,
    sizing, and lifecycle callbacks. React apps use the `<CheckoutEmbed>`
    component; everything else uses the JavaScript SDK directly.
  </Step>

  <Step title="Fulfill on the webhook">
    Fulfill after a verified `checkout.session.completed` webhook. The browser callback
    is a UX signal only.
  </Step>
</Steps>

## 1. Register your website's domain

Register each origin that will embed checkout. Until an origin is registered,
the frame refuses to render on it — this is what prevents another site from
embedding checkout as your store.

```bash theme={null}
curl https://api.ionicfi.com/v1/web_domains \
  -H "Authorization: Bearer sk_v1_test_..." \
  -H "Content-Type: application/json" \
  -d '{"domain": "https://shop.example.com"}'
```

The `domain` field accepts a bare domain (`shop.example.com`, treated as
`https://`) or a full origin with scheme and port. For local development,
register your localhost origin — `http://` is allowed for localhost only:

```bash theme={null}
  -d '{"domain": "http://localhost:3000"}'
```

Domains registered with a test key apply to test mode only; register
production domains again with your live key when you go live.

## 2. Create a session on your server

Create the session from your server with your secret key. Always compute
prices server-side; never trust amounts sent from the browser.

<CodeGroup>
  ```ts TypeScript theme={null}
  import { Ionic } from "@ionicfi/sdk";

  const ionic = new Ionic({ token: process.env.IONIC_SECRET_KEY });

  const session = await ionic.checkout.sessions.create({
    "Idempotency-Key": "order-8241-checkout",
    mode: "payment",
    ui_mode: "embedded",
    line_items: [
      { name: "House Blend, 12oz", amount: 1800, currency: "usd", quantity: 2 },
    ],
  });
  ```

  ```bash curl theme={null}
  curl https://api.ionicfi.com/v1/checkout/sessions \
    -H "Authorization: Bearer sk_v1_test_..." \
    -H "Content-Type: application/json" \
    -H "Idempotency-Key: order-8241-checkout" \
    -d '{
      "mode": "payment",
      "ui_mode": "embedded",
      "line_items": [
        {"name": "House Blend, 12oz", "amount": 1800, "currency": "usd", "quantity": 2}
      ]
    }'
  ```
</CodeGroup>

Return exactly three response fields to your page:

| Field           | Purpose                                                |
| --------------- | ------------------------------------------------------ |
| `id`            | The session identifier (`cs_...`)                      |
| `client_secret` | Grants the browser the right to drive this one session |
| `embed_url`     | The URL the SDK frames — pass it through untouched     |

The `client_secret` is scoped to this single session and expires with it. It's
safe to send to the buyer's browser — don't log it or reuse it across
sessions.

## 3. Mount the checkout on your page

Three ways in, one integration underneath. All three load the same runtime
from `https://js.ionicfi.com/v1/ionic.js` at mount time — the card-handling
code is never bundled into your app. The `/v1` URL stays current, so checkout
updates reach your integration without requiring an SDK upgrade.

<Tabs>
  <Tab title="React">
    ```bash theme={null}
    npm install @ionicfi/react
    ```

    Fetch the session from your server and hand it to `<CheckoutEmbed>`:

    ```tsx theme={null}
    import { useEffect, useState } from "react";
    import { CheckoutEmbed } from "@ionicfi/react";

    export function CheckoutPage() {
      const [session, setSession] = useState(null);

      useEffect(() => {
        fetch("/api/checkout", { method: "POST" })
          .then((res) => res.json())
          .then(setSession);
      }, []);

      if (!session) return <p>Preparing checkout…</p>;

      return (
        <CheckoutEmbed
          session={session}
          onComplete={({ sessionId }) => {
            // Show your confirmation page. Do not fulfill here — see step 4.
            window.location.href = `/order/confirmation?session_id=${sessionId}`;
          }}
          onCancel={() => {
            window.location.href = "/cart";
          }}
        />
      );
    }
    ```

    The component manages the iframe's lifecycle for you: the frame holds the
    buyer's in-progress card entry, so it is torn down and remounted only when the
    session itself changes — never because a parent re-render produced new
    callback identities. Passing a new session object with the same values does
    not remount.

    Digital wallets are offered automatically when the session is eligible; pass
    `wallets={[]}` for card only, or a specific list to restrict the offer. See
    [Apple Pay](#apple-pay) below.
  </Tab>

  <Tab title="JavaScript">
    ```bash theme={null}
    npm install @ionicfi/js
    ```

    `@ionicfi/js` is a thin loader — it injects the runtime from Ionic's CDN and
    returns the same `IonicCheckout` the script tag exposes:

    ```ts theme={null}
    import { loadIonicCheckout } from "@ionicfi/js";

    const session = await fetch("/api/checkout", { method: "POST" }).then((res) =>
      res.json(),
    );

    const IonicCheckout = await loadIonicCheckout();
    const checkout = IonicCheckout.mountSession("#checkout", {
      session,
      onComplete: ({ sessionId }) => {
        // Show your confirmation page. Do not fulfill here — see step 4.
        window.location.href = "/order/confirmation?session_id=" + sessionId;
      },
      onCancel: () => {
        window.location.href = "/cart";
      },
    });
    ```

    When your view unmounts (an SPA route change, for example), call
    `checkout.destroy()`.
  </Tab>

  <Tab title="Script tag">
    No build step required. Add the SDK:

    ```html theme={null}
    <script src="https://js.ionicfi.com/v1/ionic.js" crossorigin="anonymous"></script>
    ```

    Do not pin an `integrity` hash on this tag — the file's contents change as new
    versions ship, so a pinned hash will eventually break the tag.

    Mount the session into a container element:

    ```html theme={null}
    <div id="checkout"></div>
    <script>
      const session = /* {id, client_secret, embed_url} from your server */;
      const checkout = window.Ionic.IonicCheckout.mountSession("#checkout", {
        session,
        onComplete: ({ sessionId }) => {
          // Show your confirmation page. Do not fulfill here — see step 4.
          window.location.href = "/order/confirmation?session_id=" + sessionId;
        },
        onCancel: () => {
          window.location.href = "/cart";
        },
      });
    </script>
    ```

    When your view unmounts, call `checkout.destroy()`.
  </Tab>
</Tabs>

The frame sizes itself to its content and resizes as the buyer moves through
the flow. Give the container your page's content width.

What the SDK handles, and what you handle:

| The SDK handles                           | You handle                                        |
| ----------------------------------------- | ------------------------------------------------- |
| Creating and sizing the frame             | Creating sessions on your server                  |
| The secure `client_secret` handshake      | Passing the session object through untouched      |
| Card fields, validation, decline retry UX | Navigation on `onComplete` / `onCancel`           |
| Wallet eligibility and the payment sheet  | Fulfillment — from the webhook, never the browser |

### Handling errors

Recoverable problems — network blips, card declines — keep their own retry UI
inside the frame and never reach your code. What does reach your `onError`
carries a `terminal` flag, and that flag is what you branch on, not the code:

```ts theme={null}
onError: (error: EmbeddedCheckoutError) => {
  if (error.terminal) {
    // This frame will not change state on its own. Create a fresh session
    // on your server and mount it, or route the buyer back to the cart.
  } else {
    // The frame may still succeed (today: only "checkout_timeout", the
    // SDK's own readiness deadline). Leave it in place; consider logging.
  }
}
```

Codes you may see today: `expired`, `not_found`, `init_timeout`,
`payment_failed`, `tokenization_failed` (all terminal) and `checkout_timeout`
(not terminal). New codes can appear as the product grows — an unrecognized
code still carries `terminal`, so handlers written against the flag keep
working.

`terminal` is a statement about this mounted frame, never about the payment.
A payment can succeed and the frame still die afterward — fulfillment stays
keyed to the webhook regardless of what `onError` reports.

In React, recovering from a terminal error is a remount: create a new session
on your server and pass it down (a changed session remounts on its own), or
bump a `key` on `<CheckoutEmbed>` to retry the same session after a load
failure:

```tsx theme={null}
<CheckoutEmbed key={attempt} session={session} onError={handleError} />
```

## 4. Fulfill on the webhook

<Warning>
  Do not fulfill orders from `onComplete` or from your confirmation page
  loading. Buyers close tabs after paying, and connections drop before
  callbacks run. Fulfillment triggered only from the browser will
  miss paid orders.
</Warning>

Fulfill from the verified `checkout.session.completed` webhook delivered to
your server, idempotently, keyed by the session `id`. See
[Webhooks](/webhooks/quickstart) for receiver setup — on Node,
`webhooks.unwrap()` from `@ionicfi/sdk` verifies and types the event in one
call.

To check a payment without waiting for a webhook — on your confirmation page's
server route, or in local development where webhooks cannot reach you — read
the session directly:

<CodeGroup>
  ```ts TypeScript theme={null}
  const session = await ionic.checkout.sessions.retrieve({ id: "cs_..." });
  const paid = session.status === "complete" && session.payment_status === "paid";
  ```

  ```bash curl theme={null}
  curl https://api.ionicfi.com/v1/checkout/sessions/cs_... \
    -H "Authorization: Bearer sk_v1_test_..."
  ```
</CodeGroup>

`status: "complete"` with `payment_status: "paid"` means the payment
succeeded. The webhook drives fulfillment; the read answers whether one
specific session paid, on demand.

### Local development

Ionic cannot deliver webhooks to `localhost`. During local development,
either retrieve the session as shown above, or expose your receiver with a
tunnel (for example `ngrok http 3000`) and register the tunnel URL as a
webhook endpoint. Remember to update the endpoint URL when the tunnel
address changes.

## Handling declines

A declined card keeps the session `open` and the form interactive — the
buyer sees the decline inside the frame and can retry with another card. No
`onError` fires; a decline is not a terminal state.

To see why a payment declined, follow the session to its payment intent:

```
GET /v1/checkout/sessions/{id}        -> payment_intent_id
GET /v1/payment_intents/{id}          -> charges[0].decline_code
```

## Content Security Policy

If your page sends a `Content-Security-Policy` header, it must allow the
checkout to load. A blocked script leaves a page with no payment form, so check
this before debugging anything else. The same directives apply to all three
integration paths — the npm packages load the runtime from the CDN too.

| Directive    | Value                                   | Why                                                                                                                                                    |
| ------------ | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `script-src` | `https://js.ionicfi.com`                | The SDK loads from here at runtime rather than being bundled into your app, so security fixes reach every integration without a redeploy on your side. |
| `frame-src`  | the origin of the session's `embed_url` | The checkout document itself.                                                                                                                          |

A minimal policy for a page that embeds checkout:

```
Content-Security-Policy:
  script-src 'self' https://js.ionicfi.com;
  frame-src https://api.ionicfi.com;
```

Apple Pay needs two further origins, because the wallet button renders in your
page rather than inside the frame. The [Apple Pay guide](/guides/apple-pay)
lists them alongside the rest of the wallet setup.

<Note>
  Take `frame-src` from the `embed_url` in your create-session response rather
  than copying a hostname from these docs. The value is issued per session, and
  reading it from the response keeps your policy correct if it changes.
</Note>

## Testing

Use the [test card numbers](/guides/test-cards) in test mode. Two behaviors
worth knowing before your first test purchase:

* Repeating the same card and amount within about 20 minutes is declined as
  a duplicate. Vary the amount between repeated test runs.
* Any amount under \$1.00 always declines — useful for testing your
  decline handling.

## Apple Pay

Eligible buyers see an Apple Pay button above the card form, in your page —
never inside the frame — with an "Or pay with card" divider. Wallet payments
complete through the same `onComplete` and the same webhook as card payments.

Eligibility is decided server-side per session (device capability, domain
registration, amount and currency), so you never compute it in the browser:

* **React**: wallets are offered automatically. Restrict them with the
  `wallets` prop — `wallets={["apple_pay"]}` offers only Apple Pay,
  `wallets={[]}` is card only. When a requested wallet can't be offered, the
  form still renders and the console names the exact setup step that's
  missing.
* **JavaScript / script tag**: pass `applePay: true` to `mountSession`.

Because embedded checkout runs on your domain, Apple Pay needs a one-time
domain setup (registration + an association file) that hosted surfaces don't.
The [Apple Pay guide](/guides/apple-pay) covers the requirements, the reasons
behind them, and how to test on a real device.
