feat: loyalty extension for checkout capability#340
feat: loyalty extension for checkout capability#340ziwuzhou-google wants to merge 12 commits intoUniversal-Commerce-Protocol:mainfrom
Conversation
|
Great work on this, addresses a lot of the feedback we flagged in the TC review of #251. Squinting at the new shape, can we simplify further? A few observations... 1/ Do we need tracks at the negotiation/confirmation layer? At checkout/cart time, we need to confirm what's active for the buyer. Tracks describe the program's enrollment structure, which belong in benefits catalog, not in the negotiated outcome. 2/ The 3/ Balance is modeled at the wrong layer? Reward balance is a property of a payment instrument -- structurally identical to a gift card. It becomes available post-authentication and the buyer redeems it as a funding source. Loyalty can own the earning forecast (what you'll gain from this transaction); the payments layer should own the balance (what you can spend). Keeping balance on the instrument avoids dual sources of truth when redemption lands later. 4/ Here's a simplified shape that captures the above, with two loyalty programs (one confirmed and one provisional): Request: {
"context": {
"eligibility": ["com.example.loyalty", "com.other.runner"]
},
"line_items": [
{ "item": { "id": "prod_1", "quantity": 1, "title": "Jacket", "price": 5000 } },
{ "item": { "id": "prod_2", "quantity": 1, "title": "Cap", "price": 1500 } }
]
}The request side uses Response: {
"line_items": [
{
"id": "li_1",
"item": { "id": "prod_1", "quantity": 1, "title": "Jacket", "price": 5000 },
"totals": [
{ "type": "subtotal", "amount": 5000 },
{ "type": "discount", "amount": -500, "display_text": "Member 10% off" },
{ "type": "total", "amount": 4500 }
]
},
{
"id": "li_2",
"item": { "id": "prod_2", "quantity": 1, "title": "Cap", "price": 1500 },
"totals": [
{ "type": "subtotal", "amount": 1500 },
{ "type": "total", "amount": 1500 }
]
}
],
"discounts": {
"applied": [
{
"title": "Member 10% off",
"amount": 500,
"method": "each",
"provisional": false,
"eligibility": "com.example.loyalty",
"allocations": [
{ "path": "$.line_items[0]", "amount": 500 }
]
},
{
"title": "Free standard shipping",
"amount": 599,
"method": "each",
"provisional": true,
"eligibility": "com.other.runner",
"allocations": []
}
]
},
"loyalty": {
"com.example.loyalty": {
"title": "Example Rewards",
"member_id": "M12345",
"tier": {
"id": "gold",
"title": "Gold"
},
"benefits": [
{ "id": "BEN_001", "title": "Early access to sales" },
{ "id": "BEN_002", "title": "Priority support" }
],
"earning_forecast": {
"currency_code": "PTS",
"amount": 60,
"allocations": [
{ "path": "$.line_items[0]", "amount": 50, "title": "1 point per $1" },
{ "path": "$.line_items[1]", "amount": 10, "title": "1 point per $1" }
]
},
"provisional": false
},
"com.other.runner": {
"title": "Runner Program",
"tier": {
"id": "standard",
"title": "Standard"
},
"benefits": [
{ "id": "BEN_010", "title": "Free standard shipping" }
],
"provisional": true
}
},
"totals": [
{ "type": "subtotal", "display_text": "Subtotal", "amount": 6500 },
{ "type": "items_discount", "display_text": "Member 10% off", "amount": -500 },
{ "type": "shipping_discount", "display_text": "Free standard shipping", "amount": -599 },
{ "type": "total", "display_text": "Estimated Total", "amount": 5401 }
]
}Key diffs and design decisions in this shape:
|
|
Thanks a lot for the insightful comments @igrigorik. A few questions that I'd like to hear your thoughts:
|
| "description": "Benefits associated with a membership tier.", | ||
| "type": "object", | ||
| "required": ["id", "description"], | ||
| "properties": { |
There was a problem hiding this comment.
Add type (open enum with reverse-domain extensions) and optional metadata. Without a discriminator, free-shipping / %-off / SKU-entitlement / event-access are all indistinguishable strings.
There was a problem hiding this comment.
My thought is if we categorize benefits into two buckets: monetary ones and informational ones, then I don't feel a strong need to have a type here. Main reason is as just IIya mentioned above: "Monetary effects (member pricing, free shipping) are already fully represented in discounts.applied[] via eligibility. The loyalty extension doesn't duplicate those — it carries the informational benefits." If we agree on this then the informational benefits here do not really need to be distinguishable as they are only for display purpose?
| }, | ||
| "description": "List of quantifiable rewards value the user holds. Each object encapsulates one type of reward the membership offers." | ||
| }, | ||
| "provisional": { |
There was a problem hiding this comment.
This shape gives each membership exactly one provisional / eligibility pair, but the spec later shows a checkout carrying two simultaneous eligibility claims (com.example.loyalty and com.example.loyalty.credit_card) that activate different benefits. That makes the claim-to-outcome mapping lossy for agents and gives us no place to represent “claim A verified, claim B failed” independently. I’d strongly recommend reshaping this around per-claim entries (for example, a map keyed by eligibility claim or an array where each element is scoped to one claim), with verification state and resulting tracks/benefits attached to that claim
There was a problem hiding this comment.
Yes I think the key-val proposal IIya showed about works nicely in this case. Updated the definition of loyalty.json to expect a key-val map, with key being reverse_domain_name holding eligibility claims, and val being loyalty_membership holding membership information corresponds to the claim.
| "type": "string", | ||
| "description": "Business specific name of the loyalty membership/program." | ||
| }, | ||
| "member_id": { |
There was a problem hiding this comment.
Standardizing a raw member_id in the base loyalty response crosses a trust boundary that the spec does not currently define. Checkout eligibility claims are explicitly buyer claims rather than verified facts, and the broader spec treats PII / sensitive data conservatively. As written, this field can become a stable account identifier exposed to any negotiated platform/agent. I’d either remove it from the base schema, replace it with a display-safe masked identifier, or explicitly gate it behind an authenticated linkage / consent prerequisite in the normative text.
There was a problem hiding this comment.
Thanks for this insightful comment. Fully agree with the reasoning on the privacy aspect of concern. Updated schema to have masked display_id:
"display_id": {
"type": "string",
"description": "A masked or partial version of the membership id for user recognition (e.g., '****5678')."
},In the meantime, I'm thinking if we still need some unique identifier to help platform and business for validation/correlation but without really exposing the user, via something like member_id_hash?
| "activated_tracks": { | ||
| "type": "array", | ||
| "items": { "type": "string" }, | ||
| "description": "List of tracks that the customer is activated for, identified by the `id` of the Membership Track object." |
There was a problem hiding this comment.
Shouldn't this be singular object and not an array, especially if we are establishing an hierarchy for membership to tracks? If the loyalty.memberships is to highlight available membership programs provided by a merchant and tracks are flavors of that program (free, basic, premium etc), I'd assume a customer can be in only one flavor of the membership.
There was a problem hiding this comment.
@mnaga I think you may be slightly conflating tracks and tiers here.
The intent of the design is that a member can be active on multiple tracks simultaneously, but holds only one active tier per track. A good real-world example is a corporate travel program where the same member participates in both a personal status track and an employer's corporate track under the same membership, each unlocking independent benefits.
In the spirit of your suggestion however, it's likely membership_track.activated_tiers should be singular rather than an array. Particularly if a "track" is the model which implies one active tier at a time.
There was a problem hiding this comment.
I think it depends on how to interpret track and tier. To me,
- track represents an enrollment pathway or program category that a user can join independently or simultaneously
- tier represents specific achievement ranks or status milestones within a track that unlock escalating value as a member progresses through activity or spend.
Put this into real world examples:
- Target offers a loyalty program that has multiple tracks (Target Circle - free, Target 360 - pay annual fee to join, Target card - credit card), and one can be in one or more tracks simultaneously (see this comment from Maxim in our previous PR feat: Add basic schema for loyalty extension #251 (comment)). In this case, each track will just have one tier, and overall shape looks like:
"tracks": [
{
"id": "track_1",
"name": "Target Circle",
"tiers": [
{
"id": "tier_1",
"name": "Target Circle"
}
]
},
{
"id": "track_2",
"name": "Target 360",
"tiers": [
{
"id": "tier_2",
"name": "Target 360"
}
]
},
{
"id": "track_3",
"name": "Target Circle Card",
"tiers": [
{
"id": "tier_3",
"name": "Target Circle Card"
}
]
}
]- On the other hand, some other merchants have a loyalty program that offers multiple flavors (free, basic, premium) but buyer can only have one of them. In this case, I view them belonging to the single track but spans across different tiers:
"tracks": [
{
"id": "track_1",
"name": "My Program",
"tiers": [
{
"id": "tier_1",
"name": "Free"
},
{
"id": "tier_2",
"name": "Basic"
},
{
"id": "tier_3",
"name": "Premium"
}
]
}
]In short, the goal is to have some unified shape that can handle both cases, and that's why we introduced this (array) tracks -> (array) tiers hierarchy.
| "items": { "type": "string" }, | ||
| "description": "List of tracks that the customer is activated for, identified by the `id` of the Membership Track object." | ||
| }, | ||
| "rewards": { |
There was a problem hiding this comment.
If tracks are actual entity that a customer is activating, I'd think rewards earned and forecast also need to be at a track level. Especially if we are using the same structure for upsell as well.
There was a problem hiding this comment.
Good catch. Yes rewards should be at tracks level. Updated the schema.
|
|
||
| ### Loyalty benefits behavior | ||
|
|
||
| An eligible membership is sometimes only a preliminary prerequisite of a member-only benefit and a verified claiming of loyalty membership does not necessarily result in a valid claim of associated member-only benefit. For example, in the event of a $50 order, the member-only benefit "Free shipping for all orders" applies while the other member-only discount "Save $10 with $100+ purchase" does not, assuming the buyer is an eligible member. As such, business MUST additionally surface all monetary price impacting benefits as provisional discounts using the `provisional` and `eligibility` fields within the `discounts.applied` object. When membership is valid but there are benefits that are inapplicable, businesses MUST communicate this via the message[] array. Depending on the type of inapplicable benefits (e.g. they are affecting the order totals), businesses can choose the message type between warning or info to get them surfaced. |
There was a problem hiding this comment.
The spec says: "business MUST additionally surface all monetary price impacting benefits as provisional discounts using the provisional and eligibility fields within the discounts.applied object." This is a hard dependency on dev.ucp.shopping.discount, but the discovery snippet at lines 87–103 does not declare it, and the schema's $defs."dev.ucp.shopping.checkout" composition does not reference the discount extension. A Business that advertises dev.ucp.common.loyalty without advertising dev.ucp.shopping.discount produces an undefined checkout — the loyalty spec mandates fields on a structure (discounts.applied) that will not exist in the response. Capability negotiation should refuse this combination or the loyalty spec should degrade gracefully without the discount extension. A "Dependencies" section in loyalty.md stating that loyalty price-impacting benefits require the discount extension to be negotiated, with a build-time/runtime check. If discount extension is not negotiated, loyalty MUST surface benefit names via messages[] with code: "eligibility_accepted" and no pricing impact.
9a96ed9 to
f6564ab
Compare
| "activated_tracks": { | ||
| "type": "array", | ||
| "items": { "type": "string" }, | ||
| "description": "List of tracks that the customer is activated for, identified by the `id` of the Membership Track object." |
There was a problem hiding this comment.
@mnaga I think you may be slightly conflating tracks and tiers here.
The intent of the design is that a member can be active on multiple tracks simultaneously, but holds only one active tier per track. A good real-world example is a corporate travel program where the same member participates in both a personal status track and an employer's corporate track under the same membership, each unlocking independent benefits.
In the spirit of your suggestion however, it's likely membership_track.activated_tiers should be singular rather than an array. Particularly if a "track" is the model which implies one active tier at a time.
0c51546 to
a94e7e5
Compare
91cc6be to
94cc1e4
Compare
igrigorik
left a comment
There was a problem hiding this comment.
@ziwuzhou-google ty, we're heading in the right direction. First pass & set of comments and questions, ptal below.
nit: can you please reformat to match line length / wrap conventions.
|
|
||
| ## Eligibility Claims | ||
|
|
||
| Given almost everything related to loyalty is provisional and requires eligible membership before benefits can be applied, [Context](checkout.md#context) naturally fits here where it allows buyers to claim eligibility for loyalty benefits and businesses to verify and communicate the result with the associated effects (financial-wise and rewards-wise). When loyalty extension is active and the request contains eligibility claims about loyalty, businesses that choose to accept eligibility claims MUST surface that as an indicator of buyer’s membership status, and if applicable, effects on pricing coming from monetary benefits. Platforms MUST display those provisional loyalty discounts to the buyer. Specifically, the loyalty extension is an object keyed by reverse_domain_name — same convention as services, capabilities, and payment_handlers in the business profile. The key is the eligibility claim, and value is the membership object that contains provisional to indicate the verification result. |
There was a problem hiding this comment.
This prose reads like a design doc, not a declarative spec. I think you can drop first sentence entirely and lead with "When extension is active, ... "
When loyalty extension is active and the request contains eligibility claims about loyalty, businesses that choose to accept eligibility claims MUST surface that as an indicator of buyer’s membership status, and if applicable, effects on pricing coming from monetary benefits.
There is an implicit dependency here on auth. This can't be a must in anon/guest user flow. Platforms can display provisional benefits, but we need to have a clear two track separation between: provisional claims vs verified claims.
There was a problem hiding this comment.
Thanks for the suggestion. I think the original wording is a bit confusing and vague. Updated to the following:
When loyalty extension is active and the request contains eligibility claims about loyalty, businesses that recognize those eligibility claims MUST return them as the keys of the loyalty extension, which is an object keyed by the reverse-domain identifier — same convention as services, capabilities, and payment handlers in the business profile. The values of the returned object contain detailed membership info corresponding to the claims, and specifically contains a
provisionalfield to indicate the status of the claim.
If monetary price impacting loyalty benefits (e.g. member pricing/shipping) are available and discount extension is supported by both business and platform, businesses MUST set the same
provisionalvalue in each ofappliedobejct within discount extension as in the loyalty extension. Platform can then follow the same rendering pattern for discount extension to surface these loyalty benefits to buyers.
I've also updated the example in this section to explain what the word means. In the meantime, I want to make sure I fully understand your second part of the comment:
There is an implicit dependency here on auth.
If the loyalty benefits truly needs to be applied (e.g. becomes a non-provisional benefit during the complete-checkout stage), then yes business need to verify the claim (if there is any) by knowing who the buyer is and that is done through account-linking/direct user sign-in from platform side/tokenized identity sharing etc (basically the auth part).
This can't be a must in anon/guest user flow. Platforms can display provisional benefits
If I understand correctly, before the auth mentioned above happens, the user is anon/guest to the business. In this case, platform can still display the loyalty benefits (and it is up to platform to render disclaimers on the front-end to remind buyer that some benefits needs further verification).
but we need to have a clear two track separation between: provisional claims vs verified claims.
Yes, I think our current model works. The provisional field under the loyalty and discount extensions will help to distinguish these two cases.
|
|
||
| ### Loyalty behavior | ||
|
|
||
| Platforms MUST send buyer loyalty membership claims via `context.eligibility` to activate loyalty extension and claim for loyalty benefits. The key of the returned object within loyalty extension represents that buyer's claim to the loyalty program. If a business successfully verifies this claim, the business MUST update the `provisional` boolean to false and populate the `activated_tier` fields alongside the to reflect the buyer's verified status for that specific claim. |
There was a problem hiding this comment.
Is eligibility a must? I would expect that an authenticated user could/would see loyalty info without having to explicitly claim anything. The business lookups identity and associated loyalty benefits, and echoes it.
context.eligibility is another path in, required when you're not authed and optional when you are -- e.g. you're claiming that you'll use a special credit card that gives you free shipping benefits.
There was a problem hiding this comment.
If there is no explicit claims in the request (but business already knows who the buyer is via auth), I agree business can directly associate benefits back in the response when the user is truly a member. However, what will be the key (holds the reverse domain typed claim) there for the loyalty extension? I would think provisional should be set as false in this case but should we expect business to directly fill in the corresponding claim value that matches to loyalty automatically?
There was a problem hiding this comment.
As written, this MUST means a business cannot populate the loyalty extension for an authenticated user unless the platform also sends context.eligibility -- which creates a redundant round-trip when the business already knows who the buyer is.
I'd propose something like: "Platforms MUST send buyer loyalty membership claims via context.eligibility to claim loyalty benefits. Alternatively, when the buyer is authenticated and the business can determine loyalty membership from the authenticated identity, businesses MAY populate the loyalty extension without an explicit eligibility claim. In this case, the map key MUST be the same reverse-domain identifier the business would accept as a claim value, and provisional MUST be false."
This is a real flow for Target -- a signed-in Target Circle member should see their benefits immediately without needing the agent to separately claim com.target.loyalty.
There was a problem hiding this comment.
After some more thinking and reading through the original PR for context, I agree that the current writing is not fully accurate. As pointed by both of you, business should be able to activate the loyalty extension even if there is no context.eligibility in the request, as long as they are able to verify the buyer. After that, business should be able to populate the key of the loyalty extension that holds the reverse domain name the user is activated for, as they are also the ones advertising the claim strings.
Updated the section to be more clear.
There was a problem hiding this comment.
+1 let's make it clear that auth as well as auth+claims both can be the relevant input signals for the business to return loyalty benefits.
| "breakdown": [ | ||
| { "source": "$.tiers[0].benefits[1]", "amount": 10 }, | ||
| { "source": "$.tiers[0].benefits[2]", "amount": 20 } | ||
| ] |
There was a problem hiding this comment.
UCP JSONPaths are root-relative everywhere ($.line_items[0], $.totals[*] in discount.allocations). These are scoped to the parent track.
earning_breakdown lost id and description from earlier version? Per-rule rationale ("2x on footwear = 20 pts") now lives only in the benefit's freetext description, reachable via dereference. An agent rendering the forecast must (a) resolve the path, (b) read freetext, (c) parse heuristically. The agent rendering layer just got harder for the most marketing-critical loyalty signal.
Should we restore description on earning_breakdown so businesses can provide rendered-friendly per-rule text without forcing dereference.
There was a problem hiding this comment.
Yeah I was debating when I worked on this part as well: with the use of source the schema looks a bit more compact, but in the meantime forcing a rendering agent to resolve a JSONPath and then "scrape" or heuristically parse a description just to show a line-item explanation is a recipe for brittle UIs. Now that you share the same concern, I'm convinced that we should just use a simple description. In the meantime, I will still keep the source (but no longer required) as a fallback just for "Read More" links or deeper audit trails. Also added the id field back for easy rule traceability.
| {"type": "total", "display_text": "Estimated Total", "amount": 1000} | ||
| ] | ||
| } | ||
| ``` |
There was a problem hiding this comment.
Missing sections..
- security considerations
- privacy considerations
- error codes (we define a few in prose)
There was a problem hiding this comment.
Updated the error codes based on another comment.
Based on my understanding, unlike capability, this extension does not have operations and only defines the data shape. I don't think security applies?
For privacy, can you share which aspects are you looking for? The only part that I can think of for loyalty is the user membership id that we defined in the schema. If not handled properly, this could be used as an unique identifier by the platform and creates some privacy concerns. Therefore it was updated to be a masked id.
There was a problem hiding this comment.
I would add a short section before the examples end stating that loyalty responses MUST be data-minimized, MUST NOT expose raw stable member identifiers, MUST only include display_id after verified/authenticated membership, and MUST treat all context.eligibility values as buyer claims rather than proof.
There was a problem hiding this comment.
Added a Implementation guidelines section to include all these suggestions.
| @@ -0,0 +1,20 @@ | |||
| { | |||
| "$schema": "https://json-schema.org/draft/2020-12/schema", | |||
| "$id": "https://ucp.dev/schemas/common/types/membership_balance.json", | |||
There was a problem hiding this comment.
Meta: we broke everything into discrete files, but pretty much everything here is self-contained to one domain and not reused anywhere else. Can we collapse these types into $defs under unified schema?
@igrigorik @maximenajim thank you for your thorough and insightful comments. I've updated the PR based on those, and left some questions as well. PTAL. Regarding the format, it's a bit weird that I do have the |
|
Summarizing some of the principles we have discussed and aligned on in the TC meetings here:
-cc @igrigorik @ziwuzhou-google @raginpirate @maximenajim @mnaga fyi |
Thank you @sinhanurag for the summary! Updated the PR to remove all balanced related concepts in the schema/documentation. |
|
|
||
| ## Discovery | ||
|
|
||
| Businesses can follow standard advertisement mechanism to advertise loyalty support in the Business profile. Currently loyalty extension can ONLY decorate checkout capability and thus the profile should contain both. |
There was a problem hiding this comment.
Is there a reason why we are decorating only checkout capability and not cart and catalog capabilities as well? Surfacing price and shipping benefits early on during the journey would impact customers decisions
There was a problem hiding this comment.
Based on my understanding of the past few TC deep dive, we want to laser focused on checkout capability for this PR for now. Extending it to cart/catalog capabilities in the upper funnel discovery/upsell should be a follow-up that is not difficult (I agree that is extremely helpful and that is why we want to keep the shape extensible enough). I can raise another PR immediately after this one to discuss how it can be extended to support cart/catalog.
There was a problem hiding this comment.
+1 @mnaga as per our conversation I sent you some examples offline of how the structure will look like.
a7c34e0 to
e218c57
Compare
d1bfd8c to
f002ecc
Compare
| } | ||
| ], | ||
| "activated_tier": "tier_1", | ||
| "rewards": [ |
There was a problem hiding this comment.
Is the intent of rewards to show potential forecast for all potential tiers? If so, array makes sense but would require a source field to map to the right tier.
If the intent is to provide forecast for activated tier only, dont see a reason for rewards to be an array.
There was a problem hiding this comment.
The reason is because one tier can have multiple types of reward. For example in travel vertical (United Airline for example), you can earn miles + United Mileage Plus point if you are their highest tier member. These two should be casted as two types of rewards, each with it's own forecasted earning amount.
raginpirate
left a comment
There was a problem hiding this comment.
I appreciate that payments was generally pushed out of scope here; some nits I spotted around unifying with base UCP primitives such as currency and amount 😄
| "name": "dev.ucp.common.loyalty", | ||
| "title": "Loyalty Extension", | ||
| "description": "Extends various Capabilities with loyalty support using memberships info.", | ||
| "$defs": { |
There was a problem hiding this comment.
nit: this PR ships a bunch of structures into one big json blob; shouldn't those be separated out?
There was a problem hiding this comment.
These defs were separated into sub-files (under common/types). IIya suggested a concentration within this file. See #340 (comment). I do agree that these structures are pretty loyalty-specific for now. We can separate them out when we continue to expand the supported functionalities for loyalty and find more generalization. WDYT?
| } | ||
| } | ||
| }, | ||
| "reward_currency": { |
There was a problem hiding this comment.
is there a missing primitive here to unify this with base currency descriptions across UCP? I feel like checkout and loyalty should be sharing the same conceptual way to advertise a currency.
There was a problem hiding this comment.
I'm not 100% sure actually. Right now the currency in the rest of UCP is fiat currency using ISO 4217 code while for loyalty it is a customized version. Fiat currency does not need explicit name and decimal places as both can be deduced from the currency code, while for loyalty currency we do have to have them explicitly.
I did some research with the help of LLM and the industry splits this problem into three core architectural patterns when modeling standard fiat vs. customized (loyalty) currencies:
- The Decoupled Ledger Model (Salesforce): For Standard Commerce, it uses
dw.util.CurrencyandCurrencyTypewhich is strictly tied to ISO 4217, and API only exposingIsoCodeandDecimalPlacesas read only; For Loyalty, it usesLoyaltyProgramCurrencywhich has a similar definition as we have - The "Everything is Fiat" Model (Shopify): It relies on
CurrencyCodeEnum &MoneyV2Object, and its standard pricing fields only accept aDecimalamount and a strictCurrencyCodeenum (which strictly lists ISO 4217 codes). And because Shopify's native checkout cannot represent points, loyalty platforms (like Okendo, Bold, or Yotpo) must run entirely as external databases. When a user checks out, the loyalty engine must translate the custom currency into standard fiat and get injected into the checkout as standard fiat money - The Extended Registry Model (Oracle CX Commerce & Stripe Apps): These systems allow custom currencies to live alongside standard ones by using a registry where custom currencies supply their own formatting rules. For example Oracle CX Commerce allows merchants to define a custom loyalty currency directly within the core currency API (/ccadmin/v1/currencies)
If we want to introduce a unified currency modeling (and non-breaking backward compatible change), maybe we can do something like this:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://ucp.dev/schemas/common/types/currency.json",
"title": "Currency",
"description": "Unified representation of standard (fiat) or custom (loyalty) currency.",
"type": "object",
"required": ["code"],
"properties": {
"code": {
"type": "string",
"description": "ISO 4217 code (e.g. 'USD') or custom loyalty code (e.g. 'LST')."
},
"name": {
"type": "string",
"description": "Human-readable name of the custom currency (e.g. 'LoyaltyStars')."
},
"decimal_places": {
"type": "integer",
"description": "The position of a digit to the right of a decimal point for custom currency amounts."
}
},
"$defs": {
"fiat": {
"allOf": [
{ "$ref": "#" }
],
"properties": {
"code": {
"pattern": "^[A-Z]{3}$"
}
},
"not": {
"anyOf": [
{"required": ["name"]},
{"required": ["decimal_places"]}
]
},
"description": "For standard fiat currencies, 'name' and 'decimal_places' must NOT be set. Decimals must be resolved via ISO 4217 registry."
},
"custom": {
"allOf": [
{ "$ref": "#" }
],
"properties": {
"decimal_places": {
"type": "integer",
"default": 0,
"description": "Custom currencies default to 0 decimal places (integers) if not specified."
}
},
"required": ["name"],
"description": "For custom loyalty/rewards currencies, 'name' is strictly required. If 'decimal_places' is omitted, it defaults to 0."
}
}
}Now in the current UCP schema where currency is defined (price.json/cart.json/order.json etc), the currency can be replaced with the following for backward compatibility:
"currency": {
"oneOf": [
{
"type": "string",
"description": "Simple string representation (e.g. 'USD') using ISO 4217 currency code."
},
{
"$ref": "../../common/types/currency.json#/$defs/fiat",
"description": "Structured fiat representation."
}
]
}And the the reward_currency in this PR simplifies down to pointing directly at the custom definition:
"reward_currency": {
"$ref": "types/currency.json#/$defs/custom",
"description": "The custom loyalty reward currency."
}Let me know how this looks to you.
igrigorik
left a comment
There was a problem hiding this comment.
We're getting close, thanks for pushing this along! Pushed a few cosmetic fixes, but as I come back to this with fresh eyes, a few flags and questions...
A: Loyalty should work across catalog, cart, checkout, and post-order
Right now schema binds only dev.ucp.shopping.checkout. Loyalty spans the entire lifecycle: cart needs it (member pricing on preview), orders need it (rewards earned, member purchase history), catalog needs it (member-only items, member PDP pricing). Assuming we stay with current data model of exteding context and emitting a new top-level key, the spec work to make this work is small. I would strongly prefer we land the whole story, not piecemeal.
B: unnecessary dependency on discount extension
Monetary loyalty benefits in current prose and schemas assume that discount extension is negotiated. I have no objections to loyalty layering discount schemas (in fact, it's the right thing to do), but I would prefer that we remove this as a strict dependency. Even if the business does not advertise and support discount extensions, loyalty should be able to function on its own. We should invert the dependency, roughly...
Price-impacting loyalty benefits MUST surface via the base capability's
totals/line_items[].totals(always present on cart + checkout) —type: "items_discount"withdisplay_textattributing the loyalty source. When discount extension is also active, business MAY additionally populatediscounts.applied[]witheligibilityclaim for richer attribution; theprovisionalmirroring rule narrows to a conditional ("when discount is also active"), not a soft-required.
C: loyalty is a response-only field on catalog/cart/checkout/order?
Right now ucp_request is on the wrong layer AND, I believe, has the wrong values.
For $defs.dev.ucp.shopping.checkout definition, we currently have {create: optional, update: optional, complete: omit}. This permits the platform to send a full loyalty_membership payload on create/update requests — but the platform is not authoritative for this data. Claims travel via context.eligibility (already in the base capability); loyalty should be a response only field based on business's resolution of the claim / user identity.
Do we agree this is the right contract? At a later point we'll need to discuss how to model loyalty sign-up / purchase, but that's a whole separate conversation, we should not create/allow this data as input on current surface, and I don't think this is the right long-term surface to negotiate sign-up / purchase of loyalty programs.
D: Response shape violates UCP's advertise / negotiate / confirm convention
We've done a few turns on this with @sinhanurag and — apologies upfront for the turbulence — I think 'one data model' lands us in a client-suboptimal and spec inconsistent state. Hear me out... 🤓
The loyalty response in the checkout/cart hot path returns a full tracks[].tiers[] enumeration plus an activated_tier pointer back into the array — when the buyer's tier is a determined fact, not a selectable option. This conflates a discovery/lookup shape with a runtime confirmation shape, and pays for the mistake on every catalog/cart/checkout/order call.
Current shape:
"loyalty": {
"com.example.loyalty": {
"id": "membership_1",
"name": "My Loyalty Program",
"tracks": [
{
"id": "track_1",
"name": "track_name_1",
"tiers": [ ← full tier enumeration
{ "id": "tier_1", "name": "Gold", "benefits": [...] }
],
"activated_tier": "tier_1" ← pointer back into tiers[]
}
],
"provisional": false
}
}The platform must compute the answer with tiers.find(t => t.id === activated_tier) to render anything. The unselected-tier slots in tiers[] carry no transaction-relevant payload and semantically do not make sense: why are we listing and allowing presence of tracks and tiers that buyer cannot affect?
If we squint, the convention we have in UCP right now has three interaction states:
| State | When | Audience | Shape |
|---|---|---|---|
| Advertise | Discovery, pre-transaction | Anyone discovering the business/platform | Full enumeration of what's possible |
| Negotiate | Mid-transaction, while a decision is pending | Buyer/agent making a choice | Menu of remaining options + pointer to current selection (if any) |
| Confirm | Per-transaction response, after determination | Platform rendering authoritative state | Only the resolved/active row(s) |
Payment handlers is the canonical advertise vs. confirm split.payment_handler.json defines three schema variants via business_schema, platform_schema, and distinct response_schema. The handler advertises available_instruments[] (what's possible). The runtime response carries instruments[] with selected: true markers (what was actually picked), because its a negotiable artifact. Two different fields, two different shapes, two different lifecycles.
Fulfillment is the canonical negotiate shape. fulfillment_method.json and fulfillment_group.json use the verbose-with-pointer pattern that loyalty currently mimics:
"groups": [{
"options": [ ← full menu
{ "id": "opt_std", "title": "Standard $5", ... },
{ "id": "opt_exp", "title": "Express $15", ... }
],
"selected_option_id": "opt_std" ← pointer
}]But the pointer-based menu is here for a reason: shipping selection is interactive. The buyer/agent must choose between Standard, Express, and Free. The response carries the menu because the decision is pending — subsequent update calls echo it back with selected_option_id populated as the buyer picks. Same holds for destinations: a buyer with multiple saved addresses sees destinations[] + selected_destination_id — a real menu, a real pick.
Fulfillment's verbose shape is the negotiate state: appropriate when alternatives are a buyer/agent choice.
Loyalty doesn't fit 'negotiate' pattern
Loyalty has no selection step. A buyer doesn't pick "I want to be Gold today": their tier is determined by the business. There is no menu, no decision, no selected_*_id to populate over negotiation lifecycle. The platform/buyer can make a different claim or provide their identity, but output of loyalty is the computed confirmation emitted by the business. Loyalty is purely Confirm, and the shape should reflect that unambiguously.
We landed on the current shape to preserve symmetry: the same tracks[].tiers[] enumeration we return at checkout could one day appear at a "list all tiers" lookup endpoint, and integrators would learn one model. The symmetry is appealing, but with fresh eyes, three problems:
- It contradicts the payment handler precedent. Payment handlers explicitly use different shapes for advertise (
business_schema) and confirm (response_schema), with normative MUSTs about which is authoritative when. Loyalty inheriting one shape across discovery and runtime is the opposite of what we already do elsewhere. - A response without a selection event has nothing to negotiate. When the buyer has no decision to make, returning a menu is misleading: unselected entries have undefined semantics (already-achieved? eligible-but-not-claimed? listed by accident?), and the wire shape encodes a question that was never asked. The right shape for "this is determined" is the determined value, not a menu pointing at it.
- Implementation cost is real and recurring. These responses land on every catalog/cart/checkout/order call. Every integrator consuming loyalty has to write pointer-resolution code (
tiers.find(t => t.id === activated_tier)) to extract the active row from a shape pretending to be a selection — no menu to pick from, noselected_*_idto populate over a lifecycle. The mental cost of "this looks like a selection game but isn't" compounds across every platform — paid for a value the business has already computed, for a reason that doesn't apply.
What it could / should look like...
"loyalty": {
"com.example.loyalty": {
"id": "membership_1",
"display_id": "****5678",
"name": "My Loyalty Program",
"tracks": [
{
"id": "earned",
"name": "Earned",
"tier": { ← singular: the determined tier
"id": "gold",
"name": "Gold",
"benefits": [
{ "id": "BEN_001", "description": "Early access to sales" }
]
},
"rewards": [...]
}
],
"provisional": false
}
}Changes vs current shape:
tracks[].tiers[](array, plural) →tracks[].tier(object, singular): the track entry's existence implies activation; the tier object on the track is the active tier. No pointer indirection.- Drop
tracks[].activated_tierfield: unnecessary with the singulartierobject above it. tracks[]plurality: open question, see below.- Inactive tracks (expired, eligible-but-not-claimed) are implicitly absent from
tracks[]and no "active flag" needed.
Question: is tracks[] plurality appropriate in this response?
The doc justifies plural tracks with: "Allows for parallel participation, enabling a customer to belong to multiple tracks at once and aggregate their respective benefits (e.g., simultaneously holding 'Premium' status in a delivery track and 'Gold' status in a spend track)." What's a real-world example of this? Most programs I'm aware of are single-track multi-tier; where multi-track-like behavior exists, it tends to be modeled as separate memberships rather than tracks-within-a-membership.
Could/should those be modeled as separate top-level eligibility keys (com.example.loyalty.delivery, com.example.loyalty.spend), piggybacking on the map object we already have?
- A: singular:
track {}per membership; multi-track scenarios use multipleloyalty.<eligibility>entries — piggybacks on the map plurality we already have. - B: current:
tracks[]plural per membership; one membership entry contains multiple track objects.
6204e46 to
c8d053f
Compare
Summary
A new loyalty extension is proposed to address a critical trust and transparency milestone: ensuring existing loyalty members can seamlessly access their benefits during an agentic checkout experience. By enabling buyers to see their specific tier and eligible rewards before finalizing a purchase, we address a foundational expectation for program members and remove friction from the checkout funnel.
Motivation
Loyalty is a core concept in Commerce, serving as a primary driver for customer retention and long-term business growth. The value of a program is usually realized at the point of sale. UCP in its current format can provide some support for loyalty, mostly in the checkout capability, in the format of auto-applied monetary member-only benefits such as member price discount and/or cheaper and faster fulfillment. However, loyalty is far more than immediate-value monetary benefits. Fungible delayed-value rewards (e.g. points, miles, cashbacks), for example, is another key motivator, and in certain verticals the single most important motivator, in transactional activity. Buyers expect full visibility into their member-exclusive perks before committing to a transaction. Providing this information via an agent-to-business inquiry builds user trust and ensures that the convenience of an AI-driven checkout does not come at the expense of earned member benefits.
Proposal Scope
Different than PR #251 that aims to provide an all-inclusive schema and support a wide range of use cases, this PR focuses specifically on a baseline version, although still general enough to allow future expansion, that can be used to decorate checkout capability to provide extra loyalty information that enhances the experience. More advanced use cases such as loyalty relationship management (e.g. sign-up, tier upgrade/downgrade) and loyalty rewards transfer/redemption are out of the scope of this proposal.
Specifically the following core use cases of benefit recognition for known members are addressed with the proposal:
Design Details
Core concepts and hierarchy
Four core concepts are introduced in the schema and structured in a hierarchy:
{ "tracks": [ { "tiers": [ { "benefits": [ { ... } ] } ], "rewards": [ { ... } ] } ] }This hierarchy mirrors the reality of modern commerce, where loyalty is no longer a single ladder but a collection of parallel journeys. By separating the enrollment Track from the achievement Tier, the schema allows a user to hold multiple statuses simultaneously—like being both a credit card holder and a paid subscriber—without creating data "collisions" or redundant, "hard-coded" combined states. This structure ensures the protocol remains normalized and scalable, capable of effortlessly aggregating overlapping benefits across any number of diverse loyalty paths.
Concretely, if the loyalty program uses traditional Earned Loyalty Model (single-track ladder-like system), the hierarchy would normally have one track only with multiple tiers defined within; Conversely, if the loyalty program uses Hybrid Loyalty Model (different "entry points" based on the customer's preference, some with the option to have multiple), the hierarchy would then have more than one track, and each track normally has just one tier.
For a simplest loyalty program/membership (one track, one tier, one benefit), the shape would look like
{ "id": "membership_1", "name": "My Loyalty Program", "tracks": [ { "id": "track_1", "name": "track_name_1", "tiers": [ { "id": "tier_1", "name": "GOLD" "benefits": [ { "id": "BEN_001", "description": "Complimentary standard shipping on all orders" } ] } ], "rewards": [ { "currency": { "name": "LoyaltyStars", "code": "LST" }, "balance": { "available": 1000 } } ] } ] }Eligibility claims
Given almost everything related to loyalty is provisional and requires eligible membership before benefits can be applied, the eligibility/provisional concept introduced in PR #250 naturally fits here where it allows buyers to claim eligibility for loyalty benefits and businesses to verify and communicate the result with the associated effects (financial-wise and rewards-wise). Specifically, the loyalty extension is an object keyed by reverse_domain_name — same convention as services, capabilities, and payment_handlers in the business profile. The key is the eligibility claim, and value is the membership object that contains
provisionalto indicate the verification result.Example use cases
With the help of the design above, the checkout capability can be further decorated to provide full visibility into buyers’ member-exclusive perks and allows the platform to render the extra information to facilitate the transaction.
Price-Impacting Benefits
Alongside the discount extension, loyalty extension can provide buyer status info to allow the platform to transparently assert that correct and comprehensive member discounts are applied. In the example below, platform not only can explain the source of discounts via
discounts.applied.titlewithin discount extension, but also assure buyers that these member specific discounts are recognized because of their verified loyalty status viamemberships.tracks.tiers.namewithin loyalty extension (e.g. “My Loyalty Program Gold and Benefit Visa Card benefit applied.”)=== "Request"
{ "context": { "eligibility": ["com.example.loyalty", "com.example.loyalty.credit_card"] }, "line_items": [ { "item": { "id": "prod_1", "quantity": 1, "title": "T-Shirt", "price": 1000 } } ] }=== "Response"
{ "line_items": [ { "id": "li_1", "item": { "id": "prod_1", "quantity": 1, "title": "T-Shirt", "price": 1000 }, "totals": [ {"type": "subtotal", "amount": 1000}, {"type": "loyalty_gold_discount", "amount": -30}, {"type": "loyalty_credit_card_discount", "amount": -50}, {"type": "total", "amount": 920} ] } ], "discounts": { "applied": [ { "title": "Loyalty member benefit", "amount": 30, "method": "each", "provisional": false, "eligibility": "com.example.loyalty", "allocations": [ {"path": "$.line_items[0]", "amount": 30} ] }, { "title": "Credit Card Members save 5%", "amount": 50, "method": "each", "provisional": false, "eligibility": "com.example.loyalty.credit_card", "allocations": [ {"path": "$.line_items[0]", "amount": 50} ] } ] }, "loyalty": { "com.example.loyalty": { "id": "membership_1", "display_id": "****5678", "name": "My Loyalty Program", "tracks": [ { "id": "track_1", "name": "track_name_1", "tiers": [ { "id": "tier_1", "name": "Gold", "benefits": [ { "id": "BEN_001", "description": "Early access to sales" }, ] } ], "activated_tier": "tier_1", } ], "provisional": false }, "com.example.loyalty.credit_card": { "id": "membership_2", "display_id": "****1234", "name": "Program Visa Card", "tracks": [ { "id": "track_2", "name": "track_name_2", "tiers": [ { "id": "tier_2", "name": "Visa Card", "benefits": [ { "id": "BEN_001", "description": "Same day delivery" }, ] } ], "activated_tier": "tier_2", } ], "provisional": false } }, "totals": [ {"type": "subtotal", "display_text": "Subtotal", "amount": 1000}, {"type": "items_discount", "display_text": "Loyalty member benefit", "amount": -30}, {"type": "items_discount", "display_text": "Credit Card Members save 5%", "amount": -50}, {"type": "total", "display_text": "Estimated Total", "amount": 920} ] }Reward Earnings Forecast
In addition to immediate-value benefits like member pricing/shipping, delayed-value collectable reward benefits are another crucial element within the loyalty ecosystem. Displaying earnings forecasts of these rewards before the buyer commits complements and to some extent helps agents handle price objections - rewards earning becomes additional value on top of any pricing discount. In this example, businesses provide the reward earning forecast with a breakdown, giving platforms to explain with full transparency on why the buyer is earning and how the earning is calculated.
=== "Request"
{ "context": { "eligibility": ["com.example.loyalty"] }, "line_items": [ { "item": { "id": "prod_1", "quantity": 1, "title": "T-Shirt", "price": 1000 } } ] }=== "Response"
{ "line_items": [ { "id": "li_1", "item": { "id": "prod_1", "quantity": 1, "title": "T-Shirt", "price": 1000 }, "totals": [ {"type": "subtotal", "amount": 1000}, {"type": "total", "amount": 1000} ] } ], "loyalty": { "com.example.loyalty": { "id": "membership_1", "display_id": "****5678", "name": "My Loyalty Program", "tracks": [ { "id": "track_1", "name": "track_name_1", "tiers": [ { "id": "tier_1", "name": "Gold", "benefits": [ { "id": "BEN_001", "description": "Early access to sales" }, { "id": "BEN_002", "description": "1 point/dollar on everything" }, { "id": "BEN_002", "description": "2 extra point/dollar on footwear" }, ] } ], "activated_tier": "tier_1", "rewards": [ { "currency": { "name": "LoyaltyStars", "code": "LST" }, "earning_forecast": { "amount": 30, "breakdown": [ { "source": "$.tiers[0].benefits[1]", "amount": 10 }, { "source": "$.tiers[0].benefits[2]", "amount": 20 } ] } } ] } ], "provisional": false } }, "totals": [ {"type": "subtotal", "display_text": "Subtotal", "amount": 1000}, {"type": "total", "display_text": "Estimated Total", "amount": 1000} ] }Type of change
Please delete options that are not relevant.
functionality to not work as expected, including removal of schema files
or fields)
Checklist