Embed Checkout in your own page
Mount the Checkout Element, keep the customer on your domain, and let the iframe handle 3D Secure.
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
For React, install the wrapper as well. It has @billkit-eu/js as a peer dependency.
There is no script tag, no CDN loader and no window.BillKit. The packages are npm only.
Allow the iframe in your CSP
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.
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"
}' {
"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
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.
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
| Option | Notes |
|---|---|
| clientSecretstring | Required. |
| themeobject | colorPrimary, colorBackground, colorText, colorTextSecondary, colorDanger, fontFamily, borderRadius, spacingUnit, colorScheme. Applied live, without remounting. |
| localestring | Language for the element’s own copy. |
| loadTimeoutMsnumber | Default 20000. Set 0 to disable the timeout. |
| onReady | The 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. |
| logger | Opt-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.
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
| Hosted | Embedded | |
|---|---|---|
| Work to integrate | Redirect to a URL. | Install two packages, mount a component, set a CSP header. |
| Customer stays on your domain | No. | Yes, apart from the authentication step. |
| Styling | Mollie’s page. | Theme tokens on your page. |
| Payment created | At session creation. | At confirm, inside the element. |
| Server code | One 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.
BillKit