BillKit/DocsConsole
Payments guide

Embed Checkout in your own page

Mount the Checkout Element, keep the customer on your domain, and let the iframe handle 3D Secure.

24 Sept 2026·Intermediate·11 min read

The hosted flow sends the customer to Mollie and back. The embedded flow keeps them on your page: BillKit hosts an iframe that collects the payment method, and only the step that has to happen somewhere else leaves your site: a 3D Secure challenge, an iDEAL or EPS bank, PayPal, or the Apple Pay sheet, which Mollie renders on its own Apple-verified domain.

Card details never reach your servers. The iframe tokenises them against Mollie directly, which is what keeps your integration on the lightest PCI questionnaire.

This guide starts a subscription. To embed a single charge with no subscription behind it, such as a top-up or a one-time order, create a one-off payment with ui_mode: "embedded" and mount mountOneShotPaymentElement instead; Take the payment on your own page covers it. The install and CSP steps below are the same.

Install

npm install @billkit-eu/js

For React, install the wrapper as well. It has @billkit-eu/js as a peer dependency.

npm install @billkit-eu/js @billkit-eu/react

There is no script tag, no CDN loader and no window.BillKit. The packages are npm only.

Allow the iframe in your CSP

Content-Security-Policy
frame-src https://js.billkit.eu;

Get this wrong and the element never loads. It fails the same way an ad blocker does, with onError receiving code: "load_timeout" after 20 seconds, so check the CSP first when nothing appears.

1. Create the session server-side

Same endpoint as hosted checkout, with ui_mode: "embedded". Because no payment is created yet, the response carries a client_secret instead of a url.

shell
curl https://api.billkit.eu/v1/checkout/sessions \
-H "Authorization: Bearer $BILLKIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
  "customer_email": "ada@example.com",
  "price_id": "price_7Hd3Wm9pQx2vRt5Kna",
  "ui_mode": "embedded",
  "success_url": "https://example.com/welcome",
  "cancel_url": "https://example.com/pricing"
}'
Response · 200 OK
{
"id": "cs_Rb4nZ8yQw1eTp6Lksv",
"object": "checkout.session",
"created": 1789344120,
"livemode": false,
"customer_id": "cus_Ja7Ye2vMq9xN4TdWpr",
"price_id": "price_7Hd3Wm9pQx2vRt5Kna",
"success_url": "https://example.com/welcome",
"cancel_url": "https://example.com/pricing",
"status": "open",
"ui_mode": "embedded",
"url": null,
"client_secret": "cs_Rb4nZ8yQw1eTp6Lksv_secret_Kd7Wm2vQ9xN4rTpLbzyH",
"customer_email": null,
"customer_country_code": null,
"subscription_id": null,
"payment_id": null,
"expires_at": 1789430520,
"trial_days_override": null,
"coupon_id": null,
"payment_method": null,
"metadata": {}
}

About client_secret:

  • GET /v1/checkout/sessions/{id} never returns it, so pass it to the browser from the response that created it. Retrying the create with the same Idempotency-Key returns a working secret while the session is still open.
  • It authorises this one session and nothing else in your account, which is what makes it safe to send to a browser.
  • It stops working 30 minutes after it was minted, and as soon as the checkout completes. After that the element shows “This checkout is no longer available”, onError receives session_expired, and the fix is a new session.

The session itself ends at expires_at, 24 hours after it was created. A payment that fails (a declined card, a failed 3DS check, a bank page the customer cancelled) does not end it: the element offers another try, and the customer can retry in the same session until then. Past expires_at, confirming is refused and checkout.session.expired fires. If the customer already bought the same price since this session was opened (in another tab, say), confirming is refused with 409 checkout_session_not_open and nothing is charged.

Omit payment_method on an embedded session. The customer picks inside the element, and sending both is a 400.

The element offers the methods on the price’s payment_methods list (your account’s methods, when the price follows the account) that can start a subscription and that your Mollie account has enabled for the price’s currency: card, Apple Pay, iDEAL, EPS and PayPal. SEPA Direct Debit is never offered, because a direct debit needs a mandate that already exists. Keep directdebit on the price anyway: a customer whose first payment is by iDEAL or EPS gets a SEPA mandate, and their renewals are collected by direct debit. If nothing on the list is payable, the element draws no form and onError receives no_payment_methods.

There is no publishable key. BillKit mints only secret keys (bk_live_ and bk_test_), and those must never reach a browser. The client_secret is the whole client-side credential.

2. Mount the element

Vanilla JS
import { mountCheckoutElement } from "@billkit-eu/js";

const element = mountCheckoutElement("#checkout", {
clientSecret,
theme: { colorPrimary: "#6d28d9", borderRadius: "10px" },

onChange: ({ complete }) => {
  payButton.disabled = !complete;
},

onSuccess: ({ sessionId }) => {
  window.location.href = `/welcome?session=${sessionId}`;
},

onError: ({ code, message }) => {
  payButton.disabled = false;
  // The element renders its own retry panel for a decline.
  if (code !== "payment_declined") showBanner(message);
},
});

payButton.addEventListener("click", () => element.submit());

// On teardown. A live element holds a window message listener.
// element.destroy();

The submit button is yours. The element reports readiness through onChange, and submit() is what starts the payment. A submit() made before the element has loaded the session is held and sent once it has (as onReady fires).

React
import { useRef, useState } from "react";
import { BillKitProvider, CheckoutElement } from "@billkit-eu/react";
import type { BillKitElementRef } from "@billkit-eu/react";

export function Checkout({ clientSecret }: { clientSecret: string }) {
const el = useRef<BillKitElementRef>(null);
const [ready, setReady] = useState(false);

return (
  <BillKitProvider>
    <CheckoutElement
      ref={el}
      clientSecret={clientSecret}
      theme={{ colorPrimary: "#6d28d9" }}
      onChange={({ complete }) => setReady(complete)}
      onSuccess={({ sessionId }) => router.push(`/welcome?session=${sessionId}`)}
      onError={({ message }) => console.error(message)}
    />
    <button disabled={!ready} onClick={() => el.current?.submit()}>
      Subscribe
    </button>
  </BillKitProvider>
);
}

Both components are safe to server-render: they return null until hydration and mount the iframe on the client.

3. Provision on the webhook

onSuccess means the browser got a success. checkout.session.completed means the money settled. Only the second is a fact about your bank balance.

Use onSuccess to move the customer to a “setting up your account” page, and provision when the webhook lands. See Webhooks.

Authentication redirects

3D Secure pages refuse to render in an iframe, by design. When Mollie needs one, the element navigates the top window to it, and the customer comes back to your success_url. This is automatic: you do not read a redirect_url or handle a requires_action status, because BillKit does not expose either. See What you have to do about SCA.

Coming back to success_url does not mean the payment succeeded. Mollie sends the customer to cancel_url only when they cancel explicitly; a payment that fails at the bank or in 3D Secure also lands on success_url. Either way the session stays open. To learn how the attempt ended, call GET /v1/payments?customer_id=...: each payment carries checkout_session_id, status and failure_reason. To let the customer try again, retry the create with the same Idempotency-Key. It returns the same session with a client_secret that works now, so mount the element with that one.

The iframe cannot navigate your page itself. It asks the loader to, and the loader refuses any target that is not an absolute https URL on mollie.com, mollie.nl or a subdomain of either (a live card’s 3D Secure hand-off starts on pay.mollie.nl), reporting unsafe_redirect to onError. Pass onRedirect to run code first, for example to save state; return false from it and the loader leaves the navigation to you.

Options

OptionNotes
clientSecretstringRequired.
themeobjectcolorPrimary, colorBackground, colorText, colorTextSecondary, colorDanger, fontFamily, borderRadius, spacingUnit, colorScheme. Applied live, without remounting.
localestringLanguage for the element’s own copy, as a BCP 47 tag such as nl. Defaults to the browser’s language.
titlestringAccessible name of the iframe. Defaults to English; set it when your page is not.
loadTimeoutMsnumberDefault 20000. Set 0 to disable the timeout.
onReadyThe iframe mounted and fetched its bootstrap payload.
onChange{ complete, payment_method }. Drive your submit button from complete.
onSuccess{ sessionId, paymentStatus }. Fires only when the payment finishes without a redirect.
onProcessing{ processing }. True while a payment is in flight, false once the attempt ends. Use it to hold anything that would tear the form down mid-payment, such as a modal close button. Elements older than 1.1 never fire it, so do not wait on it.
onEscapeEscape was pressed inside the element, which your page cannot hear on its own. Lets a modal close on Escape wherever focus is.
onRedirect(url) => boolean | void. Called before the top window navigates. Return false to navigate yourself.
onError{ message, code }. Codes: payment_declined, no_payment_methods, element_crashed, missing_session_id, missing_client_secret, session_expired, session_closed, payment_in_progress, request_timeout, load_timeout, unsafe_redirect. See error codes.
loggerOpt-in diagnostics. Anything with debug and warn, such as console.
iframeOrigin, apiBaseOverrides for where the iframe is served from and which API it calls. Leave them unset. The hosted element ignores apiBase; it only takes effect against a self-served element in development. In React they go on BillKitProvider.

The handle returned by mountCheckoutElement exposes submit(), focus(), updateTheme(theme) and destroy(). focus() moves keyboard focus into the element, for a page that reveals it in a later step. The React ref exposes submit(), focus() and updateTheme(theme).

Promotion codes

Two different things share the word “coupon”, and they are wired up differently.

You applying a discount. Pass coupon_code when you create the session. It needs no opt-in, and it is the right tool when you decide who gets the discount: a staff comp, a negotiated rate, an apology credit. Creating the coupons themselves is covered in Discounts with coupons.

