Handling 3D Secure
3D Secure (3DS) adds a layer of cardholder authentication to reduce fraud. When 3DS is triggered, the cardholder may need to verify their identity with their issuing bank before the payment is authorized.
3DS Modes
| Mode | Cardholder Interaction | Description |
|---|---|---|
| Data-only | None | Transaction data is sent to the issuer for risk assessment; no cardholder action needed |
| Challenge | Required | Cardholder verifies via OTP, biometrics, or bank app |
Important: In the US market, banks are not mandated to enforce step-up authentication. The issuing bank decides whether to issue a challenge, even if you request one during tokenization. 3DS in the AFT (Account Funding Transaction) market is for fraud control only — there is no liability shift.
Enabling 3DS
3DS can be enabled in two ways:
- Automatic (backend configuration) — All payments require 3DS. Configured by the Inyo team during onboarding.
- Manual (per-transaction) — You control when to request 3DS by setting
threeDSDataduring card tokenization.
Integration Options
When the payment API returns status: "CHALLENGE", the cardholder must complete authentication. You have two ways to handle the challenge result:
| Option | How it works | Best for |
|---|---|---|
| URL Redirect | Browser redirects to redirectAcsUrl; after authentication, the 3DS provider redirects back to your successUrl or failUrl | Full-page checkout flows, server-rendered apps |
| PostMessage | Open redirectAcsUrl in an iframe; the 3DS provider sends a postMessage event to the parent window with the result | Single-page apps (SPAs), embedded/modal checkout experiences |
Option 1: URL Redirect
The traditional approach. The cardholder's browser navigates to the bank's 3DS page, and after authentication, is redirected back to your site.
Tokenizer Configuration
const tokenizer = new InyoTokenizer({
targetId: '#payment-form',
publicKey: 'YOUR_PUBLIC_KEY',
threeDSData: {
enable: true,
successUrl: 'https://yoursite.com/3ds/success',
failUrl: 'https://yoursite.com/3ds/fail'
},
successCallback: handleSuccess,
errorCallback: handleError
});
Flow
1. Frontend tokenizes card → receives token
2. Backend calls POST /v2/payment with token
3. API returns status: "CHALLENGE" + redirectAcsUrl
4. Frontend redirects browser to redirectAcsUrl
5. Cardholder completes bank authentication
6. Bank redirects to successUrl (approved) or failUrl (rejected)
7. Your server-side handler receives a POST with payment data
8. Backend confirms payment status via GET /payments/{id}
Step 4 — Redirect to Challenge
const response = await createPayment(paymentData);
if (response.data.status === 'CHALLENGE' && response.data.redirectAcsUrl) {
// Full-page redirect to bank's 3DS page
window.location.href = response.data.redirectAcsUrl;
}
Step 7a — Success URL Handler
The 3DS provider sends a POST to your successUrl with payment data:
{
"paymentId": "dce568c6-98ec-456c-bb33-4a6809c4fff8",
"externalPaymentId": "order-12345",
"amount": "99.99",
"approved": "true"
}
Server-side handler example (Node.js / Express):
// POST /3ds/success
app.post('/3ds/success', async (req, res) => {
const { paymentId, externalPaymentId } = req.body;
// CRITICAL: Always verify via API — don't trust the redirect payload alone
const payment = await fetch(
`https://{FQDN}/payments/${externalPaymentId}`,
{ headers: { 'Authorization': `Bearer ${accessToken}` } }
).then(r => r.json());
if (payment.status === 'AUTHORIZED' || payment.status === 'CAPTURED') {
// Payment confirmed — show success page
res.redirect(`/order/${externalPaymentId}/confirmed`);
} else {
// Unexpected state — show error
res.redirect(`/order/${externalPaymentId}/error`);
}
});
Step 7b — Failure URL Handler
The 3DS provider sends a POST to your failUrl:
{
"paymentId": "dce568c6-98ec-456c-bb33-4a6809c4fff8",
"externalId": "order-12345",
"amount": 99.99,
"approved": false,
"status": "Rejected by ACS",
"responseMsg": "Rejected by ACS",
"responseCode": "99",
"signatureVerification": "N",
"acsChallengeRequired": true,
"acsParStatus": "N"
}
// POST /3ds/fail
app.post('/3ds/fail', (req, res) => {
// Authentication failed — redirect user back to payment page
res.redirect('/checkout?error=3ds_failed');
});
Note: When authentication fails, the payment was never authorized. Do not attempt to capture or void.
Option 2: JavaScript PostMessage
For single-page apps and embedded checkout experiences, you can open the 3DS challenge in an iframe and receive the result via the browser's postMessage API — no full-page redirect needed.
PostMessage URL Whitelisting (Required)
The URL of the page that will receive the postMessage events must be registered with Inyo. This is a security measure to ensure that only your intended page can listen to 3DS authentication results.
- Before going live, provide Inyo with the exact origin URL (e.g.,
https://checkout.yoursite.com) of the page that listens forpostMessageevents. - Any change to the URL must be communicated to Inyo so the whitelist can be updated.
- 3DS results will not be delivered via
postMessageto non-whitelisted origins.
This is in addition to the tokenizer domain whitelisting described in Tokenizing Cards. Both the tokenizer page URL and the PostMessage listener page URL must be registered.
Tokenizer Configuration
Set enablePostMessage: true in threeDSData:
const tokenizer = new InyoTokenizer({
targetId: '#payment-form',
publicKey: 'YOUR_PUBLIC_KEY',
threeDSData: {
enable: true,
enablePostMessage: true
},
successCallback: handleSuccess,
errorCallback: handleError
});
Key difference: When using
enablePostMessage: true, you do not need to providesuccessUrlorfailUrl. The result is delivered viapostMessageinstead of a redirect.
Flow
1. Frontend tokenizes card → receives token
2. Backend calls POST /v2/payment with token
3. API returns status: "CHALLENGE" + redirectAcsUrl
4. Frontend opens redirectAcsUrl in an iframe (or modal)
5. Cardholder completes bank authentication inside the iframe
6. 3DS provider sends a postMessage to the parent window
7. Frontend listens for the message and handles the result
8. Backend confirms payment status via GET /payments/{id}
Step 4 — Open Challenge in Iframe
<!-- Hidden iframe for 3DS challenge -->
<div id="threeds-container" style="display: none;">
<iframe id="threeds-iframe" width="100%" height="500"
sandbox="allow-forms allow-scripts allow-same-origin allow-popups">
</iframe>
</div>
const response = await createPayment(paymentData);
if (response.data.status === 'CHALLENGE' && response.data.redirectAcsUrl) {
// Show the iframe container
document.getElementById('threeds-container').style.display = 'block';
// Load the 3DS challenge page
document.getElementById('threeds-iframe').src = response.data.redirectAcsUrl;
}
Step 7 — Listen for PostMessage
window.addEventListener('message', async (event) => {
// Validate the origin for security
if (!event.origin.includes('simpleps.com')) return;
const data = event.data;
// Hide the iframe
document.getElementById('threeds-container').style.display = 'none';
if (data.approved === true || data.approved === 'true') {
// 3DS succeeded — verify payment status from your backend
const paymentStatus = await verifyPaymentOnBackend(data.externalPaymentId);
if (paymentStatus === 'AUTHORIZED' || paymentStatus === 'CAPTURED') {
showSuccessMessage();
} else {
showErrorMessage('Payment could not be confirmed.');
}
} else {
// 3DS failed — let user retry
showErrorMessage('Card verification failed. Please try again or use a different card.');
}
});
PostMessage Payload — Success
{
"paymentId": "dce568c6-98ec-456c-bb33-4a6809c4fff8",
"externalPaymentId": "order-12345",
"amount": 99.99,
"approved": true
}
PostMessage Payload — Failure
{
"paymentId": "dce568c6-98ec-456c-bb33-4a6809c4fff8",
"externalPaymentId": "order-12345",
"amount": 99.99,
"approved": false,
"status": "Rejected by ACS",
"responseCode": "99"
}
Complete SPA Example
// Full 3DS handling for a single-page app
class ThreeDSHandler {
constructor() {
this.iframe = document.getElementById('threeds-iframe');
this.container = document.getElementById('threeds-container');
this.pendingResolve = null;
window.addEventListener('message', (event) => {
if (!event.origin.includes('simpleps.com')) return;
this.handleResult(event.data);
});
}
// Returns a promise that resolves when 3DS completes
startChallenge(redirectAcsUrl) {
return new Promise((resolve) => {
this.pendingResolve = resolve;
this.container.style.display = 'block';
this.iframe.src = redirectAcsUrl;
});
}
handleResult(data) {
this.container.style.display = 'none';
this.iframe.src = '';
if (this.pendingResolve) {
this.pendingResolve({
success: data.approved === true || data.approved === 'true',
paymentId: data.paymentId,
externalPaymentId: data.externalPaymentId
});
this.pendingResolve = null;
}
}
}
// Usage in your checkout flow:
const threeds = new ThreeDSHandler();
async function processPayment(paymentData) {
const response = await createPayment(paymentData);
if (response.data.status === 'CHALLENGE') {
const result = await threeds.startChallenge(response.data.redirectAcsUrl);
if (result.success) {
// Verify on backend, then show confirmation
const verified = await verifyPayment(result.externalPaymentId);
showConfirmation(verified);
} else {
showRetryPrompt();
}
} else if (response.data.status === 'AUTHORIZED') {
showConfirmation(response.data);
} else {
showDeclineMessage(response.data.message);
}
}
Choosing Between Redirect and PostMessage
| Consideration | URL Redirect | PostMessage |
|---|---|---|
| User experience | Full page navigation; user leaves your checkout | Seamless; challenge appears in iframe/modal |
| Implementation complexity | Simpler — just set URLs | More code — manage iframe + event listener |
| Server requirements | Need server-side POST handlers for success/fail URLs | No server handlers needed for 3DS result |
| SPA compatibility | Requires workarounds (state loss on redirect) | Native fit for SPAs |
| Security | Result delivered server-to-server (POST to your URL) | Result delivered client-side (validate origin!) |
| Mobile webview | Works everywhere | Some webviews restrict iframe postMessage |
Recommendation:
- Use URL Redirect if you have a traditional server-rendered checkout or need maximum compatibility.
- Use PostMessage if you're building an SPA or want an embedded checkout experience without page navigation.
Regardless of which option you choose, always verify the payment status via the API (GET /payments/{externalPaymentId}) before fulfilling the order.
Testing 3DS
Use the 3DS-enabled test cards from the Test Data page:
| Card Number | Network | 3DS Type |
|---|---|---|
5413330033003303 | Mastercard | Challenge |
5169527513596963 | Mastercard | Challenge |
4983305199046950 | Visa | Challenge |
5454545454545454 | Mastercard | Data-only |
4975303994654672 | Visa | Data-only |
6011926557021045 | Discover | Data-only |
Implementation Checklist
- [ ] Register your tokenizer page URL with Inyo (required for CORS)
- [ ] Choose your integration method: URL Redirect or PostMessage
- [ ] If Redirect: Configure
successUrlandfailUrlin tokenizer; implement server-side POST handlers - [ ] If PostMessage: Register your PostMessage listener page URL with Inyo; set
enablePostMessage: truein tokenizer; implementmessageevent listener with origin validation - [ ] Detect
status: "CHALLENGE"in payment responses - [ ] Handle the 3DS result (success and failure)
- [ ] Always verify payment status via GET API after 3DS completes
- [ ] Test with 3DS test cards in sandbox
- [ ] Handle edge cases: user closes iframe/tab, timeout, network errors
- [ ] Notify Inyo of any URL changes before deploying to new domains
