Guides
Embed checkout inline
Show checkout inside your own page with the React component or the browser SDK. Register your frontend origin first, then pick the option 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" shows checkout inline, in a frame the SDK adds to an element you choose. This guide covers the iframe path. To render checkout natively in React, see React checkout and hooks.
Register your origin first
An inline embed loads
/customer-payin a frame on your page. The browser shows that frame only if your site is allowed to frame it. Canopy reads the publishable key in the checkout URL and lists the embed origins registered for that key inframe-ancestors. If your origin is not registered, the browser will not show the frame. The native React package uses the same list to check where its API requests come from.This is self-serve. You do not need to ask Canopy.Embed origins are a setting on your account. Add one in the dashboard under Embed origins, or with the API below. It works on the next request. There is no deploy or review, and you do not need to contact support.
Register the exact origin you embed from. That is the scheme, host and port, and nothing else:
bash curl https://canopypay.io/api/merchant/embed-origins \ -H 'content-type: application/json' \ -d '{"origin": "https://shop.example.com"}'The endpoint returns an error code when a rule is broken:
- No wildcards (
wildcard_embed_origin_refused). You cannot allow*.example.com. Register each subdomain you embed from. httpsonly (https_required).- Origin only, with no path, query or fragment (
origin_must_be_scheme_host_port).
This endpoint uses your dashboard session, not an API keyThe rest of
/api/merchant/*uses an API key. This endpoint uses your logged-in dashboard session instead. A merchant secret key does not work here. If you are scripting it, the dashboard is easier.You can register a staging origin next to production. The list is per key, and each entry stands on its own.
- No wildcards (
Install a checkout package
Two packages show the same checkout.
@canopypay/checkout-sdkopens it in an iframe and works with any framework.@canopypay/react-checkoutrenders it directly in your React app, with no iframe.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 peer dependencies. It also needs your origin registered, the same as the iframe path.Version
0.3.0of the React package also provides two hooks.useCheckouthandles preparation, retry and reset, and typed callbacks.useCheckoutStatuslets other parts of your UI read checkout status. See the React SDK guide for a full hooks example and upgrade steps. The client and intent form above still works.One event difference matters. The React package has two events where the iframe SDK has one.
shell_readyfires when the shell has mounted.readyfires when checkout is usable. The iframe SDK emits onlyready, and it fires at theshell_readymoment. See the note below.paidis advisory on every surface. Fulfil from a webhook, never from this event.Mount the inline surface
With your origin 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();Use
surface: "auto"rather than"iframe". Both try the inline embed first. They differ only when the frame cannot finish its handshake:autofalls back to a button that opens the popup. The payer can still pay.iframeemitserrorwith codeiframe_blockedand stops. The payer is left with nothing.
Choose
iframeonly if you want to show your own recovery UI instead of Canopy's button.If you installed the SDK from npm,
canopyOriginis required, 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 starts with a default height, then follows the checkout's
resizemessages as the payer moves through the flow. Do not set a fixed height. 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). Older loaders showed the frame at 150px until the first resize. They also did not give the frame clipboard access, so the copy-address button did not work. On an older loader? Upgrade to the current browser package or/v1/10/canopy.js.
What you receive
Inline checkout emits these events:
readywhen the frame is alive.resizewhen the content height changes.paidwhen the payer's transfer is seen on chain.errorwhen something fails.
See SDK events for the full list.
Three things work differently from the popup surface:
readymeans the frame is alive, not that checkout is usable. Checkout sends it before its session is ready, so the payer briefly sees checkout's own loading state. Do not swap your skeleton for the frame on this event, or you will show a spinner. Reserve space for the frame from the start and let checkout show its own loading state. The gap is about 50 ms on a fast desktop connection and has been measured near two seconds on slow mobile.closenever fires inline. It means 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 for this event.- The inline surface offers manual transfer only. To pay from a browser wallet, checkout opens a popup itself when the payer asks. You do not need to wire anything for this.
paid is only a UI signal. Release goods on a webhook or a server-side status call, never on this event.
Troubleshooting
Almost every inline failure is an unregistered origin. You see a frame that never finishes loading, not an error that names the cause. The browser blocks the frame before any Canopy code can run.
- A blank frame, then
surface_unavailable. The frame was blocked, or it loaded but never finished its handshake. Look in your browser console for a Content-Security-Policyframe-ancestorsviolation. If you see one, your origin is not registered. Or it is registered with a different scheme, host or port than the page you serve. - Requesting
/customer-paydirectly returnsframe-ancestors 'none'. This is expected and does not mean you are blocked. Without a valid?merchant=key there is no account to look up, so the response blocks everyone. To check your registration, request/customer-pay?merchant=YOUR_PUBLISHABLE_KEYand read itsframe-ancestorsdirective. Your origin should be listed. - Looking for an embed-specific URL or subdomain. There is none. Inline and popup both load the same
/customer-payroute thatmount()builds for you. There is no/embedpath and nocheckout.host. A 404 on those is expected. errorwith acanopy_origin_*code. This is aboutcanopyOrigin, the option that tells the SDK where Canopy lives. It is not about embed origins, which tell Canopy where you live. See canopyOrigin.- The copy-address button says copy failed. Your loader is older than v3. Upgrade to
@canopypay/checkout-sdk@0.3.0or later.