3-D Secure
3-D Secure (3DS) is an authentication protocol used to verify a cardholder's identity during card-not-present (CNP) transactions — online purchases, phone orders, or any transaction where the physical card is not presented to a terminal. This page covers how 3DS works at the network level, how it's implemented on the Axys platform, and the complete API surface for managing OTP subscriptions.
What is 3-D Secure?
"3-D" refers to the three domains involved in the protocol:
- Issuer Domain — the cardholder's bank/issuer (and its issuer processor, i.e., Axys)
- Acquirer Domain — the merchant's bank/acquirer
- Interoperability Domain — the card network's infrastructure connecting them
In the current version of the protocol (EMV 3DS / 3DS2), the authentication flow is typically frictionless — the network authenticates based on device/transaction risk signals without the cardholder doing anything — or challenge-based, where the cardholder must prove identity, most commonly by entering a one-time passcode (OTP) sent to their registered phone number or email.
When 3DS authentication succeeds, the issuer generates a cryptographic proof:
- CAVV (Cardholder Authentication Verification Value) — Visa's implementation
- AAV (Accountholder Authentication Value) — Mastercard's implementation
This value is included in the authorization request and provides liability shift — if a 3DS-authenticated transaction is later disputed as fraudulent, liability typically shifts from the issuer to the acquirer/merchant.
Platform prerequisites
Two conditions must be met before a card can receive 3DS OTPs:
- The Program must have 3-D Secure enabled. This is a Program-level configuration managed via Account Management — see Card Programs & Card Designs (design intent). Cards on a Program without 3DS enabled will not trigger the OTP flow regardless of subscription status.
- The card must be
issuerCardStatus = 1(Active). See Cards: Virtual vs. Physical for the full status model.
The subscription model
Subscribing by cardId vs. cardholderId
cardId vs. cardholderIdPOST /otp/listeners accepts either a cardId or a cardholderId:
| Subscription type | Scope | Recommendation |
|---|---|---|
cardId | OTPs for CNP transactions on this specific card | ✅ Recommended — gives a clean 1:1 mapping between a subscription token and a specific card |
cardholderId | OTPs for CNP transactions on all of this cardholder's cards | ⚠️ Legacy — the original method, before card-level subscriptions were introduced. Using cardholderId means a single token receives events for multiple cards, which complicates routing the OTP to the right delivery channel |
| Request field | Required | Description |
|---|---|---|
cardId or cardholderId | Yes (one of) | The target of the subscription |
webhookUrl | No | HTTPS endpoint to receive callback notifications. Must have a valid SSL/TLS certificate and be publicly accessible |
The subscription response
| Response field | Type | Description |
|---|---|---|
token | string | An opaque, URL-safe token uniquely identifying this subscription — save this, keyed by cardId |
expiresAt | integer | Currently always null — the subscription itself does not expire (the field exists for future use) |
status | string | success |
Encrypt stored tokens at rest. The
tokenvalue grants the ability to retrieve OTPs and to unsubscribe. It should be treated as a credential — stored securely, never logged, and rotated if compromised.
Multiple subscriptions
Each call to POST /otp/listeners creates a new, independent subscription with its own token. If you subscribe the same cardId multiple times (e.g., with different webhookUrl values), each subscription is independent — all will receive callbacks for the same CNP events.
The OTP — a different lifetime from the token
The subscription token and the OTP code are two separate things with different lifetimes:
| Subscription token | OTP code | |
|---|---|---|
| Returned by | POST /otp/listeners | GET /otp/{token} |
expiresAt | Currently null (doesn't expire) | Set — a specific Unix timestamp; variable and enforced |
| Represents | The ongoing subscription | The one-time code for a specific authentication event |
The security design: why the callback doesn't contain the OTP
When a CNP transaction triggers a 3DS challenge on a subscribed card, Axys sends a callback to your webhookUrl. This callback contains only the cardId or cardholderId — never the OTP code itself. This is deliberate:
Separating identity from secret: An intercepted callback reveals only that some card needs an OTP — not which one specifically (if using
cardholderId), and never the code itself. An interceptor would also need your stored subscription token to retrieve the OTP, and the OTP itself has a short, enforced expiry. The two-step design means neither piece alone is sufficient to compromise an authentication.
The complete 3DS OTP flow
sequenceDiagram
participant CH as Cardholder
participant Merchant
participant Net as Card Network
participant Axys
participant You as Your webhook endpoint
participant Store as Your token store
rect rgb(240, 248, 255)
Note over You,Axys: SETUP — once per card (recommended by cardId)
You->>Axys: POST /otp/listeners<br/>{ cardId: 10532, webhookUrl: "https://..." }
Axys->>You: { token: "otp_abc123...", expiresAt: null, status: "success" }
You->>Store: Store token, keyed by cardId 10532
end
rect rgb(248, 255, 240)
Note over CH,Axys: AT CNP TRANSACTION TIME
CH->>Merchant: Submits card details for online purchase
Merchant->>Net: Authorization request (card-not-present)
Net->>Axys: 3DS authentication challenge
Axys->>You: Webhook callback → { cardId: 10532 } — no OTP
You->>Store: Look up token for cardId 10532 → "otp_abc123..."
You->>Axys: GET /otp/otp_abc123...
Axys->>You: { status: "received", code: "847291", expiresAt: 1720000000 }
You->>CH: Deliver OTP: "847291" (SMS / push / in-app)
CH->>Merchant: Enters OTP to complete purchase
Merchant->>Net: Authentication complete (CAVV/AAV generated)
Net->>Axys: Authorization proceeds
end
Retrieving the OTP
GET /otp/{token}
| Response field | Type | Description |
|---|---|---|
status | string enum | pending · received · expired · consumed |
code | string | The OTP code — present when status = received |
receivedAt | integer | Unix timestamp when the OTP was generated |
expiresAt | integer | Unix timestamp when the OTP will expire — enforced, deliver before this |
OTP status lifecycle
stateDiagram-v2
[*] --> Pending: Subscription exists,<br/>no OTP event triggered yet
Pending --> Received: CNP transaction triggers<br/>3DS challenge — OTP generated
Received --> Consumed: OTP used successfully
Received --> Expired: expiresAt timestamp reached<br/>before use
Consumed --> [*]
Expired --> [*]
Deliver the OTP before it expires. The
expiresAttimestamp on the OTP is enforced — if the cardholder doesn't receive and enter the code in time, the transaction will fail authentication. Design your OTP delivery pipeline (webhook receipt → token lookup → OTP retrieval → delivery to cardholder) to be as fast as possible, with no synchronous blocking steps if latency is a concern.
Polling as an alternative to webhooks
If you don't configure a webhookUrl, you can poll GET /otp/{token} periodically and act when status changes from pending to received. Note that received means an OTP has been generated for a specific CNP transaction event — the expiresAt clock is already running by the time you retrieve it. For time-sensitive delivery, the webhook approach is strongly preferred over polling.
Unsubscribing
DELETE /otp/{token}
Deletes the subscription identified by token, stopping further callbacks and OTP generation for that subscription. Returns 404 if the token doesn't exist or has already been deleted — see Errors & Status Codes.
Webhook endpoint requirements
If using a webhookUrl:
- Must be HTTPS with a valid (non-self-signed) SSL/TLS certificate
- Must be publicly accessible (i.e., not behind a firewall or IP restriction that would block Axys's outbound IPs)
- Should respond quickly with a
2xx— complete any downstream processing (token lookup, OTP retrieval, delivery) asynchronously if there's any latency risk
Webhook callback URLs are independent of your mTLS certificate's domain list — see Webhooks & Callback Endpoints.
Summary: endpoints
| Operation | Endpoint | Notes |
|---|---|---|
| Create subscription | POST /otp/listeners | By cardId (recommended) or cardholderId |
| Retrieve OTP | GET /otp/{token} | Check status before using code |
| Delete subscription | DELETE /otp/{token} | Returns 404 if already deleted |
