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).
<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
| Option | Details |
|---|---|
invoiceId | inv_… (required) |
publishableKey | cp_test_pk_… / cp_live_pk_… (required). A secret key is refused. |
theme | light, dark or auto |
locale | en, ru, vi, … |
accent | #rrggbb; the checkout corrects it for contrast |
whiteLabel | hide 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. |
checkoutUrl | the checkout's URL (default: where embed.js was loaded from) |
title | the frame's accessible name (default "Payment") |
initialHeight | px before the checkout reports its height (default 640) |
onReady, onResize, onStatus, onPaid, onClosed | callbacks |
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):
| Event | When | Data |
|---|---|---|
ready | the checkout is on screen | |
resize | its content height changed, or a sheet opened or closed (the iframe is already resized) | { height } |
status | the payment state changed | { status }: awaiting, detected, confirming, underpaid, paid, wrong_token, late, hold, misdirected, expired |
paid | the payment is complete (once) | |
closed | the 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. pinsmakes 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.