Embedded Fields — Quickstart
Get a working Embedded Fields integration in ~10 minutes. Card-only happy path; advanced elements covered on the Elements reference page.
Embedded Fields is the embedded path — buyers stay on your domain; sensitive card data never touches your servers; tokenization happens inside provider-issued iframes mounted via the @vonpay/vora-js browser SDK. Compare with Redirect to checkout (hosted checkout — buyer goes to checkout.vonpay.com) which is faster to integrate but gives less branding control.
Architecture in one diagram
The buyer's browser never sees your secret key. The publishable key (vp_pk_*) is safe to embed in JavaScript bundles because it cannot reach your merchant API — it authenticates only the browser-facing /v1/public/* routes. The flow above uses three of them (/v1/public/sessions/:id, /v1/public/binder-load, /v1/public/tokens), but that is not the whole set: on a charge-and-save session the SDK also calls the charge route described below. See API Key Types for every route a publishable key unlocks.
The diagram above (steps 6–8) shows the tokenize-only flow: submit returns a vp_pmt_* token and your server charges it via POST /v1/payment_intents.
There is a second flow — embedded charge-and-save — where submit already charges the card (and, with a buyer on the session, vaults a reusable token in the same step). On a charge-and-save session you must skip step 8 entirely: do not call POST /v1/payment_intents, or you will charge the buyer twice. Confirm settlement via the settlement webhook instead.
The submit result tells you which flow ran: result.charged === true means the embed already charged (skip the server charge), result.token means you have a token to charge server-side. Always discriminate the result before wiring the charge call — see Step 6 and the Charge-and-save flow guide.
The client-side result is a UX signal only — confirm settlement server-side via the settlement webhook before fulfilling the order. A timeout on submit can land after the charge succeeds, so never mark the order failed on a client error or resubmit blindly — see Reconciliation for the duplicate-safe pattern.
Step 0 — Get your test keys
If you haven't already, see Quickstart §0. For embedded checkout you need both:
vp_sk_test_*— secret key (your server)vp_pk_test_*— publishable key (your browser bundle)
Both are available in the developer dashboard at app.vonpay.com/dashboard/developers.
Step 1 — Install the browser SDK
@vonpay/vora-js is not on the public npm registry todayThe browser SDK ships from js.vonpay.com as a <script> tag. Don't run npm install @vonpay/vora-js — the package is internal-only right now (npm view @vonpay/vora-js returns 404) and there is no ETA for publishing. The CDN below is the supported delivery channel.
If you're working in a bundler (Vite / Next.js / webpack) and want a typed import, declare a Window augmentation against the global Vora constructor the script attaches. A typed @vonpay/vora-js npm package will follow — until then, treat the CDN script as the contract.
The card field on a Embedded Fields page is rendered inside a Vonpay-owned iframe. Vonpay picks the underlying processor server-side via /v1/public/binder-load — your integration code does not pick it and must not import a processor's own SDK.
Direct processor-SDK usage breaks three things: (1) the day your account is routed to a different binder, your page breaks; (2) a processor SDK returns that processor's own payment-method tokens, not Vonpay vp_pmt_* tokens — POST /v1/payment_intents will reject them; (3) you lose Vonpay's 3DS / wallet handling. If a guide or an AI agent suggests adding a processor SDK package for embedded checkout, ignore it. The CDN script below is everything you need.
CDN (recommended — supported today)
<script src="https://js.vonpay.com/v1/vora.js" crossorigin="anonymous"></script>
The /v1/vora.js channel auto-updates within the v1 major; integrity is verified continuously by Von Payments. To pin a specific version with browser-enforced Subresource Integrity instead:
<script
src="https://js.vonpay.com/vX.Y.Z/vora.js"
integrity="sha384-<copy-the-current-hash-from-integrity.json>"
crossorigin="anonymous"></script>
vX.Y.Z is a placeholder — vora.js ships new versions regularly. Pull BOTH the current version number and its matching integrity hash from the published registry at js.vonpay.com/integrity.json; the hash is byte-exact to that build, so copy both verbatim or the browser will refuse to execute the script. (Most integrations don't pin at all — the /v1/vora.js channel above stays current automatically.)
Full guidance on the two channels — what each one buys you, the published registry at js.vonpay.com/integrity.json, and how to automate version bumps — lives at Script-tag integration (SRI).
Step 2 — Create a session on your server
A VORA session is the unit of one buyer's checkout attempt. Your server creates it with the secret key (the secret key never reaches the browser):
When to create it: do this when the buyer is ready to check out (opens the cart / clicks Pay), not on every pageview — the session has a 30-minute TTL, so minting one per page just fills up with expired, unpaid sessions. See When to create the session.
// server/api/create-session.ts (Node example)
import express from "express";
const app = express();
app.use(express.json());
const SECRET = process.env.VON_PAY_SECRET_KEY; // vp_sk_test_*
const API = process.env.VONPAY_API_BASE ?? "https://checkout.vonpay.com";
app.post("/api/create-session", async (req, res) => {
const response = await fetch(`${API}/v1/sessions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${SECRET}`,
},
body: JSON.stringify({
amount: 4999, // $49.99 in cents
currency: "USD",
successUrl: "https://mystore.com/thank-you",
cancelUrl: "https://mystore.com/cart",
// integrationMode defaults to "embed" (one combined card box).
// Set "elements" for discrete, individually-placed fields — see Custom checkout with Elements.
}),
});
const session = await response.json();
// session.id is vp_cs_test_* — safe to send to the browser.
res.json({ session_id: session.id });
});
Returned session ID format: vp_cs_test_* for sandbox keys, vp_cs_live_* for live keys.
This Quickstart uses the default
embedmode (it omitsintegrationMode) — a single drop-in card box that renders the whole payment surface. To place the card and other inputs as discrete elements you position yourself, create the session withintegrationMode: "elements"— see Custom checkout with Elements.
Step 3 — Initialize VORA in the browser
The CDN <script> from Step 1 attaches the Vora constructor to window — use it directly (no import, since @vonpay/vora-js is not on npm today):
// `Vora` is the global the CDN script attached (see Step 1).
const vora = new window.Vora({
publishableKey: "vp_pk_test_…", // your publishable key
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 2.
});
Bundler (Vite / Next.js / webpack)? Add the
Windowaugmentation from Step 1 —declare global { interface Window { Vora: typeof import("...").Vora } }against the CDN global — sowindow.Vorais typed. Don'timport { Vora } from "@vonpay/vora-js"; that module doesn't resolve on the CDN-only path.
Constructor validations:
publishableKeyis required and must start withvp_pk_test_orvp_pk_live_. Passing a secret key throws aTypeErrorimmediately — there's no way to leak a secret key through this surface.- The page must be served over HTTPS (or
localhostfor development). Insecure contexts throwframe_insecure_context. apiBaseUrlis optional and defaults tohttps://checkout.vonpay.com. It is not derived from your key, and your key's mode (_test_/_live_) does not select a host — both modes are served by the same host. If your browser and your server name different hosts, your server calls succeed and every browser call fails401 auth_invalid_key_publishable. See Which environment am I talking to?.
Telemetry (optional)
The SDK sends fire-and-forget, PII-free diagnostic telemetry to Vonpay — failure events (tokenize / binder-load errors) and load-timing measurements. The payload carries no card data, buyer identifiers, or session IDs: only error codes, the operation name, the SDK version, a coarse runtime token, and elapsed-millisecond timings. It exists to catch integration issues early and to improve load performance.
Defaults:
- Failure telemetry — on for
vp_pk_test_*keys (loud-mode for integration debugging), off forvp_pk_live_*. - Load-timing telemetry — on for both key types (it carries only timings).
Control it via the constructor:
const vora = new Vora({
publishableKey: "vp_pk_live_…",
telemetry: false, // full opt-out — disables BOTH failure and load-timing telemetry
// performanceSampleRate: 0, // alternatively, keep failure telemetry but silence load timings
// (0..1 sampling fraction; default 1)
});
Step 4 — Retrieve the session
This fetches /v1/public/sessions/:id and lazy-loads the matching binder adapter for your account's active card processor:
// Browser — fetch the session_id from your server first
const res = await fetch("/api/create-session", { method: "POST" });
const { session_id } = await res.json();
const session = await vora.sessions.retrieve(session_id);
// session.id, session.amount, session.currency, session.binderConfig (opaque), …
Binder selection happens server-side based on the merchant's gateway configuration — your code doesn't pick a binder.
Step 5 — Mount the card field
<div id="card-element"></div>
<button id="pay-button" disabled>Pay</button>
const elements = vora.elements.create();
const card = elements.create("card", {
style: {
font: { size: "16px", family: "system-ui, sans-serif" },
color: { text: "#1a1a1a", placeholder: "#9ca3af" },
},
// Exactly three keys are accepted: number, expiry, cvc.
// Any other key is ignored silently, leaving that field's default label.
placeholder: { number: "4242 4242 4242 4242", expiry: "MM / YY", cvc: "CVC" },
});
card.mount("#card-element");
// Unlock the pay button once the field is ready
card.on("ready", () => {
document.getElementById("pay-button").disabled = false;
});
card.on("change", (event) => {
// event.complete = true when number/expiry/cvc all valid
document.getElementById("pay-button").disabled = !event.complete;
});
The actual card iframe is served from the active processor's CDN and mounted inside #card-element. Card data never reaches your DOM or your server.
Heads-up — in the default integration mode (you didn't set integrationMode when creating the session in Step 2), this single card iframe is your entire payment surface. It renders card number / expiry / CVC together, and — where your account supports them — the cardholder-name field, billing-address fields, the payment-method picker, and any saved methods, all inside this one mount. The only elements you place separately are email and save-for-future-use. So don't try to mount cardholder, address, or payment-method-picker as their own fields on this page: in this mode they render inside the card iframe, and payment-method-picker throws at create time. (The Apple Pay / Google Pay buttons are part of this surface too — see the wallet note just below.) To lay those fields out yourself as discrete elements, see the Elements reference.
Apple Pay & Google Pay are built in. When the buyer's device supports a wallet and your domain is verified with the wallet network, the wallet button appears automatically inside this mount — no extra integration code, no separate element. See Elements reference → Apple Pay & Google Pay for the eligibility model and the
walletdiscriminator that lands on the submit result.
Step 6 — Tokenize on submit
The submit result is a flat object with three mutually-exclusive outcomes — token set, charged: true set, or error set — so branch on it rather than reading result.token unconditionally:
document.getElementById("pay-button").addEventListener("click", async () => {
const result = await elements.submit();
if (result.error) {
// Submit failed before any charge happened.
if (result.error.code === "frame_field_validation_failed") {
alert("Please check the card details.");
} else if (result.error.code === "frame_3ds_challenge_cancelled") {
// The buyer closed the bank's confirmation step. Nothing was charged.
// Abandoned, not failed — let them try again without an error message.
} else if (result.error.code === "frame_3ds_challenge_failed") {
alert("3DS authentication failed. Try a different card.");
} else {
console.error(result.error);
}
return;
}
if (result.token) {
// Tokenize-only flow (this quickstart): a reusable vp_pmt_* token — charge it
// on your server. result.token === "vp_pmt_test_…"
// NOTE: if your account is charge-and-save, the card is ALREADY charged here —
// skip this server charge and confirm via the webhook instead (see Charge-and-save).
await chargeServer(result.token);
return;
}
if (result.charged) {
// GUEST / no buyer → one-time charge, NO token. The embed ALREADY charged on submit.
// Do NOT call chargeServer / POST /v1/payment_intents — that double-charges.
// Confirm settlement via the webhook before fulfilling. Show a pending state.
showAwaitingConfirmation();
}
});
result.charged and result.token are mutually exclusive: a guest (no-buyer) charge-and-save session resolves with { charged: true } and no token; a tokenize-only session resolves with { token }. Whichever arm you land in, the client result is a UX signal — confirm settlement via the settlement webhook before fulfilling. See the Charge-and-save flow guide for the full model.
vp_pmt_* is a VORA payment-method token. On the tokenize-only flow it's single-use by default and bound to the session that minted it — your server charges it once via POST /v1/payment_intents. On the charge-and-save flow the rules differ: a guest session returns no token at all ({ charged: true }), and a with-buyer session returns a reusable token whose card was already charged at submit — so do not charge it again. See the Charge-and-save flow guide.
Need reusable tokens (for upsells, subscriptions, one-click reorder)? See Tokenization — that page covers the full model: the two token shapes, consent flow, reuse scenarios, and what flows through to your downstream
payment_intentscalls.
Pass it to your server:
async function chargeServer(token) {
await fetch("/api/charge", {
method: "POST",
body: JSON.stringify({ payment_method: token }),
});
}
Step 7 — Charge with the token
On your server, the token becomes the payment_method on a payment intent:
// server/api/charge.ts
app.post("/api/charge", async (req, res) => {
const { payment_method } = req.body;
const response = await fetch(`${API}/v1/payment_intents`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${SECRET}`,
},
body: JSON.stringify({
amount: 4999,
currency: "USD",
payment_method: { id: payment_method }, // object form — { id: "vp_pmt_..." }
}),
});
const intent = await response.json();
// Two outcomes:
// 1. intent.status === "succeeded" — funds captured, you're done.
// 2. intent.status === "requires_action" — the issuer wants 3DS.
// intent.client_confirm carries a binder-issued challenge token.
// Forward it to the browser; the SDK renders the 3DS modal.
if (intent.status === "requires_action" && intent.client_confirm) {
return res.json({
id: intent.id,
status: intent.status,
action: intent.client_confirm, // forward as-is to vora.js
});
}
res.json({ id: intent.id, status: intent.status });
});
For 3DS-required cards, the browser passes intent.action back into collection.submit({ paymentIntent }) to drive the modal. Full walkthrough on the 3D Secure page.
See /v1/payment_intents for the full server-side reference (capture, refund, void, MIT, etc.).
Test cards
For the happy path, use 4242 4242 4242 4242 (tokenizes, no 3DS challenge) with any future expiry and any 3-digit CVC. The embedded sandbox locks down to a fixed set of numbers — the single source of truth is Reference → Test cards, which lists every card, its outcome, the webhook event it fires, decline codes, and the 3DS-required cards.
Working sample — frame-react
The complete end-to-end integration lives at samples/frame-react in the SDK repo. Like this Quickstart it creates the session in the default embed mode, where the card box renders the whole payment surface together. It mounts card, email, and save-for-future-use, and — where your account supports discrete rendering — cardholder and address as separate fields (in default embed mode these collect inside the card box rather than at their own mount points). It also includes the 3DS modal harmonization with a VORA-modal toggle. See Elements reference for the per-element status and Custom checkout with Elements for the discrete elements mode:
- An Express stub server (
server/index.ts) forPOST /api/create-session - A React app using the imperative
vora.elements.create()API (the multi-element collection API isn't yet wrapped invora-reactcomponents — see the React page for what's available there today) - A Vite dev setup with
.envseparating server-sideVON_PAY_SECRET_KEYfrom browser-sideVITE_VONPAY_PUBLISHABLE_KEY
Clone, cp .env.example .env, fill in your keys, pnpm dev. The sample's README carries the canonical test recipe — what to verify when each element mounts, validation behavior, the 3DS modal-vs-native-sheet comparison, and the production-wiring checklist.
What's next
- Elements reference —
card,email,save-for-future-use,express-checkout, andpayment-method-picker(live); cardholder name & billing address (collected inside the card box in the defaultembedmode; separate fields only inelementsmode); Apple Pay & Google Pay (built into the card mount); saved methods (not usable yet) - Using React —
<VoraProvider>,<CardField>,useVora()patterns - Error handling —
VoraMirrorErrorcodes, retry semantics, self-heal envelope /v1/payment_intentsreference — server-side payment lifecycle- Webhooks — async settlement events