Skip to content

Idempotency, concurrency and limits

Idempotency-Key, retrying requests, concurrent writes, rate limits, session lifetimes and caching.

On this page

Networks drop answers, servers restart and agents run in parallel. This chapter is the contract for those moments: how to make a write safe to repeat, what happens when two requests touch one session, which errors to retry and how often, the exact rate limits and timeouts, what is cached, how long a session lives, and how to wait for the buyer's order without wasting your budget. The codes themselves are explained in Errors and messages.

The examples use the shop https://shop.example.com and a platform whose profile is https://platform.example.com/.well-known/ucp.

Idempotency-Key

A write sent twice — because the answer never arrived, or a worker restarted — must not create two carts. The Idempotency-Key header makes that safe: the shop runs the first request, stores its answer, and answers every repeat of the same request with the stored answer.

Which operations

Only the seven writes read the header. Everything else ignores it.

OperationRoutePart of the request's fingerprint
Create a cartPOST /ucp/v1/cartsthe body
Update a cartPUT /ucp/v1/carts/{id}the path id and the body
Cancel a cartPOST /ucp/v1/carts/{id}/cancelthe path id (the body is ignored)
Create a checkoutPOST /ucp/v1/checkout-sessionsthe body
Update a checkoutPUT /ucp/v1/checkout-sessions/{id}the path id and the body
Complete a checkoutPOST /ucp/v1/checkout-sessions/{id}/completethe path id and the body
Cancel a checkoutPOST /ucp/v1/checkout-sessions/{id}/cancelthe path id (the body is ignored)

The catalog operations, GET of a cart or a checkout, the business profile and the handoff routes never read it: they change nothing a retry could duplicate.

The key

  • Format: 1–255 printable ASCII characters from ! to ~ — no spaces, no letters outside ASCII. Anything else is 400 invalid_request Idempotency-Key is not valid, and nothing runs. Send the header once: a header sent twice arrives joined with ", ", which contains a space.
  • One key per logical write. Generate it when you decide to make the write — a version 4 UUID is a good choice — and send the same key, with the same body, on every retry of that write. A different write gets a different key, even when its body happens to be equal.
  • Keep it with the pending write. If your worker can restart between attempts, store the key with the operation before the first attempt, so the retry after a restart sends the same key.
  • The header is optional. Without it, or with an empty value, the write simply runs. See Without a key.
KEY=$(uuidgen)   # generated once for this write; every retry below reuses it
curl -sS -X POST https://shop.example.com/ucp/v1/carts \
  -H 'UCP-Agent: profile="https://platform.example.com/.well-known/ucp"' \
  -H "Idempotency-Key: $KEY" \
  -H 'Content-Type: application/json' \
  -d '{"line_items":[{"item":{"id":"1e8a6c4f-2b93-4d71-9f05-6a3c8e2b1d47"},"quantity":1}],"context":{"language":"sk"}}'

Scope and fingerprint

A stored key belongs to one shop, to your exact profile URL — the string in UCP-Agent, compared character by character — and to the key itself. Another platform that happens to send the same key never sees your answer, and the same key on two different shops is two different keys. All domains of one shop share its keys.

The request the key was first used for is remembered as a fingerprint of the operation, the path id exactly as you sent it, and the body. The body is compared as JSON: the order of object keys and the whitespace do not matter; the order of array entries does. The path id is compared as a string, so an upper-case spelling of the same id is a different request.

Keep your profile URL stable: a retry under another profile URL — even one with a different query string — is a first use of the key.

What a repeat gets

SituationAnswer
First use of the keyThe operation runs. Its answer is stored.
The same request again, the first one finishedThe stored HTTP status and body — a 201 stays a 201, and a created session keeps its id. The operation does not run again.
The same request again, the first one still running409 idempotency_conflict, A request with this Idempotency-Key is still being processed, Retry-After: 2. Wait and send it again.
The same key for another operation, path id or body409 idempotency_conflict, This Idempotency-Key was used for a different request, no Retry-After. A bug: use a new key.
The first request failed with a protocol error (400, 503 session busy, 500 …)The key was released together with the error. The retry runs the operation again.
The first request ended in a business error (200 with ucp.status: "error", such as not_found or every item sold out)That answer is stored and replayed like any other. A new attempt needs a new key.
The first request's process died, or its answer could not be storedThe key stays claimed for up to 120 seconds; until then a repeat gets the 409 with Retry-After: 2. Afterwards the same request takes the key over and runs again.
The request was refused before the key was read: a body the API could not read (400 The request is not valid, 413, 415), 403, 429, 404 or 503 for the shop, the UCP-Agent and profile errors, version_unsupported, capabilities_incompatibleThe key was not used. The next attempt is its first use.

