feat: shopping permalink capability#523
Conversation
Add `dev.ucp.shopping.permalink`: a browser-addressable shopping intent that
initializes shopping state from a URL and resolves with a `303` redirect to a
buyer-facing destination. A permalink is a GET browser navigation, not a REST
API operation; loading one never places an order, charges payment, or completes
checkout.
Design
The capability deliberately owns almost nothing. It defines exactly one control
parameter, `continue_to`, and zero shopping-state fields of its own. Every
data-carrying query parameter is an existing UCP field path written as a JSON
Pointer with the leading `/` omitted (e.g. `buyer/email`, `context/postal_code`,
`discounts/codes/0`, `line_items/0/quantity`), resolved against the Business's
Cart or Checkout schema. The payment-handler preference is modeled as a core
`context.payment_handlers` field -- a provisional, buyer-supplied hint, peer to
`context.currency`/`language` -- rather than a permalink-owned field, so it
propagates to checkout, cart, and catalog through their existing `context`
reference and needs no permalink-specific URL handling.
Discovery
Businesses advertise `dev.ucp.shopping.permalink` with `config.endpoint`, an
absolute HTTPS endpoint. Platforms may advertise support with no config, so the
capability participates in normal capability negotiation.
URL shape
{endpoint}/{items}?{query}
- Compact item path: comma-separated `item_id_token:quantity` pairs. A raw token
is used when the identifier matches `[A-Za-z0-9._-]+`; otherwise it is `~` plus
base64url-without-padding of the UTF-8 identifier, which avoids fragile
percent-encoded path separators such as `%2F`.
- Query: UCP field paths, the `continue_to` control, and non-UCP parameters.
continue_to
A same-origin destination preference. A Business MUST validate it server-side --
percent-decode it, reject any URL scheme, backslash, whitespace, control
character, or leading `//`, resolve it against the endpoint origin, re-confirm
same-origin, and emit a canonical `Location` -- falling back to a default
destination on failure.
Resolution and safety
A handled request resolves with `303 See Other`; the response MUST set
`Cache-Control: no-store` and a no-referrer policy, and permanent redirects
(`301`/`308`) are forbidden. Permalink inputs are untrusted: a Business applies
them only to fields its schema accepts as input, so server-owned or derived
fields (totals, currency, status, identifiers, order) are never set from the
URL, and a Business MUST NOT echo credentials or other secrets onto redirect
destinations.
Examples
Single item, default purchase:
https://merchant.example/buy/sku_123:1
Campaign link with discount, continuation, and attribution:
https://merchant.example/buy/sku_123:1,sku_456:2?continue_to=/collections/spring&discounts/codes/0=SPRING10&attribution/utm_source=email
Buyer-directed link with a gid-encoded variant and a payment-handler preference
(`~Z2lk...` decodes to `gid://shopify/ProductVariant/70881412`):
https://merchant.example/buy/sku_kit:3,~Z2lk...NDEy:2?buyer/email=alice%40foo.com&context/payment_handlers/0=com.example.wallet
Artifacts
- source/schemas/shopping/permalink.json -- capability schema (config plus
platform/business/response declarations).
- source/services/shopping/permalink.openapi.json -- browser redirect binding
(route shape and 303/4XX responses; the specification remains normative for
encoding, query partitioning, and safety rules).
- source/schemas/shopping/types/context.json -- adds `payment_handlers`.
- docs/specification/permalink.md -- normative specification.
- mkdocs.yml, .cspell/custom-words.txt -- navigation, llms metadata, dictionary.
Governance
Adds the optional, additive `context.payment_handlers` field to the core
`context` type (used by cart, checkout, and catalog). Backward-compatible, but
flagged for TC review alongside the new capability.
Non-normative note; no schema change (https-only stands, which is also what makes Universal Links / App Links work).
jamesandersen
left a comment
There was a problem hiding this comment.
Thanks for the note on universal links
| }, | ||
| "payment_handlers": { | ||
| "type": "array", | ||
| "description": "Buyer-preferred payment handlers in priority order (most preferred first). Each provided value SHOULD map to a handler advertised in the Business profile's `ucp.payment_handlers`. The Business SHOULD use it to preselect or prioritize that handler and MAY ignore unavailable or ineligible values; unrecognized values MUST be ignored without error.", |
There was a problem hiding this comment.
high-level product concern here: A payment handler's objective is to spec out how to move a credential from a provider or platform owned experience OR tokenizer and into the business.
This modelling makes sense for experience strict handlers: think google pay, apple pay, an LPM like iDEAL.
Where this modelling breaks down is for a handler for a tokenizer such as Adyen, which supports many different instrument types for headless tokenization, where the important preference is not targeting the Adyen handler but instead targeting the Adyen handler with the card instrument, as thats the UX the platform would build. Example: Adyen handler, advertising support for cards and manual bank entry. The objective of the permalink is likely to target the manual bank entry form the platform would render, not Adyen in general.
To this end, I'd propose adding one extra optional layer to this:
payments: [{handler: "com.adyen", instruments: ["card", "bank"]}, {handler: "com.stripe"}].
To reduce complexity, I am not advocating for the ability to advertise preference just by instrument type, because speaking to a preference of tokenizer -> type is always acceptable and the hierarchy should be respected. I also avoid complex expressions such as preference for Stripe over Adyen for cards but Adyen over Stripe for banks at this time; lets spec against duplicate handler keys for simplicity.
By keeping this as an array of objects (as opposed to a hash keyed by handler name) we could support the above later if we wanted to, and we allow handlers to declare their preference order without enumerating an instrument set.
Permalinks are a popular, well-established way to turn a single shareable URL into a pre-loaded cart or checkout. The link encodes the items — and often a discount, a destination, and some buyer prefill — directly, so a buyer taps it and lands at checkout with the right state already in place. They show up all across the funnel: email and SMS campaigns, social and influencer links, paid ads and landing pages, QR codes on print and packaging, "buy now" and share-cart buttons, bundle offers, etc. The whole point is friction reduction — replace "browse, find, add, configure" with one tap.
Most commerce platforms already offer some flavor of cart or checkout permalink, but each is platform-specific by construction: a proprietary origin, a platform-specific item-ID scheme, and a bespoke prefill vocabulary. A common shape is a cart permalink like
/cart/{item_id}:{quantity}with a?discount=parameter and platform-specific prefill fields. There is no cross-merchant, agent-addressable, schema-typed equivalent — nothing an AI agent, marketplace, or campaign tool can construct once and point at any UCP business.This capability (
dev.ucp.shopping.permalink) generalizes the permalink pattern into the protocol. It keeps the ergonomics — compact item path, a discount, a destination — but expresses prefill as typed UCP field paths resolved against the business's own Cart/Checkout schema, and replaces platform-specific behavior with a single redirect contract.Design
The capability leans on existing UCP schemas, in a future-proof way.
It defines one control parameter,
continue_to, and zero shopping-state fields of its own. Every data-carrying query parameter is an existing UCP field path written as a JSON Pointer with the leading/omitted —buyer/email,context/postal_code,discounts/codes/0,line_items/0/quantity— resolved against the business's Cart or Checkout schema.The payment-handler preference is modeled as a new core
context.payment_handlersfield — a provisional, priority-ordered buyer hint, peer tocontext.currency/language— rather than a permalink-owned field, so it propagates to checkout, cart, and catalog through their existingcontextreference and needs no permalink-specific handling.Discovery
Businesses advertise
dev.ucp.shopping.permalinkwithconfig.endpoint, an absolute HTTPS endpoint.{ "ucp": { "capabilities": { "dev.ucp.shopping.permalink": [ { "version": "…", "spec": "…", "schema": "…", "config": { "endpoint": "https://merchant.example/buy" } } ] } } }URL shape
item_id_token:quantitypairs. A raw token is used when the identifier matches[A-Za-z0-9._-]+; otherwise it is~plus base64url-without-padding of the UTF-8 identifier, which avoids fragile percent-encoded path separators such as%2F.continue_tocontrol, and non-UCP parameters (preserved on the redirect by default when safe).Playground & examples: https://ucp-permalink.vercel.app
Checklist