3-D Secure
View as Markdown3-D Secure (3DS) is an issuer-driven step-up that asks the cardholder to prove they are who they say they are, usually with a one-time code or an in-app approval. A successful authentication shifts liability for fraud-related chargebacks to the issuer; other disputes (not received, not as described) stay with you.
Readiness. Call
GET /v1/3ds/configwith a secret key (sk_, scopethreeds:read).enabledis a per-environment platform flag: it istruewhen the platform's 3DS provider is configured for that environment (test or live). It is not a per-merchant or per-acquirer check. Test mode is configured, so 3-D Secure runs there. Whenenabledisfalse,POST /v1/3ds/sessionsandPOST /v1/3ds/retry-declineanswer 409three_ds_not_enabledand do not open a provider session. A publishable key (pk_) on/v1/3ds/configis 403permission_denied(threeds:readis not a browser scope). That is not "3DS is off". The Payment Element reads team/app ids fromGET /v1/evervault-config(scopenone,pk_allowed) and never calls/v1/3ds/config. Use the secret key forenabled; use/evervault-configfrom the page.
The rest of this page is the contract when enabled is true. When it is false, treat
/v1/3ds/sessions and /v1/3ds/retry-decline as unavailable.
GET /v1/3ds/config # requires threeds:read + the "charge" exec-flow
# → { object, team_id, app_id, provider, enabled }
# enabled: false → sessions and retry-decline are 409
POST /v1/3ds/sessions # 409 three_ds_not_enabled when config.enabled is false
GET /v1/3ds/sessions/:id # server-managed, authoritative (cavv/eci), needs sk_
GET /v1/3ds/sessions/:id/status # browser-safe, liveness only, works with pk_
POST /v1/3ds/retry-decline # unknown original_transaction_id → 404 resource_missing
# enabled: false (known txn) → 409 three_ds_not_enabled
You normally never call session create or retrieve directly. When a charge is stepped up, the charge engine opens the session and returns its
session_idinsidenext_action. Do not poll a session that this surface refused to open.
GET /v1/3ds/config in an environment where 3DS is configured looks like this. enabled
is the field to branch on, not team_id/app_id being present:
{
"object": "three_ds_config",
"team_id": "team_...",
"app_id": "app_...",
"provider": "evervault",
"enabled": true,
"apple_pay_enabled": false,
"google_pay_enabled": false,
"evervault_merchant_id": ""
}
POST /v1/3ds/retry-decline with an unknown original_transaction_id (for example
TXN-deadbeef) is 404 resource_missing even when 3DS is disabled. That lookup
runs before readiness, so a fake original never becomes a provider error.
The contract#
-
Create a charge. The server runs fraud + routing, then decides on a step-up.
-
If a step-up is needed, the charge returns:
{ "object": "charge", "status": "requires_action", "next_action": { "type": "three_ds_challenge", "three_ds": { "session_id": "tds_visa_439ab3fc28d1" } } } -
Run the challenge (see Running the challenge). When it completes, resubmit the same charge using the same
Idempotency-Key, withthree_ds.session_idset to the value above. -
The resubmitted charge returns
succeeded(or an error). It charges only if the session authenticated — see A session that did not authenticate.
Authentication can precede charge creation: requires_action carries no charge id
until authentication completes. Any real transaction id is preserved, and payment
intents retain their intent id. requires_action is not a terminal idempotency response.
A saved card that needs 3DS is authenticated on the encrypted copy kept when it was saved.
One saved before 2026-10-18 or imported, with no such copy, is declined with
soft_decline_3ds (402 card_error / card_declined on charges). Ask the customer to
pay again with their card. On intents the decline returns 200 requires_payment_method
with last_payment_error, and the Payment Element asks the buyer to re-enter the card
(3DS on a saved card). Authenticated payments, ACH, wallets and
merchant-initiated charges retain their exemptions.
Temporary initiation or session-storage failures return 503 api_error; retry with the
same key. Deterministic provider rejections use the normal processing_error decline.
A response without a usable session is a 502 api_error, never requires_action.
The client sends back only the session_id. It never builds or posts a cryptogram.
WorthyPayments retrieves the authoritative cryptogram (CAVV/ECI) server-side from the session
before authorizing. A client-supplied cryptogram is never trusted.
Hard rule: do not put
cavv,eci,cryptogram, orauth_valueon the charge body. They are not accepted as proof of authentication. Wallet charges must not carrythree_dsat all (the API rejects that combination). See Integration hard rules.
When does a step-up happen?#
The charge engine decides, unless you ask (see Asking for 3DS). It is worth knowing what it decides on — mostly so you can tell "no challenge appeared" apart from "something is broken".
A charge is stepped up when any of these is true:
| Trigger | Where it comes from |
|---|---|
| You asked | three_ds.request is any, challenge or challenge_mandated on the charge, or on the payment intent it confirms |
| The risk score is in the 3DS band | The default 3DS policy steps up a charge whose fraud risk score is 70 to 95. If you decide 3DS yourself with three_ds.request, turn the band off (or move it) with POST /v1/fraud/three_ds_policy — see Fraud checks. |
| The customer is marked Require 3DS | The dashboard, in the mode you are viewing: Require 3DS on one of the customer's transactions, or Require 3D Secure on customers selected in the Customers list. Every later charge carrying that customer's email is stepped up (a different or missing email is a different customer; a merchant-initiated charge never is) until you click Turn off under Restrictions on the customer's page. Your server can read, set and clear the same flag with GET / POST / DELETE /v1/customers/{id}/three_ds — see Fraud checks. |
| Another fraud rule asked for it | Card testing, a velocity rule, a signal rule (proxy, a distrusted device), or a network signal |
| The winning route has Attempt 3DS on | The route's own toggle on the Payment Workflow canvas (read it with GET /v1/payment_workflow/routes) |
Nothing about the card itself is a step-up reason: a first-time card is not one, and no card number (a test card included) triggers 3DS on its own.
Four kinds of charge are excluded, and the reasons differ:
- A merchant-initiated charge — a subscription renewal, a decline-recovery retry, an
invoice auto-charge, or your server charging a saved card on
POST /v1/charges. There is no cardholder to complete a challenge, and card networks exempt these charges, so none of the triggers above applies to them: they are charged without 3DS. A blocking fraud rule still blocks them (3DS on a saved card). - ACH — there is no cardholder challenge to run.
- Apple Pay, and Google Pay with a network token — the wallet already carries its own
cryptogram and SCA. Stacking 3DS on top is refused by the processor, so a step-up would
turn a good payment into a declined one. The wallet's own liability shift is not
automatic: Google Pay shifts it only when its ECI is
05(Visa) or02(Mastercard), and leaves it with you on any other value and on other networks. - A charge already carrying an authentication — the resubmit after a completed challenge, whose session yielded a cryptogram. It goes straight through, which is what stops an authentication loop. An ECI without a cryptogram is not an authentication and exempts nothing.
A Google Pay PAN_ONLY credential is not in that list. It carries no cryptogram, so it
is charged as an ordinary card — and can be stepped up like one.
charge
│
├─ ACH? ─────────────────────── yes ─→ no step-up
├─ wallet with a cryptogram? ── yes ─→ no step-up (wallet SCA stands)
├─ already authenticated? ───── yes ─→ no step-up (this is the resubmit)
├─ merchant-initiated? ──────── yes ─→ no step-up (renewal, retry, server charge)
│
├─ fraud scoring asked for 3DS? ─┐ (risk band, Require 3DS customer, a rule)
├─ the route says Attempt 3DS? ─┼─ any ─→ STEP UP
├─ you sent three_ds.request? ─┘ │
│ │
└─ none ─→ charge proceeds │
▼
status: requires_action
next_action.three_ds.session_id
│
▼
you mount the challenge (once)
│
┌──────────────────────┴───────────────────┐
▼ ▼
issuer goes frictionless issuer presents a challenge
(nothing shown) (shopper enters a code)
└──────────────────┬───────────────────────┘
▼
resubmit with three_ds.session_id
▼
succeeded, or an error
Whether the shopper actually sees a screen#
When one of your rules triggers a step-up, WorthyPayments tells the issuer a challenge is
mandated (EMV 3DS challenge indicator 04) rather than inviting it to decide. That is
deliberate: a control named Require 3DS that produces no challenge is indistinguishable
from a broken integration, and an issuer offered the choice will usually take the
frictionless path. When the step-up is yours (three_ds.request), the issuer is told your
preference instead — see Asking for 3DS.
The issuer still has the last word. It may authenticate frictionlessly anyway, in which case your widget completes with nothing shown and the charge carries the same liability shift it would have had after a challenge. Both outcomes are success; only the shopper's experience differs.
Asking for 3DS#
Send three_ds.request on POST /v1/charges, or on POST /v1/payment_intents for a
payment the Payment Element confirms:
three_ds.request | 3DS runs | The issuer is told |
|---|---|---|
automatic (default) | when your rules ask | mandated (04) |
any | always | no preference (01): frictionless whenever the issuer allows |
challenge | always | challenge requested (03): your preference, the issuer may still go frictionless |
challenge_mandated | always | mandated (04) |
anyis the usual choice. The issuer skips the challenge whenever its own risk check allows, and a frictionless authentication shifts liability exactly as a challenge does, so it protects the payment at the lowest cost in abandoned checkouts. Sendchallengewhen you have your own reason to doubt a payment, andchallenge_mandatedonly when a rule obliges strong authentication: every challenge shown is a step where some shoppers leave.- Your rules outrank a request. When they ask for 3DS too, the challenge is mandated
whatever you sent:
anycannot soften a challenge your risk rules demanded. - It needs a card entered for this payment (an
ev:card token). OnPOST /v1/chargesa request on a saved card, a gateway vault id, awp:envelope or a wallet is a 400parameter_invalid(param: three_ds.request); to authenticate a saved card there, collect it again (Payment Element ortokenize) and send it ascard.number. On a payment intent the Element does that for you: see 3DS on a saved card. A wallet or a card sealed on thewp:rail (the Element usesev:unless you chose otherwise) pays without it. - The step-up then works exactly as in The contract. Repeating
requeston the resubmit is fine.
A session you open yourself (POST /v1/3ds/sessions, or /v1/3ds/retry-decline) takes
the same values as a top-level request, and the issuer is told that preference. Left out,
the session carries no preference, as before.
3DS on a saved card#
A saved card is a reference to a card held by the gateway. 3-D Secure needs the card itself. When a card is saved through the Payment Element, the hosted checkout or a card-update page, WorthyPayments keeps the card number in the encrypted form the buyer's browser produced (only our encryption provider can decrypt it). A saved card with that encrypted copy is authenticated in place:
- A buyer pays with a saved card in the Payment Element (a payment intent), on a hosted
checkout page (a payment link, an invoice, a store order, a subscription's first
payment), or on your own checkout, where your server charges it on
POST /v1/chargeswith"initiated_by": "customer". The payment needs 3DS: yourthree_ds.request, or any of your rules in When does a step-up happen?, including the route's Attempt 3DS, the 3DS risk band and a customer marked Require 3DS. - The answer is
requires_actionwith athree_ds.session_id, exactly as for a card typed for this payment (The contract). The Element and the hosted page run the challenge themselves. On your own checkout you run it in the buyer's browser (Running the challenge) and resubmit the same charge withthree_ds.session_idunder the sameIdempotency-Key. - After the buyer authenticates, the saved card is charged with the authentication attached. The buyer does not type the card.
Cards saved before 2026-10-18, and cards imported from your gateway vault, have no encrypted copy. For them the card is collected once more:
- That attempt is declined with
soft_decline_3ds(on an intent: 200requires_payment_method,last_payment_error.decline_code: soft_decline_3ds; onPOST /v1/charges: 402card_declined,decline_code: soft_decline_3ds). Nothing is charged. The intent or checkout stays payable. - The Payment Element, or the hosted page, hides the saved cards, opens the card form without the save box, and asks the buyer to enter the same card again ("Your bank needs to verify this card…", in English or Spanish). Your own checkout does the same: collect the card in the Payment Element for this payment.
- That card is a new attempt: it is authenticated like any card entered for the payment, and charged. It is not saved as a second card. When it is the same card as the saved one, its encrypted copy is kept on the saved card, so the next 3DS on that card is authenticated in place: the buyer types it once, never again.
You receive a charge.failed for the declined saved-card attempt (decline_code
soft_decline_3ds) and then the normal events for the re-entered card. Only the second
attempt moves money. The declined attempt is not retried by decline recovery and does not
count toward card-testing or per-IP decline velocity.
A merchant-initiated charge is never sent to 3-D Secure. A subscription renewal, a
decline-recovery retry, an invoice auto-charge, or your server charging a saved card on
POST /v1/charges without "initiated_by": "customer" has no cardholder to complete a
challenge, and card networks exempt these charges from strong customer authentication. They
are charged without 3DS whatever asked for it: the route's Attempt 3DS, the 3DS risk
band, a customer marked Require 3DS, or a fraud rule (card testing, a velocity or signal
rule) that asks for 3DS. A fraud rule that blocks still blocks them. A
three_ds.request your server sends on such a charge is refused up front with a 400.
| Path | Rules ask for 3DS (route, risk band, Require-3DS customer, fraud rule) | three_ds.request |
|---|---|---|
Payment Element / hosted checkout / /v1/charges with initiated_by: customer, saved card with an encrypted copy | authenticated in place | authenticated in place |
| The same, older or imported saved card | collected again, then authenticated | collected again, then authenticated |
| Card typed for this payment | authenticated | authenticated |
Renewal, recovery retry, invoice auto-charge, /v1/charges on a saved card without initiated_by: customer | charged without 3DS | 400 on /v1/charges |
| Wallet (Apple Pay / Google Pay), ACH | charged without 3DS | charged without 3DS |
A session that did not authenticate#
A resubmit with three_ds.session_id charges only when that session authenticated with a
cryptogram — fully, or an "attempted" authentication that carries one. Otherwise nothing
reaches the card network:
| Session | Response |
|---|---|
| Failed | 402 card_error / card_declined, param: three_ds.session_id, decline_code three_ds_failed, three_ds_card_not_enrolled or processing_error. Stored under the key like any decline: a new attempt is a new session and a new key. |
| Still open | 409 three_ds_session_incomplete. Not stored: resubmit with the same key once the challenge finishes. |
| Authenticated, result not readable just now | 503 three_ds_result_unavailable. Not stored: retry with the same key. |
GET /v1/3ds/sessions/:id (and /status) names why a session failed in failure_reason:
failure_reason | Meaning | decline_code on the charge |
|---|---|---|
authentication_failed | The cardholder did not pass the challenge | three_ds_failed |
canceled | The cardholder closed the challenge | three_ds_failed |
abandoned | The challenge timed out | three_ds_failed |
rejected | The issuer refused to authenticate | three_ds_failed |
challenge_required | A challenge was needed and the session was opened not to show one | three_ds_failed |
card_not_enrolled | The card could not be authenticated and no attempt was recorded for it (for example, the card is unknown to the network's directory or has no supported 3DS version) | three_ds_card_not_enrolled |
processing_error | A 3DS server, directory or issuer-ACS fault | processing_error |
When the issuer cannot authenticate#
An issuer that does not take part in 3-D Secure normally leaves no gap: the card network
answers in its place, and the session ends attempted (ECI 06, or 01 on Mastercard)
with a cryptogram. The charge goes through, the shopper sees nothing, and liability normally
shifts (see Liability shift).
Only when nobody can answer, because the card is unknown to the network's directory or the
directory or issuer is down, does the session fail with card_not_enrolled or
processing_error. The charge is then refused, never quietly sent without 3DS. An unenrolled
card leaves you a choice the others do not: charge it again without three_ds if you accept
that no liability shifts, or ask for another card.
The challenge indicator plays no part in this. 01, 03 and 04 speak to an issuer that
can authenticate: a mandate does not make a non-participating issuer answer, and any does
not turn into a charge without 3DS.
Running the challenge#
Mount the widget once. It handles both outcomes itself: a frictionless authentication completes with nothing shown, and a challenge is presented only when the issuer asks for one. Keep the widget hidden until it signals it is ready, so a frictionless pass shows the shopper nothing.
Do not probe first with
failOnChallenge#An earlier version of this page recommended a two-pass flow: an invisible probe mounted with
failOnChallenge, then a visible re-mount if the probe reported a challenge was needed. That second pass can never work, and this guide was wrong to teach it.
failOnChallengedoes not report — it acts. From the provider's own documentation:If a challenge is requested, Evervault automatically fails the 3DS session, which prevents the challenge from ever being shown.
So the probe does not ask a question, it answers it destructively. By the time the second pass tries to re-render, the session is already
failed, and every challenge-flow card ends as a declined payment with no screen ever shown. It looks like an issuer problem and is not.If you built the two-pass flow from this page, delete the probe. One mount is all the provider asks for.
Once authentication completes, resubmit the charge. The resubmit carries the session id and nothing else about the authentication:
curl https://api.worthypayments.com/v1/charges \
-H "x-api-key: sk_live_xxx" \
-H "Idempotency-Key: ord_1234" \
-H "Content-Type: application/json" \
-d '{
"amount": 4999,
"currency": "usd",
"card": { "number": "ev:...", "cvv": "ev:...", "exp_month": "12", "exp_year": "2027" },
"three_ds": { "session_id": "tds_visa_439ab3fc28d1" }
}'
Resubmit the same charge: same amount, same currency, same instrument. The 3DS session
is bound to the amount it authenticated. A resubmit with a different amount or currency is
refused before any charge with 400 invalid_request_error, code: three_ds_amount_mismatch,
param: three_ds.session_id: run a new challenge for the amount you are charging.
session_id is the only field next_action.three_ds carries. The ACS URL, creq and
challenge URL are resolved inside the 3DS flow and are never surfaced on the charge
response.
3-D Secure runs on the
ev:tokenization rail. Awp:v1:envelope incard.wp_tokendoes not carry a step-up, so tokenize toev:when a charge may need authentication. See Card tokenization.
Bootstrapping the challenge widget#
Only after GET /v1/3ds/config reports enabled: true. Client ids for the widget come
from the public config endpoint:
GET https://api.worthypayments.com/v1/evervault-config
→ { "object": "evervault_config", "mode": "live", "team_id": "team_...", "app_id": "app_..." }
Those values initialize the Evervault 3DS UI, mount the (hidden, then visible) challenge
iframe, and report the result. The endpoint is scope-free, so a publishable key can read
it, unlike GET /3ds/config, which requires threeds:read because opening a session bills
your acquirer and a browser key must never be able to do that.
The ids are resolved per mode: a test key gets the platform's test app, a live key its
live app. An ev: token is encrypted against one Evervault app and only that app can
decrypt it, so never hardcode them: a token minted against the wrong app fails at the
gateway with an opaque error rather than at encryption time.
Liability shift#
When 3-D Secure authentication succeeds, liability for a fraud-related chargeback shifts from you (the merchant) to the card issuer. Declines and disputes drop on authenticated traffic. Because WorthyPayments decides per-charge when a step-up is worthwhile, most shoppers are never stepped up at all — see When does a step-up happen?.
A frictionless authentication carries the same liability shift as a completed
challenge. So a charge that came back requires_action and then finished with nothing
shown to the shopper is not a weaker outcome; it is the best one.
What the ECI says#
GET /v1/3ds/sessions/:id returns the eci (Electronic Commerce Indicator): the network's
two-digit record of how the issuer answered. The same digits mean different things on
different networks, so read them together with the card's brand:
| What happened | Visa, Amex, Discover, JCB | Mastercard | cardholder_auth | The charge |
|---|---|---|---|---|
| The issuer authenticated the cardholder, frictionless or after a challenge | 05 | 02 | verified | Goes through. Liability shifts. |
| The issuer could not authenticate and the network answered in its place | 06 | 01 | attempted | Goes through. Liability normally shifts. |
| No authentication | 07 | 00 | (empty) | Refused, see A session that did not authenticate. |
Mastercard also returns 07 for an authenticated recurring payment, which shifts liability,
and 06 for an exemption, which does not.
liability_shift on the session is the final answer for that card. The networks exclude some
cases an ECI cannot show (on Visa, for example, an attempt on a non-reloadable prepaid card),
so read liability_shift rather than inferring the shift from cardholder_auth or the ECI.
The 04 in Asking for 3DS is not an ECI: it is the EMV 3DS challenge
indicator, a preference you send. The ECI is the result you get back.
Which session endpoint to call#
Which endpoint you poll depends on which key you hold:
| From | Endpoint | Key | Returns |
|---|---|---|---|
| Your server | GET /v1/3ds/sessions/:id | sk_ (threeds:read) | The authoritative result: cavv, eci, ds_trans_id, liability_shift, failure_reason |
| The browser | GET /v1/3ds/sessions/:id/status | pk_ | Liveness only: status, authenticated, cryptogram_present, failure_reason |
The split is deliberate. The authoritative response contains the cryptogram your charge
is authorised against, and a publishable key is public by construction. It ships in
your page source, so anyone reading the page could lift a completed authentication and
attach it to their own charge. A publishable key therefore cannot hold
threeds:read; calling the authoritative route with a pk_ returns 403.
You never need the cryptogram in the browser anyway: pass three_ds.session_id to
POST /v1/charges and the server fetches the authoritative value itself.
