Skip to content

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

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:

ConditionSet by
The host is a custom domain of the shop, with verified DNS and an active TLS certificatethe merchant, under Custom Domains
The merchant switched Buying through AI agents (UCP) on for that shopthe merchant
The shop's built-in storefront is on, and its storefront key is active and not past its expiry datethe merchant
The company's E-shop module is onthe 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 callYou 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).

JSONThe profile of a shop that takes orders
{
  "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.

KeyValueWhat it is for
ucp.version"2026-04-08", alwaysThe protocol version the shop speaks. Your platform profile must declare the same.
ucp.servicesone key, dev.ucp.shopping, holding exactly one entryThe shopping service. There is no MCP, A2A or embedded binding.
….transport"rest"The only transport.
….endpointhttps://<primary domain>/ucp/v1The base of every API call.
….version"2026-04-08"The version of the binding.
….spec, ….schemahttps://ucp.dev/2026-04-08/specification/overview, https://ucp.dev/2026-04-08/services/shopping/rest.openapi.jsonThe official specification and REST schema. Monokaido's exact behaviour is in this manual's OpenAPI document.
ucp.capabilitiescapability 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{}, alwaysNothing 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" }]
    }
  }
}
PartRule
The documentA JSON object with an object ucp.
ucp.versionA string of the form YYYY-MM-DD. To be served it must be exactly 2026-04-08.
ucp.capabilitiesOptional. 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 elseIgnored — 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 profile member must be a quoted string, profile="https://…". An unquoted profile=https://… is refused.
  • Keys are lower case (Profile= is refused). Other members and ;parameters are accepted and ignored.
  • Inside the string only printable ASCII is allowed, with the escapes \" and \\.
  • When profile appears 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

AspectBehaviour
RequestGET 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.
RedirectsNot followed: a redirect is a failure.
StatusOnly 200 counts. Any other status, 304 included, is a failure.
Size and timeAt most 256 KiB; 3 s without data, 5 s in all (DNS, connection and body).
AddressThe host must resolve to an address outside the ranges listed below. The address is resolved once and the connection is pinned to it.
BodyRead 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 from 224.0.0.0 up (multicast and reserved).
  • IPv6: ::/96 (unspecified, loopback and IPv4-compatible), fe80::/10, fec0::/10, fc00::/7, ff00::/8, and the NAT64 prefixes 64:ff9b::/96 and 64: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-age of its Cache-Control, clamped to 60 seconds – 1 hour, and for 5 minutes when there is none. no-store, no-cache, private and s-maxage are 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_unavailable with a Retry-After. A profile already known never waits for the budget.

When the profile cannot be used

HTTPcodeWhenWhat to do
400invalid_profile_urlThe header is missing or cannot be parsed, or the URL breaks a rule.Fix the header. Retrying it unchanged cannot succeed.
424profile_unreachableDNS, 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.
422profile_malformedThe body is not JSON, or has no ucp.version of the form YYYY-MM-DD.Fix the document. Remembered for 60 s.
422version_unsupportedA valid profile of another protocol version.Declare 2026-04-08.
503service_unavailableThe 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.

CapabilityKindWhat it unlocks
dev.ucp.shopping.catalog.searchrootPOST /ucp/v1/catalog/search
dev.ucp.shopping.catalog.lookuprootPOST /ucp/v1/catalog/lookup and POST /ucp/v1/catalog/product
dev.ucp.shopping.cartrootEvery /ucp/v1/carts route, and cart_id when you create a checkout
dev.ucp.shopping.checkoutrootEvery /ucp/v1/checkout-sessions route
dev.ucp.shopping.fulfillmentextension of checkoutThe fulfillment of a checkout: the address, the shipping options and their fees
dev.ucp.shopping.discountextension of checkout and cartThe discounts of carts and checkouts: discount codes

The negotiation, in order:

  1. Version. Your profile's ucp.version must equal 2026-04-08, compared as a string. Anything else, older or newer, is 422 version_unsupported before anything else runs.
  2. 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.
  3. 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.discount survives with the cart alone; dev.ucp.shopping.fulfillment needs the checkout. Your own extends plays no part.
  4. 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:

