Skip to main content

Embedded Fields — 3D Secure

3D Secure (3DS / SCA) authentication is the issuer-side challenge that proves the buyer is the cardholder. Embedded Fields handles 3DS uniformly across every supported card processor — the merchant sees one challenge contract regardless of which underlying provider their account is configured for.

This page covers how 3DS surfaces in your code, the modal-harmonization toggle, the cancel + timeout paths, and what test cards to use.


When 3DS fires

3DS triggers on the binder side, not in your code. You don't decide whether to challenge — the issuer does. From your point of view:

  1. You call elements.submit() on the collection (const elements = vora.elements.create(); const card = elements.create("card", {}); card.mount("#card-element"); const result = await elements.submit();)
  2. If the issuer requires 3DS, Embedded Fields intercepts the binder's challenge and renders it
  3. On success, the result resolves as if 3DS had never happened — result.token (a reusable vp_pmt_*) when a buyer was on the session, or result.charged === true (a guest one-time charge, no token) on a charge-only session
  4. On failure / cancel / timeout, the result resolves with result.error (a VoraMirrorError with a 3DS-specific code)

On this path the challenge runs before Embedded Fields resolves the result, so there is no separate "wait for 3DS" callback in your code.

Charge-at-submit works differently — you must handle a redirect

The above describes the tokenize-then-charge path. On a charge-at-submit session (integrationMode: "elements" + chargeAtSubmit: true), a 3-D Secure card makes submit() resolve early with chargeStatus: "requires_action" and a redirectUrl, and nothing is charged. You send the buyer to the issuer's hosted challenge yourself:

const result = await collection.submit();
if (result.chargeStatus === "requires_action") {
window.location.href = result.redirectUrl; // nothing charged yet
return;
}

The buyer returns to the session's successUrl, the charge settles, and the charge.* webhook is the authoritative outcome. Requires vora.js ≥ 1.17.0. See Charge at submit → 3-D Secure.


The VORA 3DS modal

Applies to the tokenize-then-confirm path only

The in-page modal described below wraps a challenge rendered inside your page. It does not apply to charge-at-submit, where 3-D Secure completes as a hosted redirect — there is no VORA modal on that path, and disable3dsModal / challengeTimeout have no effect on it.

Different card processors render their native 3DS sheet in different ways — some use an iframe overlay, some use their own modal style, some redirect to a hosted page. To give buyers a consistent checkout experience across every supported processor, Embedded Fields wraps each provider's challenge UI in a VORA-styled modal:

  • Backdrop — semi-transparent dark layer over your checkout
  • Spinner + status text — "Authenticating with your bank…"
  • Cancel button — buyer can abandon the challenge cleanly
  • Accent color — inherits from the card element's style.color.text

The modal renders by default. The provider's native sheet shows on top of it (overlay mode or in-modal-iframe, depending on what the active processor's adapter declares via its threeDsMode).

Opting out — disable3dsModal

