Custom checkout with Elements
Vora Elements lets you build a fully custom checkout: you position and style the card element and the 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 you have a choice: the combined card box (number, expiry and CVC in one secure field) or the three discrete secure fields card-number, card-expiry and card-cvc, placed and styled 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.
You opt in with one field when you create the session — integrationMode: "elements". Where your account does not support discrete rendering the session renders as the standard embed instead and never errors mid-checkout; the integrationMode echoed on the created session (and on vora.sessions.retrieve()) tells you which surface you got, so branch your UI on that value. 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 — the full model is in Render modes: embed vs elements.
Returning buyers: the discrete saved-methods picker renders its unavailable state for every account, so for one-click saved cards either use the default embed mode with a buyer on the session (buyerId — the embed lists that buyer's saved cards inside the iframe; see buyer identification) or charge a vp_pmt_* you already hold server-side through Payment Intents without re-collecting the card. 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. You only ever hold the resulting vp_pmt_* token plus display metadata (brand, last4).
What does live in your page in this mode: the email, cardholder, address and save-for-future-use inputs, if you mount them. Any other script on the page can read those values, so keep the checkout page's scripts to ones you can vouch for; the element reference and the security reference spell out the trade-off.
Step 0 — Get your keys
You need a publishable key (vp_pk_test_*, for the browser) and a secret key (vp_sk_test_*, server-only) — Quickstart §0.
Step 1 — Create an Elements session (server)
Create the session from your backend with your secret key:
// 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.
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; for production, pin a version with Subresource Integrity.
<script src="https://js.vonpay.com/v1/vora.js" crossorigin="anonymous"></script>
const vora = new window.Vora({
publishableKey: "vp_pk_test_…", // a secret key throws a TypeError
apiBaseUrl: "https://checkout.vonpay.com", // the default — NOT inferred from your key;
// must name the 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 a 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 change 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: on this surface the secure card iframe takes font, color, background and spacing.padding; borders and margins are drawn on your own wrapper <div> — see where each token lands. 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. On a session without charge at submit it vaults the card and resolves a vp_pmt_* token — it does not charge (the charge happens server-side in Step 5, 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.chargeStatus === "requires_action") {
window.location.href = result.redirectUrl; // charge at submit only — 3-D Secure, nothing charged
} else if (result.chargeStatus === "pending") {
// charge at submit only — in flight; await the charge.* webhook, never charge again
} else if (result.recovered) {
// charge at submit only — resolved by polling after a lost response. There is no
// paymentIntentId here, only transactionId: confirm server-side, never charge again
} else if (result.token) {
await chargeOnYourServer(result.token); // vp_pmt_* — send to your backend
} else if (result.charged) {
// The session charged at submit (or fell back to a charging embed): the buyer
// was ALREADY charged and there is no reusable token. Do NOT charge again —
// confirm settlement via the webhook (Step 6).
}
});
SubmitResult is one flat object (every field optional) — branch error → chargeStatus → recovered → token → charged, in that order (the full contract). result.last4 / result.brand are display-only; a saved-card list also gets result.expMonth / result.expYear — camelCase, 1-12, absent rather than placeholdered when unavailable (Card expiry on the result).
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. payment_method 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
// Where the bank returns the buyer after a 3-D Secure challenge. Without
// it, a card that needs a challenge is refused.
return_url: `https://yourstore.com/checkout/return?order=${req.body.order_id}`,
}),
});
const intent = await response.json();
// On requires_action, hand the browser the bank's challenge URL. requires_action
// with no next_action is still processing: do not retry, wait for the webhook.
const redirect = intent.next_action?.type === "redirect_to_url" ? intent.next_action.redirect_to_url.url : null;
res.json({ intent_id: intent.id, status: intent.status, redirect });
});
The browser half, the chargeOnYourServer your Pay handler calls in Step 4 (orderId is your own order reference):
async function chargeOnYourServer(token) {
const res = await fetch("/api/create-payment-intent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ payment_method: token, order_id: orderId }),
});
const body = await res.json();
if (body.redirect) vora.redirectToChallenge(body.redirect); // 3-D Secure: the bank's page
}
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 3-D Secure challenge before it settles: send the buyer to next_action.redirect_to_url.url, which is why the create call sets return_url. chargeOnYourServer above calls vora.redirectToChallenge(body.redirect) when redirect is present; it checks the link is a genuine bank challenge before sending the buyer: 3D Secure & SCA.
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. Confirm settlement server-side via the webhook before fulfilling; one payment emits both charge.succeeded and payment_intent.succeeded, so dedupe on the event envelope's id (vp_evt_*) and make fulfilment idempotent per session — Reconciliation. Treat 409 session_already_completed as already paid or being paid right now; do not create a new session, and read the outcome from the webhook rather than assuming success.
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.
Variant — discrete card fields
To lay out the three card inputs independently, swap the single card element for card-number, card-expiry and card-cvc. Only Step 3 changes; you get the same vp_pmt_* from submit().
<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>
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; branch the result exactly as in Step 4. Three rules (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 on a supporting binder — otherwise .mount() throws frame_unsupported_element, so fall back to the combined card.
What's next
- Element reference — every element type, options, theming, and the
SubmitResultshape - Accept Apple Pay & Google Pay — standalone wallet buttons on the same session
- Render modes: embed vs elements — the two-mode model and the capability matrix
- Tokenization — the
vp_pmt_*reusability model (saved cards, MIT) - Payment Intents — the server-side charge / capture / refund lifecycle