Skip to content

feat: add 3DS2 support to spec and schemas#421

Open
aneeshali wants to merge 2 commits into
Universal-Commerce-Protocol:mainfrom
aneeshali:feature/3ds2-support
Open

feat: add 3DS2 support to spec and schemas#421
aneeshali wants to merge 2 commits into
Universal-Commerce-Protocol:mainfrom
aneeshali:feature/3ds2-support

Conversation

@aneeshali

Copy link
Copy Markdown

This PR adds vendor-agnostic 3DS2 support to the UCP specification and schemas. Closes #420.

@aneeshali aneeshali marked this pull request as ready for review May 7, 2026 23:02
@aneeshali aneeshali requested review from a team as code owners May 7, 2026 23:02
@ptiper ptiper added the payments label May 8, 2026
@aneeshali aneeshali force-pushed the feature/3ds2-support branch 10 times, most recently from 2825f10 to e6c573c Compare May 12, 2026 21:57
@aneeshali aneeshali marked this pull request as draft May 12, 2026 21:57
@aneeshali aneeshali force-pushed the feature/3ds2-support branch from e6c573c to 0f012f2 Compare May 12, 2026 22:03
@aneeshali aneeshali force-pushed the feature/3ds2-support branch from 0f012f2 to a0a7d0a Compare May 12, 2026 22:09