If your checkout's UX requires the binder's native sheet to render directly (no VORA chrome — for full custom branding, third-party analytics hooked to the binder's challenge events, etc.), pass disable3dsModal: true on the card element:

const card = collection.create("card", {
disable3dsModal: true,
});

The merchant flag wins over any per-adapter default. When disable3dsModal: true, the binder's native sheet shows directly with no VORA chrome.


Cancel + timeout paths

Cancel

The VORA modal includes a Cancel button. If the buyer clicks Cancel during the challenge:

const result = await collection.submit({ paymentIntent: { id, action } });
if (result.error?.code === "frame_3ds_challenge_cancelled") {
// The buyer closed the challenge. Nothing was charged and the card is fine.
// Let them retry — this is an abandoned checkout, not a failure.
}
This used to be frame_3ds_challenge_failed

Buyer cancellation had its own dedicated code added in vora-js 1.20.0. Before that it shared frame_3ds_challenge_failed with genuine rejections, which meant ordinary shopper behaviour looked like an integration defect — and, if you alert on that code, paged someone.

If you branch on frame_3ds_challenge_failed to catch cancels, add the new code. frame_3ds_challenge_failed keeps its documented meaning and is now narrower: a challenge that was rejected or failed technically.

Timeout

Default timeout is 5 minutes (challengeTimeout: 300_000); the maximum is 10 minutes (600_000). Override per element:

const card = collection.create("card", {
challengeTimeout: 120_000, // 2 minutes
});

If the timeout elapses without buyer interaction:

const result = await collection.submit({ paymentIntent: { id, action } });
if (result.error?.code === "frame_3ds_challenge_timeout") {
// Surface "the bank's authentication timed out — please try again"
}

The timer starts when the challenge UI mounts. Networks-side delays before the challenge appears don't count against the timeout.


Two server-driven flows

For most integrations, the synchronous tokenize-then-charge path is enough — call collection.submit(), send the vp_pmt_* to your server, your server creates a payment_intent (which charges on create), and the result tells you whether the charge succeeded. 3DS happens transparently inside submit().

Charge-and-save sessions are different. When the session is configured to charge and save, the embed charges at submit — so for that session do not create a server-side payment_intent, or you'll double-charge. A guest (no-buyer) charge-only result carries no vp_pmt_* token to forward, and the SDK does not run a confirm/3DS step against an already-charged guest result. Treat the client result as a UX signal only and confirm settlement via the webhook before fulfilling.

And on 3-D Secure they diverge further: a charge-at-submit session does not complete the challenge inside submit(). It resolves with chargeStatus: "requires_action" + redirectUrl and you must redirect the buyer to the issuer's hosted challenge. Full contract on the charge-at-submit page.

Two patterns exist for cases where your server needs more control:

Tokenize → server creates intent → forward client_confirm to browser → SDK confirms

The canonical end-to-end pattern is three round-trips:

1. Browser tokenizes the card

const result = await collection.submit();
// The result is a 3-way discriminated union — branch on all three:
if (result.error) {
// Failed before any charge — surface result.error.message
} else if (result.token) {
// A buyer was on the session → reusable vp_pmt_* (already charged on
// charge-and-save; charge server-side on tokenize-only). e.g. "vp_pmt_test_..."
} else if (result.charged) {
// GUEST / no buyer → one-time charge, NO token. Confirm via the webhook;
// do NOT forward to your server to charge again.
}

2. Browser sends the token to your server; server creates the intent

// Browser
const intent = await fetch("/api/charge", {
method: "POST",
body: JSON.stringify({ payment_method: result.token, amount: 4999 }),
}).then((r) => r.json());
// server/api/charge.ts (Node example)
app.post("/api/charge", async (req, res) => {
const response = await fetch(`${API}/v1/payment_intents`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${SECRET_KEY}`, // vp_sk_test_*
},
body: JSON.stringify({
amount: req.body.amount,
currency: "USD",
payment_method: req.body.payment_method,
}),
});
const intent = await response.json();
// For NON-3DS cards: intent.status === "succeeded", you're done.
// For 3DS cards: intent.status === "requires_action" + intent.client_confirm populated.

if (intent.status === "requires_action" && intent.client_confirm) {
// Forward the client_confirm block to the browser. Server must NOT
// log this value or persist it beyond the request — `client_secret`
// is bearer-equivalent for this single intent.
return res.json({
id: intent.id,
status: intent.status,
action: intent.client_confirm, // forwarded as-is to the SDK
});
}

// Non-3DS path
res.json({ id: intent.id, status: intent.status });
});

3. Browser passes action back into the SDK; the SDK renders the 3DS modal

// Browser — back in the submit handler
if (intent.status === "requires_action") {
const confirmed = await collection.submit({
paymentIntent: { id: intent.id, action: intent.action },
});
// confirmed.confirmationStatus === "succeeded" if 3DS passed
}

The SDK treats intent.action as opaque — each binder adapter unwraps it internally and calls the appropriate confirmation method on the underlying processor. Your code never sees inside action.

The harmonized 3DS modal renders during step 3. Buyer authenticates with their bank; result returns to the merchant page. Both disable3dsModal: false (default — VORA chrome) and disable3dsModal: true (binder native sheet) work the same way — just different UI.

Hosted redirect challenge — return_url

Some providers render the challenge as an in-page iframe (the client_confirm flow above); others host the challenge on their own page and redirect the buyer to it. For the hosted-redirect variant, the server-created intent comes back status: "requires_action" with a next_action.redirect_to_url.url handle. Send an absolute-HTTPS return_url on POST /v1/payment_intents — the page the provider returns the buyer to after they authenticate:

// server/api/charge.ts
const response = await fetch(`${API}/v1/payment_intents`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${SECRET_KEY}` },
body: JSON.stringify({
amount: 4999,
currency: "USD",
payment_method: req.body.payment_method,
return_url: "https://yourstore.com/checkout/return", // absolute HTTPS
}),
});
const intent = await response.json();

if (intent.status === "requires_action" && intent.next_action?.type === "redirect_to_url") {
// Send the buyer to the hosted challenge; the provider returns them to return_url on completion.
return res.json({ redirect: intent.next_action.redirect_to_url.url });
}
// Browser — send the buyer to the hosted challenge
if (body.redirect) window.location.assign(body.redirect);

Rules for return_url:

  • Absolute HTTPS (localhost is exempt for dev/test keys). javascript: / data: / plain http: are rejected.
  • Ignored when no challenge is required, so it is safe to always send.
  • Not used for merchant-initiated charges (mit.initiator: "merchant") — an off-session rebill has no buyer browser to redirect.
  • The return is UX only. Treat any query parameters the provider appends to your return_url as display-only — the authoritative outcome is the charge.succeeded / charge.failed webhook (or GET /v1/payment_intents/{id}). Do not fulfil the order on the redirect alone; confirm server-side.
Which handle do I use?

Server-driven integrations consume next_action.redirect_to_url; embedded-checkout integrations consume client_confirm. Both may be present on the same intent — use the one that matches how your integration completes the challenge.

confirmPaymentIntent (React hook variant)

For React integrations, the same flow runs through the useVora() hook:

import { useVora } from "@vonpay/vora-react";
const { confirmPaymentIntent } = useVora();

// 1. Tokenize (branch on the 3-way result union)
const result = await collection.submit();
if (result.error) {
// Failed before any charge — surface result.error.message and stop
return;
}
if (result.charged) {
// GUEST / no buyer → one-time charge, NO token. Confirm via the webhook;
// there is nothing to forward to /api/charge.
return;
}
const token = result.token; // a buyer was on the session → reusable vp_pmt_*

// 2. Server creates a manual-confirm intent (same as above)
const intent = await fetch("/api/charge", {
method: "POST",
body: JSON.stringify({ payment_method: token, amount: 4999 }),
}).then((r) => r.json());

// 3. SDK handles the 3DS challenge
if (intent.status === "requires_action") {
const result = await confirmPaymentIntent(intent);
// result.status: "succeeded" | "requires_action" | "failed"
}

handleAction — next-action flow

Your server returns a nextAction object directly:

const result = await handleAction(intent.nextAction);

Same end-state as confirmPaymentIntent. Use this when your server is already wrapped in a third-party SDK that produces next_action-shaped payloads instead of VORA's client_confirm block.

All three flows resolve to the same terminal status. The VORA harmonized 3DS modal renders on the collection.submit({ paymentIntent }) path (or the binder's native sheet when disable3dsModal: true); confirmPaymentIntent() and handleAction() drive the binder's own challenge UI directly.


Test cards

Two sandbox-allowlisted cards exercise the 3DS path: 4000 0027 6000 3184 (challenge → succeeds) and 4000 0084 0000 0029 (challenge → fails, frame_3ds_challenge_failed / failure_code: fraudulent). 4242 4242 4242 4242 never triggers a challenge. Any future expiry, any 3-digit CVC.

The embedded sandbox accepts only allowlisted numbers, so use these for modal testing rather than a generic processor 3DS card. The full matrix — every card, outcome, and webhook event — is single-sourced at Reference → Test cards. For binder-specific PANs, see your active processor's test-card documentation (linked from your dashboard's integration details page). The frame-react sample's test recipe walks the modal-on / modal-off / cancel / timeout paths step by step.


Error codes

3DS surfaces these VoraMirrorErrorCode values; full handling reference on the errors page:

CodeWhen
frame_3ds_requiredTokenize-then-confirm: the issuer requires a challenge that hasn't been completed — forward the server's client_confirm to render it. Charge-at-submit: this is the fail-closed terminal case — 3DS was required but no usable challenge URL could be produced. Re-running submit() will not help; it is a configuration fault (usually a session created without a successUrl), and a different card fails identically.
provider_request_rejectedNot an SDK error code — an HTTP 422 from POST /v1/public/sessions/{id}/charge. The provider rejected the request (commonly: no 3-D Secure return target). The card was neither charged nor declined, so retrying the same or a different card will not help until the request is corrected. selfHeal.retryable: false, nextAction: "fix_request".
frame_3ds_challenge_failedThe challenge ran and was rejected (wrong code, issuer rejected), or failed technically. Not buyer cancellation — see below.
frame_3ds_challenge_timeoutBuyer didn't complete within challengeTimeout
frame_3ds_challenge_cancelledBuyer closed the bank's confirmation step. The card was not charged and they can try again — treat it as an abandoned checkout, not a failure.
frame_tokenization_failedBinder rejected the card outright (decline before challenge fired)
frame_payment_declinedThe issuer declined the charge on a charge-and-save / charge-only submit

What's next

  • Quickstart — end-to-end card-only happy path (no 3DS)
  • ReactuseVora() patterns including confirmPaymentIntent + handleAction
  • Errors — full VoraMirrorError reference
  • samples/frame-react — interactive 3DS modal toggle in the sample app