CanopyAPI docs

Popup checkout

The default surface. No origin registration, but the click-handler timing is unforgiving.

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-popup/raw — see the prompt index for the rest of the catalogue.

The prompt

checkout-popup.mdmarkdown
# Integrate Canopy — popup checkout

You are integrating Canopy checkout into an existing web application, opened in
a **popup window** from a button on the page. This is the default surface and
needs no origin registration, which makes it the fastest path to a working
checkout. 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 SDK and opens the popup from a click handler.

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

---

## 1. Stop and collect credentials from the human

You cannot create these yourself. **Ask the human now, before writing any
code**, and wait:

> To wire up popup checkout I need three 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.
>
> Unlike inline embedding, the popup surface needs no origin registration.
>
> And if this task also has to fulfil orders, a fourth: the **webhook signing
> secret** (`whsec_`, Dashboard → Webhooks, registered by hand). Checkout alone
> does not need it; fulfilment cannot happen 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**,
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.

```
CANOPY_SECRET_KEY=cnpy_sk_live_...        # server only, never reaches a browser
CANOPY_API_BASE=https://...
CANOPY_PUBLISHABLE_KEY=cnpy_pk_live_...   # safe in the browser
CANOPY_ORIGIN=https://...                 # safe in the browser
```

The last two have to reach the browser somehow. **Only under Next.js can you
name them `NEXT_PUBLIC_*` and read `process.env` in client code** — that is a
build-time inline, not a runtime global. Everywhere else there is no
`process.env` in a browser at all; serve them yourself, e.g. a `/config.js`
route emitting `window.__CANOPY__ = { pk, origin }`. Section 3 shows the mount.

`cnpy_sk_live_` is a credential and must never reach a browser.
`cnpy_pk_live_` is a public identifier and is meant to.

---

## 2. Server: create the intent

One endpoint in your app that creates a Canopy intent 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" }
```

Add `"priceUnits"` for a fixed amount: a base-units decimal **string**, digits
only, no leading zero. The default settle rail is USDG with **6 decimals**, so
$12.50 is `"12500000"`. Never a JSON number — that is a 400. 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).

To confirm the decimals rather than assume 6, read
`GET /api/v1/intents/{id}/origins` and take the `settleRails` entry with
`"default": true` — the array holds rails with different decimals (USDG 6,
ETH 18), so indexing `[0]` is a coin flip. That call needs an intent to exist
while `priceUnits` is create-only, so read it once at startup from a throwaway
unpriced intent and cache it — but know the cost: that is a **real, live intent
with a real deposit address created on every process boot**, one per cold start
on serverless, spending one of your 30/min create budget and permanently
landing in the merchant's intent ledger. If you cold-start often, hardcode 6 and
assert it in CI instead.

The 201 body carries `intentId`, `inboxAddress`, `created`, `priceUnits`,
`oneTime`, `widgetToken`. `intentId` is **32 lowercase hex** — the SDK
validates it and refuses anything else. Return it and nothing else to the
browser; the id is the whole browser integration, since price, destination,
fee and supported chains resolve server-side from it. `widgetToken` is not
needed to mount, so a popup integration can ignore it.

There is exactly one dedupe rule: at most one _active_ intent per (account, payout namespace, chain, wallet, token). A create naming a wallet that already has an active intent
with the same terms returns that intent with `created: false`. Different
`priceUnits` or `oneTime` for that destination returns `409`; a create with no
destination fields mints a **new** intent every time. There is no idempotency
key — not in the body (the schema is `.strict()`, so sending one is a `400`)
and not as a header (`Idempotency-Key` is silently ignored). Decide
new-vs-existing from your own database. Unless a human explicitly asked for
per-intent destinations, do not send a `payoutWallet`.

**`intentId` is bearer-grade** — it plus the publishable key resolves a live
checkout session. Return it only to the user it belongs to.

Errors: `{ "error": { code, message, param?, request_id, docs } }`. Log
`request_id`. Two caveats: a path that is not a real API route returns an HTML
404 rather than 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.

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, then open inside the click handler

```
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: window.__CANOPY__.pk, // or process.env.NEXT_PUBLIC_* under Next.js
  canopyOrigin: window.__CANOPY__.origin,
  surface: "popup",
  onEvent: (e) => {
    if (e.type === "paid") showBanner("Payment sent — we'll confirm shortly");
    if (e.type === "close") refreshOrderStatus();
  },
});

