Developer quickstart
A whole UCP purchase step by step — curl, JavaScript and Python, from the shop's profile to handing the checkout to the buyer.
On this page
- Before you start
- A shop with UCP switched on
- Your platform profile
- Headers
- The calls
- Setup for the snippets
- Step 1: discover the shop
- Step 2: search the catalog
- Step 3: create a cart
- Step 4: update the cart
- Step 5: turn the cart into a checkout
- Step 6: add the buyer and the shipping
- Step 7: hand the buyer over
- Step 8: wait for the order
- Complete scripts
- What can go wrong
This chapter takes one purchase through a Monokaido shop from the first call
to the order. You read the shop's profile, search its catalog, build a cart,
turn it into a checkout, add the buyer and the shipping, give the buyer the
continue_url and learn that the order was placed. Each step shows the exact
request, headers included, in curl, JavaScript (Node 20, built-in fetch) and
Python 3 (requests), followed by the shop's answer. At the end are
two complete scripts that run the whole flow with retries
and polling.
The examples use the fictional shop https://shop.example.com, a Slovak bike
shop with prices in EUR, and the platform profile
https://platform.example.com/.well-known/ucp. The JSON answers are the
examples of the API reference, produced by
Monokaido's own code from sample data, so their shape is exactly what a real
shop returns. Ids, prices and texts differ from shop to shop.
Agent / platform → Shop (UCP API):
GET /.well-known/ucpReads the shop’s business profile: the protocol version, the REST endpoint and the capabilities the shop offers. No headers needed; the answer may be cached for five minutes.
Agent / platform → Shop (UCP API):
POST /ucp/v1/catalog/searchSearches the shop’s catalog (lookup and product read items by id). Every /ucp/v1 call carries the UCP-Agent header with the platform’s profile URL.
Agent / platform → Shop (UCP API):
POST /ucp/v1/cartsCreates a cart the shop prices — its own prices, stock and discounts. A cart lives 7 days from its last change.
Agent / platform → Shop (UCP API):
POST /ucp/v1/checkout-sessionsincompleteCreates a checkout from the cart (cart_id) or straight from line_items. While something is missing its status is incomplete.
Agent / platform → Shop (UCP API):
PUT /ucp/v1/checkout-sessions/{id}requires_escalationAdds the buyer’s e-mail and, with the fulfillment extension, the address and the shipping option. When nothing is missing: requires_escalation with the message payment_required and a continue_url.
Agent / platform → Buyer (browser):
continue_urlGives the buyer the continue_url. Monokaido takes no payment through UCP, and POST …/complete does not complete the checkout.
Buyer (browser) → Shop (UCP API):
GET /ucp/continue/{token}Opens continue_url on the shop’s domain. The page moves the lines and the discount code into the buyer’s cart in this browser and opens the shop’s checkout.
Buyer (browser) → Shop (UCP API):
/checkoutFinishes the purchase on the shop’s checkout, payment included — like any other customer.
Agent / platform → Shop (UCP API):
GET /ucp/v1/checkout-sessions/{id}completedOnce an order holds at least one of the session’s items, the next read of the checkout returns completed with the order. There are no webhooks: read to learn the outcome.
A cart’s continue_url (no checkout) works the same way, except that it opens the shop’s cart instead of the checkout.
Before you start
A shop with UCP switched on
You need the domain of a shop whose merchant has switched UCP on
(Turning UCP on for a shop). UCP is served only on
a shop's own custom domain with an active certificate, never on a
*.shop.monokaido.com address. A quick check:
curl -sS -o /dev/null -w '%{http_code}\n' https://shop.example.com/.well-known/ucp
200 means the shop serves UCP. 404 means it does not, whatever the
reason: the answer is the same in every case, on purpose. 503 means the
shop cannot answer right now: try again after Retry-After.
There is no sandbox. Carts and checkouts are priced with the shop's live
prices and stock, but they are not orders: nothing is ordered and nothing is
paid until a buyer opens a continue_url and orders on the shop. That order
is a real order of the shop, so agree any test with the merchant.
Your platform profile
Every call under /ucp/v1 names your platform profile in the UCP-Agent
header, and the shop fetches it. Host a JSON document like this one at an
https:// address you control:
{
"ucp": {
"version": "2026-04-08",
"capabilities": {
"dev.ucp.shopping.catalog.search": [{ "version": "2026-04-08" }],
"dev.ucp.shopping.catalog.lookup": [{ "version": "2026-04-08" }],
"dev.ucp.shopping.cart": [{ "version": "2026-04-08" }],
"dev.ucp.shopping.checkout": [{ "version": "2026-04-08" }],
"dev.ucp.shopping.fulfillment": [{ "version": "2026-04-08" }],
"dev.ucp.shopping.discount": [{ "version": "2026-04-08" }]
}
}
}
That is all the shop reads: ucp.version, which must be exactly
2026-04-08, and ucp.capabilities. A capability you leave out is not
negotiated, and every operation that needs it answers
capabilities_incompatible. Drop dev.ucp.shopping.fulfillment and
checkouts ask for no address and no shipping option; drop
dev.ucp.shopping.discount and discount codes are ignored. Other members of
your profile are allowed and ignored.
The shop fetches the profile with a plain GET and accepts it only when:
- the URL starts with
https://, has no user name or password, contains no whitespace and is at most 1 000 characters long; - the host resolves to a public IP address (private, loopback and other reserved ranges are refused);
- the answer is HTTP
200(no redirect is followed) with a JSON body of at most 256 KiB, delivered within 5 seconds.
Serve it with Cache-Control: public, max-age=3600. The shop caches the
profile for its max-age, clamped to between 60 seconds and one hour (five
minutes without a max-age). When that time is up, the next request is still
answered with the cached profile while the shop fetches it again in the
background, so a change reaches a shop shortly after that time. A failed
fetch of a profile the shop does not have yet is remembered for 60 seconds:
after you fix the hosting, wait a minute. The full URL is the cache key, and
it also scopes your Idempotency-Key values, so keep it stable. Each profile
URL that is not cached yet costs a fetch, and one client IP address can have
at most 5 new profiles fetched a minute, across all Monokaido shops; beyond
that the answer is 503 with Retry-After.
Headers
| Header | Send it on | Value |
|---|---|---|
UCP-Agent | every call under /ucp/v1 | profile="https://platform.example.com/.well-known/ucp". The double quotes are part of the syntax, and profile is lower-case. |
Content-Type | every request with a body | application/json, UTF-8. A form-encoded body (application/x-www-form-urlencoded) is parsed too, but its values arrive as strings, so numeric fields such as quantity are refused; a body of any other type is not read. |
Idempotency-Key | the seven writes: create, update and cancel a cart; create, update, complete and cancel a checkout | A new UUID for each logical write, sent again unchanged on every retry of that write. Any 1 to 255 visible ASCII characters are accepted; another value is refused with 400. A key is kept for at least 25 hours after its first use (rely on 24), separately for each shop and each profile URL. Optional, but without it a retried create makes a second cart or checkout. |
Accept-Language | optional | Used when the body has no context.language or the shop does not offer it. Tags are tried in the order written; q= weights are ignored. Without a match the answer is in the shop's default language. A cart or checkout keeps its language until a request body names another one. |
There is no API key, no OAuth and no cookie: the shop is chosen from the host you call. Call the API from your server. It sends no CORS headers for other origins, so a web page on your own domain cannot call it from the browser.
The calls
| Step | Call | Answer |
|---|---|---|
| 1 | GET /.well-known/ucp | the business profile |
| 2 | POST /ucp/v1/catalog/search | products with their variants |
| 3 | POST /ucp/v1/carts | 201, the new cart |
| 4 | PUT /ucp/v1/carts/{id} | the cart, priced again |
| 5 | POST /ucp/v1/checkout-sessions | 201, a checkout in incomplete |
| 6 | PUT /ucp/v1/checkout-sessions/{id} | the checkout in requires_escalation |
| 7 | none | the buyer opens continue_url in a browser |
| 8 | GET /ucp/v1/checkout-sessions/{id} | completed, with the order |
Setup for the snippets
The snippets of the steps share this setup. In curl they are shell variables
(the snippets also need jq and uuidgen). In JavaScript and Python a small
helper sends the headers and throws on both kinds of failure: a protocol
error (an HTTP error status with a code) and a business error (HTTP 200
with ucp.status: "error", when no resource came back). Paste the steps after
it, in order. The helpers do not retry; the
complete scripts do.
SHOP=https://shop.example.com
AGENT='profile="https://platform.example.com/.well-known/ucp"'
// ucp.mjs — paste the steps below after this, in one ES module (Node 20+, no dependencies)
import { randomUUID } from 'node:crypto';
import { setTimeout as sleep } from 'node:timers/promises';
const SHOP = process.env.SHOP ?? 'https://shop.example.com';
const UCP_AGENT = 'profile="https://platform.example.com/.well-known/ucp"';
async function ucp(method, url, body, idempotencyKey) {
const res = await fetch(url, {
method,
headers: {
'UCP-Agent': UCP_AGENT,
'Accept-Language': 'sk',
...(body && { 'Content-Type': 'application/json' }),
...(idempotencyKey && { 'Idempotency-Key': idempotencyKey }),
},
body: body && JSON.stringify(body),
});
const data = await res.json(); // an HTML error page from the web server would throw here
if (!res.ok) throw new Error(`${res.status} ${data.code}: ${data.content}`); // protocol error
if (data.ucp?.status === 'error') {
throw new Error(`${data.messages[0].code}: ${data.messages[0].content}`); // HTTP 200, but no resource
}
return data;
}
# ucp.py — run the steps below after this, in one session (pip install requests)
import os, time, uuid
import requests
SHOP = os.environ.get("SHOP", "https://shop.example.com")
http = requests.Session()
http.headers.update({
"UCP-Agent": 'profile="https://platform.example.com/.well-known/ucp"',
"Accept-Language": "sk",
})
def ucp(method, url, body=None, idempotency_key=None):
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else {}
r = http.request(method, url, json=body, headers=headers, timeout=65) # json= sets Content-Type
data = r.json() # an HTML error page from the web server would raise here
if not r.ok: # protocol error
raise RuntimeError(f"{r.status_code} {data['code']}: {data['content']}")
if data.get("ucp", {}).get("status") == "error": # HTTP 200, but no resource
raise RuntimeError(f"{data['messages'][0]['code']}: {data['messages'][0]['content']}")
return data
Step 1: discover the shop
The business profile is public: it needs no UCP-Agent header and the shop
lets it be cached for five minutes.
curl -sS -o profile.json -w 'HTTP %{http_code}\n' "$SHOP/.well-known/ucp"
jq . profile.json
API=$(jq -r '.ucp.services["dev.ucp.shopping"][0].endpoint' profile.json) # https://shop.example.com/ucp/v1
const profile = await ucp('GET', `${SHOP}/.well-known/ucp`);
const API = profile.ucp.services['dev.ucp.shopping'][0].endpoint; // https://shop.example.com/ucp/v1
console.log(Object.keys(profile.ucp.capabilities));
profile = ucp("GET", f"{SHOP}/.well-known/ucp")
API = profile["ucp"]["services"]["dev.ucp.shopping"][0]["endpoint"] # https://shop.example.com/ucp/v1
print(list(profile["ucp"]["capabilities"]))
{
"ucp": {
"version": "2026-04-08",
"services": {
"dev.ucp.shopping": [
{
"version": "2026-04-08",
"spec": "https://ucp.dev/2026-04-08/specification/overview",
"transport": "rest",
"endpoint": "https://shop.example.com/ucp/v1",
"schema": "https://ucp.dev/2026-04-08/services/shopping/rest.openapi.json"
}
]
},
"capabilities": {
"dev.ucp.shopping.catalog.search": [
{
"version": "2026-04-08",
"spec": "https://ucp.dev/2026-04-08/specification/catalog/search",
"schema": "https://ucp.dev/2026-04-08/schemas/shopping/catalog_search.json"
}
],
"dev.ucp.shopping.catalog.lookup": [
{
"version": "2026-04-08",
"spec": "https://ucp.dev/2026-04-08/specification/catalog/lookup",
"schema": "https://ucp.dev/2026-04-08/schemas/shopping/catalog_lookup.json"
}
],
"dev.ucp.shopping.cart": [
{
"version": "2026-04-08",
"spec": "https://ucp.dev/2026-04-08/specification/cart",
"schema": "https://ucp.dev/2026-04-08/schemas/shopping/cart.json"
}
],
"dev.ucp.shopping.checkout": [
{
"version": "2026-04-08",
"spec": "https://ucp.dev/2026-04-08/specification/checkout",
"schema": "https://ucp.dev/2026-04-08/schemas/shopping/checkout.json"
}
],
"dev.ucp.shopping.fulfillment": [
{
"version": "2026-04-08",
"spec": "https://ucp.dev/2026-04-08/specification/fulfillment",
"schema": "https://ucp.dev/2026-04-08/schemas/shopping/fulfillment.json",
"extends": "dev.ucp.shopping.checkout"
}
],
"dev.ucp.shopping.discount": [
{
"version": "2026-04-08",
"spec": "https://ucp.dev/2026-04-08/specification/discount",
"schema": "https://ucp.dev/2026-04-08/schemas/shopping/discount.json",
"extends": [
"dev.ucp.shopping.checkout",
"dev.ucp.shopping.cart"
]
}
]
},
"payment_handlers": {}
}
}GET https://shop.example.com/.well-known/ucp for a shop whose primary domain is shop.example.com, not in catalog mode.
Read three things from it:
endpointinucp.services["dev.ucp.shopping"]is the base of every further call. Its host is the shop's primary domain, which can differ from the domain you asked. Use it rather than building the URL yourself.ucp.capabilitiessays what the shop offers right now. A shop in catalog mode lists onlydev.ucp.shopping.catalog.searchanddev.ucp.shopping.catalog.lookup: you can search it, but every cart and checkout call answerscapabilities_incompatible, so stop after step 2.payment_handlersis always{}. No payment goes through UCP; the buyer pays on the shop.
Step 2: search the catalog
Search by text in the shop's language. pagination.limit defaults to 10 and
is lowered to 20 when you ask for more. context.language picks the language
of the texts and with it the currency of every price. context.currency does
not change the currency: it only says what currency a filters.price is in.
Without it, the price bounds are read in the currency of the answer, the one
the language gives.
curl -sS -o search.json -w 'HTTP %{http_code}\n' -X POST "$API/catalog/search" \
-H "UCP-Agent: $AGENT" \
-H 'Content-Type: application/json' \
-H 'Accept-Language: sk' \
-d '{"query":"marlin","pagination":{"limit":2},"context":{"language":"sk","address_country":"SK","currency":"EUR"}}'
jq '.products[] | {title, variants: [.variants[] | {id, title, price, availability}]}' search.json
VARIANT=$(jq -r '[.products[].variants[] | select(.availability.available)][0].id' search.json)
const search = await ucp('POST', `${API}/catalog/search`, {
query: 'marlin',
pagination: { limit: 2 },
context: { language: 'sk', address_country: 'SK', currency: 'EUR' },
});
const product = search.products[0];
const variant = product.variants.find((v) => v.availability.available);
console.log(product.title, variant.title, variant.price); // Trek Marlin 5 Modrá / M { amount: 69900, currency: 'EUR' }
console.log(search.pagination); // { has_next_page: true, cursor: 'cDoy', total_count: 3 }
search = ucp("POST", f"{API}/catalog/search", {
"query": "marlin",
"pagination": {"limit": 2},
"context": {"language": "sk", "address_country": "SK", "currency": "EUR"},
})
product = search["products"][0]
variant = next(v for v in product["variants"] if v["availability"]["available"])
print(product["title"], variant["title"], variant["price"]) # Trek Marlin 5 Modrá / M {'amount': 69900, 'currency': 'EUR'}
print(search["pagination"]) # {'has_next_page': True, 'cursor': 'cDoy', 'total_count': 3}
The request body:
{
"query": "marlin",
"pagination": {
"limit": 2
},
"context": {
"language": "sk",
"address_country": "SK",
"currency": "EUR"
}
}First page of a text search in Slovak. currency here only denominates a price filter (none is sent); unknown fields would be ignored.
The answer:
{
"ucp": {
"version": "2026-04-08",
"capabilities": {
"dev.ucp.shopping.catalog.search": [
{
"version": "2026-04-08"
}
]
}
},
"products": [
{
"id": "6f1d2b7c-3a84-4e59-b0c2-8d7e1f4a5b62",
"handle": "trek-marlin-5",
"title": "Trek Marlin 5",
"description": {
"plain": "Horský bicykel s hliníkovým rámom Alpha Silver, odpruženou vidlicou a 29\" kolesami.",
"html": "<p>Horský bicykel pre začínajúcich jazdcov.</p><ul><li>Rám Alpha Silver Aluminium</li><li>Vidlica SR Suntour XCT 30, zdvih 100 mm</li><li>Pohon Shimano 2×8</li></ul>"
},
"url": "https://shop.example.com/products/trek-marlin-5",
"price_range": {
"min": {
"amount": 69900,
"currency": "EUR"
},
"max": {
"amount": 71900,
"currency": "EUR"
}
},
"list_price_range": {
"min": {
"amount": 74900,
"currency": "EUR"
},
"max": {
"amount": 74900,
"currency": "EUR"
}
},
"media": [
{
"type": "image",
"url": "https://shop.example.com/api/public/uploads/c4d1e5a2-8b3f-4a6c-9e07-5f2d8a1b3c40/products/trek-marlin-5-modra.webp",
"alt_text": "Trek Marlin 5, modrá, bočný pohľad"
},
{
"type": "image",
"url": "https://images.vendor.example/trek/marlin-5-detail.jpg"
}
],
"options": [
{
"name": "Farba",
"values": [
{
"label": "Modrá"
},
{
"label": "Čierna"
}
]
},
{
"name": "Veľkosť",
"values": [
{
"label": "M"
},
{
"label": "L"
}
]
}
],
"variants": [
{
"id": "1e8a6c4f-2b93-4d71-9f05-6a3c8e2b1d47",
"sku": "MARLIN5-M-MOD",
"barcodes": [
{
"type": "EAN",
"value": "8594012345674"
}
],
"title": "Modrá / M",
"description": {
"plain": "Modrá / M"
},
"url": "https://shop.example.com/products/trek-marlin-5",
"price": {
"amount": 69900,
"currency": "EUR"
},
"list_price": {
"amount": 74900,
"currency": "EUR"
},
"availability": {
"available": true,
"status": "in_stock"
},
"options": [
{
"name": "Farba",
"label": "Modrá"
},
{
"name": "Veľkosť",
"label": "M"
}
]
},
{
"id": "7c2f9b1e-4a68-4e3d-b2c9-5d1f7a3e8b06",
"sku": "MARLIN5-L-MOD",
"title": "Modrá / L",
"description": {
"plain": "Modrá / L"
},
"url": "https://shop.example.com/products/trek-marlin-5",
"price": {
"amount": 69900,
"currency": "EUR"
},
"availability": {
"available": false,
"status": "out_of_stock"
},
"options": [
{
"name": "Farba",
"label": "Modrá"
},
{
"name": "Veľkosť",
"label": "L"
}
]
},
{
"id": "d93b4a7e-1f26-4c85-a0e7-3b9c6d2f4e18",
"sku": "MARLIN5-M-CIE",
"title": "Čierna / M",
"description": {
"plain": "Čierna / M"
},
"url": "https://shop.example.com/products/trek-marlin-5",
"price": {
"amount": 71900,
"currency": "EUR"
},
"availability": {
"available": true,
"status": "preorder"
},
"options": [
{
"name": "Farba",
"label": "Čierna"
},
{
"name": "Veľkosť",
"label": "M"
}
]
}
],
"tags": [
"Trek"
]
},
{
"id": "2f6c8a1d-7e43-4b95-9c2a-4d1e8b6f3a07",
"handle": "trek-marlin-7",
"title": "Trek Marlin 7",
"description": {
"plain": "Trek Marlin 7"
},
"url": "https://shop.example.com/products/trek-marlin-7",
"price_range": {
"min": {
"amount": 104900,
"currency": "EUR"
},
"max": {
"amount": 104900,
"currency": "EUR"
}
},
"media": [
{
"type": "image",
"url": "https://shop.example.com/api/public/uploads/c4d1e5a2-8b3f-4a6c-9e07-5f2d8a1b3c40/products/trek-marlin-7.webp"
}
],
"options": [
{
"name": "Veľkosť",
"values": [
{
"label": "L"
}
]
}
],
"variants": [
{
"id": "c8e2a5f1-3d79-4a16-b4e3-9f6c2d8a1e54",
"sku": "MARLIN7-L",
"title": "L",
"description": {
"plain": "L"
},
"url": "https://shop.example.com/products/trek-marlin-7",
"price": {
"amount": 104900,
"currency": "EUR"
},
"availability": {
"available": true,
"status": "in_stock"
},
"options": [
{
"name": "Veľkosť",
"label": "L"
}
]
}
],
"tags": [
"Trek"
]
}
],
"pagination": {
"has_next_page": true,
"cursor": "cDoy",
"total_count": 3
}
}The answer to CatalogSearchRequest. The listing had 3 matches (page 1 of 2), so has_next_page is true and total_count is 3 (no price filter in effect). The first available variant of Trek Marlin 5 is already first; the pre-order variant counts as available; the vendor image keeps its own URL.
What matters for the next steps:
variants[].idis what goes into a cart line. Pick a variant withavailability.available: true(statusin_stockorpreorder).- Prices are integers in the minor units of the currency, VAT included:
69900withEURis 699.00 €.list_priceis the earlier or recommended price, shown only when it is higher. - Paging. While
has_next_pageistrue, send the same body again withpagination.cursorset to thecursoryou got. The cursor holds only the page number, so the query, filters and limit must stay the same. A page can be short, even empty, while more pages follow: keep going untilhas_next_pageisfalse. - Order and matching. Products come newest first; there is no relevance
ranking. The query matches the product title and the product's own codes
(the shop's product code and the supplier's product ids), not descriptions,
variant SKUs or EAN codes. Case does not matter, diacritics do:
modradoes not findModrá. To resolve SKUs, handles or ids, usePOST /ucp/v1/catalog/lookupwith up to 20 of them. - A listing that moves. Search reads the shop's product listing, which
the platform refreshes in the background: a newly published product can
take a few minutes to appear, and one published while you page shifts the
later pages, so a product can repeat or be skipped. De-duplicate products
by
id. Lookup and product read the product data itself.
Step 3: create a cart
Each line names a variant in item.id: always the variants[].id you chose
from the catalog. A product id is accepted too, but the line then buys the
product's active variant created first, which need not be the variant the
search showed, nor one in stock; the answer names that variant. quantity
defaults to 1; zero, a negative number or a fraction is
refused with 400. At most 100 lines are accepted, and a quantity above 999
is lowered to 999 without a message. Two lines of one variant become one line
with their quantities added up. A buyer is optional on a cart. This is the
first write, so it carries an Idempotency-Key.
KEY=$(uuidgen) # one key per logical write; a retry of this request sends the same one
jq -n --arg variant "$VARIANT" '{
line_items: [
{item: {id: $variant}, quantity: 1},
{item: {id: "4a6e2d9c-8b15-4f37-9c48-1e7a5b3d2f90"}}
],
buyer: {email: "Jana.Novakova@Example.com"},
context: {address_country: "SK", language: "sk"}
}' | curl -sS -o cart.json -w 'HTTP %{http_code}\n' -X POST "$API/carts" \
-H "UCP-Agent: $AGENT" \
-H 'Content-Type: application/json' \
-H 'Accept-Language: sk' \
-H "Idempotency-Key: $KEY" \
--data-binary @-
jq . cart.json
CART=$(jq -r .id cart.json)
let cart = await ucp('POST', `${API}/carts`, {
line_items: [
{ item: { id: variant.id }, quantity: 1 },
{ item: { id: '4a6e2d9c-8b15-4f37-9c48-1e7a5b3d2f90' } }, // a helmet; quantity defaults to 1
],
buyer: { email: 'Jana.Novakova@Example.com' },
context: { address_country: 'SK', language: 'sk' },
}, randomUUID()); // keep the key if you retry this very request
console.log(cart.id, cart.totals, cart.continue_url);
cart = ucp("POST", f"{API}/carts", {
"line_items": [
{"item": {"id": variant["id"]}, "quantity": 1},
{"item": {"id": "4a6e2d9c-8b15-4f37-9c48-1e7a5b3d2f90"}}, # a helmet; quantity defaults to 1
],
"buyer": {"email": "Jana.Novakova@Example.com"},
"context": {"address_country": "SK", "language": "sk"},
}, str(uuid.uuid4())) # keep the key if you retry this very request
print(cart["id"], cart["totals"], cart["continue_url"])
{
"line_items": [
{
"item": {
"id": "1e8a6c4f-2b93-4d71-9f05-6a3c8e2b1d47"
},
"quantity": 1
},
{
"item": {
"id": "4a6e2d9c-8b15-4f37-9c48-1e7a5b3d2f90"
}
}
],
"buyer": {
"email": "Jana.Novakova@Example.com"
},
"context": {
"address_country": "SK",
"language": "sk"
}
}Step 1 of the flow. The helmet line has no quantity (1). The e-mail is lower-cased when stored.
The shop answers 201 with the cart priced by its own rules:
{
"ucp": {
"version": "2026-04-08",
"capabilities": {
"dev.ucp.shopping.cart": [
{
"version": "2026-04-08"
}
],
"dev.ucp.shopping.discount": [
{
"version": "2026-04-08"
}
]
}
},
"id": "9a3f6c2e-5b18-4d47-8e9c-1f2a7b4d6e83",
"line_items": [
{
"id": "e2b7d4a9-3c61-4f85-9a2e-7d1c5b8f3a46",
"item": {
"id": "1e8a6c4f-2b93-4d71-9f05-6a3c8e2b1d47",
"title": "Trek Marlin 5",
"price": 69900,
"image_url": "https://shop.example.com/api/public/uploads/c4d1e5a2-8b3f-4a6c-9e07-5f2d8a1b3c40/products/trek-marlin-5-modra.webp"
},
"quantity": 1,
"totals": [
{
"type": "subtotal",
"amount": 69900
},
{
"type": "total",
"amount": 69900
}
]
},
{
"id": "5c9e1a7f-2d48-4b36-a8f1-6e3d9c2b7a15",
"item": {
"id": "4a6e2d9c-8b15-4f37-9c48-1e7a5b3d2f90",
"title": "Cyklistická prilba Lumen",
"price": 5990
},
"quantity": 1,
"totals": [
{
"type": "subtotal",
"amount": 5990
},
{
"type": "total",
"amount": 5990
}
]
}
],
"currency": "EUR",
"totals": [
{
"type": "subtotal",
"amount": 75890
},
{
"type": "total",
"amount": 75890
}
],
"buyer": {
"email": "jana.novakova@example.com"
},
"context": {
"address_country": "SK",
"language": "sk"
},
"discounts": {
"codes": [],
"applied": []
},
"continue_url": "https://shop.example.com/ucp/continue/e1f0907c4abcf12564330feef5762f8471cc386a3563ce53cf8342ce6be08b62",
"expires_at": "2026-11-02T09:14:05.118Z"
}The answer to CartCreateRequest: the bike (699.00 €) and the helmet (59.90 €). No messages key (none). discounts is present because the discount extension is negotiated. The helmet has no image, so no image_url. expires_at = now + 7 days.
- Keep the cart's
id. A cart lives for 7 days after its last write; after that every call on it answersnot_found. totalsholdssubtotal, a negativediscountwhen a code applies, andtotal. Line prices are initem.price, in minor units like everything else.messagesappears only when there is something to say. A line the shop cannot sell is left out with anitem_unavailable,out_of_stockorvat_mix_not_allowedmessage; the rest of the cart is created.- If none of the lines can be added, the answer is HTTP
200withucp.status: "error", anout_of_stockoritem_unavailablemessage and no cart id. - A cart has a
continue_urltoo, but hand the buyer a checkout: only a checkout ever learns that an order was placed.
Step 4: update the cart
PUT replaces the cart's lines. line_items is required and is the whole
list: a variant you leave out is removed. Lines are matched by item.id; a
line id you send is ignored. A field you leave out, such as buyer or
context, keeps its stored value. A field you send replaces the stored one
as a whole, and discounts.codes replaces the list of codes ([] removes
them all).
KEY=$(uuidgen)
jq -n --arg variant "$VARIANT" '{
line_items: [
{item: {id: $variant}, quantity: 1},
{item: {id: "b5f81c3d-6e49-4a2b-8d73-9c0e4f1a6b25"}, quantity: 3}
],
discounts: {codes: ["JESEN10", "LETO5"]}
}' | curl -sS -o cart.json -w 'HTTP %{http_code}\n' -X PUT "$API/carts/$CART" \
-H "UCP-Agent: $AGENT" \
-H 'Content-Type: application/json' \
-H 'Accept-Language: sk' \
-H "Idempotency-Key: $KEY" \
--data-binary @-
jq '{totals, messages}' cart.json
cart = await ucp('PUT', `${API}/carts/${cart.id}`, {
line_items: [ // the whole list: the helmet is left out, so it is removed
{ item: { id: variant.id }, quantity: 1 },
{ item: { id: 'b5f81c3d-6e49-4a2b-8d73-9c0e4f1a6b25' }, quantity: 3 }, // bottles
],
discounts: { codes: ['JESEN10', 'LETO5'] },
}, randomUUID());
for (const m of cart.messages ?? []) console.log(m.type, m.code, m.path, m.content);
cart = ucp("PUT", f"{API}/carts/{cart['id']}", {
"line_items": [ # the whole list: the helmet is left out, so it is removed
{"item": {"id": variant["id"]}, "quantity": 1},
{"item": {"id": "b5f81c3d-6e49-4a2b-8d73-9c0e4f1a6b25"}, "quantity": 3}, # bottles
],
"discounts": {"codes": ["JESEN10", "LETO5"]},
}, str(uuid.uuid4()))
for m in cart.get("messages", []):
print(m["type"], m["code"], m.get("path"), m["content"])
{
"line_items": [
{
"id": "e2b7d4a9-3c61-4f85-9a2e-7d1c5b8f3a46",
"item": {
"id": "1e8a6c4f-2b93-4d71-9f05-6a3c8e2b1d47"
},
"quantity": 1
},
{
"item": {
"id": "b5f81c3d-6e49-4a2b-8d73-9c0e4f1a6b25"
},
"quantity": 3
}
],
"discounts": {
"codes": [
"JESEN10",
"LETO5"
]
}
}Step 2. line_items is a full replacement: the helmet is removed. The line id is accepted and ignored (lines match by variant). Only 2 bottles are in stock. buyer and context are left out, so they are kept.
{
"ucp": {
"version": "2026-04-08",
"capabilities": {
"dev.ucp.shopping.cart": [
{
"version": "2026-04-08"
}
],
"dev.ucp.shopping.discount": [
{
"version": "2026-04-08"
}
]
}
},
"id": "9a3f6c2e-5b18-4d47-8e9c-1f2a7b4d6e83",
"line_items": [
{
"id": "e2b7d4a9-3c61-4f85-9a2e-7d1c5b8f3a46",
"item": {
"id": "1e8a6c4f-2b93-4d71-9f05-6a3c8e2b1d47",
"title": "Trek Marlin 5",
"price": 69900,
"image_url": "https://shop.example.com/api/public/uploads/c4d1e5a2-8b3f-4a6c-9e07-5f2d8a1b3c40/products/trek-marlin-5-modra.webp"
},
"quantity": 1,
"totals": [
{
"type": "subtotal",
"amount": 69900
},
{
"type": "total",
"amount": 69900
}
]
},
{
"id": "8f4a2c6e-1b93-4d57-b6e8-3a9c7d1f5e24",
"item": {
"id": "b5f81c3d-6e49-4a2b-8d73-9c0e4f1a6b25",
"title": "Fľaša na vodu 750 ml",
"price": 990
},
"quantity": 2,
"totals": [
{
"type": "subtotal",
"amount": 1980
},
{
"type": "total",
"amount": 1980
}
]
}
],
"currency": "EUR",
"totals": [
{
"type": "subtotal",
"amount": 71880
},
{
"type": "discount",
"amount": -5844,
"display_text": "JESEN10"
},
{
"type": "total",
"amount": 66036
}
],
"messages": [
{
"type": "warning",
"code": "quantity_adjusted",
"content": "Only 2 available — quantity lowered from 3",
"path": "$.line_items[1].quantity"
},
{
"type": "warning",
"code": "discount_code_combination_disallowed",
"path": "$.discounts.codes[1]",
"content": "Code 'LETO5' cannot be combined with 'JESEN10'"
}
],
"buyer": {
"email": "jana.novakova@example.com"
},
"context": {
"language": "sk",
"address_country": "SK"
},
"discounts": {
"codes": [
"JESEN10",
"LETO5"
],
"applied": [
{
"code": "JESEN10",
"title": "JESEN10",
"amount": 5844,
"method": "across"
}
]
},
"continue_url": "https://shop.example.com/ucp/continue/e1f0907c4abcf12564330feef5762f8471cc386a3563ce53cf8342ce6be08b62",
"expires_at": "2026-11-02T09:16:47.402Z"
}The answer to CartUpdateRequest. The bottle line was lowered to the 2 in stock (quantity_adjusted at its index in the answer); JESEN10 (10 % of the net subtotal 584.39 € = 58.44 €) is applied, LETO5 cannot be combined. Subtotal 718.80 € − 58.44 € = 660.36 €. The kept buyer and context are echoed; expires_at moved to now + 7 days.
- One discount code applies to a cart. Codes are tried in the order you
send them; the first one the shop accepts applies, and each further one gets
a
discount_code_combination_disallowedwarning. A code the shop refuses gets adiscount_code_invalid,discount_code_expiredordiscount_code_user_ineligiblewarning; each warning'spathpoints to the code indiscounts.codes. A percentage code is computed from the subtotal without VAT, as on the shop's own checkout, and subtracted from the subtotal with VAT. - A quantity above the stock is lowered, with a
quantity_adjustedwarning. - The lines come back in the shop's order, not in yours. Match them by
item.id; a messagepathsuch as$.line_items[1].quantitypoints into the answer, not into your request. - Each create and update sets
expires_atto 7 days from now.
Step 5: turn the cart into a checkout
Send the cart's id to POST /ucp/v1/checkout-sessions. The checkout copies
the cart's lines, buyer, context (the language included) and discount codes;
the request's own line_items, buyer and context are not used. You may
send fulfillment and discounts with the cart_id; they apply to the new
checkout. The cart itself stays as it is. cart_id is read only when your
profile declares dev.ucp.shopping.cart; without it the request needs
line_items. Without a cart, the same route takes line_items (and the
other fields) directly, in the shape of step 3.
KEY=$(uuidgen)
curl -sS -o checkout.json -w 'HTTP %{http_code}\n' -X POST "$API/checkout-sessions" \
-H "UCP-Agent: $AGENT" \
-H 'Content-Type: application/json' \
-H 'Accept-Language: sk' \
-H "Idempotency-Key: $KEY" \
-d "{\"cart_id\":\"$CART\"}"
jq '{id, status, messages, options: .fulfillment.methods[0].groups[0].options}' checkout.json
CHECKOUT=$(jq -r .id checkout.json)
OPTION=$(jq -r '.fulfillment.methods[0].groups[0].options[0].id' checkout.json)
let checkout = await ucp('POST', `${API}/checkout-sessions`, { cart_id: cart.id }, randomUUID());
console.log(checkout.status); // incomplete
for (const m of checkout.messages) console.log(m.type, m.severity, m.code, m.path);
checkout = ucp("POST", f"{API}/checkout-sessions", {"cart_id": cart["id"]}, str(uuid.uuid4()))
print(checkout["status"]) # incomplete
for m in checkout["messages"]:
print(m["type"], m.get("severity"), m["code"], m.get("path"))
{
"cart_id": "9a3f6c2e-5b18-4d47-8e9c-1f2a7b4d6e83"
}Step 3: a checkout from the cart of step 2. Its lines, buyer, context and codes come from the cart.
{
"ucp": {
"version": "2026-04-08",
"capabilities": {
"dev.ucp.shopping.checkout": [
{
"version": "2026-04-08"
}
],
"dev.ucp.shopping.fulfillment": [
{
"version": "2026-04-08"
}
],
"dev.ucp.shopping.discount": [
{
"version": "2026-04-08"
}
]
},
"payment_handlers": {}
},
"id": "3d7b9e2a-6f14-4c58-9b3e-8a2f1c6d4e97",
"status": "incomplete",
"currency": "EUR",
"line_items": [
{
"id": "f6a3c8e1-9d27-4b54-8e6a-2c1f7b9d3a58",
"item": {
"id": "1e8a6c4f-2b93-4d71-9f05-6a3c8e2b1d47",
"title": "Trek Marlin 5",
"price": 69900,
"image_url": "https://shop.example.com/api/public/uploads/c4d1e5a2-8b3f-4a6c-9e07-5f2d8a1b3c40/products/trek-marlin-5-modra.webp"
},
"quantity": 1,
"totals": [
{
"type": "subtotal",
"amount": 69900
},
{
"type": "total",
"amount": 69900
}
]
},
{
"id": "2e9d5b7a-4c83-4f16-a9b2-6d8e1c3f7a49",
"item": {
"id": "b5f81c3d-6e49-4a2b-8d73-9c0e4f1a6b25",
"title": "Fľaša na vodu 750 ml",
"price": 990
},
"quantity": 2,
"totals": [
{
"type": "subtotal",
"amount": 1980
},
{
"type": "total",
"amount": 1980
}
]
}
],
"totals": [
{
"type": "subtotal",
"amount": 71880
},
{
"type": "discount",
"amount": -5844,
"display_text": "JESEN10"
},
{
"type": "total",
"amount": 66036
}
],
"messages": [
{
"type": "warning",
"code": "discount_code_combination_disallowed",
"path": "$.discounts.codes[1]",
"content": "Code 'LETO5' cannot be combined with 'JESEN10'"
},
{
"type": "error",
"code": "missing",
"path": "$.fulfillment.methods[0].destinations",
"content": "A shipping address is required",
"severity": "recoverable"
},
{
"type": "error",
"code": "missing",
"path": "$.fulfillment.methods[0].groups[0].selected_option_id",
"content": "Choose a shipping option",
"severity": "recoverable"
}
],
"links": [
{
"type": "terms_of_service",
"url": "https://shop.example.com/obchodne-podmienky",
"title": "Obchodné podmienky"
},
{
"type": "privacy_policy",
"url": "https://shop.example.com/ochrana-osobnych-udajov",
"title": "Ochrana osobných údajov"
}
],
"buyer": {
"email": "jana.novakova@example.com"
},
"context": {
"language": "sk",
"address_country": "SK"
},
"fulfillment": {
"methods": [
{
"id": "shipping",
"type": "shipping",
"line_item_ids": [
"f6a3c8e1-9d27-4b54-8e6a-2c1f7b9d3a58",
"2e9d5b7a-4c83-4f16-a9b2-6d8e1c3f7a49"
],
"groups": [
{
"id": "default",
"line_item_ids": [
"f6a3c8e1-9d27-4b54-8e6a-2c1f7b9d3a58",
"2e9d5b7a-4c83-4f16-a9b2-6d8e1c3f7a49"
],
"options": [
{
"id": "0c5e8a3f-7b21-4d96-8f4c-9e2a6d1b3c75",
"title": "GLS kuriér",
"description": "Doručenie na adresu do 1–2 pracovných dní",
"totals": [
{
"type": "total",
"amount": 490
}
]
},
{
"id": "6b2d9f4e-3a17-4c68-9d5b-1f8e3a7c2b40",
"title": "Packeta – výdajné miesto",
"description": "Delivery in 2–3 business days",
"totals": [
{
"type": "total",
"amount": 290
}
]
}
],
"selected_option_id": null
}
]
}
]
},
"discounts": {
"codes": [
"JESEN10",
"LETO5"
],
"applied": [
{
"code": "JESEN10",
"title": "JESEN10",
"amount": 5844,
"method": "across"
}
]
},
"continue_url": "https://shop.example.com/ucp/continue/9bbd3dedf887f93c6287fa7f4b8de141501155714c3b2050bb83551e36ec780b",
"expires_at": "2026-10-27T09:18:12.930Z"
}The answer to CheckoutFromCartRequest. The checkout has its own lines (new line ids), the cart’s buyer, context and codes; JESEN10 is applied again and LETO5 warned about again. No address yet, so both shipping options are offered (no country filter) and none is selected: two recoverable errors keep it incomplete, so there is no payment_required. expires_at = now + 24 h.
- The answer is
201. This checkout isincompletebecause no address and no shipping option are known yet; a checkout that already has everything isrequires_escalationfrom the start. A checkout always carriesmessages, and itsrecoverableerrors say what is still missing. - The checkout has its own lines, with new line ids.
fulfillment.methods[0].groups[0].optionslists the shop's shipping methods with their fees. While no address is known, they are not filtered by country.- A checkout lives for 24 hours after its last write.
- A cart has at most one open checkout. Sending the same
cart_idagain answers that checkout with HTTP200and ignores thefulfillmentanddiscountsof the repeat; change the checkout withPUTinstead.
Step 6: add the buyer and the shipping
A checkout turns requires_escalation as soon as no message is an error
with severity: "recoverable". These messages say what to send:
code | path | What to send |
|---|---|---|
missing | $.buyer.email | buyer.email. An address that is not a valid e-mail is reported as missing. |
missing | $.fulfillment.methods[0].destinations | a shipping address |
invalid_address | $.fulfillment.methods[0].destinations[0] | the address with street_address, address_locality, postal_code and address_country |
address_undeliverable | $.fulfillment.methods[0].destinations[0] | another address: none of the shop's shipping methods delivers these items to this country |
missing | $.fulfillment.methods[0].groups[0].selected_option_id | one of the offered options[].id |
invalid_option | $.fulfillment.methods[0].groups[0].selected_option_id | an option from the current list |
missing | $.line_items | at least one line |
item_unavailable, out_of_stock, vat_mix_not_allowed | none, or $.line_items[n] | another item, or line_items without it. Only the answer to the request that sent the line carries these: a line that was left out has no path, a refused change points at the line the checkout kept. |
The buyer's name and phone number are not required.
KEY=$(uuidgen)
jq -n --arg option "$OPTION" '{
buyer: {first_name: "Jana", last_name: "Nováková",
email: "jana.novakova@example.com", phone_number: "+421 905 123 456"},
fulfillment: {methods: [{
type: "shipping",
destinations: [{id: "home", street_address: "Hlavná 12", address_locality: "Bratislava",
postal_code: "811 01", address_country: "SK",
first_name: "Jana", last_name: "Nováková"}],
selected_destination_id: "home",
groups: [{selected_option_id: $option}]
}]},
discounts: {codes: ["JESEN10"]}
}' | curl -sS -o checkout.json -w 'HTTP %{http_code}\n' -X PUT "$API/checkout-sessions/$CHECKOUT" \
-H "UCP-Agent: $AGENT" \
-H 'Content-Type: application/json' \
-H 'Accept-Language: sk' \
-H "Idempotency-Key: $KEY" \
--data-binary @-
jq '{status, totals, messages}' checkout.json
const [option] = checkout.fulfillment.methods[0].groups[0].options; // GLS kuriér
checkout = await ucp('PUT', `${API}/checkout-sessions/${checkout.id}`, {
buyer: { first_name: 'Jana', last_name: 'Nováková', email: 'jana.novakova@example.com', phone_number: '+421 905 123 456' },
fulfillment: {
methods: [{
type: 'shipping',
destinations: [{
id: 'home', street_address: 'Hlavná 12', address_locality: 'Bratislava',
postal_code: '811 01', address_country: 'SK', first_name: 'Jana', last_name: 'Nováková',
}],
selected_destination_id: 'home',
groups: [{ selected_option_id: option.id }],
}],
},
discounts: { codes: ['JESEN10'] },
}, randomUUID());
const blocking = checkout.messages.filter((m) => m.type === 'error' && m.severity === 'recoverable');
console.log(checkout.status, blocking); // requires_escalation []
option = checkout["fulfillment"]["methods"][0]["groups"][0]["options"][0] # GLS kuriér
checkout = ucp("PUT", f"{API}/checkout-sessions/{checkout['id']}", {
"buyer": {"first_name": "Jana", "last_name": "Nováková",
"email": "jana.novakova@example.com", "phone_number": "+421 905 123 456"},
"fulfillment": {"methods": [{
"type": "shipping",
"destinations": [{"id": "home", "street_address": "Hlavná 12", "address_locality": "Bratislava",
"postal_code": "811 01", "address_country": "SK",
"first_name": "Jana", "last_name": "Nováková"}],
"selected_destination_id": "home",
"groups": [{"selected_option_id": option["id"]}],
}]},
"discounts": {"codes": ["JESEN10"]},
}, str(uuid.uuid4()))
blocking = [m for m in checkout["messages"] if m["type"] == "error" and m.get("severity") == "recoverable"]
print(checkout["status"], blocking) # requires_escalation []
{
"buyer": {
"first_name": "Jana",
"last_name": "Nováková",
"email": "jana.novakova@example.com",
"phone_number": "+421 905 123 456"
},
"fulfillment": {
"methods": [
{
"type": "shipping",
"destinations": [
{
"id": "home",
"street_address": "Hlavná 12",
"address_locality": "Bratislava",
"postal_code": "811 01",
"address_country": "SK",
"first_name": "Jana",
"last_name": "Nováková"
}
],
"selected_destination_id": "home",
"groups": [
{
"selected_option_id": "0c5e8a3f-7b21-4d96-8f4c-9e2a6d1b3c75"
}
]
}
]
},
"discounts": {
"codes": [
"JESEN10"
]
}
}Step 4. buyer replaces the stored buyer, so the e-mail is sent again with the new fields. GLS is selected with an option id from step 3. Sending only JESEN10 drops LETO5 and its warning.
{
"ucp": {
"version": "2026-04-08",
"capabilities": {
"dev.ucp.shopping.checkout": [
{
"version": "2026-04-08"
}
],
"dev.ucp.shopping.fulfillment": [
{
"version": "2026-04-08"
}
],
"dev.ucp.shopping.discount": [
{
"version": "2026-04-08"
}
]
},
"payment_handlers": {}
},
"id": "3d7b9e2a-6f14-4c58-9b3e-8a2f1c6d4e97",
"status": "requires_escalation",
"currency": "EUR",
"line_items": [
{
"id": "f6a3c8e1-9d27-4b54-8e6a-2c1f7b9d3a58",
"item": {
"id": "1e8a6c4f-2b93-4d71-9f05-6a3c8e2b1d47",
"title": "Trek Marlin 5",
"price": 69900,
"image_url": "https://shop.example.com/api/public/uploads/c4d1e5a2-8b3f-4a6c-9e07-5f2d8a1b3c40/products/trek-marlin-5-modra.webp"
},
"quantity": 1,
"totals": [
{
"type": "subtotal",
"amount": 69900
},
{
"type": "total",
"amount": 69900
}
]
},
{
"id": "2e9d5b7a-4c83-4f16-a9b2-6d8e1c3f7a49",
"item": {
"id": "b5f81c3d-6e49-4a2b-8d73-9c0e4f1a6b25",
"title": "Fľaša na vodu 750 ml",
"price": 990
},
"quantity": 2,
"totals": [
{
"type": "subtotal",
"amount": 1980
},
{
"type": "total",
"amount": 1980
}
]
}
],
"totals": [
{
"type": "subtotal",
"amount": 71880
},
{
"type": "discount",
"amount": -5844,
"display_text": "JESEN10"
},
{
"type": "fulfillment",
"amount": 490
},
{
"type": "total",
"amount": 66526
}
],
"messages": [
{
"type": "error",
"code": "payment_required",
"content": "Payment is completed on the shop website — continue there to place the order",
"severity": "requires_buyer_input"
}
],
"links": [
{
"type": "terms_of_service",
"url": "https://shop.example.com/obchodne-podmienky",
"title": "Obchodné podmienky"
},
{
"type": "privacy_policy",
"url": "https://shop.example.com/ochrana-osobnych-udajov",
"title": "Ochrana osobných údajov"
}
],
"buyer": {
"first_name": "Jana",
"last_name": "Nováková",
"email": "jana.novakova@example.com",
"phone_number": "+421 905 123 456"
},
"context": {
"language": "sk",
"address_country": "SK"
},
"fulfillment": {
"methods": [
{
"id": "shipping",
"type": "shipping",
"line_item_ids": [
"f6a3c8e1-9d27-4b54-8e6a-2c1f7b9d3a58",
"2e9d5b7a-4c83-4f16-a9b2-6d8e1c3f7a49"
],
"destinations": [
{
"id": "destination",
"street_address": "Hlavná 12",
"address_locality": "Bratislava",
"postal_code": "811 01",
"address_country": "SK",
"first_name": "Jana",
"last_name": "Nováková"
}
],
"selected_destination_id": "destination",
"groups": [
{
"id": "default",
"line_item_ids": [
"f6a3c8e1-9d27-4b54-8e6a-2c1f7b9d3a58",
"2e9d5b7a-4c83-4f16-a9b2-6d8e1c3f7a49"
],
"options": [
{
"id": "0c5e8a3f-7b21-4d96-8f4c-9e2a6d1b3c75",
"title": "GLS kuriér",
"description": "Doručenie na adresu do 1–2 pracovných dní",
"totals": [
{
"type": "total",
"amount": 490
}
]
},
{
"id": "6b2d9f4e-3a17-4c68-9d5b-1f8e3a7c2b40",
"title": "Packeta – výdajné miesto",
"description": "Delivery in 2–3 business days",
"totals": [
{
"type": "total",
"amount": 290
}
]
}
],
"selected_option_id": "0c5e8a3f-7b21-4d96-8f4c-9e2a6d1b3c75"
}
]
}
]
},
"discounts": {
"codes": [
"JESEN10"
],
"applied": [
{
"code": "JESEN10",
"title": "JESEN10",
"amount": 5844,
"method": "across"
}
]
},
"continue_url": "https://shop.example.com/ucp/continue/9bbd3dedf887f93c6287fa7f4b8de141501155714c3b2050bb83551e36ec780b",
"expires_at": "2026-10-27T09:21:30.551Z"
}The answer to CheckoutUpdateRequest (step 4), and to a GET before the buyer has ordered. Nothing the platform can provide is missing: status requires_escalation, payment_required (requires_buyer_input) and continue_url. GLS 4.90 € is the fulfillment total: 718.80 − 58.44 + 4.90 = 665.26 €. The destination id came back as destination. Give continue_url to the buyer now.
How the fields behave:
buyerreplaces the stored buyer as a whole. Send every field each time: abuyerwith only a name erases the stored e-mail.fulfillment. The shop reads the first method oftype: "shipping"(or one without atype). Of itsdestinationsit takes the one named byselected_destination_id, else the first. A checkout has one address; it comes back with the id"destination", whatever id you gave it. An address can be replaced but not removed. Sendaddress_countryas an ISO 3166-1 alpha-2 code such asSK(alpha-3 codes and country names are understood too); a country the shop cannot recognise is refused with400.groups[0].selected_option_idselects a shipping option;nullclears the choice.- Options depend on the address. Once an address is stored, only the
methods that ship to its country are offered. An option chosen before the
address may drop out, which shows as
invalid_optionwithselected_option_id: null. The safe order is to send the address first and choose from the options of that answer. The example does both in one request, which works when the option ships to the address's country. - Each create and
PUTsetsexpires_atto 24 hours from now, until the buyer openscontinue_url.
A checkout in requires_escalation always carries payment_required
(requires_buyer_input): the buyer pays on the shop. When the chosen
method delivers to a pickup point, pickup_point_required (also
requires_buyer_input) says that the buyer picks the point on the shop.
Neither blocks the next step.
Step 7: hand the buyer over
Show the buyer the summary — lines, totals, shipping and the shop's links
(its terms and privacy policy, when the shop has set them up) — and give
them continue_url as a link or a button they open in their own browser.
Name each line from your own record of the variant you chose (its title,
such as Modrá / M, kept by item.id): a line's item.title is only the
product's title. Give the link only now, at requires_escalation: the first
time it puts lines into a buyer's cart, the checkout is frozen as it is.
jq -r 'if .status == "requires_escalation" then .continue_url else "not ready: \(.status)" end' checkout.json
if (checkout.status !== 'requires_escalation') throw new Error('Not ready yet — fix the recoverable messages first');
console.log(`Finish the purchase on the shop: ${checkout.continue_url}`);
if checkout["status"] != "requires_escalation":
raise RuntimeError("Not ready yet — fix the recoverable messages first")
print(f"Finish the purchase on the shop: {checkout['continue_url']}")
When the buyer opens the link, a short page on the shop's domain adds the checkout's lines and its applied discount code to the cart this browser has on the shop, and opens the shop's checkout. Items already in that cart stay. If the buyer is signed in to the shop in that browser tab, the lines go to their customer cart. If that cart already holds items in another currency, the lines are added in that currency when the shop sells in it; otherwise nothing is added and the buyer lands on the shop's cart. The buyer then enters the e-mail, address, shipping and payment on the shop and places the order.
- The link is for the buyer's browser. Never open it from your server. Opening it only shows the page; the lines move when the page's script runs in a browser, which needs JavaScript and the shop's browser storage. The lines land in the cart of the browser that opened the link: an app's in-app browser keeps its own cart, so let the buyer finish there, or open the link in the system browser.
- Treat the link as a secret. It is not single-use: each browser that opens it gets the lines into its own cart once, and an order from such a cart can complete your checkout. Send it to the buyer only.
- It works while the checkout is open, until
expires_at. Afterwards it answers HTTP410with a page that only links to the shop's home page. - After the buyer has opened it, the checkout is frozen. A
PUTanswers the last state with ahanded_offwarning and changes nothing, and aGETlooks exactly as before. If the buyer wants a different basket, build a new checkout.
Step 8: wait for the order
There are no webhooks: read the checkout until it is completed or
canceled, starting as soon as you gave the buyer the link.
for wait in 5 10 20 40 60 60 60; do
sleep "$wait"
curl -sS -o checkout.json "$API/checkout-sessions/$CHECKOUT" -H "UCP-Agent: $AGENT"
STATUS=$(jq -r .status checkout.json)
echo "$STATUS"
[ "$STATUS" = completed ] || [ "$STATUS" = canceled ] && break
done
jq '.order' checkout.json
let current = checkout;
for (let wait = 5; ['incomplete', 'requires_escalation'].includes(current.status); wait = Math.min(wait * 2, 60)) {
await sleep(wait * 1000);
current = await ucp('GET', `${API}/checkout-sessions/${checkout.id}`);
}
console.log(current.status, current.order); // completed { id: '8e1c4f7a-…', label: 'SK2600417', permalink_url: '…' }
current, wait = checkout, 5
while current["status"] in ("incomplete", "requires_escalation"):
time.sleep(wait)
wait = min(wait * 2, 60)
current = ucp("GET", f"{API}/checkout-sessions/{checkout['id']}")
print(current["status"], current.get("order")) # completed {'id': '8e1c4f7a-…', 'label': 'SK2600417', …}
{
"ucp": {
"version": "2026-04-08",
"capabilities": {
"dev.ucp.shopping.checkout": [
{
"version": "2026-04-08"
}
],
"dev.ucp.shopping.fulfillment": [
{
"version": "2026-04-08"
}
],
"dev.ucp.shopping.discount": [
{
"version": "2026-04-08"
}
]
},
"payment_handlers": {}
},
"id": "3d7b9e2a-6f14-4c58-9b3e-8a2f1c6d4e97",
"buyer": {
"email": "jana.novakova@example.com",
"last_name": "Nováková",
"first_name": "Jana",
"phone_number": "+421 905 123 456"
},
"links": [
{
"url": "https://shop.example.com/obchodne-podmienky",
"type": "terms_of_service",
"title": "Obchodné podmienky"
},
{
"url": "https://shop.example.com/ochrana-osobnych-udajov",
"type": "privacy_policy",
"title": "Ochrana osobných údajov"
}
],
"status": "completed",
"totals": [
{
"type": "subtotal",
"amount": 71880
},
{
"type": "discount",
"amount": -5844,
"display_text": "JESEN10"
},
{
"type": "fulfillment",
"amount": 490
},
{
"type": "total",
"amount": 66526
}
],
"context": {
"language": "sk",
"address_country": "SK"
},
"currency": "EUR",
"messages": [],
"discounts": {
"codes": [
"JESEN10"
],
"applied": [
{
"code": "JESEN10",
"title": "JESEN10",
"amount": 5844,
"method": "across"
}
]
},
"expires_at": "2026-10-27T09:21:30.551Z",
"line_items": [
{
"id": "f6a3c8e1-9d27-4b54-8e6a-2c1f7b9d3a58",
"item": {
"id": "1e8a6c4f-2b93-4d71-9f05-6a3c8e2b1d47",
"price": 69900,
"title": "Trek Marlin 5",
"image_url": "https://shop.example.com/api/public/uploads/c4d1e5a2-8b3f-4a6c-9e07-5f2d8a1b3c40/products/trek-marlin-5-modra.webp"
},
"totals": [
{
"type": "subtotal",
"amount": 69900
},
{
"type": "total",
"amount": 69900
}
],
"quantity": 1
},
{
"id": "2e9d5b7a-4c83-4f16-a9b2-6d8e1c3f7a49",
"item": {
"id": "b5f81c3d-6e49-4a2b-8d73-9c0e4f1a6b25",
"price": 990,
"title": "Fľaša na vodu 750 ml"
},
"totals": [
{
"type": "subtotal",
"amount": 1980
},
{
"type": "total",
"amount": 1980
}
],
"quantity": 2
}
],
"fulfillment": {
"methods": [
{
"id": "shipping",
"type": "shipping",
"groups": [
{
"id": "default",
"options": [
{
"id": "0c5e8a3f-7b21-4d96-8f4c-9e2a6d1b3c75",
"title": "GLS kuriér",
"totals": [
{
"type": "total",
"amount": 490
}
],
"description": "Doručenie na adresu do 1–2 pracovných dní"
},
{
"id": "6b2d9f4e-3a17-4c68-9d5b-1f8e3a7c2b40",
"title": "Packeta – výdajné miesto",
"totals": [
{
"type": "total",
"amount": 290
}
],
"description": "Delivery in 2–3 business days"
}
],
"line_item_ids": [
"f6a3c8e1-9d27-4b54-8e6a-2c1f7b9d3a58",
"2e9d5b7a-4c83-4f16-a9b2-6d8e1c3f7a49"
],
"selected_option_id": "0c5e8a3f-7b21-4d96-8f4c-9e2a6d1b3c75"
}
],
"destinations": [
{
"id": "destination",
"last_name": "Nováková",
"first_name": "Jana",
"postal_code": "811 01",
"street_address": "Hlavná 12",
"address_country": "SK",
"address_locality": "Bratislava"
}
],
"line_item_ids": [
"f6a3c8e1-9d27-4b54-8e6a-2c1f7b9d3a58",
"2e9d5b7a-4c83-4f16-a9b2-6d8e1c3f7a49"
],
"selected_destination_id": "destination"
}
]
},
"order": {
"id": "8e1c4f7a-2d95-4b36-a7e8-5c3f9b1d6a24",
"label": "SK2600417",
"permalink_url": "https://shop.example.com/order-confirmation?orderNumber=SK2600417&orderId=8e1c4f7a-2d95-4b36-a7e8-5c3f9b1d6a24"
}
}GET after the buyer opened continue_url and placed order SK2600417 on the shop (step 6). The body is the last answer the platform received (step 4) without continue_url, with status: "completed", messages: [] and order appended. It does not reflect what the buyer changed on the shop. “Completed” means placed, not paid.
- One request at a time per checkout. A read of a checkout waits behind
every other request on it, and can answer
503withRetry-After: 1when the checkout is busy. A flood of reads can also delay the buyer's handoff. - Cadence. Every 5–10 seconds in the first minutes after you gave the link, then once a minute, is plenty. Reads are limited to 600 per minute per IP address and operation, across every shop and session your IP calls; plan for half of that (see Rate limits).
- Deadline. Only your writes extend
expires_at, and after the buyer has opened the link not even they do. The first read afterexpires_atcancels a checkout that no order completed, so one read after the deadline gives the final answer. completedmeans placed, not paid.order.idis the shop's order id,order.labelits order number (absent when the shop has none), andorder.permalink_urlthe shop's confirmation page. That page is built from its address and shows no order status.- The body is your last quote, not the order.
line_itemsandtotalsare what the checkout held when the buyer left; what the buyer changed on the shop is not reflected.
Some placed orders never complete the checkout: when the buyer signs in on the
shop's checkout after a guest handoff (the order then comes from another
cart), when they removed every item of your checkout before ordering, or when
the checkout was canceled first: by you, or after expires_at by the next
request that read it. Completion is also best effort: if it fails on
Monokaido's side, the failure is only logged. Your checkout then ends
canceled. Do not tell the buyer that no order exists; the shop's own
confirmation is the source of truth.
Complete scripts
The two scripts below run the whole flow against one shop: discovery, a
search that follows the cursor, a cart, its update, the checkout, the buyer
and address, the first offered shipping option, the continue_url, and
polling until the order. They show the error handling a real integration
needs:
- Protocol errors (an HTTP error status with
codeandcontent) stop the script, except the retryable ones. - Business errors (HTTP
200withucp.status: "error") stop it with the message; other messages are printed and the flow goes on. - One
Idempotency-Keyper logical write, generated once and sent again on every retry of that write, so a retried create never makes a second session. - Retries honour
Retry-Afteron429and503, and on409while the same request is still running. A500, answers that are not JSON (the web server's own pages for502,504and its rate limit) and network errors are retried with exponential backoff. A409withoutRetry-Aftermeans the key was reused for a different request, which is a bug, so the script stops. - Polling grows its interval from
POLL_SECONDSto one minute and stops atcompleted,canceled, the checkout's deadline orMAX_POLL_MINUTES.
| Variable | Default | Meaning |
|---|---|---|
SHOP | https://shop.example.com | the shop's origin |
PROFILE_URL | https://platform.example.com/.well-known/ucp | your platform profile |
QUERY | marlin | the search text |
LANGUAGE | sk | context.language and Accept-Language |
DISCOUNT_CODE | empty | a code to apply in step 4 |
POLL_SECONDS | 5 | the first polling interval, in seconds |
MAX_POLL_MINUTES | 30 | stop polling after this many minutes |
The buyer and the address are sample data: replace them with your buyer's.
// ucp-quickstart.mjs — one whole UCP purchase in a Monokaido shop.
// Node 20+, no dependencies. SHOP=https://shop.example.com node ucp-quickstart.mjs
import { randomUUID } from 'node:crypto';
import { setTimeout as sleep } from 'node:timers/promises';
const env = process.env;
const SHOP = (env.SHOP ?? 'https://shop.example.com').replace(/\/+$/, '');
const PROFILE_URL = env.PROFILE_URL ?? 'https://platform.example.com/.well-known/ucp';
const QUERY = env.QUERY ?? 'marlin';
const LANGUAGE = env.LANGUAGE ?? 'sk';
const DISCOUNT_CODE = env.DISCOUNT_CODE ?? '';
const POLL_SECONDS = Number(env.POLL_SECONDS ?? 5);
const MAX_POLL_MINUTES = Number(env.MAX_POLL_MINUTES ?? 30);
const BUYER = { first_name: 'Jana', last_name: 'Nováková', email: 'jana.novakova@example.com', phone_number: '+421 905 123 456' };
const ADDRESS = {
id: 'home', first_name: 'Jana', last_name: 'Nováková', street_address: 'Hlavná 12',
address_locality: 'Bratislava', postal_code: '811 01', address_country: 'SK',
};
const RETRYABLE = new Set([429, 500, 502, 503, 504]);
class ProtocolError extends Error {
constructor(status, body) {
super(`HTTP ${status} ${body.code ?? ''}: ${body.content ?? ''}`);
this.status = status;
this.body = body;
}
}
// One logical call. A write gets ONE Idempotency-Key that every retry reuses,
// so a retried create can never make a second cart or checkout.
async function call(method, url, body, { write = false } = {}) {
const key = write ? randomUUID() : undefined;
for (let attempt = 1; ; attempt++) {
let res;
try {
res = await fetch(url, {
method,
headers: {
'UCP-Agent': `profile="${PROFILE_URL}"`,
'Accept-Language': LANGUAGE,
...(body !== undefined && { 'Content-Type': 'application/json' }),
...(key && { 'Idempotency-Key': key }),
},
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(65_000), // the web server gives up after 60 s
});
} catch (err) {
// No answer: the write may or may not have run. The same key makes the retry safe.
if (attempt >= 5) throw err;
await sleep(2 ** attempt * 1000);
continue;
}
const json = (res.headers.get('content-type') ?? '').includes('application/json');
const data = json ? await res.json() : { content: `${res.status} page from the web server` };
if (res.ok) return data;
// 409 with Retry-After = "same request still running"; without it, the key was reused for another request.
const retry = RETRYABLE.has(res.status) || (res.status === 409 && res.headers.has('retry-after'));
if (!retry || attempt >= 5) throw new ProtocolError(res.status, data);
const wait = Number(res.headers.get('retry-after')) || 2 ** attempt;
console.warn(` ${res.status} ${data.code ?? '(not JSON)'} — retrying in ${wait} s${key ? ' with the same key' : ''}`);
await sleep(wait * 1000);
}
}
// HTTP 200 can still mean "nothing came back": ucp.status "error" + messages.
function resource(data, what, { quiet = false } = {}) {
if (data.ucp?.status === 'error') {
const m = data.messages?.[0] ?? {};
throw new Error(`${what}: ${m.code} — ${m.content}`);
}
if (!quiet) for (const m of data.messages ?? []) console.log(` [${m.type}${m.severity ? '/' + m.severity : ''}] ${m.code}: ${m.content}`);
return data;
}
const money = ({ amount, currency }) => `${(amount / 100).toFixed(2)} ${currency}`; // 2 decimals: EUR, CZK, HUF, PLN
const total = (totals, currency) => money({ amount: totals.find((t) => t.type === 'total').amount, currency });
console.log(`1. Discover ${SHOP}`);
const profile = await call('GET', `${SHOP}/.well-known/ucp`);
const API = profile.ucp.services['dev.ucp.shopping'].find((s) => s.transport === 'rest').endpoint;
if (!profile.ucp.capabilities['dev.ucp.shopping.checkout']) {
throw new Error('This shop takes no orders over UCP (catalog mode): only search and lookup are offered.');
}
console.log(`2. Search "${QUERY}"`);
let product, variant, cursor;
for (let page = 1; page <= 5 && !variant; page++) {
const body = { query: QUERY, pagination: { limit: 10, ...(cursor && { cursor }) }, context: { language: LANGUAGE } };
const result = resource(await call('POST', `${API}/catalog/search`, body), 'search');
product = result.products.find((p) => p.variants.some((v) => v.availability.available));
variant = product?.variants.find((v) => v.availability.available);
if (!result.pagination.has_next_page) break; // a short (even empty) page is normal: keep following the cursor
cursor = result.pagination.cursor;
}
if (!variant) throw new Error(`Nothing available for "${QUERY}"`);
console.log(` ${product.title} — ${variant.title}, ${money(variant.price)}`);
console.log('3. Create a cart');
let cart = resource(await call('POST', `${API}/carts`, {
line_items: [{ item: { id: variant.id }, quantity: 1 }],
buyer: { email: BUYER.email },
context: { language: LANGUAGE, address_country: ADDRESS.address_country },
}, { write: true }), 'create cart');
console.log(` cart ${cart.id}: ${total(cart.totals, cart.currency)}`);
console.log('4. Update the cart (PUT replaces line_items: send every line)');
cart = resource(await call('PUT', `${API}/carts/${cart.id}`, {
line_items: cart.line_items.map((l) => ({ item: { id: l.item.id }, quantity: l.quantity })),
...(DISCOUNT_CODE && { discounts: { codes: [DISCOUNT_CODE] } }),
}, { write: true }), 'update cart');
console.log(` ${total(cart.totals, cart.currency)}`);
console.log('5. Turn the cart into a checkout');
let checkout = resource(await call('POST', `${API}/checkout-sessions`, { cart_id: cart.id }, { write: true }), 'create checkout');
const put = async (body) =>
resource(await call('PUT', `${API}/checkout-sessions/${checkout.id}`, body, { write: true }), 'update checkout');
console.log('6. Buyer, address and shipping until requires_escalation');
checkout = await put({ buyer: BUYER, fulfillment: { methods: [{ type: 'shipping', destinations: [ADDRESS], selected_destination_id: 'home' }] } });
for (let round = 1; checkout.status === 'incomplete'; round++) {
const blocking = checkout.messages.filter((m) => m.type === 'error' && m.severity === 'recoverable');
const options = checkout.fulfillment?.methods?.[0]?.groups?.[0]?.options ?? [];
const onlyTheOption = blocking.length > 0 && blocking.every((m) => m.path?.endsWith('.selected_option_id'));
if (round > 3 || !onlyTheOption || options.length === 0) {
throw new Error(`The agent cannot finish this checkout: ${blocking.map((m) => `${m.code} (${m.content})`).join('; ')}`);
}
const option = options[0]; // the shop's own order; a real agent lets the buyer choose
console.log(` shipping: ${option.title}, ${total(option.totals, checkout.currency)}`);
checkout = await put({ fulfillment: { methods: [{ type: 'shipping', groups: [{ selected_option_id: option.id }] }] } });
}
if (checkout.status !== 'requires_escalation') throw new Error(`Unexpected status ${checkout.status}`);
console.log(`7. Ready: ${total(checkout.totals, checkout.currency)}. Give the buyer this link — they pay on the shop:`);
console.log(`\n ${checkout.continue_url}\n`);
for (const link of checkout.links) console.log(` ${link.type}: ${link.url}`);
console.log('8. Waiting for the order (Ctrl+C stops; the checkout stays open until expires_at)');
const deadline = Math.min(Date.parse(checkout.expires_at) + 60_000, Date.now() + MAX_POLL_MINUTES * 60_000);
for (let wait = POLL_SECONDS; ; wait = Math.min(wait * 1.5, 60)) {
await sleep(wait * 1000);
// One request at a time per session: a GET of a checkout queues behind every other request on it.
const now = resource(await call('GET', `${API}/checkout-sessions/${checkout.id}`), 'poll', { quiet: true });
if (now.status === 'completed') {
console.log(` Order ${now.order.label ?? now.order.id} placed (placed, not necessarily paid): ${now.order.permalink_url}`);
break;
}
if (now.status === 'canceled') { console.log(' The checkout was canceled or expired without an order.'); break; }
if (Date.now() > deadline) { console.log(` Still ${now.status}; stopped polling.`); break; }
}
#!/usr/bin/env python3
"""ucp_quickstart.py — one whole UCP purchase in a Monokaido shop.
Python 3.9+ and requests (pip install requests).
SHOP=https://shop.example.com python3 ucp_quickstart.py
"""
import os
import time
import uuid
from datetime import datetime
import requests
SHOP = os.environ.get("SHOP", "https://shop.example.com").rstrip("/")
PROFILE_URL = os.environ.get("PROFILE_URL", "https://platform.example.com/.well-known/ucp")
QUERY = os.environ.get("QUERY", "marlin")
LANGUAGE = os.environ.get("LANGUAGE", "sk")
DISCOUNT_CODE = os.environ.get("DISCOUNT_CODE", "")
POLL_SECONDS = float(os.environ.get("POLL_SECONDS", "5"))
MAX_POLL_MINUTES = float(os.environ.get("MAX_POLL_MINUTES", "30"))
BUYER = {"first_name": "Jana", "last_name": "Nováková",
"email": "jana.novakova@example.com", "phone_number": "+421 905 123 456"}
ADDRESS = {"id": "home", "first_name": "Jana", "last_name": "Nováková", "street_address": "Hlavná 12",
"address_locality": "Bratislava", "postal_code": "811 01", "address_country": "SK"}
RETRYABLE = {429, 500, 502, 503, 504}
http = requests.Session()
http.headers.update({"UCP-Agent": f'profile="{PROFILE_URL}"', "Accept-Language": LANGUAGE})
class ProtocolError(Exception):
def __init__(self, status, body):
super().__init__(f"HTTP {status} {body.get('code', '')}: {body.get('content', '')}")
self.status, self.body = status, body
def call(method, url, body=None, write=False):
"""One logical call. A write gets ONE Idempotency-Key that every retry reuses,
so a retried create can never make a second cart or checkout."""
headers = {"Idempotency-Key": str(uuid.uuid4())} if write else {}
for attempt in range(1, 6):
try:
# json= sets Content-Type: application/json; the web server gives up after 60 s
r = http.request(method, url, json=body, headers=headers, timeout=65)
except requests.RequestException:
# No answer: the write may or may not have run. The same key makes the retry safe.
if attempt == 5:
raise
time.sleep(2 ** attempt)
continue
is_json = "application/json" in r.headers.get("Content-Type", "")
data = r.json() if is_json else {"content": f"{r.status_code} page from the web server"}
if r.ok:
return data
# 409 with Retry-After = "same request still running"; without it, the key was reused for another request.
retry = r.status_code in RETRYABLE or (r.status_code == 409 and "Retry-After" in r.headers)
if not retry or attempt == 5:
raise ProtocolError(r.status_code, data)
wait = int(r.headers.get("Retry-After") or 2 ** attempt)
print(f" {r.status_code} {data.get('code', '(not JSON)')} — retrying in {wait} s"
+ (" with the same key" if write else ""))
time.sleep(wait)
def resource(data, what, quiet=False):
"""HTTP 200 can still mean "nothing came back": ucp.status "error" + messages."""
if data.get("ucp", {}).get("status") == "error":
m = (data.get("messages") or [{}])[0]
raise RuntimeError(f"{what}: {m.get('code')} — {m.get('content')}")
if not quiet:
for m in data.get("messages", []):
severity = f"/{m['severity']}" if m.get("severity") else ""
print(f" [{m['type']}{severity}] {m['code']}: {m['content']}")
return data
def money(amount, currency):
return f"{amount / 100:.2f} {currency}" # 2 decimals: EUR, CZK, HUF, PLN
def total(totals, currency):
return money(next(t["amount"] for t in totals if t["type"] == "total"), currency)
print(f"1. Discover {SHOP}")
profile = call("GET", f"{SHOP}/.well-known/ucp")
API = next(s["endpoint"] for s in profile["ucp"]["services"]["dev.ucp.shopping"] if s["transport"] == "rest")
if "dev.ucp.shopping.checkout" not in profile["ucp"]["capabilities"]:
raise SystemExit("This shop takes no orders over UCP (catalog mode): only search and lookup are offered.")
print(f'2. Search "{QUERY}"')
product = variant = cursor = None
for page in range(5):
pagination = {"limit": 10, **({"cursor": cursor} if cursor else {})}
result = resource(call("POST", f"{API}/catalog/search",
{"query": QUERY, "pagination": pagination, "context": {"language": LANGUAGE}}), "search")
for p in result["products"]:
variant = next((v for v in p["variants"] if v["availability"]["available"]), None)
if variant:
product = p
break
if variant or not result["pagination"]["has_next_page"]:
break # a short (even empty) page is normal: keep following the cursor
cursor = result["pagination"]["cursor"]
if not variant:
raise SystemExit(f'Nothing available for "{QUERY}"')
print(f" {product['title']} — {variant['title']}, {money(**variant['price'])}")
print("3. Create a cart")
cart = resource(call("POST", f"{API}/carts", {
"line_items": [{"item": {"id": variant["id"]}, "quantity": 1}],
"buyer": {"email": BUYER["email"]},
"context": {"language": LANGUAGE, "address_country": ADDRESS["address_country"]},
}, write=True), "create cart")
print(f" cart {cart['id']}: {total(cart['totals'], cart['currency'])}")
print("4. Update the cart (PUT replaces line_items: send every line)")
update = {"line_items": [{"item": {"id": l["item"]["id"]}, "quantity": l["quantity"]} for l in cart["line_items"]]}
if DISCOUNT_CODE:
update["discounts"] = {"codes": [DISCOUNT_CODE]}
cart = resource(call("PUT", f"{API}/carts/{cart['id']}", update, write=True), "update cart")
print(f" {total(cart['totals'], cart['currency'])}")
print("5. Turn the cart into a checkout")
checkout = resource(call("POST", f"{API}/checkout-sessions", {"cart_id": cart["id"]}, write=True), "create checkout")
def put(body):
return resource(call("PUT", f"{API}/checkout-sessions/{checkout['id']}", body, write=True), "update checkout")
print("6. Buyer, address and shipping until requires_escalation")
checkout = put({"buyer": BUYER, "fulfillment": {"methods": [
{"type": "shipping", "destinations": [ADDRESS], "selected_destination_id": "home"}]}})
for round_ in range(1, 5):
if checkout["status"] != "incomplete":
break
blocking = [m for m in checkout["messages"] if m["type"] == "error" and m.get("severity") == "recoverable"]
groups = (checkout.get("fulfillment") or {}).get("methods", [{}])[0].get("groups", [{}])
options = groups[0].get("options", []) if groups else []
only_the_option = blocking and all(m.get("path", "").endswith(".selected_option_id") for m in blocking)
if round_ > 3 or not only_the_option or not options:
raise SystemExit("The agent cannot finish this checkout: "
+ "; ".join(f"{m['code']} ({m['content']})" for m in blocking))
option = options[0] # the shop's own order; a real agent lets the buyer choose
print(f" shipping: {option['title']}, {total(option['totals'], checkout['currency'])}")
checkout = put({"fulfillment": {"methods": [{"type": "shipping", "groups": [{"selected_option_id": option["id"]}]}]}})
if checkout["status"] != "requires_escalation":
raise SystemExit(f"Unexpected status {checkout['status']}")
print(f"7. Ready: {total(checkout['totals'], checkout['currency'])}. Give the buyer this link — they pay on the shop:")
print(f"\n {checkout['continue_url']}\n")
for link in checkout["links"]:
print(f" {link['type']}: {link['url']}")
print("8. Waiting for the order (Ctrl+C stops; the checkout stays open until expires_at)")
expires = datetime.fromisoformat(checkout["expires_at"].replace("Z", "+00:00")).timestamp()
deadline = min(expires + 60, time.time() + MAX_POLL_MINUTES * 60)
wait = POLL_SECONDS
while True:
time.sleep(wait)
# One request at a time per session: a GET of a checkout queues behind every other request on it.
now = resource(call("GET", f"{API}/checkout-sessions/{checkout['id']}"), "poll", quiet=True)
if now["status"] == "completed":
order = now["order"]
print(f" Order {order.get('label', order['id'])} placed (placed, not necessarily paid): {order['permalink_url']}")
break
if now["status"] == "canceled":
print(" The checkout was canceled or expired without an order.")
break
if time.time() > deadline:
print(f" Still {now['status']}; stopped polling.")
break
wait = min(wait * 1.5, 60)
Run them with:
SHOP=https://shop.example.com PROFILE_URL=https://platform.example.com/.well-known/ucp node ucp-quickstart.mjs
SHOP=https://shop.example.com PROFILE_URL=https://platform.example.com/.well-known/ucp python3 ucp_quickstart.py
What can go wrong
| What you see | Why | What to do |
|---|---|---|
404 not_found, "No UCP business on this host" | The host serves no UCP: not a custom domain of a shop with verified DNS and an active certificate, UCP off, storefront off, the storefront key inactive or expired, or the e-shop module off. The answer never says which. | Check the domain with the merchant, and call the host of the profile's endpoint. |
400 invalid_profile_url | UCP-Agent is missing or malformed. | Send profile="https://…" with the double quotes and a lower-case profile. |
424 profile_unreachable | The shop could not fetch your profile: not 200, a redirect, too slow, over 256 KiB, or a private address. | Fix the hosting, then wait 60 seconds: the failure is remembered that long. |
422 profile_malformed or version_unsupported | The profile is not a UCP profile, or its ucp.version is not 2026-04-08. | Serve the profile shown above. |
200 with capabilities_incompatible | Your profile does not declare the capability, or the shop is in catalog mode. | Add the capability to your profile; for a catalog-mode shop, stop after the search. |
200 with not_found on a cart or checkout | The id is unknown, belongs to another shop, or the cart expired or was canceled. | Start again from step 3. |
400 invalid_request | A field has the wrong shape; content names it, for example line_items must be an array on a cart PUT without lines. "Idempotency-Key is not valid" means the key is not 1 to 255 visible ASCII characters. | Fix the request: it was not executed. |
409 idempotency_conflict without Retry-After | The key was used for a different request: another operation, path id or body. | Use a new key for each logical write. |
409 idempotency_conflict with Retry-After: 2 | The first request with this key is still running. | Wait and repeat the same request with the same key. |
503 "Another request on this session is being processed" | Another request holds the cart or checkout. | Retry after Retry-After (1 s) with the same key; send one request at a time per session. |
503 "This shop is temporarily unavailable" | The shop is suspended or its lookup failed. | Retry after Retry-After (60 s). |
503 "Too many new platform profiles from this client" or "Platform profile discovery is busy" | The shop is fetching too many profiles it has not cached: more than 5 new ones a minute from your IP, or too many overall. | Retry after Retry-After; use one stable profile URL. |
429 rate_limited (JSON) | The operation's limit per IP was reached: 600 reads, 300 writes per minute. | Wait Retry-After (60 s). |
429, 413, 502 or 504 as an HTML page | The web server in front of the shop: over 100 requests per second, a body over 1 MiB, or the API unreachable or slower than 60 s. | Back off and retry; for 502 and 504 repeat writes with the same key. Keep bodies small. |
The checkout stays incomplete | A recoverable error is left. | Read messages and send what path names (step 6). |
handed_off | The buyer has already taken the basket to the shop. | Stop writing and poll; for a new basket, build a new checkout. |
The checkout never becomes completed | The buyer did not order, signed in on the shop's checkout, removed the items, or the checkout was canceled first; rarely, the completion itself failed (it is best effort). | Stop at the deadline; the shop's confirmation is the source of truth. |
Every code, with what a client should do about it, is in Errors and messages. Retries, concurrent requests, limits and lifetimes are in Idempotency, concurrency and limits.