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