Skip to content

Developer quickstart

A whole UCP purchase step by step — curl, JavaScript and Python, from the shop's profile to handing the checkout to the buyer.

On this page

This chapter takes one purchase through a Monokaido shop from the first call to the order. You read the shop's profile, search its catalog, build a cart, turn it into a checkout, add the buyer and the shipping, give the buyer the continue_url and learn that the order was placed. Each step shows the exact request, headers included, in curl, JavaScript (Node 20, built-in fetch) and Python 3 (requests), followed by the shop's answer. At the end are two complete scripts that run the whole flow with retries and polling.

The examples use the fictional shop https://shop.example.com, a Slovak bike shop with prices in EUR, and the platform profile https://platform.example.com/.well-known/ucp. The JSON answers are the examples of the API reference, produced by Monokaido's own code from sample data, so their shape is exactly what a real shop returns. Ids, prices and texts differ from shop to shop.

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.

Before you start

A shop with UCP switched on

You need the domain of a shop whose merchant has switched UCP on (Turning UCP on for a shop). UCP is served only on a shop's own custom domain with an active certificate, never on a *.shop.monokaido.com address. A quick check:

curl -sS -o /dev/null -w '%{http_code}\n' https://shop.example.com/.well-known/ucp

200 means the shop serves UCP. 404 means it does not, whatever the reason: the answer is the same in every case, on purpose. 503 means the shop cannot answer right now: try again after Retry-After.

There is no sandbox. Carts and checkouts are priced with the shop's live prices and stock, but they are not orders: nothing is ordered and nothing is paid until a buyer opens a continue_url and orders on the shop. That order is a real order of the shop, so agree any test with the merchant.

Your platform profile

Every call under /ucp/v1 names your platform profile in the UCP-Agent header, and the shop fetches it. Host a JSON document like this one at an https:// address you control:

{
  "ucp": {
    "version": "2026-04-08",
    "capabilities": {
      "dev.ucp.shopping.catalog.search": [{ "version": "2026-04-08" }],
      "dev.ucp.shopping.catalog.lookup": [{ "version": "2026-04-08" }],
      "dev.ucp.shopping.cart": [{ "version": "2026-04-08" }],
      "dev.ucp.shopping.checkout": [{ "version": "2026-04-08" }],
      "dev.ucp.shopping.fulfillment": [{ "version": "2026-04-08" }],
      "dev.ucp.shopping.discount": [{ "version": "2026-04-08" }]
    }
  }
}

That is all the shop reads: ucp.version, which must be exactly 2026-04-08, and ucp.capabilities. A capability you leave out is not negotiated, and every operation that needs it answers capabilities_incompatible. Drop dev.ucp.shopping.fulfillment and checkouts ask for no address and no shipping option; drop dev.ucp.shopping.discount and discount codes are ignored. Other members of your profile are allowed and ignored.

The shop fetches the profile with a plain GET and accepts it only when:

  • the URL starts with https://, has no user name or password, contains no whitespace and is at most 1 000 characters long;
  • the host resolves to a public IP address (private, loopback and other reserved ranges are refused);
  • the answer is HTTP 200 (no redirect is followed) with a JSON body of at most 256 KiB, delivered within 5 seconds.

Serve it with Cache-Control: public, max-age=3600. The shop caches the profile for its max-age, clamped to between 60 seconds and one hour (five minutes without a max-age). When that time is up, the next request is still answered with the cached profile while the shop fetches it again in the background, so a change reaches a shop shortly after that time. A failed fetch of a profile the shop does not have yet is remembered for 60 seconds: after you fix the hosting, wait a minute. The full URL is the cache key, and it also scopes your Idempotency-Key values, so keep it stable. Each profile URL that is not cached yet costs a fetch, and one client IP address can have at most 5 new profiles fetched a minute, across all Monokaido shops; beyond that the answer is 503 with Retry-After.

Headers

HeaderSend it onValue
UCP-Agentevery call under /ucp/v1profile="https://platform.example.com/.well-known/ucp". The double quotes are part of the syntax, and profile is lower-case.
Content-Typeevery request with a bodyapplication/json, UTF-8. A form-encoded body (application/x-www-form-urlencoded) is parsed too, but its values arrive as strings, so numeric fields such as quantity are refused; a body of any other type is not read.
Idempotency-Keythe seven writes: create, update and cancel a cart; create, update, complete and cancel a checkoutA new UUID for each logical write, sent again unchanged on every retry of that write. Any 1 to 255 visible ASCII characters are accepted; another value is refused with 400. A key is kept for at least 25 hours after its first use (rely on 24), separately for each shop and each profile URL. Optional, but without it a retried create makes a second cart or checkout.
Accept-LanguageoptionalUsed when the body has no context.language or the shop does not offer it. Tags are tried in the order written; q= weights are ignored. Without a match the answer is in the shop's default language. A cart or checkout keeps its language until a request body names another one.

There is no API key, no OAuth and no cookie: the shop is chosen from the host you call. Call the API from your server. It sends no CORS headers for other origins, so a web page on your own domain cannot call it from the browser.

The calls

StepCallAnswer
1GET /.well-known/ucpthe business profile
2POST /ucp/v1/catalog/searchproducts with their variants
3POST /ucp/v1/carts201, the new cart
4PUT /ucp/v1/carts/{id}the cart, priced again
5POST /ucp/v1/checkout-sessions201, a checkout in incomplete
6PUT /ucp/v1/checkout-sessions/{id}the checkout in requires_escalation
7nonethe buyer opens continue_url in a browser
8GET /ucp/v1/checkout-sessions/{id}completed, with the order

Setup for the snippets

The snippets of the steps share this setup. In curl they are shell variables (the snippets also need jq and uuidgen). In JavaScript and Python a small helper sends the headers and throws on both kinds of failure: a protocol error (an HTTP error status with a code) and a business error (HTTP 200 with ucp.status: "error", when no resource came back). Paste the steps after it, in order. The helpers do not retry; the complete scripts do.

SHOP=https://shop.example.com
AGENT='profile="https://platform.example.com/.well-known/ucp"'

Step 1: discover the shop

The business profile is public: it needs no UCP-Agent header and the shop lets it be cached for five minutes.

curl -sS -o profile.json -w 'HTTP %{http_code}\n' "$SHOP/.well-known/ucp"
jq . profile.json
API=$(jq -r '.ucp.services["dev.ucp.shopping"][0].endpoint' profile.json)  # https://shop.example.com/ucp/v1
JSONProfile 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.

Read three things from it:

  • endpoint in ucp.services["dev.ucp.shopping"] is the base of every further call. Its host is the shop's primary domain, which can differ from the domain you asked. Use it rather than building the URL yourself.
  • ucp.capabilities says what the shop offers right now. A shop in catalog mode lists only dev.ucp.shopping.catalog.search and dev.ucp.shopping.catalog.lookup: you can search it, but every cart and checkout call answers capabilities_incompatible, so stop after step 2.
  • payment_handlers is always {}. No payment goes through UCP; the buyer pays on the shop.

Step 2: search the catalog

Search by text in the shop's language. pagination.limit defaults to 10 and is lowered to 20 when you ask for more. context.language picks the language of the texts and with it the currency of every price. context.currency does not change the currency: it only says what currency a filters.price is in. Without it, the price bounds are read in the currency of the answer, the one the language gives.

curl -sS -o search.json -w 'HTTP %{http_code}\n' -X POST "$API/catalog/search" \
  -H "UCP-Agent: $AGENT" \
  -H 'Content-Type: application/json' \
  -H 'Accept-Language: sk' \
  -d '{"query":"marlin","pagination":{"limit":2},"context":{"language":"sk","address_country":"SK","currency":"EUR"}}'
