3DS Authentication

Handle 3D Secure authentication for card transactions.

Before You Start

Read the following guides before proceeding:

GuideWhy
Getting StartedPlatform overview and setup
Api BasicsRequired headers and request configuration
AuthenticationHow to obtain access tokens
OnboardingUser and wallet registration
KYCKYC verification requirements

Overview

3D Secure (3DS) is an authentication protocol that adds an extra layer of security for card transactions. When a transaction requires 3DS verification, the user sees a 3DS window with two authentication options:

OTP Code (SMS)

The user receives an OTP code via SMS to the phone number provided during registration. The user enters the code directly in the 3DS form. This flow requires no backend interaction from your system.

In-App Confirmation

The user chooses to confirm the transaction in your app. This triggers a webhook to your system, and you must prompt the user to approve or decline. This document covers the in-app confirmation flow.


Flow

1. User makes card payment at merchant
                ↓
2. Issuer determines 3DS is required
                ↓
3. Wirex sends webhook to your system
                ↓
4. Your app displays approval UI to user
                ↓
5. User approves or declines
                ↓
6. Your app calls approve/decline endpoint
                ↓
7. Transaction proceeds or is blocked

Webhook

When a transaction requires 3DS authentication, Wirex sends a webhook to your configured endpoint.

Endpoint: POST {your_webhook_base_url}/v2/webhooks/3ds

{
  "card_id": "64120850-73a1-4df5-a074-d463258c9deb",
  "owner": "0x1234567890abcdef1234567890abcdef12345678",
  "user_address": "0x1234567890abcdef1234567890abcdef12345678",
  "transaction_id": "00000000000000000000000000000001",
  "merchant_name": "Amazon",
  "amount": "100.00",
  "currency": "USD",
  "card_last_4": "1234"
}
FieldDescription
card_idUUID of the card
user_addressEOA address of the user who owns the card
ownerDeprecated. Mirrors user_address
transaction_idIssuer transaction identifier (use for approve/decline)
merchant_nameMerchant name
amountTransaction amount
currencyTransaction currency
card_last_4Last 4 digits of card number

Get Pending 3DS Requests

Retrieve all pending 3DS requests for a user. Use this to show pending approvals if the user missed the webhook notification.

GET /api/v1/cards/3ds/requests
const response = await fetch(`${baseUrl}/api/v1/cards/3ds/requests`, {
  headers: {
    'Authorization': `Bearer ${accessToken}`,
    'X-User-Wallet': userEoaAddress,
    'X-Chain-Id': chainId
  }
});
const requests = await response.json();
response = requests.get(
    f"{base_url}/api/v1/cards/3ds/requests",
    headers={
        "Authorization": f"Bearer {access_token}",
        "X-User-Wallet": user_eoa_address,
        "X-Chain-Id": chain_id
    }
)
pending_requests = response.json()
req, _ := http.NewRequest("GET", baseURL+"/api/v1/cards/3ds/requests", nil)
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("X-User-Wallet", userEoaAddress)
req.Header.Set("X-Chain-Id", chainId)

resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()

var requests []CardTransactionConfirmation
json.NewDecoder(resp.Body).Decode(&requests)

Response: a JSON array (not wrapped in a data envelope). Empty array when nothing is pending.

[
  {
    "card_id": "64120850-73a1-4df5-a074-d463258c9deb",
    "owner": "0x1234567890abcdef1234567890abcdef12345678",
    "user_address": "0x1234567890abcdef1234567890abcdef12345678",
    "transaction_id": "00000000000000000000000000000001",
    "merchant_name": "Amazon",
    "amount": "100.00",
    "currency": "USD",
    "card_last_4": "1234"
  }
]

The listing is scoped to the caller identified by X-User-Wallet, and contains only requests that are
still undecided and unexpired. A request drops out as soon as it is approved or declined, and in any
case 5 minutes after it was created.


Approve Transaction

When the user confirms the transaction is legitimate, call the approve endpoint.

POST /api/v1/cards/3ds/requests/{transactionId}/approve
ParameterDescription
transactionIdTransaction ID from webhook or GET request
await fetch(`${baseUrl}/api/v1/cards/3ds/requests/${transactionId}/approve`, {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${accessToken}`,
    'X-User-Wallet': userEoaAddress,
    'X-Chain-Id': chainId
  }
});
requests.post(
    f"{base_url}/api/v1/cards/3ds/requests/{transaction_id}/approve",
    headers={
        "Authorization": f"Bearer {access_token}",
        "X-User-Wallet": user_eoa_address,
        "X-Chain-Id": chain_id
    }
)
req, _ := http.NewRequest("POST", baseURL+"/api/v1/cards/3ds/requests/"+transactionId+"/approve", nil)
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("X-User-Wallet", userEoaAddress)
req.Header.Set("X-Chain-Id", chainId)

http.DefaultClient.Do(req)

Response: 200 OK with an empty JSON object {}.


Decline Transaction

When the user does not recognize the transaction or wants to block it, call the decline endpoint.

POST /api/v1/cards/3ds/requests/{transactionId}/decline
ParameterDescription
transactionIdTransaction ID from webhook or GET request
await fetch(`${baseUrl}/api/v1/cards/3ds/requests/${transactionId}/decline`, {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${accessToken}`,
    'X-User-Wallet': userEoaAddress,
    'X-Chain-Id': chainId
  }
});
requests.post(
    f"{base_url}/api/v1/cards/3ds/requests/{transaction_id}/decline",
    headers={
        "Authorization": f"Bearer {access_token}",
        "X-User-Wallet": user_eoa_address,
        "X-Chain-Id": chain_id
    }
)
req, _ := http.NewRequest("POST", baseURL+"/api/v1/cards/3ds/requests/"+transactionId+"/decline", nil)
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("X-User-Wallet", userEoaAddress)
req.Header.Set("X-Chain-Id", chainId)

