Idempotency, concurrency and limits
Idempotency-Key, retrying requests, concurrent writes, rate limits, session lifetimes and caching.
On this page
- Idempotency-Key
- Which operations
- The key
- Scope and fingerprint
- What a repeat gets
- How long keys are kept
- Without a key
- Concurrency
- One request at a time per session
- A write that loses a race
- One open checkout per cart
- Retries
- Rate limits
- The API
- The web server
- New platform profiles
- Planning capacity
- Timeouts and sizes
- Caching
- Session lifetimes
- Waiting for the order
- Production checklist
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.
| Operation | Route | Part of the request's fingerprint |
|---|---|---|
| Create a cart | POST /ucp/v1/carts | the body |
| Update a cart | PUT /ucp/v1/carts/{id} | the path id and the body |
| Cancel a cart | POST /ucp/v1/carts/{id}/cancel | the path id (the body is ignored) |
| Create a checkout | POST /ucp/v1/checkout-sessions | the body |
| Update a checkout | PUT /ucp/v1/checkout-sessions/{id} | the path id and the body |
| Complete a checkout | POST /ucp/v1/checkout-sessions/{id}/complete | the path id and the body |
| Cancel a checkout | POST /ucp/v1/checkout-sessions/{id}/cancel | the 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 is400 invalid_requestIdempotency-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"}}'
import { randomUUID } from 'node:crypto';
// Persist { key, body } with the pending write before the first attempt.
const pending = { key: randomUUID(), body: { line_items: [{ item: { id: VARIANT_ID }, quantity: 1 }] } };
import uuid
# Persist key and body with the pending write before the first attempt.
pending = {"key": str(uuid.uuid4()), "body": {"line_items": [{"item": {"id": VARIANT_ID}, "quantity": 1}]}}
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
| Situation | Answer |
|---|---|
| First use of the key | The operation runs. Its answer is stored. |
| The same request again, the first one finished | The 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 running | 409 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 body | 409 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 stored | The 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_incompatible | The 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:
| Operation | Repeated without a key |
|---|---|
POST /ucp/v1/carts, POST /ucp/v1/checkout-sessions with line_items | A 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_id | The cart's open checkout is answered again (200). |
PUT of a cart or a checkout | Applied again: the same state, priced again, the lifetime extended again. |
| Cancel of a cart | The repeat answers 200 not_found: the cart is already canceled. |
| Cancel of a checkout | The repeat answers the canceled checkout with checkout_canceled. |
| Complete of a checkout | Answered 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 turn | Does 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 handoff | Creating 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).
| Limit | Value | Over it |
|---|---|---|
| Waiting for the turn | 10 seconds from when the request starts waiting | 503 service_unavailable, Another request on this session is being processed — retry shortly, Retry-After: 1 |
| Requests waiting behind the running one | 4 | The 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 meanwhile | You get |
|---|---|
| The checkout was completed by the order, or canceled | The 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 canceled | 200 not_found, Cart not found or has expired. |
| The session changed some other way and is still open | 503, 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
| Answer | Retry? | Wait | Same Idempotency-Key? |
|---|---|---|---|
| No answer: a timeout, a reset connection | Yes | Backoff | Yes. A write may have run; the key turns the retry into a replay. |
502 or 504 (HTML from the web server) | Yes | Backoff | Yes. 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 shortly | Yes | Retry-After (1 s) | Yes |
503 This shop is temporarily unavailable | A few times | Retry-After (60 s) | Yes |
503 Too many new platform profiles from this client, retry shortly or Platform profile discovery is busy, retry shortly | Yes | Retry-After | Yes |
409 A request with this Idempotency-Key is still being processed | Yes | Retry-After (2 s) | Yes, and the same body |
429 rate_limited (JSON) | Yes | Retry-After (60 s) | Yes |
429 from the web server (HTML) | Yes | Backoff, starting at 1 s | Yes |
500 internal_error | A few times | Backoff | Yes. The key was released, so the retry runs the operation again. |
424 profile_unreachable, 422 profile_malformed | Only after you fix the profile | At least 60 s: the failure is remembered that long | — |
Every other 4xx: 400, 403, 404, 409 without Retry-After, 413, 415, 422 version_unsupported | No | — | — |
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
}
}
import random, time, uuid
import requests
RETRYABLE = {429, 500, 502, 503, 504}
def backoff(attempt):
return min(30, 2 ** (attempt - 1)) * random.uniform(0.5, 1.0)
def ucp_call(method, url, profile_url, body=None, key=None, max_attempts=5):
"""One logical UCP call. Every retry reuses the same Idempotency-Key and body."""
write = method != "GET" and "/catalog/" not in url
key = key or (str(uuid.uuid4()) if write else None)
headers = {"UCP-Agent": f'profile="{profile_url}"'}
if key:
headers["Idempotency-Key"] = key
for attempt in range(1, max_attempts + 1):
try:
r = requests.request(method, url, json=body, headers=headers,
timeout=65, allow_redirects=False)
except requests.RequestException:
if attempt == max_attempts:
raise # no answer: the same key makes the retry safe
time.sleep(backoff(attempt))
continue
still_running = r.status_code == 409 and "Retry-After" in r.headers
if (r.status_code in RETRYABLE or still_running) and attempt < max_attempts:
retry_after = r.headers.get("Retry-After", "")
time.sleep(int(retry_after) if retry_after.isdigit() else backoff(attempt))
continue
return r # 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:
| Operations | Limit per IP and operation |
|---|---|
The business profile (GET /.well-known/ucp); catalog search, lookup and product; GET of a cart; GET of a checkout | 600 a minute each |
| Create, update and cancel a cart; create, update, complete and cancel a checkout | 300 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-Foryou 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_limitedwithRetry-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/throttler6.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 own429block resets it. Treat each limit as a budget, not as a rate: keep steady traffic at half of it or less, and on a429wait the fullRetry-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) andX-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). WatchX-RateLimit-Remainingand slow down well before it reaches zero. A429carriesRetry-Afterinstead.
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
| Pattern | Requests a minute | Half the limit is reached at |
|---|---|---|
| Polling one checkout every 5 seconds | 12 checkout reads | about 25 checkouts polled at once from one IP |
| Polling one checkout every minute | 1 checkout read | about 300 checkouts |
| Building a session: create, two updates, convert, two updates | 6 writes, in 4 different operations | far 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
| What | Value | Then |
|---|---|---|
| The web server waits for the API | 60 seconds | 504 (HTML). A write may still complete: retry with the same key. |
| A request waits for its session's turn | 10 seconds | 503, Retry-After: 1 |
An unfinished Idempotency-Key claim | 120 seconds | The same request takes the key over. |
| The shop fetches your profile | 3 seconds without data, 5 seconds in all | 424 profile_unreachable |
| Your client's timeout | Recommended: 65 seconds | Above the web server's 60, so you get its 504 rather than your own timeout. Either way, retry with the same key. |
| Limit | Value | Over it |
|---|---|---|
| Request body | 1 MiB through the shop's domain | 413 (HTML) from the web server |
line_items | 100 per request | 400 |
| Quantity per line | 999 | Lowered to 999 without a message |
| Discount codes | 10, each up to 50 characters | 400 (longer codes are cut) |
Lookup ids | 20, each up to 255 characters | 400 request_too_large (longer ids are cut) |
Search page size (pagination.limit) | 20, default 10 | Lowered to 20 |
Search query | 200 characters | Cut to 200 |
UCP-Agent header | 4096 characters; the profile URL in it 1000 | 400 invalid_profile_url |
Idempotency-Key | 255 characters | 400 invalid_request |
| Your platform profile | 256 KiB | 424 profile_unreachable |
Caching
| Answer | Cache-Control |
|---|---|
The business profile, GET /.well-known/ucp | public, max-age=300 — cache it for up to 5 minutes |
| Everything else: catalog, carts, checkouts, every error, the handoff page | no-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 sendAccept-Encoding: gzip. AGETthat sendsIf-None-Matchwith the currentETag, in either form, is answered304 Not Modifiedwithout a body, even though the answer isno-store; if your HTTP library addsIf-None-Matchby itself, handle the304. Writes never answer304. - 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
| Session | Lives | Extended by | Afterwards |
|---|---|---|---|
| Cart | 7 days | create, PUT | Every call answers 200 not_found. |
| Checkout | 24 hours | create (also from a cart), PUT | The next request that touches it cancels it: status: "canceled". |
| A session the buyer took to the shop | until its last expires_at | nothing: writes are frozen | As above. |
| Any session's data | until 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
503waitRetry-Afterand go on. - Stop at
completed(readorder) orcanceled. Stop as well afterexpires_at: one more read after the deadline gives the final answer —completedif the order came first, otherwisecanceled. - 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
incompletecompletes 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
}
}
import time
from datetime import datetime
def wait_for_order(api, checkout_id, profile_url):
"""Poll a handed-over checkout until it is completed, canceled or past its deadline."""
started = time.monotonic()
while True:
r = ucp_call("GET", f"{api}/checkout-sessions/{checkout_id}", profile_url)
if r.status_code == 404:
return {"outcome": "not_served"} # the shop stopped serving UCP: re-discover it later
r.raise_for_status() # retries spent: try again later
checkout = r.json()
if checkout.get("ucp", {}).get("status") == "error":
return {"outcome": "gone"} # not_found
if checkout["status"] == "completed":
return {"outcome": "ordered", "order": checkout["order"]}
if checkout["status"] == "canceled":
return {"outcome": "canceled"}
deadline = datetime.fromisoformat(checkout["expires_at"].replace("Z", "+00:00"))
past_deadline = datetime.now(deadline.tzinfo) > deadline
interval = 5 if time.monotonic() - started < 15 * 60 else 60
time.sleep(1 if past_deadline else 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-Afterwhen given, exponential backoff with jitter otherwise, no retries for other4xxand 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,504from the web server. - Rate budgets are watched per egress IP and per operation, across every
shop you serve:
X-RateLimit-Remaining,429counts. 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 with200from 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,canceledor one read afterexpires_at, and treats "never completed" as a normal outcome. - Every call is logged with the time in UTC, host, method, path, status,
codeand your correlation id — the API returns no request id.