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
Step-by-step
- Your backend calls
POST /initialize-paymentwith the customer's card data, amount, and currency. - If
requiresAction: true, extractactionUrlfrom the response and return it to your frontend. - Your frontend performs a full-page redirect of the customer's browser to
actionUrl. Do not use an iframe — issuers reject embedded challenges. - The customer completes the 3DS challenge (OTP, biometric, or frictionless silent approval) on the issuer's page.
- The issuer redirects the customer back to your
returnUrl(or your app's resume page). - Your frontend signals your backend to call
POST /finalize-paymentusing the samepaymentId(theexternalOrderReffrom step 1). - Your backend calls
POST /finalize-paymentand returns the result to the customer.
Note
POST /finalize-paymentlooks up the payment bypaymentId(which is theexternalOrderRefyou 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 inlocalStoragefor 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.
lifecycleStatuswill becancelledrather thanfailed. 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.