Element events
Every element exposes the same event API — wire your checkout UI to it for loading states, button enablement, validation messages, and focus styling. It's binder-agnostic: the events and payloads are identical on every gateway, so your code never branches on which binder backs your account.
The API — on / off
const card = elements.create("card", {});
const handler = (e) => { /* … */ };
card.on("change", handler); // subscribe
card.off("change", handler); // unsubscribe (pass the same handler reference)
card.mount("#card-element");
on(event, handler) and off(event, handler) are on every element type (card, email, cardholder, address, save-for-future-use, express-checkout, …).
The four events
| Event | Fires when | Typical use |
|---|---|---|
ready | The element has finished rendering and is interactive. | Hide your loading spinner / skeleton; mark the form ready. |
change | The element's value changes (and on unmount, with complete: false). | Enable the Pay button when complete; surface error for inline validation. |
focus | The field gains focus. | Apply a focus ring or floating-label state on your wrapper. |
blur | The field loses focus (or, on the express-checkout element, the buyer dismisses the wallet sheet). | Validate on blur; reset focus styling. |
All four fire with the same payload:
interface ElementChangeEvent {
complete: boolean; // value passes its own validation and is non-empty (or empty + not required)
error?: { code: string; message: string }; // scrubbed validation error, undefined when valid
}
Gate the submit button + show validation
const card = elements.create("card", {});
card.on("change", (e) => {
payButton.disabled = !e.complete; // enable only when valid
showFieldError(e.error ? e.error.message : null); // inline validation
});
card.on("ready", () => hideSpinner());
card.on("focus", () => cardWrapper.classList.add("focused"));
card.on("blur", () => cardWrapper.classList.remove("focused"));
card.mount("#card-element");
error.message is already scrubbed for display; error.code is the stable identifier to branch on.
Wallet buttons (express-checkout) use the same events
The express-checkout element (standalone wallets) maps the native wallet sheet's lifecycle onto these same four events — so you wire it the same way. When the buyer authorizes in the wallet sheet, it fires change with complete: true; call submit() on that collection to read the charge result:
const elements = vora.elements.create();
const express = elements.create("express-checkout", {});
express.on("change", async (e) => {
if (e.error) return; // buttons unavailable (e.g. frame_wallet_domain_unverified)
if (!e.complete) return; // buyer hasn't authorized yet
const result = await elements.submit(); // submit the collection this element belongs to
if (result.chargeStatus === "requires_action") {
window.location.href = result.redirectUrl; // 3DS — nothing charged yet
return;
}
if (result.charged || result.chargeStatus === "pending") {
return awaitWebhookThenFulfil(); // money already moving — do NOT charge again
}
await chargeOnYourServer(result.token, result.wallet); // vault-only accounts
});
express.mount("#express-checkout");
On the default wallet path the tap moves the money, and the result may also carry a reusable vp_pmt_*. Branching on result.token therefore double-charges. Branch on charged / chargeStatus — see the full result table.
(If the buyer dismisses the sheet, you get blur; if it can't render, you get change with an error.)
Discrete card fields — field vs form events
The discrete card fields (card-number / card-expiry / card-cvc) expose the same four events, but event granularity is binder-dependent in this release:
| Binder | change / ready | focus / blur |
|---|---|---|
| Direct-card binder | Field-scoped — each field fires its own, carrying that field's complete. | Fire per field. |
| Secure-fields | Whole-form — one event for all three fields, with a form-wide complete. | Do not fire yet. |
So don't build portable per-field UX (e.g. highlighting only the CVC field) on these events without gating on which binder backs your account. For button-enablement and validation that works on every binder, treat change as a form-level signal — complete means the whole card is valid — rather than assuming per-field delivery. Genuine per-field events on every binder are a planned follow-up; independent field placement already works on both binders.
Localization (locale)
The SDK accepts a BCP-47 locale — on the client and per element:
const vora = new window.Vora({
publishableKey: "vp_pk_test_…",
locale: "fr-FR", // client-wide default
});
const card = elements.create("card", { locale: "de-DE" }); // per-element override
It's meant to drive field labels, validation messages, and error strings.
The locale value is accepted, recorded, and forwarded, but the default label / validation surface is English-only right now — the value is captured so localization can light up in a future release without an integration change. Set it if you want to be forward-compatible; just don't expect translated strings yet. (Some binder-served field text may already reflect the locale where the underlying field provider localizes.)
Related
- Customize the look & feel — styling, themes, the
styleallowlist - Elements reference — per-element options + collect shapes
- React — the same events via
useVora()/ component props - Standalone wallets — the
express-checkoutelement