BillKit/Docs Console
SDK

React

React bindings for the embedded checkout element. SSR-safe, shaped like @stripe/react-stripe-js.

v0.2.1 React 18+ and @billkit-eu/js Beta Source

Install

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

@billkit-eu/js, react 18+ and react-dom 18+ are peer dependencies, so install the loader alongside this package.

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.

tsx
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.

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

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

<BillKitProvider>
<PaymentMethodElement clientSecret={clientSecret} customerId="cus_9XKp2vQ1" />
</BillKitProvider>;

Server rendering

Both components 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.

tsx
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 updateTheme(tokens). The declarative theme prop is hot-applied without a remount and is usually what you want. <PaymentMethodElement/>’s ref exposes updateTheme() only: 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, locale and the origin overrides remount the element, because a remount destroys an in-progress payment. customerId has to be on that list: the wallet’s set-default and remove actions act on whichever customer the iframe was initialised with, so a stale frame would point them at the wrong person.

Content Security Policy

text
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.