Skip to main content

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 today

The 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 for vp_pk_live_*; load-timing telemetry defaults on for both key types. Pass telemetry={false} for a full opt-out, or performanceSampleRate={0} to keep failure telemetry while silencing load timings. See the SDK quickstart for the full description.

Lifecycle

  1. On first render, <VoraProvider> constructs a Vora instance from publishableKey (validates the key shape; asserts secure context).
  2. When sessionId is non-null, the provider calls vora.sessions.retrieve(sessionId), which lazy-loads the binder adapter chunk.
  3. On adapter-load success, ready flips to true and useVora() consumers can call tokenize() etc.
  4. The Vora instance is stable for the provider's lifetime. Re-mounting the provider creates a fresh instance.

Don't toggle publishableKey after first render. The provider doesn't replace the underlying Vora instance 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 deprecated vora.fields.create('card', ...) single-field path that pre-dates the elements collection API. That path now refuses on a single-embed bindertokenize() rejects with code frame_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, email, cardholder, save-for-future-use) are mounted via the imperative API below, not via dedicated React components — that's what samples/frame-react does.

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 card mount is your whole payment surface; the cardholder name and billing address are collected inside it. Mounting cardholder and address as 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 useEffect calls collection.unmount() to tear down all elements. Without it, re-mounting the form (e.g., when sessionId changes) 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 set status: "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