Skip to content

feat: add Return Extension to UCP specification#257

Open
venkatesh-ucp wants to merge 11 commits into
Universal-Commerce-Protocol:mainfrom
venkatesh-ucp:returns-extension
Open

feat: add Return Extension to UCP specification#257
venkatesh-ucp wants to merge 11 commits into
Universal-Commerce-Protocol:mainfrom
venkatesh-ucp:returns-extension

Conversation

@venkatesh-ucp

@venkatesh-ucp venkatesh-ucp commented Mar 11, 2026

Copy link
Copy Markdown

Return Extension

Overview

The Return Extension allows businesses to communicate the conditions, methods, timelines, and costs associated with returning physical items directly to the platform and the buyer, mirroring real-world commerce requirements.

Motivation

Currently, the UCP Fulfillment Extension only handles how physical goods get to a buyer (shipping, pickup, and associated options). However, returning items is a core primitive of physical commerce.

By exposing the return policy natively in the UCP schema, AI agents and platforms can intelligently answer user queries like "Can I return this in-store?" or "How many days do I have to return this?" without forcing the user to leave the platform to hunt for a policy on the merchant's website.

Use Cases

This schema addition is designed to support the following agentic and platform use cases:

  • Pre-Purchase Assurance: An AI agent proactively answers questions like "Can I return this if it doesn't fit?" before the user commits to a transaction.
  • Return Cost Transparency: A platform calculates the true financial risk of a purchase by exposing fixed restocking fees or making it clear when the buyer is responsible for return shipping costs.
  • Omnichannel Return Routing: A buyer wants to verify if an item bought online can be returned in-store to avoid the hassle of printing labels and mailing packages.
  • Exception Handling (Final Sale): Automatically warning buyers when specific items in a mixed cart (e.g., custom engraved jewelry, clearance apparel) are strictly non-returnable, preventing post-purchase disputes.

Detailed Design

Architectural Discussion: Fulfillment Extension vs. Standalone Extension

One of the main questions to answer before adding return policy support is around whether to embed return_policies within the existing Fulfillment Extension. Specifically, the question is whether returns are a subset of physical fulfillment, or if they represent a broader conceptual primitive (a "policy") that applies to line items independent of logistics.

Pros of Adding to the Fulfillment Extension

  1. Logistical Cohesion (Reverse Logistics): Fulfillment represents the physical lifecycle of a product. Returns are effectively reverse-fulfillment. Both domains deal with shipping methods, carriers, physical locations (in-store drop-offs), and transit fees. Grouping outbound delivery and inbound returns keeps all physical logistics data in one conceptual domain.
  2. Schema Pattern Reuse: The Fulfillment Extension already solves the complex problem of mapping logistical realities to specific parts of a cart via line_item_ids. Reusing this extension avoids duplicating the boilerplate required to map rules to specific items.
  3. Destination Dependency: While rare, return policies can sometimes depend on the fulfillment destination (e.g., domestic returns might be free, while cross-border returns are strictly "customer responsibility" or final sale). Tying returns to the fulfillment block acknowledges that where an item is shipped can dictate how it must be returned.

Cons of Adding to the Fulfillment Extension (The Case for a Standalone Extension - Preferred approach)

  1. Asymmetry of User Choice: The core behavior of the Fulfillment Extension is interactive: it presents a list of methods (Standard, Express, Pickup) and the buyer selects one. Return policies do not behave this way; they are immutable rules assigned by the merchant. The user does not select a return policy, but rather, they are forced into it based on the merchant or the items in their cart. Mixing selectable options with static rules in the same extension creates a semantically confused model.
  2. Limited Reusability Across Capabilities: UCP is designed to be highly composable. If return_policy is tightly coupled to the Fulfillment Extension, it becomes much harder to reuse the returns spec in other contexts. For example, a post-purchase Order capability or a simple product discovery agent might need to display return policies without carrying the irrelevant baggage of forward-fulfillment routing and shipping rate calculations.
  3. Lack of Cross-Shopping in Checkout: In the context of the UCP Checkout Capability, the user is already locked into completing the transaction with a specific merchant; there is no opportunity to cross-shop and compare fulfillment/return options against other merchants at this stage. Therefore, optimizing the schema for logistical comparison might be less valuable than optimizing it for clear, policy-based disclosure.

Schema Structure & Discovery

  • Inline Extension Definitions: To ensure self-contained schemas and clean distribution, internal sub-schemas (return_policy, return_method, return_fee) are natively embedded into inline $defs within return.json rather than split into standalone shared files.
  • Discovery Advertisement: Businesses advertise capability support via their profile listing dev.ucp.shopping.return extending dev.ucp.shopping.checkout.

Proposed Entities

Return Policy Response

Name Type Required Description
return_period_in_days integer No Length of the merchant's return window in days, measured from the date of delivery. This communicates the window duration as a static policy statement.
policy_url string No URL to the merchant's full return policy document. Platforms SHOULD surface this link alongside the structured policy data so buyers can review complete terms.
reason string No Human-readable explanation of why this return policy applies. Helps platforms and agents explain policy provenance to buyers (e.g., 'Custom engraved items are non-returnable per our personalization policy').
supported_resolutions Array[string] No Supported outcomes for the return. Well-known values: original_payment_method, store_credit, gift_card, exchange, replacement. An empty array [] explicitly indicates the items are non-returnable (final sale).
methods Array[Return Method] No Permitted physical methods for returning the item, along with their associated fee structures.

Extended Checkout Line Item

This extension adds return_policy_id to the standard Checkout LineItem entity.

Name Type Required Description
return_policy_id string No ID of the return policy that applies to this line item. Must reference a key in the top-level return_policies map.

Return Method Object

Name Type Required Description
type string Yes Physical return channel. Clients MUST tolerate unknown values.

Well-known values:
in_store: The buyer returns the item physically at a retail store location.
by_mail: The buyer packages and ships the item back to the merchant via a carrier.
kiosk: The buyer drops the item off at a designated self-service kiosk or drop-off point.
fee Return Fee Yes The cost structure associated with this specific return method.

Return Fee Object

Name Type Required Description
type string Yes Cost structure for this return method. Clients MUST tolerate unknown values.

Well-known values:
free: The merchant covers return costs (prepaid label or free in-store drop-off).
fixed_fee: The merchant charges a flat fee (restocking fee or deducted return label fee).
customer_responsibility: The buyer arranges and pays for return postage entirely.
amount integer No Fixed return fee charged by the merchant, represented in minor currency units (e.g., cents). Required if type is fixed_fee.
display_text string No Human-readable text to display against the fee to provide context to the buyer (e.g., "Restocking Fee", "Return Shipping Label").

Example Payload

