Custom checkout with Elements
Vora Elements lets you build a fully custom checkout: discrete card fields you position and style yourself — and, optionally, standalone wallet buttons — instead of the single drop-in Embedded Fields embed that owns the whole payment surface.
You opt in with one field when you create the session — integrationMode: "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-specific charge flow.
Discrete card fields are available where your account's gateway supports the Elements integration mode. Your account's capability — and the canonical list of supported surfaces — is reported at GET /.well-known/vonpay.json (embedded.supported_binders) and in the capability map returned by vora.sessions.retrieve(). Where Elements isn't available, the session falls back to the standard embed — the integrationMode echoed back on the session tells you which surface you got — and 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 discrete `card` element 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 gateway's 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.VONPAY_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's gateway 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
});
// Fetch the session id from your server (Step 1), then:
await vora.sessions.retrieve(sessionId); // loads binder routing for this session
vora.sessions.retrieve() resolves the gateway and loads the matching field adapter for you — your browser code never names or selects a processor.
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 same color / font / border tokens across every gateway). 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 {
// No token and no error → the session was not created with
// integrationMode: "elements". Recheck Step 1.
}
});
SubmitResult is one flat object (every field optional) — branch error → token. 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.VONPAY_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();
res.json({ intent_id: intent.id, status: intent.status, next_action: intent.next_action ?? 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. Resolve the intent's next_action (or, on gateways that support client-side confirmation, forward the client_confirm block back to the SDK). See 3D Secure & SCA for the full server-driven flow.
The client-side result is a UX signal only. Always confirm settlement server-side via the payment_intent.succeeded / charge.succeeded webhook before fulfilling the order.
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.
What's next
- Elements reference — every element type, options, theming, and the
SubmitResultshape - Accept Apple Pay & Google Pay — standalone wallet buttons on the same session
- Advanced — binder profiles — how discrete fields render across gateway profiles
- Tokenization — the
vp_pmt_*reusability model (saved cards, MIT) - Payment Intents — the server-side charge / capture / refund lifecycle