feat: add 3DS2 support to spec and schemas#421
Conversation
2825f10 to
e6c573c
Compare
e6c573c to
0f012f2
Compare
0f012f2 to
a0a7d0a
Compare
prasad-stripe
left a comment
There was a problem hiding this comment.
Thanks @aneeshali for putting this together! The vendor-agnostic approach with the merchant as orchestrator and host as execution environment is a clean design, and reusing complete_in_progress with the action/result loop keeps the spec surface minimal. Directionally this is strong.
A few architectural suggestions and minor questions/comments inline:
Capability negotiation for challenge support
This PR introduces challenge actions but doesn't participate in UCP's existing delegation negotiation. The host currently has no way to signal whether it can render 3DS challenges (iframes, popups). Should there be a payment.challenge delegation identifier? If the host doesn't declare it, the merchant knows upfront and can either attempt frictionless-only, offer payment instruments that don't require 3DS, or fall back to requires_escalation + continue_url. Without this signal, the merchant may send a challenge action that the host can't execute. Can we note in the guide that if the host doesn't declare payment.challenge, the merchant should fall back to requires_escalation + continue_url?
Frictionless path documentation
The flow implicitly supports frictionless (merchant returns completed directly if no challenge is needed), but the guide only documents the challenge path. Can we add an example showing the frictionless case (e.g., DDC completes, merchant returns completed without Phase 3)? This will help implementors understand the challenge phase is conditional.
Instrument switching during challenge flow
If the buyer starts 3DS for card A, gets a challenge, and decides they'd rather use card B or a wallet, what's the path? Can we send /complete with a different instrument while the checkout is in complete_in_progress for card A? Or does the session need to be abandoned? Being able to switch without restarting the session would help conversion.
| "challenge" | ||
| ] | ||
| }, | ||
| "status": { |
There was a problem hiding this comment.
status currently has "success" and "failure" as examples. Different failure modes have different implications for the host: buyer abandoned the challenge (retry same instrument), ACS timed out (transient, retryable), card not enrolled (guide buyer to a different payment instrument), technical error (terminate). UCP checkout already has a severity model on messages (recoverable, requires_buyer_input, unrecoverable) that prescribes recovery actions. The same values and pattern could be applied to challenge results so the host knows how to recover. The current set may need an additional value like try_different_instrument for cases where the issue is specific to the selected payment instrument (e.g., card not enrolled).
There was a problem hiding this comment.
I have changed them to be callback_received and abandoned making them more specific. Also expanded the error handling to cover what exactly is expected in these scenarios and the corresponding interactions.
| "type": { | ||
| "type": "string", | ||
| "description": "The mechanism for the action. Well-known values: `hidden_iframe`, `popup`.", | ||
| "examples": [ |
There was a problem hiding this comment.
type currently has "hidden_iframe" and "popup" as examples. There are authentication flows where no browser interaction is needed (e.g., out-of-band approval via a banking app). How would the host handle an action that doesn't require rendering anything? Consider supporting a type that signals the host to show a waiting state rather than render an iframe or popup.
For such flows, the completion signal becomes a question:
- Short-term: Host shows a "I've approved, continue" button. Buyer taps it after approving on their phone. Host calls /complete. In the happy path, PSP has already received the approval and merchant returns completed. Race condition: buyer approved but PSP confirmation hasn't arrived yet, so merchant returns complete_in_progress again. The simple solution is to retry, but since there is no new information in the request, the merchant may not be able to distinguish a retry from a duplicate. To mitigate this, consider adding a retry_attempt field so the merchant knows this is an intentional poll, not an accidental replay. Merchant specifies max_retries and retry_after_seconds in the action to bound the polling. Note: this retry/idempotency concern is specific to the out-of-band flow; the standard iframe/popup flows always carry new data (DDC result, cres) in each /complete call.
- Long-term: The host already provides callback_url. For out-of-band flows, the merchant (or PSP) could POST to this URL server-to-server when authentication resolves, giving the host a push notification to resume without buyer interaction. This eliminates the race condition but requires clarifying that callback_url is a server-reachable endpoint (not only an iframe navigation target). This may warrant a separate mechanism or a dedicated notification_url to avoid overloading callback_url semantics.
Note: The out-of-band flow could be a fast follow-up PR rather than complicating this one, but the type field should be designed to accommodate it without breaking changes.
There was a problem hiding this comment.
Thanks, I added a section covering some high level details under section Out-of-Band Authentication. Also added a TODO for expanding on this later.
For the short term, please share your thoughts on extending the hidden iframe approach path to support this case, where the effort will be minimal for the business/PSP. If you see concerns with that approach, we can stick to the continue button approach. Alternatively, we can provide both options and let the Platform+Business choose the right path. Please share your thoughts.
There was a problem hiding this comment.
Thanks for adding the OOB section. For the short term, the hidden iframe approach is pragmatic and gets us to a working state for web contexts without much lift on the business/PSP side.
That said, I think the proper long-term solution here is server-to-server callbacks (webhooks). OOB auth completion is inherently a server-side event (the ACS notifies the PSP via RReq), and relying on client-side polling or long-lived iframes to surface that signal is fragile, especially on mobile or in restricted webview contexts. I'd suggest we keep the TODO and note that the server callback path is the intended durable solution, with the hidden execution approach as an acceptable interim for web-only platforms.
No need to block the PR on this; just want to make sure we're aligned on direction.
| "format": "uri", | ||
| "description": "The URL where the result should be sent or where the interaction completes." | ||
| }, | ||
| "action": { |
There was a problem hiding this comment.
The action object has no expiry. How long should the host wait for a DDC iframe or challenge popup to complete before giving up? Without guidance, hosts will implement inconsistently. Should there be an expires_at or timeout_seconds field on the action so the merchant can signal how long the host should wait before reporting failure?
There was a problem hiding this comment.
Added a new field expires_at to address this.
There was a problem hiding this comment.
An absolute timestamp requires clock synchronization between business and platform, which is unreliable. For the typical 5-10s DDC timeout, an interval (e.g., timeout_seconds: 10) is more practical. It tells the platform how long to wait after posting to the 3DS Method URL, without needing synchronized clocks. WDYT?
| "token": "examplePaymentMethodToken" | ||
| }, | ||
| "challenge": { | ||
| "callback_url": "https://p.g.com/challenge/callback/session_123" |
There was a problem hiding this comment.
The host provides callback_url here, and the sequence diagram shows "DDC Result via Callback" and "Challenge Result via Callback." But the host also calls /complete with the result data. Could you clarify the exact role of callback_url? Is it the 3DS notification URL (where ACS POSTs within the iframe context after DDC/challenge completes)? Or is it a server-to-server webhook? Understanding this will help implementors know what kind of endpoint to expose.
There was a problem hiding this comment.
Fixed the sequence to make it clear that it's the frontend callback. Also added a note at the end of the sequence.
There was a problem hiding this comment.
Perhaps we could name this field more clearly to remove some confusion as well.
There was a problem hiding this comment.
I have created a separate json for client_callback, which includes url and status. This makes way for a clean representation of server_callback for webhooks when we need it.
| } | ||
| }] | ||
| }, | ||
| "signals": { |
There was a problem hiding this comment.
Richer browser signals increase the probability of frictionless authentication and improve conversion. How does the host know what signals are required or supported? Should the payment handler's published schema advertise the required/optional browser signals, so hosts know what to collect?
There was a problem hiding this comment.
I have expanded the risk section to include the signals details and the signals.json is now updated with the details.
| ```json | ||
| { | ||
| "id": "chk_123", | ||
| "status": "completed", |
There was a problem hiding this comment.
After the DDC and challenge flow completes, the final response returns status: "completed" with an order but no authentication metadata. Should there be an optional field indicating the authentication outcome (e.g., authenticated, attempted, frictionless, not required)? Hosts that participate in rendering the challenge may need this for observability, metrics and compliance.
There was a problem hiding this comment.
Added a new field called challenge.auth_outcome for this purpose.
|
|
||
| * **Standard Flow Execution:** If the challenge interaction completes successfully (via the hidden iframe or popup) and the Host receives the result data, it **MUST** resume the completion flow by calling `Complete Checkout` again with the received data. | ||
| * **Backend Validation Failure:** If the data returned from the challenge is invalid or the authorization fails at the PSP level, the Merchant will return a terminal error response (or another status) in the subsequent `Complete Checkout` call. The Host should handle this as a standard checkout error. | ||
| * **User Abandonment / Interactive Errors:** If the user enters an error state within the challenge UI or cannot proceed (e.g., the user manually dismisses the challenge popup before completion), this **SHOULD** be treated as a terminated session. The current checkout session becomes invalid for completion, and the user must initiate a new checkout session to try again. |
There was a problem hiding this comment.
Does abandonment have to terminate the entire checkout session? The 3DS session at the ACS is dead, but could the checkout session remain retryable (host calls /complete again, merchant initiates a fresh 3DS attempt)? This would allow buyers who accidentally dismissed the popup (or for some reason popup failed to render) to retry without losing their cart, shipping selections, etc.
There was a problem hiding this comment.
Changes made to the flow and error handling sections to incorporate the recoverable errors and how users can switch/retry in such scenarios.
|
|
||
| When processing challenge actions, the Host must consider security and rendering requirements: | ||
|
|
||
| * **Trusting the URL:** The `url` provided in the `action` object is generated by the PSP (or 3DS Provider). While validating the trustworthiness of this URL is out of scope for the UCP specification, the Host **SHOULD** take appropriate measures to ensure it only loads trusted endpoints (e.g., verifying against known PSP domains or using browser security policies). |
There was a problem hiding this comment.
For the host to verify trust on the url in the action, the payment handler's published schema (hosted at a PSP-owned URL, independently fetchable by the host) could declare allowed URL patterns for challenge actions. This way the host has a verification source independent of the merchant.
There was a problem hiding this comment.
Thanks for the suggestion. Updated the section.
| "format": "uri", | ||
| "description": "The URL where the result should be sent or where the interaction completes." | ||
| }, | ||
| "action": { |
There was a problem hiding this comment.
In the case of a 3DS challenge the action request is coming from the PSP (The business is mostly acting as a pass through for the payment data). As such, I am confused by some of the proposed properties/the structure here. Take "type" for example - the business is not determining how the action from the PSP should be rendered by the platform as they are not in control of the action request. I am worried this proposal suggests that the business take on the role of translating PSP specific logic into boxes it doesn't naturally fit in and the business doesn't have control over.
For example, when a merchant's Adyen integration receives an action from a payment request today, the merchant server forwards that object verbatim to the Adyen Web Drop-in SDK on the client. The SDK reads its known shape and decides whether to render a fingerprint via hidden iframe, a challenge via visible iframe, a redirect, an SDK flow, a voucher, a QR code, an await poll, etc. The merchant server never interprets the action — that's the SDK's job. Now with UCP, the protocol is essentially asking "what if the platform is the client?" and the natural answer is "then the platform should consume the PSP's action the same way the client SDK does today."
UCP already has a precedent for this exact pattern: payment_handler.config. A handler declares some UCP-typed top-level metadata (type, version, schema, etc.) and an opaque config whose shape is defined by the handler's own published schema. UCP doesn't have an opinion about what's inside config. Could challenge follow the same shape? Pull the things that genuinely don't vary by PSP up to the challenge envelope, and let action (and result) be opaque PSP-payload bags? See example below
"challenge": { "callback_url": "https://platform.example/cb/abc", "intent": "device_data_collection", "display": "hidden", "expires_at": "...", "action": { // PSP's action object, passed verbatim from PSP response. // additionalProperties: true — UCP has no opinions on shape. "type": "threeDS2", "subtype": "fingerprint", "token": "...", "paymentData": "...", "url": "..." } }
Note: I replaced your suggested type field with display — hidden vs visible (keeps rendering vocabulary out of the protocol while still letting the platform pick its rendering primitive)
There was a problem hiding this comment.
Thanks Julia.
The lift on the business side is expected to be minimal and we do expect them to just pass through the response from the PSP. Regarding the actual implementation of the rendering, it might be done by the platform itself or it may rely on the payment handler to do so. I'm not sure if making some fields explicit and some not will solve the problem. Whoever is rendering needs to understand the full action payload. If the specifics of the action payload is not well-defined, it can lead to a heavy lift between payment handlers and PSPs (many-to-many) and could lead to fragmented implementation. So I think we have 2 options here.
- Make the action payload standardized (what's defined in the current PR).
- Make it generic but provide clear guidance on how the payload should look like in a standard implementation. It will help guide PSPs and Payment handlers handle the functionality without needing too much co-ordination. There could be exceptions but at least for the normal functionality, such a guidance can prove to be beneficial.
Please share your thoughts.
There was a problem hiding this comment.
Thanks for getting back to me @aneeshali — and I completely agree with your point that whoever is doing the rendering needs to understand the full action payload. That said, I think there is tension between this PR and the statement that “the lift on the business side is expected to be minimal” and that businesses can “just pass through the response from the PSP.”
If the action payload is standardized, the business is no longer doing a true pass-through. It has to add a normalization/translation layer mapping PSP-native payloads into UCP fields. Businesses already have production PSP integrations and existing challenge flows, so this introduces new transformation logic on a highly sensitive payment path.
It also pushes change-management to the wrong owner: businesses don’t control PSP action contracts, but would have to continuously adapt to PSP payload evolution and version drift specifically for UCP. That increases maintenance and correctness risk where failures directly impact conversion and payment reliability.
I think we should standardize cross-PSP lifecycle/state semantics (e.g., complete_in_progress, callback handshake, timeout/retry semantics, transition ownership), but keep action/result PSP-shaped and opaque.
If a Platform advertises support for a handler, it should own the runtime parsing/rendering and change-management for that handler’s challenge semantics (like 3DS), while the Business remains a pass-through/orchestrator at the PSP boundary.
There was a problem hiding this comment.
Thanks for sharing the context. Yes it makes more sense to keep the action and action_result payloads generic, so it works across the board. I have updated the schema to reflect that.
213b01c to
281ba1b
Compare
281ba1b to
4110e4a
Compare
drewolson-google
left a comment
There was a problem hiding this comment.
Added a few comments, overall looks good.
| "token": "examplePaymentMethodToken" | ||
| }, | ||
| "challenge": { | ||
| "callback_url": "https://p.g.com/challenge/callback/session_123" |
There was a problem hiding this comment.
Perhaps we could name this field more clearly to remove some confusion as well.
|
|
||
| #### Step 2.2: Platform Submits DDC Result (Platform -> Business) | ||
|
|
||
| The Platform renders the hidden iframe. Once it completes, the Platform resumes the complete call. |
There was a problem hiding this comment.
Rendering a hidden iframe assumes the Platform is running in a context that can render an iframe. Is it possible to keep the spec more generic and be less specific about the mechanisms for device data collection?
There was a problem hiding this comment.
I agree iframe is too specific. I have updated the guide to call it executing action, with iframe/popup etc being examples.
| "challenge": { | ||
| "result": { | ||
| "intent": "challenge", | ||
| "status": "abandoned" |
There was a problem hiding this comment.
Is this modeled as a free-form text field or an enum type?
There was a problem hiding this comment.
Thanks Drew for pointing out. I have converted this field and other relevant fields to enums.
| ] | ||
| }, | ||
| "status": { | ||
| "type": "string", |
There was a problem hiding this comment.
Why is this a string and not an enum? What should the recipient do with unrecognized strings?
| | `risk_rejected` | `recoverable` | The transaction was rejected due to high risk for this specific instrument. The Platform should guide the buyer to use a different payment instrument. | | ||
| | `payment_timeout` | `recoverable` | The ACS or 3DS server timed out. The Platform may allow the user to retry the operation or guide them to switch to a different payment instrument. | | ||
|
|
||
| ### Abandonment and Instrument Switching |
There was a problem hiding this comment.
Can you rework this section to explicitly define state-transition behavior for the new checkout states introduced by this PR?
Specifically, what is the expected business behavior when a second POST /complete arrives with a new payment instrument while the checkout is already in complete_in_progress?
- Should the business always move
complete_in_progressback toready_for_completebefore processing the second /complete? - Or only when the second request includes the old instrument marked
selected: falsewithchallenge.result.status: "abandoned"? - If that old-instrument payload is missing (and that section currently says platform SHOULD, not MUST), should the business reject the second /complete to avoid ambiguous behavior?
I’m calling this out because transitioning from complete_in_progress back to ready_for_complete may involve non-trivial business-side work (e.g., failing a partial order back to basket, voiding pending PSP authorization, and restarting the complete flow with the new PI). Without explicit normative guidance here, it introduces risk for the business and different implementations may handle retries/switches inconsistently.
There was a problem hiding this comment.
Thanks Julia. I have added more details to clarify the section as suggested. Changed to MUST and gave the explanation of why the status can stay at complete_in_progress
4110e4a to
ed28aa0
Compare
ed28aa0 to
0b7b243
Compare
0b7b243 to
4be562d
Compare
raginpirate
left a comment
There was a problem hiding this comment.
@aneeshali — appreciate the work, especially the EMVCo signals shape and the lifecycle thinking. Want to push on layering before this settles. I think a deep dive would help a lot.
The structural concern: this PR makes platforms couriers for 3DS protocol context (transStatus, CRes payloads, success/failure interpretation) that feels off crossing the UCP wires; it creates a dependency on platforms to build robust, 3ds specific implementations and for businesses to change their usually simple redirect URL integration with PSPs into a much more complex one.
I've been drafting a generic actions primitive on the checkout response — the business emits an imperative to render a URL in a specified form (invisible | inline | modal | full_page | redirect), with simple embedded protocol-like responses and results written back to a result_path. I think this could be a much simpler, more extensive path to get things like ddc and 3ds2 supported, without increased complexity for all parties when compared to simple checkout integrations.
Two implications for 3DS:
- Business-owned frame URLs. Platform mounts merchant.com/3ds/ddc, not acs.issuer.com/.... The ACS interaction happens inside that wrapper; the 3DS payload flows ACS → business server-to-server; the platform's postMessage back is just to have the frame closed.
- DDC can be done early in update_checkout. It's invisible data prep, not a submit-time step. Severity optional, mounted in the background as soon as the instrument is selected — buyer never waits on it. Submit time can also handle it as well, but hopefully folks can complete it as early as checkout loads with vaulted credentials.
Sequence diagram showing my early thoughts
And PR (please note theres lots to dislike about this AI slop, but it might be helpful to speak to with your favourite agent to decipher the intent here): #458
WDYT about shifting from solving 3ds to solving a generic frame-communication standard for UCP, and keeping 3ds, ddc, captchas, and 3p methods as follow-up implementations built ontop?
|
Thanks for the feedback, @raginpirate. Sounds good, we can move the discussion to #458. I was wondering how we could do a deep dive. If you meant syncing on the PR, that works too. Look forward to getting this addressed soon. |
|
One source-contract readback before this lands:
|
prasad-stripe
left a comment
There was a problem hiding this comment.
Thanks for the updates @aneeshali, the flow is much clearer now with the client_callback separation, enum conversions, and the error handling/recovery section. Good progress. I've left a few inline comments on areas that I think need attention.
| "timezone": { | ||
| "type": "string", | ||
| "description": "Browser Time Zone offset or name.", | ||
| "examples": ["America/Los_Angeles", "-480"] |
There was a problem hiding this comment.
The EMVCo AReq field (browserTZ) is specifically the UTC offset in minutes (e.g., "-300" for UTC-5). This value is trivially available client-side via getTimezoneOffset(). Allowing both a timezone name ("America/Los_Angeles") and an offset in the same field creates ambiguity for the business/PSP when mapping to the AReq. Suggest making this an integer offset field, and if there's a use case for timezone name, adding it as a separate optional field.
| "signals": { | ||
| "dev.ucp.browser_info": { | ||
| "user_agent": "...", | ||
| "color_depth": 24 |
There was a problem hiding this comment.
This should be string according to: https://github.com/Universal-Commerce-Protocol/ucp/pull/421/changes#diff-4bee59bbd434f780cb6676f38a32470f5510f82055fec48f634b37c491de4a75R31
| "format": "uri", | ||
| "description": "The URL where the result should be sent or where the interaction completes." | ||
| }, | ||
| "action": { |
There was a problem hiding this comment.
An absolute timestamp requires clock synchronization between business and platform, which is unreliable. For the typical 5-10s DDC timeout, an interval (e.g., timeout_seconds: 10) is more practical. It tells the platform how long to wait after posting to the 3DS Method URL, without needing synchronized clocks. WDYT?
|
|
||
| ### Sequence Diagram | ||
|
|
||
| ```mermaid |
There was a problem hiding this comment.
Would be great to add AReq/ARes and RReq/RRes to the diagram. These are the authoritative protocol messages and showing them helps implementers understand what's happening on the business/PSP side when the platform is waiting.
| "description": "The outcome of the authentication flow (e.g., 'authenticated', 'frictionless', 'attempted'). Optional, for platform observability and metrics.", | ||
| "enum": [ | ||
| "authenticated", | ||
| "frictionless", |
There was a problem hiding this comment.
"frictionless" is a flow path (no challenge was presented), not an authentication outcome. The other values here (authenticated, attempted, failed) map to protocol results, but frictionless describes how we got there rather than what happened.
Additionally, the current enum doesn't cover several transStatus values from the protocol. For reference, the EMVCo transStatus values (3DS Protocol and Core Functions Spec, Table A.5):
| Value | Meaning |
|---|---|
| Y | Authentication successful |
| N | Not authenticated / denied |
| U | Authentication could not be performed |
| A | Attempts processing performed |
| C | Challenge required |
| R | Authentication rejected |
| I | Informational only |
With the current enum, there's no distinction between N and R (both collapse into "failed"), and I and U have no representation at all. These have different risk implications for the platform/business.
I will suggest either mapping these more directly to protocol outcomes (with a clear mapping table from transStatus values) or renaming this field to something like auth_flow_result to signal it's a UCP-level abstraction and documenting the mapping.
|
Hello @aneeshali, is still actively worked on or has the work shifted to other related PRs? Like #458 Let us know! |
This PR adds vendor-agnostic 3DS2 support to the UCP specification and schemas. Closes #420.