A replay is the answer of the first request, not a fresh read: its expires_at, prices and messages are those of that moment. Its JSON object keys can be ordered differently from the first answer (key order carries no meaning), so its ETag can differ too. After a replay, GET the session when you need its current state. Nothing in the answer marks it as a replay.

How long keys are kept

A key is kept for 25 hours from the moment it was claimed. An hourly job deletes the older ones, so a key disappears between 25 and about 26 hours after its claim. After that, the same key is a first use again. Never reuse a key for another write, and do not rely on protection for a retry more than 24 hours after the first attempt.

Without a key

A write without Idempotency-Key runs every time it arrives:

OperationRepeated without a key
POST /ucp/v1/carts, POST /ucp/v1/checkout-sessions with line_itemsA second session. The first one stays until it expires — 7 days for a cart, 24 hours for a checkout — and its data until 30 days after that. You have no way to find it again.
POST /ucp/v1/checkout-sessions with cart_idThe cart's open checkout is answered again (200).
PUT of a cart or a checkoutApplied again: the same state, priced again, the lifetime extended again.
Cancel of a cartThe repeat answers 200 not_found: the cart is already canceled.
Cancel of a checkoutThe repeat answers the canceled checkout with checkout_canceled.
Complete of a checkoutAnswered again from the checkout's current state. Completing never places an order, so a repeat is harmless.

Send a key on every write. It costs nothing, and a retried create is the most common way to lose track of a buyer's session.

Concurrency

One request at a time per session

Every request that may change a session waits for its turn: requests on one cart or checkout run one after the other, in the order they arrive at the API server.

