3DS Authentication
Handle 3D Secure authentication for card transactions.
Before You Start
Read the following guides before proceeding:
| Guide | Why |
|---|---|
| Getting Started | Platform overview and setup |
| Api Basics | Required headers and request configuration |
| Authentication | How to obtain access tokens |
| Onboarding | User and wallet registration |
| KYC | KYC 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"
}| Field | Description |
|---|---|
card_id | UUID of the card |
user_address | EOA address of the user who owns the card |
owner | Deprecated. Mirrors user_address |
transaction_id | Issuer transaction identifier (use for approve/decline) |
merchant_name | Merchant name |
amount | Transaction amount |
currency | Transaction currency |
card_last_4 | Last 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
| Parameter | Description |
|---|---|
transactionId | Transaction 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
| Parameter | Description |
|---|---|
transactionId | Transaction 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
| Status | Meaning |
|---|---|
| 200 | Decision 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 no401on 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 Description | Cause |
|---|---|
| Failed to query active 3DS requests | 3DS service unavailable while resolving the request. Returned by all three endpoints |
| Failed to approve 3DS request | 3DS service rejected or could not record the approval |
| Failed to decline 3DS request | 3DS service rejected or could not record the decline |
Updated 12 days ago