jq '.products[] | {title, variants: [.variants[] | {id, title, price, availability}]}' search.json
VARIANT=$(jq -r '[.products[].variants[] | select(.availability.available)][0].id' search.json)

The request body:

JSONSearch “marlin”, two per page
{
  "query": "marlin",
  "pagination": {
    "limit": 2
  },
  "context": {
    "language": "sk",
    "address_country": "SK",
    "currency": "EUR"
  }
}

First page of a text search in Slovak. currency here only denominates a price filter (none is sent); unknown fields would be ignored.

The answer:

JSONFirst page: two products, more to come
{
  "ucp": {
    "version": "2026-04-08",
    "capabilities": {
      "dev.ucp.shopping.catalog.search": [
        {
          "version": "2026-04-08"
        }
      ]
    }
  },
  "products": [
    {
      "id": "6f1d2b7c-3a84-4e59-b0c2-8d7e1f4a5b62",
      "handle": "trek-marlin-5",
      "title": "Trek Marlin 5",
      "description": {
        "plain": "Horský bicykel s hliníkovým rámom Alpha Silver, odpruženou vidlicou a 29\" kolesami.",
        "html": "<p>Horský bicykel pre začínajúcich jazdcov.</p><ul><li>Rám Alpha Silver Aluminium</li><li>Vidlica SR Suntour XCT 30, zdvih 100 mm</li><li>Pohon Shimano 2×8</li></ul>"
      },
      "url": "https://shop.example.com/products/trek-marlin-5",
      "price_range": {
        "min": {
          "amount": 69900,
          "currency": "EUR"
        },
        "max": {
          "amount": 71900,
          "currency": "EUR"
        }
      },
      "list_price_range": {
        "min": {
          "amount": 74900,
          "currency": "EUR"
        },
        "max": {
          "amount": 74900,
          "currency": "EUR"
        }
      },
      "media": [
        {
          "type": "image",
          "url": "https://shop.example.com/api/public/uploads/c4d1e5a2-8b3f-4a6c-9e07-5f2d8a1b3c40/products/trek-marlin-5-modra.webp",
          "alt_text": "Trek Marlin 5, modrá, bočný pohľad"
        },
        {
          "type": "image",
          "url": "https://images.vendor.example/trek/marlin-5-detail.jpg"
        }
      ],
      "options": [
        {
          "name": "Farba",
          "values": [
            {
              "label": "Modrá"
            },
            {
              "label": "Čierna"
            }
          ]
        },
        {
          "name": "Veľkosť",
          "values": [
            {
              "label": "M"
            },
            {
              "label": "L"
            }
          ]
        }
      ],
      "variants": [
        {
          "id": "1e8a6c4f-2b93-4d71-9f05-6a3c8e2b1d47",
          "sku": "MARLIN5-M-MOD",
          "barcodes": [
            {
              "type": "EAN",
              "value": "8594012345674"
            }
          ],
          "title": "Modrá / M",
          "description": {
            "plain": "Modrá / M"
          },
          "url": "https://shop.example.com/products/trek-marlin-5",
          "price": {
            "amount": 69900,
            "currency": "EUR"
          },
          "list_price": {
            "amount": 74900,
            "currency": "EUR"
          },
          "availability": {
            "available": true,
            "status": "in_stock"
          },
          "options": [
            {
              "name": "Farba",
              "label": "Modrá"
            },
            {
              "name": "Veľkosť",
              "label": "M"
            }
          ]
        },
        {
          "id": "7c2f9b1e-4a68-4e3d-b2c9-5d1f7a3e8b06",
          "sku": "MARLIN5-L-MOD",
          "title": "Modrá / L",
          "description": {
            "plain": "Modrá / L"
          },
          "url": "https://shop.example.com/products/trek-marlin-5",
          "price": {
            "amount": 69900,
            "currency": "EUR"
          },
          "availability": {
            "available": false,
            "status": "out_of_stock"
          },
          "options": [
            {
              "name": "Farba",
              "label": "Modrá"
            },
            {
              "name": "Veľkosť",
              "label": "L"
            }
          ]
        },
        {
          "id": "d93b4a7e-1f26-4c85-a0e7-3b9c6d2f4e18",
          "sku": "MARLIN5-M-CIE",
          "title": "Čierna / M",
          "description": {
            "plain": "Čierna / M"
          },
          "url": "https://shop.example.com/products/trek-marlin-5",
          "price": {
            "amount": 71900,
            "currency": "EUR"
          },
          "availability": {
            "available": true,
            "status": "preorder"
          },
          "options": [
            {
              "name": "Farba",
              "label": "Čierna"
            },
            {
              "name": "Veľkosť",
              "label": "M"
            }
          ]
        }
      ],
      "tags": [
        "Trek"
      ]
    },
    {
      "id": "2f6c8a1d-7e43-4b95-9c2a-4d1e8b6f3a07",
      "handle": "trek-marlin-7",
      "title": "Trek Marlin 7",
      "description": {
        "plain": "Trek Marlin 7"
      },
      "url": "https://shop.example.com/products/trek-marlin-7",
      "price_range": {
        "min": {
          "amount": 104900,
          "currency": "EUR"
        },
        "max": {
          "amount": 104900,
          "currency": "EUR"
        }
      },
      "media": [
        {
          "type": "image",
          "url": "https://shop.example.com/api/public/uploads/c4d1e5a2-8b3f-4a6c-9e07-5f2d8a1b3c40/products/trek-marlin-7.webp"
        }
      ],
      "options": [
        {
          "name": "Veľkosť",
          "values": [
            {
              "label": "L"
            }
          ]
        }
      ],
      "variants": [
        {
          "id": "c8e2a5f1-3d79-4a16-b4e3-9f6c2d8a1e54",
          "sku": "MARLIN7-L",
          "title": "L",
          "description": {
            "plain": "L"
          },
          "url": "https://shop.example.com/products/trek-marlin-7",
          "price": {
            "amount": 104900,
            "currency": "EUR"
          },
          "availability": {
            "available": true,
            "status": "in_stock"
          },
          "options": [
            {
              "name": "Veľkosť",
              "label": "L"
            }
          ]
        }
      ],
      "tags": [
        "Trek"
      ]
    }
  ],
  "pagination": {
    "has_next_page": true,
    "cursor": "cDoy",
    "total_count": 3
  }
}

The answer to CatalogSearchRequest. The listing had 3 matches (page 1 of 2), so has_next_page is true and total_count is 3 (no price filter in effect). The first available variant of Trek Marlin 5 is already first; the pre-order variant counts as available; the vendor image keeps its own URL.

What matters for the next steps:

  • variants[].id is what goes into a cart line. Pick a variant with availability.available: true (status in_stock or preorder).
  • Prices are integers in the minor units of the currency, VAT included: 69900 with EUR is 699.00 €. list_price is the earlier or recommended price, shown only when it is higher.
  • Paging. While has_next_page is true, send the same body again with pagination.cursor set to the cursor you got. The cursor holds only the page number, so the query, filters and limit must stay the same. A page can be short, even empty, while more pages follow: keep going until has_next_page is false.
  • Order and matching. Products come newest first; there is no relevance ranking. The query matches the product title and the product's own codes (the shop's product code and the supplier's product ids), not descriptions, variant SKUs or EAN codes. Case does not matter, diacritics do: modra does not find Modrá. To resolve SKUs, handles or ids, use POST /ucp/v1/catalog/lookup with up to 20 of them.
  • A listing that moves. Search reads the shop's product listing, which the platform refreshes in the background: a newly published product can take a few minutes to appear, and one published while you page shifts the later pages, so a product can repeat or be skipped. De-duplicate products by id. Lookup and product read the product data itself.

