Specification
About the specDraft 0.5v0.3 bindingAmbiguitiesSpec changelogRCTP 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 (
ES256in JOSE,ecdsa-p256-sha256in RFC 9421). - A key directory is a JWK Set served at the operator's
key_directoryURL with media typeapplication/http-message-signatures-directory+json(falls back toapplication/json). - Every key in a directory MUST carry
kidequal to its RFC 7638 JWK SHA-256 thumbprint, base64url without padding. The RFC 9421keyidparameter is that thumbprint. - Private keys never appear in any directory, record, log or example.
2. Operator record [8]
- Served at a stable
record_urlas a compact JWS (application/jose), header{"alg":"ES256","kid":"<thumbprint>","typ":"rctp-operator+jws"}, payload = the record (schemaoperator-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
keyidfails with 401operator_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:
SignatureandSignature-Inputpresent with labelrctp.RCTP-Operatorresolves to an allow-listed, verifiable operator record → elseoperator_unknown.keyidis in that operator's key directory → elseoperator_unknown.- Signature verifies; required components covered;
createdwithin ±60 s clock skew; not expired; tag isrctp; nonce unseen;Content-Digestmatches the body.
4. Seller Mandate presentation [4, 3.1, 3.3]
- Seller agents send their mandate on every seller-side call in the
RCTP-Mandaterequest 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:
- JOSE header
typ=dc+sd-jwt,alg=ES256. issis a trusted issuer; the signature verifies with a key from the issuer's/.well-known/jwt-vc-issuermetadata (jwksorjwks_uri).vct=mandate.seller.open.1(other versions are unsupported and rejected).- The payload validates against
mandate.seller.open.1. - Key binding: the thumbprint of
cnf.jwkequals the requestkeyid, andagent.operator_idequals the operator record'soperator_id. Failure → 401signature_invalid. expis in the future → else 403mandate_expired.- The Token Status List entry at
status.status_listis0(VALID) → else 403mandate_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.
- JOSE header
- 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
cnfkey over a request that coversrctp-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 (highestiat). Inbound buyer offers for that listing are evaluated against it, re-checkingexpand 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 |
counteris an sf-decimal and is present only when the mandate sets apreset_counterfor 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 noMandate-Decisionheader. - 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_contractandaccount.updateareforbidwhatever the mandate says (INV-3). Any action not listed in the mandate isforbid. - Offer budget: counted per buyer operator per listing over
window_hours. Oncebelow_floor_maxbelow-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.createrequires the listingcategoryto be ininventory_scope.categoriesand not inexcludes. Actions on an existing listing require its ID ininventory_scope.listing_ids(when present), its category not excluded, and aprice_floorentry for it (offer actions only). Otherwisenot_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 asprice_floorortieris 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 ofdisplay_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 approvedask), the offer becomesacceptedand gainscheckout: a compact JWS signed by the gateway (typrctp-checkout+jwt,kidin the gateway's directory) with claimsiss,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 isbuyer_jkt. First use → 201{"deal_id", …}. Any later use of the same nonce → 409offer_replayed(INV-6). Afterexp→ 409offer_expired. Wrong key → 401signature_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 (typrctp-checkpoint+jwt) is signed by the same key as the request and carriescheckpoint-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'sprev_hashis 64 zeros. Enforcement-point entries carry the gateway's JWS over the entry body. - Entries never record a floor (refusals record
below_floorand 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}/checkpointsreturns 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, tagrctp-webhook, components"@method""@authority""@path""content-digest", withcreated,expires,keyid,alg,nonce. - Receivers MUST verify the signature against the gateway directory at
<gateway>/.well-known/http-message-signatures-directory, checkContent-Digest, check freshness, and drop duplicateevent_ids. The SDKs do this (verifyWebhook/verify_webhook).
10. Discovery and versions
GET /.well-known/ucplists 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 400unsupported_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