carousell/rctp

RCTP v0.2 binding (Starter profile of Draft 0.4)

Capability version: 2026-11-draft4 · Release: v0.2.0-draft.4 · UCP release: v2026-08-25 (UCP Ask at commit f2626c2)

Draft 0.4 says what an enforcement point must decide. This document fixes the wire details v0.2 needs so that the Go gateway, the TypeScript and Python SDKs, the sandbox and the conformance runner interoperate. It replaces v0.1-binding.md, which stays as the record of v0.1. Where the draft is silent, the choice and its reason are logged in AMBIGUITIES.md (rows B-n), always the more restrictive reading.

Key words follow BCP 14 as in the draft. Section numbers in brackets refer to Draft 0.4. Schemas are in schemas/; every body below names its schema.


1. Keys and UCP profiles [4.1]

  • Agents, operators and gateways sign with ECDSA P-256 (ES256; ecdsa-p256-sha256 in RFC 9421), the UCP baseline. ECDSA signatures use raw r||s encoding (RFC 9421 §3.3.1), as UCP requires.
  • Every party publishes a UCP profile (schema ucp-profile) at https://<host>/.well-known/ucp, with its public keys in keys[].
  • RCTP requires each key's kid to be its RFC 7638 JWK SHA-256 thumbprint, base64url without padding (B-1). The RFC 9421 keyid is that kid. A key whose kid is not its thumbprint is unusable.
  • An operator whose agents call an enforcement point MUST list dev.rctp.operator in ucp.capabilities with config = legal_entity, jurisdiction, incident_contact, incident_sla_hours, and MAY add certifications, data_residency, key_custody [4.1(2)]. An operator that wants webhooks lists dev.rctp.deal with config.webhook_url (section 9).
  • Reference gateway policy: operators are onboarded by profile URL in gateway configuration. The gateway caches a profile for at most 5 minutes.
  • Private keys never appear in any profile, record, log or example.

2. Request signing [4.1]

Every agent request to an enforcement point is signed per UCP Message Signatures (REST binding, RFC 9421), with the RCTP additions marked RCTP.

Item Value
Identity header UCP-Agent: profile="<operator profile URL>" (RFC 8941 Dictionary)
Signature label sig1
keyid kid of a key in the operator profile's keys[] (its thumbprint)
created, expires RCTP: REQUIRED; expires - created ≤ 300 s; created within ±60 s of the verifier's clock (B-2)
Idempotency-Key REQUIRED on every POST; at least 128 bits of entropy
Covered components "@method" "@authority" "@path" "ucp-agent"; "@query" when the target has a query; "idempotency-key" on POST; "content-digest" and "content-type" when there is a body; RCTP: "authorization" when that header is present
Body digest Content-Digest per RFC 9530 with sha-256, over the raw body bytes

The verifier checks, in this order, and refuses with 401 signature_invalid unless a row says otherwise:

  1. Signature, Signature-Input (label sig1) and UCP-Agent are present.
  2. UCP-Agent names an onboarded profile that resolves and validates → else operator_unknown.
  3. keyid is a kid in that profile's keys[] → else operator_unknown.
  4. The signature verifies; every required component is covered; created and expires hold; Content-Digest matches the body.

Idempotency (UCP replay protection). The gateway stores each POST's Idempotency-Key with the request digest and the response for 48 hours, per operator profile. A repeat with the same key and digest gets the stored response without re-executing. A repeat with the same key and a different digest gets 409 {"error":"idempotency_conflict"}. If the store is unavailable, the gateway answers 503 and executes nothing.

3. Agent Grant [5]

3.1 Discovery

The marketplace's gateway serves OAuth protected-resource metadata (RFC 9728) at /.well-known/oauth-protected-resource naming its authorization server. The authorization server serves RFC 8414 metadata at /.well-known/oauth-authorization-server, as UCP Identity Linking requires (issuer matched byte for byte).

RCTP scopes are declared, as every UCP scope is, in the config.scopes of the marketplace's dev.ucp.common.identity_linking capability entry. Each scope's policy object carries default_tier and allowed_tiers [5.2(2), 8.4]:

"dev.rctp.deal:offer_accept": { "default_tier": "ask", "allowed_tiers": ["ask", "auto"] }

