Skip to content

feat: add payment instrument qualifiers#214

Closed
ACSchil wants to merge 12 commits intoUniversal-Commerce-Protocol:mainfrom
ACSchil:alex/137
Closed

feat: add payment instrument qualifiers#214
ACSchil wants to merge 12 commits intoUniversal-Commerce-Protocol:mainfrom
ACSchil:alex/137

Conversation

@ACSchil
Copy link
Contributor

@ACSchil ACSchil commented Feb 24, 2026

Description

Context / Closes #137

Adds an optional qualifiers: string[] field to the Payment Instrument schema and documents Payment Qualifiers in the Checkout overview. Qualifiers are opaque, reverse-domain namespaced hints whose meaning/derivation is agreed out-of-band between the Platform and Business, enabling the Business to apply expected benefits prior to completion. If benefits are applied based on qualifiers, the Business SHOULD fail checkout when the selected payment instrument at completion does not meet the hinted qualifications; unknown qualifiers are treated as no-ops.

Type of change

Please delete options that are not relevant.

  • New feature (non-breaking change which adds functionality)
  • Documentation update

Is this a Breaking Change or Removal?

No. Adds a new optional field.

Checklist

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • Any dependent changes have been merged and published in downstream modules

@google-cla
Copy link

google-cla bot commented Feb 24, 2026

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

Content-Type: application/json

