carousell/rctp

Approvals

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_operator is out of scope. An answer at a level the request does not allow is refused with approval_surface_not_allowed.
  • marketplace_app needs 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):

  1. The 202 body's approval request carries term_set in its authorization details and no challenge yet.
  2. 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 carrying UCP-Agent or a signature never gets prefill.
  3. 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 carrying UCP-Agent or a signature is refused action_forbidden. The gateway checks the term set's constraints (422 terms_invalid, for example a floor above the list price), draws a 128-bit salt, and returns terms_commitment with the WebAuthn options.
  4. 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 same terms_commitment.
  5. 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, its term_set and terms_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):

  1. The gateway builds one RFC 9396 authorization details object of type dev.rctp.approval. It holds 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; for listing approvals also term_set and terms_commitment.
  2. It canonicalises the array with JCS (RFC 8785).
  3. It hashes the result with SHA-256.
  4. 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 expired with reason approval_timeout.
  • A pending offer becomes expired. An ask on an offer never outlives the offer's own expires_at.
  • The operator receives an approval.decided webhook.
  • 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

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