Skip to main content

Custom checkout with Elements

Vora Elements lets you build a fully custom checkout: you position and style the card element and other checkout elements yourself — the card box plus, as your account supports them, separate email, cardholder, address, save-for-future-use, and payment-method-picker elements — and, optionally, standalone wallet buttons, instead of the single drop-in Embedded Fields embed that owns the whole payment surface.

For the card entry itself you have a choice: mount the combined card box — number, expiry, and CVC together in one secure field — or the three discrete secure fields card-number, card-expiry, and card-cvc, which you place and style independently. Both produce the same vp_pmt_* token; this page walks the combined card element end to end and shows the discrete-fields variant at the end.

What "Elements" actually changes

In elements mode you place each piece of the checkout where you want it instead of taking the single drop-in embed. The pieces are the Vora elements: card (or the discrete card-number / card-expiry / card-cvc fields), email, cardholder, address, save-for-future-use, payment-method-picker (plus a discrete saved-methods picker, not usable yet). What becomes yours to lay out is the placement of these elements on your page.

For the card, you pick the surface: the combined card element is one box that collects number, expiry, and CVC together inside a single secure iframe; the discrete fields split those into three independently-placed secure fields. Either way the PAN never touches your page. See Discrete card fields in the Elements reference for the split-field option and its rules.

For the other elements — email, cardholder, address, save-for-future-use, payment-method-picker — and their options and value shapes, see the Elements reference. Where your account doesn't support discrete rendering, the session falls back to the standard embed (see the Availability note below).

You opt in with one field when you create the session — integrationMode: "elements". This is the composable render mode of Embedded Fields, not a separate integration — for the full model (embed monolith vs composable elements, the capability matrix, and the silent fallback) see Render modes: embed vs elements. Everything else is the surface you already know: the same vora.js SDK, the same vp_pmt_* token, and the same POST /v1/payment_intents charge path. There is no elements-mode-specific charge flow.

Returning customers (one-click saved cards)

The discrete saved-methods picker for elements mode is not usable yet — the element ships in the SDK, but the surface it reads a buyer's saved cards from is not enabled, so it returns no cards. Until that surface is live, do returning-buyer one-click one of two ways: (1) use the default embed mode — set a buyer on the session (buyerId) and the embed lists that buyer's previously-saved cards inside the iframe automatically (see buyer identification); or (2) if you already hold a buyer's vp_pmt_* from a prior session, charge it server-side through Payment Intents without re-collecting the card. Saved cards only exist for a buyer who vaulted one in a prior completed session.

Availability

Discrete Elements rendering is available where your account supports the Elements integration mode. Request integrationMode: "elements" when you create the session; if Elements isn't available for that session it transparently falls back to the standard embed — it never errors mid-checkout. The integrationMode echoed back on the created session (and on vora.sessions.retrieve()) tells you which surface you actually got, so branch your UI on that value. You can always fall back to hosted checkout.

For standalone Apple Pay / Google Pay buttons on the same session, see Accept Apple Pay & Google Pay.


How it works

1. Server  → POST /v1/sessions { integrationMode: "elements" }   → session id (vp_cs_*)
2. Browser → load vora.js (CDN) → new Vora() → vora.sessions.retrieve(sessionId)
3. Browser → mount the `card` element (one combined card box) into your own container
4. Browser → cardCollection.submit() → vp_pmt_* token
5. Browser → send the token to your server
6. Server → POST /v1/payment_intents { payment_method: { id } } → charged
7. Confirm settlement via the webhook before fulfilling

The buyer's card data never reaches your servers — PAN and CVC stay inside the secure field iframes (the SAQ-A boundary). You only ever hold the resulting vp_pmt_* token plus PCI-safe display metadata (brand, last4).


Step 0 — Get your keys

You need both a publishable key (vp_pk_test_*, for the browser) and a secret key (vp_sk_test_*, server-only). See Quickstart §0. Your secret key never reaches the browser.


Step 1 — Create an Elements session (server)

Create the session from your backend with your secret key. The one field that switches a session to discrete fields is integrationMode: "elements":

