Browser: @tuppence/js
The browser SDK for your own checkout. Card details go from the payer’s device straight into secure card fields (iframes on the card processor’s origin), never through your page’s code, this package or Tuppence’s servers.
1. Install
Section titled “1. Install”npm install @tuppence/js2. Mount the card fields and pay
Section titled “2. Mount the card fields and pay”Your server creates the payment (POST /v1/payments) and hands the browser its client_secret.
import { loadTuppence } from "@tuppence/js";
// Your PUBLISHABLE key: a secret key is refused before it is sent anywhere.const tuppence = await loadTuppence("pk_test_…");
// The secure card fields, in the business's colours, set up to match the charge.const sheet = tuppence.paymentSheet({ clientSecret }).mount("#card");sheet.on("ready", () => (payButton.disabled = false));
// When the payer presses Pay:const { error, payment } = await sheet.confirm();confirm runs the bank’s check if the bank asks for one, then reads the payment back until it
settles. A refused card returns error, with a message written for the payer. If a response is
lost, it reads back what happened: a card is never presented twice.
| Method | Does |
|---|---|
loadTuppence(pk, { apiBase }) | The client; publishable key only |
tuppence.paymentSheet({ clientSecret, … }) | The card fields for a payment; .mount(selector), .on(event), .confirm() |
tuppence.setupSheet({ clientSecret }) | The same, to save a card with nothing charged |
tuppence.confirmPayment({ clientSecret, … }) | Confirm with your own elements, a savedCard, or a testPaymentMethod |
tuppence.resumePayment({ clientSecret }) | Finish a bank check already under way |
tuppence.retrievePayment(clientSecret) | Read the payment |
tuppence.elements(payment, options) | Lower-level: the fields’ building blocks, your own fonts, language, loader |
confirmSetup, resumeSetup, setupElements | The same, for saving a card |
On the simulated gateway (local development and CI), there are no fields and the sheet’s error
event says so. Name a test card instead:
tuppence.confirmPayment({ clientSecret, testPaymentMethod: "pm_card_gb" })tuppence.confirmPayment({ clientSecret, testPaymentMethod: "pm_card_us" })tuppence.confirmPayment({ clientSecret, testPaymentMethod: "pm_card_de" }).
Styling the card fields
Section titled “Styling the card fields”The fields live in an iframe, so your CSS can’t reach them. Style them with appearance (the
options below). By default they use the
business’s branding (Settings → Branding), as on our hosted pay page: its accent colour (Tuppence
Blue if none) for the focus ring and selected tab, Inter at 16px and 8px corners. Pass appearance
to match your checkout. It replaces the default rather than adding to it.
const sheet = tuppence .paymentSheet({ clientSecret, appearance: { theme: "night", // "stripe" (light), "night" (dark) or "flat" variables: { colorPrimary: "#00d1a7", colorBackground: "#0b1020", colorText: "#f4f6fb", colorDanger: "#ff7a85", fontFamily: "system-ui, sans-serif", fontSizeBase: "15px", borderRadius: "12px", spacingUnit: "4px", }, rules: { ".Input": { borderColor: "#2a3350", boxShadow: "none" }, ".Input:focus": { borderColor: "#00d1a7" }, ".Label": { fontWeight: "600" }, }, }, layout: "accordion", // or "tabs", the default wallets: "never", // "auto", the default, offers Apple Pay and Google Pay }) .mount("#card");| Option | What it does |
|---|---|
appearance.theme | The base look: stripe (light), night or flat |
appearance.variables | Colours, font, sizes, corners and spacing across every field |
appearance.rules | CSS-like rules for the fields’ selectors (.Input, .Label, .Tab, .Error, with states such as :focus and --selected) |
layout | tabs or accordion |
wallets | auto, or never for card fields only |
appearance is passed to the card fields unchanged, so every variable and selector in
Stripe’s Appearance API reference works. The pay
button is ordinary HTML on your page, so style it however you like.
Your own web font, language or loader
Section titled “Your own web font, language or loader”The fields can’t see fonts your page loads, so pass the web font to them as well. Fonts, language
and the loading skeleton are options on the lower-level tuppence.elements(). Mount the payment
element yourself and confirm with tuppence.confirmPayment, which takes the same steps as
sheet.confirm():
const payment = await tuppence.retrievePayment(clientSecret);const elements = await tuppence.elements(payment, { appearance: { variables: { fontFamily: "'DM Sans', sans-serif", colorPrimary: "#3b5bff" } }, fonts: [{ cssSrc: "https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;600" }], locale: "en-GB", loader: "auto",});elements.create("payment").mount("#card");
// When the payer presses Pay:const { error, payment: paid } = await tuppence.confirmPayment({ clientSecret, elements });tuppence.elements() returns null on the simulated gateway.
tuppence.setupElements(session, options) is the same for saving a card.
The payer’s language
Section titled “The payer’s language”The card fields, and Stripe’s messages about a card, speak the payer’s browser language: a payer
whose browser says de-AT sees them in German. To choose it yourself, pass locale with a
language tag, for example loadTuppence("pk_test_…", { locale: "fr-BE" }). A language the fields
don’t speak shows them in English.
Portuguese is the exception. The fields’ Portuguese is Brazilian, so a payer in Portugal (pt-PT)
sees them in English. Only pt-BR shows Brazilian Portuguese.
Knowing when to enable Pay
Section titled “Knowing when to enable Pay”| Event | Means |
|---|---|
ready | The fields have loaded |
change | { complete, empty }. Enable Pay when complete |
error | They couldn’t load (error.message) |
sheet.on("ready", () => showForm());sheet.on("change", ({ complete, empty }) => (payButton.disabled = !complete));sheet.on("error", (error) => showMessage(error.message));The bank’s check (3-D Secure)
Section titled “The bank’s check (3-D Secure)”You build nothing for it. When the bank wants to check the payer, sheet.confirm() (or
tuppence.confirmPayment) opens the check in a frame over your page, waits, and reads the
payment back. The payer never leaves your checkout and you need no return URL.
const { error, payment } = await sheet.confirm();if (error) { // Declined, or the payer failed or closed the bank's check: error.message is written for // them. The payment is still payable, so they can try again or use another card. showMessage(error.message);} else if (payment.status === "succeeded" || payment.status === "requires_capture") { showConfirmation(); // your server hears payment.succeeded by webhook too}A payer can come back to a payment waiting on their bank: a charge to a saved card their bank wants confirmed, or a check left halfway. Pass its client secret to your page, then:
| Situation | Call |
|---|---|
A check already under way (requires_action) | tuppence.resumePayment({ clientSecret }) |
| Pay with the card they saved; the bank checks it’s them | tuppence.confirmPayment({ clientSecret, savedCard: true }) |
| Use another card | Mount a sheet as usual |
Saving a card (setupSheet, confirmSetup, resumeSetup) works the same way. What your server
sees is in 3-D Secure.
Bacs Direct Debit
Section titled “Bacs Direct Debit”Where a UK business takes Direct Debit, the Payment
Element offers Bacs Direct Debit beside cards and shows the mandate and the Direct Debit
Guarantee itself. Keep its container free of overflow: hidden or a fixed height. A debit takes a
few working days, so confirm returns processing at once:
const { payment } = await sheet.confirm();if (payment.status === "processing" && payment.payment_method_type === "bacs_debit") { // "Your Direct Debit is set up. We'll collect it on or around Wed 14 Oct." showSetUp(payment.processing.expected_debit_date); // YYYY-MM-DD, or null: "in a few working days"}Don’t wait for succeeded: it (or payment.failed) reaches your server by webhook days later.
The payer is emailed to confirm the mandate, and the receipt goes once the money is collected.
Where a debit can’t be used (a booking’s deposit, a till, a credit top-up), the payment’s
excluded_payment_method_types says so and the sheet leaves Bacs out.
Another API address
Section titled “Another API address”The SDK calls https://api.tuppence.ai. For another Tuppence environment you’ve been given, pass
apiBase (loadTuppence("pk_test_…", { apiBase: "https://…" })) and allow that address in
connect-src below.
Your page’s Content-Security-Policy
Section titled “Your page’s Content-Security-Policy”The card fields are served by Stripe, Tuppence’s card processor, so this is the one place your page
talks to Stripe directly. Stripe.js loads from js.stripe.com (it is never bundled) the first time
card fields are needed. Allow it, and Tuppence’s API, alongside your page’s own sources
(script-src 'self' and so on):
script-src https://js.stripe.com https://*.js.stripe.comframe-src https://js.stripe.com https://*.js.stripe.com https://hooks.stripe.comconnect-src https://api.stripe.com https://api.tuppence.ai| Directive | Why |
|---|---|
script-src | Stripe.js |
frame-src | The card fields; hooks.stripe.com is where a bank’s check (3-D Secure) opens |
connect-src | Where the card fields send the card, and Tuppence’s API (or your apiBase) |
The SDK needs nothing more: no inline script or style, no eval. Our tests run a checkout under
exactly this policy and pay through the card fields. The publishable key and client secrets
travel in headers, never in a URL, and no cookie is sent.
Wallets on your own domain
Section titled “Wallets on your own domain”The sheet offers Apple Pay and Google Pay wherever the payer’s device has them (wallets: "auto",
the default), but only on a domain registered for the business’s account. Our hosted
pages are already registered. Register each hostname your checkout runs on once, in the dashboard
under Developers → Payment domains or from a deploy script:
curl https://api.tuppence.ai/v1/payment_method_domains \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"domain_name": "shop.example.com"}'const domain = await tuppence.paymentMethodDomains.create({ domain_name: "shop.example.com" });domain = tuppence.payment_method_domains.create(domain_name="shop.example.com"){ "id": "awdom_01K…", "object": "payment_method_domain", "domain_name": "shop.example.com", "apple_pay": { "status": "active" }, "google_pay": { "status": "active" }, "error": null}An agent can use the add_payment_method_domain MCP tool.
| Hosting, DNS | Nothing to host or change: Tuppence checks the domain with Apple and Google |
| Each hostname counts alone | www.example.com and example.com both need adding |
Wallet status | active (it shows), inactive (switched off there; error says why) or pending (not registered yet) |
| Rechecks | A wallet that isn’t on is looked at hourly, every domain daily; POST /v1/payment_method_domains/{id}/validate (Check again) looks now |
| Asking twice | Safe: an existing domain is returned unchanged |
| Removing | DELETE /v1/payment_method_domains/{id} stops the wallets there; card fields keep working |
| Checkout in an iframe | Register the iframe’s domain and the page’s. In Safari the iframe needs allow="payment" |
To try wallets in test mode, open your checkout over https (a tunnel such as ngrok works locally, and that hostname needs registering too) on a device with a real card in Apple Wallet or Google Pay. Test mode never charges it.
No integrity hash on the card fields’ script
Section titled “No integrity hash on the card fields’ script”The card fields’ script (Stripe.js, from js.stripe.com) is updated in place with fixes and fraud
signals, so it can’t carry a Subresource Integrity (SRI) hash, and it can’t be bundled or hosted
elsewhere. The SDK loads it through its official loader (@stripe/stripe-js), with no integrity
attribute. What keeps your page’s
scripts in check is the policy above. @tuppence/js
itself is part of your bundle, pinned by your lockfile’s integrity hash.
What the SDK never does
Section titled “What the SDK never does”It never renders a card field of its own and never bundles the card fields’ script. Its build fails
if either creeps in: the bundle and every published file are checked for an <input>, for the names
card fields go by (cardnumber, cc-number, cvc…), and for Stripe.js’s own code.
- Take a payment: the server side.
- 3-D Secure