Chromia Pay

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

Your product page
<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:

  1. The response sets frame-ancestors to 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.
  2. 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

CallbackCalled 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.

On this page