{
  "line_items": [
    {
      "id": "shirt",
      "return_policy_id": "rp_standard"
    },
    {
      "id": "pants",
      "return_policy_id": "rp_standard"
    },
    {
      "id": "custom_engraved_watch",
      "return_policy_id": "rp_final_sale"
    }
  ],
  "return_policies": {
    "rp_standard": {
      "return_period_in_days": 30,
      "policy_url": "https://example.com/returns",
      "supported_resolutions": [
        "original_payment_method",
        "exchange"
      ],
      "methods": [
        {
          "type": "in_store",
          "fee": {
            "type": "free",
            "display_text": "Free In-Store Return"
          }
        },
        {
          "type": "by_mail",
          "fee": {
            "type": "fixed_fee",
            "amount": 500,
            "display_text": "Return Shipping Fee"
          }
        }
      ]
    },
    "rp_final_sale": {
      "supported_resolutions": [],
      "reason": "Custom engraved items are final sale."
    }
  }
}

Out of Scope for this PR

The following recommendations from the architectural review are explicitly out of scope for this PR:

  • Mail-Return Preconditions: Detailed carrier arrangement flags (e.g., label_provided) are deferred to post-purchase execution extensions.
  • Site-wide Default Fallback: Since the registry pattern allows explicit mapping of items to policies, a schema-level default fallback is omitted for simplicity in v1.
  • Policy Modifiers: Holiday/loyalty modifications are out of scope. The schema only represents the effective policy.
  • Marketplace Dynamics: Multi-seller marketplace policy overrides are out of scope.
  • Return Initiation: Post-purchase return execution (e.g., generating RMAs, labels, and refund processing) is out of scope. This is deferred to a future action-oriented extension.

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

This change introduces the Return Extension, allowing businesses to
communicate return conditions, methods, timelines, and costs.

Key changes:
- Added JSON schemas for Return Policy, Method, and Fee.
- Extended Checkout and Order with return policies.
- Renamed Return Policy 'category' to 'return_window_type'.
- Added specification documentation and updated site navigation.

