Add checkout to your site
Show ChainPay's hosted checkout as a modal, embedded in your page, or as a full-page redirect. Full API details are in the API reference.
Every mode shows the same hosted page, with the QR code, exact amount, token and network, copy buttons, a live countdown, and a status that updates while it waits for the payment. Your API key stays on your server.
| Mode | Where the customer pays | Add to your CSP |
|---|---|---|
| Modal | Over your page, in a native <dialog> | frame-src https://app.chainpay.ac |
| Embedded | Inline in your page | frame-src https://app.chainpay.ac |
| Redirect | The full page, on app.chainpay.ac | None for a link. If a form POST redirects there: form-action 'self' https://app.chainpay.ac |
1. Get a checkout_url on your server
POST /v1/payments with rail: crypto returns a checkout_url for that network and token. POST /v1/checkout-sessions also returns one, where the customer chooses the method. Both work in all three modes.
curl -X POST https://api.chainpay.ac/v1/payments \
-H "Authorization: Bearer $CHAINPAY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"amount": "50.00",
"currency": "USD",
"rail": "crypto",
"chain": "polygon",
"token": "USDT",
"metadata": { "order_id": "order_123" },
"idempotency_key": "order_123"
}'
# Store checkout_url from the response and pass it to your page.
# Use it exactly as returned. Don't build it from a payment id.2. Choose a mode
Redirect. Link to the page. This is the only mode we recommend for card, wallet and bank-redirect methods, because those providers can refuse to run inside a frame. If a form POST leads to the redirect, a CSP with form-action 'self' can block it, so use a link or add ChainPay to form-action.
<a href="CHECKOUT_URL">Pay now</a>Embedded. No React, build step or ChainPay script is needed. The page works with the iframe alone. The script file below is optional, and adds the status text and automatic height.
<iframe id="chainpay-frame"
class="chainpay-frame"
src="CHECKOUT_URL"
title="Secure checkout"
allow="clipboard-write; payment *"
referrerpolicy="no-referrer"
width="100%"
height="760"></iframe>
<p id="chainpay-status" role="status"></p>
<script src="/js/chainpay-checkout.js"></script>Modal. A native <dialog>, opened by your own script file so it works without 'unsafe-inline'. Closing it doesn't cancel the payment, and the customer can open it again.
<button type="button" id="chainpay-open">Pay now</button>
<p id="chainpay-status" role="status"></p>
<dialog id="chainpay-dialog" class="chainpay-dialog" aria-label="Secure checkout">
<div class="chainpay-dialog-bar">
<button type="button" id="chainpay-close">Close</button>
</div>
<iframe id="chainpay-frame"
class="chainpay-frame"
data-checkout-url="CHECKOUT_URL"
title="Secure checkout"
allow="clipboard-write; payment *"
referrerpolicy="no-referrer"
width="100%"
height="760"></iframe>
</dialog>
<script src="/js/chainpay-checkout.js"></script>Both modes use these two files, served from your own domain:
/* /css/chainpay-checkout.css: link it from your page */
.chainpay-frame {
display: block;
max-width: 480px;
border: 0;
}
.chainpay-dialog {
width: min(480px, calc(100vw - 16px));
max-width: none;
padding: 0;
border: 0;
border-radius: 12px;
overflow: hidden;
}
.chainpay-dialog::backdrop {
background: rgba(0, 0, 0, 0.5);
}
.chainpay-dialog-bar {
display: flex;
justify-content: flex-end;
padding: 8px 8px 0;
}
.chainpay-dialog .chainpay-frame {
max-width: none;
max-height: calc(100dvh - 72px);
}// /js/chainpay-checkout.js: serve this from your own domain
(function () {
var CHAINPAY_ORIGIN = 'https://app.chainpay.ac';
var STATUS_TEXT = {
awaiting_payment: 'Waiting for your payment.',
detected: 'We see your transfer and are waiting for it to confirm.',
partially_paid: 'Part of your payment arrived. Send the rest shown in the checkout.',
paid: 'Payment received. We will activate your order once we confirm it.',
settled: 'Payment received. We will activate your order once we confirm it.',
expired: 'This payment expired. Start again to get a new one.',
failed: 'This payment could not be completed.',
not_found: 'We could not load this payment.'
};
var frame = document.getElementById('chainpay-frame');
var note = document.getElementById('chainpay-status');
var dialog = document.getElementById('chainpay-dialog');
// Modal mode: load the checkout the first time the customer opens it.
if (dialog) {
document.getElementById('chainpay-open').addEventListener('click', function () {
if (!frame.getAttribute('src')) frame.setAttribute('src', frame.dataset.checkoutUrl);
dialog.showModal();
});
document.getElementById('chainpay-close').addEventListener('click', function () {
dialog.close();
});
}
window.addEventListener('message', function (event) {
// Check both, or any other page or frame could fake these messages.
if (event.origin !== CHAINPAY_ORIGIN || event.source !== frame.contentWindow) return;
var message = event.data;
if (!message || message.source !== 'chainpay-checkout') return;
if (message.type === 'resize') {
frame.height = String(message.height);
} else if (message.type === 'status') {
// For display only. Activate orders from the signed webhook, never from this message.
note.textContent = STATUS_TEXT[message.status] || '';
}
});
})();Don't add sandbox to the iframe: it blocks the page's scripts, copy buttons and return link. Under a strict CSP, keep sizes in your stylesheet, because a style="" attribute is blocked.
Optional: the ChainPay script
Does the same modal and embed with less of your own code, and needs no stylesheet. It has no dependencies and adds no style tags, so it works under style-src 'self'. Load it from ChainPay with its integrity hash, which needs script-src 'self' https://app.chainpay.ac; frame-src https://app.chainpay.ac. Or download it and serve it from your own domain, which only needs frame-src https://app.chainpay.ac.
<script src="https://app.chainpay.ac/embed/chainpay-embed-1.0.0.js"
integrity="sha384-89wtM0pUrxuZaAenQUfWfZGCztPvnS6AbHpQQeQdp6ipPoFObED2WON9qtmSii3m"
crossorigin="anonymous"></script>// In your own script file.
// Modal:
var modal = ChainPayEmbed.open({
checkoutUrl: checkoutUrl,
onStatus: function (status) { /* update your page */ },
onClose: function () { /* closing doesn't cancel the payment */ }
});
// Or embedded in an element on your page:
var embed = ChainPayEmbed.mount(document.getElementById('checkout'), {
checkoutUrl: checkoutUrl,
onStatus: function (status) { /* update your page */ }
});Messages to your page
The hosted page sends these with postMessage whenever it's in a frame. They hold no payment details. Accept one only if event.origin is https://app.chainpay.ac, event.source is your frame's contentWindow, and source is chainpay-checkout.
// Status changes
{ source: 'chainpay-checkout', version: 1, type: 'status', status: 'detected' }
// The height the page needs, so you can size the frame
{ source: 'chainpay-checkout', version: 1, type: 'resize', height: 812 }| status | Meaning |
|---|---|
| open | The customer is choosing a method (checkout sessions only) |
| awaiting_payment | Waiting for the transfer |
| detected | A transfer was seen and is waiting for network confirmations. On fast networks this can pass before the page checks, so don’t depend on seeing it |
| partially_paid | Too little arrived. The page shows the amount still owed |
| paid | The full amount is confirmed |
| settled | The payout finished. Only sent if the page is still open, so don’t wait for it |
| expired | Time ran out. A late transfer can still change this to detected or paid |
| failed | The payment can’t be completed |
| not_found | The checkout link isn’t valid |
X-ChainPay-Signature on payment.paid.