Guides
Embed checkout inline
Render checkout inside your own page with the native React component or the browser SDK. Register your frontend origin, then choose the integration that fits your app.
The checkout SDK has two surfaces. surface: "popup" is the default and needs no setup — see the popup timing guide. surface: "iframe" renders checkout inline, in a frame the SDK injects into an element you name. This guide covers the iframe path. For native rendering and shared lifecycle state, see React checkout and hooks.
Register your origin first
An inline embed loads
/customer-payin a frame on your page, so that response has to allow your site as a frame ancestor. Canopy computes that allowance per request, from the publishable key in the checkout URL: it looks up the embed origins registered against that key and emits them asframe-ancestors. An origin you have not registered is not in the list, and the browser refuses to render the frame. Native React requests use this same allowlist for their API origin checks.This is self-serve — no request to Canopy is involvedEmbed origins are a setting on your own account, not a server config on ours. Add one in the dashboard under Embed origins, or through the API below. It takes effect on the next request; there is no deploy, no review, and nothing to ask support for.
Register the exact origin you will embed from — scheme, host and port, nothing else:
bash curl https://canopypay.io/api/merchant/embed-origins \ -H 'content-type: application/json' \ -d '{"origin": "https://shop.example.com"}'Three rules this endpoint enforces, each with its own error code: wildcards are refused outright (
wildcard_embed_origin_refused) — there is no way to allow*.example.com, so register each subdomain you actually embed from;httpsis required (https_required); and the value must be a bare origin with no path, query or fragment (origin_must_be_scheme_host_port).This endpoint uses your dashboard session, not an API keyUnlike the rest of
/api/merchant/*, embed-origin registration authenticates with your logged-in session. A merchant secret key will not work here. If you are scripting it, the dashboard is the simpler path.Registering a staging origin alongside production is normal — the list is per key, and each entry is independent.
Install a checkout package
Two packages render the same checkout.
@canopypay/checkout-sdkopens it in an iframe, in any framework.@canopypay/react-checkoutrenders it natively, with no second document, if you are already on React.Pick the iframe path and install the checkout SDK:
npm install @canopypay/checkout-sdk@0.7.2Or pick native React and install the React package instead:
npm install @canopypay/react-checkout@0.3.0Import the stylesheet once, then render
CanopyCheckoutwith a client and an intent id:Checkout.tsxts "use client"; import { useMemo } from "react"; import { CanopyCheckout, createCheckoutClient, } from "@canopypay/react-checkout"; import "@canopypay/react-checkout/styles.css"; export function Checkout({ intentId, merchant, refreshOrder, }: { intentId: string | null; merchant: string; refreshOrder: () => void; }) { const client = useMemo(() => createCheckoutClient({ merchant }), [merchant]); return ( <CanopyCheckout client={client} intentId={intentId} features={{ manualTransfer: true, walletPayment: true }} onEvent={(event) => { if (event.type === "paid") refreshOrder(); }} /> ); }@canopypay/react-checkoutneeds React and React DOM^18.3.0or^19.0.0as peers. It needs the same registered origin as the iframe path above.React
0.3.0also providesuseCheckoutfor preparation, retry/reset and typed callbacks, plususeCheckoutStatusfor sibling UI. Follow the React SDK guide for a complete hooks example and upgrade instructions. The client/intent form above remains supported.Its events differ from the iframe SDK's in one way that matters. This package splits the two moments the iframe SDK collapses into one:
shell_readyfires once the shell has mounted, andreadywaits until checkout is actually usable. The iframe SDK emits onlyready, and itsreadyis theshell_readymoment — see the note below.paidstays advisory on every surface; fulfil from a webhook, never from this event.Mount the inline surface
Once the origin is registered, the only code change is the
surfaceoption:ts import { mount } from '@canopypay/checkout-sdk'; const { intentId } = await ( await fetch('/my-server/create-canopy-intent') ).json(); const handle = mount({ target: '#checkout', intentId, merchant: 'cnpy_pk_live_abc123', canopyOrigin: 'https://canopypay.io', surface: 'auto', onEvent: (e) => { // The frame is alive and painting -- not yet usable. Stop reserving // space for a fallback, but leave checkout's own loading state to it. if (e.type === 'ready') stopWaitingForHandshake(); // Advisory only -- never fulfilment. See the guide linked below. if (e.type === 'paid') showBanner("Payment sent -- we'll confirm shortly"); }, }); // When your component unmounts: handle.destroy();Prefer
surface: "auto"over"iframe". Both attempt the inline embed first; they differ only in what happens when the frame cannot complete its handshake. Underautothe SDK falls back to a button that opens the popup, so the payer can still pay. Underiframeit emitserrorwith codeiframe_blockedand stops, leaving the payer with nothing. Chooseiframeonly when you would rather show your own recovery UI than Canopy's button.canopyOriginis required whenever you installed the SDK from npm, inline or not — there is no script tag for the loader to read the origin from. See canopyOrigin.The SDK sizes the frame for you. It sets a starting height, then applies the real content height from the checkout's own
resizemessages as the payer moves through the flow. You do not need to set a height, and you should not fix one — the frame grows and shrinks with its content.Current browser SDK: 0.7.2, loader v10Inline embedding needs
@canopypay/checkout-sdk@0.3.0or later (loader version 3). Earlier loaders rendered the frame at the browser's default 150px until the first resize landed, and did not grant the frame clipboard access — which broke the copy-address button, the main action on the inline surface. Pinned to an older loader? Upgrade to the current browser package or/v1/10/canopy.js.
What you receive
Inline checkout emits ready once the frame is alive, resize as the content height changes, paid when the payer's transfer is seen on chain, and error for the failure cases. Full list on SDK events.
Three differences from the popup surface are worth knowing before you write UI against them:
readyis a frame-alive handshake, not a "checkout is usable" signal. The checkout document posts it before its session finishes preparing, so for a moment afterreadythe payer is looking at checkout's own loading state. Do not swap your skeleton for the frame on this event — you will reveal a spinner. Reserve space for the frame from the start and let checkout render its own loading state in place. On a fast desktop connection the gap is around 50 ms; on a slow mobile one it has been measured near two seconds.closenever fires inline. It reports that the payer closed the popup window, and an inline frame has nothing to close — you own the container and decide when to remove it. Do not wait on it.- The inline surface offers manual transfer only. Paying from a browser wallet needs a popup, which the checkout opens itself when the payer asks for it — you do not wire anything for this.
paid is a UI signal and nothing more. Release goods on a webhook or a server-side status call, never on this event.
Troubleshooting
Nearly every inline failure is an unregistered origin. It surfaces as a frame that never finishes loading rather than as an error naming the cause, because the refusal happens in the browser, before any Canopy code in the frame runs.
- A blank frame, then
surface_unavailable— the frame was refused, or it loaded but never handshook. Check your browser console for a Content-Security-Policyframe-ancestorsviolation. If it is there, the origin is not registered, or is registered with a different scheme, host or port than the page is actually served from. - Requesting
/customer-paydirectly returnsframe-ancestors 'none'— expected, and not evidence of a block. With no valid?merchant=key there is no account to resolve origins for, so the response denies everyone. To check your own registration, request/customer-pay?merchant=YOUR_PUBLISHABLE_KEYand read theframe-ancestorsdirective on that response — your origin should appear in it. - Looking for an embed-specific URL or subdomain — there is not one. Inline and popup load the same
/customer-payroute thatmount()builds for you. There is no/embedpath and nocheckout.host; a 404 on those is not a missing feature. errorwith acanopy_origin_*code — this is aboutcanopyOrigin, the option that tells the SDK where Canopy lives. Unrelated to embed origins, which tell Canopy where you live. See canopyOrigin.- The copy-address button says copy failed — you are on a loader older than v3. Upgrade to
@canopypay/checkout-sdk@0.3.0or later.