Developers

One public API for the web app, the SDKs, the MCP server, Integrators and Makers.

Quickstarts

TypeScript SDK

Start the API sandbox (apps/api/README.md, "Sandbox"): a local chain, the contracts, test tokens and the API, with nothing real in it.

shell
docker compose up -d --wait
(cd contracts/evm && forge build)
pnpm install && pnpm build
pnpm --filter @rwa-router/api sandbox

Then make the Attestation, ask for a Route preview, sign the Order it proposes and watch it fill. This is examples/quickstart.ts; the tests run it as it stands here.

ts
import { StreamClient, createClient, signAttestation, signOrder, toEvmDomain, toEvmOrder, type Order } from '@rwa-router/sdk';
import { keccak256, toHex } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';

// The API sandbox. Its first funded test wallet has a publicly known key: never use it anywhere else.
const apiUrl = process.env.RWA_API_URL ?? 'http://127.0.0.1:3000';
const account = privateKeyToAccount(keccak256(toHex('rwa-router sandbox: wallet-1')));
const client = createClient({ baseUrl: apiUrl });

// 1. The Attestation, once per wallet.
await client.submitAttestation(await signAttestation(account, account.address));

// 2. A Route preview for selling 0.5 GOLD: every candidate Source, and the Order to sign.
const gold = await client.getAsset({ id: 'sandbox-gold' });
const market = gold.markets[0]!;
const quote = await client.quote({
  chainId: gold.chainId,
  owner: account.address,
  sellToken: market.assetToken,
  buyToken: market.paymentToken,
  sellAmount: 5n * 10n ** 17n, // base units, always bigint
});
if (!quote.order || !quote.typedData) throw new Error('no Source can fill this Order now');
for (const c of quote.candidates) console.log(`${c.source}: ${c.amountOut} after ${c.platformFee} Platform Fee, in ${c.settlementSeconds} s`);

// 3. Sign the Order. The signature authorises exactly this limit price, expiry and maximum fee.
const order = toEvmOrder(quote.order);
const signature = await signOrder(account, order, toEvmDomain(quote.typedData.domain));

// 4. Watch the wallet's Orders, then submit.
const stream = new StreamClient(`${apiUrl.replace(/^http/, 'ws')}/v1/stream`);
let done!: (order: Order) => void;
const filled = new Promise<Order>((resolve) => (done = resolve));
await stream.subscribe(`orders:${account.address}`, (update) => {
  console.log(`Order ${update.hash}: ${update.status}`);
  if (update.hash === quote.orderHash && update.status === 'filled') done(update);
});
await client.submitOrder({ chainId: quote.chainId, order, signature });

// 5. Filled: the Settlement transaction is confirmed and the tokens moved wallet to wallet.
export const result = await filled;
console.log(`sold ${result.filledSellAmount}, received ${result.filledBuyAmount} from ${result.fills.map((f) => f.source).join(', ')}`);
await stream.close();

All amounts are base-unit integers as bigint, in what you send and in what you get back. The API carries them as decimal strings; the client converts both ways, and a JSON number never stands for an amount.

From packages/sdk-ts/README.md.

Python SDK

A Maker supplies liquidity two ways: it answers Quote Requests with Firm Quotes, and it rests Orders on the Book. examples/maker_bot.py does both for one market, priced off one reference price and one spread.

1.Start the API sandbox (a local chain, the contracts, test tokens, funded test wallets and the API; see apps/api/README.md, "Sandbox"). From the repo root:

shell
docker compose up -d --wait
(cd contracts/evm && forge build)
pnpm install && pnpm build
pnpm --filter @rwa-router/api sandbox

It prints the API URL, the settlement address, the admin key and three funded wallets with their test keys.

2.Register the Maker. In the sandbox the admin key is sandbox-admin-key; on a real deployment the platform does this step and hands you the API key. The wallet must hold the tokens it quotes and have approved Settlement for them (the sandbox wallets have). Settlement is the only contract a wallet approves, as Maker or as Taker and whichever Source fills the Order: never DexExecutor.

python
from rwa_router import Client

admin = Client("http://127.0.0.1:3000", "sandbox-admin-key")
maker = admin.create_maker("my-maker", "My Maker", "<wallet 2 address>", approved=True, markets=["sandbox-bond-usd"])
print(maker.api_key)  # shown once

3.Run the bot, from packages/sdk-py. The amounts are integers in base units (the sandbox tokens have 18 decimals): a reference of 100 USD per BOND, 20 bps either side, 5 BOND resting on each side. BOND is a Permissioned Asset: wallets 1 and 2 hold Approval for it.

shell
MAKER_API_KEY=rwa_... MAKER_PRIVATE_KEY=<wallet 2 private key> uv run python examples/maker_bot.py \
  --api-url http://127.0.0.1:3000 --settlement <contracts.settlement> --market sandbox-bond-usd \
  --reference 100000000000000000000 --spread-bps 20 --size 5000000000000000000

