Skip to content
In this section

3DS Authentication

How to handle 3DS challenges in the server-to-server card flow — the redirect lifecycle, localStorage bridge, and finalize call.

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:

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

Your backendFynexYour frontendIssuer ACSPOSTresponseactionUrlredirect browserredirectsignal to finalizePOSTInitialize paymentPOST /initialize-paymentAction requiredrequiresAction: true, actionUrlReturn actionUrlto your frontendRedirect browserfull-page to actionUrlCustomer completesOTP / biometric challengeBack at returnUrlissuer redirects the browserFinalize paymentPOST /finalize-paymentPayment capturedlifecycleStatus: "captured"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:

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:

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:

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

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

// ── 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 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:

{
  "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 — Full reference for POST /initialize-payment and POST /finalize-payment.
  • Payment Lifecycle — All payment statuses and how they transition.
  • Errors — HTTP status codes and error response shapes.