payButton.addEventListener("click", () => {
  handle.openNow(window.open("", "canopy_checkout", "width=460,height=640"));
});
```

`canopyOrigin` is mandatory for the npm build — the SDK discovers its origin by
scanning the DOM for a `<script src=".../v1/canopy.js">` tag, and a bundled app
has none, so it refuses with `canopy_origin_undiscoverable` rather than guessing.
Bare origin, `https`, except `localhost` / `127.0.0.1` for development. A
trailing slash is accepted; a path, query or fragment is not. The TypeScript
type is `canopyOrigin?: string`, so nothing catches the omission at compile time.

### `mount()` never throws — and a refused mount looks like a flickering popup

This is the failure you are most likely to ship without noticing. When `mount()`
refuses a **configuration** mistake, it does **not** throw. It returns a
normal-looking handle and reports the reason as an `error` event delivered
**synchronously, during the `mount()` call**, to `onEvent`. Wrapping `mount()`
in `try/catch` catches nothing.

Three consequences:

- **`onEvent` is not optional.** Omit it and every refusal is completely
  silent — no throw, no console output, nothing.
- **Your `onEvent` must not reference anything initialised after `mount()`
  returns.** Refusals fire inside the call, and the emit path swallows every
  exception your callback throws, so
  `const h = mount({ onEvent: e => report(e, h) })` dies silently in the
  temporal dead zone — with exactly the symptom of having omitted `onEvent`.
- **`openNow(win)` on a refused handle calls `win.close()` on the window you
  just opened**, then re-emits the same error. The observable symptom is a
  popup that _flashes open and vanishes_, with an empty console.

There is one genuine throw: `Error("@canopypay/checkout-sdk: loader did not
initialise")`, when `mount()` runs where `window` is absent (SSR, a server
component, a test import) or bundling dropped the package's side-effect import.
Guard the mount behind a browser check.

The refusal codes are `invalid_merchant_key`, `invalid_intent_id`,
`target_not_found`, `canopy_origin_undiscoverable`, `canopy_origin_invalid`,
`canopy_origin_not_bare` and `canopy_origin_insecure`.

**`target` is required even though the popup does not render into it**, and the
way it bites is _timing_, not forgetfulness. `MountOptions` is a closed type, so
omitting `target` is a TypeScript error you will see immediately. What compiles
fine and fails at runtime is a `target` that points at a node **not in the DOM
yet** — a selector for markup rendered later, or an effect that runs before
paint. That refuses with `target_not_found`, silently, per the rules above. Add
an empty `<div id="checkout"></div>` and mount only once it exists.

**`mount()` is idempotent per target node.** Mounting twice on the same element
returns the _first_ handle and silently ignores the second call's options —
including a different `intentId` **and its `onEvent`**. Any framework that
re-runs an effect gets a stale handle pointing at the old intent (a live
wrong-amount charge) wired to a callback that will never fire. `destroy()`
before re-mounting. (The node lookup happens _after_ validation, so a second
mount with a bad key still returns a fresh refused handle instead.)

### Getting the two public values into the browser

The snippet above uses `process.env.NEXT_PUBLIC_*`, which works **only under
Next.js**, where those are inlined at build time. There is no `process.env` in a
browser otherwise. Outside Next.js, serve them from your own server — a
`/config.js` route emitting `window.__CANOPY__ = { pk, origin }` — or define
them at bundle time. The package ships ESM, CJS **and** a prebuilt IIFE, so a bundler is not
mandatory: either bundle it (a bare specifier will not resolve from a plain
`<script type="module">`), or use the prebuilt global at
`@canopypay/checkout-sdk/canopy.js`, which installs `window.Canopy`. If you
serve that file from your own path, keep passing `canopyOrigin` explicitly —
auto-discovery matches a script `src` ending in `/v1/canopy.js` and would
otherwise resolve to _your_ origin.

### The one thing that breaks popup checkout

**Call `window.open` synchronously, inside the click handler.** The user gesture
cannot be manufactured.

Be precise about why, because the naive version of this rule is falsifiable and
an agent that tests it will throw the whole warning away. Browsers grant a click
a **transient activation window of roughly five seconds**, not a single
synchronous tick. Measured on Chrome: `await sleep(3000)` before `window.open`
still opened the popup; `await sleep(6000)` returned `null`. So the wrong pattern
below **often appears to work on localhost** and then fails for a real user
behind a cold serverless start or a slow network — the worst possible failure
distribution.

Do not spend the budget. Open the blank window synchronously and let the SDK
navigate it.

Wrong — the intent fetch consumes the gesture:

