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:
| Session | When 3DS is required | You |
|---|---|---|
| Charge at submit / charge-and-save — the embed charges on submit | submit() resolves early with chargeStatus: "requires_action" and a redirectUrl; nothing is charged | Send 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_intents | The intent comes back status: "requires_action" with next_action | Send 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
| Code | When |
|---|---|
frame_3ds_required | Charge 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_rejected | Not 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_failed | The challenge ran and was rejected, or failed technically. Not buyer cancellation. |
frame_3ds_challenge_timeout | The buyer did not complete within challengeTimeout. |
frame_3ds_challenge_cancelled | The buyer closed the challenge. Nothing charged, the card is fine — an abandoned checkout, not a failure. |
frame_tokenization_failed | The processor rejected the card before any challenge. |
frame_payment_declined | The 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
VoraMirrorErrorreference