Skip to content

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.

BrowserYour serverTuppencePOST /v1/paymentsclient_secretpaymentSheet().mount()sheet.confirm(): cardbank check, if askedread payment backpayment.succeeded
Terminal window
npm install @tuppence/js

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.

MethodDoes
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, setupElementsThe 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" }).

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");
OptionWhat it does
appearance.themeThe base look: stripe (light), night or flat
appearance.variablesColours, font, sizes, corners and spacing across every field
appearance.rulesCSS-like rules for the fields’ selectors (.Input, .Label, .Tab, .Error, with states such as :focus and --selected)
layouttabs or accordion
walletsauto, 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.

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

EventMeans
readyThe fields have loaded
change{ complete, empty }. Enable Pay when complete
errorThey couldn’t load (error.message)
sheet.on("ready", () => showForm());
sheet.on("change", ({ complete, empty }) => (payButton.disabled = !complete));
sheet.on("error", (error) => showMessage(error.message));

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:

SituationCall
A check already under way (requires_action)tuppence.resumePayment({ clientSecret })
Pay with the card they saved; the bank checks it’s themtuppence.confirmPayment({ clientSecret, savedCard: true })
Use another cardMount a sheet as usual

Saving a card (setupSheet, confirmSetup, resumeSetup) works the same way. What your server sees is in 3-D Secure.

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.

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.

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.com
frame-src https://js.stripe.com https://*.js.stripe.com https://hooks.stripe.com
connect-src https://api.stripe.com https://api.tuppence.ai
DirectiveWhy
script-srcStripe.js
frame-srcThe card fields; hooks.stripe.com is where a bank’s check (3-D Secure) opens
connect-srcWhere 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.

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:

Terminal window
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"}'
{
"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, DNSNothing to host or change: Tuppence checks the domain with Apple and Google
Each hostname counts alonewww.example.com and example.com both need adding
Wallet statusactive (it shows), inactive (switched off there; error says why) or pending (not registered yet)
RechecksA wallet that isn’t on is looked at hourly, every domain daily; POST /v1/payment_method_domains/{id}/validate (Check again) looks now
Asking twiceSafe: an existing domain is returned unchanged
RemovingDELETE /v1/payment_method_domains/{id} stops the wallets there; card fields keep working
Checkout in an iframeRegister 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.

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.

Docs assistant

Open as a page
Loading the assistant…