# 3DS Authentication

3DS (3D Secure) is an additional authentication step issued by the card-holder's bank before a payment can be authorized. Fynex exposes 3DS only on the **server-to-server card flow** — when you call `POST /initialize-payment` directly from your backend. If you use the hosted checkout (`POST /checkout`), the checkout page handles 3DS internally and you never see a `requiresAction` response.

---

## When 3DS is triggered

After `POST /initialize-payment`, inspect the response:

```json
{
  "paymentId": "ORDER-100106",
  "status": "provider_pending",
  "lifecycleStatus": "pending",
  "legacyStatus": "provider_pending",
  "amount": 49.99,
  "currencyCode": "GBP",
  "requiresAction": true,
  "actionUrl": "https://acs.issuer-bank.example/challenge?token=XYZ"
}
```

When `requiresAction` is `true`, the payment's `lifecycleStatus` is `pending`. The customer must complete the issuer's challenge at `actionUrl` before the payment can be authorized. If `requiresAction` is `false` (frictionless flow), the payment's `lifecycleStatus` may already be `authorized` or `captured` and no redirect is needed.

---

## 3DS challenge types

| Type | Description |
|------|-------------|
| 3DS 2.0 frictionless | The issuer approves silently based on risk data. `requiresAction` is `false`; no redirect needed. |
| 3DS 2.0 step-up | The issuer requires an OTP or biometric. Customer is redirected to `actionUrl` for challenge completion. |
| 3DS 1.x fallback | Legacy challenge for cards not enrolled in 3DS 2.0. Same redirect mechanism. |

---

## Integration flow

```flow
lanes: Your backend | Fynex | Your frontend | Issuer ACS
node init 0 0 | Initialize payment | POST /initialize-payment | | end
node action 1 1 | Action required | requiresAction: true, actionUrl | | warn
node handoff 0 2 | Return actionUrl | to your frontend
node redirect 2 3 | Redirect browser | full-page to actionUrl
node challenge 3 4 | Customer completes | OTP / biometric challenge
node back 2 5 | Back at returnUrl | issuer redirects the browser
node finalize 0 6 | Finalize payment | POST /finalize-payment
node captured 1 7 | Payment captured | lifecycleStatus: "captured" | capturedAmount | end ok
edge init action | POST
edge action handoff | response
edge handoff redirect | actionUrl
edge redirect challenge | redirect browser
edge challenge back | redirect
edge back finalize | signal to finalize
edge finalize captured | POST
```

### Step-by-step