Closes #TBD
@amithanda
amithanda requested a review from jingyli April 13, 2026 19:22
@amithanda amithanda added the TC review Ready for TC review label Apr 13, 2026
Comment thread source/schemas/shopping/types/return_policy.json Outdated
"enum": ["lifetime", "no_returns", "final_sale", "finite_window"],
"description": "The type of return window."
},
"return_days": {

@gsmith85 gsmith85 May 5, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

return_days works as a policy statement at checkout, but to answer "can I still return this?", a platform has to join checkout-time return_days against a delivered event in fulfillment.events on the Order or rely on its own transaction records.

Consider also extending Order with a return_deadline. The merchant computes it relevant to whatever temporal anchor applies (i.e. order placed, fulfillment, a specific unrelated date) and the platform just reads it.

This handles non-retail domains naturally, a hotel cancellation (24h before check-in) has a different anchor than delivery entirely. return_days can't express that, but a pre-computed UTC deadline can.

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 wanted some clarifications on how you expect this to work before I go ahead and make the changes.

Option 1: As you mention, have the platform store the return policy or persist it in the Order and then calculate it whenever the fulfillment.events (delivered) is received given that majority return policies will kick in after the item is delivered (physical or digital). This has the complexity that the platform needs to calculate this number.

Option 2: Add a field to the line_items part of the Order which the merchant updates whenever a fulfillment event (delivered) occurs. The platform the looks up the data to answer the "can I still return this?" question. Line items vs a global deadline for the Order since the return deadlines could be different based on the category of the product. for eg: electronics v/s clothing.

Are we on the same page that option 2 is how this should be implemented?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Apologies for the missed message here, let me attempt to clarify the premise first.

At checkout a business may communicate a policy. For example, "free returns for 30 days." The Platform is poorly positioned to translate this into an actual deadline which is the most relevant piece of information for the customer post-purchase.

The platform is poorly positioned because in the existing schema, the only anchor provided to the platform is the time checkout occurred. But modern ecommerce is more complicated than that, for physical goods it may be (i) 30 days from purchase (ii) 30 days from fulfillment or (iii) 30 days from delivery. Amazon and Target use (iii), Sephora is (i).

This brings us to the options you outlined.

Option 1. We should avoid this as it forces the platform to calculate the returns deadline against the Policy which has insufficient fidelity. Providing the platform a referential hint that captured "duration from purchase/fulfillment/delivery" may be useful in any case but doesn't solve the undesirable outcome. In the case of fulfillment or delivery, it necessitates the platform track post-purchase notifications which is onerous.

Option 2. Allows the business to serve as the source of truth which is directionally aligned to the protocol. The Order becomes the authoritative conveyance for which items have which return deadlines for which line_items. This allows the business to provide a more contextualized signal than what was shown at Checkout time.

I'm aligned to Option II and it looks like the group has similarly weighed in — @sbeashwar called for extending Order with return_policies[] given the checkout TTL gap, and @amithanda's recommended spec prose already references the Order as the authoritative source. Is that going to be tracked as part of this PR or as follow-up?

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'm good with Option II. The post order experience will be handled in a follow up PR. We should be filing the RFC for that next week'ish.

Comment thread source/schemas/shopping/types/return_method.json Outdated
Comment thread source/schemas/shopping/return.json
Comment thread docs/specification/return.md
Comment thread source/schemas/shopping/types/return_policy.json Outdated
Comment thread source/schemas/shopping/types/return_policy.json Outdated
Comment thread source/schemas/shopping/types/return_fee.json Outdated
Consolidates the Return Extension domain schemas by embedding return_policy, return_method, and return_fee directly into inline $defs within return.json, eliminating redundant standalone files.

Additionally, simplifies policy modeling by completely removing the brittle window_type property to rely natively on clean field semantics, converts logistics channels and fee structures to open strings for robust client parsing, makes policy correlation IDs optional, and adds a standard capability profile Discovery section to the specification documentation.
@prasad-stripe

Copy link
Copy Markdown

The schema covers the physical logistics of a return well (channel, fees, timeline) but doesn't address what happens financially once the return is accepted (refund semantics). In practice, merchants offer different refund options: original payment method, store credit, or exchange/replacement only. These vary per policy and materially affect purchase decisions.

How about adding a field like refund_type on return_policy (with well-known values such as original_payment, store_credit, replacement_only) would let an agent answer "what do I actually get back if I return this?" Which is distinct from how the item physically gets back to the merchant.

@amithanda

Copy link
Copy Markdown
Contributor

Thanks @venkatesh-ucp for doing a pass over some basic structural issues.

I did another pass focussing over the overall architectural shapes, granularity gaps and behavior specification of those gaps.

Although the Return Extension is well-scoped for what it tries to do — pre-purchase return policy disclosure at checkout — and the recent revisions have addressed most of the structural concerns. However, the current schema models returns as an item-by-item assignment, which doesn't reflect how merchants actually express return policies in production. Most return policy authoring is hierarchical (site → category → brand → item), and the schema currently forces all of that hierarchy to be flattened into per-line-item bindings on every checkout response.

Three categories of suggestions:

  1. Architectural shape — the array-of-policies pattern works but the wrong relationship is normalized: items belong to policies instead of policies being a property of items. This creates ambiguity (overlapping line_item_ids), redundancy (same policy repeated when shared), and a model that doesn't map cleanly onto merchant authoring tools.

  2. Granularity gaps — return policies are rarely authored per-item. Site-wide, category-level, brand-level, and attribute-based policies are the norm. The schema can express the runtime output (which policy applies to which item) but can't express the authoring intent (why that policy applies). For AI agents this is a real limitation.

  3. Behavior specification gaps — multiple operational questions are unaddressed: when does policy lock, what's the relationship to fulfillment, what's a partial-quantity return, what happens when no policy is sent, what about marketplace overrides. These need spec prose before any serious implementation begins.

Here are some specific recommendations related to these, to make this more robust:


Part 1 — Schema Architecture

1.1 The relationship is inverted: items should reference policies, not the reverse

The current design:

"return_policies": [
  {
    "id": "rp_apparel",
    "line_item_ids": ["shirt", "pants"],
    "return_days": 30,
    ...
  }
]

Policies own a list of items. To find a given item's policy, you scan the array. To deduplicate when the same policy covers 50 items, you can't — there's only one place to bind line items.

Why this matters: Returns are an attribute of an item, not a grouping of items. The current pattern works for fulfillment because fulfillment IS a grouping primitive — items genuinely travel together as a shipment. Return policy is not a grouping primitive — each item has a single return policy that applies to it regardless of what other items are in the cart.

Recommendation: Move toward a keyed-map registry pattern where policies are defined once and referenced from line items:

{
  "return_policies": {
    "rp_standard": {
      "return_days": 30,
      "exchanges_allowed": true,
      "methods": [...]
    },
    "rp_final_sale": {
      "exchanges_allowed": false
    }
  }
}

And on the line item side (via a line_item extension):

"line_items": [
  { "id": "li_1", "item": {...}, "return_policy_id": "rp_standard" },
  { "id": "li_2", "item": {...}, "return_policy_id": "rp_final_sale" }
]

Benefits:

  • O(1) item-to-policy lookup
  • No possibility of overlapping assignments (each item has at most one return_policy_id)
  • Policies are deduplicated by reference (one entry covers 50 items)
  • Mirrors UCP's registry pattern (capabilities map, services map, etc.) which is the canonical UCP shape

Trade-off: This requires extending line_item.json with return_policy_id. This is more invasive than the current top-level-only extension, but it's the right normalization for an attribute that genuinely belongs to each line item.


1.2 The schema can't express policy hierarchy or authoring intent

Real merchant return policies are authored at multiple levels:

Level Example
Site-wide "All items: 30-day returns unless otherwise noted"
Category "Electronics: 15-day returns; restocking fee for opened items"
Brand "Brand X items follow brand-specific terms"
Attribute "Custom engraved items: final sale"
Item "This specific SKU: final sale"

The current schema flattens all of this into runtime per-item assignments. A buyer asking "Why is this final sale?" gets no answer beyond "the merchant said so" — the schema can't communicate "because this item is custom engraved" or "because this falls under our jewelry category policy."

Why this matters for AI agents: The Overview explicitly cites use cases like "An AI agent proactively answers questions like 'Can I return this if it doesn't fit?'" The agent's value here is in explaining WHY a policy applies, not just stating it. Without policy provenance, the agent is reduced to reading flat data.

Recommendation: Add a reason or applies_because field on the policy:

"reason": {
  "type": "string",
  "description": "Human-readable explanation of why this return policy applies. Helps platforms and agents explain policy provenance to buyers (e.g., 'Custom engraved items are non-returnable per our personalization policy', 'Electronics carry our 15-day return window'). Optional.",
}

This costs nothing for merchants who don't have authoring context, and unlocks meaningful explanation for those that do.


1.3 The policy primitive conflates policy definition with policy binding

The current return_policy object contains two distinct kinds of data:

  • Policy definition (return_days, exchanges_allowed, methods, fees) — what the policy IS
  • Policy binding (line_item_ids) — what the policy APPLIES TO

These are different concerns. Mixing them means:

  • A policy without line_item_ids is currently meaningless (orphaned)
  • A line_item_ids array without policy content is currently invalid
  • Reusing a policy for different bindings requires duplication

This is a downstream consequence of inverting the relationship (Section 1.1) — if items reference policies, then the policy object naturally becomes pure definition with no binding data.


1.4 Restocking fee modeling is awkward

The current schema places fee inside return_method. This forces a merchant who charges a flat $5 restocking fee regardless of return method to repeat it on every method:

"methods": [
  { "type": "in_store", "fee": { "type": "fixed_fee", "amount": 500, "display_text": "Restocking Fee" } },
  { "type": "by_mail", "fee": { "type": "fixed_fee", "amount": 500, "display_text": "Restocking Fee" } }
]

Conversely, a merchant who has "free returns by mail with a $5 restocking fee" can't express that — fee.type: free and fee.type: fixed_fee are mutually exclusive within one method.

Real-world fee structure for returns typically has two independent dimensions:

  • Logistics cost — who pays for shipping/handling (free, fixed, customer_responsibility)
  • Restocking fee — a separate charge against the refund amount

Recommendation: Split the fee model into logistics and restocking concerns:

"methods": [
  {
    "type": "by_mail",
    "shipping_cost": { "type": "free" },          // logistics
    "restocking_fee": { "amount": 500 }            // separate from logistics
  }
]

If splitting is too invasive for v1, at minimum the spec should acknowledge: "When a merchant charges a restocking fee independent of return method, businesses SHOULD apply the same fee object to all methods."


1.5 methods array doesn't model practical mail-return preconditions

type: by_mail says nothing about who arranges the carrier. In practice this is a critical distinction:

  • Merchant-provided label — the business issues a prepaid shipping label or RMA number
  • Self-arranged shipping — the buyer prints their own label and ships at their own cost
  • Carrier pickup — the merchant arranges courier pickup from buyer's address

These are all "by mail" but functionally very different. A buyer expecting a prepaid label who gets customer_responsibility will have a poor experience.

Recommendation: For by_mail methods, add an optional label_provided boolean or a more structured shipping_arrangement field:

{
  "type": "by_mail",
  "fee": { "type": "free" },
  "label_provided": true,
  "label_url_template": "..."  // optional, link to where buyer can generate label post-purchase
}

This crosses into post-purchase territory but the disclosure ("we provide a label vs. you ship yourself") is pre-purchase information that affects buyer decisions.


Part 2 — Granularity & Authoring

2.1 Site-wide vs. exception-based authoring

The current schema can't naturally express "Site default + exceptions." A merchant whose 30-day return policy applies to all 50 items in a cart except 1 final-sale item has to send:

  • Option A: One policy with all 49 line item IDs + one policy with 1 line item ID (verbose, error-prone if items added)
  • Option B: A separate policy per item (extremely verbose)
  • Option C: An empty line_item_ids: [] policy as catch-all (currently undefined behavior — see review file Suggestion C)

Most merchant systems express returns as "policy hierarchy with overrides." The schema should support this directly:

Recommendation: Define a normative default policy mechanism. Either:

"return_policy_default": { "return_days": 30, ... },
"return_policy_overrides": [
  { "line_item_ids": ["watch"], "exchanges_allowed": false }
]

Or as a sentinel within the existing array:

"return_policies": [
  { "line_item_ids": [], "return_days": 30, ... },     // catch-all: matches all uncovered items
  { "line_item_ids": ["watch"], "exchanges_allowed": false }
]

Either pattern is fine. What matters is that the catch-all behavior is normatively specified — the current spec language ("may be omitted or covered by a default policy") leaves this undefined.


2.2 No support for policy modifiers (promotions, loyalty, marketplace)

Return policies in practice are not just merchant attributes. Several layers can modify them:

  • Promotions — "Holiday returns: items bought Nov–Dec can be returned through January"
  • Loyalty tier — "Gold members get 60-day returns instead of 30"
  • Marketplace — Amazon's A-to-z guarantee can supersede individual seller policies
  • Buyer location — domestic returns vs. international returns differ

The current schema returns a single static policy and provides no way to indicate:

  • The policy is enhanced from the base because of a promotion or loyalty tier
  • The buyer's eligibility for the enhanced policy is conditional on something

This is analogous to how the Discount extension distinguishes automatic, provisional, and eligibility-based discounts. Return policy could borrow that pattern:

{
  "return_days": 60,                              // the applied window
  "base_return_days": 30,                         // baseline before modification
  "modifier_reason": "loyalty_extended_window",
  "eligibility": "com.example.gold_membership"    // the claim that unlocks this
}

Out of scope for v1 perhaps, but the spec should at least acknowledge that the returned policy is the EFFECTIVE policy after all modifiers are applied, and that modifier attribution is out of scope for this version.


2.3 Marketplace dynamics are not addressed

The Return Extension implicitly assumes a single business issues a single policy per item. Marketplace dynamics break this:

  • A multi-seller marketplace (eBay, Amazon Marketplace) may surface a marketplace-level return policy alongside a seller-specific policy
  • Which policy is sent in return_policies?
  • Can a single line item have two policies (seller + marketplace)?

The spec doesn't address marketplaces at all. Recommendation: explicitly scope marketplace support as out of scope for v1, OR introduce a policy_authority field:

"policy_authority": {
  "type": "string",
  "description": "Who issues this policy. Well-known: `merchant` (single-seller, default), `marketplace` (marketplace-wide guarantee), `seller` (specific seller within a marketplace).",
  "examples": ["merchant", "marketplace", "seller"]
}

Part 3 — Behavior Specification Gaps

3.1 When does the policy lock?

The spec doesn't say. In practice:

  • Policy at cart view → may change before purchase
  • Policy at checkout view → may still change before purchase
  • Policy at order placement → typically locked

Without explicit lock semantics:

  • A buyer who sees a 30-day return at checkout may receive a 15-day policy at the order if the merchant changes it between sessions
  • AI agents and platforms have no basis for claiming "Your return window is X days" with any confidence

Recommendation: Add normative spec language:

"The return policy returned at checkout response time is the policy in effect for the buyer at that moment. Businesses MUST honor the return policy that was in effect at the time of order placement; that policy MUST be sourced from the Order resource (when available) rather than recomputed from current merchant rules."


3.2 Partial quantity returns

A line item with quantity: 3 — can the buyer return just 1? The schema implies whole-line-item returns since line_item_ids is the only granularity. In practice:

  • Apparel: returning 1 of 3 shirts is common
  • Bundled items: returning 1 piece of a 2-piece bundle is often not allowed
  • Quantity discounts: returning 1 of 3 may trigger reapplication of pricing

The spec needs to either:

  • Explicitly scope returns at the whole-line-item level for v1
  • Add quantity_returnable or similar to support partial returns

Without addressing this, platform behavior will diverge — some platforms will assume partial returns are allowed, others won't.


3.3 Methods semantics: empty vs. absent vs. with return_days: 0

Several states are currently undefined:

State Intended meaning?
methods absent ?
methods: [] ?
return_days: 0 Item is returnable for zero days? Or not returnable?
return_days absent, methods present Returnable with no time limit?
return_days: 30, methods absent Returnable but no methods specified?

A platform reading any of these has no normative basis for interpretation. The spec needs a state table making these explicit. Suggested defaults:

  • methods absent OR methods: [] → MUST treat as "no return methods supported"
  • return_days: 0 → MUST treat as "non-returnable" (equivalent to missing the policy)
  • return_days absent with methods present → MUST treat as "no time limit on returns"
  • return_days: 30 with no methods → ambiguous; spec MUST specify

3.4 Relationship to Fulfillment is structurally implicit

The Return extension is independent of the Fulfillment extension, but in practice they're entangled:

  • "In-store returns at any Nordstrom location" depends on the same retail location data that fulfillment uses
  • Carrier-paid return labels depend on which carrier delivered the item
  • Cross-border returns depend on the destination country in fulfillment

By keeping them decoupled, the schema avoids forcing a dependency, which is the right architectural choice. But the spec should explicitly call out the implicit relationships:

"The Return Extension is independent of the Fulfillment Extension. Businesses MAY use information from fulfillment to compute return options (e.g., in-store return locations may correlate with fulfillment retail locations), but this extension does NOT model that relationship. Platforms MUST NOT assume any structural correlation between return methods and fulfillment methods."


3.5 No explicit handling of the "no return policy data at all" state

When return_policies is absent from a checkout, what should a platform display?

  • "This merchant supports return policy data, but didn't provide any for this checkout" → suggests no items are returnable
  • "This merchant doesn't support return policy data" → suggests platform should fall back to merchant URL

The Discovery section tells platforms to check the capability before rendering, which partially addresses this. But the spec should still explicitly say:

"When the Return Extension capability is advertised in discovery but return_policies is absent or empty in a specific checkout response, platforms MUST NOT infer the items are non-returnable. The merchant simply has no return policy data to expose via this protocol for this checkout."


3.6 Where is the terms link?

Legal return terms are typically lengthy and link out to a full policy page. The schema has no way to:

  • Link to a full policy document
  • Indicate "see merchant's terms for restrictions"
  • Disclose exceptions that can't be modeled (state-specific overrides, etc.)

Recommendation: Add an optional policy_url at the policy level:

"policy_url": {
  "type": "string",
  "format": "uri",
  "description": "URL to the merchant's full return policy document. Platforms SHOULD surface this link alongside the structured policy data so buyers can review complete terms."
}

Part 4 — Forward-Looking Architectural Notes

4.1 Return initiation is out of scope — make this explicit

This extension is pure pre-purchase disclosure. It does NOT model:

  • How a buyer initiates a return
  • How the platform receives an RMA number
  • How return labels are generated
  • How refunds are processed

This is the correct scope for v1, but the spec should explicitly state it AND reserve the contract for future extensions:

"This extension addresses pre-purchase disclosure of return policy only. Post-purchase return initiation, label generation, refund processing, and exchange execution are out of scope. A future dev.ucp.shopping.return_action extension may address these workflows; implementers MUST NOT extend this schema for those purposes."

This prevents future PRs from mutating this extension to add post-purchase semantics that don't belong here.


4.2 Consider whether returns belong at Product, not Checkout

A return policy is fundamentally a property of a product (or its category), not a property of a checkout. The checkout-time exposure is for buyer disclosure, but the authoritative source of return policy is the catalog.

Currently, every checkout response re-computes and re-sends the full return policy for every line item, even though the policy hasn't changed. This is wasteful for large carts.

Future direction (out of scope for this PR but worth noting in the spec):

A separate Product or Catalog capability could expose return policies at the catalog level, with the Return Extension on Checkout becoming a simple confirmation: "These items have been validated against current policies; here is the effective set."

For v1, the checkout-time exposure is fine. But the spec should acknowledge that product-level return policy disclosure is a likely future direction so that the Checkout extension doesn't become the de facto only path.

@venkatesh-ucp

venkatesh-ucp commented May 18, 2026

Copy link
Copy Markdown
Author

@prasad-stripe

How about adding a field like refund_type on return_policy (with well-known values such as original_payment, store_credit, replacement_only) would let an agent answer "what do I actually get back if I return this?" Which is distinct from how the item physically gets back to the merchant.

In my opinion, the current version of the spec tackles the replacement_only and store_credit portion of your comment via the exchanges_allowed field on the return policy. I see them as being tightly coupled because you won't be able to exchange the item without an implicit store credit. I'm happy to update the documentation to clarify that if needed.

That leaves us with refund to the original form of payment. That should be the default and I don't think it needs to be tracked separately. I also expect this to fit in more with the return initiation flow/capability and should be considered out of scope for this PR.

@sbeashwar

Copy link
Copy Markdown
Contributor

Read through the recent revisions - thoughtful set of fixes. A few points from an agent perspective:

1. Extend Order with return_policies[], not just Checkout.

Today return_policies is wired only to Checkout. The complete-checkout response carries them, but the Checkout session has a default 6h TTL (checkout.json:111) and the Order resource has no return_policies field. Order event webhooks deliver Order schema, so they don't carry the policies either.

Two distinct gaps:

a) Long-tail lookup. When the buyer asks "can I still return this?" days or weeks post-delivery, the Checkout is gone. Without state-mirroring at completion time, the agent has nothing to read.

