3DS Authentication
Read pending 3D Secure challenges on corporate cards and approve or decline them.
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 the corporation token |
| Webhooks | Challenges arrive by webhook |
| Card Details | Cards must be Active to transact |
Overview
When a merchant requests 3D Secure authentication on a corporate card, the transaction is held and a
challenge is raised. The corporation must approve or decline it, and until it does, the transaction does
not proceed.
Challenges are pushed to POST {your_webhook_base_url}/v2/webhooks/3ds and can also be polled. Both
paths surface the same set: the challenges still awaiting a decision for this corporation.
List Pending Challenges
GET /api/v1/cards/3ds/requests
Requires CardSu or CardViewDetails. Returns the corporation's active challenges — those still
awaiting a decision. A decided challenge disappears from the list.
Response:
[
{
"transaction_id": "00000000000000000000000000000001",
"card_id": "64120850-73a1-4df5-a074-d463258c9deb",
"card_last_4": "5678",
"amount": "100.00",
"currency": "USD",
"merchant_name": "Amazon UK"
}
]| Field | Description |
|---|---|
transaction_id | Issuer-side transaction identifier. The {transactionId} path parameter for the decision |
card_id | The card being challenged |
card_last_4 | Last 4 digits of that card |
amount | Transaction amount, as a string |
currency | ISO 4217 currency of the transaction |
merchant_name | Merchant that requested authentication |
The response is a bare JSON array, not an object with a data key.
The list is scoped to the whole corporation, not to the calling employee. Any holder of
CardViewDetailssees challenges on every corporate card, including cards linked to other employees.
Code
const response = await fetch(`${baseUrl}/api/v1/cards/3ds/requests`, {
headers: { 'Authorization': `Bearer ${corporationToken}` }
});
const challenges = await response.json();response = requests.get(
f"{base_url}/api/v1/cards/3ds/requests",
headers={"Authorization": f"Bearer {corporation_token}"},
)
challenges = response.json()req, _ := http.NewRequest("GET", baseURL+"/api/v1/cards/3ds/requests", nil)
req.Header.Set("Authorization", "Bearer "+corporationToken)
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var challenges []CardTransactionConfirmationResponse
json.NewDecoder(resp.Body).Decode(&challenges)Approve a Challenge
POST /api/v1/cards/3ds/requests/{transactionId}/approve
Requires CardSu or CardManage. Takes no body.
Path parameters:
| Parameter | Description |
|---|---|
{transactionId} | transaction_id from the pending list or the 3DS webhook. Matched case-insensitively |
Response:
{}Decline a Challenge
POST /api/v1/cards/3ds/requests/{transactionId}/decline
Requires CardSu or CardManage. Takes no body.
Response:
{}Decisions Are Final
Both endpoints resolve the transaction within the corporation's active set before acting. A
challenge that has already been decided is no longer active, so a second call — a retry, a duplicate
webhook delivery, or an approval after a decline — is refused with 400 ErrorNotFound and
No active 3DS request for this transaction.
The same response is returned for a transaction_id belonging to another corporation and for one that
never existed. A 400 ErrorNotFound therefore means "not yours to decide, or already decided" — it does
not distinguish the two.
Treat the error as success for idempotency purposes. A webhook redelivery that produces
No active 3DS request for this transactionmeans the decision already landed. Do not escalate it as
a failure, and do not follow it with the opposite decision.
Reading Card Details During a Challenge
A cardholder completing a challenge often needs the card's PIN. The action-confirmation flow has a
dedicated action type for this: sign the message with action_type 3dsChallenge and exchange it at
POST /api/v1/confirmation/signature/verify. The resulting action token is scoped to PIN retrieval.
Use GetCardDetails for PAN, expiry and CVV. See Card Details.
Webhooks
Endpoint: POST {your_webhook_base_url}/v2/webhooks/3ds
Delivered when a challenge is raised. The payload matches an item of the pending-challenge list.
Delivery is best-effort: a 10-second timeout, no retries. Poll GET /api/v1/cards/3ds/requests on a
schedule as well — a challenge left undecided blocks a real transaction.
See Webhooks.
Error Handling
{
"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" }
]
}Validation Errors (400)
| Error Reason | Error Details | Description | Resolution |
|---|---|---|---|
ErrorMissingField | field: transaction_id | transaction id is required | Include the transaction id in the path |
ErrorNotFound | field: transaction_id | No active 3DS request for this transaction | Already decided, or belongs to another corporation |
Permission Errors (403)
| Error Reason | Description | Resolution |
|---|---|---|
ErrorPermissionDenied | User does not have required permissions | Reading needs CardViewDetails; deciding needs CardManage. A role with only one of them can see challenges but not act, or act but not see |
Server Errors (500)
| Error Reason | Description | Resolution |
|---|---|---|
ErrorGeneral | Failed to query active 3DS requests | The 3DS service was unavailable. Retry — the challenge is still held |
ErrorGeneral | Failed to approve 3DS request in DB | The decision was not recorded. Re-read the pending list before retrying |
ErrorGeneral | Failed to decline 3DS request in DB | As above |
Updated 20 days ago

