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

# Embedded hosted checkout

> Mount COPE hosted checkout in an iframe with the Checkout SDK, registered embed origins, and trusted postMessage events.

# Embedded hosted checkout

Embedded hosted checkout lets your page keep its layout while COPE renders the payment form inside an iframe. Your site owns the surrounding experience. COPE owns the checkout route, payment collection, tax finality, order creation, and buyer-facing terminal states.

Use embedded checkout when you need a custom storefront or in-page checkout modal. Use redirect checkout when the simplest integration is enough or when a browser blocks the iframe flow.

## Requirements

Before creating embedded checkout sessions:

* Create a Checkout SDK publishable key for the COPE business.
* Register every parent page origin that may host the iframe, for example `https://shop.example.com`.
* Use an origin only: scheme, host, and optional port. Do not include a path, query string, fragment, or userinfo.
* Use HTTPS in production. `http://localhost` is only for development and test environments.
* Register the success and cancel URLs used for redirect-based completion or fallback. They are matched as complete URL strings, not as origins — see [redirect URLs](./overview#redirect-urls).

The parent origin must exactly match the browser page origin that calls `checkout({ embed_origin })`. `https://shop.example.com` and `https://www.shop.example.com` are different origins.

Embed origins and redirect URLs are separate allowlists with different matching rules. An embed origin is scheme, host, and optional port only. A redirect URL is the entire URL, including its path and any query string.

## Create an iframe checkout

```ts theme={"theme":{"light":"github-light","dark":"github-dark"}}
import { CopeCart } from "@copecart/sdk"

const cope = new CopeCart({
  publishableKey: "cope_pk_live_...",
})

async function openEmbeddedCheckout(productId: string, planId: number) {
  const cart = await cope.createCart({ currency: "EUR" })

  await cope.addLine(cart.id, {
    product_id: productId,
    plan_id: planId,
  })

  await cope.setBuyerIdentity(cart.id, {
    email: "buyer@example.com",
    tax_location: {
      country: "DE",
      postal_code: "10115",
    },
  })

  await cope.reprice(cart.id)

  const checkout = await cope.checkout(cart.id, {
    embed_origin: window.location.origin,
    success_url: "https://shop.example.com/thank-you",
    cancel_url: "https://shop.example.com/cart",
    consents: [{ type: "buyer_tos" }],
  })

  return cope.mountCheckout("#cope-checkout-frame", checkout, {
    fallback: "redirect",
    onReady: () => showCheckoutFrame(),
    onResize: ({ height }) => resizeContainer(height),
    onSuccess: () => showOrderConfirmation(),
    onCancel: () => closeCheckoutFrame(),
    onError: ({ code, retryable }) => reportCheckoutError(code, retryable),
  })
}
```

The checkout response includes both URL shapes:

| Field                       | Meaning                                                                             |
| --------------------------- | ----------------------------------------------------------------------------------- |
| `checkout.checkoutUrl`      | Hosted checkout redirect route, for example `https://app.cope.com/checkout/:token`. |
| `checkout.embedCheckoutUrl` | Iframe route, for example `https://app.cope.com/checkout/embed/:token`.             |
| `checkout.embedOrigin`      | The approved parent origin returned by COPE when `embed_origin` is valid.           |

`mountCheckout()` refuses to mount when `checkout.embedOrigin` is missing or does not exactly match `window.location.origin`. It also refuses an `embedCheckoutUrl` — or a `checkoutUrl` used for `fallback: "redirect"` — whose origin is not the configured `checkoutBaseUrl`.

## Mounting a phone offer

An offer is created on your server, not in the browser, so there is no cart to
build and no `CheckoutResult` to pass. The offer response carries the fields
`mountCheckout()` actually needs:

```ts theme={"theme":{"light":"github-light","dark":"github-dark"}}
// The browser still needs a publishable key, even though the offer itself was
// created server-side with the secret one. `checkoutBaseUrl` must name the origin
// the offer's `embed_url` is on — outside production that is not app.cope.com.
const cope = new CopeCart({
  publishableKey: "cope_pk_...",
  checkoutBaseUrl: "https://app.cope.com",
})

async function openOffer(offer: Offer) {
  // Both embed fields are absent — not null — unless the offer was created with an
  // embed_origin that resolves, so narrow both before mounting.
  if (!offer.embed_url || !offer.embed_origin) {
    window.location.href = offer.public_url // hosted fallback
    return
  }

  cope.mountCheckout("#cope-checkout-frame", {
    embedCheckoutUrl: offer.embed_url,
    embedOrigin: offer.embed_origin,
    checkoutUrl: offer.public_url, // required only by fallback: "redirect"
  }, {
    onSuccess: () => showOrderConfirmation(),
  })
}
```

Pass `offer.public_url` as `checkoutUrl` if you use `fallback: "redirect"`. The SDK
will not derive one: an offer's recovery page is `/offers/:uuid`, which renders the
offer's state, while the hosted checkout route would refuse in exactly the cases the
fallback exists for — and it would carry the offer's payment-capable access token
into the address bar.

The iframe, the handshake and the events are identical to a cart checkout. Two
sections below do not apply to offers: **Test your integration** builds a cart, and
the `embed_origin` troubleshooting row points at `checkout()` — for an offer, send
`embed_origin` on create-offer instead.

## Mount behavior

`mountCheckout()` creates the iframe for you:

* `src` is `checkout.embedCheckoutUrl`.
* `title` defaults to `COPE checkout`.
* `allow` is set to `payment *` for browser wallet support.
* `referrerPolicy` is set to `no-referrer`.
* `width` is set to `100%`.
* `min-height` is set to `720px`.
* No `sandbox` attribute is added by the SDK.

Do not build the iframe manually unless you are testing the embed contract. The SDK also performs the trusted mount handshake and filters all iframe messages by origin, source window, message source, and version.

## Fallbacks

Set `fallback: "redirect"` for buyer-safe recovery if the iframe does not complete the trusted ready handshake before `readyTimeoutMs`.

```ts theme={"theme":{"light":"github-light","dark":"github-dark"}}
cope.mountCheckout("#cope-checkout-frame", checkout, {
  fallback: "redirect",
  readyTimeoutMs: 8000,
  onFallbackRedirect: () => {
    console.log("Falling back to hosted checkout")
  },
})
```

With the default `fallback: "error"`, the SDK calls:

```ts theme={"theme":{"light":"github-light","dark":"github-dark"}}
onError({ code: "load_failed", retryable: true })
```

## Events

The iframe sends sanitized events to the SDK. Callback payloads do not include checkout credentials, payment client secrets, or buyer PII.

```ts theme={"theme":{"light":"github-light","dark":"github-dark"}}
type CheckoutEmbedEvent =
  | { type: "ready"; messageId?: string; timestamp?: string }
  | {
      type: "resize"
      payload: { height: number }
      messageId?: string
      timestamp?: string
    }
  | {
      type: "error"
      payload: {
        code: "not_found" | "load_failed" | "embed_not_allowed" | "confirm_failed"
        retryable: boolean
      }
      messageId?: string
      timestamp?: string
    }
  | {
      type: "terminal"
      payload: {
        status: "expired" | "completed" | "already_completed" | "cancelled" | "processing"
      }
      messageId?: string
      timestamp?: string
    }
```

Use `onMessage` when you need the raw sanitized event stream:

```ts theme={"theme":{"light":"github-light","dark":"github-dark"}}
cope.mountCheckout("#cope-checkout-frame", checkout, {
  onMessage: (event) => {
    analytics.track("cope_checkout_embed_event", event)
  },
})
```

`onSuccess` and `onCancel` are convenience callbacks derived from terminal events.
Only a live `completed` fires `onSuccess`. `already_completed` means the checkout was
paid **before** this iframe mounted — a refresh after success, or a re-opened offer
link — so fulfilment must not run again; `processing` means an asynchronous rail
accepted the payment but the order is not final yet.

## Security model

Embedded checkout has two layers of authorization:

1. COPE validates `embed_origin` against the business checkout embed domain allowlist when checkout is created.
2. The iframe route validates the same embed contract before rendering checkout UI.

The SDK sends the mount message with a specific `targetOrigin`; it never uses `*`. It accepts iframe messages only when:

* `event.origin` matches the checkout iframe origin.
* `event.source` is the mounted iframe window.
* `event.data.source` is `cope.checkout`.
* `event.data.version` is `1`.

The regular hosted checkout route is frame-denied. Only `/checkout/embed/:token` is intended for iframe rendering.

## Test your integration

Use a development or staging page that matches the origin you registered for the COPE business. Exercise the full flow before launch:

1. Create a cart.
2. Add a product and payment plan.
3. Set buyer identity.
4. Reprice.
5. Create checkout with `embed_origin`.
6. Mount the iframe and inspect the postMessage conversation.

Also verify rejected unregistered origins, expired checkout sessions, missing mount handshakes, and redirect fallback behavior.

## Troubleshooting

| Symptom                                                   | Likely cause                                                                                                                 | Fix                                                                                                                                          |
| --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `embed_origin_not_allowed` during checkout creation       | The page origin is not registered for the COPE business.                                                                     | Register the exact parent origin and create a new checkout session.                                                                          |
| `invalid_redirect_url` during checkout creation           | `success_url` or `cancel_url` is not registered. Registering the origin or the path without its query string does not match. | Register the complete URL exactly as sent, or omit the field to use the first registered URL. See [redirect URLs](./overview#redirect-urls). |
| `mountCheckout requires checkout.embedOrigin`             | Checkout was created without `embed_origin`, or the API rejected the embed origin.                                           | Pass `embed_origin: window.location.origin` and check the checkout response.                                                                 |
| `Checkout embed origin ... does not match current origin` | Checkout was created for a different parent origin.                                                                          | Create checkout from the same origin that will mount it.                                                                                     |
| Iframe never becomes ready                                | The token is invalid, expired, blocked by headers, or the mount handshake failed.                                            | Use `fallback: "redirect"` and inspect `onError` and browser console output.                                                                 |
| Browser wallet is unavailable                             | Wallet domain registration or cross-origin iframe payment permissions are incomplete.                                        | Confirm the iframe uses `allow="payment *"` and verify wallet domain setup for the parent and checkout origins.                              |