```ts
payButton.addEventListener("click", async () => {
  const { intentId } = await fetch(...); // gesture is gone
  handle.openNow(window.open(""));       // blocked
});
```

Right — open the blank window first, then let the SDK navigate it:

```ts
payButton.addEventListener("click", () => {
  const win = window.open("", "canopy_checkout", "width=460,height=640");
  handle.openNow(win);
});
```

Create the intent and `mount()` **before** the click — on page load, or when
the cart is ready. By the time the button is pressed there must be nothing left
to await.

`openNow(null)` is what you get when the browser blocked the popup anyway. The
SDK tells you: it emits `{ type: "error", code: "popup_blocked" }`. **Do not
render that as a checkout failure** — it is routine and recoverable. Branch on
`e.code` and show a visible "Open checkout" affordance instead of an error
banner. (`popup_navigation_failed` is the neighbouring code for a window that
opened but could not be navigated.)

---

## 4. The four events a popup can emit

The SDK defines seven, but **three of them are iframe-only and can never fire
for `surface: "popup"`** — `resize`, `surface_unavailable` and
`fallback_offered` are emitted only from the frame-creation path. For a popup,
exactly four are reachable:

| Event   | Meaning                                                                                                                                                                                                                          |
| ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ready` | Handshake completed — the popup loaded the checkout.                                                                                                                                                                             |
| `paid`  | Payment sent. **Advisory only** — see section 5.                                                                                                                                                                                 |
| `error` | Carries a `code`. Not always a failure: `popup_blocked` is routine and recoverable, and the mount-refusal codes in section 3 mean the mount never happened. Branch on `code`; do not surface every `error` as a broken checkout. |
| `close` | The popup was closed.                                                                                                                                                                                                            |

**`close` fires on the happy path too.** It is emitted whenever the window goes
away — including right after a successful `paid`. Treat it as "the window is
gone", never as "the user gave up", and never as completion.

There are **two** emit paths and only one carries `via`. The 500 ms poll of
`popupWin.closed` emits `{ via: "popup" }` and lags the real close by up to half
a second; a `close` posted by the checkout page itself arrives as a bare
`{ type: "close" }` with **no `via` at all**. Branch on `e.type === "close"` and
treat `via` as advisory metadata — never as a discriminator, or you silently
drop one of the two paths.

To recover after a close, re-render your Pay button and call `openNow` with a
fresh `window.open` on the **same handle**. Do not re-`mount()` — per section 3
that silently returns the stale handle.

**`handshakeTimeoutMs` is inert for a popup.** Loader v7 defaults to 10000ms
for iframe handshakes; older pinned loader versions retain 2500ms. A value of
`0` is falsy and uses the default, as does `fallbackLabel: ""`. No timer is
armed unless a frame is created, so a handle you never `openNow` never times out.

### An idle handle is not deaf — it is a phantom-`paid` source

A handle that never opened a window does **not** sit silent. The SDK registers
its `postMessage` listener on `window` at `mount()` time, unconditionally, and
the guard deciding whether a message belongs to this handle **fails open when
the handle owns no window of its own** — it accepts _any_ message from
`canopyOrigin`.

So two Canopy surfaces on one page, or one handle you forgot to `destroy()`,
cross-talk. The idle handle receives the other's `ready` and, critically, its
**`paid`**. A page can announce "Payment sent" on checkout A because checkout
B's customer paid.

**Exactly one live handle per page, and always `destroy()` before re-mounting or
unmounting.** Keeping one live handle per checkout prevents cross-talk, and it is invisible in any happy-path test with a single mount.

**`destroy()` does not close the popup.** It clears the poll timer, removes the
listener and forgets the node; it never touches the window. Unmounting your
component while checkout is open leaves the customer on a live, orphaned payment
page you will never hear from again. Close it yourself if that is what you mean.

Full option surface: `target`, `intentId`, `merchant`, `surface`, `onEvent`,
`handshakeTimeoutMs`, `fallbackLabel`, `canopyOrigin`, `theme`, `variables`.
Unknown options are discarded at runtime — there is no `amount`, `currency` or
`onSuccess`; price lives on the intent and every callback goes through
`onEvent`. In TypeScript you will not get that far: `MountOptions` is a closed
type, so excess properties are a compile error. It is plain JavaScript where
this passes silently.

`theme` and `variables` reject per entry through the **same `error` channel**,
with codes `unknown_variable`, `variable_not_a_string`,
`variable_value_malformed`, `variable_value_too_long`,
`variable_value_erases_content`, `variable_contrast_too_low` and
`too_many_variables`, each carrying an extra `variable` field. These are
**cosmetic and never fatal** — checkout still works. Filter them out of your
alerting (`if (e.type === "error" && !e.variable)`), or a typo'd hex colour
shows the user a payment-failed banner.

`version()` is also exported; it returns an opaque loader-protocol version and
is the cheapest check that the loader initialised. Handle: `openNow(win)`, `destroy()`.

---

## 5. `paid` is not fulfilment

`paid` is a **UX signal** from a browser you do not control, on a page the user
can close or tamper with. It updates the UI. It must never release goods,
credit an account, unlock a download, or move money.

Fulfilment happens on your server, driven by Canopy's **settlement webhook**.
There is no server-side status endpoint to poll instead — `GET
/api/v1/intents/{id}` returns `{ intentId, inboxAddress, created }` and carries
no payment state whatsoever, so do not go looking for one.
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** body before
acting.

A popup makes this trap easier to fall into, because `close` feels like
completion. It is not. A user can close the popup mid-payment, and a payment
can settle long after the window is gone.

---

## 6. Failures you will actually hit

| Symptom                                                  | Cause                                                                                                                                                                                                                                                                                                                                             |
| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Popup never opens; `openNow` receives `null`             | The click's activation budget was spent before `window.open`. See section 3. The SDK emits `error` / `popup_blocked`.                                                                                                                                                                                                                             |
| Popup flashes open and immediately closes; console empty | **The mount was refused.** `openNow` on a refused handle closes the window you passed. Read the `error` code — `canopy_origin_undiscoverable`, `canopy_origin_invalid`, `canopy_origin_not_bare`, `canopy_origin_insecure`, `invalid_merchant_key`, `invalid_intent_id`, `target_not_found`. If your console is empty you did not pass `onEvent`. |
| `mount()` appears to do nothing and throws nothing       | Correct — it never throws. Refusals arrive through `onEvent` only.                                                                                                                                                                                                                                                                                |
| Popup opens but nothing renders, no events ever          | You mounted but never `openNow`, or you mounted twice on one node and are holding a stale handle.                                                                                                                                                                                                                                                 |
| `401 unauthorized`                                       | Missing/invalid secret key on the server call.                                                                                                                                                                                                                                                                                                    |
| `403 insufficient_scope`                                 | Key lacks `payment_intents:create`. Human reissues.                                                                                                                                                                                                                                                                                               |
| `400 invalid_request`                                    | Body failed validation. `error.param` names the field only for **schematic** failures (`priceUnits` wrong type). Semantic ones — `oneTime` without `priceUnits` — omit `param` entirely and put the detail in `message`. Log `request_id`; never require `param`, never branch on `message`.                                                      |
| `400 unsupported_version`                                | Fix the `Canopy-Version` header.                                                                                                                                                                                                                                                                                                                  |
| `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. Create is 30/min.                                                                                                                                                                                                                                                                                                                       |
| `503 merchant_routes_unprovisioned`                      | Account onboarding incomplete. Human finishes it.                                                                                                                                                                                                                                                                                                 |