Step 3: create a cart

Each line names a variant in item.id: always the variants[].id you chose from the catalog. A product id is accepted too, but the line then buys the product's active variant created first, which need not be the variant the search showed, nor one in stock; the answer names that variant. quantity defaults to 1; zero, a negative number or a fraction is refused with 400. At most 100 lines are accepted, and a quantity above 999 is lowered to 999 without a message. Two lines of one variant become one line with their quantities added up. A buyer is optional on a cart. This is the first write, so it carries an Idempotency-Key.

KEY=$(uuidgen)  # one key per logical write; a retry of this request sends the same one
jq -n --arg variant "$VARIANT" '{
  line_items: [
    {item: {id: $variant}, quantity: 1},
    {item: {id: "4a6e2d9c-8b15-4f37-9c48-1e7a5b3d2f90"}}
  ],
  buyer: {email: "Jana.Novakova@Example.com"},
  context: {address_country: "SK", language: "sk"}
}' | curl -sS -o cart.json -w 'HTTP %{http_code}\n' -X POST "$API/carts" \
  -H "UCP-Agent: $AGENT" \
  -H 'Content-Type: application/json' \
  -H 'Accept-Language: sk' \
  -H "Idempotency-Key: $KEY" \
  --data-binary @-
jq . cart.json
CART=$(jq -r .id cart.json)
JSONA bike and a helmet
{
  "line_items": [
    {
      "item": {
        "id": "1e8a6c4f-2b93-4d71-9f05-6a3c8e2b1d47"
      },
      "quantity": 1
    },
    {
      "item": {
        "id": "4a6e2d9c-8b15-4f37-9c48-1e7a5b3d2f90"
      }
    }
  ],
  "buyer": {
    "email": "Jana.Novakova@Example.com"
  },
  "context": {
    "address_country": "SK",
    "language": "sk"
  }
}

Step 1 of the flow. The helmet line has no quantity (1). The e-mail is lower-cased when stored.

The shop answers 201 with the cart priced by its own rules:

JSONCart created (201)
{
  "ucp": {
    "version": "2026-04-08",
    "capabilities": {
      "dev.ucp.shopping.cart": [
        {
          "version": "2026-04-08"
        }
      ],
      "dev.ucp.shopping.discount": [
        {
          "version": "2026-04-08"
        }
      ]
    }
  },
  "id": "9a3f6c2e-5b18-4d47-8e9c-1f2a7b4d6e83",
  "line_items": [
    {
      "id": "e2b7d4a9-3c61-4f85-9a2e-7d1c5b8f3a46",
      "item": {
        "id": "1e8a6c4f-2b93-4d71-9f05-6a3c8e2b1d47",
        "title": "Trek Marlin 5",
        "price": 69900,
        "image_url": "https://shop.example.com/api/public/uploads/c4d1e5a2-8b3f-4a6c-9e07-5f2d8a1b3c40/products/trek-marlin-5-modra.webp"
      },
      "quantity": 1,
      "totals": [
        {
          "type": "subtotal",
          "amount": 69900
        },
        {
          "type": "total",
          "amount": 69900
        }
      ]
    },
    {
      "id": "5c9e1a7f-2d48-4b36-a8f1-6e3d9c2b7a15",
      "item": {
        "id": "4a6e2d9c-8b15-4f37-9c48-1e7a5b3d2f90",
        "title": "Cyklistická prilba Lumen",
        "price": 5990
      },
      "quantity": 1,
      "totals": [
        {
          "type": "subtotal",
          "amount": 5990
        },
        {
          "type": "total",
          "amount": 5990
        }
      ]
    }
  ],
  "currency": "EUR",
  "totals": [
    {
      "type": "subtotal",
      "amount": 75890
    },
    {
      "type": "total",
      "amount": 75890
    }
  ],
  "buyer": {
    "email": "jana.novakova@example.com"
  },
  "context": {
    "address_country": "SK",
    "language": "sk"
  },
  "discounts": {
    "codes": [],
    "applied": []
  },
  "continue_url": "https://shop.example.com/ucp/continue/e1f0907c4abcf12564330feef5762f8471cc386a3563ce53cf8342ce6be08b62",
  "expires_at": "2026-11-02T09:14:05.118Z"
}

The answer to CartCreateRequest: the bike (699.00 €) and the helmet (59.90 €). No messages key (none). discounts is present because the discount extension is negotiated. The helmet has no image, so no image_url. expires_at = now + 7 days.

  • Keep the cart's id. A cart lives for 7 days after its last write; after that every call on it answers not_found.
  • totals holds subtotal, a negative discount when a code applies, and total. Line prices are in item.price, in minor units like everything else.
  • messages appears only when there is something to say. A line the shop cannot sell is left out with an item_unavailable, out_of_stock or vat_mix_not_allowed message; the rest of the cart is created.
  • If none of the lines can be added, the answer is HTTP 200 with ucp.status: "error", an out_of_stock or item_unavailable message and no cart id.
  • A cart has a continue_url too, but hand the buyer a checkout: only a checkout ever learns that an order was placed.

Step 4: update the cart

PUT replaces the cart's lines. line_items is required and is the whole list: a variant you leave out is removed. Lines are matched by item.id; a line id you send is ignored. A field you leave out, such as buyer or context, keeps its stored value. A field you send replaces the stored one as a whole, and discounts.codes replaces the list of codes ([] removes them all).

KEY=$(uuidgen)
jq -n --arg variant "$VARIANT" '{
  line_items: [
    {item: {id: $variant}, quantity: 1},
    {item: {id: "b5f81c3d-6e49-4a2b-8d73-9c0e4f1a6b25"}, quantity: 3}
  ],
  discounts: {codes: ["JESEN10", "LETO5"]}
}' | curl -sS -o cart.json -w 'HTTP %{http_code}\n' -X PUT "$API/carts/$CART" \
  -H "UCP-Agent: $AGENT" \
  -H 'Content-Type: application/json' \
  -H 'Accept-Language: sk' \
  -H "Idempotency-Key: $KEY" \
  --data-binary @-
jq '{totals, messages}' cart.json
JSONSwap the helmet for three bottles and add two codes
{
  "line_items": [
    {
      "id": "e2b7d4a9-3c61-4f85-9a2e-7d1c5b8f3a46",
      "item": {
        "id": "1e8a6c4f-2b93-4d71-9f05-6a3c8e2b1d47"
      },
      "quantity": 1
    },
    {
      "item": {
        "id": "b5f81c3d-6e49-4a2b-8d73-9c0e4f1a6b25"
      },
      "quantity": 3
    }
  ],
  "discounts": {
    "codes": [
      "JESEN10",
      "LETO5"
    ]
  }
}

Step 2. line_items is a full replacement: the helmet is removed. The line id is accepted and ignored (lines match by variant). Only 2 bottles are in stock. buyer and context are left out, so they are kept.

