Skip to content

feat: loyalty extension for checkout capability#340

Open
ziwuzhou-google wants to merge 2 commits intoUniversal-Commerce-Protocol:mainfrom
ziwuzhou-google:feat/loyalty-v2
Open

feat: loyalty extension for checkout capability#340
ziwuzhou-google wants to merge 2 commits intoUniversal-Commerce-Protocol:mainfrom
ziwuzhou-google:feat/loyalty-v2

Conversation

@ziwuzhou-google
Copy link
Copy Markdown

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:

  • Price-Impacting Benefits: Real-time application of member-only discounts and free shipping offers
  • Non-Price Benefits: Transparent display of rewards earned or rewards applicable to future purchases
  • Status Recognition: Verification and display of the buyers’ specific loyalty tier

Design Details

Core concepts and hierarchy

Five core concepts are introduced in the schema and structured in a hierarchy:

  • Memberships: the overarching framework and brand umbrella of a loyalty program
  • Tracks: Distinct enrollment pathways or program categories that a user can join independently or simultaneously
  • Tiers: Specific achievement ranks or status milestones within a track that unlock escalating value as a member progresses through activity or spend
  • Benefits: ongoing perks and privileges granted to a customer based on their current tier or membership status
  • Rewards: the fungible balances and/or stored value available for the customer to redeem on transactions
"memberships": [
  {
    "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

"memberships": [
  {
    "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).

"memberships": [
  {
    "tracks": [
      {
        "tiers": [
          {
            "benefits": [
              {
                ...
              }
            ]
          }
        ]
      } 
    ],
    "rewards": [
      {
        ...
      }
    ],
    "provisional": false,
    "eligibility": "com.example.loyalty"
  }
]

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.title within discount extension, but also assure buyers that these member specific discounts are recognized because of their verified loyalty status via memberships.tracks.tiers.name within 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.

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing
    functionality to not work as expected, including removal of schema files
    or fields
    )
  • Documentation update

Checklist

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

@igrigorik
Copy link
Copy Markdown
Contributor

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 loyalty.memberships wrapper is unnecessary? There are no sibling keys alongside memberships. If we switch to a map keyed by eligibility identifier (same reverse_domain_name convention we use for services, capabilities, and payment_handlers), the wrapper dissolves; the keys carry meaning, and the structure self-describes which claims produced which outcomes.

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/ provisional and eligibility granularity is wrong? Currently both sit at the membership level. With the map-keyed approach, eligibility becomes the key itself — no separate field, no echo semantics. Each map entry corresponds to one eligibility claim with its own provisional state, which means independent verification per program works naturally.


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 context.eligibility as input mechanism for loyalty claims. Alternatively, if user is authed, then business uses that at negotiation time to lookup and reflect applicable loyalty info.

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:

  • Map keyed by eligibility identifier. 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, which means: (a) correlation with context.eligibility and discounts.applied[].eligibility is by key lookup, not array scanning; (b) no separate eligibility field inside the entry; (c) uniqueness is enforced by shape, not validation rules.

  • Singular tier, not an array. At checkout, the buyer has one active tier per program. This is a negotiated outcome, not a catalog. The tiers[] + activated_tiers[] side-channel pattern in the current PR belongs in a future discovery capability where the full program hierarchy is relevant.

  • Benefits explain / provide context. 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. Agent can correlate: discounts.applied[0].eligibility === "com.example.loyalty" tells you which financial effects came from which program.

  • No rewards/balance at this layer. Balance is a property of the payment instrument; the instrument owns the balance and the spending mechanics. Loyalty owns what you'll earn, not what you can spend.

  • Earning forecast reuses the allocations pattern. earning_forecast.allocations[] follows the same { path, amount } shape as discounts.applied[].allocations[].

@ziwuzhou-google
Copy link
Copy Markdown
Author

Thanks a lot for the insightful comments @igrigorik. A few questions that I'd like to hear your thoughts:

  1. Mental model gap: I realized that how we understand "memberships"/"programs" might be different, and that is probably the biggest reason on why we proposal the layering and hierarchy in its current format. This is also related to this discussion thread in the previous PR: feat: Add basic schema for loyalty extension #251 (comment). Taking Target as a concrete example. As pointed out in that discussion thread, one can simultaneously hold Free Circle or Circle Card or Circle 360. In my mind, they are not three different memberships, but three different tracks (as they can be held simultaneously) under a single membership object (which corresponds to the larger Target Circle loyalty program). This is why in the proposal tracks are still needed at the negotiation/confirmation layer to make sure we can account for the multi-holding case.

  2. Future-proof expansion: I really like the simplification you proposed as it definitely looks cleaner if we just focus on the checkout/order capability (which is also the focus of this PR). However, I do want to admit that during the design, our thinking is the extension should be extensible enough to account for future cataloging use case for example, and it's better we don't need a breaking change to support that. As a result, we have "activated_tiers" and making tiers an array for example, that are indeed not useful at the negotiation/confirmation layer but needed for discovery/upsell stage. I'm not sure what's the general guideline here in terms of supporting immediate use case v.s. supporting future use cases without breaking change.

"description": "Benefits associated with a membership tier.",
"type": "object",
"required": ["id", "description"],
"properties": {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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": {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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": {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks for 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?

Comment thread source/schemas/common/types/membership_reward.json Outdated
"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."
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown

@gsmith85 gsmith85 Apr 23, 2026

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I 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": {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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.
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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


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
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit: "Attribution" rather than "assertion"?

Comment on lines +26 to +27
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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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."
Copy link
Copy Markdown

@gsmith85 gsmith85 Apr 23, 2026

Choose a reason for hiding this comment

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

@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": {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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


### 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.
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Some suggestions to improve this core explanation of loyalty behavior:

  1. 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 eligibility field within the membership object represents the 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_tracks and activated_tiers fields alongside the eligibility field to reflect the buyer's verified status.

  1. 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 populate activated_tracks or activated_tiers. Any applicable price-impacting benefits MUST be surfaced in the discounts object with provisional: true.

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

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

Labels

TC review Ready for TC review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants