CanopyAPI docs

Guides

Embed checkout inline

Render checkout inside your own page with the native React component or the browser SDK. Register your frontend origin, then choose the integration 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" renders checkout inline, in a frame the SDK injects into an element you name. This guide covers the iframe path. For native rendering and shared lifecycle state, see React checkout and hooks.

  1. Register your origin first

    An inline embed loads /customer-pay in a frame on your page, so that response has to allow your site as a frame ancestor. Canopy computes that allowance per request, from the publishable key in the checkout URL: it looks up the embed origins registered against that key and emits them as frame-ancestors. An origin you have not registered is not in the list, and the browser refuses to render the frame. Native React requests use this same allowlist for their API origin checks.

    Register the exact origin you will embed from — scheme, host and port, nothing else:

    bash
    curl https://canopypay.io/api/merchant/embed-origins \
      -H 'content-type: application/json' \
      -d '{"origin": "https://shop.example.com"}'

    Three rules this endpoint enforces, each with its own error code: wildcards are refused outright (wildcard_embed_origin_refused) — there is no way to allow *.example.com, so register each subdomain you actually embed from; https is required (https_required); and the value must be a bare origin with no path, query or fragment (origin_must_be_scheme_host_port).

    Registering a staging origin alongside production is normal — the list is per key, and each entry is independent.

  2. Install a checkout package

    Two packages render the same checkout. @canopypay/checkout-sdk opens it in an iframe, in any framework. @canopypay/react-checkout renders it natively, with no second document, if you are already on React.

    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 peers. It needs the same registered origin as the iframe path above.

    React 0.3.0 also provides useCheckout for preparation, retry/reset and typed callbacks, plus useCheckoutStatus for sibling UI. Follow the React SDK guide for a complete hooks example and upgrade instructions. The client/intent form above remains supported.

    Its events differ from the iframe SDK's in one way that matters. This package splits the two moments the iframe SDK collapses into one: shell_ready fires once the shell has mounted, and ready waits until checkout is actually usable. The iframe SDK emits only ready, and its ready is the shell_ready moment — see the note below. paid stays advisory on every surface; fulfil from a webhook, never from this event.

  3. Mount the inline surface

    Once the origin is 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();

    Prefer surface: "auto" over "iframe". Both attempt the inline embed first; they differ only in what happens when the frame cannot complete its handshake. Under auto the SDK falls back to a button that opens the popup, so the payer can still pay. Under iframe it emits error with code iframe_blocked and stops, leaving the payer with nothing. Choose iframe only when you would rather show your own recovery UI than Canopy's button.

    canopyOrigin is required whenever you installed the SDK from npm, 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 sets a starting height, then applies the real content height from the checkout's own resize messages as the payer moves through the flow. You do not need to set a height, and you should not fix one — the frame grows and shrinks with its content.

What you receive

Inline checkout emits ready once the frame is alive, resize as the content height changes, paid when the payer's transfer is seen on chain, and error for the failure cases. Full list on SDK events.

Three differences from the popup surface are worth knowing before you write UI against them:

  • ready is a frame-alive handshake, not a "checkout is usable" signal. The checkout document posts it before its session finishes preparing, so for a moment after ready the payer is looking at checkout's own loading state. Do not swap your skeleton for the frame on this event — you will reveal a spinner. Reserve space for the frame from the start and let checkout render its own loading state in place. On a fast desktop connection the gap is around 50 ms; on a slow mobile one it has been measured near two seconds.
  • close never fires inline. It reports that 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 on it.
  • The inline surface offers manual transfer only. Paying from a browser wallet needs a popup, which the checkout opens itself when the payer asks for it — you do not wire anything for this.

Troubleshooting

Nearly every inline failure is an unregistered origin. It surfaces as a frame that never finishes loading rather than as an error naming the cause, because the refusal happens in the browser, before any Canopy code in the frame runs.

  • A blank frame, then surface_unavailable — the frame was refused, or it loaded but never handshook. Check your browser console for a Content-Security-Policy frame-ancestors violation. If it is there, the origin is not registered, or is registered with a different scheme, host or port than the page is actually served from.
  • Requesting /customer-pay directly returns frame-ancestors 'none' — expected, and not evidence of a block. With no valid ?merchant= key there is no account to resolve origins for, so the response denies everyone. To check your own registration, request /customer-pay?merchant=YOUR_PUBLISHABLE_KEY and read the frame-ancestors directive on that response — your origin should appear in it.
  • Looking for an embed-specific URL or subdomain — there is not one. Inline and popup 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 not a missing feature.
  • error with a canopy_origin_* code — this is about canopyOrigin, the option that tells the SDK where Canopy lives. Unrelated to embed origins, which tell Canopy where you live. See canopyOrigin.
  • The copy-address button says copy failed — you are on a loader older than v3. Upgrade to @canopypay/checkout-sdk@0.3.0 or later.