JSONCart updated: quantity lowered, one code applied
{
  "ucp": {
    "version": "2026-04-08",
    "capabilities": {
      "dev.ucp.shopping.cart": [
        {
          "version": "2026-04-08"
        }
      ],
      "dev.ucp.shopping.discount": [
        {
          "version": "2026-04-08"
        }
      ]
    }
  },
  "id": "9a3f6c2e-5b18-4d47-8e9c-1f2a7b4d6e83",
  "line_items": [
    {
      "id": "e2b7d4a9-3c61-4f85-9a2e-7d1c5b8f3a46",
      "item": {
        "id": "1e8a6c4f-2b93-4d71-9f05-6a3c8e2b1d47",
        "title": "Trek Marlin 5",
        "price": 69900,
        "image_url": "https://shop.example.com/api/public/uploads/c4d1e5a2-8b3f-4a6c-9e07-5f2d8a1b3c40/products/trek-marlin-5-modra.webp"
      },
      "quantity": 1,
      "totals": [
        {
          "type": "subtotal",
          "amount": 69900
        },
        {
          "type": "total",
          "amount": 69900
        }
      ]
    },
    {
      "id": "8f4a2c6e-1b93-4d57-b6e8-3a9c7d1f5e24",
      "item": {
        "id": "b5f81c3d-6e49-4a2b-8d73-9c0e4f1a6b25",
        "title": "Fľaša na vodu 750 ml",
        "price": 990
      },
      "quantity": 2,
      "totals": [
        {
          "type": "subtotal",
          "amount": 1980
        },
        {
          "type": "total",
          "amount": 1980
        }
      ]
    }
  ],
  "currency": "EUR",
  "totals": [
    {
      "type": "subtotal",
      "amount": 71880
    },
    {
      "type": "discount",
      "amount": -5844,
      "display_text": "JESEN10"
    },
    {
      "type": "total",
      "amount": 66036
    }
  ],
  "messages": [
    {
      "type": "warning",
      "code": "quantity_adjusted",
      "content": "Only 2 available — quantity lowered from 3",
      "path": "$.line_items[1].quantity"
    },
    {
      "type": "warning",
      "code": "discount_code_combination_disallowed",
      "path": "$.discounts.codes[1]",
      "content": "Code 'LETO5' cannot be combined with 'JESEN10'"
    }
  ],
  "buyer": {
    "email": "jana.novakova@example.com"
  },
  "context": {
    "language": "sk",
    "address_country": "SK"
  },
  "discounts": {
    "codes": [
      "JESEN10",
      "LETO5"
    ],
    "applied": [
      {
        "code": "JESEN10",
        "title": "JESEN10",
        "amount": 5844,
        "method": "across"
      }
    ]
  },
  "continue_url": "https://shop.example.com/ucp/continue/e1f0907c4abcf12564330feef5762f8471cc386a3563ce53cf8342ce6be08b62",
  "expires_at": "2026-11-02T09:16:47.402Z"
}

The answer to CartUpdateRequest. The bottle line was lowered to the 2 in stock (quantity_adjusted at its index in the answer); JESEN10 (10 % of the net subtotal 584.39 € = 58.44 €) is applied, LETO5 cannot be combined. Subtotal 718.80 € − 58.44 € = 660.36 €. The kept buyer and context are echoed; expires_at moved to now + 7 days.

  • One discount code applies to a cart. Codes are tried in the order you send them; the first one the shop accepts applies, and each further one gets a discount_code_combination_disallowed warning. A code the shop refuses gets a discount_code_invalid, discount_code_expired or discount_code_user_ineligible warning; each warning's path points to the code in discounts.codes. A percentage code is computed from the subtotal without VAT, as on the shop's own checkout, and subtracted from the subtotal with VAT.
  • A quantity above the stock is lowered, with a quantity_adjusted warning.
  • The lines come back in the shop's order, not in yours. Match them by item.id; a message path such as $.line_items[1].quantity points into the answer, not into your request.
  • Each create and update sets expires_at to 7 days from now.

Step 5: turn the cart into a checkout

Send the cart's id to POST /ucp/v1/checkout-sessions. The checkout copies the cart's lines, buyer, context (the language included) and discount codes; the request's own line_items, buyer and context are not used. You may send fulfillment and discounts with the cart_id; they apply to the new checkout. The cart itself stays as it is. cart_id is read only when your profile declares dev.ucp.shopping.cart; without it the request needs line_items. Without a cart, the same route takes line_items (and the other fields) directly, in the shape of step 3.

KEY=$(uuidgen)
curl -sS -o checkout.json -w 'HTTP %{http_code}\n' -X POST "$API/checkout-sessions" \
  -H "UCP-Agent: $AGENT" \
  -H 'Content-Type: application/json' \
  -H 'Accept-Language: sk' \
  -H "Idempotency-Key: $KEY" \
  -d "{\"cart_id\":\"$CART\"}"
jq '{id, status, messages, options: .fulfillment.methods[0].groups[0].options}' checkout.json
CHECKOUT=$(jq -r .id checkout.json)
OPTION=$(jq -r '.fulfillment.methods[0].groups[0].options[0].id' checkout.json)
JSONConvert the cart
{
  "cart_id": "9a3f6c2e-5b18-4d47-8e9c-1f2a7b4d6e83"
}

Step 3: a checkout from the cart of step 2. Its lines, buyer, context and codes come from the cart.

JSONCheckout created from the cart (201, incomplete)
{
  "ucp": {
    "version": "2026-04-08",
    "capabilities": {
      "dev.ucp.shopping.checkout": [
        {
          "version": "2026-04-08"
        }
      ],
      "dev.ucp.shopping.fulfillment": [
        {
          "version": "2026-04-08"
        }
      ],
      "dev.ucp.shopping.discount": [
        {
          "version": "2026-04-08"
        }
      ]
    },
    "payment_handlers": {}
  },
  "id": "3d7b9e2a-6f14-4c58-9b3e-8a2f1c6d4e97",
  "status": "incomplete",
  "currency": "EUR",
  "line_items": [
    {
      "id": "f6a3c8e1-9d27-4b54-8e6a-2c1f7b9d3a58",
      "item": {
        "id": "1e8a6c4f-2b93-4d71-9f05-6a3c8e2b1d47",
        "title": "Trek Marlin 5",
        "price": 69900,
        "image_url": "https://shop.example.com/api/public/uploads/c4d1e5a2-8b3f-4a6c-9e07-5f2d8a1b3c40/products/trek-marlin-5-modra.webp"
      },
      "quantity": 1,
      "totals": [
        {
          "type": "subtotal",
          "amount": 69900
        },
        {
          "type": "total",
          "amount": 69900
        }
      ]
    },
    {
      "id": "2e9d5b7a-4c83-4f16-a9b2-6d8e1c3f7a49",
      "item": {
        "id": "b5f81c3d-6e49-4a2b-8d73-9c0e4f1a6b25",
        "title": "Fľaša na vodu 750 ml",
        "price": 990
      },
      "quantity": 2,
      "totals": [
        {
          "type": "subtotal",
          "amount": 1980
        },
        {
          "type": "total",
          "amount": 1980
        }
      ]
    }
  ],
  "totals": [
    {
      "type": "subtotal",
      "amount": 71880
    },
    {
      "type": "discount",
      "amount": -5844,
      "display_text": "JESEN10"
    },
    {
      "type": "total",
      "amount": 66036
    }
  ],
  "messages": [
    {
      "type": "warning",
      "code": "discount_code_combination_disallowed",
      "path": "$.discounts.codes[1]",
      "content": "Code 'LETO5' cannot be combined with 'JESEN10'"
    },
    {
      "type": "error",
      "code": "missing",
      "path": "$.fulfillment.methods[0].destinations",
      "content": "A shipping address is required",
      "severity": "recoverable"
    },
    {
      "type": "error",
      "code": "missing",
      "path": "$.fulfillment.methods[0].groups[0].selected_option_id",
      "content": "Choose a shipping option",
      "severity": "recoverable"
    }
  ],
  "links": [
    {
      "type": "terms_of_service",
      "url": "https://shop.example.com/obchodne-podmienky",
      "title": "Obchodné podmienky"
    },
    {
      "type": "privacy_policy",
      "url": "https://shop.example.com/ochrana-osobnych-udajov",
      "title": "Ochrana osobných údajov"
    }
  ],
  "buyer": {
    "email": "jana.novakova@example.com"
  },
  "context": {
    "language": "sk",
    "address_country": "SK"
  },
  "fulfillment": {
    "methods": [
      {
        "id": "shipping",
        "type": "shipping",
        "line_item_ids": [
          "f6a3c8e1-9d27-4b54-8e6a-2c1f7b9d3a58",
          "2e9d5b7a-4c83-4f16-a9b2-6d8e1c3f7a49"
        ],
        "groups": [
          {
            "id": "default",
            "line_item_ids": [
              "f6a3c8e1-9d27-4b54-8e6a-2c1f7b9d3a58",
              "2e9d5b7a-4c83-4f16-a9b2-6d8e1c3f7a49"
            ],
            "options": [
              {
                "id": "0c5e8a3f-7b21-4d96-8f4c-9e2a6d1b3c75",
                "title": "GLS kuriér",
                "description": "Doručenie na adresu do 1–2 pracovných dní",
                "totals": [
                  {
                    "type": "total",
                    "amount": 490
                  }
                ]
              },
              {
                "id": "6b2d9f4e-3a17-4c68-9d5b-1f8e3a7c2b40",
                "title": "Packeta – výdajné miesto",
                "description": "Delivery in 2–3 business days",
                "totals": [
                  {
                    "type": "total",
                    "amount": 290
                  }
                ]
              }
            ],
            "selected_option_id": null
          }
        ]
      }
    ]
  },
  "discounts": {
    "codes": [
      "JESEN10",
      "LETO5"
    ],
    "applied": [
      {
        "code": "JESEN10",
        "title": "JESEN10",
        "amount": 5844,
        "method": "across"
      }
    ]
  },
  "continue_url": "https://shop.example.com/ucp/continue/9bbd3dedf887f93c6287fa7f4b8de141501155714c3b2050bb83551e36ec780b",
  "expires_at": "2026-10-27T09:18:12.930Z"
}

