feat: Add basic schema for loyalty extension#251
feat: Add basic schema for loyalty extension#251ziwuzhou-google wants to merge 5 commits intoUniversal-Commerce-Protocol:mainfrom
Conversation
|
Great start on this extension!
The Problem: "loyalty": {
"memberships": [
{
"activated_tiers": ["T_1"],
"rewards": [
{ "redemption": { "amount": 100 } }
]
}
]
}The business server has no stable identifier to know which membership ID |
Both are correct. These identifiers should not have |
253aca7 to
d378fb7
Compare
sinhanurag
left a comment
There was a problem hiding this comment.
Good start based on all our discussions! Left some comments. Let's iterate on it more and discuss further.
docs/specification/loyalty.md
Outdated
| { | ||
| "id": "BEN_001", | ||
| "title": "Free shipping", | ||
| "description": "Complimentary standard shipping on all orders" |
There was a problem hiding this comment.
We will need some examples of cart, checkout, order objects (starting with checkout is fine) which have this extension as well as the discount object clearly displaying the benefit being applied and the discount object enumerating the benefit.
Like in this case "BEN_001" will apply to discounts.applied.allocations (path = fulfillment-options.total , amount = xx) etc.
Ref: https://ucp.dev/latest/specification/discount/#discounts-object
There was a problem hiding this comment.
Added an example here for shipping but I don't think the applies_on needs to go all the way to allocations level as the shipping benefit applies on the entire order.
docs/specification/loyalty.md
Outdated
|
|
||
| ```json | ||
| { | ||
| "memberships": [ |
There was a problem hiding this comment.
Can we also add a request/response example of how an instrument based discount will look like?
I am thinking of cases when there is a co branded instrument used for purchase. At that point in time the agent might or might not be aware of the associated discount. The agent will send the chosen FOP or identifier as a signal. This signal can be part of the loyalty extension in the incoming checkout/cart request or passed in a different placeholder. The response should contain a loyalty extension with the applied discount based on the instrument.
There was a problem hiding this comment.
I am actually fine reconciling it with the PR raised here - #250
-cc @igrigorik
So the ingress signal for loyalty identification can come from context.eligibility but then the response can contain the loyalty extension decorating the objects.
There was a problem hiding this comment.
A thought on this point:
So the ingress signal for loyalty identification can come from context.eligibility but then the response can contain the loyalty extension decorating the objects.
Given context.eligibility uses reverse domain names, I think one simple way to reconcile the 2 approaches would be to either have memberships[].id to also be reverse domain names OR add another field to reference back to the corresponding eligibility (similar to the provisional discount).
What I feel also fits well between the 2 PR proposals is that in the case any generic (not necessarily instrument-based discount) loyalty eligibility verification fails (i.e. buyer is not actually properly signed up to the loyalty program), this extension offers a mechanism for platform to request for sign-ups and renegotiate for that verification.
There was a problem hiding this comment.
add another field to reference back to the corresponding eligibility (similar to the provisional discount).
This is what I was expecting, for symmetry with discounts. From #250, both provisional and eligibility from the applied discount type feel appropriate to include on each membership, too.
There was a problem hiding this comment.
I would love to see an input example for specifying an reward balance redemption, though — it seems like that is brand new input as part of this capability.
There was a problem hiding this comment.
Thanks for the discussion here. I agree that we should add both provisional and eligibility, to handle monetary benefits (e.g. member pricing, points redemption) that links to a discount object (more specifically, discounts.applied field), AND non-monetary benefits (e.g. points/cashback earning based on transaction amount - 20 pts per dollar spent, 5 miles per dollar in base fare etc), as they all need verification from business side to be honored.
Now the question is where to, at membership level, tier level, or even benefit level? Given eligibility claims for loyalty are verified at a tier level (not membership level as businesses not only need to check if buyer is a member of the program but also which specific tier buyer is in to give corresponding benefits; also not benefit level as normally eligible for a tier means all benefits under that tier are available for the buyer), I personally felt both provisional and eligibility should be added under tier.
Concretely, a request/response would look like this for point redemption:
=== "Request"
{
"context": {
"eligibility": ["com.example.loyalty_tier_1"]
},
"line_items": [
{
"item": {
"id": "prod_shirt",
"quantity": 2,
"price": 2500
}
}
],
"loyalty": {
"memberships": [
{
"id": "membership_1",
"member_id": "member_id_1",
"rewards": [
{
"currency": {
"code": "LST"
},
"redemption": {
"amount": 100
}
}
]
}
]
}
}=== "Response"
{
"discounts": {
"applied": [
{
"title": "Point Redemption",
"amount": 250,
"automatic": true,
"provisional": true,
"eligibility": "com.example.loyalty_tier_1",
"priority": 1,
"method": "each",
"allocations": [
{"path": "$.line_items[0]", "amount": 250}
]
}
]
},
"loyalty": {
"memberships": [
{
"id": "membership_1",
"member_id": "member_id_1",
"name": "My Loyalty Program",
"tiers": [
{
"id": "tier_1",
"name": "GOLD",
"level": 1,
"provisional": true,
"eligibility": "com.example.loyalty_tier_1",
}
],
"activated_tiers": ["tier_1"],
"rewards": [
{
"currency": {
"name": "LoyaltyStars",
"code": "LST"
},
"balance": {
"available": 4500
},
"redemption": {
"amount": 100,
"applies_on": "$.discounts.applied[0]"
}
}
]
}
],
"activated_memberships": ["membership_1"]
},
"totals": [
{"type": "subtotal", "display_text": "Subtotal", "amount": 5000},
{"type": "items_discount", "display_text": "Point Redemption", "amount": -250},
{"type": "total", "display_text": "Total", "amount": 4750}
]
}@lemonmade @jingyli @sinhanurag does this make sense?
There was a problem hiding this comment.
Great work on this extension @ziwuzhou-google 👏 the hierarchical memberships → tiers → benefits → rewards model is well-designed, and placing it in common/ for cross-vertical use is the right call.
We've been building a reference implementation of incentives + loyalty on UCP (MCP transport, Shopify + Voucherify API/MCP, ~22 ucp_* tools covering the full shopping lifecycle). Based on real agent-customer interactions, there are a few capabilities we've found essential that the current schema doesn't cover yet. Happy to contribute PRs for any of these if there's interest.
1. Earning Forecast - "What will I earn from this purchase?"
This is the single most impactful missing piece for agent-driven commerce. In our implementation, showing "You'll earn 120 Loyalty Stars/Points with this order, bringing your balance to 4,620" before the customer commits is one of the strongest purchase motivators. It also helps agents handle price objections - points earning becomes additional value on top of any discount.
The current schema has balance (what you have) and redemption (what you're spending), but no way to show what the customer gains from the transaction.
Proposed addition - a new earning_forecast field on membership_reward, response-only:
"earning_forecast": {
"type": "object",
"description": "Preview of rewards to be earned from the current transaction.",
"ucp_request": "omit",
"properties": {
"amount": {
"type": "integer",
"minimum": 0,
"description": "Total points to be earned if the transaction completes."
},
"rules": {
"type": "array",
"items": {
"type": "object",
"required": ["id", "amount"],
"properties": {
"id": { "type": "string", "description": "Earning rule identifier." },
"description": { "type": "string", "description": "Human-readable explanation (e.g. '2x points on footwear')." },
"amount": { "type": "integer", "minimum": 0, "description": "Points earned from this rule." }
}
},
"description": "Breakdown of earning rules contributing to the total."
},
"projected_balance": {
"type": "integer",
"minimum": 0,
"description": "Projected available balance after earning and any redemption from this transaction."
}
}
}
This fits naturally into the existing membership_reward structure - each reward currency gets its own earning forecast. The per-rule breakdown gives agents transparency to explain why the customer is earning (e.g. "2x points on footwear" vs "base earning on all purchases"), which makes loyalty feel tangible rather than a black box.
2. Tier Progression - "How close am I to the next tier?"
The current schema shows which tiers exist and which are activated, but doesn't tell the agent how close the customer is to qualifying for a higher tier. In practice this drives natural upsell: "You're only 200 points from Platinum - this purchase would get you there!"
Proposed addition to membership_tier:
"progression": {
"type": "object",
"description": "Customer's progress toward qualifying for this tier.",
"ucp_request": "omit",
"properties": {
"qualified": {
"type": "boolean",
"description": "Whether the customer currently qualifies for this tier."
},
"progress_amount": {
"type": "integer", "minimum": 0,
"description": "Current progress value toward qualification threshold."
},
"threshold_amount": {
"type": "integer", "minimum": 0,
"description": "Required value to qualify for this tier."
},
"remaining_amount": {
"type": "integer", "minimum": 0,
"description": "Gap between current progress and threshold."
}
}
}
This is also response-only. For tiers the customer already has, qualified: true. For higher tiers, the agent can use remaining_amount to suggest cart additions that would push the customer over the threshold.
3. Agent Journey Example - When to Present Loyalty Context
Regarding PR #250 (eligibility claims) - in our implementation the agent flow looks like:
- Identify - customer provides email or loyalty card → agent looks up member profile (memberships, tiers, rewards balance)
- Discover deals - agent checks what promotions + loyalty benefits apply to the current cart, including tier-specific benefits
- Present - agent shows price with discount + "you'll earn X points" + "you're Y away from next tier"
- Redeem (optional) - customer chooses to spend points → redemption.amount on the relevant reward, linked via applies_on to discount extension
- Complete - earning is confirmed in the response
A concrete request/response example in the spec doc showing this full flow - especially the interplay between loyalty benefits, discount applies_on references, beast deals, and earning in the response - would be very helpful for agent developers building on this extension.
Context
We have a working MCP-based UCP server implementing these patterns - earning forecast and the full incentives negotiation lifecycle (search best deal → validate → lock → redeem). Happy to share more details or contribute schema additions as a follow-up PR if any of these proposals are useful.
|
@tpindel Thanks a lot for the comprehensive and well-thought comment. Really appreciate it. First-off, points earning is actually well planned in my head. The main reason I dropped it is just I want to have a simple start with the basics (e.g. the hierarchical data model) aligned with the community, and focus on those immediate-value monetary benefits (e.g. member pricing and shipping). But totally agree that points is a big motivator, especially in certain vertical (e.g. Hotel, Flights) where point is probably the single most important benefit. Now coming back to your individual proposal: 1 - Earning Forecast
2 - Tier Progression
And yes we will add concrete request/response examples for that flow to show how these extensions interact with each other! Lastly, there is no doubt that follow-up PRs from your side to enhance the basic schema are extremely welcomed. These use cases are solid ones that I believe lots of businesses share to have or desire to start with. Thanks again for the nice proposal! |
|
@ziwuzhou-google Thanks for comment and respond to me. I will try to address your questions.
I think we should remain open in the standard for that. From my experience at Voucherify, the majority of clients start with a more simplified approach where the points forecast is for the entire transaction or order. It is rare for them to switch to a per-item split later on. Even though people often design programs with a detailed breakdown in mind, I’ve seen many cases where, after launch, they never actually return to implement it. From my point of view, the standard should define the breakdown, but it should also allow for a simpler option. If a program isn't able to provide a per-item breakdown, the agent should be able to simply use the total forecasted amount. Does that make sense?
From my point of view, it is better if rewards and perks are listed generally and can be assigned to tiers from a wider pool based on particular conditions. This should apply to earning rules as well. For instance, in higher tiers, you might have better earning conditions, or you might pay less (in loyalty currency) to receive a perk. In general, I would say that bonuses and perks should be globally configurable, with adjusted variants then linked to specific tiers. Regarding 'rewards,' I would prefer to use that term for defining specific perks or incentives - like coupons, material, rewards, extra points, gift, or gift cards - that you receive for achieving a milestone or by spending points (exchange points <-> reward). I'm not convinced that this name should be linked only to accumulated balances. In loyalty programs, the different buckets used to collect various currencies (even within the same program) are typically called Points Wallets (I also know name sub-ledgers). These represent different types of point rewards or sub-wallets used to collect specific currencies for particular groups of earning rules.
From my POV, in practice, the base use case is definitely a member progressing through tiers based on accumulated points. The typical scenario involves the current balance or points accumulated within defined timeframes. Tiers defined by point accumulation across different wallets are rarer cases. I think typical case is points wallet and linked to it tiers structure. Sometimes, what I’ve seen is that progression is based on additional metadata attributes describing the customer or other segment definitions (e.g., location, total order count in the last 12 months, sign-up date, etc.). And yes, you are right - I wouldn't make it more complex at this stage by adding things like the number of transactions. That part can actually be hidden in the loyalty engine layer as a specific additional attribute, perhaps explained in the text-based terms and conditions or something similar.
Indeed |
17059da to
597dbfb
Compare
lemonmade
left a comment
There was a problem hiding this comment.
Learned a lot about the domain, and this generally hung together for me as someone unfamiliar with the ins and outs of loyalty.
docs/specification/loyalty.md
Outdated
| "id": "BEN_001", | ||
| "title": "Free shipping", | ||
| "description": "Complimentary standard shipping on all orders", | ||
| "applies_on": ["$.discounts.applied[0]"] |
There was a problem hiding this comment.
It seems like a lot of the display information for the referenced applied discount information is likely to be duplicated on this object. Is there any way to deduplicate these two (e.g., by splitting applied_benefits: ["discounts.keypath"] from available_benefits), or do we need the full-fidelity information on the membership itself for the host to render this on the business's behalf?
There was a problem hiding this comment.
If we leave the applies_on here, when could it point to more than one resource in checkout?
There was a problem hiding this comment.
1). I also share the same feeling about duplicated message (i.e. title here v.s. title within the referenced discounts.applied field). My thinking is we can put some guidance in our spec documentation that asks platform MUST use one and only one of it in this case (and I prefer the one referenced applied discount). Splitting that referencing achieved by applies_on out of benefit makes it hard to understand the linkage between the referenced object and benefit within membership. In the meantime, I do think having the full-fidelity information on the membership side will help platform as they can choose to display the corresponding tier name for clarity for example. I just don't see enough damage/confusion caused by this nesting that worth the effort to move it out and lose the "connection".
2). That is a very good point about about applies_on being an array. It does not need to be actually. If there is a benefit that touches more than one resource in checkout. it should always be possible to split the benefit into multiple non-overlapping ones. Updated!
| "type": "string", | ||
| "description": "Short and succinct explanation of the tier participation condition (e.g. “Free to join”)." | ||
| }, | ||
| "condition_type": { |
There was a problem hiding this comment.
Why does the platform need to know this? I feel like this list is probably not comprehensive, but will require breaking changes to add to in the future. What will a host do with this information, beyond just printing description?
There was a problem hiding this comment.
As you pointed out, the purpose is really for displaying the condition. In the flow of membership sign-up, platform needs to at least display some short excerpt of sign up requirement (both due to legal requirement and as an upsell), and provide a full link of terms & conditions to fully explain the nitty-gritty in case user wants to learn more.
There was a problem hiding this comment.
There are some parts of this document that read a little more mechanical than other specification docs in UCP. I’d recommend putting this new spec and its siblings, like fulfillment, discount, and checkout, through your favorite LLM. I would at least expect to see a bit more guidance using the MUST/ SHOULD/ MAY structure of the other specifications.
| } | ||
| } | ||
| } | ||
| ``` |
There was a problem hiding this comment.
There's no mention/ link to identity linking or context.eligibility in here; should there be? I assume most of the application logic in here will hinge on providing one of those two signals to checkout.
| "activated_memberships": { | ||
| "type": "array", | ||
| "items": { "type": "string" }, | ||
| "description": "List of memberships that the customer is activated with, identified by the `id` of the Loyalty Membership object." |
There was a problem hiding this comment.
Should this be ucp_request: omit?
There was a problem hiding this comment.
Request can set this field to request for membership sign up like below (multiple memberships in hypothetical case):
"loyalty": {
"memberships": [
{
"id": "membership_1",
"activated_tiers": ["tier_1"]
},
{
"id": "membership_2",
"activated_tiers": ["tier_2"]
}
],
"activated_memberships": ["membership_1", "membership_2"]
}Maybe the argument is: with activated_tiers + id set in the request, do we still need to set activated_memberships (as it seems redundant with the id field). My thought is yes / no. Having it available in the request can help the business to cross-check the platform is indeed requesting for "membership_1", "membership_2", and this field is optional in essence (correct me if I'm wrong here as we don't have it in the required field for the entire loyalty object).
There was a problem hiding this comment.
I don't think I understood that you intended to have this be mutable at all. To me, it feels odd that the platform would activate memberships on the business by way of a update_checkout mutation. I don’t think there are other mutations you can perform on checkout today that would have a side effectin this same way. Is this really an operation we expect to perform mid-checkout, without any additional confirmation/ payment/ follow-up beyond what can fit in a successful checkout update response?
There was a problem hiding this comment.
I might miss some of the setup/syntax in schema definition but this is never intended to be bind with checkout (yes loyalty extension can decorate checkout but not a must). My thought was the loyalty sign-up happened in the upper funnel discovery phase: 1). platform advertise the business loyalty program, potential benefits and there are also info provided about the sign-up requirement; 2). then platform can ask if user want to sign up a particular tier; 3). user said yes with platform sending above mentioned request to business.
Later in the real checkout flow after use has signed up, platform just send a request like this and it's up to business to verify that eligibility claim:
{
"context": {
"eligibility": ["com.example.loyalty_tier_1"]
},
"line_items": [
{
"item": {
"id": "prod_shirt",
"quantity": 2,
"price": 2000
}
}
]
}There was a problem hiding this comment.
OK, I was also expecting that sign up for layalty would happen pre-checkout, so it seems odd that sign up would ever happen through Checkout operations, by specifying the above input. Shouldn’t there be dedicated operations for loyalty membership sign up/ updates?
There was a problem hiding this comment.
Correct there should be one. I think there are lots of open discussion about loyalty membership sign up/ updates around how we should model it. One possibility is we have a high level account management capability (beyond membership specific updates) and loyalty extension is used to decorate it; the other is Loyalty/Membership management has its own capability for all the operations. I don't think this part has been fully nailed down (and it is not the plan of this PR).
| "activated_tiers": { | ||
| "type": "array", | ||
| "items": { "type": "string" }, | ||
| "description": "List of tiers that the customer is activated for or wish to activate, identified by the `id` of the Membership Tier object." |
There was a problem hiding this comment.
Should this be ucp_request: omit?
There was a problem hiding this comment.
See one of the above comments. This should not as it will allow the platform to request for membership sign-up, and in the request it needs to specify which tier they are trying to sign up for.
docs/specification/loyalty.md
Outdated
|
|
||
| ```json | ||
| { | ||
| "memberships": [ |
There was a problem hiding this comment.
I would love to see an input example for specifying an reward balance redemption, though — it seems like that is brand new input as part of this capability.
…and some documentation improvments.
95e9a32 to
32d1f8a
Compare
Summary
A new loyalty extension is introduced to facilitate high-fidelity loyalty experiences across various commerce journeys, ensuring that shoppers can for example sign-up for membership, access personalized benefits, redeem rewards, and manage memberships seamlessly across various existing and future new capabilities.
Motivation
Loyalty is a core concept in Commerce, serving as a primary driver for customer retention and long-term business growth. UCP in its current format can provide minimal and to some extent "hacky" support of loyalty in checkout for example. However, they are far from ideal and lack the generality. As Loyalty is a construct that spans across all verticals (retail, lodging, transportation, etc), the design is meant to encompass and capture the core common semantics of different capabilities and scenarios.
Design Details
Four core concepts are introduced in the schema and structured in a hierarchy:
With the help of these four building blocks, one can support use cases such as:
applies_onrefers to an applied discount in the discount extensionType of change
Please delete options that are not relevant.
functionality to not work as expected, including removal of schema files
or fields)
Checklist