For repeatable operations, retry only `429` and `5xx`, with backoff and jitter.
Do not automatically retry destination-less intent creation after a timeout or `5xx`: it may already have committed.
A create naming a destination can be retried with identical terms, but rotates its widget token. Never retry other `4xx`
unchanged.

---

## 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 has `intentId` and
   no key, token or address.
3. **Secret never ships** — build, then grep the client bundle and any
   server-rendered HTML for `cnpy_sk_live`. Assert zero matches.
4. **Popup opens on click** — load the page, click the button, confirm the
   popup opens, stays open, and reaches `ready`. A window that opens and closes
   again is a refused mount, not a working popup. If you have no browser
   available, say so plainly instead of asserting it works.
5. **Gesture discipline** — confirm by reading your own code that no `await`
   sits between the click handler entry and `window.open`. Passing on localhost
   is not evidence; the activation budget is generous enough to hide the bug.
6. **Blocked-popup path** — confirm you handle `openNow(null)` visibly.
7. **Auth is enforced** — call **Canopy** directly with the secret key removed
   (not your own endpoint, which wraps the error in its own shape). Assert
   `401` and `error.code === "unauthorized"`.
8. **A refusal is visible** — mount once with a deliberately bad `intentId` and
   assert you receive `error` / `invalid_intent_id`. This is the test that
   proves you attached `onEvent`; without it every misconfiguration in
   section 3 is silent.

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