Embedded Fields — Using React
@vonpay/vora-react is a thin React wrapper around @vonpay/vora-js that handles provider lifecycle, session retrieval, and field registration via React-idiomatic primitives.
If you're not on React, use @vonpay/vora-js directly. The React package adds no new capability — only ergonomics.
Install
@vonpay/vora-js and @vonpay/vora-react are not on public npm todayThe packages are internal-only and npm install will 404. The supported path is loading the CDN script from https://js.vonpay.com/v1/vora.js and using a thin local wrapper around the global Vora constructor — see Embedded Fields quickstart for the supported, non-React flow. A typed npm release will follow; until then, treat this page as the future API.
npm install @vonpay/vora-js @vonpay/vora-react
@vonpay/vora-js is a peer dependency. The React package re-exports VoraMirrorError, isVoraMirrorError, and the VoraConfig / VoraSession / VoraFieldStyle / TokenizeOptions / TokenizeResult types so you don't have to import both. The legacy FrameError / isFrameError names are preserved as deprecated aliases through v1.x — existing imports keep working.
The re-exported TokenizeResult is a three-arm discriminated union — exactly one arm is present per result:
type TokenizeResult =
| { token: string; /* … */ } // token-success — a reusable vp_pmt_* was vaulted (card also charged in the charge-and-save flow)
| { charged: true; /* … */ } // charged-success — guest charge-only submission, no reusable token
| { error: VoraMirrorError }; // error — tokenize failed pre-charge
Discriminate with if (result.error) … else if (result.token) … else if (result.charged) …. SubmitResult from the elements collection uses the same branching discipline, though its type is a flat all-optional interface rather than a strict union — and its setupForFutureUse is a boolean, where TokenizeResult's is the SetupForFutureUse enum (see below).
<VoraProvider> — Owns the Vora client
Wrap your checkout subtree with <VoraProvider>. It constructs the underlying Vora instance, retrieves the session, and exposes context to useVora() consumers.
import { VoraProvider, CardField, useVora } from "@vonpay/vora-react";
function App() {
const [sessionId, setSessionId] = React.useState<string | null>(null);
React.useEffect(() => {
fetch("/api/create-session", { method: "POST" })
.then((r) => r.json())
.then(({ session_id }) => setSessionId(session_id));
}, []);
if (!sessionId) return <p>Loading checkout…</p>;
return (
<VoraProvider
publishableKey={import.meta.env.VITE_VONPAY_PUBLISHABLE_KEY}
sessionId={sessionId}
>
<Checkout />
</VoraProvider>
);
}
Props
interface VoraProviderProps {
publishableKey: string; // required — vp_pk_test_* or vp_pk_live_*
sessionId?: string | null; // optional at first render; null/undefined holds the client without retrieving
apiVersion?: string; // optional — pin the API version
apiBaseUrl?: string; // optional — defaults to https://checkout.vonpay.com
locale?: string; // BCP-47, en-only today
telemetry?: boolean; // optional — false = full opt-out (failure + load-timing)
performanceSampleRate?: number; // optional — 0..1 sampling for load-timing telemetry (default 1; 0 silences it)
onError?: (err: VoraMirrorError) => void; // surfaces session-retrieve / adapter-load failures
onSessionReady?: (session: VoraSession) => void; // fires once per successful retrieve (same useCallback caveat as onError)
children?: React.ReactNode;
}
Telemetry. The SDK sends fire-and-forget, PII-free telemetry (failure events + load timings — no card data, buyer identifiers, or session IDs). Failure telemetry defaults on for
vp_pk_test_*/ off forvp_pk_live_*; load-timing telemetry defaults on for both key types. Passtelemetry={false}for a full opt-out, orperformanceSampleRate={0}to keep failure telemetry while silencing load timings. See the SDK quickstart for the full description.
Lifecycle
- On first render,
<VoraProvider>constructs aVorainstance frompublishableKey(validates the key shape; asserts secure context). - When
sessionIdis non-null, the provider callsvora.sessions.retrieve(sessionId), which lazy-loads the binder adapter chunk. - On adapter-load success,
readyflips totrueanduseVora()consumers can calltokenize()etc. - The
Vorainstance is stable for the provider's lifetime. Re-mounting the provider creates a fresh instance.
Don't toggle
publishableKeyafter first render. The provider doesn't replace the underlyingVorainstance when the prop changes; the merchant pattern is "one provider per checkout page lifetime." If you need to swap keys, unmount + remount the provider.
<CardField> — Card iframe (deprecated single-field path)
@deprecated.
<CardField>wraps the deprecatedvora.fields.create('card', ...)single-field path that pre-dates the elements collection API. That path now refuses on a single-embed binder —tokenize()rejects with codeframe_method_not_supported_for_session. Use the canonical collection path instead:const elements = vora.elements.create(); const card = elements.create("card", {}); card.mount("#card-element"); const result = await elements.submit();— see the imperative API below. This component is documented for legacy integrations; new checkouts should mount the card through the elements collection.
Mounts the adapter-served card iframe via the legacy single-field path. For checkouts that also mount email or save-for-future-use, use the imperative API below.
import { CardField, useVora } from "@vonpay/vora-react";
function Checkout() {
// `<CardField>` exposes no per-field event props — read readiness and the
// current error from the useVora() hook instead.
const { ready, error } = useVora();
return (
<form onSubmit={handleSubmit}>
<CardField
style={{
font: { size: "16px", family: "system-ui" },
color: { text: "#1a1a1a", placeholder: "#9ca3af" },
}}
placeholder={{ number: "4242 4242 4242 4242" }}
/>
{error && <p role="alert">{error.message}</p>}
<button type="submit" disabled={!ready}>Pay</button>
</form>
);
}
Props
interface CardFieldProps {
style?: VoraFieldStyle;
classes?: { focus?: string; invalid?: string; complete?: string };
placeholder?: { number?: string; expiry?: string; cvc?: string };
className?: string; // container-element class — does NOT pass through to the binder iframe
id?: string; // container-element id — does NOT pass through to the binder iframe
}
<CardField> has no onReady / onChange / onFocus / onBlur props — track readiness and validation through the useVora() hook's ready and error values.
The new elements (
address,cardholder,save-for-future-use) are mounted via the imperative API below, not via dedicated React components — that's whatsamples/frame-reactdoes.For card itself, prefer the elements collection over
<CardField>: the collection path (elements.create()→collection.create("card")→collection.submit()) is the canonical, non-deprecated way to mount and charge.
Imperative API for the elements collection
The elements collection API is mounted imperatively from a useEffect. This is the supported contract for multi-element checkouts:
On the default binder the
cardmount is your whole payment surface; the cardholder name and billing address are collected inside it. Mountingcardholderandaddressas separate fields (as the example below does) applies on the direct-card binder profile — see Advanced — binder profiles. On the default binder those two mounts have no visible effect, and the example still works.
import * as React from "react";
import { Vora, isVoraMirrorError } from "@vonpay/vora-js";
import type { ElementsCollection, SubmitResult } from "@vonpay/vora-js";
function Checkout({ sessionId }: { sessionId: string }) {
const [status, setStatus] = React.useState<"loading" | "ready" | "submitting" | "submitted" | "error">("loading");
const [result, setResult] = React.useState<SubmitResult | null>(null);
// Mount targets
const cardRef = React.useRef<HTMLDivElement>(null);
const emailRef = React.useRef<HTMLDivElement>(null);
const cardholderRef = React.useRef<HTMLDivElement>(null);
const addressRef = React.useRef<HTMLDivElement>(null);
const saveRef = React.useRef<HTMLDivElement>(null);
// Hold the collection across renders so submit() can call into the same instance
const collectionRef = React.useRef<ElementsCollection | null>(null);
React.useEffect(() => {
setStatus("loading");
let cancelled = false;
let collection: ElementsCollection | null = null;
(async () => {
try {
const vora = new Vora({ publishableKey: import.meta.env.VITE_VONPAY_PUBLISHABLE_KEY });
await vora.sessions.retrieve(sessionId);
if (cancelled) return;
collection = vora.elements.create();
collectionRef.current = collection;
const card = collection.create("card", {
style: { color: { text: "#1f2937" }, font: { family: "system-ui", size: "16px" } },
});
const email = collection.create("email", { placeholder: "you@example.com" });
const cardholder = collection.create("cardholder", { placeholder: "Name on card" });
const address = collection.create("address", { mode: "billing" });
const save = collection.create("save-for-future-use", {});
card.mount(cardRef.current!);
email.mount(emailRef.current!);
cardholder.mount(cardholderRef.current!);
address.mount(addressRef.current!);
save.mount(saveRef.current!);
if (!cancelled) setStatus("ready");
} catch (err) {
if (!cancelled) setStatus("error");
}
})();
return () => {
cancelled = true;
collection?.unmount();
collectionRef.current = null;
};
}, [sessionId]);
const onPay = React.useCallback(async () => {
const collection = collectionRef.current;
if (collection === null) return;
setStatus("submitting");
try {
const result = await collection.submit();
if (result.error !== undefined) {
setStatus("error");
return;
}
setResult(result);
setStatus("submitted");
if (result.token !== undefined) {
// A reusable vp_pmt_* was vaulted. On the charge-and-save flow the card
// is already charged in the same step — do NOT post to /v1/payment_intents
// (that double-charges). Persist the token for later off-session reuse.
await saveToken(result.token);
} else if (result.charged === true) {
// Guest charge-only submission: charge is done, no reusable token.
// Nothing to send to the server here.
}
// The client result is a UX signal. Confirm settlement via the webhook
// before fulfilling the order.
} catch (err) {
setStatus("error");
}
}, []);
return (
<form onSubmit={(e) => { e.preventDefault(); onPay(); }}>
<label>Card<div ref={cardRef} /></label>
<label>Email<div ref={emailRef} /></label>
<label>Name on card<div ref={cardholderRef} /></label>
<label>Billing address<div ref={addressRef} /></label>
<div ref={saveRef} />
<button type="submit" disabled={status !== "ready"}>Pay</button>
</form>
);
}
SubmitResult shape
interface SubmitResult {
token?: string; // vp_pmt_* — present when a reusable method was vaulted
charged?: true; // present on a guest charge-only submission (no vaulted token)
last4?: string;
brand?: string;
email?: string; // if email element mounted
cardholder?: string; // cardholder name (plain string) — if cardholder element mounted
billingAddress?: BillingAddressValue; // if address element mounted
setupForFutureUse?: boolean; // true when the buyer opted to save — if save-for-future-use element mounted
error?: VoraMirrorError; // present when submit failed pre-tokenize
}
result.error covers the pre-tokenize failure mode (validation: required field empty, malformed input). SubmitResult is one flat interface — every field is optional (no named union-arm types); branch at runtime, where exactly one of error, token, or charged is populated. token present ⇒ a reusable vp_pmt_* method was vaulted (and, in the charge-and-save flow, the card was also charged in the same step); charged present ⇒ a guest charge-only submission that completed the charge with no reusable token; error present ⇒ submit failed. Discriminate with if (result.error) … else if (result.token) … else if (result.charged) …. Network failures / unhandled binder rejections still throw — wrap submit() in try/catch to handle both paths.
Lifecycle gotchas
- Effect cleanup matters. The return from
useEffectcallscollection.unmount()to tear down all elements. Without it, re-mounting the form (e.g., whensessionIdchanges) leaks DOM nodes + binder iframes. - Toggling options re-mounts. If your effect deps include element options like
disable3dsModal, flipping the option re-runs setup. Synchronously setstatus: "loading"at the top of the effect to gate the submit button during the cleanup-then-setup window. - Differentiate setup-error vs submit-error. Errors during element mount (binder load failure) are non-retryable — the form needs a fresh mount. Errors during submit (validation, tokenization) are retryable — the user can fix and click Pay again. Keep this distinction in your error state so the Pay button stays disabled on setup errors and re-enables on submit errors.
The full reference for these patterns lives in samples/frame-react/src/App.tsx.
useVora() — Hook surface
Returns the active session state plus the three async action methods.
interface UseVoraResult {
ready: boolean;
error: VoraMirrorError | null;
tokenize: (options?: TokenizeOptions) => Promise<TokenizeResult>;
confirmPaymentIntent: (intent: PaymentIntentRef) => Promise<ConfirmResult>;
handleAction: (action: unknown) => Promise<ConfirmResult>;
}
Pattern
function PayButton() {
const { tokenize, ready, error } = useVora();
const [submitting, setSubmitting] = React.useState(false);
if (error) return <p>Checkout unavailable: {error.message}</p>;
if (!ready) return <p>Loading payment form…</p>;
return (
<button
type="submit"
disabled={submitting}
onClick={async (e) => {
e.preventDefault();
setSubmitting(true);
try {
const result = await tokenize();
if (result.error) {
alert(result.error.message);
} else if (result.token) {
// Reusable vp_pmt_* vaulted. On the charge-and-save flow the card is
// already charged here — do NOT also post to /v1/payment_intents.
await saveToken(result.token);
} else if (result.charged) {
// Guest charge-only: charge complete, no reusable token, nothing to send.
}
// Treat the result as a UX signal; confirm settlement via the webhook
// before fulfilling.
} catch (err) {
alert(err.message);
} finally {
setSubmitting(false);
}
}}
>
Pay
</button>
);
}
Synchronous-throw loading-state contract
Calling tokenize(), confirmPaymentIntent(), or handleAction() before ready === true throws frame_session_not_ready synchronously — not as a rejected promise.
This is by design. Misuse (e.g. wiring a submit button before the field is mounted) fails loud at the call site, not asynchronously where it might leave the merchant's submit button hung.
The pattern that protects against this is "render the submit button only when ready === true":
if (!ready) return <Skeleton />;
return <button onClick={() => tokenize()}>Pay</button>;
3DS — confirmPaymentIntent + handleAction
For the inline 3DS flow (server-driven confirm), use confirmPaymentIntent:
const { confirmPaymentIntent } = useVora();
async function handleSubmit() {
// 1. Tokenize — every SubmitResult field is optional; branch error->token->charged, never read result.token unconditionally
// unconditionally. The 3DS-confirm flow requires a reusable vp_pmt_* token, so
// handle the error and guest (charged, no token) arms before proceeding.
const result = await tokenize();
if (result.error) {
// Tokenize failed before any charge — surface and stop.
alert(result.error.message);
return;
}
if (result.charged) {
// Guest / no buyer on the session: a one-time charge already completed and no
// reusable token exists, so there's nothing to confirm a payment intent with.
return;
}
// result.token present ⇒ a buyer was on the session → reusable vp_pmt_*.
const token = result.token;
// 2. Server creates a manual-confirm payment intent
const intent = await fetch("/api/create-intent", {
method: "POST",
body: JSON.stringify({ payment_method: token }),
}).then((r) => r.json());
// intent: { id, status: "requires_action", clientSecret, … }
// 3. The SDK handles the 3DS challenge
const result = await confirmPaymentIntent(intent);
// result.status: "succeeded" | "requires_action" | "failed"
}
For the "next action" flow where your server returns a nextAction object directly, use handleAction:
const result = await handleAction(intent.nextAction);
Both run the binder's challenge UI inside Embedded Fields' harmonized 3DS modal (see disable3dsModal on the card element options to opt out of harmonization).
Error handling in React
error from useVora() is the most-recent VoraMirrorError. The provider also accepts onError for callback-style handling:
<VoraProvider
publishableKey={…}
sessionId={…}
onError={(err) => {
if (err.code === "frame_session_expired") {
// Refresh session
} else {
Sentry.captureException(err);
}
}}
>
…
</VoraProvider>
See the errors page for the full code union and recovery semantics.
What's next
- Elements reference — every element's options and value shapes
- Error handling —
VoraMirrorErrorshape and recovery patterns - Working sample —
frame-reactend-to-end reference