SDK events
Handle at least ready, paid, and error from the eight events the loader emits through the onEvent callback you pass to mount().
This is the callback the quickstart's hand-your-user-a-deposit-address step wires up when it mounts the checkout widget. This page describes @canopypay/checkout-sdk and the script loader. For the native React component, lifecycle hooks and typed callbacks, see React checkout and hooks.
The mount call
The whole script-tag integration, from the loader's own worked example:
<script src="https://canopypay.io/v1/canopy.js"></script>
<script type="module">
const { intentId } = await (
await fetch("/my-server/create-canopy-intent")
).json();
const handle = Canopy.mount({
target: "#checkout",
intentId,
merchant: "cnpy_pk_live_abc123",
onEvent: (e) => {
if (e.type === "paid") showBanner("Payment sent");
},
});
payButton.addEventListener("click", () => {
handle.openNow(window.open("", "canopy_checkout", "width=460,height=640"));
});
</script>The eight events
onEvent: (event) => {
switch (event.type) {
case "ready":
// The surface is alive and painting. NOT "checkout is usable" -- the
// session is still being prepared. Keep your own skeleton up.
break;
case "resize":
// The embedded iframe's content height changed; event.height is the new value.
break;
case "paid":
// Advisory. Fulfilment comes from a settlement webhook or a status read.
break;
case "deposit_detected":
// Advisory, like paid. Can arrive before paid. Release goods on a
// settlement webhook or a server-side status read, never on this event.
break;
case "error":
// Something failed: a blocked popup, an invalid merchant key, a bad canopyOrigin, ...
break;
case "close":
// The payer closed the popup or the checkout document sent a close message.
break;
case "surface_unavailable":
// The iframe surface failed its handshake within handshakeTimeoutMs.
break;
case "fallback_offered":
// A fallback button was inserted after an iframe handshake failure (surface: auto).
break;
}
}ready: the checkout document is alive and has begun painting. It is a frame-alive handshake, not a "checkout is usable" signal — the loader posts it before the session finishes preparing, so the payer may still see a loading state for a moment after it. Safe to stop waiting onhandshakeTimeoutMs; not the moment to tear down your own skeleton. On a fast desktop connection the gap is around 50 ms; on a slow mobile one it has been measured near two seconds.resize: carries a numericheight. The loader already applies it to the iframe, so you need this event only if your own layout reacts to the change too.paid: the checkout document observed a payment. UX signal only -- see the callout below.deposit_detected: the deposits the payer has sent for this intent -- including a partial that is already parked below the minimum -- together clear the intent's floor. It can arrive beforepaid, and likepaidit is an advisory UX signal from a browser you do not control -- see the callout below.error: carries acodefield naming what went wrong, such as a blocked popup, an invalid merchant key or a failed navigation.close: the payer closed the checkout popup, not necessarily after completing. A missingpaidevent does not prove the payment failed. Confirm fulfilment from a webhook or a server-side status read. Popup-only -- an inline embed has nothing to close, so this never fires withsurface: iframe.surface_unavailable: the iframe embed did not finish its handshake in time. Almost always an unregistered embed origin -- the browser refuses the frame before any Canopy code in it runs. See the embedding guide. Only reachable withsurface: iframeorsurface: auto. Underautoafallback_offeredfollows; underiframeanerrordoes.fallback_offered: a fallback pay button was inserted into your target element after an iframe handshake failure. Clicking it opens the popup surface.
Wire UI to these events, nothing more. Fulfilment must come from a settlement webhook or a server-side status read, never from either one.
error codes
Every error event carries a code. They fall into four groups by where they come from, which is also the fastest way to tell whether the problem is your call, the browser, or the checkout itself.
Refused at mount()
Nothing was built: no popup, no iframe, no checkout session. The handle mount() returned does nothing, and calling openNow() on it closes the window you passed and repeats the error rather than leaving a blank popup open.
invalid_merchant_key:merchantwas missing, not a string, or did not start withcnpy_pk_live_. Most often a secret key passed by mistake -- see the credentials guide.invalid_intent_id:intentIdwas missing, not a string, or not the 32 lowercase hex charactersPOST /api/v1/intentsreturns.invalid_features:featureswas not an object of supported boolean options, contained an unknown option, or disabled both payment methods. The supported options aremanualTransferandwalletPayment; both default totrue. Omitfeaturesto use those defaults, or keep at least one payment method enabled.target_not_found:targetwas a selector that matched no element, or an element that is not in the document. Check that the container exists beforemount()runs -- a<script>in<head>runs before the body it is looking for.canopy_origin_undiscoverable,canopy_origin_not_bare,canopy_origin_insecure,canopy_origin_invalid: see canopyOrigin.
The surface could not open
popup_blocked: the window you passed toopenNow()wasnull. Almost always anawaitbeforewindow.open()-- read popup timing.popup_navigation_failed: the window existed but could not be navigated, which a browser extension or a closed window can cause.iframe_blocked: the inline frame did not finish its handshake withinhandshakeTimeoutMs. Withsurface: iframethis normally means your origin is not registered -- see embed checkout inline. Undersurface: autoa fallback button is offered instead and this code is not emitted.
The checkout refused the intent
Sent by the checkout document itself, so the surface opened fine and the problem is the intent. Both reach you on the popup surface as well as the iframe one.
intent_not_found: the id is unknown, archived, malformed, or belongs to a different account than themerchantkey you passed. Those are ONE indistinguishable outcome on purpose -- the response cannot be used to discover whether someone else's intent id exists. Create a fresh intent.checkout_unavailable: the intent is real and yours, but a session could not be opened for it right now. Transient and worth retrying; if it persists, the account's payout route is probably not confirmed yet.
A theming entry was dropped
Cosmetic and per-entry: the checkout mounts and works, and only the named entry is discarded. Each carries a variable field naming it. See theming the checkout.
unknown_variable: not one of the eight names.variable_not_a_string,variable_value_too_long,variable_value_malformed: the value is not a string, is over 64 characters, or does not match the grammar for that name.variable_value_erases_content,variable_contrast_too_low: the value would hide content -- see the legibility floor.too_many_variables: more than twelve entries were passed.
mount() options
target: a CSS selector or element to mount into.intentId: the id of a payment intent your server created withPOST /api/v1/intents. Everything else about the checkout -- price, destination, platform fee, supported chains -- resolves server-side from it, so this is the only thing you pass. See the credentials guide.merchant: your publishable key, the one credential that is safe in a browser.surface:popup,iframeorauto. Defaults topopup, the surface that works today in every browser engine with no CSP or cookie change. Read the popup timing guide before you use it.iframeandautorender checkout inline and require you to register an embed origin first, or the frame is refused -- see embed checkout inline.onEvent: the callback documented above.handshakeTimeoutMs: how long an iframe handshake is given beforesurface_unavailablefires. Defaults to 2500.fallbackLabel: the text on the fallback button rendered after an iframe handshake failure withsurface: auto.canopyOrigin: the Canopy originmount()builds the checkout URL against and pins the message listener to. Required if you installed@canopypay/checkout-sdkfrom npm. Optional for a<script>tag integration, which can usually discover it, and always honored when set. See canopyOrigin below for the validation rules and what happens when it is missing or malformed.theme:light,darkorauto. Defaults toauto, which follows your payer's own preference. Applies to this embed only and writes nothing to their browser.variables: eight allowlisted names that repaint the checkout in your colours. See theming the checkout below for the names, the accepted values, and what happens to one it does not recognise.
canopyOrigin
With a <script> tag, the SDK reads the origin off the tag that loaded it. Installed from npm (npm install @canopypay/checkout-sdk) there is no tag to read, so you pass canopyOrigin yourself.
See installing a checkout package for the npm, pnpm and yarn commands.
There is no default on purpose. Guessing would point checkout at your own site, and you would see a dead iframe and a handshake timeout instead of a message naming the cause.
Canopy.mount({
target: '#checkout',
merchant: 'cnpy_pk_live_abc123',
canopyOrigin: 'https://canopypay.io',
onEvent: (e) => {
if (e.type === 'error') {
// e.code is one of the canopy_origin_* values below when
// canopyOrigin (or the script-tag fallback) could not be resolved.
}
},
});Pass a bare origin -- no path, no query, no fragment -- that is either:
https, for any real host, orhttp, forlocalhostor127.0.0.1only, port optional. For local development, not production.
If you pass a valid canopyOrigin it always wins, even with a <script> tag on the page -- that is how a sandbox runs alongside a live integration. Leave it out and the script tag is used. With neither, mount() refuses.
Refusals arrive as an error event whose code names what failed, and mount() hands back an inert handle.
canopy_origin_undiscoverable: nocanopyOriginand no<script>tag. Usually an npm install that forgot to pass one.canopy_origin_not_bare: the value carries a path, a query string or a fragment. For example, the full script URLhttps://canopypay.io/v1/canopy.jsinstead ofhttps://canopypay.io.canopy_origin_insecure: plainhttpagainst a real host. Onlylocalhostand127.0.0.1may use it.canopy_origin_invalid: the value cannot be parsed as a URL at all, for examplecanopypay.iowith no scheme.
Theming the checkout
Two options, both optional and both cosmetic. theme picks a light or dark checkout for this embed; variables repaints it in your colours.
Canopy.mount({
target: "#checkout",
intentId,
merchant: "cnpy_pk_live_abc123",
theme: "dark",
variables: {
accent: "#7c3aed",
background: "transparent",
radius: "8px",
font: "Inter, system-ui, sans-serif",
},
});variables takes these eight names and nothing else. Each one drives several parts of the checkout at once, so you set a colour, not a component:
| Name | What it paints | Accepts |
|---|---|---|
accent | Primary buttons, focus rings, active states | a colour |
background | The page behind the checkout | a colour |
surface | Cards, popovers, secondary fills | a colour |
text | Body copy and headings | a colour |
muted | Secondary and helper copy | a colour |
border | Card borders, dividers, input outlines | a colour |
radius | Corner rounding, everywhere | a length |
font | The whole checkout's type | a font family list |
A colour is a hex value (#7c3aed, #abc, or with an alpha pair), an rgb(), rgba(), hsl() or hsla() in either comma or space syntax, or one of the common CSS colour names (transparent, black, white, navy, and so on). Every accepted colour has to resolve to a real value, so CSS-wide keywords such as inherit, initial, unset and currentColor are not colours here and are dropped. A length is a number with px, rem, em or %, or plain 0. A font family list is unquoted names separated by commas, the way you would write font-family. Nothing else is accepted, values are capped at 64 characters, and at most 12 entries are read per call.
A name we do not recognise, or a value that is not one of the shapes above, is dropped on its own. Every other variable in the same call still applies and the checkout still renders -- styling can never block a payment. Each dropped entry arrives as an error event naming the key, so check onEvent if a colour does not take.
The legibility floor
This is a payment page, so a colour that hides the amount, the deposit address or the pay button is dropped rather than applied. Two rules, both per-entry and both reported through onEvent:
text,mutedandaccentcannot betransparentorcurrentColor-- a foreground has no legitimate invisible value.texthas to reach a 3:1 contrast ratio against the background it lands on, andaccenthas to reach 3:1 against the white label that sits on it.accentsets the button fill but not that label -- so a very light accent is refused rather than shipped as an unreadable button.
The backdrop text is measured against is your surface and background when you supply them. If you supply neither, it is measured against Canopy's own defaults -- and if you also left theme unset, the visitor's system preference decides the scheme, so it has to read on both the light and the dark default. Setting theme: "light" or theme: "dark", or passing a background of your own, is what removes that ambiguity.
theme and variables compose. theme picks the base scheme and your variables paint over it, so theme: "dark" with an accent gives you a dark checkout in your brand colour.
Pinning a loader version
Browser package @canopypay/checkout-sdk@0.7.2 includes loader version 10. npm package versions and loader pins are separate: the current pinned script is /v1/10/canopy.js. Update your npm dependency and rebuild to receive package changes.
Every script tag can point at either a rolling or an immutable loader path:
/v1/canopy.js-- rolling. Always the current loader, briefly cached, so a fix reaches you within minutes./v1/<version>/canopy.js-- pinned. One archived version, cached for a year. Use it to freeze on a known-good loader during an incident, or to choose when you take a behavior change.
An archived version stays servable forever, byte-identical, even after a newer one ships. The archive is checksum-verified on every build, so an archived entry cannot change under a pinned integration. A version that never existed returns a 404 with an explicitly uncacheable response. There is no redirect and no best-effort match to a nearby version, so a typo'd pin fails loudly instead of serving the wrong loader.
See also
- Quickstart: the full walkthrough this page's mount() call hangs off.
- Popup timing: why the default
surface: popupmust open synchronously inside the click handler that triggers it.