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 re-exported TokenizeResult is a five-arm discriminated union — exactly one arm is present per result:
type TokenizeResult =
| { token: string; /* … */ } // a reusable vp_pmt_* was vaulted (card also charged in the charge-and-save flow)
| { charged: true; /* … */ } // guest charge-only submission, no reusable token
| { chargeStatus: "requires_action"; redirectUrl: string; /* … */ } // issuer wants the buyer to authenticate — no money moved, no token
| { chargeStatus: "pending"; /* … */ } // charge settling asynchronously — not an error, no token yet
| { error: VoraMirrorError }; // tokenize failed pre-charge
Two of these five are neither a success nor a failure, which is why they need their own arms:
| Arm | What happened | What to do |
|---|---|---|
chargeStatus: "requires_action" | The issuer demanded 3-D Secure authentication. No money moved and no token was minted. | Redirect the buyer to result.redirectUrl. They authenticate and return to the session's success URL, where the charge settles. |
chargeStatus: "pending" | The charge is settling asynchronously. This is not an error. The token is minted after settlement. | Show a "processing" state and wait for the charge.* webhook. Do not charge again — a retry hits a payment already in flight. |
Both arms are reachable only on a session with charge at submit enabled; a vault-only tokenize never triggers an authentication step, because no charge is attempted.
Discriminate through every arm — if (result.error) … else if (result.chargeStatus === "requires_action") … else if (result.chargeStatus === "pending") … else if (result.token) … else if (result.charged) …. A handler written for only the first three arms matches nothing on the two new ones, so it silently does nothing and the sale is lost. 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.
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:
This example mounts discrete elements — opt in per session. Separately-placed
cardholder/addressrequire creating the session withintegrationMode: "elements". In the defaultembedmode thecardmount renders the whole surface (cardholder and address are collected inside it), so those separate mounts have no visible effect — the example still runs without error. See Elements (custom checkout) for the mode model and the create-session call.
import * as React from "react";
import { Vora } 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,
// Defaults to https://checkout.vonpay.com. Must match the host your server called.
apiBaseUrl: import.meta.env.VITE_VONPAY_API_BASE_URL,
});
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;
}
if (result.chargeStatus === "requires_action" && result.redirectUrl) {
// The issuer demanded authentication. Nothing was charged and no token
// was minted — send the buyer to the challenge page.
//
// Check redirectUrl explicitly: SubmitResult is a flat interface where
// every field is optional, so TypeScript types it `string | undefined`
// even though it is always present on this arm.
window.location.href = result.redirectUrl;
return;
}
if (result.chargeStatus === "pending") {
// Settling asynchronously. NOT an error — keep the form disabled and
// wait for the webhook. Do not submit again.
setStatus("processing");
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)
chargeStatus?: "succeeded" | "pending" | "requires_action"; // charge-at-submit sessions
redirectUrl?: string; // present with chargeStatus "requires_action" — send the buyer here
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. 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.
⚠️ On a charge-at-submit session, token / charged / error are not the whole story. The result also carries chargeStatus, and two of its values are neither a success nor a failure — on both, no token exists and none of the three fields above is set:
chargeStatus | What happened | What to do |
|---|---|---|
"requires_action" | The issuer demanded 3-D Secure authentication. Nothing was charged. | Send the buyer to result.redirectUrl. |
"pending" | The charge is settling asynchronously. Not an error. | Show a processing state and wait for the charge.* webhook. Do not submit again. |
"succeeded" | The charge completed. | Continue as for charged. |
Discriminate in this order — if (result.error) … else if (result.chargeStatus === "requires_action") … else if (result.chargeStatus === "pending") … else if (result.token) … else if (result.charged) …. Branching on only the first, fourth and fifth arms matches nothing on a challenged or settling charge, so the handler silently does nothing and the sale is lost. 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);
const [processing, setProcessing] = React.useState(false);
if (error) return <p>Checkout unavailable: {error.message}</p>;
if (!ready) return <p>Loading payment form…</p>;
if (processing) return <p>Processing your payment…</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.chargeStatus === "requires_action") {
// The issuer asked the buyer to authenticate. No money has moved and
// no token was minted — send them to the challenge page.
window.location.href = result.redirectUrl;
return;
} else if (result.chargeStatus === "pending") {
// The charge is settling asynchronously. This is NOT an error — keep
// the form disabled and wait for the webhook. Do not charge again.
setProcessing(true);
} 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 { tokenize, confirmPaymentIntent } = useVora();
async function handleSubmit() {
// 1. Tokenize — every TokenizeResult field is optional. Branch through all five
// arms and never read result.token unconditionally. The 3DS-confirm flow needs
// a reusable vp_pmt_* token, so handle every arm that does not produce one first.
const tokenizeResult = await tokenize();
if (tokenizeResult.error) {
// Tokenize failed before any charge — surface and stop.
alert(tokenizeResult.error.message);
return;
}
if (tokenizeResult.chargeStatus === "requires_action") {
// The issuer demanded authentication during the submit charge. No money moved
// and no token was minted, so there is nothing to confirm — redirect instead.
window.location.href = tokenizeResult.redirectUrl;
return;
}
if (tokenizeResult.chargeStatus === "pending") {
// The submit charge is settling asynchronously. Not an error, and no token yet —
// show "processing" and wait for the webhook rather than confirming or retrying.
showProcessing();
return;
}
if (tokenizeResult.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;
}
// tokenizeResult.token present ⇒ a buyer was on the session → reusable vp_pmt_*.
const token = tokenizeResult.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 confirmed = await confirmPaymentIntent(intent);
// confirmed.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