Errors and messages
The difference between a protocol error and a message in the answer, every code, and what a client should do about each.
On this page
- Three kinds of answer
- Telling them apart
- Protocol errors
- The body and headers
- The shop, the host and the route
- Your platform profile
- The request
- Idempotency and concurrency
- Limits and failures
- Every field error
- What is adjusted instead of refused
- Business errors
- Messages
- The message object
- Severity and what it asks of you
- Line messages
- Discount messages
- Checkout readiness
- Closed and handed-over sessions
- Catalog messages
- Showing messages to the buyer
- Answers that are not JSON
- The web server
- The handoff routes
- What to log
A UCP call to a Monokaido shop can go wrong in two very different ways. Either
the request could not be processed at all — a protocol error, with an HTTP
error status — or it was processed and the shop has something to say about
the outcome — a message, inside an ordinary 200 answer. This chapter
shows how to tell the answers apart and lists every code the API returns, with
what your client should do about each.
The examples use the shop https://shop.example.com and a platform whose
profile is https://platform.example.com/.well-known/ucp. When to retry, and
how, is covered in detail in
Idempotency, concurrency and limits.
Three kinds of answer
| Kind | HTTP status | Body | Example |
|---|---|---|---|
| Protocol error | 400–503 | {"code", "content", "continue_url"?} — no ucp object | An unreadable UCP-Agent header, a reused Idempotency-Key, a rate limit |
| Business error | 200 | {"ucp": {…, "status": "error"}, "messages": […], "continue_url"} — no resource | An unknown cart, a capability your profile does not declare, a cart in which nothing could be added |
| A resource with messages | 200, or 201 for a new cart or checkout | the resource, with messages[] | A checkout that still needs a shipping address, a quantity lowered to the stock |
Two more kinds of answer are not UCP JSON at all:
- The web server's own pages. The web server in front of the shop answers
a few situations itself, before the API sees the request, with an HTML
page: plain
http://, a body over 1 MiB, too many requests per second, the API unreachable or too slow. See Answers that are not JSON. - The handoff page.
continue_urlis a page for the buyer's browser. The API serves it on the shop's domain, as HTML. Your client never needs to call it.
Telling them apart
Check an answer in this order:
| # | Check | Then it is | Read |
|---|---|---|---|
| 1 | Status 304 | not modified — only if you sent If-None-Match | Nothing; your copy is current. A 304 has no body and no Content-Type. |
| 2 | Content-Type is not application/json | a page of the web server (or the handoff page) | The HTTP status only. There is no Retry-After. |
| 3 | Status 400 or higher | a protocol error | code, then Retry-After for 409, 429 and 503. |
| 4 | Status 200 and ucp.status is "error" | a business error | messages[0].code; there is no id and no resource. |
| 5 | Status 200 or 201, no ucp.status | a resource | The resource and its messages. |
A successful answer never carries ucp.status: read its absence as success.
No 2xx answer of the API other than 200 and 201 exists, and the API
never redirects (the web server redirects plain http:// to https:// with a
301; call https:// directly).
// Sort one UCP answer into the kinds above (Node 20+ fetch Response).
async function readUcp(res) {
const retryAfter = Number(res.headers.get('retry-after')) || undefined;
if (res.status === 304) return { kind: 'not_modified' };
if (!(res.headers.get('content-type') ?? '').startsWith('application/json')) {
// nginx's own HTML page: 413, 429, 502 or 504. Decide by the status alone.
return { kind: 'web_server', status: res.status };
}
const body = await res.json();
if (res.status >= 400) {
return { kind: 'protocol_error', status: res.status, code: body.code,
content: body.content, continueUrl: body.continue_url, retryAfter };
}
if (body.ucp?.status === 'error') {
return { kind: 'business_error', code: body.messages[0].code,
messages: body.messages, continueUrl: body.continue_url };
}
return { kind: 'resource', status: res.status, body, messages: body.messages ?? [] };
}
# Sort one UCP answer into the kinds above (a requests.Response).
def read_ucp(r):
retry_after = r.headers.get("Retry-After")
if r.status_code == 304:
return {"kind": "not_modified"}
if not r.headers.get("Content-Type", "").startswith("application/json"):
# nginx's own HTML page: 413, 429, 502 or 504. Decide by the status alone.
return {"kind": "web_server", "status": r.status_code}
body = r.json()
if r.status_code >= 400:
return {"kind": "protocol_error", "status": r.status_code, "code": body["code"],
"content": body["content"], "continue_url": body.get("continue_url"),
"retry_after": int(retry_after) if retry_after else None}
if body.get("ucp", {}).get("status") == "error":
return {"kind": "business_error", "code": body["messages"][0]["code"],
"messages": body["messages"], "continue_url": body.get("continue_url")}
return {"kind": "resource", "status": r.status_code, "body": body,
"messages": body.get("messages", [])}
Protocol errors
The body and headers
A protocol error has exactly two or three keys:
| Key | Type | Meaning |
|---|---|---|
code | string | One of the codes in the tables below. Branch on it, together with the status. |
content | string | An English sentence for your logs. A few name what was refused: the field sentences of invalid_request, and version_unsupported, which names your version. The others are fixed. Never parse content, and never show it to a buyer as it is. |
continue_url | string | Only on the platform profile errors and the profile discovery limits: the shop's home page, https://<primary domain>/. |
Every protocol error is sent with Cache-Control: no-store. Retry-After, in
whole seconds, is sent with these and no others:
| Answer | Retry-After |
|---|---|
409 idempotency_conflict, the same request still running | 2 |
429 rate_limited | 60 |
503 "Another request on this session is being processed — retry shortly" | 1 |
503 "Too many new platform profiles from this client, retry shortly" | the seconds left in the current one-minute window, at least 1 |
503 "Platform profile discovery is busy, retry shortly" | 10 |
503 "This shop is temporarily unavailable" | 60 |
{
"code": "service_unavailable",
"content": "Another request on this session is being processed — retry shortly"
}Another request on the same cart or checkout is running and this one could not get its turn within 10 s, or 4 requests were already waiting (this request did not run) — or, rarely, another writer changed the session while this request ran (its result was not stored). The Idempotency-Key was released: retry the same request with the same key. Retry-After: 1. HTTP 503.
The protocol error codes are invalid_profile_url, profile_unreachable,
profile_malformed, version_unsupported, invalid_request,
request_too_large, not_found, idempotency_conflict, rate_limited,
service_unavailable and internal_error. The negotiation code
capabilities_incompatible is not among them: it arrives as a
business error with HTTP 200.
The shop, the host and the route
| HTTP | code | content | When | Retry? | What to do |
|---|---|---|---|---|---|
404 | not_found | No UCP business on this host | The host serves no UCP: it is not a custom domain of a shop with verified DNS and an active certificate, UCP is switched off, the built-in storefront is off, the storefront key is inactive or expired, or the company's E-shop module is off. The answer never says which, on purpose. It is checked before UCP-Agent and before your body's fields, so it also answers a request without UCP-Agent; only the rate limit and an unreadable body are checked earlier. | No | Check the domain with the merchant. Call the host of the endpoint the shop's profile publishes. |
404 | not_found | Not found | The path or the method does not exist under /ucp/, for example POST /ucp/v1/nothing-here or DELETE /ucp/v1/catalog/search. An unknown method is 404, not 405. | No | Fix the path or the method. |
503 | service_unavailable | This shop is temporarily unavailable | The shop is suspended on Monokaido's side (the company behind it is deactivated or locked), or the shop could not be looked up for a moment. | Yes, after Retry-After (60 s) | Retry a few times. If it persists, the shop is suspended: tell the buyer the shop is not available right now. |
A missing cart, checkout or product is never a 404: it is a
business error with HTTP 200.
Your platform profile
These come from the UCP-Agent header and the platform profile it names. All
of them carry continue_url, the shop's home page. Each request runs the
checks in this order: the header, the fetch, the version, then the capability
negotiation.
| HTTP | code | content | When | Retry? | What to do |
|---|---|---|---|---|---|
400 | invalid_profile_url | The UCP-Agent header must carry profile="https://…" | The header is missing, empty, longer than 4096 characters or not parseable, the profile member is not a quoted string, or its URL breaks a rule: not https://, longer than 1000 characters, whitespace, a user name or password, no host. | No | Send UCP-Agent: profile="https://platform.example.com/.well-known/ucp". |
424 | profile_unreachable | Unable to fetch the platform profile | The shop could not fetch your profile: DNS, connection, TLS, more than 3 s without data or 5 s in all, a redirect, a status other than 200 (304 included), more than 256 KiB, or an address that is not public. The failure is remembered for 60 s. | Only after you fix the hosting, and not within 60 s | Serve the profile with 200, without redirects, from a public address. |
422 | profile_malformed | The platform profile is not a valid UCP profile | The body is not JSON, is not an object, has no ucp object, or ucp.version is not a YYYY-MM-DD string. Remembered for 60 s. | Only after you fix the document | Serve a UCP profile; see Concepts and lifecycle. |
422 | version_unsupported | Protocol version <version> is not supported. This business supports version 2026-04-08. | A valid profile whose ucp.version is not exactly 2026-04-08, older or newer. | Only after you change the profile. The shop fetches it again once its copy expires (your max-age, 60 seconds to 1 hour); the first request after that is still answered from the old copy while the new one is fetched. | Declare "version": "2026-04-08". |
503 | service_unavailable | Too many new platform profiles from this client, retry shortly | Your client (one IPv4 address, or one IPv6 /64) already made the shop's server fetch 5 profile URLs it did not know yet within the current one-minute window. | Yes, after Retry-After | Use one stable profile URL. Every distinct URL string — another query string included — is a new profile. |
503 | service_unavailable | Platform profile discovery is busy, retry shortly | The shop's server has fetched 60 unknown profiles within the current one-minute window, for all clients together. | Yes, after Retry-After (10 s) | Retry. A profile the server already knows never waits for this limit. |
{
"code": "profile_unreachable",
"content": "Unable to fetch the platform profile",
"continue_url": "https://shop.example.com/"
}A network error, a timeout, a redirect, any status other than 200, a body over 256 KiB, or a host resolving to a private address. Remembered for 60 s. HTTP 424.
The request
| HTTP | code | content | When | Retry? | What to do |
|---|---|---|---|---|---|
400 | invalid_request | a sentence that names the field (see Every field error) | A field the API uses has the wrong JSON type or is out of its bounds. Unknown fields are ignored, never refused. | No | Fix the field. Nothing was executed. |
400 | invalid_request | Idempotency-Key is not valid | The key is longer than 255 characters or holds anything but printable ASCII without spaces — a header sent twice arrives joined with ", " and fails too. | No | Send one key of 1–255 characters from ! to ~, for example a UUID. |
400 | invalid_request | The request is not valid | The body is declared as JSON but is not valid JSON, is only white space, or is a bare JSON string, number, boolean or null. | No | Send a JSON object. |
415 | invalid_request | The request is not valid | Content-Type names a charset that is not UTF (charset=iso-8859-2), or Content-Encoding is none of identity, gzip, deflate and br. | No | Send Content-Type: application/json in UTF-8. |
403 | invalid_request | The request is not valid | A write that carries a Monokaido session cookie (accessToken, refreshToken, platformAccessToken, platformRefreshToken) from a foreign web origin. A server-side client never sends these. | No | Do not send cookies. |
400 | request_too_large | ids holds at most 20 identifiers | POST /ucp/v1/catalog/lookup with more than 20 ids. Note the status: 400, not 413. | No | Split the lookup into batches of 20. |
413 | request_too_large | Request body too large | A body over the API's limits once decompressed: 2 MiB of JSON, or a form-encoded body (application/x-www-form-urlencoded) over 100 KiB or with more than 1 000 fields. The web server refuses a body over 1 MiB as sent before the API sees it (its own HTML 413), so a JSON body gets this JSON 413 only when it is sent compressed (Content-Encoding) and inflates past 2 MiB. | No | Send JSON and keep bodies small: 100 lines take a few kilobytes. |
The fields are checked only once the session is found and open. A PUT to
an unknown, completed, canceled or handed-over session gets that session's
answer — not_found, checkout_completed, checkout_canceled or
handed_off — with HTTP 200, without its fields being read (only a body
that is not a JSON object is refused first). Such an answer says nothing
about whether your body was valid.
Send every body with Content-Type: application/json. A body sent as
text/plain, as another +json type, or without a Content-Type, is not
read: the catalog search then runs as an empty search, and lookup, product and
every cart and checkout create or PUT answer 400
request must be an object. A form-encoded body
(application/x-www-form-urlencoded) is parsed, but every value in it arrives
as a string, so a numeric field such as quantity or pagination.limit is
refused with 400 invalid_request. Do not use it.
Idempotency and concurrency
| HTTP | code | content | When | Retry? | What to do |
|---|---|---|---|---|---|
409 | idempotency_conflict | This Idempotency-Key was used for a different request | You used the key before, on this shop with this profile URL, for another operation, another path id or another body. | No | A bug in your client: use a new key for every logical write. |
409 | idempotency_conflict | A request with this Idempotency-Key is still being processed | The first request with this key and the same body has not finished yet. | Yes, after Retry-After (2 s) | Send the same request with the same key again. You then get the stored answer. |
503 | service_unavailable | Another request on this session is being processed — retry shortly | Another request on the same cart or checkout ran and this one did not get its turn within 10 s, or four requests were already waiting behind it. Nothing was done, and the key was released. Rarely, the same answer means that another writer changed the session while this request ran; the session was then not updated. | Yes, after Retry-After (1 s) | Retry the same request with the same key. Send one request at a time per session. |
The first 409 has one rare second cause: two copies of the same request
raced, and the first one failed and released the key while the second looked
it up. If you are certain both copies were identical, one more retry with the
same key runs the request.
Limits and failures
| HTTP | code | content | When | Retry? | What to do |
|---|---|---|---|---|---|
429 | rate_limited | Too many requests | Your IP exceeded the operation's limit: 600 a minute for reads, 300 for writes, 60 for the handoff routes, counted per operation across every Monokaido shop. | Yes, after Retry-After (60 s) | Slow down; see Rate limits. |
500 | internal_error | Unexpected server error | Anything unexpected. The detail is logged on the server and never sent. A failed write released its Idempotency-Key. | A few times, with backoff | Retry reads as they are and writes with the same key. Report it if it repeats. |
{
"code": "rate_limited",
"content": "Too many requests"
}More than the operation’s per-minute limit from your IP (600 reads, 300 writes, 60 handoff). Retry-After: 60. HTTP 429.
Every field error
The content of a 400 invalid_request is one of these sentences. <i> is the index
in the array you sent; <field> is the name of a text field.
content | Raised for |
|---|---|
request must be an object | A body that is not a JSON object (an array, for example), or a body sent without Content-Type: application/json (another type, or none at all) — on lookup, product, and every cart and checkout create or PUT. An empty body sent as application/json reads as {} and gets the error of the first field it lacks instead. |
<path> must be an object | line_items[<i>], line_items[<i>].item, buyer, context, fulfillment, fulfillment.methods[<i>], fulfillment.methods[].destinations[<i>], fulfillment.methods[].groups[0], discounts, filters, filters.price or pagination with another JSON type. fulfillment and discounts are read only when you negotiated that extension; otherwise they are ignored, whatever their shape. |
<path>.<field> must be a string | A text field with another JSON type, for example buyer.email must be a string, context.language must be a string, request.query must be a string, fulfillment.methods[].destinations[0].postal_code must be a string. |
line_items must be an array | A cart create or PUT without line_items (a cart PUT always needs them), and a checkout create without a usable cart_id. |
line_items holds at most 100 entries | More than 100 lines. |
line_items[<i>].item.id is required | A line without item.id, with a blank one, or with one that is not a string. |
line_items[<i>].quantity must be a positive integer | 0, a negative number, a fraction or a string such as "2". A missing quantity means 1. |
fulfillment.methods must be an array | fulfillment.methods that is not an array. |
fulfillment.methods[].destinations must be an array | destinations that is not an array. |
fulfillment.methods[].destinations[<i>].address_country is not a recognised country; send an ISO 3166-1 alpha-2 code such as "SK" | The chosen destination's country is neither an ISO alpha-2 or alpha-3 code nor a country name the shop recognises. Only the chosen destination is checked: the one selected_destination_id names, else the first. |
discounts.codes must be an array | discounts.codes that is not an array. |
discounts.codes holds at most 10 codes | More than 10 codes, counted as sent (before blanks and duplicates are removed). |
pagination.cursor is not valid | A cursor this API did not issue, or one longer than 40 characters. |
pagination.limit must be a positive integer | 0, a negative number, a fraction or a string. |
filters.price.min must be a non-negative integer, filters.price.max must be a non-negative integer | A price bound that is not a whole number of minor units, or below zero. |
filters.categories must be an array of strings | filters.categories that is not a list of strings. |
filters.categories is not supported by this shop; search by query (and filters.price) instead | A category filter with any value, in search, lookup or product. An empty list is read as no filter. |
ids must be a non-empty array | A lookup without ids, or with an empty list. |
id is required | A product request without id, or with a blank one. |
Idempotency-Key is not valid | See Idempotency and concurrency. |
The request is not valid | Malformed JSON, an unsupported charset or encoding (415), a refused cross-origin write (403). |
What is adjusted instead of refused
Some values are corrected without an error. Compare what you sent with the answer:
| You send | You get |
|---|---|
| A quantity above 999 on one line | 999, without a message. Several lines of one variant are added up; a sum above 999 is lowered with the warning quantity_adjusted. |
pagination.limit above 20 | 20 results per page. |
query longer than 200 characters | The first 200 characters are searched. |
| A text field over its length (names 100, e-mail 254, phone 40, street 255, city 100, postal code 20 characters) | The field cut to that length. Control characters become spaces; invisible formatting characters are removed. |
context.language, context.currency or context.address_country the API cannot read | The value is dropped. For the language, your Accept-Language or else the shop's first language applies. |
More than 10 entries in fulfillment.methods or in a method's destinations | Only the first 10 are read. |
A selected_option_id that is not a string, or an empty one | Ignored: the stored option stays selected. (null clears it.) |
A selected_destination_id that is not a string, or that names no destination | Ignored: the first destination is used. |
groups that is not an array | Ignored, like a missing groups. |
A line id, of any type | Ignored: lines are matched by item.id. |
| A lookup id that is not a string, or a duplicate | Left out. |
| A field the API does not know | Ignored. |
Business errors
A business error is an HTTP 200 whose ucp.status is "error". It returns
no resource — no id, no lines — only messages with one entry and a
continue_url:
{
"ucp": {
"version": "2026-04-08",
"status": "error",
"capabilities": {
"dev.ucp.shopping.cart": [
{
"version": "2026-04-08"
}
],
"dev.ucp.shopping.discount": [
{
"version": "2026-04-08"
}
]
}
},
"messages": [
{
"type": "error",
"code": "not_found",
"content": "Cart not found or has expired",
"severity": "unrecoverable"
}
],
"continue_url": "https://shop.example.com/"
}Any cart operation on an id that is not a UUID, belongs to another shop, or names a canceled or expired cart. HTTP 200.
code | content | Operations | continue_url | What to do |
|---|---|---|---|---|
capabilities_incompatible | The platform and this business share no version of the capability this operation needs | Any /ucp/v1 operation whose root capability your profile does not declare at 2026-04-08. Every cart and checkout operation of a shop in catalog mode. Nothing runs, and no Idempotency-Key is used. | The shop's home page | Declare the capability in your profile. For a catalog-mode shop, stop after the catalog and give the buyer the product's url. |
not_found | Product not found | POST /ucp/v1/catalog/product: an id the shop does not show. | The shop's home page | Search again. |
not_found | Cart not found or has expired | Every cart operation: an id that is not a UUID, another shop's cart, a canceled or expired cart, a cart already deleted. | The shop's home page | Create a new cart. |
not_found | Checkout session not found | Every checkout operation: an id that is not a UUID, another shop's checkout, a checkout already deleted; also POST /ucp/v1/checkout-sessions with an unknown, canceled or expired cart_id. | The shop's home page | Create a new checkout. A canceled or expired checkout is not this: it is answered with status: "canceled". |
out_of_stock | All requested items are currently out of stock | Creating a cart or a checkout, or converting a cart, when lines were sent and every one of them is sold out. The session is discarded and has no id. | The shop's home page | Offer other items, or read the catalog again later. |
item_unavailable | None of the requested items can be bought in this shop | The same, when none of the lines could be added for mixed reasons: unknown ids, items that cannot be ordered, sold-out items — or only sold-out items when you sent discount codes too, whose refusals count as another reason. | The shop's home page | Check the variant ids with a catalog lookup. |
handed_off | The buyer continued on the shop: the items are in the shop’s cart — finish the purchase there | POST /ucp/v1/checkout-sessions with the cart_id of a cart the buyer already took to the shop, or of a cart whose earlier checkout they took and which is no longer open. (While a checkout of the cart is still open, that checkout is answered instead, with 200.) | The shop's cart page, https://<primary domain>/cart | Stop building on this cart. Give the buyer this continue_url if they need to find their cart again. |
Every business error severity is unrecoverable: the same request will get
the same answer. A business error on a write is a result like any other, so it
is stored with the Idempotency-Key and a retry with that key gets it again —
use a new key when you try again with a changed request.
Messages
The message object
| Key | Type | Present | Meaning |
|---|---|---|---|
type | "error", "warning", "info" | always | error: something blocks or was refused. warning: something was changed or not applied, and the answer already shows the result. info: a remark. |
code | string | always | What happened. The codes are listed below; treat a code you do not know by its type and severity. |
content | string | always | A fixed English sentence; on not_found in a lookup, the identifier that matched nothing. |
path | string | on some codes | A JSONPath into the answer it arrives in, for example $.line_items[1].quantity or $.buyer.email. The lines of an answer follow the shop's order, not your request's. |
severity | "recoverable", "requires_buyer_input", "unrecoverable" | on errors only | Who can resolve it. Warnings and info messages have none. |
Where messages appears:
| Answer | messages |
|---|---|
| Checkout | Always, possibly []. |
| Cart | Only when there is at least one. |
| Catalog search and lookup | Only when there is at least one. |
| Product detail | Never; a business error has its own. |
| Business error | Always one. |
Severity and what it asks of you
severity | Meaning | Your move |
|---|---|---|
recoverable | Your platform can resolve it with another request. Any such error keeps a checkout incomplete. | Send what path names, in a PUT. |
requires_buyer_input | Only the buyer can resolve it, on the shop's website. It does not keep a checkout incomplete. | Hand the buyer over with continue_url. |
unrecoverable | This request, or this session, cannot go further. | Stop; start a new session if the buyer still wants to buy. |
| (none — a warning) | Something was adjusted or not applied; the answer shows the result. | Show the buyer the new state, or ask whether it is still what they want. |
| (none — info) | A remark that changes nothing. | Nothing, or log it. |
A checkout's status follows from its messages: any error with severity
recoverable makes it incomplete; otherwise it is requires_escalation
and carries payment_required. The messages of a checkout come in this order:
line messages, discount messages, then what the checkout lacks, and
payment_required last.
Line messages
Carts and checkouts, on create and on every write that sends line_items
(also when a language change re-prices the lines). They appear only in the
answer to the write that caused them: the line is already removed or lowered
in that answer, so the next read of a checkout can return
requires_escalation without them.
code | type / severity | path | content | When | What to do / show the buyer |
|---|---|---|---|---|---|
item_unavailable | error / recoverable | none | Item <id> is not available in this shop | The item.id is not a UUID — a SKU, for instance — or it is neither an active variant of a published product of the company nor such a product (a product id stands for its active variant created first). The storefront's exclusion filters are not applied here. The line is left out. | Look the item up in the catalog and use the variant id. Tell the buyer the item is not sold here. |
item_unavailable | error / recoverable | $.line_items[<i>] while the line is still in the answer, else none | Item <id> cannot be bought right now | The shop refused to add or change the line: not orderable, without a price, or not enough stock for the change. | Offer another variant or item. |
vat_mix_not_allowed | error / recoverable | as above | Item <id>: this shop does not sell zero-VAT and VAT items in one order | The shop does not sell items with and without VAT in one order. | Split the purchase into two sessions. |
out_of_stock | error / recoverable | none (the line leaves the session) | Item <id> is out of stock | No sellable stock and the variant is not on pre-order. The line is removed. | Tell the buyer it is sold out; offer an alternative. |
quantity_adjusted | warning | $.line_items[<i>].quantity | Only <n> available — quantity lowered from <m> | The quantity exceeded the stock; it was lowered to the stock. Pre-order variants are never lowered. | Show the new quantity and ask whether it is still wanted. |
quantity_adjusted | warning | $.line_items[<i>].quantity | At most 999 of one item — quantity lowered from <m> | Several lines of one variant added up to more than 999. | Show the new quantity. |
<id> is the item.id as you sent it (trimmed). The messages about one
variant — cannot be bought right now, vat_mix_not_allowed and
out_of_stock — name the first id you sent for that variant.
Discount messages
With the discount extension negotiated, on every create and PUT of a cart
or checkout, a cart's conversion included. Codes are tried in the order you
sent them, and the first one the shop accepts is applied.
code | type | path | content | When | What to do / show the buyer |
|---|---|---|---|---|---|
discount_code_combination_disallowed | warning | $.discounts.codes[<i>] | Code '<code>' cannot be combined with '<applied code>' | A code after the one already applied: one code per order. | Tell the buyer only one code applies. |
discount_code_expired | warning | $.discounts.codes[<i>] | Code '<code>' cannot be applied | The shop's reason says the code has expired. | Tell the buyer the code has expired. |
discount_code_user_ineligible | warning | $.discounts.codes[<i>] | Code '<code>' cannot be applied | The order is below the code's minimum order total, which is compared with the subtotal without VAT, before the discount. | Tell the buyer the code does not apply to this order. |
discount_code_invalid | warning | $.discounts.codes[<i>] | Code '<code>' cannot be applied | Any other refusal: an unknown or switched-off code, a code not active yet, a code at its usage limit, and every code on a session that has never held a line. | Ask the buyer to check the code. |
On a session whose lines were all removed, codes are judged against a total
of 0: a code with a minimum order total gets discount_code_user_ineligible,
and any other code the shop accepts is applied without a message but takes
nothing off, so discounts.applied stays empty.
<i> counts in the list as the API keeps it: duplicates (compared without
regard to case) and blank codes removed. A refused code stays stored and is
tried again on the next write; send discounts.codes without it to drop it.
Checkout readiness
Derived anew on every create, PUT, GET and POST …/complete of an open
checkout — once the buyer has taken it to the shop, the last answer before the
handoff is repeated instead. The fulfillment messages appear only when the
fulfillment extension is negotiated.
code | severity | path | content | When | What to do |
|---|---|---|---|---|---|
missing | recoverable | $.line_items | The checkout holds no item | The checkout has no line. | PUT line_items. |
missing | recoverable | $.buyer.email | Buyer email is required | No e-mail, or one that is not an e-mail address. | PUT the whole buyer with a valid email. |
missing | recoverable | $.fulfillment.methods[0].destinations | A shipping address is required | No destination yet. | PUT a destination. |
invalid_address | recoverable | $.fulfillment.methods[0].destinations[0] | The shipping address needs street, city, postal code and country | The destination lacks street_address, address_locality, postal_code or address_country. | PUT the complete address. |
address_undeliverable | recoverable | $.fulfillment.methods[0].destinations[0] | The shop does not ship to this address | The address is complete, but none of the shop's shipping methods serves its country for these items. | Ask the buyer for another address; otherwise the buyer cannot buy here. |
invalid_option | recoverable | $.fulfillment.methods[0].groups[0].selected_option_id | The selected shipping option is not offered for this checkout | The selected option is no longer offered, for example after the country changed. The session keeps your choice: the answer shows selected_option_id: null and repeats this message until you select an offered option. | Select one of the options now offered. |
missing | recoverable | $.fulfillment.methods[0].groups[0].selected_option_id | Choose a shipping option | Options exist and none is selected. | Select an option. |
pickup_point_required | requires_buyer_input | $.fulfillment.methods[0].groups[0] | The pickup point is chosen on the shop website | The selected option is a pickup-point service (Packeta). It does not block. | Tell the buyer they pick the point on the shop. |
payment_required | requires_buyer_input | none | Payment is completed on the shop website — continue there to place the order | No recoverable error is left: the checkout is requires_escalation. | Give the buyer continue_url. |
Closed and handed-over sessions
code | type / severity | content | When | What to do |
|---|---|---|---|---|
checkout_completed | error / unrecoverable | This checkout is already completed | A PUT or cancel on a completed checkout. Nothing changes. (A GET or POST …/complete of it answers messages: [].) | Stop; the order is in order. |
checkout_canceled | error / unrecoverable | This checkout is canceled or expired — start a new one | A PUT, POST …/complete or cancel on a canceled or expired checkout. (A GET of it answers messages: [].) | Create a new checkout if the buyer still wants to buy. |
handed_off | warning | The buyer continued on the shop: the items are in the shop’s cart now and this session is no longer changed | A PUT of a cart or checkout after the buyer took it to the shop. The answer is the last one before the handoff; your request was not applied. | Stop writing; poll the checkout for the order. |
These messages can also arrive on a write that raced with the order or with the deadline: the answer is then the session as it is now, closed.
{
"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": "requires_escalation",
"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": [
{
"code": "payment_required",
"type": "error",
"content": "Payment is completed on the shop website — continue there to place the order",
"severity": "requires_buyer_input"
},
{
"type": "warning",
"code": "handed_off",
"content": "The buyer continued on the shop: the items are in the shop’s cart now and this session is no longer changed"
}
],
"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"
}
]
},
"continue_url": "https://shop.example.com/ucp/continue/9bbd3dedf887f93c6287fa7f4b8de141501155714c3b2050bb83551e36ec780b"
}A PUT after the buyer opened continue_url but before the order: the last answer, its values unchanged (served from storage, so its keys come in JSONB order), with its messages and a handed_off warning. The request was not applied. Only a PUT shows the handoff; a GET (and complete) returns the values of step 4.
Catalog messages
code | type | path | content | When | What to do |
|---|---|---|---|---|---|
price_filter_ignored | warning | $.filters.price | filters.price is denominated in <currency>, prices are presented in <currency> and cannot be converted, so the price filter was not applied | Search or lookup with filters.price in a context.currency the shop has no exchange rate for. Every match is returned. | Filter the results yourself, or send the bounds in the currency of the answer. |
not_found | info | none | the identifier itself, for example trek-marlin-9 | Lookup: an id that matched no product or variant the shop shows. | Drop the id; the other results stand. |
Showing messages to the buyer
content is English and written for developers. Build the text you show the
buyer from code and path, in the buyer's language, and keep content for
your logs. Three rules cover every message:
- Act on
recoverableerrors yourself, by sending whatpathnames. - Show warnings as the change they describe — "only 2 in stock, quantity lowered" — because the answer already contains the changed state.
- Pass
requires_buyer_inputto the buyer together withcontinue_url.
Codes may be added in the future. Handle an unknown code by its type and
severity, never by failing.
Answers that are not JSON
The web server
The web server in front of every shop answers these itself, before the API sees the request. The body is a short HTML page, not JSON.
| HTTP | When | Retry? | What to do |
|---|---|---|---|
301 | A request to http://. It is redirected to the same URL on https://; a client following it may turn a POST into a GET. | — | Call https:// from the start. |
413 | A request body over 1 MiB. | No | Keep bodies small. |
429 | More than 100 requests a second from your IP, with a burst of 200, counted over all of the web server's shop traffic. No Retry-After. | Yes, with backoff | Spread requests; see Rate limits. |
502 | The API is down or restarting. | Yes, with backoff | Retry writes with the same Idempotency-Key. |
504 | The API did not answer within 60 seconds. A write may have been applied. | Yes, with backoff | Retry writes with the same Idempotency-Key: you get the stored answer, or a 409 while it is still running. |
<html>
<head><title>429 Too Many Requests</title></head>
<body>
<center><h1>429 Too Many Requests</h1></center>
<hr><center>nginx</center>
</body>
</html>
nginx’s own page, above 100 requests/s per client IP (burst 200): one budget for everything that IP sends to any shop host Monokaido serves (UCP calls, pages, images) and to the storefront API. No Retry-After, not JSON. Back off and retry. The footer may carry nginx’s version.
Paths the web server does not send to the API — upper-case /UCP/…,
/.well-known/ucp/ with a trailing slash, /ucp without the rest — reach the
shop's web pages and answer with HTML as well. Use the paths exactly as
published.
The handoff routes
continue_url (GET /ucp/continue/<token>) is opened by the buyer's browser,
and its page sends POST /ucp/continue/<token> itself. Your client calls
neither, but you may see these answers reported by buyers:
| Route | HTTP | Body | When |
|---|---|---|---|
GET | 200 | HTML | The handoff page: it moves the lines into the buyer's cart and goes on to the shop. |
GET | 410 | HTML | The session is completed, canceled or expired, or the token is not one of this shop's. |
GET | 404 | HTML (an empty page) | The host no longer serves UCP. |
GET, POST | 429 | JSON rate_limited | More than 60 requests a minute from the buyer's IP to that route. |
GET, POST | 503 | JSON service_unavailable, This shop is temporarily unavailable | The shop is suspended or could not be looked up — or, on POST only, the session was busy with your requests. |
GET, POST | 500 | JSON internal_error | An unexpected failure. |
POST | 200 | {"redirect": "/checkout"}, "/cart" or "/" | Where the page sends the browser next. |
POST | 400 | JSON invalid_request, The request is not valid | The page sent no usable guest-cart id. |
POST | 404 | JSON not_found, Not found | The session can no longer be handed over. (No UCP business on this host when the host serves no UCP.) |
The page retries a 5xx, a 429 or a lost connection twice, after 1 and
2 seconds, then sends the browser to the shop's checkout, cart or home page
without the lines. If your platform keeps polling a session the buyer is
opening, keep at most one request in flight, so the buyer's claim gets its
turn.
What to log
The API returns no request id. For every call, log at least the time in UTC,
the host, the method and path, the HTTP status, code (or the message codes)
and your own correlation id. Send that id as X-Request-ID too: the shop's
web server writes it into its access log (it is not returned), so a problem
can be found on Monokaido's side. Put nothing secret in it. Cart and checkout ids are credentials: keep them out of logs that other
parties can read — see Security and data protection.