b) Locked policy retrieval. amithanda's Part 3.1 says the locked-at-purchase policy "MUST be sourced from the Order resource (when available)" - that MUST only works if Order carries return_policies. Otherwise the platform either re-queries the merchant (gets current, possibly-changed policy) or mirrors locally (state burden).

Cleanest: extend Order with the same return_policies[]. Pull (GET /orders/{id}) and push (webhook payloads) both deliver the locked policy. This is a novel pattern in UCP - discount, fulfillment, buyer_consent extend Cart/Checkout only - but Return is the first extension whose primary use case is post-purchase, which makes the Order side natural.

2. Rename return_days -> return_period_in_days.

Current name parses ambiguously ("what does 30 return_days mean?"). Putting the unit in the name resolves it. Stripe uses trial_period_days, Shopify uses processing_time_in_days; same pattern.

3. Refund options are richer than exchanges_allowed captures.

Building on prasad-stripe's refund_type ask. The current boolean collapses several distinct policies:

  • Refund to original payment method (no exchange)
  • Refund to original payment + optional exchange
  • Store credit only
  • Gift card / merchant cash card (e.g., Costco-style)
  • Replacement only

All five render identically against exchanges_allowed. An agent asked "if I return this, do I get my money back?" cannot answer.

Suggest refund_methods[] (mirrors the existing methods[] shape):

