Embedded checkout
The same checkout, in a frame over your own page. The customer never leaves the store.
Nothing about the payment changes — same origin, same wallet handling, same webhooks. What changes is that a redirect does not throw away the page the customer was on. On a phone that page is often the whole cart.
Add it
<script src="https://pay-checkout.chromia.com/embed.js"></script>
<button id="pay">Pay with CHR</button>
<script>
document.getElementById('pay').addEventListener('click', async () => {
// Your own endpoint: it calls payments.create server-side and returns { checkout_url }.
// The API key never reaches the browser.
const res = await fetch('/api/checkout', { method: 'POST' })
const { checkout_url } = await res.json()
ChromiaPay.open({
url: checkout_url,
onStatus: (status) => console.log('payment is', status),
onPaid: () => location.assign('/orders/1041/thanks'),
onClose: (reason) => console.log('frame closed:', reason),
})
})
</script>Your server still creates the payment exactly as in the hosted flow. The script only frames a URL you already have; it never sees a key, an amount, or anything it was not told.
It is a plain <script> rather than an npm package on purpose: it is a small amount of DOM code that
has to load on a Shopify theme as readily as in a bundler, and it is served from the checkout origin so
it is always the version that matches the page it opens.
Who is allowed to frame it
The constraint that catches everyone
The page doing the embedding must be on the same origin as that payment's success_url. If your
button is on https://shop.example.com, then success_url has to be there too — a success_url on
https://www.shop.example.com is a different origin, and the frame will refuse with a screen naming
both.
Two independent gates, because framing a page that asks for wallet signatures is not something to be casual about:
- The response sets
frame-ancestorsto exactly the origin that asked, so the browser refuses to render the frame anywhere else. A copied checkout URL is inert on a stranger's site. - The page then checks that origin against the payment's own
success_url. An attacker who names their own origin passes the first gate and fails here.
Plain http is accepted only on localhost and 127.0.0.1, so the flow is testable before you deploy.
What the frame tells you
| Callback | Called when |
|---|---|
onStatus(status, event) | Every status change, starting with the first. Same status values the API returns. |
onPaid(event) | The payment reached confirmed or overpaid. |
onClose(reason) | The frame was dismissed — "checkout", "escape", "backdrop", or "api" if you called close(). |
ChromiaPay.open() returns a handle with a close() method if you need to dismiss it yourself.
onPaid is for the spinner, not the shipment
It is a browser telling you what a browser was told. Ship the order from the webhook.
Closing does not cancel
Escape, a backdrop click and the frame's own × all close it. None of them cancel the payment: the link
stays valid until it expires, and money already sent is already on its way. To cancel for real, call
POST /v1/payments/:id/cancel from your server.