The answer to CheckoutFromCartRequest. The checkout has its own lines (new line ids), the cart’s buyer, context and codes; JESEN10 is applied again and LETO5 warned about again. No address yet, so both shipping options are offered (no country filter) and none is selected: two recoverable errors keep it incomplete, so there is no payment_required. expires_at = now + 24 h.

  • The answer is 201. This checkout is incomplete because no address and no shipping option are known yet; a checkout that already has everything is requires_escalation from the start. A checkout always carries messages, and its recoverable errors say what is still missing.
  • The checkout has its own lines, with new line ids.
  • fulfillment.methods[0].groups[0].options lists the shop's shipping methods with their fees. While no address is known, they are not filtered by country.
  • A checkout lives for 24 hours after its last write.
  • A cart has at most one open checkout. Sending the same cart_id again answers that checkout with HTTP 200 and ignores the fulfillment and discounts of the repeat; change the checkout with PUT instead.

Step 6: add the buyer and the shipping

A checkout turns requires_escalation as soon as no message is an error with severity: "recoverable". These messages say what to send:

codepathWhat to send
missing$.buyer.emailbuyer.email. An address that is not a valid e-mail is reported as missing.
missing$.fulfillment.methods[0].destinationsa shipping address
invalid_address$.fulfillment.methods[0].destinations[0]the address with street_address, address_locality, postal_code and address_country
address_undeliverable$.fulfillment.methods[0].destinations[0]another address: none of the shop's shipping methods delivers these items to this country
missing$.fulfillment.methods[0].groups[0].selected_option_idone of the offered options[].id
invalid_option$.fulfillment.methods[0].groups[0].selected_option_idan option from the current list
missing$.line_itemsat least one line
item_unavailable, out_of_stock, vat_mix_not_allowednone, or $.line_items[n]another item, or line_items without it. Only the answer to the request that sent the line carries these: a line that was left out has no path, a refused change points at the line the checkout kept.

The buyer's name and phone number are not required.

KEY=$(uuidgen)
jq -n --arg option "$OPTION" '{
  buyer: {first_name: "Jana", last_name: "Nováková",
          email: "jana.novakova@example.com", phone_number: "+421 905 123 456"},
  fulfillment: {methods: [{
    type: "shipping",
    destinations: [{id: "home", street_address: "Hlavná 12", address_locality: "Bratislava",
                    postal_code: "811 01", address_country: "SK",
                    first_name: "Jana", last_name: "Nováková"}],
    selected_destination_id: "home",
    groups: [{selected_option_id: $option}]
  }]},
  discounts: {codes: ["JESEN10"]}
}' | curl -sS -o checkout.json -w 'HTTP %{http_code}\n' -X PUT "$API/checkout-sessions/$CHECKOUT" \
  -H "UCP-Agent: $AGENT" \
  -H 'Content-Type: application/json' \
  -H 'Accept-Language: sk' \
  -H "Idempotency-Key: $KEY" \
  --data-binary @-
jq '{status, totals, messages}' checkout.json
JSONBuyer, address, shipping option, one code
{
  "buyer": {
    "first_name": "Jana",
    "last_name": "Nováková",
    "email": "jana.novakova@example.com",
    "phone_number": "+421 905 123 456"
  },
  "fulfillment": {
    "methods": [
      {
        "type": "shipping",
        "destinations": [
          {
            "id": "home",
            "street_address": "Hlavná 12",
            "address_locality": "Bratislava",
            "postal_code": "811 01",
            "address_country": "SK",
            "first_name": "Jana",
            "last_name": "Nováková"
          }
        ],
        "selected_destination_id": "home",
        "groups": [
          {
            "selected_option_id": "0c5e8a3f-7b21-4d96-8f4c-9e2a6d1b3c75"
          }
        ]
      }
    ]
  },
  "discounts": {
    "codes": [
      "JESEN10"
    ]
  }
}

Step 4. buyer replaces the stored buyer, so the e-mail is sent again with the new fields. GLS is selected with an option id from step 3. Sending only JESEN10 drops LETO5 and its warning.