"refund_methods": [
  { "type": "original_payment_method" },
  { "type": "store_credit" },
  { "type": "gift_card" },
  { "type": "replacement_only" }
]

Open string with well-known values, same convention as methods[].type and fee.type. Subsumes prasad-stripe's narrower ask plus merchant-specific cases the boolean can't express.

4. Drop the empty capability schemas.

The capability defines platform_schema and business_schema as empty allOf blocks. Comparing existing extensions: Fulfillment defines them with real config (supports_multi_group etc.); discount and buyer_consent don't define them at all. Return is the only one with empty boilerplate.

Return policies are pure response data; no pre-checkout negotiation flag is needed. Follow the discount/buyer_consent pattern - drop the empty definitions. If a real config field emerges later (e.g., international_returns_supported), add it then.

@venkatesh-ucp

Copy link
Copy Markdown
Author

@prasad-stripe

Created a new field called supported_resolutions field that addresses your use case and removed the exchanges_allowed field altogether since they achieve the same goal.

@venkatesh-ucp

Copy link
Copy Markdown
Author

@amithanda Thanks for the detailed comments! I've responded inline.

Part 1 — Schema Architecture

1.1 The relationship is inverted: items should reference policies, not the reverse

I have implemented this change. To provide more context as to why I picked the previous design. My assumption was that cart sizes are usually small. One or two items or whatever might be needed to meet the free shipping threshold. In that case linking the policy to the line item made more sense since the platform didn't have to deal with the mapping logic of going from a policy list to a line item nor did merchant have to do any work to make it available in that format.
In addition to that, working on merchant feeds, we've learnt that merchants usually have the data stored at the product level instead of at a more generic policy level so they found it easier to provide it to us in that format.

However, I think all of this changes if the cart is larger. The one example I could think of is a grocery store shopping cart or a cart that has line items/products of multiple different categories (electronics + home decor) with vastly different policies. I think this design gives most flexibility for those cases.

1.2 The schema can't express policy hierarchy or authoring intent

I have implemented this recommendation.

1.3 The policy primitive conflates policy definition with policy binding

This was implemented in 1.1 and a previous change.

1.4 Restocking fee modeling is awkward

The current schema places fee inside return_method. This forces a merchant who charges a flat $5 restocking fee regardless of return method to repeat it on every method:

"methods": [
  { "type": "in_store", "fee": { "type": "fixed_fee", "amount": 500, "display_text": "Restocking Fee" } },
  { "type": "by_mail", "fee": { "type": "fixed_fee", "amount": 500, "display_text": "Restocking Fee" } }
]

Conversely, a merchant who has "free returns by mail with a $5 restocking fee" can't express that — fee.type: free and fee.type: fixed_fee are mutually exclusive within one method.

Real-world fee structure for returns typically has two independent dimensions:

  • Logistics cost — who pays for shipping/handling (free, fixed, customer_responsibility)
  • Restocking fee — a separate charge against the refund amount

