carousell/rctp

RCTP v0.1 binding (Starter profile of Draft 0.3)

Capability version: 2026-10-draft3 · Release: v0.1.0-draft.3

Draft 0.3 says what an enforcement point must decide. This document fixes the wire details v0.1 needs so that the Go gateway, the TypeScript and Python SDKs and the conformance runner interoperate. Where the draft is silent or ambiguous, the choice and its reason are logged in AMBIGUITIES.md.

Key words follow BCP 14 as in the draft. Section numbers in brackets refer to Draft 0.3.


1. Keys and key directories [3.2, 8]

  • Agents, operators, issuers and gateways sign with ECDSA P-256 (ES256 in JOSE, ecdsa-p256-sha256 in RFC 9421).
  • A key directory is a JWK Set served at the operator's key_directory URL with media type application/http-message-signatures-directory+json (falls back to application/json).
  • Every key in a directory MUST carry kid equal to its RFC 7638 JWK SHA-256 thumbprint, base64url without padding. The RFC 9421 keyid parameter is that thumbprint.
  • Private keys never appear in any directory, record, log or example.

2. Operator record [8]

  • Served at a stable record_url as a compact JWS (application/jose), header {"alg":"ES256","kid":"<thumbprint>","typ":"rctp-operator+jws"}, payload = the record (schema operator-record).
  • The JWS MUST verify with a key from the record's own key_directory.
  • Every agent request carries the record URL in RCTP-Operator (an RFC 9651 sf-string).
  • Reference gateway policy: operators are allow-listed by record URL in gateway configuration (partner onboarding). A record URL that is not configured, does not resolve, does not verify, or whose directory lacks the request keyid fails with 401 operator_unknown.

3. Request signing [3.2, 11]

Every agent request to an enforcement point is signed per RFC 9421.

Item Value
Signature label rctp
alg ecdsa-p256-sha256
keyid JWK thumbprint of the signing key
created, expires REQUIRED; expires - created ≤ 300 s
nonce REQUIRED; at least 128 bits of randomness, base64url; single use within the validity window
tag rctp
Covered components "@method" "@authority" "@path" "rctp-operator"; plus "@query" when the target has a query; "content-digest" when there is a body; "rctp-mandate" when that header is present
Body digest Content-Digest per RFC 9530 with sha-256

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

  1. Signature and Signature-Input present with label rctp.
  2. RCTP-Operator resolves to an allow-listed, verifiable operator record → else operator_unknown.
  3. keyid is in that operator's key directory → else operator_unknown.
  4. Signature verifies; required components covered; created within ±60 s clock skew; not expired; tag is rctp; nonce unseen; Content-Digest matches the body.

4. Seller Mandate presentation [4, 3.1, 3.3]

  • Seller agents send their mandate on every seller-side call in the RCTP-Mandate request header, as the SD-JWT VC compact serialisation. The header is covered by the request signature.
  • v0.1 mandates contain no selectively disclosable claims (only the holder and the enforcement point ever see a mandate). A mandate carrying disclosures is rejected.
  • The enforcement point verifies, in order:
    1. JOSE header typ = dc+sd-jwt, alg = ES256.
    2. iss is a trusted issuer; the signature verifies with a key from the issuer's /.well-known/jwt-vc-issuer metadata (jwks or jwks_uri).
    3. vct = mandate.seller.open.1 (other versions are unsupported and rejected).
    4. The payload validates against mandate.seller.open.1.
    5. Key binding: the thumbprint of cnf.jwk equals the request keyid, and agent.operator_id equals the operator record's operator_id. Failure → 401 signature_invalid.
    6. exp is in the future → else 403 mandate_expired.
    7. The Token Status List entry at status.status_list is 0 (VALID) → else 403 mandate_revoked. The reference gateway fetches the list on every signed call (cache lifetime 0 by default) so that revocation fails the very next call (INV-7). HTTP conditional requests keep this cheap.
  • A mandate that fails steps 1–4 is refused with 403 mandate_revoked (see AMBIGUITIES A-3).
  • The proof of possession for the mandate is the RFC 9421 signature by the cnf key over a request that covers rctp-mandate; v0.1 does not add a KB-JWT (AMBIGUITIES A-2).
  • The gateway remembers, per listing in inventory_scope.listing_ids, the most recent verified mandate (highest iat). Inbound buyer offers for that listing are evaluated against it, re-checking exp and status on every offer.

5. Decisions [9, 12]