1. **Your backend** calls `POST /initialize-payment` with the customer's card data, amount, and currency.
2. If `requiresAction: true`, extract `actionUrl` from the response and return it to your frontend.
3. **Your frontend** performs a full-page redirect of the customer's browser to `actionUrl`. Do not use an iframe — issuers reject embedded challenges.
4. The customer completes the 3DS challenge (OTP, biometric, or frictionless silent approval) on the issuer's page.
5. The issuer redirects the customer back to your `returnUrl` (or your app's resume page).
6. **Your frontend** signals your backend to call `POST /finalize-payment` using the same `paymentId` (the `externalOrderRef` from step 1).
7. Your backend calls `POST /finalize-payment` and returns the result to the customer.

> [!NOTE]
> `POST /finalize-payment` looks up the payment by `paymentId` (which is the `externalOrderRef` you originally passed to `/initialize-payment`). You do not need a separate payment lookup between steps 6 and 7.

---

## State continuity across the redirect

The 3DS challenge is a **full-page redirect** that bounces the customer's browser away from your origin and back. Any in-memory state your frontend holds is lost during that hop.

Bridge the gap using `localStorage` (or a server-side session/cookie):

**Before the redirect — save what you need:**
```js
localStorage.setItem('checkout_payment_id', paymentId);
localStorage.setItem('checkout_amount', amount.toString());
localStorage.setItem('checkout_currency', currencyCode);
// add any other fields your resume page needs
```

**After the redirect lands on your resume page — restore and finalize:**
```js
const paymentId = localStorage.getItem('checkout_payment_id');
const amount    = parseFloat(localStorage.getItem('checkout_amount') || '0');

// signal backend to finalize
await finalizePayment(paymentId, amount);

// clean up
localStorage.removeItem('checkout_payment_id');
localStorage.removeItem('checkout_amount');
localStorage.removeItem('checkout_currency');
```

> [!CAUTION]
> The Fynex hosted dashboard uses `checkout_*` keys in `localStorage` for exactly this purpose. If you are building a custom integration on the same origin as the dashboard, use distinct key names to avoid collisions.

---

## Double-completion guard (React strict mode)

In React 18+ strict mode, effects fire twice in development. If your resume page calls `/finalize-payment` inside a `useEffect`, it may fire twice — resulting in a double-capture attempt.

Guard against this with a ref:

```jsx
import { useEffect, useRef } from 'react';

function PaymentResumePage() {
  const hasSubmitted = useRef(false);

  useEffect(() => {
    if (hasSubmitted.current) return;
    hasSubmitted.current = true;

    const paymentId = localStorage.getItem('checkout_payment_id');
    if (!paymentId) return;

    finalizePayment(paymentId).then((result) => {
      // handle success or failure
    });
  }, []);

  return <div>Processing payment…</div>;
}
```

---

## Code samples

#### curl — initialize

```bash
curl -sS -X POST "$FYNEX_API/initialize-payment" \
  -H "Authorization: Bearer $FYNEX_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "externalOrderRef": "ORDER-100106",
    "amount": 49.99,
    "paymentType": "card",
    "paymentMethod": "card",
    "currencyCode": "GBP",
    "countryCode": "GB",
    "autoSettlement": false,
    "captureMode": "manual",
    "cardData": {
      "cardNumber": "4111111111111111",
      "expMonth": 12,
      "expYear": 2028,
      "holderName": "Jane Smith",
      "cvv": "123"
    }
  }'
# If requiresAction is true, redirect customer to actionUrl.
# Then call /finalize-payment once they return.
```

#### JavaScript — browser redirect + resume

```js
// ── Step 1: initialize (call from your backend and return actionUrl to the browser) ──
// On your frontend, after receiving actionUrl from your server:

async function startPayment({ paymentId, amount, currencyCode, actionUrl }) {
  if (actionUrl) {
    // Save state before the full-page redirect
    localStorage.setItem('checkout_payment_id', paymentId);
    localStorage.setItem('checkout_amount', String(amount));
    localStorage.setItem('checkout_currency', currencyCode);

    // Redirect customer to the 3DS challenge page
    window.location.href = actionUrl;
    return; // execution stops here; browser navigates away
  }

  // No 3DS required — proceed directly
  await notifyBackendToFinalize(paymentId, amount);
}

// ── Step 2: resume page (your returnUrl lands here) ──
// Runs after the issuer redirects the customer back to your site.

async function onPaymentReturn() {
  const paymentId = localStorage.getItem('checkout_payment_id');
  const amount    = parseFloat(localStorage.getItem('checkout_amount') || '0');

  if (!paymentId) {
    console.error('No pending payment found in localStorage');
    return;
  }

  try {
    const result = await notifyBackendToFinalize(paymentId, amount);
    // result.lifecycleStatus will be 'captured' on success ('authorized' for a manual-capture payment)
    displaySuccessUI(result);
  } catch (err) {
    displayErrorUI(err);
  } finally {
    localStorage.removeItem('checkout_payment_id');
    localStorage.removeItem('checkout_amount');
    localStorage.removeItem('checkout_currency');
  }
}
```

---

## Triggering (and skipping) 3DS

On this sandbox, whether a payment goes through a 3DS challenge is controlled by the
**`skip3DS` request flag — not by the card number**:

| Request | Behaviour |
|---------|-----------|
| `"skip3DS": false` (or omitted) | 3DS requested → `requiresAction: true` + `actionUrl`. Complete the challenge, then `/finalize-payment`. |
| `"skip3DS": true` | No 3DS → `requiresAction: false`, the payment authorizes without a redirect. |

Use any of the sandbox Visa PANs from the [Test cards & sandbox](https://docs.fynex.ai/payments-api/v2/docs#tag/test-cards) guide (e.g.
`4111 1111 1111 1111`) with any future expiry — the same card works for both the 3DS and the
no-3DS flow depending on `skip3DS`.

---

## Failure cases

If the customer cancels the 3DS challenge, fails the OTP, or the issuer declines, the payment transitions to `failed` or `cancelled`. Your resume page should handle these gracefully.

The `/finalize-payment` response will carry a `lifecycleStatus` of `failed` or `cancelled` plus `failureCode` and `failureDescription`:

```json
{
  "paymentId": "ORDER-100106",
  "status": "failed",
  "lifecycleStatus": "failed",
  "legacyStatus": "failed",
  "failureCode": 2001,
  "failureDescription": "Card declined by issuer"
}
```

Common failure scenarios:

| Scenario | Resulting `lifecycleStatus` | Recommended UX |
|----------|-----------------|----------------|
| Customer clicks "Cancel" on issuer page | `cancelled` | Show a "Payment cancelled" message with option to retry |
| Wrong OTP / too many attempts | `failed` | Show error; allow customer to try a different card |
| Issuer hard decline | `failed` | Show generic decline message; do not expose issuer reason verbatim |
| Session timeout | `failed` | Prompt customer to start checkout again |

> [!NOTE]
> Detect user-cancellation from the error shape rather than treating it as a generic failure. `lifecycleStatus` will be `cancelled` rather than `failed`. Show a neutral "Payment not completed" message rather than an error for cancellations.

## See also

- **[Server-to-Server](https://docs.fynex.ai/payments-api/v2/docs#tag/server-to-server)** — Full reference for POST /initialize-payment and POST /finalize-payment.
- **[Payment Lifecycle](https://docs.fynex.ai/payments-api/v2/docs#tag/payment-lifecycle)** — All payment statuses and how they transition.
- **[Errors](https://docs.fynex.ai/payments-api/v2/docs#tag/errors)** — HTTP status codes and error response shapes.