Recommendation: Split the fee model into logistics and restocking concerns:

"methods": [
  {
    "type": "by_mail",
    "shipping_cost": { "type": "free" },          // logistics
    "restocking_fee": { "amount": 500 }            // separate from logistics
  }
]

If splitting is too invasive for v1, at minimum the spec should acknowledge: "When a merchant charges a restocking fee independent of return method, businesses SHOULD apply the same fee object to all methods."

I have decided to leave this out of scope for this PR since this i think this is crossing the lines into returns initiation which will be handled soon in a follow up PR. I want to focus on what the user needs to know when they want to make a purchase decision and it is my opinion that as an user it is most important to understand if there is a restocking fee or whether it is their responsibility to pay for the shipping back. Calculating the shipping cost back ahead of time will not be a deal breaker, the fact that they have to pay for returns will be the deal breaker. Lastly, the merchant has a display text available to them to communicate this information if needed.

1.5 methods array doesn't model practical mail-return preconditions

type: by_mail says nothing about who arranges the carrier. In practice this is a critical distinction:

  • Merchant-provided label — the business issues a prepaid shipping label or RMA number
  • Self-arranged shipping — the buyer prints their own label and ships at their own cost
  • Carrier pickup — the merchant arranges courier pickup from buyer's address

These are all "by mail" but functionally very different. A buyer expecting a prepaid label who gets customer_responsibility will have a poor experience.

Recommendation: For by_mail methods, add an optional label_provided boolean or a more structured shipping_arrangement field:

{
  "type": "by_mail",
  "fee": { "type": "free" },
  "label_provided": true,
  "label_url_template": "..."  // optional, link to where buyer can generate label post-purchase
}

This crosses into post-purchase territory but the disclosure ("we provide a label vs. you ship yourself") is pre-purchase information that affects buyer decisions.

I'm keeping this out of scope for this PR since I agree it might be crossing into the post-purchase territory which will be covered in a follow up PR soon.

Part 2 — Granularity & Authoring

2.1 Site-wide vs. exception-based authoring

After moving to the map of return policies linked to the line items via keys, this suggestion is already implemented. I have not gone with allowing a default policy using a standardized default keyword because I feel that is more error prone and with the new architecture, the merchant can achieve this by defining a default keyed policy and use that key in all the line items.

2.2 No support for policy modifiers (promotions, loyalty, marketplace)

Add to the list of out of scope items.

2.3 Marketplace dynamics are not addressed

Add to the list of out of scope items.

Part 3 — Behavior Specification Gaps

I have applied all the six recommendations from this section.

Part 4 — Forward-Looking Architectural Notes

I've applied both the recommendations from this section.

Comment thread docs/assets/partner/endorsed/Affirm.svg
Comment thread source/schemas/shopping/return.json
Comment thread docs/specification/return.md
Comment thread source/schemas/shopping/return.json
@amithanda

Copy link
Copy Markdown
Contributor

Thanks @venkatesh-ucp for all the work here. This has come a long way and the latest revision is in great shape. A few things worth calling out as real improvements:

  • The inversion to a keyed return_policies registry with line_items[].return_policy_id references normalizes nicely. It removes the overlapping-assignment ambiguity, deduplicates shared policies, and matches the registry shape used elsewhere in UCP.
  • supported_resolutions cleanly folds refund semantics (original payment, store credit, gift card) and exchange/replacement into one open vocabulary, and using [] as the explicit final-sale signal is a nice touch.
  • The added behavior specs (policy locking, partial-quantity returns, absent-policy fallback, and the policy-state interpretation table) close most of the implementer-facing ambiguity that structured data alone would have left open.
  • reason and policy_url give agents what they need to actually explain a policy rather than just state it.

Remaining comments are narrow consistency items, not design problems.

@venkatesh-ucp

Copy link
Copy Markdown
Author

Thanks @venkatesh-ucp for all the work here. This has come a long way and the latest revision is in great shape. A few things worth calling out as real improvements:

  • The inversion to a keyed return_policies registry with line_items[].return_policy_id references normalizes nicely. It removes the overlapping-assignment ambiguity, deduplicates shared policies, and matches the registry shape used elsewhere in UCP.
  • supported_resolutions cleanly folds refund semantics (original payment, store credit, gift card) and exchange/replacement into one open vocabulary, and using [] as the explicit final-sale signal is a nice touch.
  • The added behavior specs (policy locking, partial-quantity returns, absent-policy fallback, and the policy-state interpretation table) close most of the implementer-facing ambiguity that structured data alone would have left open.
  • reason and policy_url give agents what they need to actually explain a policy rather than just state it.

Remaining comments are narrow consistency items, not design problems.

Thank you for all the reviews so far!

Comment thread docs/specification/return.md Outdated

- `line_items[].return_policy_id` — reference to the policy that applies to this item.
- `return_policies` — map of policy definitions keyed by ID, containing:
- `return_period_in_days` — the number of days allowed for the return.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If we do use duration, we should consider preferring JSON Schema's duration built-in type versus granularity post-fixing (i.e._in_days).

This is consistent with AIP-142 and create a more flexible API that can handle fractional day granularity as the protocol extends to other verticals.

Requires TC discussion, but we should likely add this guidance to our schema authoring guidelines.

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'm good with this. I can add it in if y'all have the discussion in the TC and agree on it or we can always do this and the suggestion in the below comment in a follow up PR.