@prasad-stripe prasad-stripe left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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": {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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": [

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@aneeshali aneeshali May 13, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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": {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a new field expires_at to address this.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@aneeshali aneeshali May 13, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed the sequence to make it clear that it's the frontend callback. Also added a note at the end of the sequence.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps we could name this field more clearly to remove some confusion as well.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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": {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

@aneeshali aneeshali May 13, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the suggestion. Updated the section.

"type": "object",
"description": "Display information for this payment instrument. Each payment instrument schema defines its specific display properties, as outlined by the payment handler."
},
"challenge": {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm thinking we should make this an array of challenges so it's generic enough going beyond 3DS, if an issuer is able to support multiple challenges including 3DS/SMS/Email etc. @prasad-stripe please share your thoughts.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for raising this. My instinct is to keep it as a single object for now. In practice, a completed 3DS is a stronger signal than completed SMS/email challenge from risk point of view, so the UCP layer doesn't need to model that multiplicity.

If we were to make this an array, we'd need to define sequencing semantics (all? any? ordered?) which is a significant design surface without real use cases driving it yet. I'd suggest we note in the spec that multi-challenge support is a known future extension point, but keep the current single-object shape to avoid premature complexity.

"deprecated": true,
"examples": ["Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"]
},
"dev.ucp.browser_info.accept_headers": {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A couple of comments/questions on the proposed signal changes here:

  1. How will browser_info.buyer_ip and browser_info.user_agent differ from the existing ones we currently have? Any reason why we can't lean on these 2 signals as-is and only introduce the net new signals?
    1.1. If for any reason we want to consolidate everything into browser_info, like how you are deprecating the other signals, then we should do it consistently and also use the UCP transition schema to move it to "omit" so we can drop it in the next version:
"transition": {
  "from": "optional",
  "to": "omit",
  "description": "..."
}
  1. Any particular reason for the browser_info wrapper around all the individual signals (I don't think EMVCo requirements specifically required the wrapper)? If we feel wrapper is the right way to go, have we consider just defining 1 object in types/ to contain all these browser signals? i.e.
"dev.ucp.browser_info": {
  "$ref": "brower_info_signals.json"
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Jing.

  1. How will browser_info.buyer_ip and browser_info.user_agent differ from the existing ones we currently have? Any reason why we can't lean on these 2 signals as-is and only introduce the net new signals?
    1.1. If for any reason we want to consolidate everything into browser_info, like how you are deprecating the other signals, then we should do it consistently and also use the UCP transition schema to move it to "omit" so we can drop it in the next version:
"transition": {
  "from": "optional",
  "to": "omit",
  "description": "..."
}

I was looking at having a logical grouping of signals. There can be other signals such as device signals, which will be different from the browser signals, so it is better not to have a flat list for all signals. I can make the changes to add transition but wondering if there are any guidelines I should follow to do that.

  1. Any particular reason for the browser_info wrapper around all the individual signals (I don't think EMVCo requirements specifically required the wrapper)? If we feel wrapper is the right way to go, have we consider just defining 1 object in types/ to contain all these browser signals? i.e.
"dev.ucp.browser_info": {
  "$ref": "brower_info_signals.json"
}

I have moved browser signals to a separate file as suggested.

Comment thread docs/specification/checkout-3ds2-guide.md Outdated
Business-->>Platform: complete_in_progress (Challenge Action)
deactivate Business

Note over Platform: Render Challenge UI (Popup)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Naive question on this: At a high-level, this feels like we are initiating an embedded context that renders/embeds the 3DS challenge on platform host. Have we considered a design option to augment our existing embedded transport to support this use case (i.e. I guess in this case it would be between platform host and PSP issuer) so we don't need to introduce a new pattern of exchanging data via callback_url..?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIUC, 3DS is a problem that doesn't exist in the context of embedded transport. This is because the merchant is able to embed its own UI which means it will be able to render the 3DS flows also if required by the issuer. So this is a problem we need to solve for the native integration, hence this path is followed.

## UCP Spec Enhancement

In this model, the **Business** acts as the orchestrator, and the **Platform** acts as the execution environment for standardized 3DS2 actions.
The design uses `complete_in_progress` to signal that the checkout is in an intermediate state, awaiting the result of a 3DS2 action.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On top of this signal, would it be good to consider whether there should be an accompanying message so platform can clearly know what to parse-for/look-at in case the complete_in_progress status is used in other non-3DS challenge context?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the suggestion, I have made changes to include a new code called challenge_required for this purpose.

"intent": "challenge",
"type": "popup",
"url": "https://bank-acs.com/challenge",
"request_data": { "creq": "eyJtZXNzYWdlVHlwZSI6..." },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apologies for my unfamiliarity on how 3DS works, but curious about this field: based on the schema description below, it sounds like this is Data to be sent with the request. when platform loads the url from above, do we have restrictions around what can be in here? If it's truly a freeform, is there any concerns around injection attacks when a compromised business is involved..?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is expected to be free form since every PSP will have it's own way of handling it. Under URL security, I have added the considerations around trusting the URL, and the trust of the payload should go along with it.


To prevent the Business from sending challenge actions that the Platform cannot execute (e.g., in non-browser environments), the Platform should be able to signal its capability to handle challenges upfront.

- **Capability Signal:** If the Platform does not declare support for handling challenges (e.g., via a generic delegation negotiation mechanism), the Business **MUST NOT** send a `complete_in_progress` status with a challenge action.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This probably should be done as part of platform profile (some options: should be a config under payment_handler or checkout capabilities).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushing back on the need to declare challenge support & statement of "the Business MUST NOT send a complete_in_progress status with a challenge action." -

Maybe controversial but if the platform can't execute a challenge the PSP returned, that's the platform's problem, not the business's. Platforms that never have a way to render a challenge UI shouldn't advertise support for handlers whose action surface includes challenges. So this would already be covered by existing handler discovery. The handler spec implicitly declares its action surface (including whether 3DS challenges may be issued) by stating it supports cards. A platform that can't render challenges should self-select out of those handlers and advertise only ones whose action surface it can execute end-to-end (ACH, SEPA, wire transfer, stored-token MIT flows, etc.). So I don't think we need explicit declaration of challenge support from the business or platform.

A 3DS challenge isn't something the business can choose — it's controlled by the PSP and ultimately the issuer based on risk scoring, issuer policy, and (for EEA cards) PSD2/SCA mandates. A business "advertising payment_handler support for cards without 3DS" is effectively advertising a payment method that will get declined by European issuers (and has a chance of getting declined in the US, Canada, Australia, etc). The merchant is a pass-through — the PSP returns an action or it doesn't, and the merchant forwards it.

The business has no alternative PSP path — there's nothing meaningful to do in the adapter besides forward the action. Requiring the business to add conditional logic which would check if the platform said they support 3DS before they respond with the PSP’s challenge adds unnecessary complexity and is solving for a decision that doesn't exist, and the failure mode it would prevent (buyer can't complete a challenge) is the same as any other graceful payment failure: the challenge doesn't complete, the PI fails, the buyer picks another method or abandons.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with the POV that this can be achieved at the payment_handler config level. But we will still have a problem where it could lead to a dead end if the Platform is not able to choose any payment handler. Instead of treating this as WAI, I was wondering if there is some way to indicate this issue to the business so they can provide the requires_escalation response. Then they can themselves handle the flow. Are there scenarios in UCP currently where we run into a similar problem, where we guide the business to fallback to requires_escalation? If there is a precedent, we could follow that.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there is no compatible payment handler between a Platform and Business, that should be handled during capability negotiation, which occurs well before /complete. In that case, the buyer should be escalated to the Business-hosted flow via continue_url, avoiding a runtime dead-end.

Once a Platform proceeds into /complete for a handler, it should be treated as a commitment that the Platform can execute that handler’s runtime contract (including non-happy-path behavior like 3DS). Otherwise, we shift platform capability risk onto the Business at runtime, which effectively makes the Business a compatibility layer for contracts it doesn’t own.

Do you agree with that ownership split?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suspect many of these ownership ambiguities come from treating 3DS as an add-on to card-capable payment_handlers (including wallets like Google Pay). For card methods, 3DS should be part of the baseline handler runtime contract. Once framed that way, capability negotiation and fallback ownership become much clearer.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Julia, agreed on this ownership split. I have modified the section to reflect that.

Comment thread docs/specification/checkout.md Outdated
The business remains the Merchant of Record (MoR), and they don't need to become
PCI DSS compliant to accept card payments through this Capability.

For handling 3DS2 challenge flows in a vendor-agnostic manner, see the [Checkout 3DS2 Guide](checkout-3ds2-guide.md).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we consider adding this under the Complete Checkout operation section - https://ucp.dev/latest/specification/checkout/#complete-checkout..?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved under completeCheckout.

@aneeshali aneeshali marked this pull request as ready for review May 14, 2026 23:26
@jingyli jingyli added TC review Ready for TC review and removed WIP labels May 14, 2026
@igrigorik igrigorik requested a review from raginpirate May 15, 2026 17:03
"format": "uri",
"description": "The URL where the result should be sent or where the interaction completes."
},
"action": {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 displayhidden vs visible (keeps rendering vocabulary out of the protocol while still letting the platform pick its rendering primitive)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@aneeshali aneeshali force-pushed the feature/3ds2-support branch from 213b01c to 281ba1b Compare May 17, 2026 23:22
@aneeshali aneeshali marked this pull request as draft May 17, 2026 23:26
@aneeshali aneeshali force-pushed the feature/3ds2-support branch from 281ba1b to 4110e4a Compare May 17, 2026 23:27
@aneeshali aneeshali marked this pull request as ready for review May 17, 2026 23:56

@drewolson-google drewolson-google left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a few comments, overall looks good.

"token": "examplePaymentMethodToken"
},
"challenge": {
"callback_url": "https://p.g.com/challenge/callback/session_123"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this modeled as a free-form text field or an enum type?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Drew for pointing out. I have converted this field and other relevant fields to enums.

]
},
"status": {
"type": "string",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this a string and not an enum? What should the recipient do with unrecognized strings?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Modified to enum.

| `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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_progress back to ready_for_complete before processing the second /complete?
  • Or only when the second request includes the old instrument marked selected: false with challenge.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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@aneeshali aneeshali force-pushed the feature/3ds2-support branch from 0b7b243 to 4be562d Compare May 19, 2026 01:45
@aneeshali aneeshali marked this pull request as ready for review May 19, 2026 01:47
@aneeshali aneeshali requested a review from julia-downey May 19, 2026 04:01

@raginpirate raginpirate left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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:

  1. 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.
  2. 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?

@aneeshali

Copy link
Copy Markdown
Author

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.

@TateLyman

Copy link
Copy Markdown

One source-contract readback before this lands:

  • The guide example sends dev.ucp.browser_info.color_depth as a JSON number (24), but source/schemas/shopping/types/browser_info_signals.json defines color_depth as type: "string". The same schema also models java_enabled, javascript_enabled, screen_height, and screen_width as strings. If the intent is EMVCo-style serialized values, the guide should quote the value; if the intent is direct browser/platform observation, the schema probably needs booleans/numbers for those fields.
  • The URL security section says platforms should validate action.url against PSP-owned allowed_challenge_urls, but source/schemas/shopping/types/challenge.json leaves action as a fully open object with no shared url or request_method property. That means a schema-valid complete_in_progress challenge can omit or rename the exact URL field that the security guidance depends on. If the PSP payload needs to stay vendor-specific, a minimal common envelope like action.url (format: uri) + request_method, with request_data still open, would make the trust check machine-readable without constraining PSP internals.

@prasad-stripe prasad-stripe left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"format": "uri",
"description": "The URL where the result should be sent or where the interaction completes."
},
"action": {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

payments TC review Ready for TC review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support 3DS2 in UCP Specification

9 participants