Embedded Fields — Quickstart
A working card-only Embedded Fields integration: the buyer stays on your domain, card data never touches your server, and tokenization happens inside an iframe mounted by the browser SDK. The alternative is hosted checkout, where the buyer is redirected to checkout.vonpay.com.
Architecture in one diagram
The browser never sees your secret key: the publishable key (vp_pk_*) authenticates only the browser-facing /v1/public/* routes (API Key Types).
submit() can already have charged — read the result before you charge server-sideThe diagram is the tokenize-only flow. On a charge-and-save session the embed charges the card at submit (and, with a buyer on the session, vaults a reusable token in the same step): result.charged === true or a token on a charge-and-save session means the money has moved — skip Step 7 or you charge the buyer twice, and confirm settlement via the webhook. See Charge-and-save.
Step 0 — Get your test keys
You need both keys from app.vonpay.com/dashboard/developers: vp_sk_test_* (your server) and vp_pk_test_* (your browser bundle). See Quickstart §0.
Step 1 — Load the browser SDK
<script src="https://js.vonpay.com/v1/vora.js" crossorigin="anonymous"></script>
vora.js is delivered from the CDN only — @vonpay/vora-js is not on npm — and it is the only script you load: the processor is selected server-side, and a processor SDK's own tokens are rejected by POST /v1/payment_intents. To pin a version with Subresource Integrity, see Script-tag integration (SRI).
Step 2 — Create a session on your server
A session is one buyer's checkout attempt; create it when the buyer is ready to pay, not on every page view (it has a 30-minute TTL — when to create it). Your server creates it with the secret key:
// 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 });
});
The session id is vp_cs_test_* for sandbox keys, vp_cs_live_* for live keys. This Quickstart omits integrationMode, so the session renders in the default embed mode — one card box that renders the whole payment surface. For discrete, individually-placed fields create the session with integrationMode: "elements" (Custom checkout with Elements).
Step 3 — Initialize VORA in the browser
The script from Step 1 attaches the Vora constructor to window:
// `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.
});
Constructor validations:
publishableKeyis required and must start withvp_pk_test_orvp_pk_live_. A secret key throws aTypeErrorimmediately.- The page must be served over HTTPS (or
localhostfor development); an insecure context throwsframe_insecure_context. apiBaseUrldefaults tohttps://checkout.vonpay.comand is not derived from your key — both key 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(Which environment am I talking to?).
Typing window.Vora in a bundler
There is no package to import, so declare the global. Save this as vora.d.ts anywhere TypeScript picks up types:
export {};
declare global {
interface Window {
/** Attached by the CDN script in Step 1. */
Vora: new (config: {
/** Publishable key — `vp_pk_test_*` or `vp_pk_live_*`. */
publishableKey: string;
/** Defaults to https://checkout.vonpay.com. Must name the same host your
* server called in Step 2. */
apiBaseUrl?: string;
/** BCP-47 locale for field labels and validation messages. */
locale?: string;
/** API version pin; defaults to current. */
apiVersion?: string;
/** Set `false` to opt out of load-timing telemetry, which is ON by
* default for both key types. */
telemetry?: boolean;
/** Fraction of loads to sample for performance timing. */
performanceSampleRate?: number;
}) => VoraInstance;
}
}
/** Deliberately open. Copying the full instance surface into your project would
* drift from the CDN build the first time we ship one. */
type VoraInstance = Record<string, any>;
publishableKey is typed string, as in the SDK; the constructor's runtime check is the guard.
Telemetry (optional)
The SDK sends fire-and-forget, PII-free diagnostics — failure events (tokenize / binder-load errors) and load timings: error codes, the operation name, the SDK version and elapsed milliseconds, never card data, buyer identifiers or session ids. Failure telemetry is on for vp_pk_test_* keys and off for vp_pk_live_*; load-timing telemetry is on for both.
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 binder adapter for your account's active card processor — your code does not pick a binder:
// 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), …
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 not applied — the SDK logs a console.warn naming these three.
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;
});
Both style and placeholder are optional: the SDK applies its own default look and your values override it property by property (Default style). Style the box around the field from your own stylesheet — the SDK stamps vora-field on the container you mounted into, plus vora-field--focus, --invalid and --complete as the field's state changes inside the iframe:
.vora-field {
box-sizing: border-box;
min-height: 44px;
padding: 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
background: #fff;
transition: border-color 0.15s, box-shadow 0.15s;
}
.vora-field--focus { border-color: #2563eb; box-shadow: 0 0 0 3px rgb(37 99 235 / 0.15); }
.vora-field--invalid { border-color: #b91c1c; }
.vora-field--complete { border-color: #059669; }
The full class list is on Customize the look → The container's own classes.
In the default embed mode this one iframe is your entire payment surface: card number / expiry / CVC together and — where your account supports them — cardholder name, billing address, the payment-method picker, saved methods and the Apple Pay / Google Pay buttons (when the device and your domain are eligible — eligibility model). The only elements you place separately are email and save-for-future-use; the card mount already collects the name and address, so mounting cardholder or address as well renders a second field, and payment-method-picker throws at create time. To lay those out yourself, see the Elements reference.
Step 6 — Tokenize on submit
Branch on the result — error, then chargeStatus where the session can charge at submit, then token / charged — 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);
}
// A client-side error is not proof that nothing was charged — a timeout can
// land after the charge succeeded. Never mark the order failed on it and
// never start a new session blindly; reconcile via the webhook.
return;
}
// On a charge-at-submit session (elements mode) read result.chargeStatus
// BEFORE token / charged — see Charge at submit. It is absent on this flow.
if (result.token) {
// Tokenize-only flow (this quickstart): a vp_pmt_* token — charge it on your
// server. On a charge-and-save session the card is ALREADY charged here:
// skip the server charge and confirm via the webhook instead.
await chargeServer(result.token, yourOrderId);
return;
}
if (result.charged) {
// Guest charge-and-save: the embed ALREADY charged on submit, no token.
// Do NOT call POST /v1/payment_intents — that double-charges.
// Confirm settlement via the webhook before fulfilling.
showAwaitingConfirmation();
}
});
A vp_pmt_* token on the tokenize-only flow is single-use by default and bound to the session that minted it — your server charges it once. On a charge-and-save session a guest submit returns { charged: true } with no token and a with-buyer submit returns a reusable token whose card was already charged. Whichever arm you land in, the client result is a UX signal — confirm settlement via the webhook before fulfilling. Reusable tokens, consent and reuse rules: Tokenization.
Pass the token to your server:
async function chargeServer(token, orderId) {
const res = await fetch("/api/charge", {
method: "POST",
headers: { "Content-Type": "application/json" },
// Send your own order reference too — the server keys the charge's
// Idempotency-Key off it (Step 7), so a retry collapses instead of
// charging twice.
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
}
Step 7 — Charge with the token
On your server the token becomes the payment_method on a payment intent.
Idempotency-KeyFrom the 28 October 2026 cut-off it is required on every charge, and a keyless charge can be refused with 400 idempotency_key_required; some connections already refuse one — and on every connection it is what turns a timed-out retry into a replay instead of a second charge. Key it to the operation (order_123_charge) and send the same value on every retry. See Idempotency.
// server/api/charge.ts
app.post("/api/charge", async (req, res) => {
const { payment_method, order_id } = req.body;
const response = await fetch(`${API}/v1/payment_intents`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${SECRET}`,
// Names the OPERATION, not the attempt. Reuse this exact value on every
// retry of this charge — that is what collapses a retry instead of
// charging twice. Never append an attempt counter.
"Idempotency-Key": `${order_id}_charge`,
},
body: JSON.stringify({
amount: 4999,
currency: "USD",
payment_method: { id: payment_method }, // object form — { id: "vp_pmt_..." }
// Where the bank sends the buyer back after a 3-D Secure challenge.
// Without it, a card that needs a challenge is refused.
return_url: `https://mystore.com/orders/${order_id}/return`,
}),
});
const intent = await response.json();
// intent.status === "requires_action": the bank wants 3-D Secure. Send the
// buyer to its page; they come back to return_url, and the webhook is the outcome.
if (intent.status === "requires_action" && intent.next_action?.type === "redirect_to_url") {
return res.json({
id: intent.id,
status: intent.status,
redirect: intent.next_action.redirect_to_url.url,
});
}
// requires_action with no next_action: still processing. Do not retry; wait
// for the webhook.
res.json({ id: intent.id, status: intent.status });
});
chargeServer above sends the buyer to redirect when it is present. Fulfil from the webhook, not the return: 3D Secure. Server-side lifecycle (capture, refund, void): /v1/payment_intents.
Test cards
In test mode your processor's test environment decides the outcome. Use one of the sandbox test cards (common numbers such as 4242 4242 4242 4242 are declined); with those, an ordinary total approves. Use a decline-trigger total to exercise the failure paths.
Before going live
Confirm that every element mounts, that validation fires as the buyer types, and, on your merchant account's test path, that a card needing 3-D Secure sends the buyer to the bank's page and back, and that the order settles from the webhook rather than the return. 3D Secure walks those paths.
What's next
- Elements reference — every element, its options and collect shape
- Error handling —
VoraMirrorErrorcodes and recovery /v1/payment_intentsreference — server-side payment lifecycle- Webhooks — async settlement events