// server/create-session.ts (Node)
app.post("/api/create-session", async (req, res) => {
const response = await fetch("https://checkout.vonpay.com/v1/sessions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.VON_PAY_SECRET_KEY}`, // vp_sk_*
},
body: JSON.stringify({
amount: 4999, // minor units (cents)
currency: "USD",
integrationMode: "elements", // ← discrete fields instead of the embed
}),
});

const session = await response.json();
// session.id is vp_cs_* — browser-safe. Send only the id to the client.
res.json({ session_id: session.id });
});

The response is a session object — { id, checkoutUrl, expiresAt, integrationMode }. If your account doesn't support Elements, the echoed integrationMode comes back "embed" rather than erroring — check it if you need to branch your UI.


Step 2 — Load vora.js and retrieve the session (browser)

Load the SDK from the CDN — @vonpay/vora-js is not on npm; the <script> tag attaches a global Vora constructor. The auto-update channel always serves the current v1 build:

<script src="https://js.vonpay.com/v1/vora.js" crossorigin="anonymous"></script>

For production, pin a version with Subresource Integrity — copy the current version and hash from js.vonpay.com/integrity.json. Then construct the client and retrieve the session your server created:

const vora = new window.Vora({
publishableKey: "vp_pk_test_…", // a secret key throws a TypeError
apiBaseUrl: "https://checkout.vonpay.com", // this is the default — it is NOT inferred
// from your key. It must name the same host
// your server called in Step 1.
});

// Fetch the session id from your server (Step 1), then:
await vora.sessions.retrieve(sessionId); // loads the render config for this session

vora.sessions.retrieve() resolves how this session should render and loads the matching field adapter for you — your browser code never names or selects an underlying provider.


Step 3 — Mount the card field

Create an elements collection, create a card element, and mount it into a container you own. Listen for the change event to know when the field is complete:

const cardCollection = vora.elements.create();

const card = cardCollection.create("card", {
style: {
color: { text: "#1f2937", placeholder: "#9ca3af" },
font: { family: "system-ui, sans-serif", size: "16px" },
},
});

let cardComplete = false;
card.on("change", (e) => {
cardComplete = e.complete; // enable your Pay button when true
showFieldError(e.error?.message ?? null); // e.error is { code, message }
});

card.mount("#card-element"); // your own <div id="card-element">

Style is the unified Vora schema. The secure card iframe themes font and color only — borders, padding, and internal layout are drawn on your own outer wrapper <div>, not inside the iframe. Calling create("card", …) before vora.sessions.retrieve() throws frame_session_not_ready.


Step 4 — Tokenize on submit

On your Pay click, call submit() on the card collection. It vaults the card onto the session and resolves a vp_pmt_* token — it does not charge (the charge happens server-side in Step 6, so pass no paymentIntent argument):

payButton.addEventListener("click", async () => {
const result = await cardCollection.submit();

if (result.error) {
showFieldError(result.error.message); // validation / tokenize failure
} else if (result.token) {
await chargeOnYourServer(result.token); // vp_pmt_* — send to your backend
} else if (result.charged) {
// The session silently fell back to "embed" and ran a guest charge-and-save:
// the buyer was ALREADY charged and there is no reusable token. Do NOT charge
// again server-side — confirm settlement via the charge.succeeded /
// payment_intent.succeeded webhook (both fire; see Step 6).
} else {
// No token, no charge, no error → the session was not created with
// integrationMode: "elements". Recheck Step 1.
}
});

SubmitResult is one flat object (every field optional) — branch errortoken. result.token is the reusable vp_pmt_*; result.last4 / result.brand are display-only.


Step 5 — Charge on your server

Send the vp_pmt_* token to your backend and charge it with POST /v1/payment_intents using your secret key. The payment_method field takes an object — { id }:

