carousell/rctp

Quickstart

Status: Draft 0.4 (v0.2 binding), pre-release. The sandbox and every key in it are TEST ONLY.

In about 15 minutes you will start a local sandbox, make your first signed call as a buyer agent, connect a seller with an Agent Grant, list an item whose terms the seller sets, and see your first approval go through with the seller's own passkey.

1. Before you start

You need Version
Node.js and pnpm Node 22.23.4+, 24.22.1+ or 26.12.1+; pnpm 10
Docker with Compose Any recent version
Python and uv (only for the Python SDK) Python 3.12+

Clone the repository, install the workspace and build the SDK once:

git clone https://github.com/carousell/rctp && cd rctp
pnpm install && pnpm --filter @rctp/sdk build

2. Install the SDK in your own project

Not on npm or PyPI yet. v0.2.0-draft.4 is a dry-run release: CI checks that publishing would work, but nothing is published until the editors cut the first public draft. Until then, use the workspace packages (packages/sdk-ts, packages/sdk-py) from your clone, for example npm install <path-to-rctp>/packages/sdk-ts after pnpm --filter @rctp/sdk build, or uv add --editable <path-to-rctp>/packages/sdk-py.

Once published, TypeScript will be under the npm dist-tag draft:

npm install @rctp/sdk@draft

and Python as 0.2.0a4, the PEP 440 form of v0.2.0-draft.4 (a pre-release):

uv add rctp            # or: pip install rctp --pre

The examples on this page use the TypeScript SDK. The Python SDK has the same surface (seller connect, signed requests, typed decisions, listings, offers, deals, webhook verification); see packages/sdk-py.

3. Start the sandbox

docker compose -f sandbox/compose.yaml up --build --wait

The first build takes about 5 minutes; --wait returns once every service is healthy and the seed listings exist. You now have:

Service URL What it does
Reference gateway (enforcement point) http://localhost:8080 Checks every signed call. Hosts the approval page at /approve/{id} and passkey enrolment at /enroll. Its UCP profile is at /.well-known/ucp.
Authorization server http://localhost:4000 The marketplace's OAuth server: Agent Grants, the consent page, the seller's account screen (/account). Sandbox helpers: GET /sandbox/grants, POST /sandbox/grants/{id}/revoke, POST /sandbox/enrollment-ticket.
Sandbox operators http://localhost:8090 UCP profiles of five test operators: partner (seller agent), demo-buyer, funds-holder, and carrier and relay (who write parcel events). Their TEST ONLY keys are at /sandbox/keys/{name}, the webhooks they received at /sandbox/webhooks/{name}. Also the mock holder of funds (POST /funds-holder/holds) and the seed listings (GET /sandbox/listings).
Mock marketplace http://127.0.0.1:8081 The marketplace API behind the gateway. Agents only reach it through the gateway; it is published on loopback only so the seller can act in the marketplace app (e.g. withdraw a listing).
Test-mode signer http://localhost:8091 A virtual passkey for automation. Answers approvals (and fills in a listing's terms) the way the hosted page would.

The sandbox seeds three listings through the real protocol: a seller agent proposed them and Test Seller One set their terms and approved. Their IDs change with every fresh sandbox:

curl -s localhost:8090/sandbox/listings

This page uses the iPhone 13 (480 SGD; its seller set floor 420). Every detail of the sandbox is in sandbox/README.md. To start over: docker compose -f sandbox/compose.yaml down -v.

4. Your first signed call

Every call to the gateway is signed with an agent key (RFC 9421) and names your operator's UCP profile in the UCP-Agent header; the key must be in that profile's keys[]. The gateway only accepts operators it has onboarded. The sandbox has onboarded three test operators, so you sign as one of them.

Save this as first-call.ts in a project where @rctp/sdk is installed, and run it with npx tsx first-call.ts:

import { agentKeyFromJwk, generateAgentKey, match, RctpClient, type DealTerms, type Offer, type Outcome } from '@rctp/sdk';

const gatewayUrl = 'http://localhost:8080';
const seeded: Array<{ listing_id: string; title: string }> = await (
  await fetch('http://localhost:8090/sandbox/listings')
).json();
const listing = seeded.find((l) => l.title.startsWith('iPhone'))!.listing_id;
// Deal Terms: a price, and a fulfillment option and payment term from the listing's public terms.
const terms = (price: number): DealTerms => ({
  price: { value: price, currency: 'SGD' },
  fulfillment: { method_type: 'shipping', selected_option_id: 'std' },
  payment: { selected_term_id: 'escrow' },
});

