React
React bindings for the embedded checkout element. SSR-safe, shaped like @stripe/react-stripe-js.
Install
@billkit-eu/js (1.1 or newer within 1.x), react and react-dom (18 or 19) are peer dependencies, so install the loader alongside this package. Both React majors are tested on every change.
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 the browser half. No secret key ever belongs in a browser; for server calls use
@billkit-eu/sdk. All of the security model and postMessage handling lives in
@billkit-eu/js, and this package is the React surface over it.
Quick start
Mint an ephemeral client_secret on your server, then hand it to the element.
import { BillKitProvider, CheckoutElement } from "@billkit-eu/react";
export function Checkout({ clientSecret }: { clientSecret: string }) {
return (
<BillKitProvider>
<CheckoutElement
clientSecret={clientSecret}
theme={{ colorPrimary: "#2f6bff", borderRadius: "10px" }}
onSuccess={({ sessionId }) => router.push(`/thanks?cs=${sessionId}`)}
onError={({ message }) => toast.error(message)}
/>
</BillKitProvider>
);
}No credential goes on the provider. The client_secret already names the tenant, the mode and the session. <BillKitProvider> takes iframeOrigin, apiBase and logger, applied to every element beneath it. Rendering any of the three elements outside a provider throws, and the error names all three.
The element props are the @billkit-eu/js options (clientSecret, theme, locale, title, loadTimeoutMs, logger, onReady, onChange, onProcessing, onEscape (both since 1.1), onSuccess, onError, onRedirect), plus className and style for the container the iframe mounts into. title is the iframe’s accessible name; it defaults to English, so set it when your page is not. See @billkit-eu/js for what each one does and for the error codes.
No publishable key
Migrating from @stripe/react-stripe-js? The shapes line up, with one difference. There is no publishableKey.
BillKit only mints secret keys, which must never reach a browser, so elements authenticate with the ephemeral
client_secret instead.
Saved payment methods
import { BillKitProvider, PaymentMethodElement } from "@billkit-eu/react";
<BillKitProvider>
<PaymentMethodElement clientSecret={clientSecret} />
</BillKitProvider>;The client_secret comes from an embedded checkout session created for that customer, and the wallet always acts on the customer that session belongs to, so no customerId is needed. A new clientSecret remounts the element.
One-off payments
<OneShotPaymentElement/> takes the same props as <CheckoutElement/>, for a single charge with
no subscription behind it. Pass the client_secret from POST /v1/checkout/one_shot with
ui_mode: "embedded"; onSuccess receives { oneShotPaymentId, paymentStatus }. A decline is
final for that payment, so to let the buyer try again create a new one-off payment and pass its
secret, which remounts the element. See
Take the payment on your own page.
Server rendering
All three components (<CheckoutElement/>, <PaymentMethodElement/> and <OneShotPaymentElement/>) render null on the server and on the first client render, then mount the iframe after hydration. No hydration mismatch, no window access during render. Next.js App Router, Remix and Astro islands work with no dynamic(..., { ssr: false }) wrapper.
Your own pay button
Take a ref and call submit(). Gate the button on onChange’s complete, and re-enable it from onError: a declined card fires onError({ code: "payment_declined" }) while the element shows its own retry panel, so that is the only signal the attempt is over.
import { useRef, useState } from "react";
import { BillKitProvider, CheckoutElement, type BillKitElementRef } from "@billkit-eu/react";
function Checkout({ clientSecret }: { clientSecret: string }) {
const element = useRef<BillKitElementRef>(null);
const [complete, setComplete] = useState(false);
const [submitting, setSubmitting] = useState(false);
return (
<BillKitProvider>
<CheckoutElement
ref={element}
clientSecret={clientSecret}
onChange={({ complete }) => setComplete(complete)}
onError={() => setSubmitting(false)}
onSuccess={({ sessionId }) => router.push(`/thanks?cs=${sessionId}`)}
/>
<button
disabled={!complete || submitting}
onClick={() => {
setSubmitting(true);
element.current?.submit();
}}
>
Pay
</button>
</BillKitProvider>
);
}The ref also exposes focus(), which moves keyboard focus into the element for a page that reveals it in a later step, and updateTheme(tokens). The declarative theme prop is hot-applied without a remount and is usually what you want. <PaymentMethodElement/>’s ref exposes focus() and updateTheme() and no submit(): it has no form to submit, its actions are per-row buttons inside the iframe.
Callbacks and re-renders
Pass inline arrow functions freely. Callbacks are read through a ref at event time, so a fresh closure on every render does not tear down a live payment iframe. The same goes for logger.
Only clientSecret, customerId (when you pass one), locale, title and the origin overrides remount the element, because a remount destroys an in-progress payment.
Content Security Policy
frame-src https://js.billkit.eu;If the iframe never boots, onError fires with load_timeout. A missing frame-src and an ad blocker look identical from the page, so check both.
Logging
Silent by default. Opt in on the provider with logger={console}, or per element. The client_secret, message payloads and full redirect URLs are never logged.
Upgrading from 0.x
1.0 is a deliberate break, and every change is listed in the package’s CHANGELOG.md. In short:
- Peer ranges:
@billkit-eu/js^1.0.0(^1.1.0since 1.1), andreact/react-dom^18 || ^19. A future React major is not assumed to work until it is tested. customerIdon<PaymentMethodElement/>is optional.- New: the
titleprop, andfocus()on every element’s ref. - The outside-a-provider error names every element, not only
<CheckoutElement/>. - Everything listed under
@billkit-eu/jsapplies too, since the types are re-exported from it.
BillKit