Documentation
OverviewQuickstartConceptsDecisions and errorsApprovalsVersioningSecurityExtensionsFAQPartner integration profileDecisions and errors
Status: Draft 0.4, pre-release. Sources: Draft 0.4 sections 8 and 13, binding section 6,
registries/reason-codes.jsonandschemas/error.schema.json.
Every call an agent makes is either decided or rejected as malformed.
- A decision is the enforcement point applying the seller's grant and terms:
auto,askorforbid. It always carries anRCTP-Decisionheader. Aforbidalways names exactly one registered reason, so your agent can act on it without a human reading a message [13]. - A protocol error means the request itself was wrong: bad JSON, an unknown route, an unsupported version, a reused
Idempotency-Key. It carries noRCTP-Decisionheader.
The RCTP-Decision header
The header is an RFC 9651 Structured Field Item: a Token (auto, ask or forbid) with parameters [8.2]
(binding 6). The reference gateway serialises it without optional spaces:
HTTP/1.1 202 Accepted
RCTP-Decision: ask;approval_id="apr_01K8ZB7";expires_in=900;on_timeout=deny
HTTP/1.1 403 Forbidden
RCTP-Decision: forbid;action="offer.accept";reason=below_floor
HTTP/1.1 429 Too Many Requests
RCTP-Decision: forbid;reason=offer_budget_exhausted;retry_after=3600
Parse the header with a library (parseDecision in @rctp/sdk, parse_decision in rctp, structured-headers,
dunglas/httpsfv, http-sf), never with string matching.
| Decision | HTTP | Parameters | Body |
|---|---|---|---|
auto |
200, or 201 for a create | none | The resource |
ask |
202 | approval_id, expires_in (seconds), on_timeout=deny |
{"approval_request": {…}} (see Approvals) |
forbid |
401, 403, 409 or 422 | action (when there is one), reason |
Refusal object |
forbid (budget) |
429 | reason=offer_budget_exhausted, retry_after (seconds) |
Refusal object, plus a Retry-After header |
| Parameter | Type | Meaning |
|---|---|---|
approval_id |
sf-string | The pending approval, apr_… |
expires_in |
sf-integer | Seconds until no answer becomes a decline |
on_timeout |
Token | Always deny (INV-2) |
action |
sf-string | The verb that was evaluated, such as offer.accept |
reason |
Token | One code from the table below |
retry_after |
sf-integer | Seconds until this buyer agent or operator may offer on this listing again |
A refusal body validates against the Refusal branch of error.schema.json:
{ "reason": "below_floor", "action": "offer.accept" }
It may also carry retry_after, approval_id, offer_id and a short message. It never carries a floor: a schema
test fails if any schema other than listing-terms and terms-submission has a floor field.
The check order
The enforcement point runs its checks in a fixed order and stops at the first refusal [8.1] (binding 6).
| Step | Question | If not |
|---|---|---|
| 1 | Is the request signed correctly (UCP message signature, created/expires, Content-Digest), by a key in the profile of an onboarded operator? |
401 signature_invalid or operator_unknown |
| 2 | Seller side: is the access token known, bound to the signing key and covered by the signature; is the grant live; is the verb's scope granted? | 401 grant_invalid; 403 grant_revoked, grant_expired or action_forbidden |
| 3 | Is the listing the seller's, with approved terms, open, and not past available_until? |
403 not_in_scope; 409 listing_reserved for a second accept; 409 offer_expired for an offer past expires_at |
| 4 | Buyer side: does the buyer meet the listing's counterparty_requirements? |
403 counterparty_requirements_unmet (always, when a listing sets them: no buyer can prove them in v0.2, B-6) |
| 5 | Do the Deal Terms match the listing's options; is the price at or above the floor; is there offer budget left? | 422 terms_invalid; 403 below_floor (no private value); 429 offer_budget_exhausted |
| 6 | Which tier applies? | auto → 200/201, the verb runs. ask → 202, one approval request to the seller |
Why this order:
- The cheapest refusals come first.
- The floor check comes after the counterparty check, so a buyer who fails the requirements never learns how close their offer was.
- The floor check comes before the tier, so an accept below the floor is refused even when
offer.acceptisauto(V1, INV-1). - Once a buyer agent's budget (or its operator's ceiling) for a listing is spent, every offer from it on that listing gets 429, even one above the floor. Otherwise 200-versus-429 would reveal the floor [8.5].
A buyer offer (offer.create) has no tier: one that passes is delivered with 201. On a listing without approved terms
there is no seller agent, and the offer goes to the marketplace as an ordinary offer (auto) [6.5]. The fixed
forbids, deal.sign_contract and account.update, are always 403 action_forbidden once the signature is valid
(INV-3).
Every decision, including every refusal, is written as an entry in the deal record [10.1].
Reason codes
All 22 codes from registries/reason-codes.json, in the order of Draft 0.4
section 13. "v0.2" says whether the reference gateway at v0.2.0-draft.4 can return it.
| Reason | HTTP | What it means | What your agent should do next | v0.2 |
|---|---|---|---|---|
signature_invalid |
401 | The UCP message signature or key binding failed: missing, tampered, outside its created/expires window, a wrong Content-Digest, a required component not covered, or a checkout redeemed with a key other than buyer_jkt. |
Fix signing: keep clocks within 60 seconds, sign every request afresh (the SDKs do), and redeem with the key the checkout is bound to. | Yes |
operator_unknown |
401 | The UCP-Agent profile is not onboarded, does not resolve or validate, or its keys[] lacks the request's keyid. |
Check the UCP-Agent URL and your profile's keys (kid must be the thumbprint, B-1). In production, get onboarded by the marketplace. |
Yes |
grant_invalid |
401 | The access token is unknown, inactive, not bound to the signing key, or Authorization is not covered by the signature. Also: a seller verb with no token. |
Refresh the access token and retry once (the SDKs do this on 401). If it still fails, ask the seller to connect again. | Yes |
grant_revoked |
403 | The seller or operator revoked the grant. | Stop acting for this seller. Only a new consent gives you a new grant. | Yes |
grant_expired |
403 | The grant is past its exp (at most 90 days after issue). |
Ask the seller to connect again. A refresh cannot extend a grant. | Yes |
action_forbidden |
403 | The verb is not granted, not the caller's to use (a buyer accepting), or always forbidden (deal.sign_contract, account.update). Also: an agent-signed request to the hosted terms route, or an event written by a party not allowed to write it. |
Do not retry. Tell your principal. Only the seller, on the consent page, can grant a verb. | Yes |
not_in_scope |
403 | The listing is not this seller's, has no approved terms, is closed or past available_until, or was withdrawn while reserved. |
Do not retry for this listing. A seller agent may propose terms with listing.set_terms. |
Yes |
below_floor |
403 | The offer, counter, accept or reprice is under the seller's floor. The floor is never stated. | Buyer: offer more or walk away; the refusal carries no private value (Draft 0.5 removed the counter). Each below-floor offer uses your budget. Seller: you cannot go below your own floor; tell the seller. | Yes |
counterparty_requirements_unmet |
403 | The buyer falls short of the listing's counterparty_requirements. In v0.2 every offer on a listing that sets them gets this (B-6). |
Do not retry. Party Credentials, which would let a buyer prove them, are not built yet. | Yes |
buyer_authority_exceeded |
403 | The offer is outside the buyer's AP2 mandate. | Offer within the limit, or ask your principal to raise it. | No (#59) |
protected_payment_required |
403 | Protected payment is required. | Move the deal to protected payment. Never pay directly or pass payment links. | No |
approval_invalid |
403 | An approval answer's signature, challenge, terms_commitment, expiry or single use fails, or the displayed text does not match. Returned to the approval page, not to the agent. |
The approval stays pending. Only the seller can answer, with their own passkey on the hosted page. If it has expired, ask again. | Yes |
approval_surface_not_allowed |
403 | The answer came through a level the request does not allow or the gateway does not support. | Use a level in the request's allowed_levels: in v0.2, the hosted_url page. |
Yes |
approval_timeout |
403 | An ask expired with no answer. It is the reason on an expired approval (polling and the approval.decided webhook). |
Treat it as a decline. A pending offer is now expired. Tell your principal; do not re-ask straight away. |
Yes |
terms_invalid |
422 | Listing Terms break the term set (for example a floor above the list price), Deal Terms name an option the listing does not offer, an expires_at is already past, or supersedes names an offer outside the negotiation or no longer open. |
Fix the terms. Read the listing's public_terms and choose an offered selected_option_id and selected_term_id. |
Yes |
custody_attestation_invalid |
422 | A custody attestation does not bind to this listing or item. | Get one bound to this listing hash and item. | No |
offer_expired |
409 | The offer or counter passed expires_at, or the checkout passed its exp. |
Make a new offer. | Yes |
listing_reserved |
409 | Another accepted offer holds the listing until its checkout is redeemed or expires [7.4]. | Do not retry now. The listing returns to active if that checkout expires. |
Yes |
offer_replayed |
409 | The offer was already accepted, or its checkout was already redeemed. | Do not retry. Use the deal or checkout you already have (GET /offers/{id}). |
Yes |
release_condition_unmet |
409 | A deal event came out of order: money requested before its condition holds, an event without its precondition, or without the term set's required_observation (B-7) [10.3]. |
Wait for the missing event (for example the buyer's accepted), then try once more. Do not poll in a loop. |
Yes |
seal_mismatch |
409 | The seal or item identifier differs from pickup. | Stop. Release or refund stays blocked until it is resolved. | No |
offer_budget_exhausted |
429 | This buyer agent, or its operator, made too many below-floor offers on this listing within the window [8.5]. | Stop offering on this listing until retry_after seconds have passed. Until then every offer, at any price, gets 429. |
Yes |
Protocol errors
Protocol errors are not decisions. They carry no RCTP-Decision header and use the ProtocolError branch of
error.schema.json:
{ "error": "invalid_request", "message": "…" }
| Error | HTTP | When | What to do |
|---|---|---|---|
invalid_request |
400 | Malformed JSON; a body that does not match the closed request schema, including any field that tries to set a tier, floor, budget, grant or scope (INV-5); a buyer calling /counter (answer with POST /offers and supersedes) |
Fix the request. Do not resend it unchanged. |
invalid_request |
409 | The resource's state does not allow it, for example "offer is declined", or countering a seller's counter | Read the resource again and choose a valid next step. |
invalid_request |
413 | The body is over 1 MiB. Checked before the signature. | Send a smaller body. |
not_found |
404 | Unknown route or resource. Also another negotiation's offer: the gateway never reveals it. | Check the ID and the route. |
unsupported_version |
400 | RCTP-Version names a version the gateway does not serve. Checked before anything else. |
Send 2026-11-draft5, or omit the header. See Versioning. |
idempotency_conflict |
409 | The Idempotency-Key was already used with a different request. A repeat of the same request gets the stored response instead (binding 2). |
Use a fresh key for a new request. |
unavailable |
503 | The gateway, its idempotency store, the authorization server or the marketplace behind it failed. If the idempotency store is down, nothing is executed. | Retry later with backoff, as a newly signed request with the same Idempotency-Key (a 5xx answer is never stored). |
In the SDKs
| Call | Returns | Throws |
|---|---|---|
Governed calls (offers.create, offers.accept, listings.create, deals.redeem, deals.events.post, …) |
Outcome<T>: auto, ask or forbid. Handle it with match or unwrap |
ProtocolError for protocol errors |
Reads (offers.get, approvals.get, deals.events.list, …) |
The resource | ForbiddenError for a refusal, ProtocolError otherwise |
unwrap(outcome) |
The data of an auto |
ApprovalPendingError for ask, ForbiddenError for forbid |
ForbidOutcome.reason is typed as the registered Reason union. REASON_CODES and REASON_HTTP_STATUS are generated
from the registry by pnpm codegen. The Python SDK returns Auto | Ask | Forbid with the same reasons.
On other transports
Draft 0.4 carries the same decision on every transport [12]. v0.2 ships the REST binding only; the MCP and A2A bindings are not built yet.
Source: docs/decisions-and-errors.md