Embedding the checkout#
The default integration sends the tenant away to gateway_url and brings them back via success_url. You can instead keep them on your own page and render the hosted checkout inside an iframe, or open it in a popup.
What you embed is CasaPay's full hosted checkout - the amount, your entity name, and the card / bank options. You never rebuild the payment UI, and no card data touches your page.
Framing is allowed
The checkout sends no X-Frame-Options header and no Content-Security-Policy frame-ancestors directive, so /s/{session_id} renders inside a cross-origin iframe on any domain. No allowlisting is required.
When to embed#
| Approach | Use when |
|---|---|
| Redirect (default) | Simplest and most robust. Works on every browser and device. Start here. |
| Iframe | You want the payment to feel in-page - a checkout drawer or modal. |
| Popup | You need to stay in-page but the flow must own a real top-level window. |
Redirect remains the recommended default. Embedding adds two failure modes you have to handle yourself: the tenant closing the frame mid-payment, and browsers that restrict third-party cookies inside frames.
1. Create the session as usual#
Embedding changes nothing server-side. Create the session with your secret key and point success_url at a small return page you control.
curl -X POST https://manage.test.casapay.com/api/v1/gateway/sessions \
-H 'Authorization: Bearer sk_test_...' \
-H 'Content-Type: application/json' \
-d '{
"tenant": { "email": "tenant@example.com", "first_name": "Mari", "last_name": "Tamm" },
"agreement_type": "payment_link",
"first_payment_amount": 650,
"currency": "EUR",
"success_url": "https://your-app.com/casapay/return",
"cancel_url": "https://your-app.com/casapay/cancelled"
}'Use the returned gateway_url verbatim as the iframe src. Do not construct the URL yourself.
2. Render the iframe#
<iframe
id="casapay-checkout"
src="https://gateway.test.casapay.com/s/gwy_9mK2xQ7pLr4vT1sZ"
style="width:100%;min-height:720px;border:0"
allow="payment"
referrerpolicy="strict-origin-when-cross-origin"
></iframe>3. Learn when the payment finished#
There is no postMessage API
The checkout does not post messages to the parent window. The completion contract is the redirect to your success_url, exactly as in the non-embedded flow. Do not wait for an event from the iframe - none will arrive.
Because the redirect lands inside the frame, your return page has to tell the parent. Bridge it with one postMessage:
<!-- https://your-app.com/casapay/return -->
<script>
const params = new URLSearchParams(location.search);
const result = {
source: "casapay",
status: params.get("status"),
session_id: params.get("session_id"),
};
// Works framed (parent) and as a popup (opener).
const target = window.parent !== window ? window.parent : window.opener;
target?.postMessage(result, "https://your-app.com");
</script>And listen on the page hosting the frame:
window.addEventListener("message", (event) => {
// Always check the origin - never trust an unverified message.
if (event.origin !== "https://your-app.com") return;
if (event.data?.source !== "casapay") return;
document.getElementById("casapay-checkout").remove();
showPendingState(event.data.session_id); // not "paid" - see below
});4. Confirm server-side before you fulfil#
The browser signal is a UX hint, never proof of payment
Anything reaching your page through the browser - the redirect, its query string, your own postMessage - is attacker-controllable and can be lost when the tenant closes the tab. Treat it only as a cue to refresh your UI.
Fulfil on the gateway.session.completed webhook, or confirm with GET /api/v1/gateway/sessions/{session_id} from your backend.
This is the same rule as the redirect flow - see Webhooks.
Popup instead of an iframe#
Same session, same return page. The bridge script above already handles both, because it falls back to window.opener.
const popup = window.open(gatewayUrl, "casapay", "width=480,height=800");
// The tenant may close the popup without paying - poll so your UI does not hang.
const timer = setInterval(() => {
if (popup?.closed) {
clearInterval(timer);
refreshSessionStatus(sessionId); // ask your backend
}
}, 800);Things that bite#
- Clipped layout. A fixed 400px-tall frame hides the pay button once a verification or deposit step appears. Use a generous
min-height. - Sandbox attributes. If you add
sandbox, you must includeallow-scripts allow-forms allow-same-origin allow-popups, or the checkout cannot submit. Omittingsandboxentirely is simpler and is what we test. - Third-party cookie restrictions. Some browsers restrict storage in cross-origin frames. If the checkout misbehaves in a frame, fall back to redirect or popup - both are top-level contexts.
- Provider redirects. Card and bank flows hand off to EveryPay and the bank, and some banks refuse to be framed. If a step goes blank inside the frame, reopen the same
gateway_urlat top level; the session is unchanged. - One live session per invoice. Embedding does not change this. See Pay now button.
Checklist#
- [ ]
gateway_urlused verbatim assrc. - [ ] Frame tall enough for the verification and deposit steps.
- [ ]
success_urlpoints at a return page that bridges to the parent. - [ ]
messagelistener validatesevent.origin. - [ ] Fulfilment happens on the webhook or a server-side session read, not the redirect.
- [ ] Popup path handles the tenant closing the window.
Related#
- Checkout flow - the full step machine.
- Webhooks - the authoritative completion signal.
- Sessions - session object and status values.
A closed popup does not mean failure. The payment may have settled just before it closed, so always resolve the real state from your backend.
Give the frame room. The checkout is a full page and may add a verification or deposit-choice step, so a short fixed height will clip it - min-height: 720px is a safe floor.