It makes the Attestation, rests a bid at 99.80 and an ask at 100.20 (GET /v1/markets/sandbox-bond-usd/book shows them), logs in on WS /v1/maker, publishes its indicative capacity (below) and answers each Quote Request for its market with a Firm Quote at those prices, or declines when its price does not reach the Taker's limit after fees. Ctrl-C cancels the resting Orders. An Order of wallet 1 selling BOND with a limit under 99.80 is then filled by the bot: from its resting bid first, from its Firm Quote for the rest (tests/test_sandbox.py does exactly this).

The same in your own code:

python
from eth_account import Account
from rwa_router import Client, MakerClient, build_firm_quote, build_order, sign_order

account = Account.from_key(private_key)
client = Client(api_url, api_key)

# Rest an Order on the Book. A Maker's Order carries no fee (maxFeeBps 0): Makers pay nothing.
order = build_order(owner=account.address, sell_token=gold, buy_token=usd, sell_amount=10**18, buy_amount=4008 * 10**18)
stored = client.submit_order(chain_id, order, sign_order(account, order, chain_id, settlement))

# Answer Quote Requests. The request's tokens and amounts are the Taker's.
with MakerClient(maker_url, api_key, account) as maker:
    for request in maker:
        gives = my_price(request)  # int: what you give for the Taker's whole sell_amount, before the Taker's fees
        if gives is None:
            maker.decline(request.id)
        else:
            quote = build_firm_quote(request, owner=account.address, sell_amount=gives)
            maker.firm_quote(request.id, quote, sign_order(account, quote, request.chain_id, settlement))

client.maker_stats()  # rolling 24 hour fill rate; under 95% the Maker is suspended
client.maker_quotes()  # the Quote Requests you were sent, newest first, with your own answer and whether it won and settled

Indicative capacity

A Maker may say how much it is prepared to quote on a market, per side, and until when. The figure is indicative, not binding: a Firm Quote is the only commitment, and nothing is owed on a figure. Its use is that the hub does not send a Quote Request to a Maker whose figure for that market and side is below the request's size while the figure is in force; a Maker that published nothing is asked. A size of 0 says it has no room until valid_until. side is the Maker's own side of the Asset: buy answers Takers who sell it, sell answers Takers who buy it.

python
client.set_maker_capacity(
    "sandbox-bond-usd", "buy", 50 * 10**18, "2030-01-01T00:00:00.000Z"
)  # valid_until: ISO 8601 UTC, in the future
client.maker_capacity()  # your own figures still in force, never another Maker's
maker.set_capacity(
    "sandbox-bond-usd", "sell", 50 * 10**18, valid_until
)  # the same over the Maker socket; the hub answers capacity_set
client.get_market_capacity(
    "sandbox-bond-usd"
).indicative_capacity  # public: .buy and .sell, summed over the Makers in force

The same sum is Market.indicative_capacity and Quote.indicative_capacity. examples/maker_bot.py publishes ten times its --size on both sides for two minutes and again every minute, a figure it states and does not guarantee.

A Firm Quote is binding until its expiry (build_firm_quote sets 60 seconds; the hub takes 15 to 300). It must be answered within the request's deadline_ms (500 ms). A Firm Quote that fails to settle because of the Maker counts against its fill rate; a decline, a late or a refused answer does not.

From packages/sdk-py/README.md.

MCP server

Build first, from the repo root: pnpm install && pnpm build. The server needs an RWA Router API to talk to; for a local one with nothing real in it, start the sandbox (apps/api/README.md, "Sandbox").

stdio (local)

The client starts the server as a child process.

Claude Desktop (claude_desktop_config.json; Settings, Developer, Edit Config):

json
{
  "mcpServers": {
    "rwa-router": {
      "command": "node",
      "args": ["/absolute/path/to/rwa-router/apps/mcp/dist/main.js"],
      "env": {
        "RWA_API_URL": "http://127.0.0.1:3000",
        "RWA_API_KEY": "rwa_...",
        "MAX_ORDER_SIZE": "{\"0x<payment token>\":\"10000000000\",\"0x<asset token>\":\"5000000000000000000\"}"
      }
    }
  }
}

Claude Code:

shell
claude mcp add rwa-router \
  --env RWA_API_URL=http://127.0.0.1:3000 \
  -- node /absolute/path/to/rwa-router/apps/mcp/dist/main.js