{
"id": "chk_123456789",

Choose a reason for hiding this comment

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

The Update Payment example uses PUT /checkout-sessions/{id} and includes "id": "chk_123456789" in the body, but the spec section doesn’t state what happens if the path {id} and body id differ. Add a normative rule: either (a) body id MUST match path {id} and mismatches MUST be rejected with a specific error, or (b) body id is ignored and the path is authoritative.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Great call-out. Because this PR feat did not introduce that potential conflict (i.e. there are other Update docs with this issue), I propose we create a separate issue to address that. Thoughts?

Thinking through the solution, it's probably to return a 400 or 422. And this would then be captured under https://ucp.dev/2026-01-23/specification/checkout-rest/#status-codes .

Choose a reason for hiding this comment

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

Sounds good. Please create a separate issue.

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

Choose a reason for hiding this comment

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

Qualifiers is currently an unbounded array of unbounded strings. For a protocol-level schema used by autonomous agents, this is a straightforward DoS / log-injection / storage-amplification risk.

Add schema constraints, for example:

  • maxItems (e.g., 10–50 depending on expected use)
  • uniqueItems: true (if semantics are set-like)
  • string constraints like maxLength (e.g., 128/256) and a conservative pattern (e.g., reverse-domain-ish allowed chars)

Even if implementations don’t enforce strictly, having constraints in the canonical schema sets a safe baseline.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

How should we communicate the reverse-domain-ish characters?

Shall we say items match the pattern of ^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+\.[a-z][a-z0-9_-]*$ ? i.e. {reverse-domain}.{service}.{qualifier}, e.g. "com.target.red_card" or "co.uk.example.qualifier-abc123"

Copy link
Contributor

Choose a reason for hiding this comment

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

re: max items and string constraints; I feel like those needs to be applied across the board to UCP and are not today. We should discuss this in the TC; do we need a sweeping application of that to the spec, and should that be done in isolation of this PR?

I also disagree with enforcing reverse-dns here; from my POV the primary use case of this should be in-band by payment handlers to express how you negotiate qualfiier context that you want exposed by a payment handler for a specific instrument. Out-of-band use cases should not be dictated and require reverse-dns; they have their own solutions for identifying the relying party they negotiated with and interpreting those fields independently for those parties.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I agree we have an opportunity to apply these across the board. I am okay adding the constraint here (as I have done), or removing it an referencing this feedback in a new issue that takes a broader look.

Out-of-band use cases should not be dictated and require reverse-dns

Without reverse domain naming, there's a significant chance of collision. Imagine two entities with member_program; it would be helpful for the program named be tied to that program owner, e.g. com.target.member_program so that another merchant can also have com.merchant_b.member_program.

@ACSchil ACSchil marked this pull request as ready for review February 24, 2026 23:30
@ACSchil ACSchil requested review from a team as code owners February 24, 2026 23:30
@igrigorik igrigorik added the TC review Ready for TC review label Feb 25, 2026
@igrigorik igrigorik added this to the Working Draft milestone Feb 25, 2026
Copy link
Contributor

@raginpirate raginpirate left a comment

Choose a reason for hiding this comment

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

Thanks for driving at this! Sadly I missed our breakout on this topic last week, but I did add context to the notes about how I think we can introduce this ontop of #187.

I'm wondering why this PR doesn't have any approach for businesses to communicate the qualifiers they are asking from handlers and platforms.

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

Choose a reason for hiding this comment

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

re: max items and string constraints; I feel like those needs to be applied across the board to UCP and are not today. We should discuss this in the TC; do we need a sweeping application of that to the spec, and should that be done in isolation of this PR?

I also disagree with enforcing reverse-dns here; from my POV the primary use case of this should be in-band by payment handlers to express how you negotiate qualfiier context that you want exposed by a payment handler for a specific instrument. Out-of-band use cases should not be dictated and require reverse-dns; they have their own solutions for identifying the relying party they negotiated with and interpreting those fields independently for those parties.

},
"qualifiers": {
"type": "array",
"description": "Opaque, namespaced qualifier strings from the Platform that hint to the Business the benefits to apply at checkout time, based on the selected payment instrument."
Copy link
Contributor

Choose a reason for hiding this comment

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

Will this always come from the platform?
Lets chat out a hypothetical.
I'm using , and using it's agentic mode it returns back to the platform a token encrypted for merchant 1234. As the platform, I have no ability to actually determine the qualifiers; that credential isn't transparent to me.
But, if the wallet exposes the selected instrument qualifier, then easy; we can pass it through to the merchant early, just like a shipping option event.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

we can pass it through to the merchant early

This comes from the Platform, correct? Even if the Platform is using a wallet that abstract the credential?

2. **Acquisition (Platform ↔ Payment Credential Provider):** The platform executes the handler's logic. This happens client-side or agent-side, directly with the payment credential provider (e.g., exchanging credentials for a network token). The business is not involved, ensuring raw data never touches the business's frontend API.
3. **Completion (Platform → Business):** The platform submits the opaque credential (token) to the business. The business uses it to capture funds via their backend integration with the payment credential provider.

### Payment Qualifiers

Choose a reason for hiding this comment

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

Consideration: Is qualifiers descriptive enough? "Qualifiers" often implies a binding state or a restrictive limitation. Since the semantics explicitly define these as non-binding "hints" that require independent verification, benefit_hints or eligibility_hints might be a better fit. It signals that the field is a suggestion for the presentation and pre-checkout logic rather than a final, validated qualification for financial settlement.

**Qualifier Strings:**

- Qualifier strings **SHOULD** use reverse-domain naming to avoid collisions.
- Qualifiers strings **MUST NOT** contain sensitive payment attributes such as PAN, BIN, PII, or user-unique identifiers.

Choose a reason for hiding this comment

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

Nit: We are not at all consistent, but define acronyms like BIN (Bank Identification Number)upon their first mention? It helps ensure the spec remains accessible to engineers who might be new to the payments domain.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I agree. We have a glossary, but it's mostly for our own terms. Maybe separately we take a pass at capturing all acronyms there?

Copy link

Choose a reason for hiding this comment

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

Primarily as a smoke test to make sure that I had the CLA signed, I split out a central glossary (#241). We can layer these acronyms in there once it's merged!


Prior to completing checkout, the Platform **MAY** provide the Business with selected payment instrument *hints*. These hints allow the Business to apply to the checkout session the expected benefits the selected payment instrument qualifies the Buyer for. This gives the user a more accurate preview of the final order total before they commit to the purchase.

Payment instruments **MAY** include a `qualifiers` array: opaque, namespaced strings that hint benefit eligibility associated with the selected instrument. The meaning of qualifier values, and how they are derived, is communicated between the Business and Platform out of band (for example, via offline agreement on BIN ranges or program identifiers).

Choose a reason for hiding this comment

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

Consideration: Will this field exclusively be used for "benefits"?

While the current description focuses on perks like discounts, there are several
scenarios where a Platform might pass hints for regulatory or technical restriction
that aren't "benefits" to the user. For example:

  • Product-Category Restrictions: Signaling a corporate or government card that is legally barred from purchasing specific items (e.g., alcohol, tobacco, or gaming).
  • Surcharge Eligibility: Signaling a high-cost commercial card where a merchant is legally allowed (or required) to pass on a processing fee.
  • KYC/Identity Status: Signaling a specific "Know Your Customer" verification level that might skip—or trigger—additional identity checks.
  • Cross-Border Compliance: Signaling an instrument with geographic limitations that might affect tax or shipping legality.

If we anticipate these non-benefit use cases, would a more neutral term like eligibility_hints be more appropriate?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks for this input. I went with eligibility_hints.

@igrigorik igrigorik self-requested a review February 27, 2026 18:05
@ACSchil
Copy link
Contributor Author

ACSchil commented Feb 27, 2026

Thanks for driving at this! Sadly I missed our breakout on this topic last week, but I did add context to the notes about how I think we can introduce this ontop of #187.

I'm wondering why this PR doesn't have any approach for businesses to communicate the qualifiers they are asking from handlers and platforms.

@raginpirate , thank you for the notes! I agree that I believe we'll want to solve for communicating qualifiers. The consensus was that we did not want to prematurely prescribe how this is done, and that we will let usage inform next steps. Here's that decision - https://github.com/Universal-Commerce-Protocol/meeting-minutes/blob/main/tc/2026/2026-02-11.md?plain=1#L38

@douglasborthwick-crypto
Copy link

The trust question here — how does a Business verify that an eligibility_hint is legitimate and not injected by a compromised agent? — maps well to what we've built at InsumerAPI.

We serve UCP-format eligibility checks across 31 blockchains. A Platform verifies a buyer's on-chain holdings with a single call:

curl -X POST https://api.insumermodel.com/v1/ucp/discount \
  -H "X-API-Key: insr_live_..." \
  -H "Content-Type: application/json" \
  -d '{"merchantId": "MERCHANT_ID", "wallet": "0xBuyerWallet..."}'

The response is UCP-format (extension: "dev.ucp.shopping.discount") with applied discounts and an ECDSA P-256 signature + key ID in a verification object. The Business validates the sig against the JWKS at /.well-known/jwks.json — no need to trust the Platform's word.

Concretely: this lets a Platform turn an on-chain eligibility check into a cryptographically backed eligibility_hint, so the Business can pre-apply a discount knowing it's been independently verified.

We already publish a UCP discovery file at /.well-known/ucp.json declaring this capability. Happy to set up a free API key for anyone who wants to test the integration. Docs: https://insumermodel.com/developers/commerce/

- The Business **MUST NOT** grant final or irreversible benefits solely due to `eligibility_hints`.
- The Business **MUST** determine benefits eligibility from the completion payment instrument and credential, not from `eligibility_hints`.
- The Business **SHOULD** return an error, using the [`invalid_eligibility_hint`](checkout.md#standard-errors), during checkout completion if the provided `eligibility_hints` do not match the final payment instrument's eligibility.
- When receiving `invalid_eligibility_hint`, the Platform **SHOULD** update `eligibility_hints`, and **MUST** present the user with an opportunity to review benefits changes (e.g. discounts, totals, etc.).
Copy link
Contributor

Choose a reason for hiding this comment

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

So does this mean, the platform should ask the user to select a different payment instrument or remove the eligibility_hint from their request and use the same instrument or are there other options for the platform?
I am trying to understand what are the valid responses from Platform once Business returns an error "invalid_eligibility_hint" and should we update the documentation to be clearer?

Copy link
Contributor

@raginpirate raginpirate Mar 5, 2026

Choose a reason for hiding this comment

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

Great question Amit and I will actually drive this further while thinking through the full flow:

  • As a platform, I submit instrument 1234 with a qualifier the business does not respect. I am expecting the business can actually silently ignore it, or optionally provide back a message to say "hey this qualifier isn't right so I removed it". The state can still be ready-for-submit immediately, not requiring platform action. The platform is informed regardless because the qualifier is not surfaced back.
  • As a business, when I receive a credential for an instrument with qualifier 1234, but now the credential mismatches from that qualifier context, I can perform the same operation: remove the qualifier, optional non-blocking message back to the platform.
  • If this were to occur during a complete call, and this actually impacts the totals, I should fail the complete. It should not be a requirement to fail though if the qualifier never meaningfully impacted the state of the checkout; that should be up to business discretion, and I am not sure needs to be reflected in the overview of this feature.

Is all of the above correct? If so, I'd love to see it (or the adjustments to that view) codified with the right MUST / SHOULD outlook.

gsmith85 added a commit to gsmith85/ucp that referenced this pull request Mar 5, 2026
- Organize terms by category (Commerce, Payments, etc.).
- Define financial acronyms previously used but not expanded.
- Establish guidelines for first-use acronyms in Markdown files.

Ref: Universal-Commerce-Protocol#214 (comment)
Copy link
Contributor

@raginpirate raginpirate left a comment

Choose a reason for hiding this comment

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

Thanks for the great work @ACSchil! Totally aligned to unblock on the rev-dns & how discovery through regular handlers can work.

Just one last ask on codifying the unhappy paths here that @amithanda started poking at 🙇

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

@raginpirate raginpirate Mar 5, 2026

Choose a reason for hiding this comment

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

Nit: The phrase "eligibility_hints" feels like its conveying what this instrument can be used for (especially confusing for me after working on constraints) and I'm not sure it has any industry examples of the term being used. I preferred the original title, or some other title along the lines of "something_hint", but this is non-blocking.

Copy link

@gsmith85 gsmith85 Mar 5, 2026

Choose a reason for hiding this comment

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

With the feedback from @raginpirate on naming and to push on the idea that these might not strictly be benefits (per #214 (comment)) perhaps consider provisions? Or, provision_hints if you want to more strictly communicate it's a hint.

In the description, and elsewhere we can note provisions may capture hints for "entitlements" (e.g. priority_fulfillment, travel_credit) and "constraints" (e.g. no_tobacco, domestic_only).

Also non-blocking, but obviously this modeling is easiest to change early.

- The Business **MUST NOT** grant final or irreversible benefits solely due to `eligibility_hints`.
- The Business **MUST** determine benefits eligibility from the completion payment instrument and credential, not from `eligibility_hints`.
- The Business **SHOULD** return an error, using the [`invalid_eligibility_hint`](checkout.md#standard-errors), during checkout completion if the provided `eligibility_hints` do not match the final payment instrument's eligibility.
- When receiving `invalid_eligibility_hint`, the Platform **SHOULD** update `eligibility_hints`, and **MUST** present the user with an opportunity to review benefits changes (e.g. discounts, totals, etc.).
Copy link
Contributor

@raginpirate raginpirate Mar 5, 2026

Choose a reason for hiding this comment

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

Great question Amit and I will actually drive this further while thinking through the full flow:

  • As a platform, I submit instrument 1234 with a qualifier the business does not respect. I am expecting the business can actually silently ignore it, or optionally provide back a message to say "hey this qualifier isn't right so I removed it". The state can still be ready-for-submit immediately, not requiring platform action. The platform is informed regardless because the qualifier is not surfaced back.
  • As a business, when I receive a credential for an instrument with qualifier 1234, but now the credential mismatches from that qualifier context, I can perform the same operation: remove the qualifier, optional non-blocking message back to the platform.
  • If this were to occur during a complete call, and this actually impacts the totals, I should fail the complete. It should not be a requirement to fail though if the qualifier never meaningfully impacted the state of the checkout; that should be up to business discretion, and I am not sure needs to be reflected in the overview of this feature.

Is all of the above correct? If so, I'd love to see it (or the adjustments to that view) codified with the right MUST / SHOULD outlook.

"last_digits": "5678",
"rich_text_description": "Google Pay •••• 5678"
},
"eligibility_hints": [
Copy link
Contributor

Choose a reason for hiding this comment

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

What do we think of using context for this instead, introduced in https://github.com/Universal-Commerce-Protocol/ucp/pull/62/changes? It feels like that slice of the object is meant to give us a home for these kinds of up front "hints", and its documentation specifically references custom pricing, and the higher-resolution fields in the API that would override the hinting. It feels a little awkward to require the partner to provide so much of the payment instrument, just to offer up a hint on what payment-related qualifiers will apply.

Copy link
Contributor

Choose a reason for hiding this comment

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

Honestly great question @lemonmade. I think there is serious merit in just using context.

To argue the point of this PR though, I do see that qualifiers are tied to an instrument, so in the case of split payments it becomes harder to justify using a default checkout context bucket; which instrument should be visualized as contributing which qualifier(s)?

And the deeper reason I would prefer to push for qualifiers, is because I do want the primitive to be usable by payment handler developers, rather than it being this arbitrary bucket its being first introduced as here. Example: Use the company_name_pay sdk, and have it expose an event similar to address selection for method selection, which lets the platform push an instrument with qualifier pay_1234 to describe the matching bin range it was initialized with. If this is the future we want to support, the primitive living on the instrument does make sense to me, as its meant for handlers to interface with, rather than checkout's generic context bucket.

@douglasborthwick-crypto
Copy link

On @lemonmade's context suggestion: if context is the right home for pre-checkout signals, it might be worth distinguishing in the spec between opaque hints the Business has no way to validate until completion, and signed attestations that carry a signature and key ID verifiable against a published JWKS. The latter don't require any trust assumption about the Platform.

That distinction also speaks to the unhappy path @raginpirate outlined. The credential mismatch scenario becomes verifiable rather than trust-based when the hint itself is signed by a third party -- the Business can check independently, without a round-trip to the Platform, before completion.

@igrigorik
Copy link
Contributor

Thanks for driving this @ACSchil.

This is a deceivingly hard problem to model, and I've now made three+ loops on how to approach it, arriving back at "I think we need broaden the scope here", but before we get there, let me lay out the why and what's giving me pause...

1/ Lifecycle scope. The current design places eligibility_hints on PaymentInstrument, which locks it to checkout. But benefit visibility matters earlier: in cart (where there's no payment instrument yet) and in catalog/discovery (where a buyer browsing products should see "RedCard members save 10%"). It feels logical and expected that buyers would expect and want to see benefits reflecting earlier in the journey — I think this tells us that PaymentInstrument is not the right place to model this. Further, benefits don't have to tied to an instrument, they can also be a property of a membership, student / employee affiliations, loyalty tiers, etc.

2/ Output contract. The PR defines the input (Platform sends hints) but not the output. When the Business receives eligibility_hints: ["com.target.red_card", "com.target.circle"], how does the Platform know what the Business did with them? Today the Platform would have to diff totals before/after and guess — "did the price drop because of the hint, or a time-limited sale?" We solved this problem in discounts, and I think we can learn from that — more on that later.

3/ Array semantics. If two hints correspond to two different instruments with mutually exclusive benefits (RedCard: 10% off vs. Amex Gold: free returns), which benefits does the Business show? The flat array doesn't distinguish concurrent eligibility (buyer-level, stackable) from alternative eligibility (instrument-level, pick one). How does the Business resolve, and how does the Platform know what was resolved?


What if: eligibility as a discount capability evolution

What the PR calls "eligibility hints" is really a new trigger mechanism for discounts (eligibility-based, alongside code-based and automatic) with a new lifecycle state (provisional, pending verification at completion). This isn't a payment instrument concern — it's a discount capability concern with a broader input channel.

Input: context.eligibility: string[] — opaque, reverse-domain-namespaced eligibility signals. Lives on context, which is already available on catalog search, catalog lookup, cart, and checkout. The Platform sends it with any request; the Business reads it whenever it needs to adjust behavior.

{
  "context": {
    "address_country": "US",
    "eligibility": ["com.target.red_card", "com.target.circle_member"]
  }
}

Output at cart/checkout: Two new optional fields on applied_discount in the discount extension:

  • eligibility: string — which signal from context.eligibility triggered this discount
  • provisional: boolean — pending verification at completion (distinguishes from firm discounts)

Output at catalog: The existing price / list_price pattern (already supports strikethrough display) plus messages with a standardized code for attribution.

Examples

Catalog: browsing products. Platform sends eligibility with a catalog search:

// Request
{
  "query": "red t-shirt",
  "context": {
    "address_country": "US",
    "eligibility": ["com.target.red_card"]
  }
}

Business adjusts prices and explains via message:

// Response
{
  "products": [{
    "id": "prod_tshirt",
    "title": "Red T-Shirt",
    "price_range": {
      "min": {"amount": 2250, "currency": "USD"},
      "max": {"amount": 2250, "currency": "USD"}
    },
    "list_price_range": {
      "min": {"amount": 2500, "currency": "USD"},
      "max": {"amount": 2500, "currency": "USD"}
    },
    "variants": [{
      "id": "var_tshirt_m",
      "title": "Medium",
      "price": {"amount": 2250, "currency": "USD"},
      "list_price": {"amount": 2500, "currency": "USD"}
    }]
  }],
  "messages": [{
    "type": "info",
    "code": "eligibility_benefit",
    "path": "$.products[0]",
    "content": "RedCard members save 10%"
  }]
}

Platform renders: $22.50 $25.00 — RedCard members save 10%. No discount extension needed at catalog — it's not transactional. The existing price/list_price pair and an info message are sufficient.

Cart: estimated pricing with attribution

Platform sends eligibility with cart create/update:

// Request
{
  "line_items": [{"item": {"id": "var_tshirt_m"}, "quantity": 2}],
  "context": {
    "address_country": "US",
    "eligibility": ["com.target.red_card", "com.target.circle_member"]
  }
}

Business applies stacking rules and returns attributed discounts:

// Response
{
  "id": "cart_abc",
  "currency": "USD",
  "line_items": [{
    "id": "li_1",
    "item": {"id": "var_tshirt_m", "title": "Red T-Shirt", "price": 2500},
    "quantity": 2,
    "totals": [
      {"type": "subtotal", "amount": 5000},
      {"type": "items_discount", "amount": 500, "display_text": "RedCard 10% off"},
      {"type": "total", "amount": 4500}
    ]
  }],
  "totals": [
    {"type": "subtotal", "amount": 5000},
    {"type": "discount", "amount": 500},
    {"type": "fulfillment", "amount": 0, "display_text": "Free shipping"},
    {"type": "total", "amount": 4500}
  ],
  "discounts": {
    "applied": [
      {
        "title": "RedCard 10% off",
        "amount": 500,
        "automatic": true,   // <--
        "provisional": true,  // <-- to be verified
        "eligibility": "com.target.red_card", // <-- attribution
        "priority": 1,
        "allocations": [
          {"path": "$.line_items[0]", "amount": 500}
        ]
      },
      {
        "title": "Circle Member free shipping",
        "amount": 500,
        "automatic": true,    // <-- ditto
        "provisional": true,  // <-- ditto
        "eligibility": "com.target.circle_member",
        "priority": 2,
        "allocations": [
          {"path": "$.totals.fulfillment", "amount": 500}
        ]
      }
    ]
  }
}

Platform now knows exactly:

  • What was applied, in what priority order
  • Which eligibility signal triggered each benefit
  • That both are provisional (verified at purchase)
  • Where each discount was allocated

It can render: "RedCard 10% off: -$5.00 (verified at purchase) | Free shipping (Circle Member — verified at purchase)". Same display logic and flow applies in checkout too.

Checkout complete: verification

Business verifies the actual payment credential or identity / membership status against the claimed eligibility.

  • Happy path: credential confirms eligibility. Provisional flag drops. Order reflects confirmed discounts.
  • Unhappy path: credential or status doesn't match. Business returns error:
{
  "messages": [{
    "type": "error",
    "code": "invalid_eligibility_hint",
    "path": "$.discounts.applied[0]",
    "content": "Payment instrument does not qualify for Amex Gold discount. Totals updated."
  }]
}

Platform MUST present the buyer with updated totals for re-confirmation before retrying completion.


To model this we need a ~minimal extension to discount + context

context.json — one new property:

"eligibility": {
  "type": "array",
  "description": "Opaque, reverse-domain-namespaced eligibility signals. The Business applies recognized signals per its own stacking rules and ignores unrecognized values. Strings MUST NOT contain PAN, BIN, PII, or user-unique identifiers.",
  "uniqueItems": true,
  "items": {
    "type": "string",
    "maxLength": 256
  }
}

discount.json / applied_discount: two new optional properties:

"provisional": {
  "type": "boolean",
  "default": false,
  "description": "True if this discount is contingent on verification at completion. Business MUST verify eligibility from the completion payment credential, not from context signals."
},
"eligibility": {
  "type": "string",
  "description": "The context.eligibility signal that triggered this discount. Omitted for code-based and non-eligibility automatic discounts."
}

@douglasborthwick-crypto
Copy link

@igrigorik this reframe is sharp — context.eligibility available across the full lifecycle solves the "too late" problem cleanly, and provisional + eligibility on applied_discount gives the Platform the attribution it currently has to guess at.

One thing the proposal leaves open is the verification mechanism at completion. For instrument-bound eligibility (RedCard BIN check), the Business can verify locally. But for eligibility that isn't derivable from the payment credential — on-chain token holdings, DAO memberships, NFT ownership — the Business needs an external attestation.

This is where the opaque-vs-signed distinction from my earlier comment becomes concrete under your model. A Platform can call an attestation provider and pass the signed result alongside the eligibility signal:

{
  "context": {
    "eligibility": ["com.example.nft_holder"],
    "eligibility_attestations": {
      "com.example.nft_holder": {
        "code": "INSR-A7F2K",
        "expiresAt": "2026-03-06T14:30:00.000Z",
        "sig": "MEUCIQD...base64-P1363...",
        "kid": "insumer-attest-v1"
      }
    }
  }
}

That's the actual shape returned by our POST /v1/ucp/discount verification object. The sig is ECDSA P-256 over {"verified":true,"totalDiscount":10,"code":"INSR-A7F2K","merchantId":"...","expiresAt":"..."}. The Business verifies it against the JWKS at /.well-known/jwks.json using kid to select the key — no round-trip to the Platform or the chain.

The provisional flag in your model still works as-is: the Business applies the discount as provisional: true at cart, then at completion verifies the signature and drops the flag. The unhappy path becomes deterministic — a failed sig check produces invalid_eligibility_hint, not a trust dispute.

This doesn't require any new schema beyond what you've already proposed. eligibility_attestations on context would be an optional companion to eligibility — Platforms that can supply a signed backing do so, Platforms that can't still send opaque hints. The Business decides its own policy on which it accepts as provisional vs. which it requires a signature for.

@ACSchil
Copy link
Contributor Author

ACSchil commented Mar 11, 2026

Superseded by #250

@ACSchil ACSchil closed this Mar 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

TC review Ready for TC review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feat]: Allow tender-based discounts via payment instrument qualifiers in Checkout Session Update

10 participants