JSONReady: the buyer must continue on the shop
{
  "ucp": {
    "version": "2026-04-08",
    "capabilities": {
      "dev.ucp.shopping.checkout": [
        {
          "version": "2026-04-08"
        }
      ],
      "dev.ucp.shopping.fulfillment": [
        {
          "version": "2026-04-08"
        }
      ],
      "dev.ucp.shopping.discount": [
        {
          "version": "2026-04-08"
        }
      ]
    },
    "payment_handlers": {}
  },
  "id": "3d7b9e2a-6f14-4c58-9b3e-8a2f1c6d4e97",
  "status": "requires_escalation",
  "currency": "EUR",
  "line_items": [
    {
      "id": "f6a3c8e1-9d27-4b54-8e6a-2c1f7b9d3a58",
      "item": {
        "id": "1e8a6c4f-2b93-4d71-9f05-6a3c8e2b1d47",
        "title": "Trek Marlin 5",
        "price": 69900,
        "image_url": "https://shop.example.com/api/public/uploads/c4d1e5a2-8b3f-4a6c-9e07-5f2d8a1b3c40/products/trek-marlin-5-modra.webp"
      },
      "quantity": 1,
      "totals": [
        {
          "type": "subtotal",
          "amount": 69900
        },
        {
          "type": "total",
          "amount": 69900
        }
      ]
    },
    {
      "id": "2e9d5b7a-4c83-4f16-a9b2-6d8e1c3f7a49",
      "item": {
        "id": "b5f81c3d-6e49-4a2b-8d73-9c0e4f1a6b25",
        "title": "Fľaša na vodu 750 ml",
        "price": 990
      },
      "quantity": 2,
      "totals": [
        {
          "type": "subtotal",
          "amount": 1980
        },
        {
          "type": "total",
          "amount": 1980
        }
      ]
    }
  ],
  "totals": [
    {
      "type": "subtotal",
      "amount": 71880
    },
    {
      "type": "discount",
      "amount": -5844,
      "display_text": "JESEN10"
    },
    {
      "type": "fulfillment",
      "amount": 490
    },
    {
      "type": "total",
      "amount": 66526
    }
  ],
  "messages": [
    {
      "type": "error",
      "code": "payment_required",
      "content": "Payment is completed on the shop website — continue there to place the order",
      "severity": "requires_buyer_input"
    }
  ],
  "links": [
    {
      "type": "terms_of_service",
      "url": "https://shop.example.com/obchodne-podmienky",
      "title": "Obchodné podmienky"
    },
    {
      "type": "privacy_policy",
      "url": "https://shop.example.com/ochrana-osobnych-udajov",
      "title": "Ochrana osobných údajov"
    }
  ],
  "buyer": {
    "first_name": "Jana",
    "last_name": "Nováková",
    "email": "jana.novakova@example.com",
    "phone_number": "+421 905 123 456"
  },
  "context": {
    "language": "sk",
    "address_country": "SK"
  },
  "fulfillment": {
    "methods": [
      {
        "id": "shipping",
        "type": "shipping",
        "line_item_ids": [
          "f6a3c8e1-9d27-4b54-8e6a-2c1f7b9d3a58",
          "2e9d5b7a-4c83-4f16-a9b2-6d8e1c3f7a49"
        ],
        "destinations": [
          {
            "id": "destination",
            "street_address": "Hlavná 12",
            "address_locality": "Bratislava",
            "postal_code": "811 01",
            "address_country": "SK",
            "first_name": "Jana",
            "last_name": "Nováková"
          }
        ],
        "selected_destination_id": "destination",
        "groups": [
          {
            "id": "default",
            "line_item_ids": [
              "f6a3c8e1-9d27-4b54-8e6a-2c1f7b9d3a58",
              "2e9d5b7a-4c83-4f16-a9b2-6d8e1c3f7a49"
            ],
            "options": [
              {
                "id": "0c5e8a3f-7b21-4d96-8f4c-9e2a6d1b3c75",
                "title": "GLS kuriér",
                "description": "Doručenie na adresu do 1–2 pracovných dní",
                "totals": [
                  {
                    "type": "total",
                    "amount": 490
                  }
                ]
              },
              {
                "id": "6b2d9f4e-3a17-4c68-9d5b-1f8e3a7c2b40",
                "title": "Packeta – výdajné miesto",
                "description": "Delivery in 2–3 business days",
                "totals": [
                  {
                    "type": "total",
                    "amount": 290
                  }
                ]
              }
            ],
            "selected_option_id": "0c5e8a3f-7b21-4d96-8f4c-9e2a6d1b3c75"
          }
        ]
      }
    ]
  },
  "discounts": {
    "codes": [
      "JESEN10"
    ],
    "applied": [
      {
        "code": "JESEN10",
        "title": "JESEN10",
        "amount": 5844,
        "method": "across"
      }
    ]
  },
  "continue_url": "https://shop.example.com/ucp/continue/9bbd3dedf887f93c6287fa7f4b8de141501155714c3b2050bb83551e36ec780b",
  "expires_at": "2026-10-27T09:21:30.551Z"
}

The answer to CheckoutUpdateRequest (step 4), and to a GET before the buyer has ordered. Nothing the platform can provide is missing: status requires_escalation, payment_required (requires_buyer_input) and continue_url. GLS 4.90 € is the fulfillment total: 718.80 − 58.44 + 4.90 = 665.26 €. The destination id came back as destination. Give continue_url to the buyer now.

How the fields behave:

  • buyer replaces the stored buyer as a whole. Send every field each time: a buyer with only a name erases the stored e-mail.
  • fulfillment. The shop reads the first method of type: "shipping" (or one without a type). Of its destinations it takes the one named by selected_destination_id, else the first. A checkout has one address; it comes back with the id "destination", whatever id you gave it. An address can be replaced but not removed. Send address_country as an ISO 3166-1 alpha-2 code such as SK (alpha-3 codes and country names are understood too); a country the shop cannot recognise is refused with 400.
  • groups[0].selected_option_id selects a shipping option; null clears the choice.
  • Options depend on the address. Once an address is stored, only the methods that ship to its country are offered. An option chosen before the address may drop out, which shows as invalid_option with selected_option_id: null. The safe order is to send the address first and choose from the options of that answer. The example does both in one request, which works when the option ships to the address's country.
  • Each create and PUT sets expires_at to 24 hours from now, until the buyer opens continue_url.

A checkout in requires_escalation always carries payment_required (requires_buyer_input): the buyer pays on the shop. When the chosen method delivers to a pickup point, pickup_point_required (also requires_buyer_input) says that the buyer picks the point on the shop. Neither blocks the next step.

Step 7: hand the buyer over

Show the buyer the summary — lines, totals, shipping and the shop's links (its terms and privacy policy, when the shop has set them up) — and give them continue_url as a link or a button they open in their own browser. Name each line from your own record of the variant you chose (its title, such as Modrá / M, kept by item.id): a line's item.title is only the product's title. Give the link only now, at requires_escalation: the first time it puts lines into a buyer's cart, the checkout is frozen as it is.

jq -r 'if .status == "requires_escalation" then .continue_url else "not ready: \(.status)" end' checkout.json

When the buyer opens the link, a short page on the shop's domain adds the checkout's lines and its applied discount code to the cart this browser has on the shop, and opens the shop's checkout. Items already in that cart stay. If the buyer is signed in to the shop in that browser tab, the lines go to their customer cart. If that cart already holds items in another currency, the lines are added in that currency when the shop sells in it; otherwise nothing is added and the buyer lands on the shop's cart. The buyer then enters the e-mail, address, shipping and payment on the shop and places the order.

  • The link is for the buyer's browser. Never open it from your server. Opening it only shows the page; the lines move when the page's script runs in a browser, which needs JavaScript and the shop's browser storage. The lines land in the cart of the browser that opened the link: an app's in-app browser keeps its own cart, so let the buyer finish there, or open the link in the system browser.
  • Treat the link as a secret. It is not single-use: each browser that opens it gets the lines into its own cart once, and an order from such a cart can complete your checkout. Send it to the buyer only.
  • It works while the checkout is open, until expires_at. Afterwards it answers HTTP 410 with a page that only links to the shop's home page.
  • After the buyer has opened it, the checkout is frozen. A PUT answers the last state with a handed_off warning and changes nothing, and a GET looks exactly as before. If the buyer wants a different basket, build a new checkout.

Step 8: wait for the order

There are no webhooks: read the checkout until it is completed or canceled, starting as soon as you gave the buyer the link.

