Payment Form
Custom payment form pattern for JavaScript using Elements and tokenize.
This recipe shows how to build a fully custom card form with @easylabs/browser 0.2.0 — your own layout, your own styling, your own submit button — while keeping the PAN inside Easy Labs' iframed inputs. The flow is: mount three Elements, tokenize on submit, then forward the token reference to your backend to create a payment instrument.
If you'd rather drop in the hosted checkout iframe (zero design work, zero PCI scope), use Embedded Checkout. If you want a wallet button instead of a card form, see Wallet Checkout.
Goal
Collect a one-time card payment from a custom form and turn it into a charged payment instrument. By the end you'll have:
- A browser page with three iframed card fields, live focus/complete/invalid styling, and a submit button.
- A
tokenizecall that produces a Basis Theory token reference. - A
POST /api/payment-instrumentsendpoint that exchanges the token reference for a charged payment instrument.
Implementation
1. Browser markup
<form id="card-form">
<label for="card-number">Card number</label>
<div id="card-number" class="el-host"></div>
<div class="row">
<div>
<label for="card-exp">Expiration</label>
<div id="card-exp" class="el-host"></div>
</div>
<div>
<label for="card-cvc">CVC</label>
<div id="card-cvc" class="el-host"></div>
</div>
</div>
<button id="submit" type="submit">Pay $19.99</button>
<p id="error" hidden></p>
</form>.el-host {
border: 1px solid #d8d8df;
border-radius: 8px;
padding: 0.625rem 0.75rem;
height: 44px;
background: white;
}
.el-host.is-focused { border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.08); }
.el-host.is-complete { border-color: #16a34a; }
.el-host.is-invalid { border-color: #dc2626; }
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }2. Browser script
import {
createEasyClient,
mountCardNumberElement,
mountCardExpirationDateElement,
mountCardVerificationCodeElement,
tokenize,
} from "@easylabs/browser";
await createEasyClient({ apiKey: import.meta.env.VITE_EASY_API_KEY });
const stateClasses = {
focus: "is-focused",
complete: "is-complete",
invalid: "is-invalid",
};
const cardNumber = mountCardNumberElement("#card-number", {
placeholder: "1234 5678 9012 3456",
classes: stateClasses,
});
const cardExpiration = mountCardExpirationDateElement("#card-exp", {
placeholder: "MM / YY",
classes: stateClasses,
});
const cardCvc = mountCardVerificationCodeElement("#card-cvc", {
placeholder: "CVC",
classes: stateClasses,
});
const form = document.querySelector<HTMLFormElement>("#card-form")!;
const errorEl = document.querySelector<HTMLParagraphElement>("#error")!;
const submitBtn = document.querySelector<HTMLButtonElement>("#submit")!;
form.addEventListener("submit", async (event) => {
event.preventDefault();
errorEl.hidden = true;
submitBtn.disabled = true;
submitBtn.textContent = "Processing…";
try {
const token = await tokenize({ cardNumber, cardExpiration, cardCvc });
const res = await fetch("/api/payment-instruments", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tokenId: token.id }),
});
if (!res.ok) throw new Error(`Server error: ${res.status}`);
window.location.href = "/checkout/success";
} catch (err) {
errorEl.hidden = false;
errorEl.textContent = (err as Error).message;
submitBtn.disabled = false;
submitBtn.textContent = "Pay $19.99";
}
});
// On SPA route change, beforeunload, etc.:
window.addEventListener("beforeunload", () => {
cardNumber.unmount();
cardExpiration.unmount();
cardCvc.unmount();
});3. Server endpoint
The browser hands you a Basis Theory token reference. Exchange it for a charged payment instrument server-side:
// server/payment-instruments.ts
import { createClient } from "@easylabs/node";
const easy = await createClient({ apiKey: process.env.EASY_API_KEY! });
export async function createPaymentInstrument(req, res) {
const { tokenId, cardholderName } = req.body;
const { data: instrument } = await easy.createPaymentInstrument({
type: "PAYMENT_CARD",
identityId: req.session.customerId,
tokenId,
// `name` is required by `CreatePaymentInstrumentBase`. Pass the
// cardholder name your form collected, or fall back to the
// customer's stored name.
name: cardholderName,
});
// Charge it, attach it to a subscription, save it for later — your call.
res.json({ instrumentId: instrument.id });
}4. Server-side reaction
For long-lived flows (subscriptions, scheduled charges, fulfilment) listen for the relevant webhooks rather than trusting the browser's success redirect. See @easylabs/node's webhook helpers.
Improvements you might add
- Disable submit until all three are complete. Track each element's
getState().complete(or wire achangelistener for each) and togglesubmitBtn.disabledaccordingly. - Pass
cardBrandto the CVC element. Readstate.brandoff the card-numberchangeevent and re-mount the CVC element with{ cardBrand }so length validation matches the network (Amex = 4 digits, others = 3). - Render a brand logo. Subscribe to
cardNumber.on("change", ...)and swap an inline SVG based onstate.brand. - Surface server errors inline. Catch
EasyApiErroron the server and forwardcode/messageback to the page so the inline error reflects the API's reason rather than a generic 500.
Tradeoffs
- Pro: total control over layout, copy, validation timing, and submit affordance.
- Pro: PAN never reaches your origin — Basis Theory's iframes own it. PCI scope stays minimal (typically SAQ A-EP).
- Con: more code than Embedded Checkout. You own the form, the error UX, and the cleanup.
- Con: wallets (Apple Pay / Google Pay) are not built into this flow — add them separately via the wallet button factories. See Wallet Checkout.
- Watch out: always
unmount()the elements on navigation. Forgetting leaks one iframe per mount.