Comment thread docs/specification/return.md
"description": "Cost structure for this return method. Clients MUST tolerate unknown values. Well-known values: `free` (merchant covers return costs), `fixed_fee` (flat fee charged, see amount), `customer_responsibility` (buyer arranges and pays for return shipping).",
"examples": ["free", "fixed_fee", "customer_responsibility"]
},
"amount": {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It is fairly common for restocking fees to structure as a percentage of the total cost rather than a fixed amount.

For example on Kohl's for freighted delivery:

- Shipping costs and surcharges are non-refundable.
- There is a 15% restocking fee unless the returned item is defective.

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.

Leaving this unresolved since it related to the above comment.

}
}
},
"return_fee": {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

To elaborate on @amithanda 's observation in 1.4, you can have this API be considerably more expressive for all parties if return_fee is return_fees and you split a new shape out (i.e. "return_fees": ReturnFee[]).

  • The ReturnFee shape can capture fee-type specific fields (i.e. amount for fixed_fee) through schema (subtyping, FixedReturnFee) rather than documentation.
  • You provide the business the option to convey more granular fee data. For example the return fee of $15 is attributable to a $5 shipping cost and a $10 bulky item restock.
  • You can represent "free" as an empty array.

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.

Per prior decisions, this fee is mostly part of the pre-purchase disclosure. It is mostly for display purposes and my goal has been to keep it as simple as possible and provide more flexibility to provide information to the merchant to make a decision. In this design, the platform is meant to mostly pass through this information to the user and use it for display purposes. All the complex behaviors around storing the data will be handled in the post-purchase PR.

For what you are asking, merchants can achieve this today using the existing schema in two robust ways:

  • Pre-computing the amount: Because return policies are exposed at checkout time where line item prices are fully established, the merchant backend will pre-compute the exact percentage fee in minor units (e.g., 15% of a $100 item = $15.00) and pass it as a fixed_fee with the explanation in display_text.
  • Disclosing the rule via display_text: If the fee cannot be pre-computed (e.g., due to pending freight calculation or potential defect inspection), the merchant can use customer_responsibility or fixed_fee (with base shipping) and explicitly state the percentage rule in display_text.
  • The display_text field provides the most flexibility in terms of messaging.

Example representation in existing schema:

"methods": [
  {
    "type": "by_mail",
    "fee": {
      "type": "fixed_fee",
      "amount": 1500,
      "display_text": "15% Restocking Fee ($15.00 pre-computed)"
    }
  }
]

"type": {
"type": "string",
"description": "Cost structure for this return method. Clients MUST tolerate unknown values. Well-known values: `free` (merchant covers return costs), `fixed_fee` (flat fee charged, see amount), `customer_responsibility` (buyer arranges and pays for return shipping).",
"examples": ["free", "fixed_fee", "customer_responsibility"]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

In the above model, customer_responsibility a VariableFee that has the description of "Customer assumed shipping cost." or the like.

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.

Leaving this unresolved since it related to the above comment.

Comment thread docs/specification/return.md Outdated
"enum": ["lifetime", "no_returns", "final_sale", "finite_window"],
"description": "The type of return window."
},
"return_days": {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Apologies for the missed message here, let me attempt to clarify the premise first.

At checkout a business may communicate a policy. For example, "free returns for 30 days." The Platform is poorly positioned to translate this into an actual deadline which is the most relevant piece of information for the customer post-purchase.

The platform is poorly positioned because in the existing schema, the only anchor provided to the platform is the time checkout occurred. But modern ecommerce is more complicated than that, for physical goods it may be (i) 30 days from purchase (ii) 30 days from fulfillment or (iii) 30 days from delivery. Amazon and Target use (iii), Sephora is (i).

This brings us to the options you outlined.

Option 1. We should avoid this as it forces the platform to calculate the returns deadline against the Policy which has insufficient fidelity. Providing the platform a referential hint that captured "duration from purchase/fulfillment/delivery" may be useful in any case but doesn't solve the undesirable outcome. In the case of fulfillment or delivery, it necessitates the platform track post-purchase notifications which is onerous.

Option 2. Allows the business to serve as the source of truth which is directionally aligned to the protocol. The Order becomes the authoritative conveyance for which items have which return deadlines for which line_items. This allows the business to provide a more contextualized signal than what was shown at Checkout time.

I'm aligned to Option II and it looks like the group has similarly weighed in — @sbeashwar called for extending Order with return_policies[] given the checkout TTL gap, and @amithanda's recommended spec prose already references the Order as the authoritative source. Is that going to be tracked as part of this PR or as follow-up?

Comment thread docs/specification/return.md
This extension adds a `return_policies` registry to Checkout and a reference on line items:

- `line_items[].return_policy_id` — reference to the policy that applies to this item.
- `return_policies` — map of policy definitions keyed by ID, containing:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

return_policies likely need to be polymorphic or use a oneOf to capture the period as either a duration or a deadline.

  • Duration: "30-day return window" (retail)
  • Deadline: "free cancellation until 2026-07-30T00:00:00Z" (travel, events)

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.

SGTM, pending TC alignment on introduction of Duration.

- Rename `return_days` to `return_period_in_days` for clarity.
- Replace `exchanges_allowed` boolean with `supported_resolutions` flat array of strings to support richer outcomes (e.g. store credit, gift card).
- Drop empty capability schemas (`dev.ucp.shopping.return`).
- Update documentation and examples to match schema changes.
@igrigorik igrigorik added this to the Working Draft milestone Jun 19, 2026

@igrigorik igrigorik left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Catching up, a lot of great points came up in the reviews. With the benefit of fresh eyes on the problem, I want to step back and reason through the properties of the domain we're modelling... bear with me, please! 🙇🏻

What a merchant can promise vs. what is resolved later

A merchant can publish a policy about returns ahead of time. However, fine-grained resolution (methods available, fees, refund destination, label source, who-pays-what) depends on inputs that don't yet exist at cart or checkout:

  • Buyer's reason (remorse vs defect, wrong item, didn't fit, ...)
  • Item condition (new / damaged in transit / worn)
  • Return ship-from location (distinct from the original ship-to)
  • Payment instrument actually used (split tenders, gift card, ...)
  • Buyer's resolution preference (refund / exchange / store credit)
  • Item vs order-level policies (order thresholds, bundles, bogos, ...)
  • ... the list goes on

I think this maps and suggests two distinct surfaces:

  • Pre-purchase policy (Catalog / Cart): roughly, "if you returned this under the worst case, here is what to expect."
  • Post-purchase resolution: "given this reason + condition + location + preference, the policy is..."

Most of the concerns raised in the review trace back to one root: the proposal places post-purchase-resolution fields (methods, fees, supported resolutions, refund destinations) on a pre-purchase surface, where they can't be reliably authored. The data needed to populate them doesn't yet exist, and the business policy is often complex and dynamic -- i.e. it's not something that a business would publish or enumerate as static facts.

Implication: I think the pre-purchase contract needs to be MUCH narrower than the proposal carries today, and post-purchase resolution needs a much richer negotiation surface (not a static policy). The same extension can extend and cover both, and we can and should tackle top of funnel first and separately.

This isn't just returns, we should aim to solve the meta "policy" problem

"Structured policy, per-item targetable, lifecycle tied to a purchase event" describes a class. Refund (return + cancellation), warranty, and subscription terms all share similar shape:

  • Each is a structured, machine-readable claim a merchant publishes ahead of time
  • Each can attach cart-wide or per-item
  • Each gets locked at purchase and travels with the order
  • Each has its own dynamic resolution exchange after the fact

I think we should step back and think how we can chisel out a reusable primitive rather than a returns s one-off. In other words, something like dev.ucp.shopping.policies, which can carry refund, warranty, subscription, and so on.

Discount capability has a shape that fits, and we should take inspiration from it

The discount extension already solved a very similar problem. discount.applied[] carries structured per-discount entries, and each entry's allocations[].path uses JSONPath to attribute the discount to specific line items, totals, or the cart structure. The key thing with that pattern is that it allows rich targeting and avoids duplication -- you can target the whole cart, you can target sublines, without repeating same data on each line.

Napkin sketch

Worth calling out that we already have links[] that carry policy information. links[] stays as it is — the always-present, untargeted URL-shaped fallback (privacy, ToS, FAQ, refund_policy URL). But, when dev.ucp.shopping.policies is negotiated, a new policies[] object (extends catalog, cart, checkout) can carry typed structured policies alongside it. Body fields are deliberately omitted here, focusing on the macro shape:

"line_items": [...],
...
"policies": [
  {
    "type": "refund",    // no applies_to means cart/catalog-wide default
    "id": "default",
    "url": "https://example.com/returns"
    // body fields (window, cancellation, ...) — TBD per type
  },
  {
    "type": "refund",
    "id": "engraved-final-sale",
    "applies_to": ["$.line_items[2]"],
    "final_sale": true
  },
  {
    "type": "warranty",
    "id": "std-warranty",
    "url": "https://example.com/warranty"
  }
]

Targeting reuses JSONPath against the catalog/cart/checkout structure, matching the convention established by discount.allocations[].path. Omit applies_to → applies cart-wide. final_sale: true is shown as a hint at the kind of upper-bound, pre-purchase-honest field that belongs in a body; the actual body schema per type, and conflict-resolution rules when targets overlap, we can define if the overall approach makes sense.


I'm deliberately omitting a lot of detail in the sketch above. Stepping back:

  1. I think we're trying to model facts that cannot be modelled accurately at catalog/cart/checkout. To be clear, I'm not saying we cannot express some structured properties about refund/return policies, but that we can't overreach at this stage because resolution is dynamic and requires context that we do not have.
  2. I would like to propose that we explore the generic construct for policies, as outlined above.

Would love to hear thoughts and feedback. Happy to put together a more detailed proposal for (2) if we think it's a good direction to explore.

@amithanda

Copy link
Copy Markdown
Contributor

@igrigorik Thanks for laying this out—these are really strong architectural points. I thought about the tension between providing a structured Return extension versus moving towards a generalized meta-policy/dynamic resolution model.

While I agree that the ultimate source of truth for financial math and eligibility lies in the merchant's backend, I want to push back slightly on abstracting Returns into a generic policy container. Here are some counter-arguments that we should consider, as always looking forward to a good discussion around this 😄 :

1. The Upfront Promise vs. Dynamic Resolution
While it's absolutely true that exact return eligibility (e.g., "Eligible until Oct 14th" based on delivery date, Target Circle membership, or item condition) can only be resolved dynamically post-purchase, the upfront promise is a massive conversion driver at checkout.
If we look at how top retailers (Walmart, Target, Kohl's) structure their PDPs and Checkout, they rely heavily on displaying baseline boundaries (e.g., "Free 90-Day Returns", "30-Days for Electronics"). We need enough structure in the schema for the client to confidently render the promise, even if the resolution is deferred.

2. Are Returns, Warranties, and Subscriptions really the same "Meta-Policy"?
Grouping these under a generic policies array risks classic over-abstraction. While they sound similar at a high level, their semantics and data models are vastly different:

  • Returns are a post-purchase right to reverse a transaction (Initiate -> Label -> Ship -> Refund).
  • Warranties are often standalone products with an additional upfront cost, deductibles, third-party fulfillment (e.g., Asurion), and conditional attributes (accidental damage vs. defect).
  • Subscriptions are recurring financial contracts requiring tokenized payments and state lifecycle management (Pause/Cancel/Resume).
    If we create a single "meta-policy" object, it will either become bloated with irrelevant fields or too sparse to be useful without custom extensions. Returns deserve to be a first-class citizen because of their unique, universal role in commerce.

3. The Discount Analogy Breaks Down on "JSONPath Attribution"
The parallel to how UCP handles Discounts via JSONPath is an interesting one, but I think the analogy breaks down structurally. Discounts are generally occasional and selective—they apply to specific line items or the cart total, making a detached JSONPath attribution model highly effective.
Return policies, however, are universal. Nearly every physical line item in a cart requires a return policy to be defined (even if that policy is "final sale/no returns allowed"). If we use a generalized policy array that relies on JSONPath to map return rules back to individual items, the payload will quickly become clunky. We would either end up with massive, brittle JSONPath strings to cover different item categories, or heavy duplication. Embedding a primitive natively within the line item hierarchy is significantly cleaner and avoids the overhead of detached mapping for an attribute that is universally required.

@igrigorik

Copy link
Copy Markdown
Contributor

If we look at how top retailers (Walmart, Target, Kohl's) structure their PDPs and Checkout, they rely heavily on displaying baseline boundaries (e.g., "Free 90-Day Returns", "30-Days for Electronics"). We need enough structure in the schema for the client to confidently render the promise, even if the resolution is deferred.

Agree. The meta point is that we can define a polymorphic base that will allow us to model different types of policies via shared mechanism. This does not mean that we cannot or should not define structured shapes for specific policies; we can model a refund/return policy with appropriate fidelity that makes sense. This is similar to how we modelled messages contract: a single primitive with distinct types, instead of defining distinct error/warning/info types. This enables simpler and more scalable implementation for both sides - e.g. a description field can carry generic string that any platform can render even if it may not know how to reason about a policy type it doesn't model natively.

Warranties are often standalone products with an additional upfront cost, deductibles, third-party fulfillment (e.g., Asurion), and conditional attributes (accidental damage vs. defect).
Subscriptions are recurring financial contracts requiring tokenized payments and state lifecycle management (Pause/Cancel/Resume).

You're right that those can be products / line-items, but that's not what this primitive is describing. The job of policy object is to highlight applicable policies for negotiated products -- e.g. the items you added to your cart all have 30-day return and 1-year warranty. When you add a subscription to your cart, a subscription cancellation policy appears in the set.

The parallel to how UCP handles Discounts via JSONPath is an interesting one, but I think the analogy breaks down structurally. Discounts are generally occasional and selective—they apply to specific line items or the cart total, making a detached JSONPath attribution model highly effective. Return policies, however, are universal. If we use a generalized policy array that relies on JSONPath to map return rules back to individual items, the payload will quickly become clunky.

I disagree. It's very common to have cart-wide discounts (most common type, in fact), and policies frequently differ from item to item based on type of good, season/promo status, etc. One of the big reasons we adopted jsonpath approach for discounts is precisely because it allowed us to express these configurations in a much more compact way - a cart-wide discount is a single object, instead of being replicated on every object in the cart; a targeted discount is a single object just as it would be on the line itself. I agree that indirection has a cost, but that's a design pattern and decision we've already adopted in many places, and I think it fits here as well.


@amithanda stepping back, I think we're actually very close in what we're saying from both sides, I'm just arguing for reusing the established design patterns and contracts in UCP. First, we need to more clearly separate (as you already suggested in earlier review) pre-purchase vs. return action operations (methods, fees, supported resolutions are return action primitives), and then examine how the pre-purchase facts can be expressed in a well-modeled and extensible way.

@igrigorik

Copy link
Copy Markdown
Contributor

@venkatesh-ucp @amithanda draft of an alternate design sketched out above: #572 — ptal.

@damaz91 damaz91 added gov:needs-gc-review status:needs-triage Signal that the PR is ready for human triage and removed status:needs-triage Signal that the PR is ready for human triage labels Jul 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants