BillKit/Docs Console
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.

13 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 3-D Secure challenge, an iDEAL bank, 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.

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": null,
"trial_days_override": null,
"coupon_id": null,
"method": null,
"metadata": {}
}

Two things to know about client_secret. It is minted once and never returned by GET /v1/checkout/sessions/{id}, so pass it to the browser on the same response that created it. And it authorises exactly one session for 30 minutes, nothing else in your account, which is what makes it safe to send to a browser.

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

There is no publishable key. BillKit mints only sk_ secret keys, 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.

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.

The element only permits redirects to origins it recognises, so a compromised response cannot navigate your customer somewhere arbitrary.

Options

OptionNotes
clientSecretstringRequired.
themeobjectcolorPrimary, colorBackground, colorText, colorTextSecondary, colorDanger, fontFamily, borderRadius, spacingUnit, colorScheme. Applied live, without remounting.
localestringLanguage for the element’s own copy.
loadTimeoutMsnumberDefault 20000. Set 0 to disable the timeout.
onReadyThe iframe mounted and fetched its bootstrap payload.
onChange{ complete, method }. Drive your submit button from complete.
onSuccess{ sessionId, paymentStatus }.
onError{ message, code }. Codes: payment_declined, load_timeout, unsafe_redirect.
loggerOpt-in diagnostics.

The handle returned by mountCheckoutElement exposes submit(), updateTheme(theme) and destroy(). The React ref exposes submit() and updateTheme(theme).

Promotion codes

Two different things share the word “coupon”, and only one of them is new.

You applying a discount. Pass coupon_code when you create the session. This has always worked, needs no opt-in, and is the right tool when you decide who gets the discount: a staff comp, a negotiated rate, an apology credit.

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. Leaving it off for products that don’t run public promotions is the point: 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 doesn’t consume it either. This is what makes a one-redemption code usable at all: under the obvious implementation the first person to abandon the checkout burns it permanently.

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.

Rejections come back with a typed reason, one of coupon_invalid, coupon_expired, coupon_exhausted or coupon_not_applicable. The element renders it as its own localised copy rather than showing the buyer an identifier.

One caveat worth knowing 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 a capped coupon can end up one or two over its limit under real contention. BillKit logs coupon_overredeemed_at_settlement when that happens.

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, resolved against a database inside BillKit rather than a geolocation service. That is only a starting position. The buyer can change it, and the methods on offer re-query when they do.

locale sets the language of the element’s own copy and nothing else. It deliberately does not imply a country: a browser set to en-US says nothing about where its owner is, and guessing from it hid payment methods that buyers in the EU needed.

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} customerId={customerId} />
</BillKitProvider>

It takes customerId in addition to clientSecret, and it has no submit(): each action commits on its own. Removing the last method backing an active subscription is refused with payment_method_in_use, because it would strand the renewal.

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.