http.DefaultClient.Do(req)

Response: 200 OK with an empty JSON object {}.


Implementation Example

Complete flow handling in your application:

// 1. Webhook handler (your server)
app.post('/v2/webhooks/3ds', async (req, res) => {
  const { transaction_id, merchant_name, amount, currency, card_last_4, user_address } = req.body;

  // Store request and notify user (push notification, in-app alert, etc.)
  await notifyUser(user_address, {
    transaction_id,
    merchant_name,
    amount,
    currency,
    card_last_4
  });

  res.status(200).send();
});

// 2. User approval handler (your client)
async function handle3dsDecision(transactionId, approved) {
  const action = approved ? 'approve' : 'decline';

  const response = await fetch(
    `${baseUrl}/api/v1/cards/3ds/requests/${transactionId}/${action}`,
    {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${accessToken}`,
        'X-User-Wallet': userEoaAddress,
        'X-Chain-Id': chainId
      }
    }
  );

  if (response.ok) {
    showMessage(approved ? 'Transaction approved' : 'Transaction declined');
  }
}
# 1. Webhook handler (your server)
@app.route('/v2/webhooks/3ds', methods=['POST'])
def handle_3ds_webhook():
    data = request.json

    # Store request and notify user
    notify_user(data['user_address'], {
        'transaction_id': data['transaction_id'],
        'merchant_name': data['merchant_name'],
        'amount': data['amount'],
        'currency': data['currency'],
        'card_last_4': data['card_last_4']
    })

    return '', 200

# 2. User approval handler
def handle_3ds_decision(transaction_id: str, approved: bool):
    action = 'approve' if approved else 'decline'

    response = requests.post(
        f"{base_url}/api/v1/cards/3ds/requests/{transaction_id}/{action}",
        headers={
            "Authorization": f"Bearer {access_token}",
            "X-User-Wallet": user_eoa_address,
            "X-Chain-Id": chain_id
        }
    )

    return response.ok
// 1. Webhook handler (your server)
func handle3dsWebhook(w http.ResponseWriter, r *http.Request) {
    var req CardTransactionConfirmation
    json.NewDecoder(r.Body).Decode(&req)

    // Store request and notify user
    notifyUser(req.UserAddress, req)

    w.WriteHeader(http.StatusOK)
}

// 2. User approval handler
func handle3dsDecision(transactionId string, approved bool) error {
    action := "decline"
    if approved {
        action = "approve"
    }

    req, _ := http.NewRequest("POST",
        baseURL+"/api/v1/cards/3ds/requests/"+transactionId+"/"+action, nil)
    req.Header.Set("Authorization", "Bearer "+accessToken)
    req.Header.Set("X-User-Wallet", userEoaAddress)
    req.Header.Set("X-Chain-Id", chainId)

    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return err
    }
    defer resp.Body.Close()

    return nil
}

UI Requirements

When displaying 3DS approval to users, show:

  • Merchant name
  • Transaction amount and currency
  • Last 4 digits of the card
  • Clear Approve and Decline buttons
  • A timeout warning — see below

A 3DS request expires 5 minutes after it is created. After that it drops out of
Get Pending 3DS Requests and approve and decline both return
No Active Request.

The expiry timestamp is not included in the webhook payload or the pending-requests response. Start
your countdown from the moment the webhook arrives, and budget for delivery latency — the 5 minutes run
from creation on the Wirex side, not from receipt on yours.


Error Handling

Success

StatusMeaning
200Decision recorded successfully

Validation Errors (400)

Missing Transaction ID

Returned when {transactionId} is empty in the path.

{
  "error_reason": "ErrorMissingField",
  "error_description": "transaction id is required",
  "error_category": {
    "category": "CategoryValidationFailure",
    "http_status_code": 400
  },
  "error_details": [
    { "key": "field", "details": "transaction_id" },
    { "key": "issue", "details": "missing" },
    { "key": "transaction_id", "details": "transaction id is required" }
  ]
}

No Active Request

{
  "error_reason": "ErrorNotFound",
  "error_description": "No active 3DS request for this transaction",
  "error_category": {
    "category": "CategoryValidationFailure",
    "http_status_code": 400
  },
  "error_details": [
    { "key": "field", "details": "transaction_id" },
    { "key": "issue", "details": "not_found" },
    { "key": "transaction_id", "details": "00000000000000000000000000000001" }
  ]
}

This one error covers four distinct situations, and they are not distinguishable from the response:
the transaction ID does not exist, the request has already been approved or declined, the request has
expired, or the request belongs to a different user. Approve and decline resolve the transaction against
the caller's own list of live, undecided requests, so ownership is enforced by the lookup itself — there
is no separate authorization error, and no 401 on these endpoints.

Treat it as terminal, not transient. Retrying will not change the outcome; re-read
Get Pending 3DS Requests to find what is actually still open.

Because a decided request leaves the active list, a second decision on the same transaction returns this
error rather than overwriting the first. An approve cannot replay over a decline the user already made.

Service Errors (500)

Error DescriptionCause
Failed to query active 3DS requests3DS service unavailable while resolving the request. Returned by all three endpoints
Failed to approve 3DS request3DS service rejected or could not record the approval
Failed to decline 3DS request3DS service rejected or could not record the decline

Did this page help you?