carousell/rctp

Decisions and errors

Status: Draft 0.4, pre-release. Sources: Draft 0.4 sections 8 and 13, binding section 6, registries/reason-codes.json and schemas/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, ask or forbid. It always carries an RCTP-Decision header. A forbid always 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 no RCTP-Decision header.

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.accept is auto (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