API reference
devnet preview — all data is replayOne server, no API keys. Fourteen read endpoints anyone can call, one paid endpoint that answers 402 Payment Required until you sign, and two devnet write endpoints. Payment authority travels as signatures in the request body — there is no key to steal and no account to close.
Conventions
Base URL today: your local devserver (http://127.0.0.1:8645) or a locally run relay-server; the public endpoint publishes with the public devnet. All responses are JSON. Money and capacity amounts are decimal strings (they exceed float precision); heights and counts are numbers; ids and hashes are lowercase 0x-hex. Lists return {"data": [...], "next_cursor": …} and accept cursor and limit (default 100, max 1000).
The paid endpoint
POST /v1/chat/completions — OpenAI-shaped chat that settles as a one-fill lease. Call it bare and it does not fail; it quotes you:
1 · The 402 challenge
The response is an x402-style challenge. Inside accepts[0]: scheme exact, the settlement network and USDC asset, and maxAmountRequired — which is the escrow ceiling, not the charge. The challenge says so itself, in three fields under extra.dispute_tail: the ceiling is escrowed not charged, the settled amount is metered and may be lower, and settlement is optimistic with a clawback window after the 200. The full native terms sit in extra.freeside: the book and its key, the lease intent (price, units, spend ceiling, fill caps, heights, nonce), the relay's fee attribution, the relay-fee approval terms, the fill authorization, and the exact derivation of the request id.
2 · The signed retry
Sign locally and re-POST the same endpoint with a freeside object added to the body — a body envelope, not a header:
{
"model": "freeside-chat",
"messages": [...],
"max_tokens": 100,
"temperature": 0.7, // optional sampling params pass through to the
"top_p": 0.9, // provider verbatim; the quote never reads them
"freeside": {
"approval_tx": "0x…", // optional: sets the relay-fee budget once, reused after
"intent_tx": "0x…", // required: the signed lease intent
"fill_authorization_tx": "0x…", // required: the signed fill authorization
"acceptance_delegation": "0x…" // required on delegated-acceptance books: your signed §17.1
} // authorization (see the 402's acceptance_delegate terms)
} The intent must bind the approval's digest before signing — the SDK does this for you; implementers, see the signing vectors in the repository.
3 · The response
A normal chat completion: id is the lease id, created is the block height, and usage is what settlement meters. What you see in usage equals what moved on chain — that equality is asserted by a cross-language test on every commit.
The read API
Open to anyone. No key, ever — prices never gate.
| endpoint | params | returns |
|---|---|---|
| GET /v1/status | — | index head: height, app_hash, ingested_height |
| GET /v1/models | — | the routed catalog: model slug, book, listing state, latest clearing price, minimum posted ask, per-fill token caps |
| GET /v1/books | cursor, limit | listed books |
| GET /v1/books/:hash | — | one book: book_key, index_source_status, listed, listed_at_height |
| GET /v1/books/:hash/clearing-prints | from_height, to_height, cursor, limit | clearing prints: price, cleared units, match counts, concentration shares, status |
| GET /v1/books/:hash/benchmarks | window, cursor, limit | benchmark prints: window bounds, benchmark price, eligible volume and count, method_id |
| GET /v1/books/:hash/obligations | status, cursor, limit | posted asks: ask price, capacity committed and remaining, concurrency, per-fill token caps, status |
| GET /v1/leases/:id | — | the full lease object — escrow locked and remaining, cumulative usage, fees paid, fills remaining, status |
| GET /v1/leases/:id/fills | cursor, limit | fills: commitments, every deadline height, token counts, computed charge, status |
| GET /v1/accounts/:addr | — | balances in four buckets: available, locked_escrow, locked_bond, pending_withdrawal |
| GET /v1/accounts/:addr/leases | role (user | provider), status, cursor, limit | leases where the address is buyer or provider |
| GET /v1/providers | cursor, limit | provider stats list |
| GET /v1/providers/:id | — | one provider: completed fills, settled volume, fault counters |
| GET /v1/events | from_height, kind, cursor, limit | the event log — the polling substitute for a stream |
Devnet write endpoints
POST /v1/tx submits a hex-encoded signed transaction. POST /v1/faucet drips devnet funds — and it is not anonymous: you prove control of the account you want funded by signing a challenge with its own key. The payout is fixed server-side (callers cannot request an amount) and each address has a cooldown.
POST /v1/faucet
{ "public_key": "0x…32-byte ed25519", "signature": "0x…64-byte" } The signature covers a 64-byte message you build locally — no round-trip, the challenge is deterministic:
message = DOMAIN_FAUCET ‖ account_address(public_key)
DOMAIN_FAUCET = "FREESIDE_FAUCET_V1" ascii, zero-padded to 32 bytes
DOMAIN_ACCOUNT = "FREESIDE_ACCOUNT_V1" ascii, zero-padded to 32 bytes
account_address = SHA-256( DOMAIN_ACCOUNT ‖ 0x00 ‖ public_key )
↑ the ed25519 scheme byte of the
canonical public-key encoding That scheme byte is the detail worth writing down: the address is hashed over the canonical key encoding, which is the scheme tag followed by the raw key — not the bare 32 bytes. The SDK's accountAddress() does this for you; the derivation is spelled out here so nobody has to rediscover it from a failing signature.
Errors
Chat errors are OpenAI-shaped ({"error": {"message", "type", "code"}}); read-API errors are {"error": {"code", "message"}} with not_found or bad_request. The relay's error table is frozen and enforced verbatim by an HTTP conformance probe in CI — the rows below are tested, not aspirational:
| http | type | code | meaning |
|---|---|---|---|
| 400 | invalid_request_error | freeside_bad_request | malformed JSON or missing fields |
| 400 | invalid_request_error | freeside_checktx_0x···· | a signed transaction failed admission; the suffix is the CheckTx code |
| 404 | invalid_request_error | model_not_found | the model did not route to a listed book |
| 402 | insufficient_quota | no_clearing | the order did not clear this auction; nothing was charged |
| 402 | insufficient_quota | insufficient_available | the account cannot escrow the order's ceiling |
| 408 | timeout | freeside_refunded_unaccepted | no acceptance by deadline; the fill refunded, the provider unpaid |
| 502 | api_error | freeside_provider_unavailable | payload transport to the provider failed before activation |
| 503 | api_error | freeside_node_unavailable | the node is unreachable |
| 500 | api_error | freeside_internal | a relay fault — never an on-chain event |
Note that 402 appears three ways: the payment challenge (a different body — see above) and the two insufficient_quota rows.
Limits, CORS, streaming
Rate limits are per-IP fixed 60-second windows, in three tiers: 120/min across all of /v1, 10/min on the mutating routes, and 60/min on POST /v1/chat/completions — its own tier so a sustained agent loop does not starve at the mutating limit. Over budget answers 429 freeside_rate_limited with a Retry-After header. Operators override each with FREESIDE_RELAY_RATE_GLOBAL, _RATE_MUTATING, _RATE_CHAT.
CORS echoes an exact allow-listed Origin (never *) with Vary: Origin, and answers preflight ahead of the limiter. The allow-list is FREESIDE_RELAY_CORS_ORIGINS (comma-separated); empty echoes nothing, i.e. same-origin only.
Streaming: send "stream": true and chat answers text/event-stream with OpenAI chat.completion.chunk frames terminated by data: [DONE]. Omit the field and the response is the one buffered JSON body, byte for byte as before.
On a stream, a 200 status line is not a settled completion. The status line is committed before the provider runs, so content can reach you and settlement can still fail afterwards. The verdict rides in the final chunk — the one carrying usage — as freeside: {lease_id, settlement: "settled"}. A stream that fails after delivery instead ends with one frame carrying the same error body this page's table specifies, and no terminator. So the rule holds even for a client that never reads the Freeside receipt: you saw [DONE] ⇒ the chain settled it. Treat a stream that stops any other way as unsettled. The SDK's chatStreamed already does: it rejects rather than return content the chain never confirmed.
There is still no subscribe endpoint for chain events — poll GET /v1/events with from_height instead.
Devserver differences
The quickstart's devserver is a strict subset. It serves the core paths (status, books, accounts, leases, provider by id, clearing prints, faucet, tx, chat) but not the full read API (no models catalog, single-book, benchmarks, obligations, lease fills, account-leases, provider list, or events routes), ignores query parameters on lists, and returns the native Freeside challenge JSON on 402 rather than the x402 skin. The production surface on this page is the contract; the devserver is the porch.