Embedded Fields — Tokenization
elements.submit() turns the buyer's card details plus the rest of the checkout fields into one opaque vp_pmt_* token. Which of two flows runs depends on your account's binder and the session you created — the result tells you which:
- Tokenize-only.
submit()mints avp_pmt_*and charges nothing; your server runs every payment operation later viaPOST /v1/payment_intents. - Charge-and-save.
submit()charges the card at submit. With a buyer attached to the session it also vaults a reusablevp_pmt_*in the same step; a guest session charges once and saves nothing ({ charged: true }, no token). Your server must not callPOST /v1/payment_intentsfor that session — that charges the buyer a second time.
Branch error → chargeStatus → token → charged. On a charge-at-submit session chargeStatus adds two outcomes that are neither success nor failure — "requires_action" (redirect the buyer to result.redirectUrl; nothing charged) and "pending" (settling asynchronously; await the webhook, do not re-charge) — requires_action carries no token, and a token on a pending result is a vaulted card, not a settled charge. Whichever arm you land in, the client result is a UX signal: confirm settlement via the webhook before fulfilling. The full interface: SubmitResult shape.
The model in one diagram
Tokenize-only — the embed mints the token and your server moves the money later:
Charge-and-save — the embed charges on submit, so the POST /v1/payment_intents leg does not apply:
The token shape
Every token uses the vp_pmt_* prefix; reusability is a property of the token row, not the identifier.
| Field | Type | Meaning |
|---|---|---|
id | vp_pmt_* | Opaque token handle you pass to POST /v1/payment_intents |
status | "active" | "revoked" | Lifecycle state. There is no expired status — card expiry lives on card.exp_month / card.exp_year and the token itself stays active until it is revoked. Tokens cannot be re-activated; vault a fresh one if you hit revoked. |
setup_for_future_use | null | "on_session" | "off_session" | Reusability scope (see below) |
card.brand | "visa" | "mastercard" | … | null | Network, read from the payment provider. null if it could not be read; the card is still saved. |
card.last4 | string | null | Last 4 digits, read from the payment provider. null if they could not be read. |
card.exp_month / card.exp_year | number | null | Expiration, read from the payment provider. null if it could not be read. |
What gets tokenized vs what gets attached
Only the card credential becomes the token; everything else is metadata attached at registration. Which fields render as their own inputs depends on integrationMode — in the default embed mode cardholder name, billing address, method picker, saved methods and wallets all render inside the card iframe (Render modes).
| Field source | Lives where during entry | Becomes part of the token credential? | Stored on the vault row alongside |
|---|---|---|---|
| PAN / expiry / CVC | Inside binder iframe (PCI scope) | Yes — this IS the token credential | (it's the credential, not metadata) |
| Cardholder name | Inside the card iframe in embed; SDK-rendered DOM input in elements | No (attached as billing-details metadata) | Yes |
| SDK-rendered DOM input (both modes) | No | No — returned to you in result.email; it is not attached to the token or buyer automatically. Set the buyer email via buyerEmail on session-create (Buyer identification). | |
| Billing address | Inside the card iframe in embed; SDK-rendered DOM input in elements | No | Yes (used for AVS) |
save-for-future-use checkbox | SDK-rendered DOM input | No | Yes (sets setup_for_future_use — see reusability section) |
| Shipping address, phone, order summary, anything else on your page | Your own DOM | No | No — not VORA's data |
On the tokenize-only flow the attached metadata travels with the token: when your server later calls payment_intents.create, the engine pulls the credential and the metadata — you do not forward email, address or cardholder name yourself.
The submit step
elements.submit() reads every mounted element — iframe (PCI) and SDK-rendered DOM (non-PCI) alike — then drives tokenize + register:
elements collection
│
├── card element (iframe — PAN/expiry/CVC)
├── email element (DOM input — separate field in both modes)
├── cardholder element (inside the card iframe in embed mode;
│ separate DOM input in elements mode)
├── address element (inside the card iframe in embed mode;
│ separate field in elements mode)
└── save-for-future-use element (DOM checkbox — sets setup_for_future_use,
separate field in both modes)
│
▼
elements.submit()
│
├─ 1. Card iframe runs the gateway tokenize() → gateway-native token returns to SDK
│ (the iframe → SDK message channel; cross-origin postMessage, no PAN leaks
│ to your page)
│
├─ 2. SDK reads the values of whichever elements render as proxy DOM fields for the
│ active integrationMode (always email + sff-checkbox; also cardholder + address
│ in elements mode — in embed mode those are collected inside the card iframe)
│
├─ 3. SDK assembles the registration payload + posts to POST /v1/public/tokens
│ with the merchant's publishable key for auth
│
└─ 4. /v1/public/tokens registers the vault row → returns the vp_pmt_*, plus
last4 + brand + setup_for_future_use as set by the SDK
The result is one flat SubmitResult object regardless of how many elements were involved; fields are present iff their element was mounted. Branch in the order above:
const result = await elements.submit();
if (result.error) {
// Submit failed. Surface result.error; nothing was charged or vaulted.
} else if (result.chargeStatus === "requires_action") {
window.location.href = result.redirectUrl; // 3-D Secure; nothing charged yet
} else if (result.chargeStatus === "pending") {
// Charge in flight — await the charge.* webhook. Do NOT charge again.
// A token here (buyer on the session) is the vaulted card, not settlement.
} else if (result.token) {
// Tokenize-only: charge later via POST /v1/payment_intents.
// Charge-and-save with a buyer: the card was ALSO charged at submit —
// do NOT call payment_intents for this session; confirm via webhook.
} else if (result.charged) {
// Charge-and-save, guest: charged once, nothing vaulted, no token.
// Confirm settlement via the webhook before fulfilling.
}
On the { charged: true } arm there is no vaulted credential, so last4 / brand may be display-only placeholders.
Card expiry on the result
expMonth and expYear come back on the vaulted-token arm — what you need to render "Visa ···· 4242 — expires 12/33" from your own markup. Three things about them:
- They are camelCase. The API's token object spells expiry
card.exp_month/card.exp_year(the wire format); the browser result spells itexpMonth/expYear, like the rest of the interface. expMonthis 1-12, not zero-based — the number printed on the card:
const expiry = `${String(result.expMonth).padStart(2, "0")}/${String(result.expYear).slice(-2)}`;
// → "12/33"
- They are absent, never a placeholder, when the binder did not return them — a synthesised date would drive a wrong "your card is expiring" warning. Guard before you read them:
if (result.expMonth !== undefined) {
// safe to render an expiry
}
Charge-and-save
On the charge-and-save flow submit() does the payment in one step: the embed charges the buyer's card on submit, and with a buyer on the session also vaults a reusable vp_pmt_*. There is no separate server-side charge step; use elements.submit(), the collection method.
| Session state | What the embed does on submit | What the result carries |
|---|---|---|
| Buyer on the session | Charges the card and vaults a reusable payment method in one step | { token: "vp_pmt_..." } — already charged, and the token is reusable |
| Guest / no buyer | Charges the card once, saves nothing | { charged: true } — no token |
Vaulting requires a buyer: without one the result is { charged: true } and no token. A successful guest charge is not frame_tokenization_failed — that code arrives under result.error for a genuine pre-charge failure. The vaulted token is for a future, separate charge — never for re-charging the session that minted it.
Reusability — setup_for_future_use
Buyer requirement for vaulting
A reusable token is minted only when a buyer is attached to the session. With a buyer, charge-and-save resolves with a vp_pmt_* (charged and vaulted in one step) whose reuse scope the model below governs; on a charge-at-submit session the card is vaulted when it is charged, so a requires_action result carries no token and a pending one may not — read it back from the payment intent once the charge settles. Without a buyer the embed charges once and vaults nothing.
Nothing is vaulted and nothing fails: the charge goes through, your page shows success, and the card you believed you saved does not exist. The SDK reports frame_consent_requires_buyer without throwing; you find out at the first rebill, and consent cannot be repaired afterwards. On a gateway that vaults as part of the charge the opposite happens — submit() rejects with frame_buyer_required_for_saved_card and nothing is charged. Branch on the code. Either create the session with a buyer before rendering the control, or don't render it on guest checkouts (Recurring & saved cards).
Reuse scope
setup_for_future_use | UX that produces it | What the token allows |
|---|---|---|
null (default) | Buyer didn't check save-for-future-use; merchant didn't set it explicitly at vault time. | Charges made while the buyer is present. A merchant-initiated charge (mit.initiator: "merchant") returns payment_method_consent_missing. With no buyer on the session, the card can be charged only within 24 hours of being saved; after that it returns 404 payment_method_not_found. |
"on_session" | Buyer is present and the card may be charged again while they are, for example a one-click upsell. Set server-side: setupForFutureUse: "on_session" on POST /v1/sessions, or setup_for_future_use: "on_session" on POST /v1/tokens. The save-for-future-use checkbox toggles off_session vs single-use; it does not emit on_session. | Charges made while the buyer is present, such as one-click upsells and order bumps. A merchant-initiated charge returns payment_method_consent_missing. |
"off_session" | Buyer consented to ongoing charges without being present at each one. Typical UX: a "Save my card for future purchases" checkbox or a subscription-signup confirmation step. Set by a checked save-for-future-use box, or server-side via POST /v1/tokens with setup_for_future_use: "off_session". | All of the above, plus merchant-initiated transactions (recurring subscriptions, automated retries, scheduler-driven charges). |
The gate is enforced on every merchant-initiated payment_intents.create: a charge the buyer is not present for needs an off_session token, and any other token returns payment_method_consent_missing (HTTP 422). The value is set once, at vault time, and cannot be changed — there is no update path. To widen a card's scope the buyer must present it again with the new consent sent on the call that vaults it (Fixing a missing consent).
Reuse scenarios
One-shot charge
Buyer enters card → submit (save-for-future-use unchecked → setup_for_future_use null)
│
▼
Returns vp_pmt_* with setup_for_future_use: null
│
▼
Your server → POST /v1/payment_intents → done.
A later merchant-initiated charge against this token → payment_method_consent_missing.
One-click upsell / order bump
Your server creates the session with setupForFutureUse: "on_session"
│ and a buyer (POST /v1/sessions; or vault server-side with
│ POST /v1/tokens setup_for_future_use: "on_session")
▼
Buyer pays → first charge succeeds, card saved as vp_pmt_* (on_session)
│
▼
Upsell page renders, buyer clicks "Yes, add to my order"
│
▼
Second charge → POST /v1/payment_intents
payment_method: { id: vp_pmt_* } (the SAME token)
buyer_id: the session's buyer
no mit block (the buyer is present)
→ succeeds
There is no field that marks a charge as buyer-present: leaving out the mit block is what does it, and the request schema refuses unknown fields with 400 validation_unknown_field. on_session covers charges made while the buyer is present; a merchant-initiated charge against it returns payment_method_consent_missing.
Recurring / subscription
Signup:
Buyer enters card → checks "Save my card for future purchases" → submit
(or: subscription signup flow with explicit off-session consent)
│
▼
Returns vp_pmt_* with setup_for_future_use: "off_session"
│
▼
Your server stores the vp_pmt_* + the subscription schedule
│
▼
On each recurring cycle:
Your scheduler fires → POST /v1/payment_intents with vp_pmt_*
+ mit: { initiator: "merchant",
reason: "recurring",
original_transaction_id: "vpi_live_..." }
→ succeeds
off_session is what every saved-card / recurring / scheduled flow needs. The mit block takes exactly three fields — initiator, reason (recurring, installment or unscheduled) and original_transaction_id (the first payment in the chain); anything else is refused. Declare the same word when the card is stored — Recurring & saved cards.
Network tokens
Where the account's connection is backed by network tokens, the credential behind a vp_pmt_* can stay current through a card reissue or expiry — fewer declines on cards you charge repeatedly. Nothing in the API exposes this per card (supported_operations.network_tokens on GET /v1/capabilities is the only signal), so keep handling renewal declines and keep a path for asking the buyer to re-enter a card. Such an account enforces stored-card rules more strictly — every payment must identify the buyer — see Recurring & saved cards — constraints.
Common token-related errors
| Code | HTTP | Cause | Fix |
|---|---|---|---|
frame_session_expired | n/a (client-side) | Session was created more than the configured expiry window ago. | Mint a new session on your server, retrieve it in the SDK. |
frame_tokenization_failed | n/a (client-side) | The adapter rejected the tokenize call (invalid card, network issue, fraud-rule rejection). | Surface the error to the buyer; they can retry with a different card. On the charge-and-save flow, a successful guest / no-buyer charge resolves as { charged: true } — NOT frame_tokenization_failed. Only treat frame_tokenization_failed as a genuine failure; never re-prompt or re-charge after a charged result. |
frame_method_not_supported_for_session | n/a (client-side) | The deprecated single-field card.tokenize() was called on a session whose binder renders the whole payment surface in one card iframe. | Use vora.elements.create() + collection.create('card') + collection.submit(). |
payment_method_consent_missing | 422 | Your server tried a merchant-initiated charge (mit.initiator: "merchant") against a token whose setup_for_future_use is null or "on_session". | The existing token cannot be upgraded — consent is write-once. With the buyer's explicit consent, have them present the card again with setup_for_future_use: "off_session" sent on the call that vaults it (POST /v1/public/sessions/{id}/charge or POST /v1/public/tokens for embedded fields), then charge the new token. |
payment_method_inactive | 422 | Token's status is "revoked". | Tokens cannot be re-activated. Vault a fresh one. |
payment_method_unvaulted | 422 | Token is registered but the upstream vault hasn't completed yet. | Brief retry (2–5 seconds). If it persists, create a fresh token. |
payment_method_binder_mismatch | 422 | The token belongs to a different payment provider than this merchant's current configuration. Tokens are NOT portable across providers — each provider holds its own vault (this happens after a re-onboarding moves you to a different processor). | Vault a fresh token under the current provider via POST /v1/tokens. |
What's next
- Quickstart — minimal end-to-end card-only integration
- Elements reference — per-element options and the
SubmitResultshape - 3D Secure — when the issuer requires SCA, how the confirm path drives it
- Errors — full
VoraMirrorErrortaxonomy