Takes the session's turnDoes not
PUT and cancel of a cart; GET, PUT, complete and cancel of a checkout; converting a cart into a checkout (under the cart's turn); the buyer's handoffCreating a cart, creating a checkout from line_items, GET of a cart, the catalog

A checkout GET takes a turn too, because a read may store a status that changed (an expired checkout is canceled by the read that finds it).

LimitValueOver it
Waiting for the turn10 seconds from when the request starts waiting503 service_unavailable, Another request on this session is being processed — retry shortly, Retry-After: 1
Requests waiting behind the running one4The next one gets the same 503 at once

The operation behind such a 503 never ran, and its Idempotency-Key was released: repeat the same request with the same key after a second. The queue limit applies per API server process; across processes a database lock keeps the one-at-a-time rule.

Send at most one request per session at a time. Parallel writes on one session gain nothing — they run one after the other anyway — and a burst of them can starve the buyer's handoff, which waits for the same turn.

A write that loses a race

Two things change a checkout without taking its turn: the buyer's order on the shop, which completes it, and a new conversion of its cart, which cancels it once it has expired. Every session write is therefore applied only if the session has not changed since the request read it. When a write loses that race:

What happened meanwhileYou get
The checkout was completed by the order, or canceledThe checkout as it is now — completed with order, or canceled. A PUT or cancel also carries checkout_completed or checkout_canceled; a GET or complete carries checkout_canceled for a canceled checkout and no message for a completed one. Your change was not applied.
The cart was canceled200 not_found, Cart not found or has expired.
The session changed some other way and is still open503, Retry-After: 1. Retry with the same key.

Sessions have no version number you could send: there is no If-Match, and none is needed — the server does the comparison.

One open checkout per cart

A cart has at most one open checkout. Two conversions of the same cart at the same moment both answer the same checkout: one with 201, the other with 200. To change that checkout, PUT it; converting the cart again changes nothing.

Retries

AnswerRetry?WaitSame Idempotency-Key?
No answer: a timeout, a reset connectionYesBackoffYes. A write may have run; the key turns the retry into a replay.
502 or 504 (HTML from the web server)YesBackoffYes. After a 504 the first request may still be running: you then get 409 with Retry-After: 2 until it finishes.
503 Another request on this session is being processed — retry shortlyYesRetry-After (1 s)Yes
503 This shop is temporarily unavailableA few timesRetry-After (60 s)Yes
503 Too many new platform profiles from this client, retry shortly or Platform profile discovery is busy, retry shortlyYesRetry-AfterYes
409 A request with this Idempotency-Key is still being processedYesRetry-After (2 s)Yes, and the same body
429 rate_limited (JSON)YesRetry-After (60 s)Yes
429 from the web server (HTML)YesBackoff, starting at 1 sYes
500 internal_errorA few timesBackoffYes. The key was released, so the retry runs the operation again.
424 profile_unreachable, 422 profile_malformedOnly after you fix the profileAt least 60 s: the failure is remembered that long—
Every other 4xx: 400, 403, 404, 409 without Retry-After, 413, 415, 422 version_unsupportedNo——
200 with ucp.status: "error"No: the same request gets the same answer—A new attempt with a changed request needs a new key.

Use at most 5 attempts per logical call, an exponential backoff of 1, 2, 4, 8 seconds with random jitter and a ceiling of 30 seconds, and the Retry-After value instead of the backoff whenever the answer carries one. Send the same body on every attempt — the same JSON; key order and whitespace may differ, the order of array entries may not — or the key answers 409.

A write whose attempts all ended without a definitive answer — network errors, 502, 504, 409 with Retry-After — may have run, and there is no way to list your sessions and find it. Do not drop it: keep its key and body with the pending write and send the same request again later. While the first attempt still runs, the retry gets 409 with Retry-After: 2; once it has finished, the stored answer; and if it died, the same request takes the key over 120 seconds after its claim and runs the write. If it never reached the API, the retry is the key's first use. A new key instead would make a second session.

import { randomUUID } from 'node:crypto';
import { setTimeout as sleep } from 'node:timers/promises';

const RETRYABLE = new Set([429, 500, 502, 503, 504]);
const backoff = (attempt) => Math.min(30_000, 1000 * 2 ** (attempt - 1)) * (0.5 + Math.random() / 2);

// One logical UCP call. Every retry reuses the same Idempotency-Key and body.
export async function ucpCall(method, url, { profileUrl, body, key, maxAttempts = 5 } = {}) {
  const write = method !== 'GET' && !url.includes('/catalog/');
  key ??= write ? randomUUID() : undefined;
  for (let attempt = 1; ; attempt++) {
    let res;
    try {
      res = await fetch(url, {
        method,
        redirect: 'error', // the API never redirects; http:// is a mistake, not a hop
        headers: {
          'UCP-Agent': `profile="${profileUrl}"`,
          ...(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) {
      if (attempt >= maxAttempts) throw err; // no answer: the same key makes the retry safe
      await sleep(backoff(attempt));
      continue;
    }
    const stillRunning = res.status === 409 && res.headers.has('retry-after');
    if ((RETRYABLE.has(res.status) || stillRunning) && attempt < maxAttempts) {
      const retryAfter = Number(res.headers.get('retry-after'));
      await sleep(retryAfter > 0 ? retryAfter * 1000 : backoff(attempt));
      continue;
    }
    return res; // final: classify it as in Errors and messages
  }
}

A 429 or a 503 for the shop asks for a minute's wait. A conversational agent should tell the buyer it is waiting, or give up and come back, rather than block silently.

Rate limits

The API

Each operation has its own limit per client IP address, over 60 seconds:

OperationsLimit per IP and operation
The business profile (GET /.well-known/ucp); catalog search, lookup and product; GET of a cart; GET of a checkout600 a minute each
Create, update and cancel a cart; create, update, complete and cancel a checkout300 a minute each
The handoff page (GET /ucp/continue/<token>) and its claim (POST)60 a minute each
  • Each operation counts separately: 600 searches and 600 checkout reads in the same minute are both within the limits.
  • Your IP address is the key, not the shop and not your profile: one egress IP shares each operation's budget across every Monokaido shop. The address is the one the web server sees; an X-Forwarded-For you send is not used.
  • Each request counts for 60 seconds — in principle. The request that would exceed the limit is refused and starts a 60-second block of that operation for your IP; every request in the block gets 429 rate_limited with Retry-After: 60, and the count starts from zero when the block ends.
  • The count does not always fall back. The counts are kept in the API server's memory (a restart clears them). Each request is meant to leave the count 60 seconds after it was made, but the rate limiter the API uses (@nestjs/throttler 6.5) drops those pending decrements — of every client at once — whenever any client's block ends. From then on your count only grows, until it reaches the limit and your own 429 block resets it. Treat each limit as a budget, not as a rate: keep steady traffic at half of it or less, and on a 429 wait the full Retry-After.
  • Headers. Every answer that passed the limiter carries X-RateLimit-Limit (the operation's limit), X-RateLimit-Remaining (the limit minus your current count) and X-RateLimit-Reset (seconds left in a 60-second window the limiter keeps per IP and operation; the count is not reset when that window ends, so it does not tell you when the budget comes back). Watch X-RateLimit-Remaining and slow down well before it reaches zero. A 429 carries Retry-After instead.

The handoff limits are counted for the buyer's IP, not yours. Buyers behind one shared address — an office network, say — share those 60 a minute.

The web server

In front of the API, the web server allows 100 requests a second per IP, with a burst of 200, counted over all storefront traffic from that IP: the UCP calls of every shop and the shops' own pages and images. Above it the answer is the web server's own HTML 429, without Retry-After. Back off and retry, starting at one second.

New platform profiles

Fetching a platform profile the shop's server does not know yet is limited: 5 new profile URLs a minute per client (one IPv4 address, or one IPv6 /64) and 60 a minute in all. Over it, the answer is 503 with a Retry-After. A profile the server already knows never counts, so one stable profile URL is never affected.

Planning capacity

PatternRequests a minuteHalf the limit is reached at
Polling one checkout every 5 seconds12 checkout readsabout 25 checkouts polled at once from one IP
Polling one checkout every minute1 checkout readabout 300 checkouts
Building a session: create, two updates, convert, two updates6 writes, in 4 different operationsfar below 150 per operation

Because a count may not fall back (see above), these figures hold only while your traffic stays well under the limit; a pattern that runs at the full limit for long gets 429 blocks.

If one egress IP serves many buyers at once, spread the traffic over several addresses or lengthen the polling interval (see Waiting for the order).

Timeouts and sizes

WhatValueThen
The web server waits for the API60 seconds504 (HTML). A write may still complete: retry with the same key.
A request waits for its session's turn10 seconds503, Retry-After: 1
An unfinished Idempotency-Key claim120 secondsThe same request takes the key over.
The shop fetches your profile3 seconds without data, 5 seconds in all424 profile_unreachable
Your client's timeoutRecommended: 65 secondsAbove the web server's 60, so you get its 504 rather than your own timeout. Either way, retry with the same key.
LimitValueOver it
Request body1 MiB through the shop's domain413 (HTML) from the web server
line_items100 per request400
Quantity per line999Lowered to 999 without a message
Discount codes10, each up to 50 characters400 (longer codes are cut)
Lookup ids20, each up to 255 characters400 request_too_large (longer ids are cut)
Search page size (pagination.limit)20, default 10Lowered to 20
Search query200 charactersCut to 200
UCP-Agent header4096 characters; the profile URL in it 1000400 invalid_profile_url
Idempotency-Key255 characters400 invalid_request
Your platform profile256 KiB424 profile_unreachable

Caching

AnswerCache-Control
The business profile, GET /.well-known/ucppublic, max-age=300 — cache it for up to 5 minutes
Everything else: catalog, carts, checkouts, every error, the handoff pageno-store
  • Do not cache catalog answers or sessions on your side either: prices, stock and codes are checked live, and a session can change under your copy (the buyer's order completes it, expiry cancels it).
  • Answers carry an ETag: strong as the API sends it, and weak (W/"…") when the web server compresses the answer, which it does when you send Accept-Encoding: gzip. A GET that sends If-None-Match with the current ETag, in either form, is answered 304 Not Modified without a body, even though the answer is no-store; if your HTTP library adds If-None-Match by itself, handle the 304. Writes never answer 304.
  • No proxy between you and the API caches UCP answers.
  • A merchant's change — UCP switched on or off, the storefront or its key switched off, a domain removed — applies at once. A newly added domain is served once its DNS is verified and its certificate is active, and then within 30 seconds. Your copy of the profile can be up to 5 minutes old.
  • The shop caches your platform profile for its max-age, clamped to 60 seconds – 1 hour (5 minutes without one). A change to your profile — a new capability, a corrected version — takes effect after that copy expires: the first request after it is still answered from the old copy while the shop fetches the new one in the background. If that fetch fails, the old copy stays in use until an hour after it expired, and the fetch is retried at most once a minute.

Session lifetimes

SessionLivesExtended byAfterwards
Cart7 dayscreate, PUTEvery call answers 200 not_found.
Checkout24 hourscreate (also from a cart), PUTThe next request that touches it cancels it: status: "canceled".
A session the buyer took to the shopuntil its last expires_atnothing: writes are frozenAs above.
Any session's datauntil 30 days after expires_at—Deleted; calls answer 200 not_found.

GET, complete and cancel never extend a session. expires_at in every answer is the deadline in UTC. Expiry is lazy: nothing happens at the deadline itself; the next request that reads or writes the checkout, or converts its cart again, cancels it. Details in Concepts and lifecycle.

To keep an open checkout alive while the buyer decides, PUT it before expires_at — a PUT of {} prices it again and extends it by 24 hours. Once the buyer has taken it to the shop, nothing extends it.

Waiting for the order

The API sends no webhooks. You learn that the buyer ordered by reading the checkout: GET /ucp/v1/checkout-sessions/{id} returns status: "completed" and order once the order is placed on the shop.

  • Hand over a checkout, not a cart: only a checkout becomes completed.
  • Interval. The server suggests none. A sensible cadence is every 5–10 seconds for the first quarter of an hour after the buyer got continue_url, then once a minute.
  • One request in flight per checkout. Every checkout read waits for the session's turn; on 503 wait Retry-After and go on.
  • Stop at completed (read order) or canceled. Stop as well after expires_at: one more read after the deadline gives the final answer — completed if the order came first, otherwise canceled.
  • Expect "never completed". A buyer who does not order, signs in on the shop's checkout after a guest handoff, or removes the items leaves the checkout open until it expires; so does, rarely, a completion that failed on Monokaido's side (it is best effort and only logged). The shop's own confirmation is what the buyer sees.
  • Poll from any open status. A checkout handed over while incomplete completes with the order too.
import { setTimeout as sleep } from 'node:timers/promises';

// Poll a handed-over checkout until it is completed, canceled or past its deadline.
async function waitForOrder(api, checkoutId, profileUrl) {
  const started = Date.now();
  for (;;) {
    const res = await ucpCall('GET', `${api}/checkout-sessions/${checkoutId}`, { profileUrl });
    if (res.status === 404) return { outcome: 'not_served' };                   // the shop stopped serving UCP: re-discover it later
    if (!res.ok) throw new Error(`HTTP ${res.status}`);                         // retries spent: try again later
    const checkout = await res.json();
    if (checkout.ucp?.status === 'error') return { outcome: 'gone' };            // not_found
    if (checkout.status === 'completed') return { outcome: 'ordered', order: checkout.order };
    if (checkout.status === 'canceled') return { outcome: 'canceled' };
    const pastDeadline = Date.now() > Date.parse(checkout.expires_at);
    const interval = Date.now() - started < 15 * 60_000 ? 5_000 : 60_000;
    await sleep(pastDeadline ? 1_000 : interval);                               // one last read decides
  }
}

A checkout's deadline can be a day away. Run polling in a background job that survives restarts — keep the checkout id and the next poll time in your storage — rather than in the request that served the buyer.

Production checklist

  • Every write carries an Idempotency-Key, generated once per logical write, stored with it, reused on every retry with the same body.
  • Retries follow the table above: at most 5 attempts, Retry-After when given, exponential backoff with jitter otherwise, no retries for other 4xx and for business errors.
  • One request in flight per session, never several in parallel: the shop queues at most four behind a running one.
  • Your client timeout is 65 seconds, and redirects are not followed.
  • Non-JSON answers are handled by status code: 413, 429, 502, 504 from the web server.
  • Rate budgets are watched per egress IP and per operation, across every shop you serve: X-RateLimit-Remaining, 429 counts. Steady traffic stays at half of each limit or less.
  • Your platform profile has one stable URL, Cache-Control: public, max-age=3600, and is served fast with 200 from a public address.
  • Nothing but the business profile is cached, and that for at most 5 minutes.
  • Polling runs in the background, stops at completed, canceled or one read after expires_at, and treats "never completed" as a normal outcome.
  • Every call is logged with the time in UTC, host, method, path, status, code and your correlation id — the API returns no request id.