Skip to main content

Embedded Fields — 3D Secure

3D Secure (3DS / SCA) is the issuer's challenge that proves the buyer is the cardholder. The issuer decides whether to challenge, not your code. Where the challenge surfaces depends on which flow the session runs:

SessionWhen 3DS is requiredYou
Charge at submit / charge-and-save — the embed charges on submitsubmit() resolves early with chargeStatus: "requires_action" and a redirectUrl; nothing is chargedSend the buyer to redirectUrl. They return to the session's successUrl; the charge.* webhook is the outcome.
Tokenize-only — your server charges via POST /v1/payment_intentsThe intent comes back status: "requires_action" with next_actionSend return_url on the create call and redirect the buyer to next_action.redirect_to_url.url. The charge.* webhook is the outcome.

Code the requires_action branch on every charge-at-submit integration even if you have never seen it fire — a card that needs a challenge lands nowhere else, and a buyer left on a spinner is an abandoned payment. Full contract: Charge at submit → 3-D Secure.

Tokenize-only: the hosted redirect​

submit() mints the vp_pmt_*; your server creates the intent. Send an absolute-HTTPS return_url on the create call — the page the provider returns the buyer to after they authenticate — and redirect the buyer when the intent needs a challenge. This is the path for every card your server charges, on every connection:

// server/api/charge.ts — req.body.payment_method is the vp_pmt_* from submit()
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: { id: req.body.payment_method }, // object form: { id: "vp_pmt_..." }
return_url: `https://yourstore.com/checkout/return?order=${req.body.order_id}`, // absolute HTTPS, with your order ref
}),
});
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 });
}
// requires_action with no next_action: still processing, nothing for the buyer to do.
// Do not retry the charge. Wait for the webhook, which can take up to a day.
res.json({ status: intent.status });
// Browser — send the buyer to the hosted challenge. `vora` is your page's
// `new window.Vora({ publishableKey })` instance, `result` is the resolved
// `collection.submit()` that minted the token, and `orderId` is your own order reference.
const res = await fetch("/api/charge", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ payment_method: result.token, order_id: orderId }),
});
const body = await res.json();
if (body.redirect) vora.redirectToChallenge(body.redirect);

If the redirect never reaches the buyer (a timeout, a crash, a closed tab), read GET /v1/payment_intents/{id}: it returns the same next_action, with issued_at for its age, for as long as the intent is requires_action. On a browser charge, resending with the same Idempotency-Key while the check is open also returns the same challenge link; a resent wallet charge does the same (Standalone wallets). Send the buyer to it once.

vora.redirectToChallenge() (vora.js 1.32.0 or later) checks that the URL is a genuine bank challenge before sending the buyer, and throws frame_3ds_required rather than navigate to a host it does not recognise, so do not replace it with a plain window.location assignment. It also accepts intent.next_action or the Node SDK's intent.nextAction as they are.

return_url must be absolute HTTPS (javascript: / data: / plain http: are rejected; localhost is accepted for local development). It is ignored when no challenge is required, so it is safe to always send, and it is not used for merchant-initiated charges (mit.initiator: "merchant") — an off-session rebill has no buyer browser to redirect. The return is UX only: the authoritative outcome is the charge.succeeded / charge.failed webhook (or GET /v1/payment_intents/{id}), never the query string the provider appends to your return_url.

Test cards​

Test payments run on a sandbox account, which runs 3-D Secure on every card payment. To test a challenge, pay with 4111 1111 1118 1072 (Visa) or 5240 0000 0000 1072 (Mastercard): the test page lets you pass or fail it. 4111 1111 1110 1203 and 5200 0000 0000 1203 verify without a challenge. Test cards has the full list, and the totals that decline. A merchant account's test key cannot take a test payment at all: it is refused with sandbox_account_required.

Error codes​

CodeWhen
frame_3ds_requiredCharge at submit only — the fail-closed case: 3DS was required but no usable challenge URL could be produced. Re-running submit() will not help; a different card fails identically. (Tokenize-only has no SDK error for this — the server's status: "requires_action" is the signal; see the hosted redirect.)
provider_request_rejectedNot an SDK code — HTTP 422 from the charge endpoint. The provider rejected the request; the card was neither charged nor declined, so a different card fails the same way until the request is corrected. selfHeal.retryable: false, nextAction: "fix_request".
frame_3ds_challenge_failedThe challenge ran and was rejected, or failed technically. Not buyer cancellation.
frame_3ds_challenge_timeoutThe buyer did not complete within challengeTimeout.
frame_3ds_challenge_cancelledThe buyer closed the challenge. Nothing charged, the card is fine — an abandoned checkout, not a failure.
frame_tokenization_failedThe processor rejected the card before any challenge.
frame_payment_declinedThe issuer declined the charge on a charge-and-save / charge-only submit.

Full handling reference: Errors.

What's next​

  • Charge at submit — the redirect contract, AVS/CVV results, recovering the saved card
  • Quickstart — end-to-end card-only happy path (no 3DS)
  • Errors — full VoraMirrorError reference