Embedded Fields — Tokenization
The flow that turns a buyer's card details + the rest of the checkout fields into a single opaque token your server uses for every payment operation. This page covers the model end-to-end: what gets tokenized, how reusability works, and how the token feeds your downstream API calls.
The embed supports two flows. Which one applies depends on your active checkout configuration, and the shape of the elements.submit() result tells you which path ran.
- Tokenize-only. The embed mints a reusable
vp_pmt_*token at submit time and charges nothing. Your server runs every payment operation later viaPOST /v1/payment_intents. This is the model the diagrams and reuse scenarios below describe in detail. - Charge-and-save. The embed charges the buyer's card at submit time. With a buyer attached to the session it ALSO vaults a reusable
vp_pmt_*in the same step; a guest / no-buyer session charges once and saves nothing (no token,{ charged: true }). On this flow your server must NOT also callPOST /v1/payment_intentsfor the same session — doing so charges the buyer a second time. Confirm settlement via the webhook before fulfilling.
Discriminate on the result: if (result.error) … else if (result.token) … else if (result.charged) …. See the SubmitResult shape below.
If you only need the happy-path sequence, jump to Quickstart. If you're trying to understand WHY the flow works the way it does, read this.
The model in one diagram
The diagram above is the tokenize-only flow: the embed mints the token and your server moves the money later with POST /v1/payment_intents.
Under the charge-and-save flow the embed charges on submit, so the POST /v1/payment_intents leg does not apply — calling it for the same session double-charges the buyer:
The page never sees the PAN. Your server never sees the PAN. Your code only ever holds the opaque vp_pmt_* handle.
Why the model looks like this
| Concern | How the model addresses it |
|---|---|
| PCI scope | Card data lives only inside the binder-served iframe — never touches your DOM, never touches your server. You stay SAQ A. |
| Vendor independence | Your server only ever speaks to VORA's API using vp_* tokens. Swap the underlying card processor without changing merchant code. |
| Mixed data collection | One element collects the card; other elements collect non-PCI data (email, name, address). elements.submit() aggregates them into one token registration. |
| Reusability | The same vp_pmt_* shape covers single-use and reusable scenarios. The setup_for_future_use field on the token row governs what operations are allowed. |
The token shape
Every token VORA mints uses the vp_pmt_* prefix. There's no separate prefix for reusable vs single-use — 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" | "expired" | "revoked" | Lifecycle state. Tokens cannot be re-activated; vault a fresh one if you hit expired or revoked. |
setup_for_future_use | null | "on_session" | "off_session" | Reusability scope (see below) |
card.brand | "visa" | "mastercard" | … | Network |
card.last4 | string | Last 4 digits |
card.expMonth / card.expYear | number | Expiration |
VORA's model is aligned with Stripe's PaymentMethod + setup_future_usage convention. If you're moving from Stripe to VORA (or running both), the field names + semantics match precisely.
What gets tokenized vs what gets attached
Tokenization isn't "the whole checkout becomes the token." Only the card credential becomes the token. Everything else is metadata attached to it at registration time.
| 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 | SDK-rendered DOM input | No (attached as billing-details metadata) | Yes |
| SDK-rendered DOM input | No | Yes (used for receipt delivery + AVS) | |
| Billing address | SDK-rendered DOM input | 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, when your server later calls payment_intents.create, the engine pulls the card credential AND the attached metadata to drive the charge. You don't have to forward the email / address / cardholder name yourself — they travel with the token.
On the charge-and-save flow the buyer's card is charged at submit time, inside the embed. Do NOT call POST /v1/payment_intents for that session — doing so charges the buyer a second time. Confirm the charge server-side via the webhook before fulfilling. Whether a session tokenizes-only or charges-and-saves is decided by your active checkout configuration; the result shape (result.charged vs a tokenize-only result.token you charge yourself) tells you which path ran.
Mixed iframe + DOM collection — the submit step
elements.submit() is the moment everything comes together. It reads from every mounted element regardless of whether each one renders as an iframe (PCI) or as SDK-controlled DOM (non-PCI), then drives the tokenize + register flow.
elements collection
│
├── card element (iframe — PAN/expiry/CVC)
├── cardholder element (DOM input)
├── email element (DOM input)
├── address element (DOM inputs or NATIVE iframe per binder)
└── save-for-future-use element (DOM checkbox — sets setup_for_future_use)
│
▼
elements.submit()
│
├─ 1. Card iframe runs binder.tokenize() → binder-native token returns to SDK
│ (the iframe → SDK message channel; cross-origin postMessage, no PAN leaks
│ to your page)
│
├─ 2. SDK reads DOM-side element values (email, cardholder, address, sff-checkbox)
│
├─ 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 submit returns a single SubmitResult object regardless of how many elements were involved. Fields are present iff their corresponding element was mounted in the collection:
interface SubmitResult {
token?: string; // vp_pmt_* — present when a vault row was registered
charged?: true; // present on the charge-and-save guest/no-buyer arm
last4?: string;
brand?: string;
email?: string;
cardholder?: string; // cardholder name (a plain string, not an object)
billingAddress?: BillingAddressValue;
setupForFutureUse?: boolean; // true when the buyer opted to save (vaulted off_session)
error?: VoraMirrorError;
}
SubmitResult is one flat interface — every field is optional (no separate union-arm types to import). Always branch at runtime in this order:
const result = await elements.submit();
if (result.error) {
// Submit failed. Surface result.error to the buyer; nothing was charged or vaulted.
} else if (result.token) {
// A vault row was registered → result.token is a vp_pmt_*.
// 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/no-buyer: the card was charged once and nothing
// was vaulted, so there is no token. Do NOT re-prompt or re-charge.
// 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 rather than vault-row values.
Reusability — setup_for_future_use
Buyer requirement for vaulting
A reusable token is only minted when a buyer is attached to the session. On the charge-and-save flow:
- With a buyer on the session,
elements.submit()resolves with avp_pmt_*token (the card is charged AND a reusable credential is vaulted in one step), and thesetup_for_future_usemodel below governs that token's reuse scope. - Without a buyer (guest session), the embed charges the card once and vaults nothing — the result is
{ charged: true }with no token. There is no card-on-file to reuse.
So before designing any card-on-file / saved-card / recurring UX, make sure a buyer is on the session; otherwise no reusable token is produced regardless of the save-for-future-use checkbox or merchant policy.
Reuse scope
A vp_pmt_* token's reuse scope is governed by the setup_for_future_use field set at vault time. Three values:
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. | Single charge by the originating payment_intents.create. Subsequent CIT or MIT charges referencing this token return payment_method_consent_missing. |
"on_session" | Buyer is present and consented to "save my card for this purchase session." Set explicitly — card.tokenize({ setupForFutureUse: "on_session" }) in the browser, or the merchant's server calls POST /v1/tokens with setup_for_future_use: "on_session". (The save-for-future-use checkbox itself only toggles off_session vs single-use; it does not emit on_session.) | Single + follow-on cardholder-initiated charges where the buyer is interactively present (e.g. one-click upsells, order-bumps within the same checkout window). MIT/recurring is still rejected. |
"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 explicitly via card.tokenize({ setupForFutureUse: "off_session" }) / 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 at every payment_intents.create call. If your server tries an operation the token wasn't consented for, you get payment_method_consent_missing (HTTP 422) with a self-healing hint telling you to vault a fresh token with the right setup_for_future_use.
The value is set ONCE, at vault time. To upgrade scope (e.g. null → "off_session"), the buyer needs to re-vault their card with the new consent — VORA doesn't expose a "promote consent" call because PSD2/SCA stored-credential rules require the consent record to match what the buyer agreed to at the moment of vault.
Reuse scenarios
What's allowed depends on the setup_for_future_use value and the network rules for merchant-initiated transactions.
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.
Any subsequent charge against this token → payment_method_consent_missing.
Most merchants integrating Embedded Fields for the first time use this flow.
One-click upsell / order bump
Buyer enters card → tokenize with setupForFutureUse: "on_session"
│
▼
Returns vp_pmt_* with setup_for_future_use: "on_session"
│
▼
First charge → POST /v1/payment_intents { payment_method: { id: vp_pmt_* } } → succeeded
│
▼
Upsell page renders, buyer clicks "Yes, add to my order"
│
▼
Second charge → POST /v1/payment_intents with the SAME vp_pmt_*
+ cit: true (cardholder-initiated; buyer clicked)
→ succeeds (still within session-scope consent)
The on-session scope means the buyer needs to be interactively present (cardholder-initiated transactions only). Off-session/scheduler charges against an "on_session" token return 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: "subscription",
sequence: N }
→ succeeds
The off_session scope is what every "saved card on file" / "recurring" / "scheduled charge" flow needs. Without it, merchant-initiated charges are rejected.
Network tokens
A network token (issued by the card network — Visa Token Service, Mastercard MDES, Amex Token Service) can be provisioned at vault time when the underlying binder supports it. It's invisible to merchant code — same vp_pmt_* prefix, same handle — but it can affect two things:
| Effect | What changes |
|---|---|
| Higher auth approval | Issuers typically favor network-token-backed auths. Typical lift: 1-4 percentage points. |
| Survives card reissues | When the buyer's bank reissues their card (new PAN, same account), the network silently updates the credential behind the same vp_pmt_*. Your subscription / saved-card flow doesn't break. |
Provisioning happens automatically wherever the underlying binder supports it. The network_token field on the token response indicates whether one was provisioned for this specific token.
You don't have to do anything to opt into network-token coverage. The engine handles it.
How the token feeds the downstream API
┌─── POST /v1/payment_intents
│ (creates the auth or auth+capture)
│
├─── POST /v1/payment_intents/:id/capture
vp_pmt_* ─────▶│ (captures an auth-only intent)
│
├─── POST /v1/payment_intents/:id/void
│ (voids an uncaptured auth)
│
└─── POST /v1/refunds
(refunds by intent ID — token reference
lives on the intent, not passed directly)
The token (or rather, the intent the token produced) is the handle for everything. Your server doesn't need to know which underlying processor ran the charge; the intent ID is opaque and uniform across binders.
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 single-embed binder. Integration-time wrong-method condition, not a server bug — do not page on it. | card.tokenize() is deprecated. Use vora.elements.create() + collection.create('card') + collection.submit() to charge and save. Your server configuration is fine. |
payment_method_consent_missing | 422 | Your server tried an MIT charge (or a CIT outside the originating intent) against a token whose setup_for_future_use is null or "on_session". | Re-vault with setup_for_future_use: "off_session" after obtaining explicit buyer consent (typically a "Save my card for future purchases" checkbox). PSD2/SCA stored-credential rules require this consent at vault time. |
payment_method_inactive | 422 | Token's status is "revoked" (or age-expired). | 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 | Token was vaulted under a different binder than the merchant's current configuration. Tokens are NOT portable across binders. | Vault a fresh token under the current binder. |
What's next
- Quickstart — minimal end-to-end card-only integration
- Elements reference — per-element options, the iframe/DOM boundary, per-binder capability matrix
- 3D Secure — when the issuer requires SCA, how the confirm path drives it
- Errors — full
VoraMirrorErrortaxonomy