Skip to content

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"
}
The 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" if three_ds_method_url was absent
  • payment_data: pass through the action.payment_data value 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:

  • result present — transaction finalized (frictionless approval or decline). Done.
  • action with type: "THREE_DS_2_CHALLENGE" — proceed to Step 8
  • redirect or form_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.