Any other client (Cursor's mcp.json, VS Code, an agent framework): the command node /absolute/path/to/rwa-router/apps/mcp/dist/main.js with the environment below, over stdio.

RWA_API_KEY is optional. With one, the server adds the Maker and Integrator tools (the API checks which kind the key is), and MAX_ORDER_SIZE is then required: the server does not start without it. Without a key the anonymous rate limit applies and, unless MAX_ORDER_SIZE is set, the safe default cap ("The maximum Order size").

Streamable HTTP (hosted)

The operator runs one process:

shell
RWA_API_URL=https://api.example \
MAX_ORDER_SIZE='{"0x<payment token>":"10000000000","0x<asset token>":"5000000000000000000"}' \
MCP_RESOURCE_URL=https://mcp.example/mcp \
OAUTH_ISSUER=https://auth.example \
PORT=3333 HOST=0.0.0.0 node apps/mcp/dist/main.js --http

The endpoint is POST /mcp (the path of MCP_RESOURCE_URL). A client connects by URL with a bearer token and, to act as an Integrator or Maker or to get that key's maximum Order size, its API key in x-api-key:

Claude Code: claude mcp add --transport http rwa-router https://mcp.example/mcp --header "x-api-key: rwa_...". Claude Code finds the authorization server from the server's 401 and runs the OAuth flow itself (/mcp in Claude Code to log in). With a static token (sandbox), add --header "Authorization: Bearer <token>".

Claude Desktop: Settings, Connectors, Add custom connector, URL https://mcp.example/mcp; Claude runs the OAuth flow. (Custom headers such as x-api-key are not set there: such a connector is an anonymous caller with the default maximum.)

Any other client:

json
{
  "mcpServers": {
    "rwa-router": {
      "type": "http",
      "url": "https://mcp.example/mcp",
      "headers": { "Authorization": "Bearer <access token>", "x-api-key": "rwa_..." }
    }
  }
}

The transport is stateless: every request gets a server of its own, made with that request's API key, and nothing of a request is kept, except a Maker's session (below), which is kept per API key. Put TLS in front of it; the process speaks plain HTTP.

Authorization (hosted mode)

The hosted server is an OAuth 2.1 resource server, as the MCP authorization specification describes:

–Every request to /mcp needs Authorization: Bearer <token>. Without one, or with one that fails, the answer is 401 with WWW-Authenticate: Bearer resource_metadata="<origin>/.well-known/oauth-protected-resource/mcp" (and error="invalid_token" or, 403, error="insufficient_scope"). The token is never echoed.

–GET /.well-known/oauth-protected-resource/mcp (and without the path) serves the Protected Resource Metadata (RFC 9728): resource (MCP_RESOURCE_URL), authorization_servers ([OAUTH_ISSUER]), bearer_methods_supported, scopes_supported. A client finds the authorization server there and runs the authorization code flow with PKCE against it; this server issues no tokens.

–OAuth mode (OAUTH_ISSUER): a token is a JWT access token, verified against the issuer's JWKS (OAUTH_JWKS_URL, default <issuer>/.well-known/jwks.json, cached and refreshed on an unknown kid): signature, iss, exp, and aud, which must be this server (OAUTH_AUDIENCE, default MCP_RESOURCE_URL), so a token minted for another resource is refused (no token passthrough). OAUTH_SCOPES lists scopes every token must carry (scope or scp).

–Static mode (MCP_STATIC_TOKEN, 32 characters or more): one shared token, compared in constant time. For the sandbox and local testing only.

–Exactly one of the two must be set: the HTTP transport does not start with neither, or both.

–Host and Origin. Against DNS rebinding, a request whose Host is not in MCP_ALLOWED_HOSTS (default: the host of MCP_RESOURCE_URL, and the loopback names when that is a loopback address) is refused with 403, and so is a request carrying an Origin not in MCP_ALLOWED_ORIGINS (default: none; non-browser clients send no Origin). Both are checked before the token.

The bearer token admits a caller to the server; the API key (x-api-key) is what the API knows the caller by. They are separate on purpose (Spec note 10).

Environment

| Variable | Default | Meaning | |---|---|---| | RWA_API_URL | http://127.0.0.1:3000 | The RWA Router API. | | RWA_API_KEY | unset | stdio only: the API key of whoever runs the server. Opens the Maker and Integrator tools; requires MAX_ORDER_SIZE. Over HTTP each request carries its own in x-api-key. | | MAX_ORDER_SIZE | unset | The maximum Order size (below), as JSON. Required with --http, and on stdio with RWA_API_KEY. Unset on stdio without a key: the safe default cap. | | MAX_ORDER_SIZE_BY_KEY | {} | A higher maximum Order size for single API keys, as JSON. Never lower than MAX_ORDER_SIZE. | | PORT, HOST | 3333, 127.0.0.1 | --http only. | | MCP_RESOURCE_URL | http://<HOST>:<PORT>/mcp | --http: this server's canonical URL, the token audience and the metadata's resource. | | OAUTH_ISSUER, OAUTH_JWKS_URL, OAUTH_AUDIENCE, OAUTH_SCOPES | unset | --http, OAuth mode (above). | | MCP_STATIC_TOKEN | unset | --http, static mode (above). | | MCP_ALLOWED_HOSTS, MCP_ALLOWED_ORIGINS | see above | --http: comma- or space-separated. |

A value that is not exactly the documented shape stops the process at start, with the variable named.

From apps/mcp/README.md.

API reference

Amounts are decimal strings in base units; Orders and cancels are authorised by wallet signature.

Error codes

Schemas

API keys

An API key identifies an Integrator or a Maker for rate limits and fees; it is sent as x-api-key and shown once when issued. Read endpoints work without one at a lower limit. Today the API issues, rotates and revokes keys through its admin endpoints only, so those actions need the operator's admin key (in the sandbox: sandbox-admin-key).

Check an Integrator key

Register an Integrator (operator)

Issue or rotate a key (operator)

Revoke a key (operator)