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