// server/charge.ts (Node)
app.post("/api/create-payment-intent", async (req, res) => {
const response = await fetch("https://checkout.vonpay.com/v1/payment_intents", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.VON_PAY_SECRET_KEY}`,
// Production: send a stable Idempotency-Key (e.g. the cart id) so a
// retry never double-charges.
},
body: JSON.stringify({
amount: 4999,
currency: "USD",
payment_method: { id: req.body.payment_method }, // the vp_pmt_* token
}),
});

const intent = await response.json();
// Embedded/Elements integrations forward `client_confirm` (redirect-based
// integrations resolve `next_action` instead — both may be present).
res.json({ intent_id: intent.id, status: intent.status, client_confirm: intent.client_confirm ?? null });
});

The intent status is one of requires_action, authorized, captured, succeeded, voided, or failed.


Step 6 — Handle 3DS and confirm settlement

If status === "requires_action", the charge needs a 3D Secure challenge before it settles. Forward the intent's client_confirm block back to the SDK and drive the modal with collection.submit({ paymentIntent }) (redirect-based integrations resolve next_action instead — both may be present on the intent). See 3D Secure & SCA for the full flow.

The client-side result is a UX signal only — a timeout or network error on submit can land after the charge already succeeded, so never mark the order failed on a client error, and never resubmit blindly (you may double-charge). Confirm settlement server-side via the settlement webhook before fulfilling.

A single successful payment emits both the charge.succeeded and payment_intent.succeeded events — both families fire for every terminal outcome, regardless of which flow (charge-and-save or tokenize-then-charge) produced it. Subscribe to whichever your reconciliation keys on; if you subscribe to both, you receive two distinct events for the one payment. So dedupe on the event envelope's top-level id (vp_evt_*) — not session_id, which one payment can reuse across those two events — and make fulfillment idempotent per session so the same payment is never fulfilled twice.

See Reconciliation — redirect vs. webhook for the full duplicate-safe pattern, and treat 409 session_already_completed as already paid (do not create a new session).


The two-collection rule

If you mount both a card field and standalone wallet buttons on the same page, keep them in separate collections:

const cardCollection   = vora.elements.create();  // card lives here
const walletCollection = vora.elements.create(); // wallets live here

collection.submit() tokenizes whatever card element is registered in that collection. If the wallet shared the card's collection, submitting the wallet would try to tokenize the still-empty card field. Separate collections keep each submit() scoped to its own surface.


Variant — discrete card fields

Everything above uses the combined card element (one box for number + expiry + CVC). If you'd rather lay out the three card inputs independently — a full split-field checkout — swap the single card element for the three discrete fields card-number, card-expiry, and card-cvc. Only Step 3 (mount) changes; Steps 1, 2, 4, 5, and 6 are identical, and you get the same vp_pmt_* from submit().

Give each field its own container in your HTML:

<label>Card number <div id="card-number"></div></label>
<label>Expiry <div id="card-expiry"></div></label>
<label>CVC <div id="card-cvc"></div></label>

Then create and mount all three into one collection:

const cardCollection = vora.elements.create({
theme: { color: { text: "#1f2937", placeholder: "#9ca3af" }, font: { size: "16px" } },
});

const number = cardCollection.create("card-number", { placeholder: "1234 1234 1234 1234", showIcon: true });
const expiry = cardCollection.create("card-expiry", { placeholder: "MM / YY" });
const cvc = cardCollection.create("card-cvc", { placeholder: "CVC" });

number.mount("#card-number");
expiry.mount("#card-expiry");
cvc.mount("#card-cvc");

// Wire per-field validation errors. On the direct-card binder each
// field fires its own field-scoped `change`; on the secure-fields binder `change`
// is whole-form. Treat `complete` as a form-level signal for portability — see
// the events caveat in the Elements reference.
number.on("change", (e) => showFieldError(e.error?.message ?? null));
expiry.on("change", (e) => showFieldError(e.error?.message ?? null));
cvc.on("change", (e) => showFieldError(e.error?.message ?? null));

submit() is unchanged — it drives the shared secure-fields controller and vaults all three fields together:

payButton.addEventListener("click", async () => {
const result = await cardCollection.submit();
if (result.error) {
showFieldError(result.error.message);
} else if (result.token) {
await chargeOnYourServer(result.token); // same vp_pmt_* as the combined card
}
});

Three gotchas (full rules + options in the Elements reference → Discrete card fields): all three fields must be created and mounted before submit(); never put both the combined card and the discrete fields in one collection (that tokenizes twice); and discrete fields need "elements" mode + a supporting binder — otherwise .mount() throws frame_unsupported_element, so fall back to the combined card.


What's next