TillsafeDocstillsafe.comRequest early access

Embedding the checkout

Put the hosted checkout inside your own page with one script tag, sized to its content, with live payment events.

You can send payers to the hosted checkout (https://pay.example.com/?invoice=inv_…&key=cp_test_pk_…, see the quickstart), or keep them on your page with the embed loader below. Either way, create the invoice on your server with your secret key first; the page only ever holds the publishable key.

One script tag and one call put the hosted checkout inside your own page, sized to its content, with live payment events for your page. The loader is embed.js, served by the checkout itself (https://<checkout host>/embed.js), about 1.5 kB gzip (budget: 3 kB, enforced by pnpm budget).

html
<div id="pay"></div>
<script src="https://pay.example.com/embed.js"></script>
<script>
  const checkout = CryptoPay.mount('#pay', {
    invoiceId: 'inv_…',              // created by your server with your SECRET key
    publishableKey: 'cp_live_pk_…',  // the only key a web page may hold
    theme: 'auto',                   // light | dark | auto
    locale: 'en',                    // default: the payer's browser language
    whiteLabel: true,                // your brand only, the processor is never named
    pins: 'tron:mainnet@T…,eip155:56@0x…', // your payout destinations (strongly recommended)
    onStatus: ({ status }) => console.log(status),
    onPaid: () => showThankYou(),
    onClosed: () => checkout.destroy(),
  });
</script>

A complete page is in example/index.html (the e2e suite runs it).

Options

OptionDetails
invoiceIdinv_… (required)
publishableKeycp_test_pk_… / cp_live_pk_… (required). A secret key is refused.
themelight, dark or auto
localeen, ru, vi, …
accent#rrggbb; the checkout corrects it for contrast
whiteLabelhide the processor's name everywhere
pins<caip2>@<address>,…: the checkout refuses any deposit address that does not pay these destinations. Travels in the URL fragment, so it never reaches a server.
checkoutUrlthe checkout's URL (default: where embed.js was loaded from)
titlethe frame's accessible name (default "Payment")
initialHeightpx before the checkout reports its height (default 640)
onReady, onResize, onStatus, onPaid, onClosedcallbacks

mount returns { iframe, on(type, fn), destroy() }. on returns an unsubscribe function.

Events

Each event is a callback, an on() subscription and a DOM CustomEvent on the container (checkout:<type>, data in event.detail):

EventWhenData
readythe checkout is on screen
resizeits content height changed, or a sheet opened or closed (the iframe is already resized){ height }
statusthe payment state changed{ status }: awaiting, detected, confirming, underpaid, paid, wrong_token, late, hold, misdirected, expired
paidthe payment is complete (once)
closedthe payer pressed "Done" after paying{ reason: 'done' }

Sheets and dialogs. The checkout's sheets ("Find my payment", wallet pickers, ...) are overlays inside the frame. While one is open, the checkout asks for enough height to show it whole (resize with the larger height), then scrolls it into view on your page; when it closes, the next resize gives the page's own height back. Don't cap the frame's height (max-height, a fixed-height overflow: hidden parent): a capped frame squeezes the sheet into a scroll box.

Browser events are for the experience, not for fulfilment. Ship the order on the invoice.paid webhook (verified with your webhook secret), never on a message from a web page.

Security

  • The frame is sandboxed: scripts, its own origin (for its API calls), forms, new tabs (explorers, exchange guides), printing and downloading the receipt. It can never navigate your page.
  • Messages flow one way, from the checkout to your page, with a versioned protocol (src/protocol.ts). The checkout posts only to your page's origin (passed as ?parent=, and checked against the real parent where the browser exposes it), never to *. Your page accepts a message only from the checkout's origin and from that very iframe. The checkout listens to no messages, so a page cannot drive it.
  • pins makes the checkout prove, on the payer's device, that every deposit address pays your own destination, whatever the API says.

Payer emails

Paid and underpaid screens offer an optional "Email me a receipt" field (POST /v1/checkout/invoices/{id}/notify) when the server can send email (the checkout invoice's capabilities.receipt_email); otherwise the field is not shown. Receipts are sent once the invoice is paid, and a notice when you create a refund (it never carries the claim link: send that to the payer yourself), always in your name. Nothing is sent before a payment arrives.

The address is bound to the checkout session that left it (the view's session_token, kept in the frame's sessionStorage, or in memory where the frame's storage is blocked): another browser that opens the same invoice cannot replace it. Nothing is needed from the host page.