CanopyAPI docs

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:

html
<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

ts
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 on handshakeTimeoutMs; 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 numeric height. 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 before paid, and like paid it is an advisory UX signal from a browser you do not control -- see the callout below.
  • error: carries a code field 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 missing paid event 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 with surface: 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 with surface: iframe or surface: auto. Under auto a fallback_offered follows; under iframe an error does.
  • fallback_offered: a fallback pay button was inserted into your target element after an iframe handshake failure. Clicking it opens the popup surface.

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: merchant was missing, not a string, or did not start with cnpy_pk_live_. Most often a secret key passed by mistake -- see the credentials guide.
  • invalid_intent_id: intentId was missing, not a string, or not the 32 lowercase hex characters POST /api/v1/intents returns.
  • invalid_features: features was not an object of supported boolean options, contained an unknown option, or disabled both payment methods. The supported options are manualTransfer and walletPayment; both default to true. Omit features to use those defaults, or keep at least one payment method enabled.
  • target_not_found: target was a selector that matched no element, or an element that is not in the document. Check that the container exists before mount() 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 to openNow() was null. Almost always an await before window.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 within handshakeTimeoutMs. With surface: iframe this normally means your origin is not registered -- see embed checkout inline. Under surface: auto a 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 the merchant key 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 with POST /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, iframe or auto. Defaults to popup, the surface that works today in every browser engine with no CSP or cookie change. Read the popup timing guide before you use it. iframe and auto render 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 before surface_unavailable fires. Defaults to 2500.
  • fallbackLabel: the text on the fallback button rendered after an iframe handshake failure with surface: auto.
  • canopyOrigin: the Canopy origin mount() builds the checkout URL against and pins the message listener to. Required if you installed @canopypay/checkout-sdk from 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, dark or auto. Defaults to auto, 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.

ts
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, or
  • http, for localhost or 127.0.0.1 only, 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: no canopyOrigin and 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 URL https://canopypay.io/v1/canopy.js instead of https://canopypay.io.
  • canopy_origin_insecure: plain http against a real host. Only localhost and 127.0.0.1 may use it.
  • canopy_origin_invalid: the value cannot be parsed as a URL at all, for example canopypay.io with 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.

js
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:

NameWhat it paintsAccepts
accentPrimary buttons, focus rings, active statesa colour
backgroundThe page behind the checkouta colour
surfaceCards, popovers, secondary fillsa colour
textBody copy and headingsa colour
mutedSecondary and helper copya colour
borderCard borders, dividers, input outlinesa colour
radiusCorner rounding, everywherea length
fontThe whole checkout's typea 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, muted and accent cannot be transparent or currentColor -- a foreground has no legitimate invisible value.
  • text has to reach a 3:1 contrast ratio against the background it lands on, and accent has to reach 3:1 against the white label that sits on it. accent sets 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: popup must open synchronously inside the click handler that triggers it.