feat: shopping permalink capability#523
Open
igrigorik wants to merge 6 commits into
Open
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
approved these changes
Jun 21, 2026
jamesandersen
left a comment
Contributor
There was a problem hiding this comment.
Thanks for the note on universal links
5 tasks
raginpirate
reviewed
Jun 26, 2026
Reshape the field from a flat array of handler keys into an array of objects:
context.payment_handlers: ["com.adyen", ...]
-> context.payment: [{ "handler": "com.adyen", "types": ["bank","card"] }, ...]
- `handler` (required) is the `ucp.payment_handlers` registry key; `types`
(optional) is a priority-ordered list aligned with the handler's advertised
`payment_instrument.type` values (open vocabulary; unrecognized ignored).
- Named `payment`, not `payment_handlers`: context.payment is the buyer's
payment *preference*, mirroring how context.currency is a preference echo of
an authoritative binding — a distinct layer, so its shape need not match
checkout.payment.
Update the permalink examples and the OpenAPI query-semantics hint to the new
context/payment/N/handler form.
Resolve two conflicts created by main's refactors: - .cspell/custom-words.txt: main alphabetized the dictionary; re-insert prefetchers, unparseable, userinfo in sorted positions. - context.json: main moved context fields into an allOf composition; the auto-merge misplaced the new context.payment field (broke JSON). Re-add payment under allOf[1].properties after eligibility.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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