feat: loyalty extension for checkout capability#340
feat: loyalty extension for checkout capability#340ziwuzhou-google wants to merge 2 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
|
|
||
| Specifically the following core use cases of benefit recognition for known members are addressed: | ||
|
|
||
| * Price-Impacting Benefits: Real-time application of member-only discounts and free shipping offers with clear assertion of benefit sources |
There was a problem hiding this comment.
Nit: "Attribution" rather than "assertion"?
| In the future, more advanced use cases such as loyalty relationship management (e.g. sign-up, tier upgrade/downgrade) and loyalty rewards transfer/redemption can be added on top. | ||
|
|
There was a problem hiding this comment.
Nit: Consider cutting.
The sentence doesn't help an implementer today, and "can be added on top" carries no commitment. When additional functionality lands we can interleave it in 🙂.
| "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.
| }, | ||
| "description": "List of tracks associated with the loyalty membership." | ||
| }, | ||
| "activated_tracks": { |
There was a problem hiding this comment.
I assume activated_tracks is intended to be a convenience field to avoid traversing the full membership tree to determine which tracks are active via membership_track.activated_tiers?
It does create a two-sources-of-truth problem: if activated_tracks and the activated_tiers within each track disagree, the response is ambiguous. I think per business membership cardinality will be low in practice. A member is unlikely to hold more than two or three active tracks, so the traversal cost the field is trying to save is negligible.
Would recommend dropping activated_tracks and relying on the presence of activated_tiers within each track as the sole signal. You can always add it back later.
…d some minor tweaks.
0c51546 to
a94e7e5
Compare
|
|
||
| ### Loyalty behavior | ||
|
|
||
| Platforms MUST send buyer loyalty membership claims via `context.eligibility` to activate loyalty extension and claim for loyalty benefits. Businesses MAY run required verification for the membership claim and communicate back the result via `provisional` and `eligibility` fields under membership. The `eligibility` sitting under the membership holds the claim for the general loyalty membership. In case businesses run the verification and the claim is valid, businesses MUST additionally populate `activated_tracks` and `activated_tiers` on top of `provisional` and `eligibility` fields to communicate the buyer membership status. When verification runs but fails, businesses MUST communicate the failure via a recoverable error message that platforms can choose to remove the claim on the membership entirely and proceed with no member benefits applied. |
There was a problem hiding this comment.
Some suggestions to improve this core explanation of loyalty behavior:
- The phrasing in this section relies on some colloquialisms ("sitting under", "on top of") that make the schema requirements a bit ambiguous. It also misses the opportunity to explicitly state the required state transition for the provisional boolean upon successful verification.
The
eligibilityfield within themembershipobject represents the buyer's claim to the loyalty program. If a business successfully verifies this claim, the business MUST update theprovisionalboolean tofalseand populate theactivated_tracksandactivated_tiersfields alongside theeligibilityfield to reflect the buyer's verified status.
- Requirements for
{ Verified, Succeeds },{ Verified, Failed }are described but not{ Unverified }(deferred). For complete coverage of the three states this paragraph introduces it may be beneficial.
If a business chooses not to synchronously verify the membership claim, the membership object MUST retain
provisional: true, and the business MUST NOT populateactivated_tracksoractivated_tiers. Any applicable price-impacting benefits MUST be surfaced in thediscountsobject withprovisional: true.
- Split last sentence so the business requirement is separate from the platform behavior.
If verification fails, businesses MUST communicate the failure via a recoverable error. Platforms MAY then choose to remove the membership claim and proceed the checkout without benefits applied.
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
Five core concepts are introduced in the schema and structured in a hierarchy:
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
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).
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": { "memberships": [ { "id": "membership_1", "member_id": "member_id_1", "name": "My Loyalty Program", "tracks": [ { "id": "track_1", "name": "track_name_1", "tiers": [ { "id": "tier_1", "name": "Gold", "benefits": [ { "id": "benefit_1", "description": "Member price on eligibility products" } ] } ], "activated_tiers": ["tier_1"] }, { "id": "track_2", "name": "track_name_2", "tiers": [ { "id": "tier_2", "name": "Benefit Visa Card", "benefits": [ { "id": "benefit_2", "description": "Visa Card holders save 5%" } ] } ], "activated_tiers": ["tier_2"] } ], "activated_tracks": ["track_1", "track_2"], "provisional": false, "eligibility": "com.example.loyalty" } ] }, "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": { "memberships": [ { "id": "membership_1", "member_id": "member_id_1", "name": "My Loyalty Program", "tracks": [ { "id": "track_1", "name": "track_name_1", "tiers": [ { "id": "tier_1", "name": "Gold" } ], "activated_tiers": ["tier_1"] } ], "activated_tracks": ["track_1"], "rewards": [ { "currency": { "name": "LoyaltyStars", "code": "LST" }, "balance": { "available": 1000 }, "earning_forecast": { "amount": 10, "projected_balance": 1010, "breakdown": [ { "id": "breakdown_rule_1", "amount": 10, "description": "1 point for every dollar spent" } ] } } ], "provisional": false, "eligibility": "com.example.loyalty" } ] }, "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