The buyer typing a code. Off by default. Switch it on per product, under Products → (product) → Edit → “Let customers enter a coupon code at checkout”, or with allow_promotion_codes: true on product create. The element then shows a collapsed “Add promo code” link above the pay button. Leave it off for products that do not run public promotions: a promo field on everything you sell invites code-guessing across the catalogue.

Both paths obey the same rule about when a code is spent:

A coupon’s redemption is claimed when the payment settles, not when the session is created, and not when the buyer presses pay.

So a shopper can type a single-use code, see the discounted price, and close the tab without consuming it. A declined card does not consume it either.

Applying a code returns the refreshed element state, so the amount the buyer sees after entering one is produced by the same server-side quote that /confirm charges from. There is no separate “preview price” that can drift from the real one.

A code the buyer types is refused with reason: coupon_invalid whether it is unknown, expired, used up or not valid for this price, so the element cannot be used to find out which codes exist. The element renders it as its own localised copy rather than showing the buyer an identifier. Rejected codes are limited to 10 per checkout session, 20 per customer and 20 per buyer network (an IPv4 address, or an IPv6 /64) in 24 hours, and past that the element’s requests answer 429 rate_limited, even for a valid code. A code that is accepted never counts. There is no limit across your whole account: one visitor’s guessing never locks your other buyers out of their codes. An unusual volume of rejected codes on your account (2,000 in 24 hours) is flagged to BillKit’s operators, and for the rest of that window each session and each network may have only 3 codes rejected.

Know this before you rely on a hard cap: between the check and settlement, a concurrent checkout can take the last slot. The buyer who has already been charged keeps their discount, so under real contention a coupon’s redemptions_count can end up one or two above its max_redemptions.

The country question

The element asks the buyer for their country when the customer record has none, and will not enable payment until it is answered. That country selects the VAT rate for the charge and filters the methods on offer, so onChange’s complete stays false while it is blank. If you drive a submit button from complete, this is one of the reasons it can be disabled with every card field filled in.

You can avoid the question entirely by setting country_code when you create the customer, or by passing country on POST /v1/checkout/sessions. Either one is an answer, so the element hides the control.

When it does ask, the select is prefilled from the buyer’s IP address, looked up inside BillKit rather than sent to a geolocation service. That is only a starting position. The buyer can change it, and the methods on offer re-query when they do. The country they settle on is stored on the customer when they pay, unless the customer already has one.

locale sets the language of the element’s own copy and nothing else. It does not imply a country: a browser set to en-US says nothing about where its owner is.

Saved payment methods

PaymentMethodElement is the same machinery pointed at a customer’s wallet rather than a checkout. It lists their saved methods and lets them set a default, remove one, or add another.

React
import { BillKitProvider, PaymentMethodElement } from "@billkit-eu/react";

<BillKitProvider>
<PaymentMethodElement clientSecret={clientSecret} />
</BillKitProvider>

It takes only the clientSecret, and it has no submit(): each action commits on its own. The client_secret comes from an embedded checkout session created for that customer with customer_id. The wallet always acts on the customer that session belongs to, and the secret has the same limits as above: 30 minutes, and dead once that checkout completes. For a returning customer managing their methods, the billing portal needs no checkout session.

BillKit charges each renewal against the one mandate the subscription names, and the default is the one it charges. Setting a default moves every active subscription of the same kind (card to card, SEPA to SEPA) onto it, and emits subscription.payment_method_updated for each; the method they leave stays in the wallet. An added method becomes the default, with the same effect, unless the customer already chose one. A subscription stopped on a failed card is retried on the new default. Setting a default is refused with a 409 and operation_pending while a charge on the method it replaces is still processing.

Removing a method that an active subscription renews on moves that subscription to another saved method of the same kind: the customer’s default if it qualifies, otherwise the newest. A subscription keeps the kind of payment method it was set up with, so a card subscription needs another card, and a SEPA one another SEPA mandate. When no such method exists the removal is refused with a 409 and payment_method_in_use; add the replacement first. While a charge on the method is still processing, removal is refused with a 409 and operation_pending; retry once it has settled. The mandate is revoked at Mollie shortly after the removal commits.

What you have to do about SCA

Nothing. Strong Customer Authentication is decided and performed by Mollie and the customer’s bank. BillKit sends no authentication parameter, requests no exemption, and exposes no SCA field, status or error code. There is no requires_action. Recurring charges run against a stored mandate with no interactive session, so there is no challenge to handle.

What you get instead is a redirect, handled for you by the element, and a payment.failed event if the bank refuses. A failure with an authentication cause is recovered the same way as any other decline, through dunning and reauthorization.

Hosted or embedded

HostedEmbedded
Work to integrateRedirect to a URL.Install two packages, mount a component, set a CSP header.
Customer stays on your domainNo.Yes, apart from the authentication step.
StylingMollie’s page.Theme tokens on your page.
Payment createdAt session creation.At confirm, inside the element.
Server codeOne call.The same call, plus getting client_secret to the browser.

Start hosted. It is one redirect and it works. Move to embedded when the handoff is measurably costing you conversions, not before.