for wait in 5 10 20 40 60 60 60; do
  sleep "$wait"
  curl -sS -o checkout.json "$API/checkout-sessions/$CHECKOUT" -H "UCP-Agent: $AGENT"
  STATUS=$(jq -r .status checkout.json)
  echo "$STATUS"
  [ "$STATUS" = completed ] || [ "$STATUS" = canceled ] && break
done
jq '.order' checkout.json
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.

  • One request at a time per checkout. A read of a checkout waits behind every other request on it, and can answer 503 with Retry-After: 1 when the checkout is busy. A flood of reads can also delay the buyer's handoff.
  • Cadence. Every 5–10 seconds in the first minutes after you gave the link, then once a minute, is plenty. Reads are limited to 600 per minute per IP address and operation, across every shop and session your IP calls; plan for half of that (see Rate limits).
  • Deadline. Only your writes extend expires_at, and after the buyer has opened the link not even they do. The first read after expires_at cancels a checkout that no order completed, so one read after the deadline gives the final answer.
  • completed means placed, not paid. order.id is the shop's order id, order.label its order number (absent when the shop has none), and order.permalink_url the shop's confirmation page. That page is built from its address and shows no order status.
  • The body is your last quote, not the order. line_items and totals are what the checkout held when the buyer left; what the buyer changed on the shop is not reflected.

Some placed orders never complete the checkout: when the buyer signs in on the shop's checkout after a guest handoff (the order then comes from another cart), when they removed every item of your checkout before ordering, or when the checkout was canceled first: by you, or after expires_at by the next request that read it. Completion is also best effort: if it fails on Monokaido's side, the failure is only logged. Your checkout then ends canceled. Do not tell the buyer that no order exists; the shop's own confirmation is the source of truth.

Complete scripts

