Skip to content

Commit 87da12d

Browse files
committed
feat: add Saved Payment Instruments extension (dev.ucp.shopping.saved_instruments)
Adds a response-only extension that surfaces the authenticated user's saved payment instruments on cart and checkout responses under `payment.saved_instruments[]`. Identity-gated by a new `dev.ucp.shopping.saved_instruments:read` scope listed in `identity_linking.config.scopes`. Mirrors the loyalty extension pattern. Key design choices: - Response-only (`ucp_request: omit`) — flows merchant → platform only - No credential on the wire: schema enforces `"credential": { "not": {} }`. Platforms select via `payment.instruments[].id`; the business mints a checkout-bound TokenCredential per its declared payment handler. - Per-instrument `default: true` flag instead of inferring from list order - Business-scoped IDs; cross-merchant portability explicitly out of scope
1 parent 0ea2f36 commit 87da12d

3 files changed

Lines changed: 427 additions & 0 deletions

File tree

Lines changed: 341 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,341 @@
1+
<!--
2+
Copyright 2026 UCP Authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
-->
16+
17+
# Saved Payment Instruments Extension
18+
19+
## Overview
20+
21+
The Saved Payment Instruments extension surfaces the set of payment
22+
instruments the authenticated user has previously saved with a business
23+
on cart and checkout responses. It lets platforms — especially AI
24+
agents — recognize and present "your saved methods" without requiring
25+
the user to re-enter payment information for every transaction.
26+
27+
Without this extension, the only way for a platform to know what is
28+
saved is to inspect what the business pre-populates in
29+
`payment.instruments[]` on a cart or checkout. That conflates "available
30+
for this transaction" with "saved for this user" and offers no way to
31+
mark which instrument is the user's default.
32+
33+
Core use cases:
34+
35+
* **Recognition:** "Use your saved Visa ending in 4242?" surfaced as the
36+
default suggestion when the user opens a cart.
37+
* **Selection:** Render the full saved set ("Visa ****4242 (default),
38+
Mastercard ****5454") so the user picks rather than re-enters.
39+
* **Expiration awareness:** Surface "your saved card expires next month"
40+
inline with cart/checkout.
41+
42+
A standalone capability that allows enumeration and management of saved
43+
instruments **outside any cart/checkout context** (pre-cart enumeration,
44+
set-default, delete) is intentionally deferred — see
45+
[Open Questions](#open-questions).
46+
47+
## Key Concepts
48+
49+
**Identity-gated.** The extension activates only with a user-identity
50+
bearer token obtained via the
51+
[Identity Linking Capability](identity-linking.md). Businesses MUST list
52+
the scope `dev.ucp.shopping.saved_instruments:read` under
53+
`dev.ucp.common.identity_linking.config.scopes`. Without that scope the
54+
extension MUST be omitted from responses.
55+
56+
**Response-only.** The extension is decorated with `ucp_request: omit`
57+
on every extended capability — `saved_instruments` flows merchant →
58+
platform only. Platforms MUST NOT send the field in cart/checkout
59+
requests; doing so has no defined effect.
60+
61+
**Display-only on the wire.** Saved instruments carry **no credential
62+
material**. The schema explicitly forbids a `credential` field
63+
(`"credential": { "not": {} }`). To use a saved instrument at checkout
64+
time, the platform sets `payment.instruments[].id` to the saved
65+
instrument's `id`; the business resolves it against its internal vault
66+
and mints a checkout-bound `TokenCredential` per its declared payment
67+
handler. See the [Tokenization Guide](tokenization-guide.md).
68+
69+
**Business-scoped IDs.** A `saved_instrument.id` is meaningful only to
70+
the issuing business; cross-merchant portability is out of scope.
71+
72+
**Why not identity linking?** Identity linking authenticates *who the
73+
user is*; it does not enumerate per-business state. Saved instruments
74+
have their own lifecycle (additions, expirations, deletions) and their
75+
own consent dimension, so they belong in a payments-domain surface gated
76+
by an identity-linking-declared scope. This mirrors the pattern
77+
established by [`dev.ucp.common.loyalty`](loyalty.md).
78+
79+
## Discovery
80+
81+
Businesses advertise the extension by adding it to their UCP profile and
82+
listing the read scope under identity linking:
83+
84+
<!-- ucp:example schema=profile def=business_schema extract=$.ucp target=$.ucp -->
85+
```json
86+
{
87+
"ucp": {
88+
"version": "{{ ucp_version }}",
89+
"capabilities": {
90+
"dev.ucp.common.identity_linking": [
91+
{
92+
"version": "{{ ucp_version }}",
93+
"spec": "https://ucp.dev/{{ ucp_version }}/specification/identity-linking",
94+
"schema": "https://ucp.dev/{{ ucp_version }}/schemas/common/identity_linking.json",
95+
"config": {
96+
"scopes": {
97+
"dev.ucp.shopping.saved_instruments:read": {
98+
"description": "View payment instruments you've saved with this business."
99+
}
100+
}
101+
}
102+
}
103+
],
104+
"dev.ucp.shopping.cart": [
105+
{
106+
"version": "{{ ucp_version }}",
107+
"spec": "https://ucp.dev/{{ ucp_version }}/specification/cart",
108+
"schema": "https://ucp.dev/{{ ucp_version }}/schemas/shopping/cart.json"
109+
}
110+
],
111+
"dev.ucp.shopping.checkout": [
112+
{
113+
"version": "{{ ucp_version }}",
114+
"spec": "https://ucp.dev/{{ ucp_version }}/specification/checkout",
115+
"schema": "https://ucp.dev/{{ ucp_version }}/schemas/shopping/checkout.json"
116+
}
117+
],
118+
"dev.ucp.shopping.saved_instruments": [
119+
{
120+
"version": "{{ ucp_version }}",
121+
"extends": [
122+
"dev.ucp.shopping.cart",
123+
"dev.ucp.shopping.checkout"
124+
],
125+
"spec": "https://ucp.dev/{{ ucp_version }}/specification/saved-instruments",
126+
"schema": "https://ucp.dev/{{ ucp_version }}/schemas/shopping/saved_instruments.json"
127+
}
128+
]
129+
}
130+
}
131+
}
132+
```
133+
134+
**Dependencies:**
135+
136+
* [Identity Linking Capability](identity-linking.md) — required for user
137+
authentication and scope declaration.
138+
* [Cart Capability](cart.md) and/or [Checkout Capability](checkout.md)
139+
at least one MUST be advertised; the extension decorates these.
140+
141+
## Schema
142+
143+
### Entities
144+
145+
#### Saved Instrument
146+
147+
{{ extension_schema_fields('saved_instruments.json#/$defs/saved_instrument', 'saved_instruments') }}
148+
149+
#### Saved Instruments
150+
151+
{{ extension_schema_fields('saved_instruments.json#/$defs/saved_instruments', 'saved_instruments') }}
152+
153+
## Behavior
154+
155+
### Activation
156+
157+
The extension activates only when the cart or checkout request carries a
158+
valid user-identity bearer token that includes the
159+
`dev.ucp.shopping.saved_instruments:read` scope. When activated, the
160+
business MUST populate `payment.saved_instruments` with the set of
161+
instruments owned by the authenticated user.
162+
163+
When the scope is absent or the bearer is missing, the business MUST
164+
omit the `payment.saved_instruments` field. An empty array (`[]`) is
165+
reserved for "scope granted, user has no saved instruments" — distinct
166+
from "extension not active."
167+
168+
### Default instrument
169+
170+
At most one entry in `payment.saved_instruments` MAY have `default: true`.
171+
If no entry is marked default, the business has no recorded default
172+
preference for this user; platforms SHOULD prompt the user to pick
173+
rather than infer a default from list ordering.
174+
175+
The default flag is informational on this surface — it is not a
176+
selection. To use the default instrument at checkout, the platform
177+
copies its `id` into `payment.instruments[]` like any other selection.
178+
179+
### Relationship with `payment.instruments[]`
180+
181+
`payment.instruments[]` and `payment.saved_instruments` answer different
182+
questions:
183+
184+
| Field | Question answered |
185+
| :---------------------------- | :---------------------------------------------------------------------- |
186+
| `payment.instruments[]` | What instruments are eligible (or selected) for *this* cart/checkout? |
187+
| `payment.saved_instruments` | What instruments has *this user* saved with this business overall? |
188+
189+
The two sets MAY overlap (a saved instrument is eligible for this
190+
checkout) or diverge (a saved card is below the cart total minimum, or a
191+
business pre-fills a guest instrument the user has not saved). Platforms
192+
SHOULD reconcile by `id` — the same identifier appears in both when an
193+
instrument is both saved and eligible.
194+
195+
### Credential is forbidden on this surface
196+
197+
The schema sets `"credential": { "not": {} }` on saved instruments to
198+
make the contract explicit: this extension never carries credential
199+
material. Businesses MUST resolve a selected `saved_instrument.id` to a
200+
usable credential at checkout time, through their declared payment
201+
handler — typically by minting a checkout-bound `TokenCredential` per
202+
the [Tokenization Guide](tokenization-guide.md).
203+
204+
## Use Cases and Examples
205+
206+
### Listing saved instruments on cart creation
207+
208+
The platform creates a cart with a user-identity bearer token that
209+
carries the `:read` scope. The business returns the cart plus the
210+
user's saved set under `payment.saved_instruments`.
211+
212+
=== "Request"
213+
214+
<!-- ucp:example schema=shopping/cart op=create direction=request -->
215+
```json
216+
{
217+
"line_items": [
218+
{ "item": { "id": "prod_1" }, "quantity": 1 }
219+
]
220+
}
221+
```
222+
223+
=== "Response"
224+
225+
```json
226+
{
227+
"ucp": {
228+
"version": "{{ ucp_version }}",
229+
"capabilities": {
230+
"dev.ucp.shopping.cart": [{"version": "{{ ucp_version }}"}],
231+
"dev.ucp.shopping.saved_instruments": [{"version": "{{ ucp_version }}"}]
232+
}
233+
},
234+
"id": "cart_abc",
235+
"currency": "USD",
236+
"line_items": [
237+
{
238+
"id": "li_1",
239+
"item": { "id": "prod_1", "title": "T-Shirt", "price": 2000 },
240+
"quantity": 1,
241+
"totals": [
242+
{"type": "subtotal", "amount": 2000},
243+
{"type": "total", "amount": 2000}
244+
]
245+
}
246+
],
247+
"totals": [
248+
{"type": "subtotal", "amount": 2000},
249+
{"type": "total", "amount": 2000}
250+
],
251+
"payment": {
252+
"saved_instruments": [
253+
{
254+
"id": "msi_01H8YN...",
255+
"handler_id": "merchant_vault",
256+
"type": "card",
257+
"default": true,
258+
"display": {
259+
"brand": "visa",
260+
"last_digits": "4242",
261+
"expiry_month": 11,
262+
"expiry_year": 2027
263+
}
264+
},
265+
{
266+
"id": "msi_01H8YP...",
267+
"handler_id": "merchant_vault",
268+
"type": "card",
269+
"display": {
270+
"brand": "mastercard",
271+
"last_digits": "5454",
272+
"expiry_month": 4,
273+
"expiry_year": 2026
274+
}
275+
}
276+
]
277+
}
278+
}
279+
```
280+
281+
### Using a saved instrument at checkout
282+
283+
To use the default Visa, the platform sets
284+
`payment.instruments[0].id` to the saved instrument's `id`. The business
285+
resolves it against its vault and continues the standard checkout flow.
286+
287+
```json
288+
{
289+
"payment": {
290+
"instruments": [
291+
{
292+
"id": "msi_01H8YN...",
293+
"handler_id": "merchant_vault",
294+
"type": "card",
295+
"selected": true
296+
}
297+
]
298+
}
299+
}
300+
```
301+
302+
## Implementation Guidelines
303+
304+
* `saved_instrument.id` values MUST be opaque (not derived from
305+
credential data), stable across sessions, and meaningful only to the
306+
issuing business.
307+
* Businesses MUST scope the returned set to instruments owned by the
308+
authenticated user. The set MUST NOT include other users'
309+
instruments under any circumstance.
310+
* Platforms SHOULD NOT cache the saved set across sessions without
311+
re-fetching on each cart/checkout that surfaces it — the saved set
312+
is mutable (expirations, deletions, additions).
313+
* Saved-instrument responses MUST NOT carry a `credential` field. The
314+
schema enforces this with `"credential": { "not": {} }`;
315+
implementations should additionally reject any in-flight credential
316+
on this surface as a defense-in-depth measure.
317+
318+
## Open Questions
319+
320+
* **Pre-cart enumeration and CRUD:** This extension surfaces the saved
321+
set only on cart/checkout responses. A separate standalone
322+
capability for pre-cart enumeration (list outside a transaction
323+
context) and management (set-default, delete) may be added in a
324+
follow-up if implementer demand emerges.
325+
* **Saved addresses:** The same identity-linking-gated, response-only
326+
shape would extend cleanly to saved shipping/billing addresses
327+
(`dev.ucp.shopping.saved_addresses`). The convention established
328+
here is intended to make that addition mechanical.
329+
330+
## Out of Scope
331+
332+
* **Adding a new saved instrument.** Saving occurs via the business's
333+
existing UX (typically opt-in at checkout completion) or via the
334+
business's site. This extension does not define a write path.
335+
* **Updating a saved instrument** (e.g., billing address). Out of scope
336+
for v1.
337+
* **Cross-merchant credential portability.** Using a card saved at
338+
Merchant A on Merchant B requires a platform-vault model that is
339+
distinct from merchant-side enumeration.
340+
* **Raw PAN handling.** Saved instruments never carry raw credentials;
341+
PCI scope stays inside the business's vault.

mkdocs.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ nav:
5353
- Discounts Extension: specification/discount.md
5454
- Fulfillment Extension: specification/fulfillment.md
5555
- Loyalty Extension: specification/loyalty.md
56+
- Saved Payment Instruments Extension: specification/saved-instruments.md
5657
- Split Payments Extension: specification/split-payments.md
5758
- Cart Capability:
5859
- Overview: specification/cart.md
@@ -323,6 +324,11 @@ plugins:
323324
memberships, tiers, benefits, and reward earning forecasts while
324325
managing provisional eligibility claims and enforcing
325326
data-minimization privacy rules.
327+
- specification/saved-instruments.md: >-
328+
Saved Payment Instruments Extension, surfacing the authenticated
329+
user's previously-saved payment instruments on cart and checkout
330+
responses as display-only metadata gated by the
331+
`dev.ucp.shopping.saved_instruments:read` identity-linking scope.
326332
Cart Capability:
327333
- specification/cart.md: >-
328334
Pre-purchase Cart Capability, detailing item collection, state

0 commit comments

Comments
 (0)