Embedded checkout
Keep the payer on your page: the VelPay checkout runs in an iframe inline in your layout or in a popup, and tells your page when the payer has paid.
1. Allow your site to embed
Browsers only show the checkout inside pages you have allowed. In the dashboard, open Settings → Stores, pick the store and add your site under Allowed embed origins, one per line:
- An exact origin:
https://shop.example.com(with a port if you use one:https://shop.example.com:8443). - A wildcard for one subdomain level:
https://*.example.commatcheshttps://eu.example.comandhttps://us.example.com, but nothttps://example.comitself orhttps://a.b.example.com. - https only, up to 10 per store, no paths.
https://*.comis not allowed.
The store must be approved, and the order must belong to that store: create it with an API key limited to the store. Changes take up to a minute to apply.
2. Create the order on your server
Exactly as for a redirect (see the quickstart). Pass the order's id to your page. Never create orders from the browser: that would expose your secret key.
3. Show the checkout
<script src="https://velpay.pro/embed.js"></script>
<div id="pay"><p id="spinner">Loading payment…</p></div>
<script>
// The order id comes from POST /api/v1/orders, made on your server.
const pay = velpay.embed("ord_7Hq2Lx9vTn3Kp0aWm1Qz8c", { locale: "de" });
pay
.on("loaded", () => {
// The checkout replaced the spinner; nothing to do unless you show your own.
})
.on("paid", () => showMessage("Thanks! Your payment is confirmed."))
.on("expired", () => showMessage("This payment expired. Reload to try again."))
.on("closed", (e) => {
if (e.reason === "dismissed") showMessage("Payment not finished.");
})
.mount("#pay");
// Later, to remove it yourself:
// pay.unmount(); // fires "closed" with reason "unmounted"
</script>velpay.embed(orderId, options) returns a handle and throws a TypeError if the id is malformed. The only option is locale, the checkout language (for example "de"); without it the checkout uses the order's locale if you set one, else the payer's language. Every handle method returns the handle, so calls chain.
| Method | Notes |
|---|---|
on(event, fn) | Adds a handler. Add them before mount() or open(): a handler added after its event fired is not called for it. |
mount(target) | Shows the checkout inside target (CSS selector or Element), replacing its content. Throws if nothing matches. |
open() | Shows the checkout in a modal over your page. It closes with ×, Esc or a click outside. |
unmount() | Removes the checkout and fires closed with reason unmounted. After that you can mount or open again. |
| Event | Payload | When |
|---|---|---|
loaded | { order, state } | The checkout has loaded and is talking to your page. |
paid | { order, state: "paid" } | The order is paid. |
expired | { order, state: "expired" } | The order expired before it was paid. |
closed | { order, reason, url? } | The checkout went away. reason: dismissed (×, Esc, click outside the modal), canceled (the payer chose to cancel; url is the order's cancel_url, where the window goes next), voided (you voided the order), unmounted (your code). |
The iframe grows and shrinks with its content, so don't give it a fixed height.
Modal on a button
<button id="pay-button">Pay now</button>
<script src="https://velpay.pro/embed.js"></script>
<script>
document.querySelector("#pay-button").addEventListener("click", async () => {
const res = await fetch("/api/create-velpay-order", { method: "POST" }); // your server
const { id } = await res.json();
velpay.embed(id).on("paid", () => location.assign("/thanks")).open();
});
</script>Redirects
If the order has a success_url, the whole window (not the iframe) goes there a few seconds after paid. If the payer cancels and the order has a cancel_url, the window goes there right after closed. Leave both out when you want to handle everything in your handlers.
Card payments continue at the provider in a new tab; the embedded checkout updates by itself when the provider confirms.
Fulfil on the webhook
The paid event is for your interface: show a thank-you, move to the next step. It runs in the payer's browser and can be faked. Ship goods only on the signed order.paid webhook, or after checking GET /api/v1/orders/:id on your server.
Without embed.js
embed.js is small and has no dependencies; we recommend it. If you build your own frame, load https://velpay.pro/c/<orderId>?embed=1&origin=<your origin> (add &lang=de to pick the language). The checkout and your page then connect in two steps:
- The checkout posts
{ velpay: "hello", v: 2, order }to your origin, never to*. - You answer with a
MessageChannel: post{ velpay: "connect", v: 2, order }to the checkout withport2attached. From then on every event arrives onport1, which only the checkout can write to.
If the checkout reloads (a language switch does that), it says hello again: answer with a new channel. Events on the port are { event, … }:
| event | Fields | Meaning |
|---|---|---|
loaded | state | Loaded and connected. |
height | px | Content height in CSS pixels; set the iframe height. |
paid | state | Paid. |
expired | state | Expired. |
closed | reason, url? | Remove the frame. With canceled, url is the order's cancel_url: navigate there. |
navigate | url, outcome | Navigate the top window to url. |
Answer a hello only when it comes from the VelPay origin and from your iframe:
const VELPAY = "https://velpay.pro";
const iframe = document.createElement("iframe");
iframe.src = `${VELPAY}/c/${orderId}?embed=1&origin=${encodeURIComponent(location.origin)}`;
document.querySelector("#pay").append(iframe);
window.addEventListener("message", (ev) => {
// 1. The checkout says hello. Answer with a fresh channel (again after a reload).
if (ev.origin !== VELPAY || ev.source !== iframe.contentWindow) return;
if (ev.data?.velpay !== "hello" || ev.data.order !== orderId) return;
const channel = new MessageChannel();
channel.port1.onmessage = ({ data }) => {
// 2. Every event arrives here, from the checkout only.
switch (data.event) {
case "height": iframe.style.height = data.px + "px"; break;
case "navigate": location.assign(data.url); break;
case "closed": iframe.remove(); if (data.url) location.assign(data.url); break;
// "loaded", "paid", "expired": update your page
}
};
iframe.contentWindow.postMessage({ velpay: "connect", v: 2, order: orderId }, VELPAY, [channel.port2]);
});Troubleshooting
- The frame stays empty or shows a browser error: your page's origin isn't in the store's allowed embed origins, or the order was created with a key that isn't limited to that store. Check the exact scheme, host and port.
- No events fire: the page is served from an origin that isn't allowed (for example
http://localhost). Use a tunnel with https and add its origin while you develop. - Test mode: the simulate buttons work inside the frame too, so you can test every event.