CanopyAPI docs

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.

  1. Register your origin first

    An inline embed loads /customer-pay in 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 in frame-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.

    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.
    • https only (https_required).
    • Origin only, with no path, query or fragment (origin_must_be_scheme_host_port).

    You can register a staging origin next to production. The list is per key, and each entry stands on its own.

  2. Install a checkout package

    Two packages show the same checkout. @canopypay/checkout-sdk opens it in an iframe and works with any framework. @canopypay/react-checkout renders 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.2

    Or pick native React and install the React package instead:

    npm install @canopypay/react-checkout@0.3.0

    Import the stylesheet once, then render CanopyCheckout with 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-checkout needs React and React DOM ^18.3.0 or ^19.0.0 as peer dependencies. It also needs your origin registered, the same as the iframe path.

    Version 0.3.0 of the React package also provides two hooks. useCheckout handles preparation, retry and reset, and typed callbacks. useCheckoutStatus lets 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_ready fires when the shell has mounted. ready fires when checkout is usable. The iframe SDK emits only ready, and it fires at the shell_ready moment. See the note below. paid is advisory on every surface. Fulfil from a webhook, never from this event.

  3. Mount the inline surface

    With your origin registered, the only code change is the surface option:

    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:

    • auto falls back to a button that opens the popup. The payer can still pay.
    • iframe emits error with code iframe_blocked and stops. The payer is left with nothing.

    Choose iframe only if you want to show your own recovery UI instead of Canopy's button.

    If you installed the SDK from npm, canopyOrigin is 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 resize messages as the payer moves through the flow. Do not set a fixed height. The frame grows and shrinks with its content.

What you receive

Inline checkout emits these events:

  • ready when the frame is alive.
  • resize when the content height changes.
  • paid when the payer's transfer is seen on chain.
  • error when something fails.

See SDK events for the full list.

Three things work differently from the popup surface:

  • ready means 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.
  • close never 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.

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-Policy frame-ancestors violation. 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-pay directly returns frame-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_KEY and read its frame-ancestors directive. Your origin should be listed.
  • Looking for an embed-specific URL or subdomain. There is none. Inline and popup both load the same /customer-pay route that mount() builds for you. There is no /embed path and no checkout. host. A 404 on those is expected.
  • error with a canopy_origin_* code. This is about canopyOrigin, 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.0 or later.