Browser (JS)
Embedded checkout for the browser. Card fields render in a cross-origin iframe, so your page stays PCI SAQ A.
Install
ESM and CJS builds with bundled types, and no runtime dependencies.
This is not an API client. No secret key ever belongs in a browser. For server calls use
@billkit-eu/sdk. Using React? @billkit-eu/react wraps this
package in components.
How it fits together
The element authenticates with an ephemeral client_secret, not a key. Mint one on your server by creating an embedded Checkout session, then hand it to the browser.
// Your server, using @billkit-eu/sdk
const session = await client.checkoutSessions.create<{ client_secret: string }>({
customer_id: "cus_9XKp2vQ1", // or customer_email; exactly one is required
price_id: "price_9XKp2vQ1",
ui_mode: "embedded",
success_url: "https://acme.example/thanks",
cancel_url: "https://acme.example/pricing", // required, even when embedded
}); Then mount the element with it. mountCheckoutElement takes an HTMLElement or a CSS selector.
import { mountCheckoutElement } from "@billkit-eu/js";
const element = mountCheckoutElement("#checkout", {
clientSecret,
theme: { colorPrimary: "#2f6bff", borderRadius: "10px" },
onSuccess: ({ sessionId }) => { location.href = `/thanks?cs=${sessionId}`; },
onError: ({ message }) => showToast(message),
});
element.submit(); // drive your own pay button
element.updateTheme({ colorPrimary: "#0f766e" }); // restyle in place
element.destroy(); // remove the iframe Call destroy() when you tear down the surrounding view. A live element holds a message listener on window.
The element header
By default the element draws a header above the payment form: your logo, your business name, the product, and the amount. That is the right default for an element dropped onto a bare page, where it would otherwise show a buyer a price with no indication of who is charging it.
If the page you embed into already shows those things, turn the header off in the dashboard under Settings → Branding → Show the header on the embedded checkout. The element then renders the payment form alone. There is no mount-time option for it: it is a property of your account, so the setting applies everywhere the element is embedded and does not have to be repeated at each call site.
The logo it shows is the same one used on your invoices and in your dashboard, set once under Settings → Branding. It must be served over HTTPS. If it fails to load, the element falls back to a letter mark rather than showing a broken image.
Saved payment methods
The same iframe renders a customer’s stored methods, with update and remove built in.
import { mountPaymentMethodElement } from "@billkit-eu/js";
const wallet = mountPaymentMethodElement("#methods", {
clientSecret,
customerId: "cus_9XKp2vQ1",
}); Error codes
onError receives { message, code? }. The message is human-readable and already localised; the code is the stable tag to branch on.
const element = mountCheckoutElement("#checkout", {
clientSecret,
onChange: ({ complete }) => (payButton.disabled = !complete),
onError: ({ code, message }) => {
payButton.disabled = false; // the attempt is over, whatever the cause
if (code !== "payment_declined") showToast(message);
},
});
payButton.onclick = () => element.submit(); payment_declined fires after a confirm that failed with no redirect. It is the only signal that the attempt ended, because a decline fires neither onSuccess nor a redirect, so without handling it an external pay button stays stuck. The element keeps its own retry panel on screen: do not navigate away.
load_timeout fires when the iframe never booted within loadTimeoutMs, which defaults to 20000. A missing CSP frame-src and an ad blocker look identical from the page, so check both. unsafe_redirect means the loader refused a redirect target that was not absolute http(s); treat it as a security event.
Newer element versions can mint new codes, so branch on what you handle and fall through to message.
Redirect-based methods
Cards that clear without a challenge fire onSuccess. 3DS and iDEAL navigate the top window to the bank instead. Intercept it if you need to save state first.
onRedirect: (url) => {
sessionStorage.setItem("cart", JSON.stringify(cart));
window.location.assign(url);
return false; // you took over; the loader will not navigate
} Terminal state for a redirect flow reaches your server through the checkout.session.completed webhook. Treat the return to success_url as a hint and the webhook as the truth.
Content Security Policy
The element renders in an iframe, so your CSP has to allow the origin it is served from.
frame-src https://js.billkit.eu; Security model
The fixed iframe origin is the whole design.
- Card fields are cross-origin, so the page hosting the element cannot read what the customer types. That is what keeps you on PCI SAQ A.
- The
client_secretis never in the iframe URL, in either the query or the fragment. It arrives by targetedpostMessageonly after the iframe announcesready, which keeps it out ofRefererheaders, browser history and server logs. - Every inbound message is checked against the exact iframe origin and the specific frame that sent it, before the payload is parsed.
- Outbound messages always name a target origin, never
*. - The iframe is sandboxed without
allow-top-navigation. Redirects are requested by message and performed by the loader only after the URL is validated, so a compromised element cannot navigate your page to a phishing clone.
Logging
Silent by default: it never calls console on its own. Pass logger to opt in and you get one debug line per lifecycle step and a warn whenever a message is dropped or a redirect is refused. The client_secret, message payloads and full redirect URLs are never logged; only a redirect’s origin is recorded, because the rest of the URL carries payment identifiers.
BillKit