3-D Secure

View as Markdown

3-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/config with a secret key (sk_, scope threeds:read). enabled is a per-environment platform flag: it is true when 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. When enabled is false, POST /v1/3ds/sessions and POST /v1/3ds/retry-decline answer 409 three_ds_not_enabled and do not open a provider session. A publishable key (pk_) on /v1/3ds/config is 403 permission_denied (threeds:read is not a browser scope). That is not "3DS is off". The Payment Element reads team/app ids from GET /v1/evervault-config (scope none, pk_ allowed) and never calls /v1/3ds/config. Use the secret key for enabled; use /evervault-config from 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_id inside next_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:

json
{
  "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#

  1. Create a charge. The server runs fraud + routing, then decides on a step-up.

  2. If a step-up is needed, the charge returns:

    json
    {
      "object": "charge",
      "status": "requires_action",
      "next_action": {
        "type": "three_ds_challenge",
        "three_ds": {
          "session_id": "tds_visa_439ab3fc28d1"
        }
      }
    }
    
  3. Run the challenge (see Running the challenge). When it completes, resubmit the same charge using the same Idempotency-Key, with three_ds.session_id set to the value above.

  4. 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, or auth_value on the charge body. They are not accepted as proof of authentication. Wallet charges must not carry three_ds at 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:

TriggerWhere it comes from
You askedthree_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 bandThe 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 3DSThe 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 itCard testing, a velocity rule, a signal rule (proxy, a distrusted device), or a network signal
The winning route has Attempt 3DS onThe 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) or 02 (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.request3DS runsThe issuer is told
automatic (default)when your rules askmandated (04)
anyalwaysno preference (01): frictionless whenever the issuer allows
challengealwayschallenge requested (03): your preference, the issuer may still go frictionless
challenge_mandatedalwaysmandated (04)
  • any is 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. Send challenge when you have your own reason to doubt a payment, and challenge_mandated only 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: any cannot soften a challenge your risk rules demanded.
  • It needs a card entered for this payment (an ev: card token). On POST /v1/charges a request on a saved card, a gateway vault id, a wp: envelope or a wallet is a 400 parameter_invalid (param: three_ds.request); to authenticate a saved card there, collect it again (Payment Element or tokenize) and send it as card.number. On a payment intent the Element does that for you: see 3DS on a saved card. A wallet or a card sealed on the wp: rail (the Element uses ev: unless you chose otherwise) pays without it.
  • The step-up then works exactly as in The contract. Repeating request on 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:

  1. 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/charges with "initiated_by": "customer". The payment needs 3DS: your three_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.
  2. The answer is requires_action with a three_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 with three_ds.session_id under the same Idempotency-Key.
  3. 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:

  1. That attempt is declined with soft_decline_3ds (on an intent: 200 requires_payment_method, last_payment_error.decline_code: soft_decline_3ds; on POST /v1/charges: 402 card_declined, decline_code: soft_decline_3ds). Nothing is charged. The intent or checkout stays payable.
  2. 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.
  3. 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.

PathRules 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 copyauthenticated in placeauthenticated in place
The same, older or imported saved cardcollected again, then authenticatedcollected again, then authenticated
Card typed for this paymentauthenticatedauthenticated
Renewal, recovery retry, invoice auto-charge, /v1/charges on a saved card without initiated_by: customercharged without 3DS400 on /v1/charges
Wallet (Apple Pay / Google Pay), ACHcharged without 3DScharged 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:

SessionResponse
Failed402 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 open409 three_ds_session_incomplete. Not stored: resubmit with the same key once the challenge finishes.
Authenticated, result not readable just now503 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_reasonMeaningdecline_code on the charge
authentication_failedThe cardholder did not pass the challengethree_ds_failed
canceledThe cardholder closed the challengethree_ds_failed
abandonedThe challenge timed outthree_ds_failed
rejectedThe issuer refused to authenticatethree_ds_failed
challenge_requiredA challenge was needed and the session was opened not to show onethree_ds_failed
card_not_enrolledThe 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_errorA 3DS server, directory or issuer-ACS faultprocessing_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.

failOnChallenge does 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:

bash
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. A wp:v1: envelope in card.wp_token does not carry a step-up, so tokenize to ev: 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 happenedVisa, Amex, Discover, JCBMastercardcardholder_authThe charge
The issuer authenticated the cardholder, frictionless or after a challenge0502verifiedGoes through. Liability shifts.
The issuer could not authenticate and the network answered in its place0601attemptedGoes through. Liability normally shifts.
No authentication0700(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:

FromEndpointKeyReturns
Your serverGET /v1/3ds/sessions/:idsk_ (threeds:read)The authoritative result: cavv, eci, ds_trans_id, liability_shift, failure_reason
The browserGET /v1/3ds/sessions/:id/statuspk_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.