Skip to main content

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:

ModeHow it happensWhen
Native 3DS2 (fingerprint/challenge in iframe)The Adyen Web Component renders the challenge inside the checkout, without leaving the pageDefault — the connector sends nativeThreeDS: 'preferred'
Redirect (3DS1 / fallback)The shopper is redirected to the issuer and comes back through the connector's returnUrlWhen the issuer/Adyen doesn't support the native flow
Data OnlyNo 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

#RouteMethodWho calls itRole in 3DS
1/payments (standard PPP route — authorize)POSTVTEX GatewayCreates the payment on Adyen; if the response is 3DS, returns paymentAppData
2/_v/api/payment-detailsPOSTPayment App (Adyen Web's onAdditionalDetails event) or headless appCompletes native 3DS: sends threeDSResult to Adyen's /payments/details and approves/denies on the gateway
3/_v/api/payment/3ds/redirectGETShopper'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-statusPOSTPayment App (5s polling) or headless appQueries the consolidated status (VBase av3settle)
5/_v3/api/webhook/notificationPOSTAdyen (webhooks)Async notifications; correlated by pspReference (VBase av3notify)
6/_v/api/payment-authorizationPOSTPayment 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' and additionalData.threeDS2DataOnly: true — no challenge occurs.
  • Card data travels through VTEX's secure proxy (SecureExternalClient + Idempotency-Key = paymentId header).
  • browserInfo + origin are a prerequisite for native 3DS2: without them, Adyen won't return a native action.

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 (key paymentId). If the gateway retries the authorize, the connector reuses the same action instead 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 VBase av3settle, sets networkTxReference on the transaction, and calls the gateway callback (POST https://{account}.vtexpayments.com.br/api/pvt/payment-provider/transactions/{tx}/payments/{py}/callback) with status: "approved" and tid/nsu/authorizationId = pspReference;
  • Refused / Error / Cancelled → callback with status: "denied" + refusalReasonCode;
  • HTTP response to the caller: a string with the resultCode (e.g. "Authorised"). The Payment App uses this to trigger transactionValidation.vtex.
  • If merchantName differs 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:

  1. Calls POST /payments/details with { details: { redirectResult } };
  2. If the transaction is already Cancelled on VTEX → cancelOrRefund on Adyen;
  3. Authorisedapproved callback to the gateway; refusals → denied callback;
  4. 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

BucketKeyContentPurpose
rprpaymentIdAdyen's ThreeDSResponseIdempotency for authorize retries
av3notifypspReference{ paymentId, transactionId, orderId, account }Correlation of Adyen webhooks
av3settlepaymentId / 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 browserInfo data, 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 APIPOST /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 APIPOST /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 responsibilityHeadless equivalent
adyenv3 script sends browserInfodeviceInfo in the query string of POST .../transactions/{id}/payments
Checkout renders the Payment AppgatewayCallback (428) → parse appPayload + createFromAction(action)
onAdditionalDetails → payment-detailsSame: POST /_v/api/payment-details with {...state.data, ...authorization}
transactionValidation.vtex eventPolling 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 transStatus values.

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 sends dataOnly: true and there will never be an action — it's not possible to test a challenge under this configuration.
  • Idempotent retry: VBase rpr guarantees that authorize retries reuse the original action; clearing this record is necessary to re-test the same paymentId.
  • Webhooks: besides the synchronous flow, Adyen sends async notifications to /_v3/api/webhook/notification; correlation uses the VBase av3notify record written during authorize/payment-details.
  • Multi-account: payment-details proxies the call when merchantName differs 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?