Mandate-Decision is an RFC 9651 Item: a Token with parameters.

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 or 409 forbid;action="offer.accept";reason=below_floor;counter=430.0 error object
budget exhausted 429 forbid;reason=offer_budget_exhausted;retry_after=3600 error object
  • counter is an sf-decimal and is present only when the mandate sets a preset_counter for the listing that is strictly above the floor (a counter equal to the floor would state the floor, INV-1).
  • Protocol errors that are not decisions (malformed JSON, schema violations, unknown routes) use 400/404 with {"error":"invalid_request"|"not_found"|"unsupported_version", "message": "…"} and no Mandate-Decision header.
  • Check order is exactly section 9's: signature and operator → mandate current → action allowed and in scope → counterparty requirements (always passes in v0.1) → floor and budget → tier.
  • Fixed forbids: deal.sign_contract and account.update are forbid whatever the mandate says (INV-3). Any action not listed in the mandate is forbid.
  • Offer budget: counted per buyer operator per listing over window_hours. Once below_floor_max below-floor offers have been counted, every offer from that operator on that listing gets 429 until the window frees a slot (retry_after = seconds until the oldest counted offer leaves the window). Answering 429 regardless of amount stops an exhausted buyer from probing the floor with 200-vs-429 (AMBIGUITIES A-5).
  • Scope: listing.create requires the listing category to be in inventory_scope.categories and not in excludes. Actions on an existing listing require its ID in inventory_scope.listing_ids (when present), its category not excluded, and a price_floor entry for it (offer actions only). Otherwise not_in_scope.
  • Tier inputs: tiers, floors and budgets come only from the verified mandate. Request bodies are validated against closed schemas (additionalProperties: false), so a body field such as price_floor or tier is a 400, never an override (INV-5).

5.1 Which actions each operation evaluates

Operation Caller Action evaluated Floor check
POST /listings seller listing.create —
POST /listings/{id}/answers seller listing.answer_buyer —
POST /offers buyer inbound offer against the listing's mandate (recorded as offer.accept, as in draft A.4) yes, plus budget
POST /offers/{id}/counter seller offer.counter counter ≥ floor
POST /offers/{id}/counter buyer inbound offer at the new amount yes, plus budget
POST /offers/{id}/accept seller offer.accept offer amount ≥ floor, whatever the tier (INV-1, V1)
POST /offers/{id}/decline either — (always allowed for the parties) —

In v0.1 only the seller side accepts. A buyer who agrees to a seller's counter re-offers at that amount. For an inbound offer with no registered mandate for the listing there is no seller agent: the offer is delivered to the marketplace as a normal offer (decision auto).

6. Approvals at level hosted_embedded [9.4]

6.1 Request

The 202 body carries approval_request (schema approval-request), which adds authorization_details to the draft's fields: an RFC 9396 array with one object of type dev.rctp.approval holding approval_id, action, listing_hash, amount, counterparty, display_digests (per language, sha-256:<hex> of the UTF-8 display text), expires_at, allowed_levels and a random nonce.

challenge = "b64url:" + base64url(SHA-256(JCS(authorization_details))) (JCS = RFC 8785). Anyone holding the request can recompute it; the WebAuthn challenge bytes are the SHA-256 output.

hosted_url = <gateway public URL>/approve/<approval_id>. The page shows display in the seller's language, the amount and the counterparty bands, and asks for the seller's passkey with userVerification: "required".

6.2 Response

POST /approvals/{id}/response (approval page only, not agent-signed) with approval_response:

  • assertion = "webauthn:" + base64url(JSON of the PublicKeyCredential, WebAuthn Level 3 toJSON form).
  • displayed_text = sha-256:<hex> of the exact text shown; it MUST equal one of display_digests.

The gateway verifies, else refuses (and records the failure):

Check Refusal
Approval exists, is pending and is answered for the first time approval_invalid
Not past expires_at approval_invalid
level is in the mandate's approval_surfaces and supported by the gateway (hosted_embedded) approval_surface_not_allowed
Credential is a passkey registered for this seller (issuer + accountable_human.party_ref); assertion verifies over this challenge with user verification approval_invalid (INV-4)
displayed_text matches a display digest approval_invalid

decision: "decline" needs the same signature. On approve, the gateway re-checks the mandate (current, not revoked) and the floor, then executes the pending action exactly as it was asked.

6.3 Timeout

No valid response by expires_at is a decline (on_timeout=deny, INV-2): the approval becomes expired with reason approval_timeout, a pending offer becomes expired, and the operator receives approval.decided.

6.4 Outcome delivery

Polling: GET /approvals/{id} (signed by the operator that triggered it) returns {"approval_id","status":"pending|approved|declined|expired|failed","reason"?,"level"?,"decided_at"?,"result"?}, where result is {"status": <HTTP status>, "body": <JSON>} of the executed action. Webhook: approval.decided (section 9).

6.5 Seller passkey enrolment (reference implementation, not RCTP)

"The seller's passkey with the marketplace" must be registered somewhere. The reference gateway accepts it through GET /enroll?ticket=…, POST /enroll/options and POST /enroll/complete, authorised by an enrolment ticket: a JWT from a trusted issuer with typ rctp-enrollment+jwt, sub = the seller's party_ref, aud = the gateway ID, exp ≤ 10 minutes and a single-use jti. Production platforms use their own login instead.

