Concepts and lifecycle
The business profile, the UCP-Agent header, capability negotiation, carts and checkouts, statuses, money, languages and the continue_url handoff.
On this page
- One shop, one domain
- Alias domains and the primary domain
- How fast a merchant's change reaches you
- The business profile
- Your platform profile and the UCP-Agent header
- What the profile must contain
- The header
- How the shop fetches it
- Caching and budgets
- When the profile cannot be used
- What the header does not do
- Capability negotiation
- The ucp envelope
- When a capability is missing
- Carts and checkouts
- When no line can be bought
- Identifiers
- From cart to checkout
- Checkout statuses
- What "ready" requires
- How the status is derived
- Closed checkouts
- Carts have no status
- Updating a session
- Money, totals and tax
- Language and currency
- When stock or prices change
- Catalog mode
- Lifetimes and expiry
- Handing the buyer over
- The link
- What happens when the buyer opens it
- What travels and what does not
- Opening the link more than once
- After the handoff the session is frozen
- Signed in or guest
- Order completion
- When a checkout becomes completed
- What the answer contains
- When an order does not complete it
- Polling
- Putting it together
This chapter is the model behind every UCP call to a Monokaido shop: which shop answers, what its profile promises, how your platform introduces itself, what the two sides negotiate, how carts and checkouts behave, and how a purchase leaves the API for the shop's own checkout. The exact fields are in the API reference, every code in Errors and messages, and retries, concurrency and rate limits in Idempotency, concurrency and limits.
The examples use the shop https://shop.example.com and a platform whose
profile is https://platform.example.com/.well-known/ucp.
One shop, one domain
Monokaido serves many shops from the same servers. A UCP request finds its shop
by one thing only: the host name it was sent to. There is no API key, no
account and no shop id in the path. The Host header of the request is the
shop.
A host serves UCP only while every one of these holds:
| Condition | Set by |
|---|---|
| The host is a custom domain of the shop, with verified DNS and an active TLS certificate | the merchant, under Custom Domains |
| The merchant switched Buying through AI agents (UCP) on for that shop | the merchant |
| The shop's built-in storefront is on, and its storefront key is active and not past its expiry date | the merchant |
| The company's E-shop module is on | the merchant's subscription |
If any of them fails, the profile and every /ucp/v1 call on the host answer
404 with {"code":"not_found","content":"No UCP business on this host"},
and the continue_url page answers 404 with an empty HTML page. The answer
is the same whatever the reason, on purpose. Monokaido's own *.shop.monokaido.com addresses never
serve UCP. A shop that has UCP switched on but whose company is deactivated or
locked for an unpaid subscription answers 503 service_unavailable with
Retry-After: 60 to every request instead.
Use the paths exactly as published: /.well-known/ucp (no trailing slash) and
/ucp/v1/… in lower case. Other spellings can reach the shop's web pages
instead of the API. Plain http:// is redirected to https:// with a 301;
call https:// directly.
Alias domains and the primary domain
A shop can have several custom domains. Every one of them that is served
answers for the same shop — the same catalog, and the same carts and
checkouts: a session created on one is found on the others. Every URL the shop
publishes, however, names its primary domain: the profile's
endpoint, every continue_url, order.permalink_url, product URLs, the
shop's own image URLs and the continue_url in error answers. When the primary
domain is not served itself (DNS not verified, or no active certificate), or no
domain is marked primary, each served domain names itself instead.
| You call | You get back |
|---|---|
https://shop.example.net/.well-known/ucp (an alias of the shop) | "endpoint": "https://shop.example.com/ucp/v1" |
POST https://shop.example.net/ucp/v1/carts | "continue_url": "https://shop.example.com/ucp/continue/…" |
Discover a shop through its profile, keep the endpoint it publishes, and send
every later call to that host. Give the buyer exactly the continue_url you
received.
How fast a merchant's change reaches you
A change the merchant saves in the admin applies at once: UCP switched on or
off, the storefront or its key switched off, a domain removed or made primary.
So does the e-shop module being switched off or the company being deactivated
or locked. A newly added domain is served only once its DNS is verified and
its certificate is active, and from then on within 30 seconds; a key's expiry
date, once reached, also takes up to 30 seconds. Your copy of the business
profile can be up to five minutes old (Cache-Control: public, max-age=300),
so for a few minutes after a switch-off your calls get the 404 above. Read a
404 not_found from the profile as "this shop does not serve UCP now".
The business profile
GET /.well-known/ucp on the shop's domain returns the business profile: the
shop in protocol terms. It needs no UCP-Agent header and no other header,
never redirects, answers Cache-Control: public, max-age=300, and counts
against the read rate limit (600 requests a minute per IP).
{
"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.
| Key | Value | What it is for |
|---|---|---|
ucp.version | "2026-04-08", always | The protocol version the shop speaks. Your platform profile must declare the same. |
ucp.services | one key, dev.ucp.shopping, holding exactly one entry | The shopping service. There is no MCP, A2A or embedded binding. |
….transport | "rest" | The only transport. |
….endpoint | https://<primary domain>/ucp/v1 | The base of every API call. |
….version | "2026-04-08" | The version of the binding. |
….spec, ….schema | https://ucp.dev/2026-04-08/specification/overview, https://ucp.dev/2026-04-08/services/shopping/rest.openapi.json | The official specification and REST schema. Monokaido's exact behaviour is in this manual's OpenAPI document. |
ucp.capabilities | capability name → an array with one {version, spec, schema, extends?} | What the shop offers right now: six capabilities, or two in catalog mode. |
ucp.payment_handlers | {}, always | Nothing is paid through UCP: the buyer pays on the shop. |
Not in the profile: supported_versions (there is one version),
signing_keys (Monokaido sends no webhooks and signs nothing), and no shop
name or logo. The only parts that differ from shop to shop are the host in
endpoint and, in catalog mode, the shorter capability list. A profile can
change when the merchant changes the shop, so re-read it when your copy
expires rather than keeping it forever.
Your platform profile and the UCP-Agent header
Every call under /ucp/v1 names your platform's profile — a JSON document
you host — in the UCP-Agent header:
UCP-Agent: profile="https://platform.example.com/.well-known/ucp"
The shop fetches that document, checks its protocol version and intersects the
capabilities it declares with the shop's own. The business profile and the
continue_url routes never read the header.
What the profile must contain
A profile that unlocks everything a shop can offer:
{
"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" }]
}
}
}
| Part | Rule |
|---|---|
| The document | A JSON object with an object ucp. |
ucp.version | A string of the form YYYY-MM-DD. To be served it must be exactly 2026-04-08. |
ucp.capabilities | Optional. Capability name → array of { "version": "YYYY-MM-DD" }. Names are lower case with at least one dot (dev.ucp.shopping.cart). The first 20 entries of each name are read; an entry without a valid version is dropped. |
| Everything else | Ignored — services, payment_handlers, signing keys, spec, schema, and your own extends. |
A profile without ucp.capabilities is valid but unlocks nothing: every
operation then answers capabilities_incompatible.
The header
The header is a small subset of an RFC 8941 dictionary, at most 4096 characters:
- The
profilemember must be a quoted string,profile="https://…". An unquotedprofile=https://…is refused. - Keys are lower case (
Profile=is refused). Other members and;parametersare accepted and ignored. - Inside the string only printable ASCII is allowed, with the escapes
\"and\\. - When
profileappears twice, the last one counts. A header that fails to parse anywhere is ignored as a whole.
The URL must start with https://, be at most 1000 characters long, and contain
no whitespace and no user name or password. Any port, path and query string is
accepted: the specification's /.well-known/ucp path is not enforced, though it
is the natural place for the document. A header that breaks any of these rules
is answered with 400 invalid_profile_url.
How the shop fetches it
| Aspect | Behaviour |
|---|---|
| Request | GET with Accept: application/json and User-Agent: Monokaido-UCP/1.0 (+https://monokaido.com). The HTTP client also sends Host and Connection: close; no other header is sent. |
| Redirects | Not followed: a redirect is a failure. |
| Status | Only 200 counts. Any other status, 304 included, is a failure. |
| Size and time | At most 256 KiB; 3 s without data, 5 s in all (DNS, connection and body). |
| Address | The host must resolve to an address outside the ranges listed below. The address is resolved once and the connection is pinned to it. |
| Body | Read as UTF-8 JSON. Content-Type is not checked. |
The shop refuses a profile host that resolves to one of these ranges:
- IPv4:
0.0.0.0/8,10.0.0.0/8,100.64.0.0/10(carrier-grade NAT),127.0.0.0/8,169.254.0.0/16,172.16.0.0/12,192.168.0.0/16,198.18.0.0/15, and everything from224.0.0.0up (multicast and reserved). - IPv6:
::/96(unspecified, loopback and IPv4-compatible),fe80::/10,fec0::/10,fc00::/7,ff00::/8, and the NAT64 prefixes64:ff9b::/96and64:ff9b:1::/48. An IPv4-mapped address (::ffff:0:0/96) is judged by the IPv4 address it holds.
Every other address is accepted.
Serve the document as plain, uncompressed UTF-8 JSON without a byte-order mark:
the shop sends no Accept-Encoding and parses the body as it arrives.
Caching and budgets
- A fetched profile is cached for the
max-ageof itsCache-Control, clamped to 60 seconds – 1 hour, and for 5 minutes when there is none.no-store,no-cache,privateands-maxageare ignored. - Once that time is up, the cached copy is still used for up to one more hour while a single background refresh runs. A failed refresh keeps the old copy.
- A failed fetch is remembered for 60 seconds: during that time the same error is answered without a new fetch.
- A profile that declares another protocol version is cached like any valid
one. After you correct the version, the shop sees it when its copy expires —
your
max-age, at most one hour. - The cache key is the full URL string, query included, and the cache is kept by the Monokaido server, not per shop.
- Fetching profiles the server does not know yet is budgeted: 5 new profile
URLs a minute per client (one IPv4 address, or one IPv6 /64) and 60 a minute
in all. Over the budget the answer is
503 service_unavailablewith aRetry-After. A profile already known never waits for the budget.
When the profile cannot be used
| HTTP | code | When | What to do |
|---|---|---|---|
| 400 | invalid_profile_url | The header is missing or cannot be parsed, or the URL breaks a rule. | Fix the header. Retrying it unchanged cannot succeed. |
| 424 | profile_unreachable | DNS, connection, TLS, timeout, redirect, a status other than 200, more than 256 KiB, or a private address. | Fix the hosting. The failure is remembered for 60 s. |
| 422 | profile_malformed | The body is not JSON, or has no ucp.version of the form YYYY-MM-DD. | Fix the document. Remembered for 60 s. |
| 422 | version_unsupported | A valid profile of another protocol version. | Declare 2026-04-08. |
| 503 | service_unavailable | The budget for new profiles is spent. | Wait as long as Retry-After says. |
Each of these carries a continue_url pointing at the shop's home page. The
checks run in this order: the shop first (an unknown host answers 404 even
without the header), then the header and the fetch, then the version, then the
negotiation.
What the header does not do
The header identifies your platform; it does not authenticate it. Monokaido
checks no signature, key or certificate, so anyone can name any profile URL.
Your Idempotency-Key values are scoped to the profile URL, but sessions are
not bound to it: the session id alone gives access to a session, so keep ids
private. More in Security and data protection.
Capability negotiation
Every request is answered with what both sides support. The shop declares its capabilities in its business profile, you declare yours in your platform profile, and each request intersects the two.
| Capability | Kind | What it unlocks |
|---|---|---|
dev.ucp.shopping.catalog.search | root | POST /ucp/v1/catalog/search |
dev.ucp.shopping.catalog.lookup | root | POST /ucp/v1/catalog/lookup and POST /ucp/v1/catalog/product |
dev.ucp.shopping.cart | root | Every /ucp/v1/carts route, and cart_id when you create a checkout |
dev.ucp.shopping.checkout | root | Every /ucp/v1/checkout-sessions route |
dev.ucp.shopping.fulfillment | extension of checkout | The fulfillment of a checkout: the address, the shipping options and their fees |
dev.ucp.shopping.discount | extension of checkout and cart | The discounts of carts and checkouts: discount codes |
The negotiation, in order:
- Version. Your profile's
ucp.versionmust equal2026-04-08, compared as a string. Anything else, older or newer, is422 version_unsupportedbefore anything else runs. - Intersection. A shop capability is active when your profile names it —
exactly, case-sensitively — at a version both sides list. The highest common
version wins; today that is always
2026-04-08. - Pruning. An extension stays active only while at least one of its
parents, as the shop declares them, is active; this repeats until nothing
changes.
dev.ucp.shopping.discountsurvives with the cart alone;dev.ucp.shopping.fulfillmentneeds the checkout. Your ownextendsplays no part. - Leftovers. Capabilities only your profile declares (your own
com.example.…names, say) are ignored.
Nothing about the negotiation is stored. Every request negotiates again with the copy of your profile the shop has at that moment.
The ucp envelope
Every JSON answer except a protocol error carries a ucp object:
| Key | When it is there |
|---|---|
version | Always: "2026-04-08". |
status | "error" only when no resource could be returned (a business error). Absent otherwise — read its absence as success. |
capabilities | Always. The operation's root capability and every negotiated extension of it, each as [{ "version": "2026-04-08" }]; {} in a capabilities_incompatible answer. |
payment_handlers | {} on checkout answers that are not errors. |
The order of the keys in a JSON object carries no meaning. Answers built from
stored data — a write replayed with its Idempotency-Key, a closed checkout, a
session after the handoff — come back with their keys in PostgreSQL JSONB
order (shorter keys first, then alphabetically), so not even ucp has to come
first. Read every answer by key.
With a profile that declares all six capabilities:
| Operation | ucp.capabilities lists |
|---|---|
| Catalog search | dev.ucp.shopping.catalog.search |
| Catalog lookup, product | dev.ucp.shopping.catalog.lookup |
| Every cart route | dev.ucp.shopping.cart, dev.ucp.shopping.discount |
| Every checkout route | dev.ucp.shopping.checkout, dev.ucp.shopping.fulfillment, dev.ucp.shopping.discount |
An extension you did not negotiate is missing from the list. A write replayed
with its Idempotency-Key returns the envelope stored with the first answer.
When a capability is missing
The operation's root capability. The answer is HTTP 200 with
ucp.status: "error", ucp.capabilities: {}, the message
capabilities_incompatible and a continue_url to the shop's home page.
Nothing runs: the body is not validated and no Idempotency-Key is used. This
is also the answer to every cart and checkout call on a shop in catalog mode.
{
"ucp": {
"version": "2026-04-08",
"status": "error",
"capabilities": {}
},
"messages": [
{
"type": "error",
"code": "capabilities_incompatible",
"content": "The platform and this business share no version of the capability this operation needs",
"severity": "unrecoverable"
}
],
"continue_url": "https://shop.example.com/"
}Any operation whose root capability your profile does not declare at 2026-04-08 — e.g. a cart call when the profile lists only the catalog capabilities, or any cart / checkout call on a shop in catalog mode. Nothing runs, no Idempotency-Key is used.
An extension. Its fields are ignored without being validated, and the answer lacks its object:
- Without
dev.ucp.shopping.fulfillment, a checkout has nofulfillment, no shipping fee in its totals, and needs no address to be ready. The buyer chooses delivery on the shop. - Without
dev.ucp.shopping.discount, carts and checkouts have nodiscounts, the codes you send are ignored, and a code already on the session is removed at the nextPUT— the totals then show no discount.
Declare all six capabilities at 2026-04-08 unless you deliberately want less.
Carts and checkouts
UCP has two kinds of session. Both are priced by the shop through a cart of its own, with the shop's prices, VAT, markup rules, stock and discount rules — the same rules as its website.
| Cart | Checkout | |
|---|---|---|
| Root capability | dev.ucp.shopping.cart | dev.ucp.shopping.checkout |
| Routes | POST /carts, GET and PUT /carts/{id}, POST /carts/{id}/cancel | POST /checkout-sessions, GET and PUT /checkout-sessions/{id}, POST …/{id}/complete, POST …/{id}/cancel |
| Holds | lines, buyer, context, discount codes | the same, plus shipping, a status, legal links and, once ordered, the order |
status field | none | incomplete, requires_escalation, completed, canceled |
| Shipping | never quoted | quoted with the fulfillment extension |
PUT | line_items required, the whole list each time | line_items optional |
| Lifetime | 7 days from the last create or PUT | 24 hours from the last create or PUT |
| After expiry | every call answers not_found | canceled |
continue_url | in every answer; opens the shop's cart (or the cart's open checkout) | while open; opens the shop's checkout |
| Completed by the buyer's order | never | yes |
Use a cart to collect items before you know who buys them, or to hand a
basket to the buyer as it is. Use a checkout once you have the buyer's
e-mail and want shipping and final totals — and whenever you want to learn
whether an order followed, because only a checkout becomes completed. A cart
is not a prerequisite: a checkout can be created straight from line_items.
When no line can be bought
A create — of a cart, of a checkout from line_items, or of a checkout from a
cart — whose lines all fail leaves you no session. The answer is HTTP 200 with
ucp.status: "error", no id and a single unrecoverable message:
out_of_stock when every message of the request was out_of_stock, otherwise
item_unavailable. Its continue_url is the shop's home page. An empty
line_items is not such a failure: it creates a cart without lines, or a
checkout that is incomplete until it gets lines.
Identifiers
| Identifier | Format | Notes |
|---|---|---|
Session id | lower-case UUID v4 | The only credential of the session. A path id that is not a UUID answers not_found. |
line_items[].id | UUID | The shop's line. Stable while the line exists; new after a language change, and different in a checkout made from a cart. |
line_items[].item.id | variant UUID | Send a variant id. A product id is accepted, but it resolves to the product's active variant created first — not necessarily the featured, available or priced variant that search, lookup and product show — and the answer then shows that variant's id. Any other string, a SKU for instance, and the id of a product the company does not sell give item_unavailable. |
Shipping option id | UUID | The shop's shipping method. |
| Method, group and destination ids | "shipping", "default", "destination" | Constants. |
The lines of an answer come in the shop's order, not yours: match them by
item.id, never by position. A message path such as $.line_items[1]
points into the answer it arrives in.
A line tells you little about its item:
item.titleis the product's title, in the session's language — never the variant's. Two lines of one product (the bike in blue M and in blue L) read the same. Keep the variant you chose — itstitle,optionsandskufrom the catalog — keyed byitem.id, or look the ids up withPOST /ucp/v1/catalog/lookup({"ids": [item.id, …]}), before you show the basket.item.idis checked against the company's products, not this storefront's catalog. A line is accepted for any active variant of a published product of the company; the storefront's exclusion filters and blocked categories, which hide products from search and lookup, are not applied to it. Use only variant ids you got from this shop's catalog: an id cached from another storefront of the same company can be accepted here.quantityanditem.pricecount the unit the shop sells. For goods sold in packages (a box of floor tiles, say) that is one package. UCP does not publish the package content, so a quantity in square metres cannot be converted to packages through UCP.
From cart to checkout
POST /ucp/v1/checkout-sessions with {"cart_id": "<cart id>"} converts a
cart. cart_id counts only when the cart capability is negotiated; otherwise
the request is a direct creation and needs line_items.
| The checkout's | Comes from |
|---|---|
| Lines | The cart's current lines, added anew to the checkout: stock is checked and the shop's current prices apply, so the answer can carry line messages. line_items in the request is ignored. |
| Buyer | The cart. A buyer in the request is validated but not used. |
| Context and language | The cart. A context in the request is validated but not used. |
Shipping (fulfillment) | The request. |
| Discount codes | The request's discounts.codes when the discount extension is negotiated and codes are sent; otherwise the cart's codes. |
- One open checkout per cart. Converting the cart again answers the open
checkout with HTTP 200, and nothing from the new request is applied: change
that checkout with
PUT. - An open checkout of the cart that has expired is canceled first, and a new
one is created (
201). - A cart the buyer has already taken to the shop (or one of whose checkouts
they took) is refused with
200,ucp.status: "error"and the messagehanded_off; the answer'scontinue_urlis the shop's cart page. If the cart still has an open checkout, that checkout is answered instead. - An unknown, canceled or expired
cart_idanswers200 not_foundwith the contentCheckout session not found. - The checkout has lines of its own with new line ids. The cart stays as it was, and later changes to it do not reach the checkout: once converted, work on the checkout.
Checkout statuses
| From | To | When |
|---|---|---|
| (new) | incomplete | Created while something is still missing — a line, the buyer’s e-mail or, with the fulfillment extension, the address or the shipping option — or when the answer carries a line error (an item the shop could not add). |
| (new) | requires_escalation | The same call when nothing is missing. |
incomplete | requires_escalation | A PUT that supplies what was missing. GET and POST …/complete re-derive the status from the current cart too. |
requires_escalation | incomplete | A PUT after which a requirement is no longer met; a GET or POST …/complete, which re-derive the status, can move it back as well. |
incompleterequires_escalation | completed | The buyer opened continue_url and an order placed on the shop from that cart holds at least one of the session’s items. The agent sees it on its next read. POST …/complete never completes a checkout. |
incompleterequires_escalation | canceled | POST …/cancel, or expiry: 24 hours after the creation or the last PUT. The next request that touches the session cancels it. |
completedcanceled | — | Final. A later PUT, POST …/complete or POST …/cancel returns the session unchanged; continue_url no longer works. |
Once the buyer has opened continue_url and the lines moved into their cart, the agent’s PUT no longer changes the session — it returns the last state with the warning handed_off.
| Status | Meaning | continue_url | Your next step |
|---|---|---|---|
incomplete | At least one message is an error with severity recoverable. | yes | PUT what the messages ask for. |
requires_escalation | Nothing you can provide is missing. The message payment_required is listed. | yes | Give continue_url to the buyer. |
completed | The buyer placed an order on the shop from the cart the handoff filled. order is set. | no | Stop polling; show the order. |
canceled | Canceled with POST …/cancel, or expired. | no | Start a new checkout if the buyer still wants to buy. |
The specification's statuses ready_for_complete and complete_in_progress
never occur: the API never completes a checkout itself.
What "ready" requires
| Requirement | Without fulfillment | With fulfillment | Message while it is missing |
|---|---|---|---|
| At least one line | required | required | missing at $.line_items |
A valid buyer.email | required | required | missing at $.buyer.email, also for an address that is not a valid e-mail |
| A destination | — | required | missing at $.fulfillment.methods[0].destinations |
street_address, address_locality, postal_code and address_country in it | — | required | invalid_address |
| The shop ships there | — | required | address_undeliverable |
| A selected option among those offered | — | required while options exist | missing, or invalid_option for one no longer offered |
| No line error in this answer | required | required | item_unavailable, out_of_stock, vat_mix_not_allowed |
| Names, phone number | not required | not required | — |
| A pickup point (Packeta) | — | not required | pickup_point_required, severity requires_buyer_input; it does not block |
A ready checkout is requires_escalation. It always carries payment_required
(severity requires_buyer_input) and a continue_url:
{
"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 status is derived
- The status of an open checkout is derived anew on every create,
PUT,GETandPOST …/complete, from the session as it stands. Anyerrorwith severityrecoverablemakes itincomplete; otherwise it isrequires_escalation. After the handoff it no longer changes (see After the handoff the session is frozen). - Line messages —
out_of_stock,item_unavailable,quantity_adjusted,vat_mix_not_allowed— appear only in the answer to the write that caused them. In that answer the line is already removed, lowered or left as it was, so the nextGETcan returnrequires_escalationfor the same checkout. React to messages in the answer to your write. POST …/completeplaces no order. Like aGET, it answers the current state, derived anew, and does not extend the lifetime.completedcomes only from an order the buyer places on the shop.
Closed checkouts
completed and canceled are final. Every answer is HTTP 200, keeps the
status, has no continue_url and no ucp.status:
| Operation | On completed | On canceled |
|---|---|---|
GET | messages: [], with order | messages: [] |
PUT | checkout_completed | checkout_canceled |
POST …/complete | messages: [] | checkout_canceled |
POST …/cancel | checkout_completed | checkout_canceled |
Nothing is written. A completed checkout's order is never canceled through UCP.
Carts have no status
A cart answer has no status. Canceling a cart answers with the cart as it was
— still carrying a continue_url that no longer works. From then on, and once
the cart expires, every call answers 200 not_found with
Cart not found or has expired. Canceling a cart does not cancel a checkout
made from it: cancel that checkout on its own.
Updating a session
A PUT names fields; what it leaves out keeps its stored value. Some fields
replace what is stored as a whole:
| Field | Sent with a value | Sent as null | Left out |
|---|---|---|---|
line_items | Replaces every line. | 400 | Cart: 400 line_items must be an array. Checkout: lines kept. |
buyer | Replaces the whole buyer. | Clears the buyer. | Kept. |
context | Replaces the whole context. | Clears it (the session's language is kept). | Kept. |
| Shipping destination | Replaced by the chosen destination; an empty destinations list changes nothing. | Kept (fulfillment: null changes nothing). | Kept. It cannot be removed, only replaced. |
| Selected shipping option | Selected. | groups[0].selected_option_id: null clears it. | Kept. |
discounts.codes | Replaces the list; [] clears it. | Kept. | Kept. |
| A field of an extension you did not negotiate | Ignored. | Ignored. | — |
Lines.
- The list replaces the session's lines. Lines are matched by variant; a line
you leave out is removed. The
idyou send on a line is ignored. quantityis a positive integer and defaults to 1. A quantity above 999 is lowered to 999 without a message; several lines of one variant are added up, and a sum above 999 is lowered with the warningquantity_adjusted.- At most 100 lines per request (more is a
400).
Buyer. Always send the whole buyer: {"buyer":{"first_name":"Jana"}}
erases a stored e-mail. An e-mail is stored as sent and lower-cased when it is
valid; an invalid one is reported as missing $.buyer.email.
Shipping (fulfillment, with the extension):
- The first method whose
typeis"shipping"or absent is read. Other types,pickupamong them, are ignored. Only the first 10 methods and the first 10 destinations of a method are read. - Its destination is the one
selected_destination_idnames, else the first. The whole address is stored, and itsidbecomes"destination". Only that destination's country is checked: one the shop cannot recognise is a400that asks for an ISO 3166-1 alpha-2 code such as"SK". groups[0].selected_option_idselects an option;nullclears it; a missing or blank value changes nothing.- The options are the shop's active shipping methods for this storefront, filtered by the destination's country (with no destination there is no country filter yet) and by the methods' own product rules, in the shop's order.
- A selected option that is no longer offered — after the country changed, say —
stays selected: the answer shows
selected_option_id: nullandinvalid_optionuntil you choose again. - An option's
titleis the shipping method's name in the session's language. Itsdescriptionis the method's description in that language, else the method's own description, which can be in another language of the shop, else an English sentence built from the delivery estimate (Delivery in 2–3 business days); at most 300 characters, and absent when there is none of these. Showtitle, or translatedescriptionyourself.
Discount codes (discounts, with the extension):
- Codes are tried in order on every create and
PUT, and the first one the shop accepts is applied. Each later code gets the warningdiscount_code_combination_disallowed. A refused code getsdiscount_code_expired,discount_code_user_ineligibleordiscount_code_invalid, and the next one is tried. These messages are warnings: they never keep a checkoutincomplete.discounts.appliedholds at most one entry, and only while the applied code takes something off. - At most 10 codes (more is a
400), each cut to 50 characters and de-duplicated without regard to case. AGETdoes not apply codes again. - On a session without lines no code takes effect:
discounts.appliedis empty and the totals show no discount. The codes stay stored and are tried again on the next create orPUT.
Lifetime. Every cart PUT extends the cart to 7 days from now, every
checkout PUT the checkout to 24 hours from now. A PUT of {} to a checkout
applies its codes again, quotes shipping again and extends it; a cart PUT
always needs its lines.
Money, totals and tax
Every amount is an integer in the minor units of the currency beside it,
ISO 4217:
| Decimals | Currencies |
|---|---|
| 0 | BIF, CLP, DJF, GNF, ISK, JPY, KMF, KRW, PYG, RWF, UGX, UYI, VND, VUV, XAF, XOF, XPF |
| 3 | BHD, IQD, JOD, KWD, LYD, OMR, TND |
| 4 | CLF, UYW |
| 2 | every other currency, HUF included: 1 990 Ft is 199000 |
Amounts are rounded half away from zero. They are gross: item.price is
the unit price with VAT, line totals are with VAT, and the shop's markup rules
are already in. There is no tax entry and no net amount.
totals[].type | Amount | When |
|---|---|---|
subtotal | The sum of the line totals. | Always, first. |
discount | Negative. display_text is the applied code, in upper case. | When an applied code takes something off. |
fulfillment | The selected option's fee, with VAT; it may be 0. | Checkouts, once a valid option is selected. |
total | subtotal + discount + fulfillment | Always, last. |
The entries before total always add up to it. The discount is not spread
over the lines: line totals stay undiscounted.
Three rules follow the shop's own checkout and can surprise:
- A percentage code is a percentage of the net subtotal, shown against the gross one. A bike at 699.00 € and two bottles at 9.90 € make a subtotal of 718.80 €; with 23 % VAT the net subtotal is 584.39 €, so a 10 % code takes 58.44 €, not 71.88 €.
- A free-shipping threshold is compared with the net subtotal, before the discount.
- A code's minimum order total is compared with the net subtotal too,
before the discount. The 718.80 € basket above has a net subtotal of
584.39 €, so a code with a minimum order total of 600 € is refused with
discount_code_user_ineligible.
Not quoted: a surcharge for the payment method the buyer picks on the shop. The buyer can also change the items or the shipping there, so the final order can differ from the last UCP total.
Language and currency
A session's language decides the translations (product titles, shipping method names, legal link titles) and the currency of every price. It is chosen when the session is created:
context.languagefrom the request body, if it is a language tag of the formskoren-GB(at most 35 characters; a string of another form is silently dropped);- then the tags of
Accept-Language, in header order — the first 10,qweights ignored,*skipped.
The first candidate the shop offers wins: the exact tag (case-insensitive),
otherwise the first shop language with the same primary subtag (en-US →
en or en-GB). With no match, the shop's first language is used. A shop
without a language list of its own takes the primary subtag of the first
candidate (en-GB → en), and with no candidate it answers in the company's
default language; a language the company has not set up is priced like the
default one. On such a shop a session created without any candidate stores
no language: until a request names one, each later request is answered in the
language its own Accept-Language picks, and a different pick adds the lines
again (new line ids, possibly another currency). Send context.language on
the create.
- Currency. Each language of the shop has its currency.
context.currencynever changes the currency of the prices: on carts and checkouts it is only echoed back, and in the catalog it states the currency offilters.price(without it, the bounds are in the currency of the answer). To get prices in another currency, ask for a language the shop sells in that currency. - The language sticks. Once a session has a language, it is chosen again
only when a request body sends a
context.languagethat differs (case-insensitive) from thecontext.languagestored on the session. The choice then runs as above, with that request'sAccept-Language.Accept-Languagealone never switches it, and re-sending the same language is no change. A checkout made from a cart keeps the cart's language. - A switch re-prices everything. When the choice gives another language, the lines are added again in the new language and currency: they get new line ids and the shop's current prices, and the codes are applied again.
- The language is not echoed.
context.languagerepeats what you sent, even a language the shop does not offer. The effective language shows only incurrencyand in the translated titles.
Send context.language when you create a session, and keep it. If the buyer
switches language, expect new line ids and prices.
When stock or prices change
A session is priced by the shop's cart, but not every call checks everything again:
| What | Checked again |
|---|---|
| Stock, and whether the shop still offers the item | On every write that sends lines: a create, every cart PUT, a checkout PUT with line_items, and a language switch. A sold-out line is removed (out_of_stock); a quantity above the stock is lowered (quantity_adjusted, "Only 2 available — quantity lowered from 5"); an item the shop no longer offers is removed (item_unavailable). Pre-order variants are never limited by stock. A checkout PUT without line_items (and without a language switch) and every read leave stock unchecked. |
| Unit prices | A line keeps the unit price it got when its variant entered the session, also when its quantity changes or a PUT sends it unchanged. Every line gets the shop's current price when the session is created (from a cart too) and when its language switches. The exception is a storefront with the setting Live prices on cart load on: there every read and write prices the lines again. The profile does not say whether a shop has it on. |
| Discount codes | On every create and PUT: a code that expired or reached its usage limit becomes a warning and leaves the totals. Reads do not apply codes again. |
- A line removed as sold out is gone from the session: send it again in a
later
PUTonce the stock is back. - Line messages appear only in the answer to the write that caused them.
Compare
totalsbetween answers rather than waiting for a message. - A checkout kept open for a long time by
PUTs keeps the prices of the moment its lines entered it. To price a line again, leave it out of onePUTand send it again in the next (it gets a new line id), or start a new checkout: fromline_items, or from the cart once you have canceled the cart's open checkout. - The handoff adds the lines to the buyer's cart at the shop's prices of that moment, so the binding price is the one on the shop's checkout.
Right before you hand the checkout over, PUT it with its current
line_items: the answer has checked stock and the discount code again (the
unit prices only on a shop with live prices). Show the buyer those totals.
Catalog mode
A merchant can run a storefront as a catalog: products and prices are visible,
but nothing can be bought. For UCP this applies when the storefront is in
catalog mode and runs on Storefront v2. Such a shop declares only
dev.ucp.shopping.catalog.search and dev.ucp.shopping.catalog.lookup:
{
"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"
}
]
},
"payment_handlers": {}
}
}The same shop with catalog mode in effect (storefront v2 with “Katalógový režim” on): only the two catalog capabilities. Every cart and checkout call then answers 200 capabilities_incompatible.
- Catalog search, lookup and product detail work as usual; availability still reflects stock.
- Every cart and checkout operation,
GETincluded, answers HTTP 200capabilities_incompatible, and nothing runs. Sessions created before the switch can no longer be reached through the API. - A
continue_urlissued before the switch sends the buyer to the shop's home page and adds nothing to their cart.
Lifetimes and expiry
| What | Lives | Extended by | Afterwards |
|---|---|---|---|
| Cart | 7 days | create, PUT | Every call answers not_found; its continue_url answers 410. |
| Checkout | 24 hours | create (from a cart too), PUT | The next GET, PUT, complete or cancel — or a conversion of its cart — cancels it: status: "canceled". Its continue_url answers 410. |
| A session the buyer took to the shop | the last expires_at before the handoff | nothing | As above. |
| Session data | 30 days after expires_at | — | Deleted by the hourly clean-up; calls answer not_found. |
Idempotency-Key | at least 25 hours from its first use; rely on 24 | — | Deleted by the hourly clean-up within the following hour; the key can then be used again. |
GET, complete and cancel never extend a session. expires_at is ISO 8601
in UTC with milliseconds, e.g. 2026-10-27T09:21:30.551Z. The specification's
default checkout lifetime is 6 hours; Monokaido's is 24. Expiry is lazy: nothing
happens at the deadline itself; the next request that touches the checkout
cancels it.
Handing the buyer over
Monokaido takes no payment through UCP. A ready checkout is handed to the buyer, who finishes on the shop's own website with the shop's own payment methods.
The link
continue_url looks like
https://shop.example.com/ucp/continue/9bbd3ded…ec780b: the shop's primary
domain and a 64-character hex token.
- The token is a secret of its own, never the session id. It never changes for the life of the session.
- It is in every cart answer and in every answer of an open checkout, and
missing from
completedandcanceledcheckouts. - It is a page for a person's browser. Give it to the buyer — as a link or a
button — at the moment the checkout is
requires_escalation. Do not call it from your server. - It works on every open session, an
incompletecheckout included, and the first line that reaches a buyer's cart freezes the session in the status it has at that moment (see After the handoff the session is frozen). A link shown too early therefore leaves anincompletecheckout you can no longer complete. The same goes for a cart's link: once you have made a checkout from the cart, the cart'scontinue_urlhands over that checkout, so do not show a cart's link ("view your basket") after converting it.
What happens when the buyer opens it
- The shop serves a small page. Fetching it changes nothing, so a link preview or a prefetch is harmless.
- The page's script adds the session's lines and its applied discount code to the buyer's cart in that browser: the customer cart if the buyer is signed in to the shop in that browser tab, otherwise the browser's guest cart. Without JavaScript the page only shows a link to the shop, and nothing is added.
- The browser goes on to the shop's checkout (for a checkout), to the shop's cart (for a cart), or to the home page (shop in catalog mode). A cart's link whose cart has an open checkout made from it hands over that checkout.
The page needs JavaScript and the browser storage of the shop's domain: the
shop knows a guest's cart only by an id kept in that browser's
localStorage, and a signed-in tab by the sign-in kept in its
sessionStorage. The items therefore land in the cart of the browser — and
of the storage partition — that opened the link. An in-app browser or a
WebView of a mobile app keeps a cart of its own, so the buyer must finish the
purchase in the same browser, or open the link in the system browser to begin
with. Where storage is blocked, the items go into a cart no page of the shop
shows, and the session is frozen all the same: build a new checkout.
The buyer's cart keeps whatever else it held. A variant already in it gets the quantities added up (at most 999), and the session's code, when it is valid for that cart, replaces a code the buyer had applied. A line the shop's cart refuses — sold out since, not enough stock for the added-up quantity, or a mix of zero-VAT and VAT items the shop does not allow — is skipped without a message. If the buyer's cart already holds items priced in another currency, the lines are added in that cart's language when it prices in that currency; otherwise nothing is added and the buyer lands on the cart page.
What travels and what does not
| Travels to the shop | Stays in the UCP session |
|---|---|
| The lines: variant and quantity (at most 999 each) | The buyer: name, e-mail, phone |
| The applied discount code | The shipping address |
| The selected shipping option and the pickup point |
The buyer types their details again on the shop's checkout, and can change the order there. Tell them so: whatever your platform collected is not pre-filled.
Opening the link more than once
- Reloading the page, a second click or a second tab in the same browser adds nothing more. A tab in which the buyer is signed in counts as another cart.
- Another browser or device that opens the link gets the lines once more, into its own cart. Anyone who holds the link can do that while the session is open, so share it only with the buyer.
- The link answers
410once the session is completed, canceled or expired, and404once the shop no longer serves UCP.
After the handoff the session is frozen
Once at least one line has reached the buyer's cart, the session is no longer changed. A handoff that added no line at all leaves it as it was.
| Call | Answer |
|---|---|
GET cart or checkout, POST …/complete | The last answer stored before the handoff, its messages included (a checkout with its current status). When that answer was a write's, its line and discount messages — a quantity_adjusted, say — come back on every read: do not report them to the buyer again, and do not read them as a sign of the handoff. Nothing marks the handoff. |
PUT | The same, plus the warning handed_off. Nothing is stored. |
POST /checkout-sessions with the cart's cart_id | 200 with the error handed_off and a continue_url to the shop's cart page. |
POST …/cancel | Still cancels it. An order placed afterwards cannot complete it. |
Its expires_at can no longer be extended. A GET cannot tell you whether the
buyer opened the link; a PUT can (handed_off), but on a session that was not
handed over it changes the session and extends it.
Signed in or guest
The page sees only the sign-in of the browser tab it opens in. A link opened in
a new tab — the usual case — fills the browser's guest cart, which is also the
cart that tab of the shop shows. If the buyer then signs in during the shop's
checkout, the shop merges the guest cart into the customer's cart, and the
resulting order can no longer complete the UCP checkout: it keeps the status
it was handed over in (normally requires_escalation) until it expires. The
order itself is unaffected.
Order completion
When a checkout becomes completed
The checkout becomes completed when the buyer places an order on the shop's
checkout from the cart the handoff filled, and that order holds at least one
of the session's variants. Quantities do not matter. The next read shows it;
there are no webhooks and no order capability, so you learn it by reading the
checkout.
- Only a checkout is completed; a cart never is. Hand over a checkout if you want to know the outcome.
completedmeans the order was placed, not paid. It is set after the shop has started the payment, whatever the payment method, and does not wait for the payment's outcome.- Completion is best effort. It runs after the order is saved, once the shop
has started the payment and handed the order to its ERP, so it can lag the
order by as long as those take. If it fails, the failure is only logged on
Monokaido's side and the checkout keeps its status: an order can exist for a
checkout that never reads
completed. - The shop sends its usual order confirmation e-mail to the address the buyer entered on its checkout.
What the answer contains
The answer is the last one you received before the handoff, with
status: "completed", messages: [], no continue_url, and order:
| Field | Value |
|---|---|
order.id | The order's UUID (for an order split by VAT rate, the parent order). |
order.label | The shop's order number; absent when there is none. |
order.permalink_url | https://<primary domain>/order-confirmation?orderNumber=<number>&orderId=<id> (without orderNumber when the order has no number) — the shop's confirmation page, which needs no sign-in. |
line_items and totals are still your last quote, not the order: the buyer
may have changed quantities, shipping or payment on the shop. Never present the
quote as the order's total.
{
"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.
When an order does not complete it
| Case | Why |
|---|---|
| The buyer signed in during the shop's checkout after a guest handoff | The order comes from the customer cart, not from the cart the handoff filled. |
| The order holds none of the session's variants | The buyer removed them and bought something else. |
| No line reached the buyer's cart | The shop's cart refused every line at the handoff, so the checkout was never handed over. |
| The checkout was canceled first | By POST …/cancel, or by a request after expires_at. |
| The session is a cart | Carts are never completed. |
| The order was not placed through the shop's web checkout | Only orders placed there count. |
| The completion itself failed | It is best effort; the failure is only logged. |
Expiry races the order. Completion does not look at expires_at, but any
request of yours that touches the checkout after expires_at cancels it first. So after the
deadline a single GET gives the final answer: completed if the order came
first, canceled otherwise.
Polling
- Poll
GET /ucp/v1/checkout-sessions/{id}with your usualUCP-Agentheader. Every read of a checkout waits for the session's turn, so keep at most one request per session in flight; on503honourRetry-After. - The server suggests no interval. A reasonable cadence is every 5–10 seconds for the first quarter of an hour after the buyer got the link, then every minute.
- Poll from whatever open status the checkout has: one handed over while
incompletestill completes with the buyer's order. Stop atcompletedorcanceled. Afterexpires_at, one more read decides. - Treat "never completed" as a normal outcome: the buyer may have ordered in a way that cannot be linked back (see the table above). The buyer always sees the shop's own confirmation.
Putting it together
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.
Discover
GET /.well-known/ucp. Keependpointand check that the capabilities you need are listed. Cache it for up to five minutes.Read the catalog
POST /ucp/v1/catalog/search,/ucp/v1/catalog/lookupor/ucp/v1/catalog/producton the host ofendpoint, each withUCP-Agentandcontext.language. Note the variant ids.Build a cart or go straight to a checkout
POST /ucp/v1/cartswithline_items, orPOST /ucp/v1/checkout-sessions. Send anIdempotency-Keywith every write. Read the messages in each answer and match lines byitem.id.Make the checkout ready
Convert the cart with
cart_idif you have one, thenPUTthe buyer (all of it), the destination and a shipping option untilstatusisrequires_escalation.Hand over
PUTthe checkout'sline_itemsonce more, show the buyer the totals of that answer and give themcontinue_url. Tell them they finish and pay on the shop.Learn the outcome
Poll
GET /ucp/v1/checkout-sessions/<id>untilcompleted(showorder.labelandorder.permalink_url) orcanceled.
The same sequence as runnable code in curl, JavaScript and Python is in the Developer quickstart. Where this behaviour follows the specification and where it departs from it is in UCP conformance.