Documentation
OverviewQuickstartConceptsDecisions and errorsApprovalsVersioningSecurityExtensionsFAQPartner integration profileApprovals
Status: Draft 0.4, pre-release. Sources: Draft 0.4 sections 6.1 and 9, binding sections 4.2 and 7,
registries/approval-levels.json, ambiguity B-4.
When a verb's tier is ask, the enforcement point holds the action and asks the seller. The seller sees exactly what
they are agreeing to and answers with their own passkey. A partner's app can show the approval, but only a key the
seller controls can sign it [9].
Approvals are also where the seller sets Listing Terms. listing.create (ask by default) and listing.set_terms
(always ask) open the hosted page's terms form, and the seller's passkey signs a challenge that binds the terms they
confirmed. The seller's agent may propose values (proposed_terms), which only pre-fill that form; the marketplace
stores only what the seller confirms and never returns proposed or stored terms to any agent [6.1] (INV-1, INV-5).
If the seller does not answer in time, the answer is no.
The three levels
| Level | Shown in | Signed with | Allowed when | v0.2 |
|---|---|---|---|---|
marketplace_app |
The marketplace's own app | The seller's marketplace passkey | Any amount | Not yet |
hosted_embedded |
A page hosted by the marketplace, opened inside the partner's app | The seller's marketplace passkey | Any amount; recommended for partners | Yes |
certified_operator |
The partner's own interface | A hardware-backed approval key on the seller's device | Certified operators only, below the grant's cap | Out of scope |
Why only hosted_embedded in v0.2
- The partner app shows the marketplace's page. The marketplace renders what the seller reads and the terms form they fill in, so a partner app cannot show one thing while the seller signs another, and never touches the floor.
- The seller's own passkey signs. It is their marketplace account passkey [9.3(5)]. No partner key can approve anything (INV-4).
- It works for every amount and every verb, including listing approvals with terms, and anything above a certified cap [9.3(3)].
- No partner certification scheme exists yet, so
certified_operatoris out of scope. An answer at a level the request does not allow is refused withapproval_surface_not_allowed. marketplace_appneeds each platform's own app. That lives outside this repository and is not built yet.
How an approval flows
sequenceDiagram
autonumber
participant P as Partner (seller agent and backend)
participant G as Gateway (enforcement point)
participant H as Approval page (hosted by the marketplace, opened in the partner app)
actor S as Seller
P->>G: POST /offers/{id}/accept (UCP-signed, Bearer token)
G->>G: Grant introspected, checks pass, tier is ask
G-->>P: 202, RCTP-Decision: ask with approval_id, expires_in, on_timeout=deny
Note right of P: Body: approval_request with challenge and hosted_url
P->>H: Open hosted_url inside the partner app
H->>G: GET /approve/{id}/request, then POST /approve/{id}/options
alt Seller answers before expires_at
H->>S: Show the exact text and amount
S->>H: Approve or decline with a passkey (user verification)
H->>G: POST /approvals/{id}/response (WebAuthn assertion, displayed_text)
G->>G: Verify, then re-check the grant, the floor and the reservation
G->>G: Run the held action exactly as asked, write the deal record entry
else No answer by expires_at
G->>G: Approval expired (approval_timeout), pending offer expired
end
par Webhook
G--)P: POST approval.decided (UCP signed webhook)
and Polling
P->>G: GET /approvals/{id} (signed)
G-->>P: approved, declined, expired or failed, with the result
end
Listing approvals: the terms form
For listing.create (POST /listings, which names the term_set) and listing.set_terms
(POST /listings/{id}/terms, empty body), the flow adds one step on the hosted page (binding 4.2):
- The 202 body's approval request carries
term_setin its authorization details and no challenge yet. - The page reads
GET /approve/{id}/request, which returns the approval request, the term-set definition and, if the agent proposed terms,prefill. The page builds the form from the term set and pre-fills it; the seller edits and confirms [6.1]. A request carryingUCP-Agentor a signature never getsprefill. - The page posts the seller's values to
POST /approve/{id}/terms(terms-submission). This route is for the page only: it is not agent-signed, and a request carryingUCP-Agentor a signature is refusedaction_forbidden. The gateway checks the term set's constraints (422terms_invalid, for example a floor above the list price), draws a 128-bit salt, and returnsterms_commitmentwith the WebAuthn options. - Only now is the challenge computed (B-4): over authorization details that include
terms_commitment. The seller approves with their passkey, and the response carries the sameterms_commitment. - The gateway stores exactly the terms behind that commitment [6.1(3)]. The agent learns the outcome from
GET /approvals/{id}or the webhook: the listing, itsterm_setandterms_commitment, never the values [6.1(4)].
The approval request
The 202 body carries approval_request (schema):
| Field | Meaning |
|---|---|
approval_id |
apr_…, also in the RCTP-Decision header |
action |
The verb held for approval, such as offer.accept |
listing_hash |
Binds the approval to the exact listing |
amount |
Value and currency, when the verb has an amount |
counterparty |
The counterparty's disclosed bands. Empty in v0.2, which has no Party Credentials yet |
display |
The exact text to show, per language. Never contains private terms [9.2(2)] |
expires_at |
After this, no answer is a decline |
challenge |
b64url: and 43 base64url characters: the one-time challenge. Absent for listing approvals until the terms are submitted (B-4) |
allowed_levels |
The levels allowed, such as ["hosted_embedded"] |
hosted_url |
<gateway>/approve/<approval_id>, the page to open |
authorization_details |
The RFC 9396 object the challenge is computed from |
The challenge binding
The challenge commits to everything the seller is agreeing to [9.2(1)] (binding 7):
- The gateway builds one RFC 9396 authorization details object of type
dev.rctp.approval. It holdsapproval_id,action,listing_hash,amount,counterparty,display_digests(per language,sha-256:<hex>of the UTF-8 display text),expires_at,allowed_levelsand a randomnonce; for listing approvals alsoterm_setandterms_commitment. - It canonicalises the array with JCS (RFC 8785).
- It hashes the result with SHA-256.
challenge="b64url:" + base64url(SHA-256(JCS(authorization_details))).
The WebAuthn challenge bytes are the 32-byte SHA-256 output. Anyone holding the request can recompute the challenge with a JCS library and a SHA-256 function, and so check that the page asks the seller to sign exactly this request. Never hand-write the canonicalisation or the hash (INV-8).
The approval response
The approval page posts approval_response to POST /approvals/{id}/response
(schema). This endpoint is for the approval page only; agents do not call
it.
| Field | Meaning |
|---|---|
approval_id |
The approval being answered |
decision |
approve or decline. A decline needs the same signature as an approval |
level |
The level used: hosted_embedded in v0.2 |
assertion |
"webauthn:" + base64url of the WebAuthn Level 3 PublicKeyCredential JSON |
displayed_text |
sha-256:<hex> of the exact text the seller saw. It must equal one of the display_digests |
terms_commitment |
Required for listing approvals; must equal the commitment returned by POST /approve/{id}/terms |
What the gateway checks
Before acting, the gateway checks [9.3(1)]:
| # | Check | Refusal |
|---|---|---|
| 1 | The approval exists, is pending, and this is its first answer | approval_invalid |
| 2 | It is not past expires_at |
approval_invalid |
| 3 | The level is in the request's allowed_levels and the gateway supports it (hosted_embedded) |
approval_surface_not_allowed |
| 4 | For a listing approval, terms_commitment matches the submitted terms; otherwise it is absent |
approval_invalid |
| 5 | displayed_text matches one of the display digests |
approval_invalid |
| 6 | The credential is a passkey registered for this seller (their pairwise pseudonym, B-9), and the assertion verifies over this challenge with user verification | approval_invalid (INV-4) |
A refused answer leaves the approval pending, so the real seller can still answer. An operator key, a partner key or a replayed answer can never approve anything.
On approve, the gateway re-checks the grant by introspection (it must not be revoked or expired; a lapsed access
token alone does not stop the action, B-5), the floor and the reservation, then runs the held action exactly as it was
asked. If a re-check fails, the action does not run: the approval ends failed with that reason, for example
grant_revoked, and a held offer becomes refused (INV-7).
The approval, its level, the time, the displayed-text digest and any terms_commitment are written to the deal record
[9.3(4)].
Timeout means decline
No valid answer by expires_at is a decline [9.3(2)] (INV-2):
- The approval becomes
expiredwith reasonapproval_timeout. - A pending offer becomes
expired. Anaskon an offer never outlives the offer's ownexpires_at. - The operator receives an
approval.decidedwebhook. - A late answer, even from the seller's own passkey, is refused with
approval_invalid.
on_timeout can only ever be deny. The schemas allow no other value, and a test checks it
(INV-2: on_timeout can only ever be deny). The reference gateway's window is ask_timeout_seconds in its
configuration (default 900).
Learning the outcome
Webhook: approval.decided (recommended)
The gateway POSTs a UCP signed webhook (binding 9) to the config.webhook_url of the dev.rctp.deal entry in your
operator profile. The body is {"event_id", "type", "created_at", "data"}
(schema); for approval.decided, data is the approval status.
import { fetchGatewayProfile, verifyWebhook } from '@rctp/sdk';
const profile = await fetchGatewayProfile(gatewayUrl); // <gateway>/.well-known/ucp
const seen = new Set<string>(); // use a shared store when you run several workers
// In your HTTP handler, with the raw body bytes exactly as received:
const event = await verifyWebhook(
{ method, url, headers, body: rawBody },
{ profile, profileUrl: `${gatewayUrl}/.well-known/ucp`, seen },
);
if (event.type === 'approval.decided') handleApproval(event.data);
verifyWebhook (Python: verify_webhook) checks UCP-Agent, the sig1 signature against the gateway profile's
keys[], Content-Digest, a Webhook-Timestamp no more than 300 s old, and drops a repeated Webhook-Id. Never
apply an outcome from a webhook that did not verify (conformance vector S4).
Polling: GET /approvals/{id}
Signed by the operator whose agent triggered the ask. It returns the approval status (schema):
| Field | Meaning |
|---|---|
status |
pending, approved, declined, expired or failed |
reason |
For example approval_timeout on expired, or grant_revoked on failed |
level, decided_at |
How and when it was answered |
offer_id, listing_id |
What the approval was about |
term_set, terms_commitment |
For listing approvals: which term set and the commitment, never the values |
result |
{"status": <HTTP status>, "body": <JSON>} of the executed action, for example the accepted offer with its checkout |
In the TypeScript SDK, rctp.approvals.wait(id) polls until the status leaves pending (every second, for up to 20
minutes by default) and returns expired when nobody answers; check it with approvalGranted. rctp.approvals.get(id)
reads it once.
Opening the page in your app
rctp.approvals.hostedUrl(approval) returns the page to open. The passkey belongs to the marketplace's domain, so
open the page where passkeys work for that domain, such as an in-app browser tab. Check your platform's WebAuthn
support before you rely on an embedded web view.
Seller passkey enrolment (reference gateway only)
How the seller's marketplace passkey gets registered is the marketplace's concern [9.3(5)]. The reference gateway
accepts it on its /enroll page, authorised by a short-lived, single-use enrolment ticket (typ
rctp-enrollment+jwt) signed by the marketplace authorization server. In the sandbox, the seller's account screen
(http://localhost:4000/account) mints one. This is not part of RCTP. Production platforms use their own login.
Where it is implemented and tested
| What | Where |
|---|---|
| Approval service | packages/gateway-go/approval (WebAuthn through go-webauthn) |
| Hosted page and terms route | packages/gateway-go/gateway/pages.go |
| SDKs | approvals.hostedUrl, get, wait; verifyWebhook in packages/sdk-ts; verify_webhook in packages/sdk-py |
| Tests | TestINV2_V2_AskThatExpiresIsADecline, TestINV4_V9_OnlyTheSellersPasskeyApproves (gateway/invariants_test.go); TestListingCreateSetsTermsOnTheHostedPage, TestListingApprovalBindsTheSubmittedTerms, TestINV5_OnlyTheSellerOnTheHostedPageSetsTerms, TestTermsThatBreakTheTermSetAreRefused (gateway/terms_test.go); TestINV7_RevocationBetweenAskAndApprovalStopsTheAction (gateway/grant_test.go); TestApprovedAskIsRecheckedBeforeItRuns (gateway/offers_test.go) |
| Conformance vectors | V2 (an ask with no reply lapses), V9 (an operator-signed approval fails), V10 (terms on the hosted page), S4 (a bad webhook is rejected) |
Source: docs/approvals.md