Easy Labs
SDKsJavaScriptExamples

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 tokenize call that produces a Basis Theory token reference.
  • A POST /api/payment-instruments endpoint 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 a change listener for each) and toggle submitBtn.disabled accordingly.
  • Pass cardBrand to the CVC element. Read state.brand off the card-number change event 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 on state.brand.
  • Surface server errors inline. Catch EasyApiError on the server and forward code / message back 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.

On this page