function report(what: string, outcome: Outcome<Offer>) {
  match(outcome, {
    auto: ({ status, data }) => console.log(`${what}: auto ${status}, offer ${data.id} is ${data.status}`),
    ask: ({ approval }) => console.log(`${what}: ask, waiting for the seller until ${approval.expires_at}`),
    forbid: ({ status, reason }) => console.log(`${what}: forbid ${status} ${reason}`),
  });
}

// 1. A brand-new agent key: ES256, kid = RFC 7638 thumbprint. It is not in the demo buyer's profile,
//    so the gateway refuses it with 401 operator_unknown.
const stranger = new RctpClient({
  gatewayUrl,
  key: await generateAgentKey(),
  profileUrl: 'https://demo-buyer.sandbox.example/.well-known/ucp',
});
report('my own key', await stranger.offers.create({ listing_id: listing, deal_terms: terms(440) }));

// 2. The sandbox demo buyer (TEST ONLY key), whose profile the gateway onboarded.
const demo = await (await fetch('http://localhost:8090/sandbox/keys/demo-buyer')).json();
const buyer = new RctpClient({ gatewayUrl, key: await agentKeyFromJwk(demo.private_jwk), profileUrl: demo.profile_url });
report('offer 360', await buyer.offers.create({ listing_id: listing, deal_terms: terms(360) }));
const fair = await buyer.offers.create({ listing_id: listing, deal_terms: terms(440) });
report('offer 440', fair);

What you should see:

Call Result
My own key forbid 401 operator_unknown
Offer 360 forbid 403 below_floor, header RCTP-Decision: forbid;action="offer.create";reason=below_floor. No private value is sent: never the floor (INV-1).
Offer 440 auto 201: delivered, in a new negotiation (neg_…), with an expiry the gateway set

match makes you handle all three decisions. The data of an auto is only reachable inside the auto branch, so an ask or a forbid cannot be mistaken for success. Each below-floor offer spends offer budget: after three on one listing in a day, every offer from that agent there gets 429 offer_budget_exhausted.

In production you do not borrow a key. You generate your own, publish it in your UCP profile (operatorProfile, with dev.rctp.operator and your webhook URL), and the marketplace onboards your profile URL in its gateway.

5. Connect a seller

A seller agent needs an Agent Grant. The seller grants it on the marketplace's consent page, which shows your agent, your operator, each verb with its tier and the expiry, and asks for no prices: the seller sets prices per listing, later. The SDK discovers the authorization server from the gateway and runs OAuth 2.0 with PKCE and RFC 9396 authorization details.

The snippets below continue the script from step 4. Add SellerConnect to its @rctp/sdk import, and add import { createServer } from 'node:http'; at the top.

const partner = await (await fetch('http://localhost:8090/sandbox/keys/partner')).json(); // TEST ONLY
const partnerKey = await agentKeyFromJwk(partner.private_jwk);

const connect = await SellerConnect.discover(gatewayUrl, {
  clientId: partner.profile_url, // the sandbox registers operators by profile URL
  redirectUri: 'http://127.0.0.1:3000/callback',
  allowInsecureRequests: true, // sandbox only: plain http
});
const pending = await connect.authorizationUrl({
  agentId: partner.agent_id,
  key: partnerKey, // the grant is bound to this key's thumbprint
  tiers: {
    'dev.rctp.listing:create': 'ask', // always ask
    'dev.rctp.deal:offer_counter': 'auto',
    'dev.rctp.deal:offer_accept': 'ask',
  },
});
console.log('Open:', pending.url.href);
// Sign in as Test Seller One (who owns the seed listings), review each verb and tier, and click Allow.
const callbackUrl = await new Promise<string>((resolve) => {
  const srv = createServer((req, res) => {
    res.end('Connected. You can close this tab.');
    srv.close();
    resolve(`http://127.0.0.1:3000${req.url}`);
  }).listen(3000, '127.0.0.1');
});
const grant = await connect.exchange(callbackUrl, pending); // grant.details.tiers: what the seller confirmed

const seller = new RctpClient({
  gatewayUrl,
  key: partnerKey,
  profileUrl: partner.profile_url,
  grant, // sent as Authorization: Bearer, covered by the signature; introspected on every call
  refreshGrant: (g) => connect.refresh(g), // refresh tokens rotate: persist each new grant (onGrant)
});

The seller may lower a tier on the consent page; deal.sign_contract and account.update have no scope at all and are always forbid (INV-3). Revoke the grant on the seller's account screen (http://localhost:4000/account) and the very next signed call fails with grant_revoked (INV-7).