KeyWhen it is there
versionAlways: "2026-04-08".
status"error" only when no resource could be returned (a business error). Absent otherwise — read its absence as success.
capabilitiesAlways. 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:

Operationucp.capabilities lists
Catalog searchdev.ucp.shopping.catalog.search
Catalog lookup, productdev.ucp.shopping.catalog.lookup
Every cart routedev.ucp.shopping.cart, dev.ucp.shopping.discount
Every checkout routedev.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.

JSONcapabilities_incompatible (HTTP 200)
{
  "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 no fulfillment, 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 no discounts, the codes you send are ignored, and a code already on the session is removed at the next PUT — 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.

CartCheckout
Root capabilitydev.ucp.shopping.cartdev.ucp.shopping.checkout
RoutesPOST /carts, GET and PUT /carts/{id}, POST /carts/{id}/cancelPOST /checkout-sessions, GET and PUT /checkout-sessions/{id}, POST …/{id}/complete, POST …/{id}/cancel
Holdslines, buyer, context, discount codesthe same, plus shipping, a status, legal links and, once ordered, the order
status fieldnoneincomplete, requires_escalation, completed, canceled
Shippingnever quotedquoted with the fulfillment extension
PUTline_items required, the whole list each timeline_items optional
Lifetime7 days from the last create or PUT24 hours from the last create or PUT
After expiryevery call answers not_foundcanceled
continue_urlin 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 orderneveryes

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

IdentifierFormatNotes
Session idlower-case UUID v4The only credential of the session. A path id that is not a UUID answers not_found.
line_items[].idUUIDThe 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.idvariant UUIDSend 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 idUUIDThe 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.title is 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 — its title, options and sku from the catalog — keyed by item.id, or look the ids up with POST /ucp/v1/catalog/lookup ({"ids": [item.id, …]}), before you show the basket.
  • item.id is 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.
  • quantity and item.price count 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'sComes from
LinesThe 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.
BuyerThe cart. A buyer in the request is validated but not used.
Context and languageThe cart. A context in the request is validated but not used.
Shipping (fulfillment)The request.
Discount codesThe 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 message handed_off; the answer's continue_url is the shop's cart page. If the cart still has an open checkout, that checkout is answered instead.
  • An unknown, canceled or expired cart_id answers 200 not_found with the content Checkout 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

Checkout statuses and what moves them
Transitions
FromToWhen
(new)incompleteCreated 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_escalationThe same call when nothing is missing.
incompleterequires_escalationA PUT that supplies what was missing. GET and POST …/complete re-derive the status from the current cart too.
requires_escalationincompleteA 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_escalationcompletedThe 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_escalationcanceledPOST …/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.

StatusMeaningcontinue_urlYour next step
incompleteAt least one message is an error with severity recoverable.yesPUT what the messages ask for.
requires_escalationNothing you can provide is missing. The message payment_required is listed.yesGive continue_url to the buyer.
completedThe buyer placed an order on the shop from the cart the handoff filled. order is set.noStop polling; show the order.
canceledCanceled with POST …/cancel, or expired.noStart 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

RequirementWithout fulfillmentWith fulfillmentMessage while it is missing
At least one linerequiredrequiredmissing at $.line_items
A valid buyer.emailrequiredrequiredmissing at $.buyer.email, also for an address that is not a valid e-mail
A destination—requiredmissing at $.fulfillment.methods[0].destinations
street_address, address_locality, postal_code and address_country in it—requiredinvalid_address
The shop ships there—requiredaddress_undeliverable
A selected option among those offered—required while options existmissing, or invalid_option for one no longer offered
No line error in this answerrequiredrequireditem_unavailable, out_of_stock, vat_mix_not_allowed
Names, phone numbernot requirednot required—
A pickup point (Packeta)—not requiredpickup_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:

JSONA ready checkout
{
  "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, GET and POST …/complete, from the session as it stands. Any error with severity recoverable makes it incomplete; otherwise it is requires_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 next GET can return requires_escalation for the same checkout. React to messages in the answer to your write.
  • POST …/complete places no order. Like a GET, it answers the current state, derived anew, and does not extend the lifetime.
  • completed comes 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:

OperationOn completedOn canceled
GETmessages: [], with ordermessages: []
PUTcheckout_completedcheckout_canceled
POST …/completemessages: []checkout_canceled
POST …/cancelcheckout_completedcheckout_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:

FieldSent with a valueSent as nullLeft out
line_itemsReplaces every line.400Cart: 400 line_items must be an array. Checkout: lines kept.
buyerReplaces the whole buyer.Clears the buyer.Kept.
contextReplaces the whole context.Clears it (the session's language is kept).Kept.
Shipping destinationReplaced by the chosen destination; an empty destinations list changes nothing.Kept (fulfillment: null changes nothing).Kept. It cannot be removed, only replaced.
Selected shipping optionSelected.groups[0].selected_option_id: null clears it.Kept.
discounts.codesReplaces the list; [] clears it.Kept.Kept.
A field of an extension you did not negotiateIgnored.Ignored.—

Lines.

  • The list replaces the session's lines. Lines are matched by variant; a line you leave out is removed. The id you send on a line is ignored.
  • quantity is 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 warning quantity_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 type is "shipping" or absent is read. Other types, pickup among 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_id names, else the first. The whole address is stored, and its id becomes "destination". Only that destination's country is checked: one the shop cannot recognise is a 400 that asks for an ISO 3166-1 alpha-2 code such as "SK".
  • groups[0].selected_option_id selects an option; null clears 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: null and invalid_option until you choose again.
  • An option's title is the shipping method's name in the session's language. Its description is 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. Show title, or translate description yourself.

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 warning discount_code_combination_disallowed. A refused code gets discount_code_expired, discount_code_user_ineligible or discount_code_invalid, and the next one is tried. These messages are warnings: they never keep a checkout incomplete. discounts.applied holds 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. A GET does not apply codes again.
  • On a session without lines no code takes effect: discounts.applied is empty and the totals show no discount. The codes stay stored and are tried again on the next create or PUT.

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:

DecimalsCurrencies
0BIF, CLP, DJF, GNF, ISK, JPY, KMF, KRW, PYG, RWF, UGX, UYI, VND, VUV, XAF, XOF, XPF
3BHD, IQD, JOD, KWD, LYD, OMR, TND
4CLF, UYW
2every 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[].typeAmountWhen
subtotalThe sum of the line totals.Always, first.
discountNegative. display_text is the applied code, in upper case.When an applied code takes something off.
fulfillmentThe selected option's fee, with VAT; it may be 0.Checkouts, once a valid option is selected.
totalsubtotal + discount + fulfillmentAlways, 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:

  1. context.language from the request body, if it is a language tag of the form sk or en-GB (at most 35 characters; a string of another form is silently dropped);
  2. then the tags of Accept-Language, in header order — the first 10, q weights 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.currency never changes the currency of the prices: on carts and checkouts it is only echoed back, and in the catalog it states the currency of filters.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.language that differs (case-insensitive) from the context.language stored on the session. The choice then runs as above, with that request's Accept-Language. Accept-Language alone 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.language repeats what you sent, even a language the shop does not offer. The effective language shows only in currency and 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:

WhatChecked again
Stock, and whether the shop still offers the itemOn 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 pricesA 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 codesOn 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 PUT once the stock is back.
  • Line messages appear only in the answer to the write that caused them. Compare totals between 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 one PUT and send it again in the next (it gets a new line id), or start a new checkout: from line_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:

JSONProfile of a shop in catalog mode
{
  "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, GET included, answers HTTP 200 capabilities_incompatible, and nothing runs. Sessions created before the switch can no longer be reached through the API.
  • A continue_url issued before the switch sends the buyer to the shop's home page and adds nothing to their cart.

Lifetimes and expiry

WhatLivesExtended byAfterwards
Cart7 dayscreate, PUTEvery call answers not_found; its continue_url answers 410.
Checkout24 hourscreate (from a cart too), PUTThe 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 shopthe last expires_at before the handoffnothingAs above.
Session data30 days after expires_at—Deleted by the hourly clean-up; calls answer not_found.
Idempotency-Keyat 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.

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 completed and canceled checkouts.
  • 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 incomplete checkout 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 an incomplete checkout you can no longer complete. The same goes for a cart's link: once you have made a checkout from the cart, the cart's continue_url hands over that checkout, so do not show a cart's link ("view your basket") after converting it.

What happens when the buyer opens it

  1. The shop serves a small page. Fetching it changes nothing, so a link preview or a prefetch is harmless.
  2. 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.
  3. 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 shopStays in the UCP session
The lines: variant and quantity (at most 999 each)The buyer: name, e-mail, phone
The applied discount codeThe 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.

  • 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 410 once the session is completed, canceled or expired, and 404 once 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.

CallAnswer
GET cart or checkout, POST …/completeThe 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.
PUTThe same, plus the warning handed_off. Nothing is stored.
POST /checkout-sessions with the cart's cart_id200 with the error handed_off and a continue_url to the shop's cart page.
POST …/cancelStill 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.
  • completed means 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:

FieldValue
order.idThe order's UUID (for an order split by VAT rate, the parent order).
order.labelThe shop's order number; absent when there is none.
order.permalink_urlhttps://<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.

JSONCompleted by the buyer’s order
{
  "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

CaseWhy
The buyer signed in during the shop's checkout after a guest handoffThe order comes from the customer cart, not from the cart the handoff filled.
The order holds none of the session's variantsThe buyer removed them and bought something else.
No line reached the buyer's cartThe shop's cart refused every line at the handoff, so the checkout was never handed over.
The checkout was canceled firstBy POST …/cancel, or by a request after expires_at.
The session is a cartCarts are never completed.
The order was not placed through the shop's web checkoutOnly orders placed there count.
The completion itself failedIt 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 usual UCP-Agent header. Every read of a checkout waits for the session's turn, so keep at most one request per session in flight; on 503 honour Retry-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 incomplete still completes with the buyer's order. Stop at completed or canceled. After expires_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

A UCP purchase, from the shop’s profile to the order
  1. Agent / platform → Shop (UCP API):

    GET /.well-known/ucp

    Reads 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.

  2. Agent / platform → Shop (UCP API):

    POST /ucp/v1/catalog/search

    Searches 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.

  3. Agent / platform → Shop (UCP API):

    POST /ucp/v1/carts

    Creates a cart the shop prices — its own prices, stock and discounts. A cart lives 7 days from its last change.

  4. Agent / platform → Shop (UCP API):

    POST /ucp/v1/checkout-sessionsincomplete

    Creates a checkout from the cart (cart_id) or straight from line_items. While something is missing its status is incomplete.

  5. Agent / platform → Shop (UCP API):

    PUT /ucp/v1/checkout-sessions/{id}requires_escalation

    Adds 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.

  6. Agent / platform → Buyer (browser):

    continue_url

    Gives the buyer the continue_url. Monokaido takes no payment through UCP, and POST …/complete does not complete the checkout.

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

  8. Buyer (browser) → Shop (UCP API):

    /checkout

    Finishes the purchase on the shop’s checkout, payment included — like any other customer.

  9. Agent / platform → Shop (UCP API):

    GET /ucp/v1/checkout-sessions/{id}completed

    Once 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.

  1. Discover

    GET /.well-known/ucp. Keep endpoint and check that the capabilities you need are listed. Cache it for up to five minutes.

  2. Read the catalog

    POST /ucp/v1/catalog/search, /ucp/v1/catalog/lookup or /ucp/v1/catalog/product on the host of endpoint, each with UCP-Agent and context.language. Note the variant ids.

  3. Build a cart or go straight to a checkout

    POST /ucp/v1/carts with line_items, or POST /ucp/v1/checkout-sessions. Send an Idempotency-Key with every write. Read the messages in each answer and match lines by item.id.

  4. Make the checkout ready

    Convert the cart with cart_id if you have one, then PUT the buyer (all of it), the destination and a shipping option until status is requires_escalation.

  5. Hand over

    PUT the checkout's line_items once more, show the buyer the totals of that answer and give them continue_url. Tell them they finish and pay on the shop.

  6. Learn the outcome

    Poll GET /ucp/v1/checkout-sessions/<id> until completed (show order.label and order.permalink_url) or canceled.

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.