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 — it can only authenticate the public endpoints (/v1/public/sessions/:id, /v1/public/binder-load, /v1/public/tokens).
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 webhook before fulfilling the order.
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):
// server/api/create-session.ts (Node example)
import express from "express";
const app = express();
app.use(express.json());
const SECRET = process.env.VONPAY_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",
}),
});
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.
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
});
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.
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.binder, …
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" },
},
placeholder: { number: "4242 4242 4242 4242" },
});
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.
vora.fields.create("card") + card.tokenize() is deprecatedThe single-field path — vora.fields.create("card") then card.tokenize() — is @deprecated. Use the canonical collection path shown above: const elements = vora.elements.create(); const card = elements.create("card", {}); card.mount("#card-element"); const result = await elements.submit();. On a single-embed binder the legacy path now refuses with error code frame_method_not_supported_for_session rather than tokenizing. The concept still exists for legacy integrations, but new code should use elements.submit().
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_failed") {
alert("3DS authentication failed. Try a different card.");
} else {
console.error(result.error);
}
return;
}
if (result.token) {
// A buyer was on the session → reusable vp_pmt_* token.
// On charge-and-save the card is already charged (skip the server charge);
// on tokenize-only you charge it server-side. result.token === "vp_pmt_test_…"
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,
}),
});
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
| Card | Outcome |
|---|---|
4242 4242 4242 4242 | Tokenizes; no 3DS challenge |
4000 0025 0000 3155 | 3DS2 / SCA always-authenticate — recommended for testing the VORA 3DS modal |
4000 0000 0000 9995 | Tokenizes; charge declines at payment_intents.create |
Any future expiry, any 3-digit CVC. See Reference → Test cards for the full matrix including decline codes, 3DS-required cards, and per-processor test PANs.
Working sample — frame-react
The complete end-to-end integration lives at samples/frame-react in the SDK repo. It demos all five live elements (card + email + cardholder + address + save-for-future-use) plus the 3DS modal harmonization with a VORA-modal toggle:
- 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-sideVONPAY_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, Address, Email, Cardholder, Save-for-future-use; Apple Pay & Google Pay (built into the card mount); Saved methods (coming soon)
- 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