BillKit/Docs Console
SDK

Browser (JS)

Embedded checkout for the browser. Card fields render in a cross-origin iframe, so your page stays PCI SAQ A.

v0.2.2 Any modern browser Beta Source

Install

npm install @billkit-eu/js

ESM and CJS builds with bundled types, and no runtime dependencies.

This is not an API client. No secret key ever belongs in a browser. For server calls use @billkit-eu/sdk. Using React? @billkit-eu/react wraps this package in components.

How it fits together

The element authenticates with an ephemeral client_secret, not a key. Mint one on your server by creating an embedded Checkout session, then hand it to the browser.

typescript
// Your server, using @billkit-eu/sdk
const session = await client.checkoutSessions.create<{ client_secret: string }>({
customer_id: "cus_9XKp2vQ1", // or customer_email; exactly one is required
price_id: "price_9XKp2vQ1",
ui_mode: "embedded",
success_url: "https://acme.example/thanks",
cancel_url: "https://acme.example/pricing", // required, even when embedded
});

Then mount the element with it. mountCheckoutElement takes an HTMLElement or a CSS selector.

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

const element = mountCheckoutElement("#checkout", {
clientSecret,
theme: { colorPrimary: "#2f6bff", borderRadius: "10px" },
onSuccess: ({ sessionId }) => { location.href = `/thanks?cs=${sessionId}`; },
onError: ({ message }) => showToast(message),
});

element.submit();                                 // drive your own pay button
element.updateTheme({ colorPrimary: "#0f766e" }); // restyle in place
element.destroy();                                // remove the iframe

Call destroy() when you tear down the surrounding view. A live element holds a message listener on window.

The element header

By default the element draws a header above the payment form: your logo, your business name, the product, and the amount. That is the right default for an element dropped onto a bare page, where it would otherwise show a buyer a price with no indication of who is charging it.

If the page you embed into already shows those things, turn the header off in the dashboard under Settings → Branding → Show the header on the embedded checkout. The element then renders the payment form alone. There is no mount-time option for it: it is a property of your account, so the setting applies everywhere the element is embedded and does not have to be repeated at each call site.

The logo it shows is the same one used on your invoices and in your dashboard, set once under Settings → Branding. It must be served over HTTPS. If it fails to load, the element falls back to a letter mark rather than showing a broken image.

Saved payment methods

The same iframe renders a customer’s stored methods, with update and remove built in.

typescript
import { mountPaymentMethodElement } from "@billkit-eu/js";

const wallet = mountPaymentMethodElement("#methods", {
clientSecret,
customerId: "cus_9XKp2vQ1",
});

Error codes

onError receives { message, code? }. The message is human-readable and already localised; the code is the stable tag to branch on.

typescript
const element = mountCheckoutElement("#checkout", {
clientSecret,
onChange: ({ complete }) => (payButton.disabled = !complete),
onError: ({ code, message }) => {
  payButton.disabled = false; // the attempt is over, whatever the cause
  if (code !== "payment_declined") showToast(message);
},
});
payButton.onclick = () => element.submit();

payment_declined fires after a confirm that failed with no redirect. It is the only signal that the attempt ended, because a decline fires neither onSuccess nor a redirect, so without handling it an external pay button stays stuck. The element keeps its own retry panel on screen: do not navigate away.

load_timeout fires when the iframe never booted within loadTimeoutMs, which defaults to 20000. A missing CSP frame-src and an ad blocker look identical from the page, so check both. unsafe_redirect means the loader refused a redirect target that was not absolute http(s); treat it as a security event.

Newer element versions can mint new codes, so branch on what you handle and fall through to message.

Redirect-based methods

Cards that clear without a challenge fire onSuccess. 3DS and iDEAL navigate the top window to the bank instead. Intercept it if you need to save state first.

typescript
onRedirect: (url) => {
sessionStorage.setItem("cart", JSON.stringify(cart));
window.location.assign(url);
return false; // you took over; the loader will not navigate
}

Terminal state for a redirect flow reaches your server through the checkout.session.completed webhook. Treat the return to success_url as a hint and the webhook as the truth.

Content Security Policy

The element renders in an iframe, so your CSP has to allow the origin it is served from.

text
frame-src https://js.billkit.eu;

Security model

The fixed iframe origin is the whole design.

  • Card fields are cross-origin, so the page hosting the element cannot read what the customer types. That is what keeps you on PCI SAQ A.
  • The client_secret is never in the iframe URL, in either the query or the fragment. It arrives by targeted postMessage only after the iframe announces ready, which keeps it out of Referer headers, browser history and server logs.
  • Every inbound message is checked against the exact iframe origin and the specific frame that sent it, before the payload is parsed.
  • Outbound messages always name a target origin, never *.
  • The iframe is sandboxed without allow-top-navigation. Redirects are requested by message and performed by the loader only after the URL is validated, so a compromised element cannot navigate your page to a phishing clone.

Logging

Silent by default: it never calls console on its own. Pass logger to opt in and you get one debug line per lifecycle step and a warn whenever a message is dropped or a redirect is refused. The client_secret, message payloads and full redirect URLs are never logged; only a redirect’s origin is recorded, because the rest of the URL carries payment identifiers.