CanopyAPI docs

Inline embedded checkout

Canopy checkout rendered inside your own page, with the embed origin it requires.

Copy this whole, and paste it into your coding agent along with your own instructions about the codebase. It is self-contained: it restates every part of the Canopy contract it needs, so the agent does not have to reach this site to follow it.

Also available as plain text at /agents/checkout-inline/raw — see the prompt index for the rest of the catalogue.

The prompt

checkout-inline.mdmarkdown
# Integrate Canopy — inline embedded checkout

You are integrating Canopy checkout into an existing web application, rendered
**inline inside the page** (not a popup, not a redirect). Canopy takes a
deposit in a supported currency from an end user on whatever chain they already hold funds
on, takes a platform fee, and delivers the net to the configured payout destination.

Two halves, and you need both:

- **Server**: creates a payment intent and returns its `intentId`.
- **Browser**: mounts the Canopy checkout SDK against that `intentId`.

Work through the sections in order. Do not skip section 1.

This prompt covers the iframe browser SDK, currently
`@canopypay/checkout-sdk@0.7.2` (loader v10). For native React rendering, use
`@canopypay/react-checkout@0.3.0` and follow the
[React checkout and hooks guide](https://www.canopypay.io/sdk/react).
That package provides `useCheckout`, `useCheckoutStatus`, typed callbacks and
prepare/retry/reset controls. React `shell_ready` reports the mounted shell;
React `ready` means usable checkout. The iframe event and mounting instructions
below apply only to the browser SDK. Payment notifications in either package
remain advisory; confirm settlement on the server.

---

## 1. Credentials and account setup come from a human

You cannot create any of these. **Ask before writing code:**

> To wire up inline checkout I need four things from your Canopy dashboard:
>
> 1. **Secret key** — starts `cnpy_sk_live_`. Server-side only.
> 2. **Publishable key** — starts `cnpy_pk_live_`. Safe in the browser.
> 3. **API / Canopy origin** — the host your Canopy account is served from,
>    used both for server API calls and as the SDK's `canopyOrigin`.
> 4. **Registered embed origin** — inline embedding will not render until the
>    exact origin my page is served from is registered on your account.
>    Dashboard → Embed origins. Add production _and_ any staging/localhost
>    origin, each one separately.
>
> Point 4 is self-serve and takes effect on the next request — no deploy, no
> review, nothing to ask Canopy support for. But it must be a human in the
> dashboard: that endpoint authenticates with a logged-in session, and a
> secret key cannot call it.
>
> And if this task also has to fulfil orders, a fifth: the **webhook signing
> secret** (`whsec_`, Dashboard → Webhooks). Checkout alone does not need it,
> but nothing may be fulfilled without it — see section 5.

Skip the ask for anything you were already given; do not spend a turn
re-requesting credentials that arrived with the task.

**If nobody answers** — you are running unattended, or the human does not have
these yet — do not stall and do not invent values. Build everything, read every
value from configuration, and finish by printing a short blocking list of what
is still needed. Never fabricate a key or hardcode a placeholder that could be
mistaken for a real one.

The two key types are not interchangeable:

- `cnpy_sk_live_` is a **credential**. Server only. Never in client code, never
  in HTML, never in a public config file.
- `cnpy_pk_live_` is a **public identifier**. It names the merchant and anchors
  the embed-origin allowlist. It belongs in the browser and can do nothing on
  its own.

**`intentId` is bearer-grade.** An intent id plus the publishable key resolves
a live checkout session. Return it only to the user it belongs to, never log it
somewhere public, and never put it in a URL you share.

---

## 2. Server: create the intent

One endpoint in your app — say `POST /api/checkout/session` — that creates a
Canopy intent for the current user and returns **only** the `intentId`.

```
POST {CANOPY_API_BASE}/api/v1/intents
Authorization: Bearer {CANOPY_SECRET_KEY}
Canopy-Version: 2026-09-01
Content-Type: application/json

{ "merchantReference": "order_10024" }
```

`Canopy-Version` is optional but always send it. Omitting it does not fail —
the server applies its current version and echoes it in a `canopy-version`
response header — which is exactly the problem: without the header you silently
ride whatever the current version becomes. An unknown value is a
`400 unsupported_version`.

Add `"priceUnits"` if the amount is fixed: a base-units decimal **string**,
digits only, no leading zero. A JSON number is a 400. The default settle rail
is USDG with **6 decimals**, so $12.50 is `"12500000"`. Use integer or `BigInt`
arithmetic, never `Math.round(x * 1e6)`. Pair with `"oneTime": true` if
reaching the price should close the intent (`oneTime` without `priceUnits` is
a 400).

If you want to confirm the decimals rather than assume 6, read them from
`GET /api/v1/intents/{id}/origins` — but read the **right rail**.
`settleRails` is an array and its entries have _different_ decimals (USDG 6,
ETH 18). Take the entry with `"default": true`; indexing `[0]` is a coin flip.
Note the ordering trap too: that call needs an intent to already exist, while
`priceUnits` is create-only, so read it once at startup from a throwaway
unpriced intent and cache it.

201 body:

```json
{
  "intentId": "0f2ad4e998206c864aa65d54c4a4b5cd",
  "inboxAddress": "0x...",
  "created": true,
  "priceUnits": null,
  "oneTime": false,
  "widgetToken": "<64 hex>"
}
```

`intentId` is **32 lowercase hex characters** — the SDK validates it with
`/^[0-9a-f]{32}$/` and refuses anything else. Return it and nothing else to the
browser. It is the whole browser integration: price, destination, fee and
supported chains all resolve server-side from it. `widgetToken` is not needed
to mount — an inline integration can ignore it; do not build the mount around
it.

Errors use `{ "error": { code, message, param?, request_id, docs } }`. Log
`request_id`. Two caveats: a request to a path that is not a real API route
returns an HTML 404, not this envelope, so check `content-type` before reading
`body.error.code`; and `param` is frequently empty or absent, so never require
it and never branch on `message` text.

`GET /api/v1/intents/{id}` → `{ intentId, inboxAddress, created }` is useful for
confirming an intent is real while debugging. Note it returns nothing else —
not `priceUnits`, not `merchantReference` — so store those yourself.

One more create-path note: dedupe is scoped to the resolved payout destination — at most one
_active_ intent per (account, payout namespace, chain, wallet, token), so two creates naming the same
wallet on the same chain and token, with matching terms, return the _same_ `intentId` with `created: false`. A create with no
destination fields mints a new intent every time, and there is no idempotency
key in the body or as a header. Unless a human has explicitly asked for
per-intent destinations, do not send a `payoutWallet`.

A different chain or token is a different destination and can have its own active intent.
Supplying any destination field enables this rule; omitted fields resolve from account defaults.
Serialize creates for the same destination and retain the returned `intentId` in your own database.
A retry rotates the widget token, while a request without destination fields creates another intent.
Do not blindly retry a timed-out destination-less create. `merchantReference` is metadata, not a dedupe key.

---

## 3. Browser: mount the SDK

```
npm install @canopypay/checkout-sdk
```

```ts
import { mount } from "@canopypay/checkout-sdk";

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

const handle = mount({
  target: "#checkout",
  intentId,
  merchant: CANOPY_PUBLISHABLE_KEY,
  canopyOrigin: CANOPY_ORIGIN,
  surface: "auto",
  handshakeTimeoutMs: 45000, // optional extra allowance for dev-server compilation
  onEvent: (e) => {
    if (e.type === "ready") hideSpinner();
    if (e.type === "error") reportError(e);
    if (e.type === "paid") showBanner("Payment sent — we'll confirm shortly");
  },
});

// When the component unmounts:
handle.destroy();
```

`target` is a CSS selector or `Element` that must already exist in the DOM. In
a component framework, mount in the effect that runs after the container
renders and `destroy()` in its cleanup; guard against double-mounting under
strict-mode double invocation.

### Three options that decide whether this works at all

**`mount()` never throws for a configuration mistake.** Every such refusal — bad
publishable key, bad intent id, missing target, undiscoverable origin — returns
a working no-op handle and reports itself as an `error` event through `onEvent`.
`try/catch` around `mount()` catches none of them. **Always pass `onEvent`, even
in a throwaway test**, or a misconfigured mount fails in complete silence.

It throws exactly one thing: `Error("@canopypay/checkout-sdk: loader did not
initialise")`, when it runs where `window` is absent — an SSR render, a Next.js
server component, a `node --test` import — or when bundling dropped the
package's side-effect import. Guard the mount behind a browser check.

**Your `onEvent` must not reference anything initialised after `mount()`
returns.** Refusals are emitted _synchronously, inside the `mount()` call_, and
the emit path swallows every exception your callback throws. So this fails in
total silence:

```ts
const handle = mount({ /* … */ onEvent: (e) => reportError(e, handle) });
//    ^^^^^^ in the temporal dead zone when a refusal fires; the
//           ReferenceError is discarded and you see no events at all
```

The symptom is identical to having omitted `onEvent` entirely.

**`surface` defaults to `"popup"`, and only three values exist.** They are
`"popup"`, `"iframe"` and `"auto"`. If you omit it you get no inline embed and
no error — and if you pass anything else it is **accepted and silently
ignored**, with no event of any kind. `surface: "inline"` is the natural typo
here, given this page says "inline" throughout, and it produces exactly zero
frames, zero events and zero errors. Assert the literal you passed.

**`canopyOrigin` is required far more often than "bundled consumers".** The SDK
auto-discovers its origin from `document.currentScript` (a synchronously
executing classic `<script>`), falling back to scanning for a script whose src
matches `/v1/[…/]canopy.js`. An ESM `import`, a bundle, or the same file served
from any other path — `/vendor/canopy-sdk.js`, say — matches neither, and
`mount()` refuses with `canopy_origin_undiscoverable`. **If you are not loading
it from a literal `<script src=".../v1/canopy.js">` tag, pass `canopyOrigin`.**
It must be a bare origin (no path, query or fragment) and `https`, except that
`localhost` and `127.0.0.1` are allowed for development.

**Loader v7 defaults `handshakeTimeoutMs` to 10000ms.** Versions 1–6 retain
2500ms because versioned loader URLs are immutable. Upgrade a pinned script to
`/v1/7/canopy.js`, or set an explicit timeout on an older integration.

The timeout covers the iframe handshake, not completion of session bootstrap.
`ready` cancels it as soon as the skeleton mounts. To measure when payment is
actually usable, use the `checkout.client.startup` Axiom event documented in
[widget performance](../widget-performance.md).

A development server may need longer to compile; `45000` is an optional dev
setting. In production, start with the v7 default and use measured latency to
choose an override. `handshakeTimeoutMs: 0` is falsy and uses the default;
`fallbackLabel: ""` likewise uses its default label.

### While you are debugging, use `surface: "iframe"`

`"auto"` is right in production: it attempts the inline embed and falls back to
the popup surface, emitting `surface_unavailable` then `fallback_offered`.

Debug with `"iframe"` anyway. On `"auto"` a timeout swaps in the fallback
button and you never see the underlying error at all — notably
`checkout_unavailable`, which only ever arrives _through_ a surviving frame.

Be precise about what `"iframe"` buys you: it does **not** keep the frame
alive on a timeout — that path removes the frame too, and reports
`iframe_blocked`. What it does is surface an error the frame itself sends,
instead of hiding it behind a fallback. That is the difference that matters
when diagnosing.

### Loading without a bundler

The published package ships browser-ready builds. For a
`<script type="module">` page, vendor **`dist/index.js`** — the ESM entry, the
one `exports["."].import` points at. Do not vendor `dist/index.cjs` (that is
`main`, for CommonJS) and note `./canopy.js` maps to the IIFE at
`dist/canopy.global.js`, which installs a global `Canopy` instead of exporting
`mount`.

Serving any of them from your own static path defeats origin auto-discovery,
so pass `canopyOrigin` — see above. The canonical alternative is
`<script src="{CANOPY_ORIGIN}/v1/canopy.js">`, which self-discovers.

`mount` is a thin re-export that delegates to the `window.Canopy` a
side-effect import installs, so importing the module twice does not give you
two isolated loaders.

**Mount one checkout per page, and `destroy()` anything you are done with.**
This is not tidiness. Each handle registers a `postMessage` listener on `window`
at mount time, and the guard deciding whether a message belongs to a given
handle **fails open when that handle owns no window or frame of its own** — it
accepts any message from `canopyOrigin`. So a second mount, or one you forgot to
destroy, receives the live checkout's events, **including `paid`**. A page can
announce "Payment sent" for the wrong checkout. If you are comparing two
configurations, do it across separate page loads.

### Theming, if needed

```ts
theme: "dark",   // "auto" (default) | "light" | "dark"
variables: { accent: "#7c3aed", background: "transparent", radius: "8px" },
```

Eight allowlisted names: `accent`, `background`, `surface`, `text`, `muted`,
`border`, `radius`, `font`.

Anything else is rejected — but "rejected" here means an **`error` event**, not
silence, and values are checked semantically as well as syntactically. An
allowlisted name with a perfectly valid colour can still be refused
(`text: "#fff"` → `variable_contrast_too_low`). These are cosmetic and never
fatal; see section 4 for how to keep them out of your alerting. An invalid
`theme` value, by contrast, is silently ignored with no event.

The contrast floor is checked against whichever schemes can still apply, so the
same value can pass or fail depending on `theme`: `text: "#fff"` is refused
under `theme: "auto"` (the default) and `"light"`, and **accepted silently**
under `theme: "dark"`.

Full option surface: `target`, `intentId`, `merchant`, `surface`, `onEvent`,
`handshakeTimeoutMs`, `fallbackLabel`, `canopyOrigin`, `theme`, `variables`.
The handle exposes `openNow(win)` (popup surface) and `destroy()`. The module
also exports `version()`, which is the fastest way to confirm the loader
installed — it returns an opaque loader-protocol version (`"6"`), **not** the
npm package version, so do not quote it as one in a bug report.

**Unknown option names are accepted and silently discarded**, with no warning.
In particular there is no `amount`, `currency`, `onSuccess` or `onPaid` option —
price lives on the intent and every callback goes through `onEvent`. A developer
who assumes the amount goes in `mount()` gets a working-looking checkout at the
wrong price with no signal at all. If you are not using TypeScript, assert your
option object's keys against this list yourself.

`mount()` is idempotent per target node — a second mount on the same node
returns the _existing_ handle rather than a second one, **provided the second
call passes validation** (the node lookup happens after the key/intent/origin
checks, so a second mount with a bad key still returns a fresh refused handle
and fires an error).

Two hazards follow. `destroy()` on either reference kills the one mount. And the
second call's options are discarded — **including its `onEvent`** — so under
React strict-mode double invocation the callback you kept may be the dead one,
and you will never receive `ready`.

---

## 4. The eight events

| Event                 | Meaning                                                                                                                                                                                              |
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ready`               | Frame handshake completed. Hide your spinner. This is your success signal.                                                                                                                           |
| `resize`              | The iframe element's rendered height changed (observed via `ResizeObserver`) and the SDK has already applied it. See below.                                                                          |
| `paid`                | Payment sent. **Advisory only** — see section 5.                                                                                                                                                     |
| `deposit_detected`    | The payer's deposits for this intent — including a below-minimum partial already parked — together clear the intent's floor. Can arrive before `paid`. **Advisory only** — see section 5.            |
| `error`               | Carries a `code`. Treat the set as **open** — see below.                                                                                                                                             |
| `close`               | **Popup surface only.** An inline embed never emits `close` — do not wait for one.                                                                                                                   |
| `surface_unavailable` | The inline frame did not come up in time, or was blocked; `auto` is falling back.                                                                                                                    |
| `fallback_offered`    | Render your fallback control (`fallbackLabel`). Carries `reason: "iframe_unavailable"` — a code-shaped string that is **not** one of the `error` codes below; do not go hunting for it in the table. |

### `error` codes come in four families

There are far more than three, and matching exhaustively will fail you:

- **Mount refusals**, emitted synchronously with no frame created:
  `invalid_merchant_key`, `invalid_intent_id`, `target_not_found`,
  `canopy_origin_undiscoverable`, `canopy_origin_not_bare`,
  `canopy_origin_insecure`, `canopy_origin_invalid`.
- **Surface failures**: `iframe_blocked`, `popup_blocked`,
  `popup_navigation_failed`.
- **Frame-reported failures**: `checkout_unavailable`, `intent_not_found`.
- **Cosmetic theme rejections**: `unknown_variable`, `variable_not_a_string`,
  `variable_value_malformed`, `variable_value_too_long`,
  `variable_value_erases_content`, `variable_contrast_too_low`,
  `too_many_variables`.

**The theme family is not fatal.** Those events carry an extra `variable` field
naming the offending key, the frame still mounts, and `ready` still fires — so
wiring `if (e.type === "error") reportError(e)` verbatim will page your on-call
over a colour. Filter them:
`if (e.type === "error" && !e.variable) reportError(e)`.

### `resize` will look like a bug if you are not expecting it

Two different mechanisms are in play and it matters which you are watching.

The frame **sends** its height over `postMessage`. The SDK applies those
directly to `iframe.style.height` and emits **no event for the message itself**
— and drops any reported height below **46px**.

The `resize` **event** you receive comes from a `ResizeObserver` on the iframe
element. It reports _any_ height change above 0px and has **no 46px floor** —
including the SDK's own hardcoded **420px** starting height. So the first
`resize` you see is usually `height=420`, and it arrives **before `ready`**, not
after:

```
t=2531ms  resize height=420   (the SDK's initial height, merely observed)
t=2926ms  resize height=46    (frame's first reported height)
t=3212ms  ready
t=3217ms  resize height=225   (content settled)
```

The content height normally settles within a few hundred milliseconds of
`ready`, occasionally up to ~5s. Do not lay out against any single event, and do
not conclude the frame rendered blank.

**`ResizeObserver` is driven by the rendering loop, so a hidden or backgrounded
tab delivers no `resize` events at all** — heights still apply, but you observe
them tens of seconds late or never. If you are verifying with browser
automation, measure in a foreground tab, or tap `window.addEventListener(
"message", …)` directly, which is not throttled.

---

## 5. `paid` is not fulfilment

`paid` and `deposit_detected` are both **UX signals** from a browser you do
not control, on a page a user can close, reload or tamper with. They update
the UI. Neither must ever release goods, credit an account, unlock a
download, or move money.

Fulfilment happens on your server, driven by Canopy's **settlement webhook**.
If this task needs fulfilment wired, ask the human for the `whsec_` signing
secret (Dashboard → Webhooks — registered by hand; there is no API for it) and
verify every delivery with `standardwebhooks` against the **raw** request body
before acting on it. Branch on the verified body's `state` field — no header or
body field carries an event type.

---

## 6. Troubleshooting — symptom to cause

Work down this table in order. Do not conclude "this needs Canopy support"
until you have ruled out every row, and do not assume the first plausible row
is the cause — several of these look identical from the outside.

| Symptom                                                             | Cause                                                                                                                                                                                                                                                                                                                                                                                     |
| ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| No inline frame at all, no error, **no events whatsoever**          | `surface` is not `"iframe"` or `"auto"`. Either you omitted it (it defaults to `"popup"`) **or you passed an unrecognised string — `"inline"` is the common one, and it is accepted and silently ignored**. The only valid values are `"popup"`, `"iframe"`, `"auto"`.                                                                                                                    |
| No events at all, and you cannot tell why                           | You omitted `onEvent`. Every refusal reports through it and nowhere else.                                                                                                                                                                                                                                                                                                                 |
| `error` event, `code: "canopy_origin_undiscoverable"`               | Not loaded from a `/v1/canopy.js` script tag. Pass `canopyOrigin`. (`mount()` does **not** throw — nothing does. If you wrapped it in `try/catch` you caught nothing and saw nothing.)                                                                                                                                                                                                    |
| `canopy_origin_not_bare` / `_insecure` / `_invalid`                 | `canopyOrigin` had a path or query, was not `https` (and not localhost), or would not parse.                                                                                                                                                                                                                                                                                              |
| `surface_unavailable` then `error code: iframe_blocked`             | **Ambiguous — two different causes.** Either your origin/publishable key is not registered, or the frame simply did not answer in time. Run the `curl` check below to tell them apart. Do not assume CSP: the console is usually silent here.                                                                                                                                             |
| `surface_unavailable` then `fallback_offered` on `"auto"`           | Same two causes as the row above; `"auto"` just swaps in the fallback instead of surfacing the error.                                                                                                                                                                                                                                                                                     |
| `error` with a code not in this table                               | Expected — see section 4's four families. Cosmetic `variable_*` codes are not failures at all.                                                                                                                                                                                                                                                                                            |
| Frame renders but emits `error` with `code: "checkout_unavailable"` | **Not your integration.** The intent is real and belongs to your account, but Canopy could not bind a deposit customer to it or issue a checkout session. Account-side. See below.                                                                                                                                                                                                        |
| Frame renders but emits `error` with `code: "intent_not_found"`     | The intent id is malformed, unknown, archived, or belongs to a _different_ account than the publishable key names. Check the two came from the same account.                                                                                                                                                                                                                              |
| `wildcard_embed_origin_refused` when registering                    | No wildcards. Register each subdomain explicitly.                                                                                                                                                                                                                                                                                                                                         |
| `https_required` when registering                                   | Must be `https` (except localhost).                                                                                                                                                                                                                                                                                                                                                       |
| `origin_must_be_scheme_host_port`                                   | Registered value had a path, query or fragment. Send a bare origin.                                                                                                                                                                                                                                                                                                                       |
| `401 unauthorized` from the API                                     | Missing/invalid secret key on the **server** call. If the key you were _given_ is rejected verbatim, it is revoked or from another account — its message is byte-identical to a made-up key's, so stop and ask the human for a fresh one instead of re-checking your headers. A _missing_ header says `"A bearer token is required."` instead, which tells you the request shape is fine. |
| `403 insufficient_scope`                                            | Key lacks `payment_intents:create`. Human reissues.                                                                                                                                                                                                                                                                                                                                       |
| `409 idempotency_key_reuse`                                         | The named payout wallet already has an active intent with different terms. Settle/archive it, or use another wallet.                                                                                                                                                                                                                                                                      |
| `429 rate_limited`                                                  | Back off; read `ratelimit-reset`.                                                                                                                                                                                                                                                                                                                                                         |
| `503 merchant_routes_unprovisioned` on create                       | Account onboarding incomplete. Human finishes it.                                                                                                                                                                                                                                                                                                                                         |

### Check registration from the server first. Do not guess in the browser.

`iframe_blocked` and a handshake timeout produce the **same** symptom pair —
`surface_unavailable` then `error code: iframe_blocked` — and Chrome frequently
logs **no CSP message at all** for the blocked case. So you cannot tell an
unregistered origin from a slow one by watching the browser, and guessing
wastes entire sessions.

Settle it with one request instead. The embed allowlist is keyed on the
**publishable key**, and the frame page publishes it in its own CSP header:

Run it **twice** — once with your real publishable key, once with a deliberately
bogus one. The second run is a control, and without it the check can lie to you.

```bash
for PK in "$REAL_PK" "cnpy_pk_live_deadbeefdeadbeef"; do
  curl -sD- -o /dev/null \
    "{CANOPY_ORIGIN}/customer-pay?merchant=$PK&intent={ANY_INTENT_ID}" \
    | grep -o "frame-ancestors[^;]*"
done
```

Read the pair, not either line alone:

| Real key                          | Bogus key | Verdict                                                                                                                                                          |
| --------------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| lists origins **including yours** | `'none'`  | **Registered.** Move on to the timeout and `checkout_unavailable` rows.                                                                                          |
| lists origins but **not yours**   | `'none'`  | **The key is good; your origin is not on it.** Add your exact origin in Dashboard → Embed origins. This is the commonest real case.                              |
| `'none'`                          | `'none'`  | **Nothing is registered**, or the key itself is wrong/revoked. Verify the key first — a dead key looks identical to an unregistered origin — then the dashboard. |

The header lists the account's **entire** allowlist, not a yes/no for you:

```
frame-ancestors http://localhost:3000 http://localhost:3001 https://example.vercel.app
```

So this one request also tells you every origin currently registered — which is
usually how you discover that you are serving on the wrong port. Read the list;
do not just check whether it is `'none'`.

The control matters because `frame-ancestors 'none'` is also the app's default
on unrelated routes — you will see it on an API 404 or a JSON error. Query
`/customer-pay` specifically, and treat a naming result on the real key as the
only positive signal. The intent id can be any well-formed value; this check
reads a CSP header and does not need a live intent, which is what lets you run
it before your first successful create.

### `checkout_unavailable` deserves its own paragraph

This is the failure most likely to waste your time, because **the server half
looks perfect**: `POST /api/v1/intents` returns `201` with a real
`inboxAddress`, your keys are right, registration checks out above, and there
is no CSP error anywhere. The frame still refuses.

It means the intent could not be turned into a checkout session — typically its
deposit customer could not be bound, or inbox issuance is unavailable. That is
account and platform state, not your code, and no change to your integration
will fix it.

**Pre-flight it from the server before opening a browser at all.** Call
`GET /api/v1/intents/{id}/origins`. If **every** entry in `origins[]` has
`"depositIssuable": false`, there is nothing the payer can pay with and the
frame will fail — you have your answer without a browser session.

If only _some_ are `false`, that is normal: those rails are simply unavailable
to the payer and the checkout still mounts. And `sourceDepositIssuable: false`
on its own means nothing here — it tracks whether a deposit customer is bound
yet, is routinely `false` on a fresh intent, and is **not** a reason to expect
`checkout_unavailable`.

Report it to the human with the `request_id` from the create call rather than
rewriting your integration.

---

## 7. Prove it works before you report done

Run these and paste the real output. Do not claim success on unrun code.

1. **Server create** — call your session endpoint. Assert `200` and a
   32-lowercase-hex `intentId`.
2. **Response is minimal** — assert the browser-facing JSON contains
   `intentId` and no key, token or address.
3. **Secret never ships** — fetch **every asset your server will serve a
   browser** (the page, each script, any config endpoint) and grep each for
   `cnpy_sk_live`. Assert zero matches. If there is a build step, grep the
   built bundle too.
4. **Registration** — run the `frame-ancestors` curl from section 6 and assert
   it names your origin. Do this **before** opening a browser; it is the one
   check that cannot be confused by timing.
5. **Payability pre-flight** — `GET /api/v1/intents/{id}/origins`. Record
   `sourceDepositIssuable` and each `depositIssuable`. Only if **all** origins
   are `false` should you expect `checkout_unavailable`; say so up front rather
   than discovering it in the browser. A false `sourceDepositIssuable` alone is
   not a predictor and must not be reported as one.
6. **Mount reaches `ready`** — load the page in a real browser and assert a
   `ready` event fires and the checkout renders. `ready` is the only success
   signal; a rendered frame that emitted a non-`variable_*` `error` is a
   failure. Log every event **in order, with its `code`**.

   **First assert a frame exists, then wait for a terminal event.** The
   handshake timer is created _with the frame_, so the guarantee is conditional:
   _if_ `surface` is `"iframe"` or `"auto"` and a frame was created, the SDK
   delivers one of `ready`, `error` or `fallback_offered` by
   `handshakeTimeoutMs` — no silent-failure window, no reason to sit out the
   full 45s.

   On `surface: "popup"`, on an omitted `surface`, and on an unrecognised string
   like `"inline"`, **no frame and no timer are created and no event will ever
   arrive, at any deadline.** So check your container has an `<iframe>` child
   immediately after `mount()` returns. If it does not, you have a `surface`
   problem (section 6, row 1) — do not wait, and do not report a timeout.

   If you have no browser available, say so plainly rather than asserting it
   works.

7. **Unmount is clean** — call `handle.destroy()` and assert the container is
   empty and no iframe remains.
8. **Auth is enforced** — repeat step 1 with the secret key removed. Assert
   `401` and `error.code === "unauthorized"`.
9. **Non-JSON error survives** — request a nonsense `/api/v1/...` path and
   assert your client raises a clean error, not a `TypeError`.

If step 6 fails, work the section 6 table in order and report which row
matched, with the evidence that ruled out the rows above it.

Then report: what you built, the commands you ran, their real output, and
anything you could not verify and why.