Skip to content

feat: shopping permalink capability#523

Open
igrigorik wants to merge 4 commits into
mainfrom
feat/permalinks
Open

feat: shopping permalink capability#523
igrigorik wants to merge 4 commits into
mainfrom
feat/permalinks

Conversation

@igrigorik

Copy link
Copy Markdown
Contributor

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_handlers field — a provisional, priority-ordered buyer 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 handling.

Discovery

Businesses advertise dev.ucp.shopping.permalink with config.endpoint, an absolute HTTPS endpoint.

{
  "ucp": {
    "capabilities": {
      "dev.ucp.shopping.permalink": [
        { "version": "", "spec": "", "schema": "", "config": { "endpoint": "https://merchant.example/buy" } }
      ]
    }
  }
}

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 (preserved on the redirect by default when safe).

Playground & examples: https://ucp-permalink.vercel.app


Checklist

  • Capability: New schemas (Discovery, Cart, etc.) or extensions. (Requires Maintainer approval)
  • I have followed the Contributing Guide
  • I have updated the documentation (if applicable).
  • My changes pass all local linting and formatting checks.

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.
@igrigorik igrigorik self-assigned this Jun 17, 2026
@igrigorik igrigorik requested review from a team as code owners June 17, 2026 20:47
@igrigorik igrigorik added the TC review Ready for TC review label Jun 17, 2026
@ptiper ptiper removed their request for review June 18, 2026 10:59
@igrigorik igrigorik added this to the Working Draft milestone Jun 19, 2026
Comment thread docs/specification/permalink.md
Comment thread docs/specification/permalink.md
Comment thread source/schemas/shopping/permalink.json
   Non-normative note; no schema change (https-only stands, which is also what
   makes Universal Links / App Links work).

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

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.",

@raginpirate raginpirate Jun 26, 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.

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.

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.

3 participants