|
| 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. |
0 commit comments