CanopyAPI docs

SDK events

The loader sends eight events to the onEvent callback you pass to mount(). Handle at least ready, paid and error.

The quickstart's hand your user a deposit address step mounts the checkout widget with an event callback. This page covers that callback and the rest of @canopypay/checkout-sdk and the script loader. For the React component, hooks and typed callbacks, see React checkout and hooks.

The mount call

The complete script-tag integration:

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 has loaded and started to draw. It does not mean checkout is usable yet. The session is still being prepared, so the payer may see a loading state for a moment. You can stop waiting on handshakeTimeoutMs, but keep your own skeleton up. The gap is about 50 ms on a fast desktop connection and up to about two seconds on a slow mobile one.
  • resize: carries a numeric height. The loader already resizes the iframe, so you need this event only if your own layout has to react too.
  • paid: the checkout saw a payment. Use it for UI only. See the callout below.
  • deposit_detected: the payer's deposits for this intent have reached the intent's minimum when added together. This includes a partial deposit already held because it was below the minimum. It can arrive before paid. Like paid, it comes from a browser you do not control, so use it for UI only. See the callout below.
  • error: carries a code field that names the problem, such as a blocked popup, an invalid merchant key or a failed navigation.
  • close: the payer closed the checkout popup. They may not have finished paying. A missing paid event does not mean the payment failed. Confirm fulfilment with a webhook or a server-side status read. This event is popup-only. An inline embed has nothing to close, so it never fires with surface: iframe.
  • surface_unavailable: the iframe embed did not finish its handshake in time. The usual cause is an embed origin you have not registered, which makes the browser block the frame. See the embedding guide. It fires only with surface: iframe or surface: auto. With auto, a fallback_offered event follows. With iframe, an error event follows.
  • fallback_offered: after an iframe handshake failure, a fallback pay button was added to your target element. Clicking it opens the popup.

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. This is the only checkout detail you pass. Price, destination, platform fee and supported chains all come from the intent on the server. See the credentials guide.
  • merchant: your publishable key. It is the only credential that is safe in a browser.
  • surface: popup, iframe or auto. Defaults to popup, which works in every browser engine today with no CSP or cookie changes. Read the popup timing guide before you use it. iframe and auto show checkout inside your page. Register an embed origin first, or the browser blocks the frame. See embed checkout inline.
  • onEvent: the callback described above.
  • handshakeTimeoutMs: how long to wait for the iframe handshake before surface_unavailable fires. Defaults to 2500.
  • fallbackLabel: the text on the fallback button shown after an iframe handshake failure with surface: auto.
  • canopyOrigin: the Canopy origin. mount() builds the checkout URL from it and only accepts messages from it. Required if you installed @canopypay/checkout-sdk from npm. Optional with a <script> tag, which can usually find it on its own. When you set it, it is always used. See canopyOrigin below for the rules and the errors you get when it is missing or malformed.
  • theme: light, dark or auto. Defaults to auto, which follows the payer's own setting. It applies to this embed only and stores nothing in their browser.
  • variables: eight allowed names that repaint the checkout in your colours. See theming the checkout below for the names, the accepted values, and what happens to a value it does not recognise.

canopyOrigin

With a <script> tag, the SDK reads the origin from the tag that loaded it. If you installed it with npm install @canopypay/checkout-sdk, there is no tag to read, so pass canopyOrigin yourself.

See installing a checkout package for the npm, pnpm and yarn commands.

There is no default. A guess would point checkout at your own site, and you would see a dead iframe and a handshake timeout instead of an error that names 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, with no path, query string or fragment. It must use:

  • https, for any real host, or
  • http, for localhost or 127.0.0.1 only, with an optional port. Use this for local development, not production.

A valid canopyOrigin always wins, even when the page has a <script> tag. This lets a sandbox run alongside a live integration. If you leave it out, the SDK uses the script tag. With neither, mount() fails.

When mount() fails, it sends an error event whose code names the problem. It returns a handle that does nothing.

  • canopy_origin_undiscoverable: no canopyOrigin and no <script> tag. Usually an npm install that did not 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 with a real host. Only localhost and 127.0.0.1 may use it.
  • canopy_origin_invalid: the value is not a URL, for example canopypay.io with no scheme.

Theming the checkout

Two optional settings change how checkout looks, and nothing else. 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 accepts only these eight names. Each name styles several parts of the checkout at once:

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

Accepted values:

  • A colour is a hex value (#7c3aed, #abc, or with an alpha pair), an rgb(), rgba(), hsl() or hsla() in comma or space syntax, or a common CSS colour name such as transparent, black, white or navy. CSS-wide keywords such as inherit, initial, unset and currentColor are not accepted.
  • A length is a number with px, rem, em or %, or plain 0.
  • A font family list is unquoted names separated by commas, as you would write font-family.
  • Each value can be at most 64 characters. At most 12 entries are read per call.

An unknown name, or a value in any other format, is dropped on its own. The other variables still apply and checkout still renders. Styling never blocks a payment. Each dropped entry sends an error event that names the key, so check onEvent if a colour does not apply.

Colours must stay readable

This is a payment page, so a colour that would hide the amount, the deposit address or the pay button is dropped. Two rules apply to each entry. Each dropped entry is reported through onEvent.

  • text, muted and accent cannot be transparent or currentColor.
  • text needs a 3:1 contrast ratio against its background. accent needs a 3:1 ratio against the white label on the button. accent sets the button fill, not the label, so a very light accent is dropped.

text is checked against your surface and background when you set them. If you set neither, it is checked against Canopy's defaults. If you also leave theme unset, the visitor's system setting picks light or dark, so text must pass against both defaults. To check against one scheme only, set theme: "light" or theme: "dark", or pass your own background.

theme and variables work together. theme sets the base scheme and your variables paint over it. For example, theme: "dark" with an accent gives you a dark checkout in your brand colour.

Pinning a loader version

The browser package @canopypay/checkout-sdk@0.7.2 includes loader version 10. Package versions and loader versions are numbered separately. The current pinned script is /v1/10/canopy.js. If you use the npm package, update the dependency and rebuild to get changes.

A script tag can load a rolling loader or a pinned one:

  • /v1/canopy.js: rolling. Always the latest loader. It is cached briefly, so a fix reaches you within minutes.
  • /v1/<version>/canopy.js: pinned. One fixed version, cached for a year. Use it to stay on a known-good loader during an incident, or to choose when you take a behavior change.

A pinned version never changes and stays available, even after newer versions ship. A version that does not exist returns a 404 that is never cached. There is no redirect to a nearby version, so a mistyped pin fails clearly instead of loading the wrong loader.

See also

  • Quickstart: the full walkthrough that uses this mount() call.
  • Popup timing: why the default surface: popup must open synchronously inside the click handler.