3D Secure (Card) Payment Flow
This document explains how 3D Secure (3DS/3DS2) authentication for credit/debit card payments works with the adyen.payment-provider-v3 connector on VTEX: the complete flow, the APIs involved, and the payloads your application needs to send and handle. It also covers the extra steps required in a headless storefront.
Context
3DS is the cardholder authentication protocol (a challenge performed by the issuing bank). In the connector, it works in three modes:
| Mode | How it happens | When |
|---|---|---|
| Native 3DS2 (fingerprint/challenge in iframe) | The Adyen Web Component renders the challenge inside the checkout, without leaving the page | Default — the connector sends nativeThreeDS: 'preferred' |
| Redirect (3DS1 / fallback) | The shopper is redirected to the issuer and comes back through the connector's returnUrl | When the issuer/Adyen doesn't support the native flow |
| Data Only | No challenge — only data is sent to the scheme (frictionless) | When the AppSetting serviceAuthentication = 'Data Only', credit card and BRL currency |
Flow Diagrams
Card without 3DS (simple flow)
Card with native 3DS2 (challenge in checkout)
Card with 3DS via redirect (fallback)
Connector APIs in the 3DS Flow
| # | Route | Method | Who calls it | Role in 3DS |
|---|---|---|---|---|
| 1 | /payments (standard PPP route — authorize) | POST | VTEX Gateway | Creates the payment on Adyen; if the response is 3DS, returns paymentAppData |
| 2 | /_v/api/payment-details | POST | Payment App (Adyen Web's onAdditionalDetails event) or headless app | Completes native 3DS: sends threeDSResult to Adyen's /payments/details and approves/denies on the gateway |
| 3 | /_v/api/payment/3ds/redirect | GET | Shopper's browser (return from the issuer) | Completes 3DS via redirect: sends redirectResult to /payments/details and approves/denies on the gateway |
| 4 | /_v/api/payment-status | POST | Payment App (5s polling) or headless app | Queries the consolidated status (VBase av3settle) |
| 5 | /_v3/api/webhook/notification | POST | Adyen (webhooks) | Async notifications; correlated by pspReference (VBase av3notify) |
| 6 | /_v/api/payment-authorization | POST | Payment App (wallet/component flows — Google Pay, Blik, etc.) | Does not take part in the standard card flow; listed here to avoid confusion with the PPP's /payments |
Data Sent and Received per API
POST /payments (PPP authorize) → Adyen POST /checkout/v72/payments
The gateway sends the VTEX protocol's AuthorizationRequest (PCI-tokenized card: numberToken, cscToken, holderToken, expiration, plus miniCart, merchantSettings, transactionId, paymentId, returnUrl...).
⚠️ Critical point of the 3DS flow: Adyen only returns the native 3DS2 action if the connector can build the shopper's browserInfo (device/browser). Without this data, the payment is processed frictionless (no challenge), silently. The CardService builds this browserInfo from the deviceInfo field recorded on the transaction — and for that, the app needs to send deviceInfo in the payment call itself:
POST https://{account}.vtexpayments.com.br/api/pub/transactions/{transactionId}/payments
?orderId={orderGroup}&redirect=false&deviceInfo={base64}
Where {base64} is the base64 of:
sw={screen.width}&sh={screen.height}&cd={screen.colorDepth}&tz={new Date().getTimezoneOffset()}&lang={navigator.language}&java={navigator.javaEnabled()}
orderGroup comes from the order creation response. This is the same mechanism used by VTEX's native checkout — see Headless Integration below for the full step-by-step in a headless integration.
Body sent to Adyen (fields relevant to 3DS):
{
"merchantAccount": "MyStore_BR",
"reference": "1268540098765",
"amount": { "currency": "BRL", "value": 10000 },
"paymentMethod": {
"type": "scheme",
"number": "<PCI token>",
"expiryMonth": "03",
"expiryYear": "2030",
"cvc": "<PCI token>",
"holderName": "JOHN S SILVA",
"fundingSource": "credit"
},
"returnUrl": "https://{workspace}--{account}.myvtex.com/_v/api/payment/3ds/redirect?account={account}&paymentId={paymentId}&transactionId={transactionId}",
"authenticationData": {
"threeDSRequestData": { "dataOnly": false, "nativeThreeDS": "preferred" }
},
"channel": "web",
"origin": "https://www.mystore.com",
"browserInfo": { "...": "collected in the checkout" },
"shopperInteraction": "Ecommerce",
"shopperEmail": "john@email.com",
"shopperIP": "200.1.2.3",
"shopperReference": "<vtexUserId>",
"shopperConversionId": "<paymentId>",
"installments": { "value": 1 },
"billingAddress": { "...": "..." },
"deliveryAddress": { "...": "..." }
}
- In Data Only mode:
dataOnly: true,nativeThreeDS: 'disabled'andadditionalData.threeDS2DataOnly: true— no challenge occurs. - Card data travels through VTEX's secure proxy (
SecureExternalClient+Idempotency-Key = paymentIdheader). browserInfo+originare a prerequisite for native 3DS2: without them, Adyen won't return a nativeaction.
Adyen's response when 3DS is required (ThreeDSResponse):
{
"additionalData": {
"threeds2.threeDS2Token": "***",
"threeds2.threeDSServerTransID": "***",
"threeds2.threeDSMethodURL": "***",
"threeds2.cardEnrolled": "true",
"cardBin": "411111"
},
"pspReference": "ZXC7Q4JV5SRPDKV5",
"resultCode": "IdentifyShopper",
"action": {
"type": "threeDS2",
"subtype": "fingerprint",
"paymentMethodType": "scheme",
"paymentData": "***",
"token": "***"
}
}
Possible resultCode values on the 3DS branch: RedirectShopper, ChallengeShopper, IdentifyShopper (for redirect, action.type: "redirect" with url, method and data { MD, PaReq }).
Connector's response to the gateway (Authorizations.redirect) — this is what triggers the Payment App rendering:
{
"paymentId": "...",
"status": "undefined",
"delayToCancel": 21600,
"connectorMetadata": [
{ "name": "Currency", "value": "BRL" },
{ "name": "OriginalReference", "value": "<pspReference>" }
],
"paymentAppData": {
"appName": "adyen.payment-provider-v3",
"payload": "{\"authorization\":{...original request without apiKey...},\"action\":{...},\"clientKey\":\"test_...\",\"environment\":\"test|live\",\"merchantName\":\"...\",\"shopperEmail\":\"...\",\"shopperReference\":\"...\",\"countryCode\":\"BR\",\"lineItems\":[...],\"denyUrl\":\"/_v/api/cancel-payment?paymentId=...\"}"
}
}
Idempotency: the 3DS response is saved in VBase
rpr(keypaymentId). If the gateway retries theauthorize, the connector reuses the sameactioninstead of creating a new payment on Adyen.
POST /_v/api/payment-details
Called by the Payment App on Adyen Web's onAdditionalDetails event, after the shopper completes the fingerprint/challenge. The body is the merge of the component's state.data with the payload's authorization object:
{
"details": { "threeDSResult": "eyJ0cmFuc1N0YXR1cyI6IlkifQ==" },
"paymentData": "...",
"transactionId": "...",
"paymentId": "...",
"orderId": "...",
"merchantName": "mystore",
"card": { "bin": "411111" },
"miniCart": { "buyer": { "id": "..." } }
}
The connector forwards only body.details to Adyen's POST /payments/details, and based on the response:
Authorised→ saves VBaseav3settle, setsnetworkTxReferenceon the transaction, and calls the gateway callback (POST https://{account}.vtexpayments.com.br/api/pvt/payment-provider/transactions/{tx}/payments/{py}/callback) withstatus: "approved"andtid/nsu/authorizationId = pspReference;Refused/Error/Cancelled→ callback withstatus: "denied"+refusalReasonCode;- HTTP response to the caller: a string with the
resultCode(e.g."Authorised"). The Payment App uses this to triggertransactionValidation.vtex. - If
merchantNamediffers from the account (multi-store), the call is proxied to the account that owns the configuration.
GET /_v/api/payment/3ds/redirect
Query string: account, paymentId, transactionId (fixed in the returnUrl) + redirectResult (appended by Adyen on the return from the issuer).
The middleware:
- Calls
POST /payments/detailswith{ details: { redirectResult } }; - If the transaction is already
Cancelledon VTEX →cancelOrRefundon Adyen; Authorised→approvedcallback to the gateway; refusals →deniedcallback;- Always finishes with a 302 to the gateway's return page:
https://{account}.vtexpayments.com.br/payment-provider/transactions/{transactionId}/payments/{paymentId}/return?accountName={account}.
POST /_v/api/payment-status
Request: { "paymentId": "..." } → Response: { "status": "pending" } while not completed, or { "status": true|false } once the payment is completed (read from VBase av3settle). The Payment App polls every 5s as a safety net for the challenge.
Persistence (VBase) Used by 3DS
| Bucket | Key | Content | Purpose |
|---|---|---|---|
rpr | paymentId | Adyen's ThreeDSResponse | Idempotency for authorize retries |
av3notify | pspReference | { paymentId, transactionId, orderId, account } | Correlation of Adyen webhooks |
av3settle | paymentId / orderId | { pspReference, success, step } | Short-circuit for repeated authorize + payment-status |
Headless Integration
RFC context: "Merchants that use a headless checkout (Whirlpool/Beko) may be affected, since the API will start depending on
browserInfodata, which is currently not being sent."
In a headless frontend, the VTEX checkout scripts don't run, so browserInfo/origin never reach the connector and the Payment App isn't rendered automatically. The headless app needs to take on these two roles.
📚 General headless integration reference: Headless Cart and Checkout — Complete order.
Step by step
1. Create the order and the payment, sending deviceInfo (Checkout API / Transaction API — do-payment flow: orderForm → order → transactions → payments). The gateway calls the connector's authorize on its own; nothing changes in this flow, with one critical exception:
⚠️ Without deviceInfo, Adyen won't return the native challenge — the payment is processed frictionless, silently. Send deviceInfo as a query string on the payment call (full format in Data Sent and Received per API):
POST https://{account}.vtexpayments.com.br/api/pub/transactions/{transactionId}/payments
?orderId={orderGroup}&redirect=false&deviceInfo={base64}
📚 Reference: Payments Gateway API — POST /api/payments/transactions/{transactionId}/payments.
2. Call gatewayCallback and detect 3DS from the response. After submitting the payment, call:
POST https://{account}.{environment}.com.br/api/checkout/pub/gatewayCallback/{orderGroup}
📚 Reference: Checkout API — POST /api/checkout/pub/gatewayCallback/{orderGroup}.
⚠️ 428 Precondition Required is the expected response when there's a pending 3DS — it's not an error. Treat it as a valid result, not as a call failure. The body carries paymentAuthorizationAppCollection, one item per pending payment app:
{
"paymentAuthorizationAppCollection": [
{
"appName": "adyen.payment-provider-v3",
"appPayload": "{\"authorization\":{...},\"action\":{...},\"clientKey\":\"...\",\"environment\":\"test\"}"
}
]
}
If the response is 204 No Content, the payment was already resolved (frictionless) — there's no challenge to render, go straight to step 4 (polling).
The headless app must find the item with appName: "adyen.payment-provider-v3" and parse the appPayload (it's a JSON string) to extract action, clientKey, environment and authorization.
3. Render the challenge with Adyen Web (the role the Payment App plays in the native checkout):
import { AdyenCheckout } from '@adyen/adyen-web'
import '@adyen/adyen-web/styles/adyen.css'
// appPayload came from the gatewayCallback (428) in the previous step
const payload = JSON.parse(appPayload)
const checkout = await AdyenCheckout({
clientKey: payload.clientKey,
environment: payload.environment, // 'test' | 'live'
countryCode: 'BR',
onAdditionalDetails: async (state, component, actions) => {
// Complete 3DS on the connector
try {
const { data: resultCode } = await axios.post(
'https://{account}.myvtex.com/api/io/_v/api/payment-details',
{ ...state.data, ...payload.authorization }
)
// resultCode: 'Authorised' | 'Refused' | 'Error' | 'Cancelled' ...
// ⚠️ REQUIRED: Adyen Web's "advanced flow" requires resolving/rejecting
// this callback via `actions`. Without it, the component never finalizes its
// own internal state — in practice the challenge "closes" (the ACS/issuer
// closes its own iframe) but nothing else happens, because the SDK was
// never told the flow ended.
actions.resolve({ resultCode })
} catch (err) {
actions.reject()
}
},
})
checkout
.createFromAction(payload.action, { challengeWindowSize: '02' })
.mount('#adyen-container')
4. Complete and confirm. The connector itself approves/denies the payment on the gateway via callback — the headless app doesn't need to call the gateway. To confirm, use polling:
POST https://{account}.myvtex.com/api/io/_v/api/payment-status
{ "paymentId": "<paymentId>" }
→ { "status": "pending" } … → { "status": true }
or check the transaction/order status through VTEX's APIs.
5. When action.type === "redirect". The shopper leaves for the issuer and comes back through the connector's returnUrl (/_v/api/payment/3ds/redirect), which finalizes the payment and redirects to the gateway's return page on {account}.vtexpayments.com.br. Since this returnUrl is fixed (built on the backend), the headless app doesn't control the final landing page — handle this scenario by opening the redirect in a popup/iframe and monitoring payment-status, or prioritize the native flow (which is preferred by the connector via nativeThreeDS: 'preferred' and only exists if step 1 was done correctly).
Headless Contract Summary
| Native checkout responsibility | Headless equivalent |
|---|---|
adyenv3 script sends browserInfo | deviceInfo in the query string of POST .../transactions/{id}/payments |
| Checkout renders the Payment App | gatewayCallback (428) → parse appPayload + createFromAction(action) |
onAdditionalDetails → payment-details | Same: POST /_v/api/payment-details with {...state.data, ...authorization} |
transactionValidation.vtex event | Polling POST /_v/api/payment-status |
Testing
To validate the 3DS implementation before going to production, use an Adyen test merchantAccount together with the test cards Adyen provides.
Adyen publishes a list of test cards and authentication scenarios that let you validate different 3DS behaviors, including:
- Challenge flow;
- Frictionless flow;
- Successful and failed authentication scenarios;
- Advanced test cases, such as timeouts, errors, and different
transStatusvalues.
For the full list of test cards, credentials, and available scenarios, see Adyen's official documentation:
https://docs.adyen.com/development-resources/testing/3d-secure-2-authentication
That documentation contains all the test cards, the expected behavior for each scenario, and the information needed to correctly validate the 3DS implementation in Adyen's test environment.
Observations and Limitations
- Data Only turns off the challenge: with
serviceAuthentication = 'Data Only'(AppSettings), credit and BRL, the connector sendsdataOnly: trueand there will never be anaction— it's not possible to test a challenge under this configuration. - Idempotent retry: VBase
rprguarantees thatauthorizeretries reuse the originalaction; clearing this record is necessary to re-test the samepaymentId. - Webhooks: besides the synchronous flow, Adyen sends async notifications to
/_v3/api/webhook/notification; correlation uses the VBaseav3notifyrecord written duringauthorize/payment-details. - Multi-account:
payment-detailsproxies the call whenmerchantNamediffers from the account that received the request. - Wallets: Google Pay/Apple Pay with DPAN send
mpiData(cryptogram/eci) and don't go through the challenge; the 3DS flow described in this document applies to cards (FPAN) — see the Google Pay RFC for the FPAN × DPAN scenarios.
Was this page helpful?