# 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",
  "amount": 49.99,
  "currencyCode": "GBP",
  "requiresAction": true,
  "actionUrl": "https://acs.issuer-bank.example/challenge?token=XYZ"
}
```

When `requiresAction` is `true`, the payment is in `provider_pending` state. The customer must complete the issuer's challenge at `actionUrl` before the payment can be authorized. If `requiresAction` is `false` (frictionless flow), the payment may already be `authorized` or `provider_completed` 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

```
Your Backend                   Your Frontend              Issuer ACS
    │                               │                          │
    │── POST /initialize-payment ──►│                          │
    │◄── { requiresAction: true,    │                          │
    │      actionUrl: "https://..." }│                         │
    │                               │                          │
    │── return actionUrl ──────────►│                          │
    │                               │── redirect browser ─────►│
    │                               │                          │
    │                               │  (customer completes     │
    │                               │   OTP / biometric)       │
    │                               │                          │
    │                               │◄── redirect to returnUrl ─┤
    │                               │                          │
    │◄── signal to finalize ────────│                          │
    │                               │                          │
    │── POST /finalize-payment ─────►                          │
    │◄── { status: "provider_completed", capturedAmount: ... } │
```

### 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.status will be 'provider_completed' on success
    displaySuccessUI(result);
  } catch (err) {
    displayErrorUI(err);
  } finally {
    localStorage.removeItem('checkout_payment_id');
    localStorage.removeItem('checkout_amount');
    localStorage.removeItem('checkout_currency');
  }
}
```

#### Python — finalize

```python
import os
import uuid
import requests

FYNEX_API   = os.environ["FYNEX_API"]   # e.g. https://api.fynex.ai/payments-api/v1
FYNEX_TOKEN = os.environ["FYNEX_TOKEN"]

def finalize_payment(payment_id: str, amount: float | None = None) -> dict:
    """
    Call after the customer completes the 3DS challenge.
    payment_id is the externalOrderRef from /initialize-payment.
    amount is optional; omit to capture the full authorized amount.
    """
    body: dict = {"paymentId": payment_id}
    if amount is not None:
        body["amount"] = amount

    response = requests.post(
        f"{FYNEX_API}/finalize-payment",
        json=body,
        headers={
            "Authorization": f"Bearer {FYNEX_TOKEN}",
            "Content-Type": "application/json",
            "Idempotency-Key": str(uuid.uuid4()),
        },
        timeout=30,
    )
    response.raise_for_status()
    return response.json()

# Example:
# result = finalize_payment("ORDER-100106", amount=49.99)
# print(result["status"])   # "provider_completed"
# print(result["capturedAmount"])  # 49.99
```

---

## 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://api.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 non-success `status` plus `failureCode` and `failureDescription`:

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

Common failure scenarios:

| Scenario | Resulting status | 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. The `status` field 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://api.fynex.ai/payments-api/v2/docs#tag/server-to-server)** — Full reference for POST /initialize-payment and POST /finalize-payment.
- **[Payment Lifecycle](https://api.fynex.ai/payments-api/v2/docs#tag/payment-lifecycle)** — All payment statuses and how they transition.
- **[Errors](https://api.fynex.ai/payments-api/v2/docs#tag/errors)** — HTTP status codes and error response shapes.
