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. Supported browsers are the last two versions of Chrome, Edge and Firefox, Firefox ESR, and Safari 16.4 or newer on macOS and iOS; the build targets ES2020, and the same list is in the package’s browserslist.
The package follows semantic versioning from 1.0.0: nothing in a 1.x release breaks code written against an earlier 1.x. Coming from 0.x, read Upgrading from 0.x.
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. The secret authorises that one session, stops working after 30 minutes or once the checkout completes, and is never returned by a later GET.
// Your server, using @billkit-eu/sdk
const session = await client.checkoutSessions.create({
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 iframeCall destroy() when you tear down the surrounding view. A live element holds a message listener on window.
submit() is safe to call before the element has loaded the session, whether before the iframe has booted or during the session fetch: one pending submit is held and sent once it has loaded, just before onReady fires. An element that fails before loading drops it; onError has fired and there is nothing to submit. destroy() reports onProcessing({ processing: false }) if a payment had been reported in flight, so a modal holding on it is released. The handle also has focus(), which moves keyboard focus into the element for a page that reveals it in a later step. A focus() call before boot is dropped.
Other options: locale (a BCP 47 tag such as nl; defaults to the browser’s language), title (the iframe’s accessible name, English by default, so set it when your page is not), loadTimeoutMs (default 20000, 0 disables it), onReady, onChange ({ complete, payment_method }), onProcessing ({ processing }, true while a payment is in flight, so a modal can hold its close; since 1.1, and never fired by an older element), onEscape (Escape pressed inside the element), onRedirect and logger. The theme tokens are colorPrimary, colorBackground, colorText, colorTextSecondary, colorDanger, fontFamily, borderRadius, spacingUnit (px only, 1-16) and colorScheme ("light", "dark" or "auto").
One-off payments
mountOneShotPaymentElement takes the same options and returns the same handle, for a single
charge with no subscription behind it. Its secret comes from POST /v1/checkout/one_shot with
ui_mode: "embedded", and its onSuccess receives { oneShotPaymentId, paymentStatus }. The form
offers the one-off methods (including Bancontact and bank transfer), shows the amount and the VAT
in it, and says nothing about subscriptions. A decline is final for that payment, so the element
offers no retry: create a new one-off payment and mount again. See
Take the payment on your own page.
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, or send
"capabilities": {"checkout_header": false} to POST /v1/tenant/portal_branding. The element then
renders the payment form alone. There is no mount-time option for it: it is a property of your
account, so it applies everywhere the element is embedded.
The logo it shows is the logo_url from your branding, the same one your portal, customer emails
and invoices use. 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 set-default, remove and add built in. Its client_secret comes from an embedded checkout session created for that customer, and the wallet always acts on the customer that session belongs to, which is why it needs no customerId. The default is what renewals charge: setting one moves the customer’s active subscriptions of the same kind onto it, and a newly added method becomes the default unless the customer already chose one. Removing a method that an active subscription renews on moves that subscription to another saved method of the same kind (card to card, SEPA to SEPA), preferring the customer’s default. With no such method the removal is refused with payment_method_in_use, and while a charge on the method is still processing it is refused with operation_pending.
import { mountPaymentMethodElement } from "@billkit-eu/js";
const wallet = mountPaymentMethodElement("#methods", { clientSecret });Error codes
onError receives { message, code? }. The message is human-readable and already localised; the code is the stable tag to branch on. ELEMENT_ERROR_CODES lists every code this version knows, the loader’s own two included, and code is typed as one of them widened with string.
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 an absolute https URL on mollie.com, mollie.nl or a subdomain of either; treat it as a security event. Before 1.1.1 the loader also refused pay.mollie.nl, where Mollie sends a live card’s 3-D Secure step, so upgrade if live card payments fail with this code.
The element can also send these. None of them charged anything, and none is fixed by retrying:
no_payment_methods: nothing on the price’spayment_methodslist is payable for this currency and buyer country, so no form is drawn. Check the price’s list (or, when it follows the account, the methods switched on in your dashboard’s Mollie settings) against what your Mollie account has enabled.element_crashed: the element hit an unrecoverable error and replaced itself with an error pane. Only a reload recovers it.missing_session_id: theclient_secretcould not be parsed. Pass the whole value from the create response, unmodified.missing_client_secret: the payment-method element was mounted without a secret.payment_not_open: the one-off payment element was mounted on, or confirmed, a payment that expired unpaid or already failed. Create a new one-off payment.client_secret_invalid: the API refused the one-off payment’sclient_secretfor a reason other than its expiry. Pass the whole value from the create response, unmodified.
Two one-off payment codes describe a payment that may have been charged, so do not create a new one on them alone:
client_secret_expired: theclient_secretstopped working, 30 minutes after the create (a confirm does not extend it), so the element can no longer show the payment. A card may have cleared or a bank transfer may still be on its way. Retrieve it withGET /v1/checkout/one_shot/{id}or wait for its webhook before creating another.payment_refunded: the payment was made and then refunded.
The checkout element also sends these:
session_expired: the checkout session expired, or itsclient_secretstopped working. Create a new session.session_closed: the checkout session is no longer open for another reason, such as being completed in another tab. Retrieve it withGET /v1/checkout/sessions/{id}before creating another: it may have been paid.payment_in_progress: the buyer came back to a checkout whose payment is still open at the bank or 3-D Secure step. Nothing new was charged. The element offers the way back to that payment (and, where the provider allows it, another method); re-enable your pay button, and a submit continues to it. Where there is no page to go back to, a submit checks the payment again, and you getpayment_in_progressagain while it is still in flight.request_timeout: a request to the API got no answer in time. The element keeps its retry on offer, and after a confirm it first checks whether the payment went through.
Newer element versions can mint new codes, so branch on what you handle and fall through to message.
The loader and the element agree on a protocol version during the ready handshake. The element keeps serving every protocol a released loader speaks, so a loader in your bundle keeps working as the hosted element moves on; you upgrade the package for new features, never to stay working.
Redirect-based methods
Cards that clear without a challenge fire onSuccess. 3D Secure, iDEAL, EPS and PayPal navigate the top window to the provider instead, and the customer comes back to your success_url. Intercept the navigation 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 to anhttpsURL onmollie.com,mollie.nlor a subdomain of either, 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.
Upgrading from 0.x
1.0 is a deliberate break, and every change is listed in the package’s CHANGELOG.md with before and after code. In short:
- The postMessage internals are no longer exported:
ClientMessage,HostMessage,ElementKind,sessionIdFromClientSecret,isSafeRedirectUrlandDEFAULT_API_BASE. They are a protocol between the loader and the element, not an API. onChangereceives{ complete, payment_method }; 0.x called the fieldmethod.- A redirect is followed only to an
httpsURL on one of Mollie’s domains (mollie.com, and since 1.1.1mollie.nl). 0.x followed anyhttp(s)URL; anything else is nowonErrorwithunsafe_redirect, beforeonRedirectis called. customerIdonmountPaymentMethodElementis optional; theclient_secretalready names the customer.ELEMENT_ERROR_CODESincludesload_timeoutandunsafe_redirect, andBillKitElementError.codeis typedElementErrorCode | (string & {}).- Node 22 or newer for builds and tests; the browser support list is above.
BillKit