Recipe: 3DS Card Payment Integration¶
This recipe walks you through the complete 3D Secure card payment flow — from authentication and card encryption through to device fingerprinting, challenge handling, and status verification. All examples use plain HTTP requests (with curl) for server-side steps and browser JavaScript for the client-side steps that 3DS requires.
Prerequisites¶
You will need the following from your merchant dashboard:
- API Base URL and OIDC Base URL — see Environments
- Client ID and Client Secret — OIDC credentials (dashboard > Merchant / API details)
- Terminal ID — configured with a card provider or the card-simulator
- RSA Public Key — PEM public key for card data encryption (dashboard > Merchant details)
- A return URL — publicly accessible URL where the customer is redirected after 3DS authentication (use ngrok or similar for local development)
Step 1 — Obtain an Access Token¶
All API calls require a Bearer token. Obtain one using the OAuth 2.0 Client Credentials flow:
POST {OIDC_BASE_URL}/realms/{realm}/protocol/openid-connect/token
Content-Type: application/x-www-form-urlencoded
Use uat as the realm for UAT, production for production. Cache the token and refresh it before expires_in elapses.
curl -X POST "{OIDC_BASE_URL}/realms/{realm}/protocol/openid-connect/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id={CLIENT_ID}" \
-d "client_secret={CLIENT_SECRET}"
Save as get-token.mjs and run with node get-token.mjs:
// get-token.mjs
const TOKEN_URL = '{OIDC_BASE_URL}/realms/{realm}/protocol/openid-connect/token';
const CLIENT_ID = '{CLIENT_ID}';
const CLIENT_SECRET = '{CLIENT_SECRET}';
async function getAccessToken() {
const res = await fetch(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
}).toString(),
});
if (!res.ok) {
throw new Error(`Token request failed: ${res.status} ${await res.text()}`);
}
return await res.json();
}
const data = await getAccessToken();
console.log('Access Token:', data.access_token);
console.log('Expires In:', data.expires_in, 'seconds');
Save as GetToken.java and run with java GetToken.java (Java 11+):
// GetToken.java
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class GetToken {
private static final String TOKEN_URL =
"{OIDC_BASE_URL}/realms/{realm}/protocol/openid-connect/token";
private static final String CLIENT_ID = "{CLIENT_ID}";
private static final String CLIENT_SECRET = "{CLIENT_SECRET}";
public static void main(String[] args) throws Exception {
String body = "grant_type=client_credentials"
+ "&client_id=" + CLIENT_ID
+ "&client_secret=" + CLIENT_SECRET;
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(TOKEN_URL))
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
System.err.println("Token request failed: " + response.statusCode());
System.err.println(response.body());
System.exit(1);
}
System.out.println(response.body());
}
}
Response:
{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"expires_in": 300,
"token_type": "Bearer"
}
See Obtain Access Token for full details.
Step 2 — Encrypt Card Data¶
All sensitive card fields must be encrypted with your merchant RSA public key using RSA-OAEP with SHA-256 before sending to the API. Encrypt each field individually and Base64-encode the result.
Fields requiring encryption: card number, CVV, expiration month, expiration year.
encrypt_field() {
echo -n "$1" | openssl pkeyutl -encrypt \
-pubin -inkey <(echo "$PUBLIC_KEY") \
-pkeyopt rsa_padding_mode:oaep \
-pkeyopt rsa_oaep_md:sha256 \
-pkeyopt rsa_mgf1_md:sha256 | base64
}
ENCRYPTED_CARD_NUMBER=$(encrypt_field "4111111111111111")
ENCRYPTED_CVV=$(encrypt_field "123")
ENCRYPTED_EXP_MONTH=$(encrypt_field "12")
ENCRYPTED_EXP_YEAR=$(encrypt_field "2027")
Server-side only
Card encryption must happen on your server. Never expose the RSA public key or raw card data in the browser.
See Encryption Example for more languages and details.
Step 3 — Collect Browser Info¶
The browser_info object is required for all card transactions. It is used by the issuer for 3DS risk assessment. Collect it from the customer's browser using JavaScript:
const browserInfo = {
user_agent: navigator.userAgent,
accept_header: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
java_enabled: navigator.javaEnabled?.() ?? false,
color_depth: screen.colorDepth,
screen_height: screen.height,
screen_width: screen.width,
time_zone_offset: new Date().getTimezoneOffset(),
language: navigator.language,
};
Send this object to your server along with the card details so it can be included in the authorize request.
Step 4 — Authorize the Payment¶
Submit the encrypted card data, browser info, and payment details to the gateway:
POST {API_BASE_URL}/api/transactions/authorize
Authorization: Bearer {access_token}
Content-Type: application/json
{
"terminal_id": "{TERMINAL_ID}",
"reference": "ORDER-123456",
"description": "Card payment",
"currency": "EUR",
"amount": 10000,
"transaction_type": "PURCHASE",
"return_url": "https://your-domain.com/payment/return",
"error_url": "https://your-domain.com/payment/return",
"cancel_url": "https://your-domain.com/payment/return",
"payment_method": {
"type": "card",
"data": {
"encrypted_card_number": "{ENCRYPTED_CARD_NUMBER}",
"encrypted_cvv": "{ENCRYPTED_CVV}",
"encrypted_expiration_month": "{ENCRYPTED_EXP_MONTH}",
"encrypted_expiration_year": "{ENCRYPTED_EXP_YEAR}"
}
},
"customer": {
"first_name": "John",
"last_name": "Doe",
"email": "john.doe@example.com"
},
"browser_info": {
"user_agent": "Mozilla/5.0...",
"accept_header": "text/html,application/xhtml+xml...",
"java_enabled": false,
"color_depth": 24,
"screen_height": 1080,
"screen_width": 1920,
"time_zone_offset": -120,
"language": "en-US"
}
}
Step 5 — Handle the Response¶
The authorize response contains four top-level fields. Which field is populated determines your next step. This same logic applies after every API call in the 3DS flow — not just the initial authorize.
| Response Field | Meaning | Next Step |
|---|---|---|
result |
Transaction finalized (approved or declined) | Done |
action with type THREE_DS_2_FINGERPRINT |
Device fingerprint required | Go to Step 6 |
action with type THREE_DS_2_CHALLENGE |
Challenge required | Go to Step 8 |
redirect |
Redirect the customer | Redirect browser to redirect.url |
form_submit |
Auto-submit a form | Post a hidden form to form_submit.url with the fields in form_submit.data |
Example response (fingerprint required):
{
"result": null,
"action": {
"transaction_id": "vvIVFmPuwYosePLsoDsW",
"session_id": "abc123-session-id",
"type": "THREE_DS_2_FINGERPRINT",
"token": "eyJhbGciOiJIUzI1NiIs...",
"payment_data": "encrypted_payment_data_string"
},
"redirect": null,
"form_submit": null
}
Build a response handler, not a step counter
Implement a single function that inspects each response and routes to the appropriate action. The gateway may return a new fingerprint or challenge at any point (e.g., after auto-retry with a different provider). See Handling Repeated Authentication for details.
Example response router (browser JavaScript):
async function processPayment(data) {
if (data.result) {
// Transaction finalized — show result to customer
displayResult(data.result);
return;
}
if (data.action) {
if (data.action.type === 'THREE_DS_2_FINGERPRINT') {
const result = await handleFingerprint(data.action);
return processPayment(result); // recurse — next response may need more steps
}
if (data.action.type === 'THREE_DS_2_CHALLENGE') {
handleChallenge(data.action);
return; // page navigates away
}
}
if (data.redirect) {
window.location.href = data.redirect.url;
return;
}
if (data.form_submit) {
submitHiddenForm(data.form_submit.url, '_self', data.form_submit.data);
return;
}
}
form_submit.data shape
form_submit.data is a flat key–value object, for example:
{
"PaReq": "eJxVUdtugkAQ/RXC...",
"MD": "vvIVFmPuwYosePLsoDsW",
"TermUrl": "https://gateway.example.com/return"
}
submitHiddenForm helper iterates these entries with Object.entries() and creates a hidden <input> for each one.
Step 6 — Device Fingerprint¶
When the response contains action.type: "THREE_DS_2_FINGERPRINT", you must collect the customer's device fingerprint before authentication can proceed.
6a. Decode the token¶
The action.token is a Base64-encoded JSON object:
const decodedToken = JSON.parse(atob(action.token));
Decoded structure:
{
"three_ds_server_trans_id": "abc123-server-trans-id",
"three_ds_method_url": "https://acs.issuer.com/fingerprint",
"three_ds_method_notification_url": "https://gateway.example.com/notification",
"acs_url": "https://acs.issuer.com/challenge",
"acs_trans_id": "xyz789-acs-trans-id",
"message_version": "2.2.0",
"challenge_window_size": "05"
}
6b. Submit the fingerprint form¶
If three_ds_method_url is present, submit a hidden form to it inside a hidden iframe:
<!-- Add this iframe to your page -->
<iframe id="fingerprint-iframe" name="fingerprint-iframe"
style="display:none" width="0" height="0"></iframe>
const methodData = btoa(JSON.stringify({
threeDSServerTransID: decodedToken.three_ds_server_trans_id,
threeDSMethodNotificationURL: decodedToken.three_ds_method_notification_url,
}));
// Submit to ACS in hidden iframe
submitHiddenForm(decodedToken.three_ds_method_url, 'fingerprint-iframe', {
threeDSMethodData: methodData,
});
// The ACS signals completion to the server, not to this iframe.
// Wait a fixed 5 seconds (per EMVCo 3DS spec) before proceeding.
await new Promise(resolve => setTimeout(resolve, 5000));
If three_ds_method_url is not present, skip the iframe submission and proceed directly to Step 7.
Helper: submitHiddenForm¶
function submitHiddenForm(url, target, fields) {
const form = document.createElement('form');
form.method = 'POST';
form.action = url;
form.target = target;
form.style.display = 'none';
for (const [name, value] of Object.entries(fields)) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = name;
input.value = value;
form.appendChild(input);
}
document.body.appendChild(form);
form.submit();
form.remove();
}
Step 7 — Request Authentication¶
After the fingerprint step (or immediately if no three_ds_method_url was provided), call the request-authentication endpoint from your server:
POST {API_BASE_URL}/api/transactions/three-ds/{three_ds_server_trans_id}/request-authentication
Authorization: Bearer {access_token}
Content-Type: application/json
{
"fingerprint_result": "{base64-encoded 'Y' or 'N'}",
"payment_data": "{payment_data from the action response}"
}
fingerprint_result: Base64-encode"Y"if the fingerprint form was submitted,"N"ifthree_ds_method_urlwas absentpayment_data: pass through theaction.payment_datavalue from the authorize response
The response uses the same structure as Step 5. Check result, action, redirect, and form_submit to determine the next step:
resultpresent — transaction finalized (frictionless approval or decline). Done.actionwithtype: "THREE_DS_2_CHALLENGE"— proceed to Step 8redirectorform_submit— redirect or auto-submit as described in Step 5
Step 8 — Challenge¶
If the issuer requires additional verification, the response will contain action.type: "THREE_DS_2_CHALLENGE". The customer must interact with the issuer's authentication UI (e.g., OTP, biometric, banking app confirmation).
8a. Build the Challenge Request (CReq)¶
Decode the token (same structure as Step 6a) and construct the CReq:
const decodedToken = JSON.parse(atob(action.token));
const creq = btoa(JSON.stringify({
threeDSServerTransID: decodedToken.three_ds_server_trans_id,
acsTransID: decodedToken.acs_trans_id,
messageVersion: decodedToken.message_version,
messageType: "CReq",
challengeWindowSize: decodedToken.challenge_window_size,
}));
8b. Submit to the ACS (full-page redirect)¶
Submit the CReq as a hidden form targeting the current window. The customer's browser navigates to the issuer's authentication page:
submitHiddenForm(decodedToken.acs_url, '_self', { creq });
After the customer completes the challenge, the gateway processes the authentication result, authorizes the transaction, and redirects the customer back to your return_url.
No server-side callback needed
With the full-page redirect approach, the gateway handles the authentication-completed step internally. You do not need to call this endpoint yourself.
Step 9 — Verify Transaction Status¶
Once the customer is redirected back to your return_url, verify the transaction outcome from your server:
GET {API_BASE_URL}/api/transactions/{transaction_id}/status
Authorization: Bearer {access_token}
Response:
{
"id": "vvIVFmPuwYosePLsoDsW",
"status": "APPROVED",
"amount": 10000,
"currency": "EUR"
}
| Status | Description |
|---|---|
APPROVED |
Authentication successful, transaction authorized |
DECLINED |
Authentication failed or transaction declined by issuer |
CANCELED |
Customer canceled or abandoned the challenge |
SESSION_EXPIRED |
Authentication session timed out |
Use Webhooks
For the most reliable status updates, configure a webhook to receive real-time notifications. This handles edge cases where the customer closes the browser before being redirected back.
Complete Flow Summary¶
sequenceDiagram
participant Browser
participant Server as Your Server
participant Gateway as Payment Gateway
participant ACS as Issuer ACS
Server ->> Gateway: Step 1: Obtain access token (OIDC)
Server ->> Server: Step 2: Encrypt card data (RSA-OAEP)
Browser ->> Server: Card details + browser info
Server ->> Gateway: Step 4: POST /api/transactions/authorize
Gateway -->> Server: Step 5: action (THREE_DS_2_FINGERPRINT)
Server -->> Browser: Forward action to browser
Note over Browser: Step 6 — Fingerprint
Browser ->> ACS: POST threeDSMethodData (hidden iframe)
Note over Browser: Wait 5 seconds (fixed, per EMVCo spec)
Browser ->> Server: Step 7: Fingerprint result + payment_data
Server ->> Gateway: POST /three-ds/{id}/request-authentication
Gateway -->> Server: action (THREE_DS_2_CHALLENGE) or result
Server -->> Browser: Forward response
alt Frictionless — result returned
Note over Browser: Transaction approved, no customer interaction needed
else Challenge Required
Note over Browser: Step 8 — Challenge
Browser ->> ACS: POST creq (full-page redirect)
ACS ->> Browser: Authentication UI (OTP, biometric, etc.)
ACS -->> Gateway: Authentication result
Gateway -->> Browser: Redirect to return_url
Note over Browser: Step 9 — Verify status
Server ->> Gateway: GET /api/transactions/{id}/status
end
Testing with the 3DS Simulator¶
In UAT, you can control the 3DS flow by passing simulated_flow in the transaction metadata:
| Flow | Description |
|---|---|
VERSIONING_FINGERPRINT_Y_CHALLENGE |
Full flow: fingerprint + challenge (default) |
VERSIONING_FINGERPRINT_A_FRICTIONLESS |
Frictionless approval — no customer interaction |
VERSIONING_FINGERPRINT_N_FRICTIONLESS |
Frictionless decline |
VERSIONING_NOT_ENROLLED |
Card not enrolled in 3DS — skips authentication |
VERSIONING_FINGERPRINT_TIMEOUT_CHALLENGE |
Fingerprint times out, then challenge |
VERSIONING_FINGERPRINT_TIMEOUT_FRICTIONLESS |
Fingerprint times out, then frictionless |
Control the final transaction result with response_code:
| Code | Result |
|---|---|
TRX_STATUS_APPROVED |
Approved |
TRX_STATUS_DECLINED |
Declined |
TRX_STATUS_FAILED |
Failed |
Example:
{
"metadata": {
"simulated_flow": "VERSIONING_FINGERPRINT_Y_CHALLENGE",
"response_code": "TRX_STATUS_APPROVED"
}
}
See 3DS Simulator for full details and additional override options.
Related Documentation¶
- 3D Secure Reference — Full protocol details, token structures, and edge cases
- API Integration — Direct API integration guide
- Card Encryption — RSA-OAEP encryption examples
- Authentication — OIDC token retrieval
- Webhooks — Real-time payment status notifications
- Transaction Statuses — All transaction lifecycle states