The two scripts below run the whole flow against one shop: discovery, a search that follows the cursor, a cart, its update, the checkout, the buyer and address, the first offered shipping option, the continue_url, and polling until the order. They show the error handling a real integration needs:

  • Protocol errors (an HTTP error status with code and content) stop the script, except the retryable ones.
  • Business errors (HTTP 200 with ucp.status: "error") stop it with the message; other messages are printed and the flow goes on.
  • One Idempotency-Key per logical write, generated once and sent again on every retry of that write, so a retried create never makes a second session.
  • Retries honour Retry-After on 429 and 503, and on 409 while the same request is still running. A 500, answers that are not JSON (the web server's own pages for 502, 504 and its rate limit) and network errors are retried with exponential backoff. A 409 without Retry-After means the key was reused for a different request, which is a bug, so the script stops.
  • Polling grows its interval from POLL_SECONDS to one minute and stops at completed, canceled, the checkout's deadline or MAX_POLL_MINUTES.
VariableDefaultMeaning
SHOPhttps://shop.example.comthe shop's origin
PROFILE_URLhttps://platform.example.com/.well-known/ucpyour platform profile
QUERYmarlinthe search text
LANGUAGEskcontext.language and Accept-Language
DISCOUNT_CODEemptya code to apply in step 4
POLL_SECONDS5the first polling interval, in seconds
MAX_POLL_MINUTES30stop polling after this many minutes

The buyer and the address are sample data: replace them with your buyer's.

// ucp-quickstart.mjs — one whole UCP purchase in a Monokaido shop.
// Node 20+, no dependencies.  SHOP=https://shop.example.com node ucp-quickstart.mjs
import { randomUUID } from 'node:crypto';
import { setTimeout as sleep } from 'node:timers/promises';

const env = process.env;
const SHOP = (env.SHOP ?? 'https://shop.example.com').replace(/\/+$/, '');
const PROFILE_URL = env.PROFILE_URL ?? 'https://platform.example.com/.well-known/ucp';
const QUERY = env.QUERY ?? 'marlin';
const LANGUAGE = env.LANGUAGE ?? 'sk';
const DISCOUNT_CODE = env.DISCOUNT_CODE ?? '';
const POLL_SECONDS = Number(env.POLL_SECONDS ?? 5);
const MAX_POLL_MINUTES = Number(env.MAX_POLL_MINUTES ?? 30);
const BUYER = { first_name: 'Jana', last_name: 'Nováková', email: 'jana.novakova@example.com', phone_number: '+421 905 123 456' };
const ADDRESS = {
  id: 'home', first_name: 'Jana', last_name: 'Nováková', street_address: 'Hlavná 12',
  address_locality: 'Bratislava', postal_code: '811 01', address_country: 'SK',
};
const RETRYABLE = new Set([429, 500, 502, 503, 504]);

class ProtocolError extends Error {
  constructor(status, body) {
    super(`HTTP ${status} ${body.code ?? ''}: ${body.content ?? ''}`);
    this.status = status;
    this.body = body;
  }
}

// One logical call. A write gets ONE Idempotency-Key that every retry reuses,
// so a retried create can never make a second cart or checkout.
async function call(method, url, body, { write = false } = {}) {
  const key = write ? randomUUID() : undefined;
  for (let attempt = 1; ; attempt++) {
    let res;
    try {
      res = await fetch(url, {
        method,
        headers: {
          'UCP-Agent': `profile="${PROFILE_URL}"`,
          'Accept-Language': LANGUAGE,
          ...(body !== undefined && { 'Content-Type': 'application/json' }),
          ...(key && { 'Idempotency-Key': key }),
        },
        body: body === undefined ? undefined : JSON.stringify(body),
        signal: AbortSignal.timeout(65_000), // the web server gives up after 60 s
      });
    } catch (err) {
      // No answer: the write may or may not have run. The same key makes the retry safe.
      if (attempt >= 5) throw err;
      await sleep(2 ** attempt * 1000);
      continue;
    }
    const json = (res.headers.get('content-type') ?? '').includes('application/json');
    const data = json ? await res.json() : { content: `${res.status} page from the web server` };
    if (res.ok) return data;
    // 409 with Retry-After = "same request still running"; without it, the key was reused for another request.
    const retry = RETRYABLE.has(res.status) || (res.status === 409 && res.headers.has('retry-after'));
    if (!retry || attempt >= 5) throw new ProtocolError(res.status, data);
    const wait = Number(res.headers.get('retry-after')) || 2 ** attempt;
    console.warn(`   ${res.status} ${data.code ?? '(not JSON)'} — retrying in ${wait} s${key ? ' with the same key' : ''}`);
    await sleep(wait * 1000);
  }
}

// HTTP 200 can still mean "nothing came back": ucp.status "error" + messages.
function resource(data, what, { quiet = false } = {}) {
  if (data.ucp?.status === 'error') {
    const m = data.messages?.[0] ?? {};
    throw new Error(`${what}: ${m.code} — ${m.content}`);
  }
  if (!quiet) for (const m of data.messages ?? []) console.log(`   [${m.type}${m.severity ? '/' + m.severity : ''}] ${m.code}: ${m.content}`);
  return data;
}

const money = ({ amount, currency }) => `${(amount / 100).toFixed(2)} ${currency}`; // 2 decimals: EUR, CZK, HUF, PLN
const total = (totals, currency) => money({ amount: totals.find((t) => t.type === 'total').amount, currency });

console.log(`1. Discover ${SHOP}`);
const profile = await call('GET', `${SHOP}/.well-known/ucp`);
const API = profile.ucp.services['dev.ucp.shopping'].find((s) => s.transport === 'rest').endpoint;
if (!profile.ucp.capabilities['dev.ucp.shopping.checkout']) {
  throw new Error('This shop takes no orders over UCP (catalog mode): only search and lookup are offered.');
}

console.log(`2. Search "${QUERY}"`);
let product, variant, cursor;
for (let page = 1; page <= 5 && !variant; page++) {
  const body = { query: QUERY, pagination: { limit: 10, ...(cursor && { cursor }) }, context: { language: LANGUAGE } };
  const result = resource(await call('POST', `${API}/catalog/search`, body), 'search');
  product = result.products.find((p) => p.variants.some((v) => v.availability.available));
  variant = product?.variants.find((v) => v.availability.available);
  if (!result.pagination.has_next_page) break; // a short (even empty) page is normal: keep following the cursor
  cursor = result.pagination.cursor;
}
if (!variant) throw new Error(`Nothing available for "${QUERY}"`);
console.log(`   ${product.title} — ${variant.title}, ${money(variant.price)}`);

console.log('3. Create a cart');
let cart = resource(await call('POST', `${API}/carts`, {
  line_items: [{ item: { id: variant.id }, quantity: 1 }],
  buyer: { email: BUYER.email },
  context: { language: LANGUAGE, address_country: ADDRESS.address_country },
}, { write: true }), 'create cart');
console.log(`   cart ${cart.id}: ${total(cart.totals, cart.currency)}`);

console.log('4. Update the cart (PUT replaces line_items: send every line)');
cart = resource(await call('PUT', `${API}/carts/${cart.id}`, {
  line_items: cart.line_items.map((l) => ({ item: { id: l.item.id }, quantity: l.quantity })),
  ...(DISCOUNT_CODE && { discounts: { codes: [DISCOUNT_CODE] } }),
}, { write: true }), 'update cart');
console.log(`   ${total(cart.totals, cart.currency)}`);

console.log('5. Turn the cart into a checkout');
let checkout = resource(await call('POST', `${API}/checkout-sessions`, { cart_id: cart.id }, { write: true }), 'create checkout');
const put = async (body) =>
  resource(await call('PUT', `${API}/checkout-sessions/${checkout.id}`, body, { write: true }), 'update checkout');

console.log('6. Buyer, address and shipping until requires_escalation');
checkout = await put({ buyer: BUYER, fulfillment: { methods: [{ type: 'shipping', destinations: [ADDRESS], selected_destination_id: 'home' }] } });
for (let round = 1; checkout.status === 'incomplete'; round++) {
  const blocking = checkout.messages.filter((m) => m.type === 'error' && m.severity === 'recoverable');
  const options = checkout.fulfillment?.methods?.[0]?.groups?.[0]?.options ?? [];
  const onlyTheOption = blocking.length > 0 && blocking.every((m) => m.path?.endsWith('.selected_option_id'));
  if (round > 3 || !onlyTheOption || options.length === 0) {
    throw new Error(`The agent cannot finish this checkout: ${blocking.map((m) => `${m.code} (${m.content})`).join('; ')}`);
  }
  const option = options[0]; // the shop's own order; a real agent lets the buyer choose
  console.log(`   shipping: ${option.title}, ${total(option.totals, checkout.currency)}`);
  checkout = await put({ fulfillment: { methods: [{ type: 'shipping', groups: [{ selected_option_id: option.id }] }] } });
}
if (checkout.status !== 'requires_escalation') throw new Error(`Unexpected status ${checkout.status}`);

console.log(`7. Ready: ${total(checkout.totals, checkout.currency)}. Give the buyer this link — they pay on the shop:`);
console.log(`\n   ${checkout.continue_url}\n`);
for (const link of checkout.links) console.log(`   ${link.type}: ${link.url}`);

console.log('8. Waiting for the order (Ctrl+C stops; the checkout stays open until expires_at)');
const deadline = Math.min(Date.parse(checkout.expires_at) + 60_000, Date.now() + MAX_POLL_MINUTES * 60_000);
for (let wait = POLL_SECONDS; ; wait = Math.min(wait * 1.5, 60)) {
  await sleep(wait * 1000);
  // One request at a time per session: a GET of a checkout queues behind every other request on it.
  const now = resource(await call('GET', `${API}/checkout-sessions/${checkout.id}`), 'poll', { quiet: true });
  if (now.status === 'completed') {
    console.log(`   Order ${now.order.label ?? now.order.id} placed (placed, not necessarily paid): ${now.order.permalink_url}`);
    break;
  }
  if (now.status === 'canceled') { console.log('   The checkout was canceled or expired without an order.'); break; }
  if (Date.now() > deadline) { console.log(`   Still ${now.status}; stopped polling.`); break; }
}

Run them with:

SHOP=https://shop.example.com PROFILE_URL=https://platform.example.com/.well-known/ucp node ucp-quickstart.mjs
SHOP=https://shop.example.com PROFILE_URL=https://platform.example.com/.well-known/ucp python3 ucp_quickstart.py

What can go wrong

What you seeWhyWhat to do
404 not_found, "No UCP business on this host"The host serves no UCP: not a custom domain of a shop with verified DNS and an active certificate, UCP off, storefront off, the storefront key inactive or expired, or the e-shop module off. The answer never says which.Check the domain with the merchant, and call the host of the profile's endpoint.
400 invalid_profile_urlUCP-Agent is missing or malformed.Send profile="https://…" with the double quotes and a lower-case profile.
424 profile_unreachableThe shop could not fetch your profile: not 200, a redirect, too slow, over 256 KiB, or a private address.Fix the hosting, then wait 60 seconds: the failure is remembered that long.
422 profile_malformed or version_unsupportedThe profile is not a UCP profile, or its ucp.version is not 2026-04-08.Serve the profile shown above.
200 with capabilities_incompatibleYour profile does not declare the capability, or the shop is in catalog mode.Add the capability to your profile; for a catalog-mode shop, stop after the search.
200 with not_found on a cart or checkoutThe id is unknown, belongs to another shop, or the cart expired or was canceled.Start again from step 3.
400 invalid_requestA field has the wrong shape; content names it, for example line_items must be an array on a cart PUT without lines. "Idempotency-Key is not valid" means the key is not 1 to 255 visible ASCII characters.Fix the request: it was not executed.
409 idempotency_conflict without Retry-AfterThe key was used for a different request: another operation, path id or body.Use a new key for each logical write.
409 idempotency_conflict with Retry-After: 2The first request with this key is still running.Wait and repeat the same request with the same key.
503 "Another request on this session is being processed"Another request holds the cart or checkout.Retry after Retry-After (1 s) with the same key; send one request at a time per session.
503 "This shop is temporarily unavailable"The shop is suspended or its lookup failed.Retry after Retry-After (60 s).
503 "Too many new platform profiles from this client" or "Platform profile discovery is busy"The shop is fetching too many profiles it has not cached: more than 5 new ones a minute from your IP, or too many overall.Retry after Retry-After; use one stable profile URL.
429 rate_limited (JSON)The operation's limit per IP was reached: 600 reads, 300 writes per minute.Wait Retry-After (60 s).
429, 413, 502 or 504 as an HTML pageThe web server in front of the shop: over 100 requests per second, a body over 1 MiB, or the API unreachable or slower than 60 s.Back off and retry; for 502 and 504 repeat writes with the same key. Keep bodies small.
The checkout stays incompleteA recoverable error is left.Read messages and send what path names (step 6).
handed_offThe buyer has already taken the basket to the shop.Stop writing and poll; for a new basket, build a new checkout.
The checkout never becomes completedThe buyer did not order, signed in on the shop's checkout, removed the items, or the checkout was canceled first; rarely, the completion itself failed (it is best effort).Stop at the deadline; the shop's confirmation is the source of truth.

Every code, with what a client should do about it, is in Errors and messages. Retries, concurrent requests, limits and lifetimes are in Idempotency, concurrency and limits.