3.2 Issuance [5.2]

  • Authorization code grant with PKCE (S256). The authorization request carries RFC 9396 authorization_details with one object of type dev.rctp.agent_grant: {"type", "agent_id", "jkt", "tiers": {"<scope>": "<tier>"}}, where jkt is the thumbprint of the agent key that will sign calls. The requested scopes are the keys of tiers.
  • The consent page shows the agent, its operator, each verb with its tier, and the grant expiry. It asks for no prices. The seller may lower a tier within the scope's allowed_tiers; the grant records what the seller confirmed.
  • The token response carries the access token (opaque), token_type Bearer, expires_in ≤ 900 s, a refresh token, and the granted authorization_details. Refresh tokens rotate on every use; reuse of a rotated refresh token revokes the grant.
  • A grant expires at most 90 days after issue [5.2(4)]; refresh never extends it past that.

3.3 Use on every call [5.3]

  • The seller agent sends Authorization: Bearer <access token>. The request signature MUST cover authorization.
  • The gateway introspects the token on every seller-side call (RFC 7662; the reference gateway authenticates with client_secret_basic), never caching the answer (INV-7). The response (schema grant-introspection) carries active, scope, sub (the seller's pairwise pseudonym), exp, cnf.jkt, grant_status and the dev.rctp.agent_grant authorization details.
  • Checks and refusals, in order:
Check Refusal
Authorization present and covered by the signature 401 grant_invalid
Token known to the authorization server 401 grant_invalid
cnf.jkt equals the request keyid 401 grant_invalid
grant_status is not revoked 403 grant_revoked
grant_status is not expired (grant past exp) 403 grant_expired
active (the access token itself is current; else refresh and retry) 401 grant_invalid
The verb's scope is granted 403 action_forbidden
  • RFC 7662 lets an inactive token answer only active: false. RCTP's authorization server adds grant_status for tokens it issued, to the authenticated gateway only, so revoked and expired grants get their own reasons (B-3).

3.4 Revocation [5.4]

The seller revokes on a marketplace screen; operators revoke their own tokens (RFC 7009). Because every call is introspected, a revoked or expired grant fails the next signed call (INV-7, V6).

4. Listing Terms [6]

4.1 Term sets [6.2]

A term set (schema term-set, registry term-sets) lists its terms with type, visibility, whether required, and constraints from the closed set min, max, gt_term, lt_term, lte_list_price, min_ratio_of_list_price. The gateway advertises the term sets it enforces in config.term_sets of its dev.rctp.listing entry.

4.2 Setting terms on the hosted page [6.1]

  1. The seller agent calls POST /listings (schema listing-create-request, which names the term_set). It sends no private terms. The answer is always 202 ask.
  2. The seller opens hosted_url. GET /approve/{id}/request returns the approval request and the term-set definition, from which the page builds the form. Nothing is pre-filled [6.1(6)].
  3. The page posts the seller's values to POST /approve/{id}/terms (schema terms-submission). This route is for the approval page only: it is not agent-signed and is refused for any request carrying UCP-Agent. The gateway checks the constraints (422 terms_invalid before the seller can approve, [6.2]), draws a 128-bit salt and returns terms_commitment and the WebAuthn options whose challenge binds it.
  4. The seller approves with their passkey (POST /approvals/{id}/response, carrying terms_commitment). The gateway stores exactly the terms behind that commitment [6.1(3)].
  5. The agent learns the outcome from GET /approvals/{id} or the webhook: the listing, its term_set and terms_commitment, never the values [6.1(4)].

terms_commitment = "sha-256:" + hex(SHA-256(JCS({"salt": <base64url salt>, "terms": <terms>}))), where terms is the listing-terms terms object and JCS is RFC 8785 [6.4(2)].

POST /listings/{id}/terms (listing.set_terms, empty body) starts the same flow for an existing listing.

4.3 Privacy [6.4]

Private terms (floor, counter) never appear in listing data, offers, decisions, approval request bodies, webhooks, partner-readable logs or deal records. Public terms (available_until, fulfillment_options, payment_terms, release_condition, deliver_within_days, …) appear in listing data under public_terms.

5. Offers and Deal Terms [7]

5.1 Objects

An offer and a counter are both offer objects, with by = buyer or seller. Each carries deal_terms (schema deal-terms: price, fulfillment, payment), negotiation_id, optional supersedes, and expires_at.

Operation Caller Verb Effect
POST /offers buyer offer.create New buyer offer. With supersedes naming a counter or the buyer's own open offer, that one becomes superseded
POST /offers/{id}/counter seller offer.counter New seller offer (by = seller, supersedes = {id}); {id} becomes countered
POST /offers/{id}/counter buyer — Refused 400; a buyer answers a counter with POST /offers and supersedes [7.3(1)]
POST /offers/{id}/accept seller offer.accept Only on a buyer offer; reserves the listing (5.3)
POST /offers/{id}/accept buyer — 403 action_forbidden [8.3]
POST /offers/{id}/decline either party — Always allowed for the parties

5.2 Identity, revision and expiry [7.2, 7.3]

  • negotiation_id (neg_…) is assigned by the gateway on a buyer agent's first offer on a listing and is the same for every later offer and counter between that buyer agent (key thumbprint) and that listing. It is random, never derived from the buyer.
  • supersedes must name an offer in the same negotiation that is open or countered; otherwise 422 terms_invalid.
  • expires_at: the creator MAY send it; otherwise the gateway sets now + offer_expiry.default_seconds. The gateway caps it at now + offer_expiry.max_seconds and at the listing's available_until. Both numbers are in config of the gateway's dev.rctp.deal entry (reference defaults: 48 hours and 7 days).
  • At expires_at an offer not accepted becomes expired; any later action on it is 409 offer_expired. An ask on an offer expires no later than the offer.

5.3 Acceptance, reservation and checkout [7.4]

  • A completed accept (directly under auto, or after an approved ask) makes the offer accepted and gives it checkout: a compact JWS signed by a key in the gateway profile (typ rctp-checkout+jwt), claims per checkout: iss, offer_id, negotiation_id, listing_id, listing_hash, deal_terms, buyer_jkt, nonce, iat, exp (reference default 30 minutes, never past available_until plus 30 minutes).
  • The listing becomes reserved. While it is, every other offer.accept on it gets 409 listing_reserved; buyer offers are still accepted as open. Accepting the same offer again gets 409 offer_replayed.
  • The buyer redeems with POST /deals (schema deal-create-request), signed by the key whose thumbprint is buyer_jkt. First use → 201 deal, and the listing becomes sold. A later use of the same nonce → 409 offer_replayed; after exp → 409 offer_expired; a different key → 401 signature_invalid.
  • At checkout exp without redemption the reservation ends: the listing returns to active and the offer to expired.
  • Accepted Deal Terms are fixed: listing.set_terms and listing.reprice on a reserved listing change future offers only. If the seller withdraws a reserved listing on the marketplace, redemption fails with 403 not_in_scope and the withdrawal is written to the deal record.

5.4 Buyer authority [7.5]

AP2 Checkout Mandate verification (7.5(1), vector V25) is not part of v0.2.0-draft.4: AP2 v0.2 mandates are SD-JWT delegation chains, and no pinned Go library verifies them, so implementing it now would mean custom cryptography (INV-8). Tracked in #59. Until then a buyer agent's authority is its operator's onboarding.

6. Decisions [8]

RCTP-Decision is an RFC 9651 Item: a Token with parameters (schema decision).

Decision HTTP Header Body
auto 200 (201 for creates) auto the resource
ask 202 ask;approval_id="apr_…";expires_in=900;on_timeout=deny {"approval_request": {…}}
forbid 401, 403, 409, 422 forbid;action="offer.accept";reason=below_floor;counter=340.0 error
budget exhausted 429 forbid;reason=offer_budget_exhausted;retry_after=3600 error
  • counter is an sf-decimal and is present only when the listing's counter term is set (the term set requires it to be above the floor).
  • Protocol errors that are not decisions (malformed JSON, schema violations, unknown routes, idempotency conflicts) use 400/404/409/503 with {"error": "invalid_request" | "not_found" | "unsupported_version" | "idempotency_conflict" | "unavailable", "message": "…"} and no RCTP-Decision header.
  • Check order is exactly Draft 0.4 section 8.1: signature and operator → grant (seller side) → listing → buyer requirements (buyer side) → terms, Deal Terms and budget → tier. Every decision, refusals included, appends a deal record entry.
  • Tiers come from the grant's tiers for the verb's scope, within the scope's allowed_tiers; otherwise the scope's default_tier. Fixed forbid verbs (deal.sign_contract, account.update) have no scope and are always 403 action_forbidden (INV-3).
  • Offer budget [8.5]: below-floor offers are counted per buyer agent (key thumbprint) per listing, and per buyer operator per listing, over window_hours. Once either count reaches its limit, every offer from that agent or operator on that listing gets 429 until the window frees a slot. The limits are in config.offer_budget of the gateway's dev.rctp.deal entry (reference: 3 per agent, 10 per operator, 24 hours).
  • Request bodies are closed schemas: a body field such as floor or tier is a 400, never an override (INV-5).

6.1 Which verb each operation evaluates

Operation Caller Verb Floor Tier
POST /listings seller listing.create — always ask
POST /listings/{id}/terms seller listing.set_terms — always ask
POST /listings/{id}/price seller listing.reprice new price ≥ floor grant
POST /listings/{id}/answers seller listing.answer_buyer — grant
POST /offers buyer offer.create yes, plus budget none: delivered at 201
POST /offers/{id}/counter seller offer.counter counter price ≥ floor grant
POST /offers/{id}/accept seller offer.accept offer price ≥ floor, whatever the tier (V1) grant
POST /deals/{id}/commit-to-ship seller deal.commit_to_ship — grant
POST /deals/{id}/contract seller deal.sign_contract — always 403
POST /account seller account.update — always 403

An offer on a listing without approved terms has no seller agent: it is delivered to the marketplace as an ordinary offer (decision auto), and a seller agent acting on that listing is refused not_in_scope [6.5].

7. Approvals at level hosted_embedded [9]

As in v0.1 binding section 6, with these changes:

  • The 202 body's approval_request.authorization_details[0] (type dev.rctp.approval) adds term_set and, once the seller has filled the form, terms_commitment for listing.create and listing.set_terms. For those two verbs the challenge is computed after POST /approve/{id}/terms, over the details that include terms_commitment; the approval request in the 202 body carries no challenge yet (B-4).
  • display never contains private terms; the terms form appears only on the hosted page [9.2(2)].
  • approval_response adds terms_commitment (required for listing approvals).
  • On approve, the gateway re-checks the grant (introspection), the floor and the reservation, then executes the pending action exactly as it was asked.
  • No valid response by expires_at is a decline (INV-2); a pending offer becomes expired.
  • Seller passkey enrolment stays a reference-gateway feature (v0.1 binding 6.5), with the enrolment ticket issued by the marketplace authorization server.

8. Deal record and events [10]

8.1 Writing events

POST /deals/{id}/events (schema deal-event-post-request) carries {"event": "<compact JWS>"}, typ rctp-deal-event+jwt, signed by the same key as the request, payload deal-event.

Event Kind Who may write it Precondition (else 409 release_condition_unmet)
funds_held RCTP the holder of funds (an operator profile configured as such) none
shipped, delivered, returned_to_sender UCP fulfillment event carrier (first_hand) or marketplace (relayed) funds_held
accepted RCTP the buyer agent (buyer_jkt) funds_held; neither accepted nor rejected yet
rejected (reason: not_as_described, not_received) RCTP the buyer agent as accepted; not_received only after deliver_within_days since funds_held
return UCP adjustment the seller agent, carrier or Custodian rejected with not_as_described
released RCTP the holder of funds funds_held and the release condition (default accepted)
refund UCP adjustment the holder of funds rejected + not_as_described + return; or rejected + not_received

Where the listing's term set sets required_observation for an event, the event counts only with that observation [10.3(4)]. A relayed event names reported_by and source_event_id [10.4(2)].

8.2 The record

  • The enforcement point writes an entry (attestation-entry) for every decision, including refusals, and for every accepted event, into a hash chain per subject (negotiation, deal or listing). prev_hash = sha-256:<hex> of SHA-256 over the JCS of the previous entry; the first is 64 zeros. Gateway entries carry its JWS over the entry body.
  • Entries carry terms_commitment where terms apply, never a floor [10.4(1)], and no names, addresses or phone numbers; locations are city-level; evidence is digests [10.4(5)].
  • GET /deals/{id}/events returns the deal's entries to its parties.

9. Webhooks

UCP signed webhooks (Standard Webhooks headers, RFC 9421 signature), to the config.webhook_url of the dev.rctp.deal entry in the receiving operator's profile.

Header Value
Webhook-Id the event's event_id
Webhook-Timestamp Unix seconds of the event
UCP-Agent profile="<gateway profile URL>"
Content-Type, Content-Digest application/json; RFC 9530 sha-256
Signature-Input, Signature label sig1, components "@method" "@authority" "@path" "content-digest" "content-type", with keyid, created

Body: webhook-event {"event_id", "type", "created_at", "data"}, types approval.decided, offer.updated, deal.event. Receivers verify the signature against the gateway profile's keys[], check Content-Digest, reject a Webhook-Timestamp more than 300 s old, and drop repeated Webhook-Ids. The SDKs do this (verifyWebhook / verify_webhook).

10. Discovery and versions

GET /.well-known/ucp returns the gateway profile: UCP version 2026-08-25; capabilities dev.ucp.common.identity_linking (RCTP scopes with tiers), dev.rctp.listing (config.term_sets) and dev.rctp.deal (config.offer_expiry, config.offer_budget), each at version 2026-11-draft4; and keys[].

Clients MAY send RCTP-Version: 2026-11-draft4 (an sf-string); an unsupported version is 400 unsupported_version. The reference gateway v0.2 serves 2026-11-draft4 only; a deployment that must keep 2026-10-draft3 runs v0.1 beside it for at most 90 days [4.5(3)].

10a. Open values in v0.2

Draft 0.4 4.5(2) makes term names and event types open values. v0.2 narrows both, so that closed schemas can carry them:

  • Term names: only the eleven names of Draft 0.4 6.2 are accepted. A term set that needs another term waits for a registry addition.
  • Deal events: an event is an RCTP checkpoint, one of the UCP values in section 8.1, or a reverse-domain extension such as example.carrier:picked_up_sealed. Extension events are recorded as informative and never change a money rule.
  • Fulfillment and payment options in Listing Terms are lists of {method_type, option_ids} and payment term IDs, so the gateway can check an offer's selected_option_id and selected_term_id against them.

11. Personal data [15, INV-9]

No RCTP object carries names, addresses, phone numbers or identity numbers. The seller is a pairwise pseudonym (sub of the grant); a buyer is its agent key thumbprint. Schemas for every object are closed and have no field where personal data could go.

12. Endpoints (reference gateway)

Binding routes are part of this binding (the conformance suite uses them); reference routes belong to the reference gateway only.

Method and path Caller Decides Status
POST /listings seller listing.create spec 12
GET /listings/{id} any operator — reference
POST /listings/{id}/terms seller listing.set_terms spec 12
POST /listings/{id}/price seller listing.reprice binding
GET /listings/{id}/questions, POST /listings/{id}/questions seller, buyer — reference
POST /listings/{id}/answers seller listing.answer_buyer binding
POST /offers buyer offer.create spec 12
GET /offers?listing_id= seller — reference
GET /offers/{id} either party — spec 12
POST /offers/{id}/counter, /accept, /decline parties offer.counter, offer.accept spec 12
GET /approvals/{id} operator that asked — spec 12
POST /approvals/{id}/response approval page only — spec 12
GET /approve/{id}, /approve/{id}/request; POST /approve/{id}/terms, /approve/{id}/options the seller's browser terms_invalid binding
POST /deals buyer (checkout key) single use spec 12
GET /deals/{id} parties — reference
POST /deals/{id}/events, GET /deals/{id}/events parties, holder of funds, carrier money rules spec 12
POST /deals/{id}/commit-to-ship seller deal.commit_to_ship binding
POST /deals/{id}/contract seller deal.sign_contract → always 403 binding (INV-3)
POST /account seller account.update → always 403 binding (INV-3)
GET /enroll, POST /enroll/options, POST /enroll/complete the seller's browser — reference
GET /.well-known/ucp, GET /.well-known/oauth-protected-resource anyone — binding

Source: spec/v0.2-binding.md