7. Accepted offers, checkouts and deals [9.1, 10.1]

  • When an accept completes (directly under auto, or after an approved ask), the offer becomes accepted and gains checkout: a compact JWS signed by the gateway (typ rctp-checkout+jwt, kid in the gateway's directory) with claims iss, offer_id, listing_id, listing_hash, price, buyer_jkt (thumbprint of the buyer agent key that made the offer), nonce, iat, exp (default 30 minutes).
  • Accepting an already-accepted offer fails with 409 offer_replayed; no second checkout is created.
  • The buyer redeems it with POST /deals {"checkout": "<jws>"}, signed by the key whose thumbprint is buyer_jkt. First use → 201 {"deal_id", …}. Any later use of the same nonce → 409 offer_replayed (INV-6). After exp → 409 offer_expired. Wrong key → 401 signature_invalid.

8. Checkpoints and the attestation chain [7, 16.2]

  • Starter checkpoints: funds_held, accepted, rejected, released, refunded. Others are 400 in v0.1.
  • Writers post POST /deals/{id}/checkpoints {"event": "<compact JWS>"}. The JWS (typ rctp-checkpoint+jwt) is signed by the same key as the request and carries checkpoint-event.
Checkpoint Who may write it Precondition (else 409 release_condition_unmet)
funds_held a configured holder of funds none
accepted, rejected the buyer agent (buyer_jkt) funds_held, and neither already written
released the holder of funds accepted (release condition buyer_accepted)
refunded the holder of funds rejected
  • The enforcement point writes an entry for every decision, including refusals, into a hash chain per subject (deal thread, listing, or mandate). prev_hash = sha-256:<hex> of SHA-256 over the JCS of the previous entry; the first entry's prev_hash is 64 zeros. Enforcement-point entries carry the gateway's JWS over the entry body.
  • Entries never record a floor (refusals record below_floor and the offered amount only, INV-1) and never carry addresses, phone numbers, identity numbers or personal names (INV-9). Locations are city-level; evidence is digests.
  • GET /deals/{id}/checkpoints returns the deal's checkpoint entries to the deal's parties.

9. Webhooks [9.1, 11]

  • The gateway POSTs JSON {"event_id","type","created_at","data"} to the webhook URL configured for an operator. Types: approval.decided, offer.updated, checkpoint.recorded.
  • Each webhook is signed per RFC 9421 by the gateway: label rctp, tag rctp-webhook, components "@method" "@authority" "@path" "content-digest", with created, expires, keyid, alg, nonce.
  • Receivers MUST verify the signature against the gateway directory at <gateway>/.well-known/http-message-signatures-directory, check Content-Digest, check freshness, and drop duplicate event_ids. The SDKs do this (verifyWebhook / verify_webhook).

10. Discovery and versions

  • GET /.well-known/ucp lists the RCTP capabilities (dev.rctp.listing, dev.rctp.offer, dev.rctp.deal, dev.rctp.escrow), the supported versions, the gateway ID and its key directory.
  • Clients MAY send RCTP-Version: 2026-10-draft3 (an sf-string). An unsupported version is a 400 unsupported_version.

11. Personal data [14, INV-9]

accountable_human stays between issuer, holder agent and enforcement point. The gateway never copies it into a decision, approval, checkpoint, webhook or partner-visible log. Schemas for those objects are closed and contain no field for names, addresses, phone numbers or identity numbers.

12. Endpoints (reference gateway)

Draft 0.3 section 11 names the offer operations. v0.1 needs a few more to be testable end to end; those marked binding are part of this binding (the conformance suite uses them), those marked reference belong to the reference gateway only.

Method and path Caller Decides Status
POST /listings seller listing.create spec 11 (Create listing)
GET /listings/{id} any operator — reference
GET /listings/{id}/questions seller — reference
POST /listings/{id}/questions buyer — reference
POST /listings/{id}/answers seller listing.answer_buyer spec 11 (Answer buyer)
POST /offers buyer inbound offer spec 11
GET /offers?listing_id= seller — reference
GET /offers/{id} either party — spec 11
POST /offers/{id}/counter, /accept, /decline parties offer.counter, offer.accept spec 11
GET /approvals/{id} operator that asked — spec 11
POST /approvals/{id}/response approval page only — spec 11
GET /approve/{id}, /approve/{id}/request, /approve/{id}/options the seller's browser — binding (hosted page and its data)
GET /mandates/current seller mandate current binding (also registers the mandate)
POST /deals buyer (checkout key) single use binding (A-14)
GET /deals/{id} parties — reference
GET /deals/{id}/checkpoints parties — spec 11
POST /deals/{id}/checkpoints holder of funds, buyer release conditions binding (A-14)
POST /deals/{id}/contract seller deal.sign_contract → always 403 binding (A-14, INV-3)
POST /account seller account.update → always 403 binding (A-14, INV-3)
GET /enroll, POST /enroll/options, POST /enroll/complete the seller's browser — reference (6.5)
GET /.well-known/ucp, GET /.well-known/http-message-signatures-directory anyone — binding (10, 9)

Source: spec/v0.1-binding.md