# 3-D Secure

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](./03-charges.md). 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](#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](./10-errors.md)). It charges
   only if the session authenticated — see
   [A session that did not authenticate](#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](#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](./27-integration-hard-rules.md#3-3-d-secure-session-id-only).

---

## When does a step-up happen?

The charge engine decides, unless you ask (see [Asking for 3DS](#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](./23-fraud-checks.md#the-risk-score-3ds-step-up). |
| 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](./23-fraud-checks.md#3ds-and-fraud-controls-from-your-own-tools). |
| 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](#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](#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) |

- **`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](#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](#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?](#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-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](#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](./08b-payment-element.md#3-d-secure-on-a-saved-card), 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.

| 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](#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](./28-card-tokenization.md).

---

## 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?](#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](#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](#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.