6. Your first approvals

6.1 Register the seller's passkey (once)

On the account screen, http://localhost:4000/account, sign in as Test Seller One and click Register approval passkey, or:

curl -s -X POST http://localhost:4000/sandbox/enrollment-ticket \
  -H 'content-type: application/json' -d '{"grant_id":"<grt_… from grant.details.grant_id>"}'

Open the returned enroll_url (http://localhost:8080/enroll?ticket=…) in a browser and create a passkey. Use localhost, not 127.0.0.1: the passkey is bound to the localhost relying party ID. Enrolment tickets are a feature of the reference gateway, not of RCTP; production platforms use their own login (binding 7).

6.2 List an item: the seller sets its terms

const listed = await seller.listings.create({
  title: 'Pixel 7 128GB Obsidian',
  category: 'mobile-phones',
  condition: 'good',
  price: { value: 390, currency: 'SGD' },
  term_set: 'goods.small/1', // the agent names the term set; it never sends terms
});
if (listed.decision === 'ask') {
  console.log('Open in a browser:', seller.approvals.hostedUrl(listed.approval)); // http://localhost:8080/approve/apr_…
  const status = await seller.approvals.wait(listed.approval.approval_id);
  console.log('listing', status.status, status.listing_id); // the agent can negotiate on it now
}

The hosted page shows an empty terms form built from the term set: floor, available until, fulfillment and payment options, release condition. The seller fills it in and approves with the passkey. The gateway keeps exactly those terms; the listing shows only its public terms and a terms_commitment, and no agent ever sees the floor.

6.3 Accept an offer

Accept the 440 offer from step 4 (offer.accept is ask in this grant):

const offerId = fair.decision === 'auto' ? fair.data.id : '';
const outcome = await seller.offers.accept(offerId);
await match(outcome, {
  auto: ({ data }) => console.log('accepted; single-use checkout for the buyer:', data.checkout),
  ask: async ({ approval }) => {
    console.log('Open in a browser:', seller.approvals.hostedUrl(approval));
    const result = await seller.approvals.wait(approval.approval_id); // 'expired' if nobody answers
    console.log('approval', result.status, result.reason ?? '');
  },
  forbid: ({ reason }) => console.log('refused:', reason),
});

Open the printed URL. The page shows the exact action, amount and text the seller is approving. Approve with the passkey. wait resolves approved; the offer now carries a gateway-signed, single-use checkout and the listing is reserved, so accepting any other offer on it is 409 listing_reserved. If nobody answers before expires_at, it resolves expired: no answer means no (INV-2). The buyer redeems the checkout with buyer.deals.redeem(checkout).

6.4 Automation: the test-mode signer

For CI and headless runs, the test-mode signer holds a virtual passkey and answers through the gateway's public approval endpoints, exactly as the hosted page would. It is TEST ONLY. seller is the grant's pairwise pseudonym (sub in GET localhost:4000/sandbox/grants, also returned by /sandbox/enrollment-ticket).

# Register the seller's virtual passkey with a ticket from the authorization server (see 6.1)
curl -s -X POST http://localhost:8091/enroll -H 'content-type: application/json' \
  -d '{"gateway_url":"http://localhost:8080","seller":"pp:…","ticket":"<ticket>"}'

# Answer an approval; for a listing approval, "terms" is what the seller types into the form
curl -s -X POST http://localhost:8091/approve -H 'content-type: application/json' \
  -d '{"gateway_url":"http://localhost:8080","seller":"pp:…","approval_id":"apr_…","decision":"approve",
       "terms":{"floor":{"value":340,"currency":"SGD"},
                "available_until":"2026-12-31T00:00:00Z","fulfillment_options":[{"method_type":"shipping","option_ids":["std"]}],
                "payment_terms":["escrow"],"release_condition":"buyer_accepted","deliver_within_days":5}}'

gateway_url is the gateway's public URL; the signer rewrites it to the compose network (GATEWAY_REWRITES in sandbox/compose.yaml). Set "signer":"operator" to see an operator-signed approval refused with approval_invalid (INV-4).

7. Run the whole flow

pnpm quickstart                    # headless: the test-mode signer plays the seller
pnpm quickstart -- --interactive   # you consent, set the terms and approve in your browser with your own passkey

It runs connect → list with terms → an offer below the floor → an offer with Deal Terms → counter → re-offer with supersedes → accept (listing reserved) → POST /deals → funds_held → accepted → released, and verifies every webhook the operators received with verifyWebhook. The step-by-step guide is examples/partner-quickstart/README.md.

Next

Source: docs/quickstart.md