API & SDK Reference
The BTR gateway is a public, unauthenticated, read-only HTTP API. Writes happen on chain from the user’s own wallet, so there is no authenticated surface and no key to obtain. This page is the verified path list, the request shapes for the three POST endpoints, the error model, and the @btr-protocol/sdk entrypoints that actually resolve.
1. Hosts
https://api.btr.markets/v1 # canonical — use this in all examplesUser-facing REST examples use https://api.btr.markets/v1/*. A machine-readable OpenAPI 3.1 spec is published at https://btr.markets/openapi.json. It is served from the btr.markets host only (api.btr.markets/openapi.json is a 404). https://data.btr.markets also resolves to the gateway for market-data WebSocket but is not the canonical REST host; do not build REST calls against it.
REST: https://api.btr.markets/v1/* (canonical)
Market-data WebSocket: wss://data.btr.markets/stream (ticks); gateway WS is wss://api.btr.markets/stream when documented
Infra-only rewrites (not user URLs): btr.markets /api/* → gateway and btr.markets /docs-api/* / /blog-api/* → docs service are ingress path-rewrites.
Everything is Arc testnet (chain id 5042002) today. Every response carries chainId: 5042002, and GET /v1/venues returns a single chain entry. Placeholders for other chains land as they deploy; write your chain handling as a lookup over the /v1/venues map rather than pinning one id.
1.1. Conventions
| Property | Value |
|---|---|
| Auth | None. No key, no header, no signed request |
| Rate limit | None. No RateLimit headers are emitted |
| CORS | Allowlist, not wildcard; see below |
| Caching | GET responses are edge-cached for a few seconds; polling faster returns the same body |
| Versioning | Major version pinned in the path. Fields are added, never removed or retyped, within /v1. A breaking change ships as /v2 alongside |
| Deprecation | RFC 8594 Deprecation / Sunset headers, sunset ≥180 days out. Nothing is deprecated today, so no such header is emitted |
1.2. CORS is an allowlist
The gateway echoes Access-Control-Allow-Origin only for allowlisted origins. An origin that is not on the list gets no ACAO header at all, and the browser blocks the read.
Origin: https://btr.markets → access-control-allow-origin: https://btr.markets
Origin: http://localhost:3000 → access-control-allow-origin: http://localhost:3000
Origin: https://your-app.com → (no header - browser blocks)Local development against http://localhost:3000 works. A deployed third-party front end calling the gateway directly from the browser will not. Proxy through your own backend, or ask to have your origin allowlisted. Server-side calls (cURL, Node, Rust, Python) are unaffected: CORS is a browser policy, not a server restriction.
1.3. Error model
Most errors are a JSON object with a single error key:
{"error": "unknown preset 'close'"}Some are text/plain. Do not assume a parseable body; check content-type, or guard your .json() in a try.
| Status | Meaning | Body |
|---|---|---|
400 | Malformed request: unparseable body, or a query value the handler rejected | JSON on /v1/quote, /v1/route; plain text on /v1/md/ohlc and GET /stream |
404 | No such path, wrong method (the POST endpoints 404 on GET), or an unindexed resource | JSON |
422 | Well-formed request, rejected value: an unknown preset, or a body that failed to deserialize | JSON on /v1/indicators; plain text on /v1/depth |
502 | Upstream panic. Not always your fault, and not always retryable; see below | plain text, error code: 502 |
Status codes are not uniform across the POST endpoints even for the same class of failure: a missing-field body returns 400 (JSON) on /v1/quote and /v1/route, but 422 (plain text) on /v1/depth. Branch on the status you actually got, not on the endpoint family.
Deserialization failures name the first missing field, so iterate on the message.
On 502. A structurally valid, fully-populated request can still 502 from an upstream panic on edge-case values. Confirmed triggers on /v1/quote: confidence_bps: 0, curve.header: "0x0", and mark: "0x0". confidence_bps: 0 matters in practice: /v1/md/tickers serves "confidence": 0 on this same API, so feeding a ticker straight back into a quote reproduces it. Treat 502 as a bad-input signal first and a transient second: retrying an identical body will not clear it.
2. Path surface
Verified live on both hosts. Paths not listed here are not part of the API.
2.1. GET
| Path | Returns | Query |
|---|---|---|
/health | Liveness and readiness | - |
/v1/md/tickers | Current oracle mark per tracked ticker | - |
/v1/md/ohlc/{sym} | OHLC bars | tf (required) |
/v1/indicators | One technical-indicator series | symbol, preset (both required); tf (default 30), fast, slow, signal, from (lookback ms, default 30d) |
/v1/assets | Metadata for named assets | addresses (required) |
/v1/assets/pools | Deployed pools and their assets | chainId |
/v1/activity | Recent swap, deposit and withdraw events | limit (1–200, default 200), before, pool, asset, action, payer, receiver, token_in, token_out |
/v1/liquidity | Liquidity flow bucketed over time | asset |
/v1/abis/{name} | Lean ABI array for one contract | - |
/v1/venues | Deployed contracts, tokens, feeds and pools, keyed by chain id | - |
/v1/summary | Protocol totals | - |
/v1/pools | Deployed pools + symbol rosters | - |
/v1/pools/metrics | Per-pool metrics | - |
/v1/timeseries | One metric bucketed | metric, grain |
/v1/oracle/roster | Oracle address, signer set, event count | - |
/v1/oracle/history | Feed price history | feed selector required |
{name} on /v1/abis is verified for Pool, Admin, Flash, PoolFactory, ExternalOracleV4, ExternalOracle, AccessControl and LPToken. ExternalOracleV4 is the generation the live fleet runs; ExternalOracle is the V1 contract and is kept for chains still on it.
2.2. POST
| Path | Purpose |
|---|---|
/v1/quote | Price one exact-in leg against supplied leg state |
/v1/route | Rank routes and splits across supplied pool states |
/v1/depth | Build a depth ladder across supplied pool states |
/v1/timeseries/bulk | Several timeseries queries in one request |
GET on any of these returns 404: they are POST-only. That 404 is a method mismatch, not a missing endpoint.
2.3. WebSocket
/stream: live price batches at wss://data.btr.markets/stream (market data) and wss://api.btr.markets/stream (gateway) where documented. A plain GET returns 400 because the request is not an upgrade. The message schema is not documented here; read it off a live connection before depending on it.
3. The three POST endpoints are stateless pricing kernels
This is the single most important thing to understand about /v1/quote, /v1/route and /v1/depth, and it is not what the endpoint names suggest.
They hold no pool state. You do not pass a pool address and get a price back. You pass the entire pricing state of every leg involved (curve, reserves, liabilities, mark, σ, confidence, staleness, fee params) and the service runs the same integer pricing kernel the contracts run and hands the numbers back. It is the on-chain pricer, callable off chain, on state you supply.
That means:
- Reading pool state is your job, on chain.
/v1/venuesgives you the addresses; the SDK’s storage readers (@btr-protocol/sdk/pool) give you the slots. - The kernel is authoritative for arithmetic, not for freshness. Stale inputs produce a confidently wrong number.
- All 256-bit values cross the wire as
0xhex strings. A JSON number above 2^53 loses precision and JavaScript stringifies large integers as1e+21, which the deserializer rejects. - Field names are snake_case (
token_in,amount_in,sigma_pbps).
3.1. POST /v1/quote
Required body fields, all at the top level:
| Field | Type | Notes |
|---|---|---|
curve | object | { header: string, segs: SegWire[], m: number }; SegWire = {c0,c1,c2,c3,c4,s}, all 0x hex |
min_dispersion_pbps | u32 | |
vega_bps | u16 | |
min_fee_pbps | u32 | |
kappa_cov_bps | u16 | |
amount_in | 0x hex | |
reserves | 0x hex u128 | native raw units of the token |
liabilities | 0x hex u128 | native raw units |
mark | 0x hex U256 | WAD (1e18) |
sigma_pbps | u32 | |
selling | bool | |
counterparty | object | {reserves, liabilities, vega_bps, kappa_cov_bps} — the swap’s OTHER endpoint; see below |
confidence_bps | u16 | |
stale_excess | u32 | seconds of staleness beyond TTL |
proto_share_pct | u8 |
The canonical request shape, once per corpus (other pages link here, never restate it):
curl -s https://api.btr.markets/v1/quote \
-H 'content-type: application/json' \
-d '{
"curve": {"header": "0x…", "segs": [], "m": 0},
"min_dispersion_pbps": 100,
"vega_bps": 1,
"min_fee_pbps": 1,
"kappa_cov_bps": 100,
"amount_in": "0x…",
"reserves": "0x…",
"liabilities": "0x…",
"mark": "0x…",
"sigma_pbps": 10000,
"selling": true,
"counterparty": {
"reserves": "0x…", "liabilities": "0x…", "vega_bps": 10000, "kappa_cov_bps": 600
},
"confidence_bps": 5,
"stale_excess": 0,
"proto_share_pct": 25
}'counterparty: a leg is not a path
The body above describes ONE LEG. The contract prices a PATH, and its settle tail reads two things off the path’s two ENDPOINTS that no leg carries:
acc.vegaBps = max(cIn.vegaBps, cOut.vegaBps)— the spread’s vega is the endpoint maximum, not the walked leg’s own dial._covToll(cOut, …)— the convex coverage toll is charged on whichever endpoint the swap delivers. On a spoke→base sell that endpoint is the hub, and the hub is not exempt: every listed asset including the base runs0 < κ ≤ BPS.
So counterparty is the endpoint on the far side of your leg — the pool’s base on a direct spoke↔base swap, in that token’s native raw units. It is required. There is no default, because the only available default (a zero endpoint) silently drops the hub’s coverage toll and quotes a price the pool will not fill; on the live fleet that gap reaches hundreds of bps at size. A body without it is a 400.
One case takes a zero endpoint on purpose: a hop whose far token is interior to a longer path — the first leg of a spoke→base→spoke cross, whose base is not an endpoint of the swap at all. Send {"reserves":"0x0","liabilities":"0x0","vega_bps":0,"kappa_cov_bps":0} there; the chain neither tolls that node nor takes its vega.
Response:
{
"amount_out": "0x…", "gross_out": "0x…", "avg_price": "0x…",
"mid_price": "0x…", "mark_price": "0x…", "spread_pbps": 0,
"cov_toll": "0x…", "proto_fee": "0x…", "lp_fee": "0x…"
}There is no min_out. Derive your own slippage floor from amount_out; see Basic Operations §7.
3.2. POST /v1/route
| Field | Type | Required |
|---|---|---|
pools | NamedPoolWire[] | yes |
token_in | string | yes |
token_out | string | yes |
amount_in | 0x hex U256 | yes |
slices | u32 | no |
min_gain_bps | u64 | no |
max_routes | usize | no |
NamedPoolWire = { tag, base, spokes: SpokeWire[] } plus addr, base_address, base_decimals and the hub’s endpoint book: base_reserves, base_liabilities (both 0x hex u128), base_vega_bps and base_kappa_cov_bps.
The four hub fields are the
/routeand/depthform ofcounterpartyabove, and they are all-or-nothing. Report the whole book or the router drops every leg that delivers your base rather than quote it toll-free — a missing κ or vega each silently removes a charge the chain makes.
base_addressandbase_decimalsare individually optional but not jointly omissible: a wire carrying neither is rejected with400 {"error":"USDC: wire omits both base_decimals and base_address"}. Supply at least one so the decimal resolver can work. The same applies per spoke.
SpokeWire = { token, pricing, reserves, liabilities, mark, sigma_pbps, confidence_bps, stale_excess, proto_share_pct } plus address and decimals. pricing is the same five-field object that heads a quote request: { curve, min_dispersion_pbps, vega_bps, min_fee_pbps, kappa_cov_bps }. Give each spoke decimals or address: omitting both fails the same way the base does.
Response:
{
"best_amount_out": "0x…",
"best_is_split": false,
"best_parts": [{ "legs": [], "fraction": "0x…", "amount_out": "0x…" }],
"singles": [{ "legs": [], "amount_in": "0x…", "amount_out": "0x…" }]
}LegWire = { pool_tag, token_in, token_out, amount_in, amount_out }. No minOut is computed anywhere in the response; it is the caller’s to apply per leg.
3.3. POST /v1/depth
| Field | Type | Required |
|---|---|---|
pools | NamedPoolWire[] | yes |
from | string | yes |
to | string | yes |
base_reserves | 0x hex | no: per-pool base_reserves wins when both are present |
samples | u32 | no; sweep resolution per side per pool, default 48 |
Response: { mark, mid, bid, ask, step, bids: Row[], asks: Row[], poolCount }, Row = { price, size, cum }. These are JSON floats; this endpoint is for rendering a book, not for settlement arithmetic.
4. Response shapes worth knowing
4.1. /v1/md/tickers
[{"ticker":288583527640858624,"mid":291807.65,"bid":291221.02,"ask":292394.28,
"ci":12367,"confidence":0,"flags":64,"age_ms":77084658,"status":"dead"}]Two traps:
tickeris a numeric id, not a symbol. The id→symbol mapping is not served by this endpoint. Resolve symbols through/v1/assets/poolsor/v1/pools, which speak symbols directly.- Gate on
statusandage_ms. A ticker whose feed has gone stale reportsstatus: "dead"and keeps its lastmid. A client that readsmidwithout checkingstatuswill price off a corpse.
4.2. /v1/activity
{"chainId":5042002,"events":[{"id":"0x…:47","kind":"swap","pool":"btr-crypto",
"payer":"0x…","amountIn":3.6386154,"amountOut":1.99187305,"logIndex":47}]}id is txHash:logIndex. Paginate backwards with before.
4.3. /v1/venues
Keyed by chain id. Each entry carries chain_id, name, and a contracts map: ac, admin, adminImpl, deployer, faucet, flash, flashImpl, guardian, oracle, owner, poolFactory, poolImpl, refOracle, treasury. This is the address source of truth for every integration. Do not hardcode addresses.
4.4. Floats on the wire
The GET surface returns JSON floats for prices and amounts (mid, amountIn, amountUsd). They are display values. Exact integer arithmetic lives on the POST kernels and on chain, where every 256-bit value is 0x hex. Never settle against a GET float.
4.5. Indicator presets
preset accepts exactly: ema-trend, emacd, rsima, rsimacd, adxma, adxmacd, sdevma, sdevmacd.
Anything else returns 422 {"error":"unknown preset '…'"}.
5. SDK
@btr-protocol/sdk, version 0.5.0.
5.1. Reachable entrypoints
These subpaths resolve. Nothing outside this table is importable, whatever a file in the source tree appears to export.
| Import | Surface |
|---|---|
@btr-protocol/sdk | Re-exports ./pool, ./router, ./amm, ./venues, ./oracle, ./eth, ./types and the ./utils helpers |
@btr-protocol/sdk/pool | getSwapQuote, swap, deposit, withdraw, getPoolData, getAsset, getCoverageRatio, getLPBalance, defaultDeadline, NATIVE_TOKEN, NO_DEADLINE, DEFAULT_DEADLINE_S, POOL_ABI, storage readers, types |
@btr-protocol/sdk/router | planToLegs, buildSwapCalls, buildApprovalCalls, buildSwapExecCalls, buildDepositCalls, buildRedeemCalls, refloorLeg, totalValue, rankDeposit, rankRedeem, applySlip |
@btr-protocol/sdk/amm | quoteExactIn, rankSwap, poolStateFrom, SwapPlan |
@btr-protocol/sdk/abis | POOL_ABI, ADMIN_ABI, FLASH_ABI, POOL_FACTORY_ABI, ACCESS_CONTROL_ABI, EXTERNAL_ORACLE_V4_ABI, EXTERNAL_ORACLE_ABI, LP_TOKEN_ABI, POOL_HOOKS_ABI. EXTERNAL_ORACLE_V2_ABI is a deprecated alias of EXTERNAL_ORACLE_V4_ABI |
@btr-protocol/sdk/eth | Contract, RPC client helpers |
@btr-protocol/sdk/eth/wallets | Wallet helpers |
@btr-protocol/sdk/oracle | Signed-quote decode / digest / recover / quorum |
@btr-protocol/sdk/governance | Governance helpers |
@btr-protocol/sdk/venues | Static deployment registry |
@btr-protocol/sdk/utils | applySlip, applySlippage, maths / format / encoding helpers |
@btr-protocol/sdk/utils/format · /utils/logger · /types | Formatting, logging, shared types |
Not importable. setApiRoot, btrFetch, fetchAbi and fetchVenues exist in the source tree but are re-exported by no index and reachable through no subpath. Fetch ABIs and venues with plain fetch against the paths in §2.1:
const API = 'https://api.btr.markets';
const poolAbi = await fetch(`${API}/v1/abis/Pool`).then((r) => r.json());
const venues = await fetch(`${API}/v1/venues`).then((r) => r.json());applySlip is re-exported from @btr-protocol/sdk/router as well as @btr-protocol/sdk/utils and the package root; all three resolve to the same function.
5.2. Signatures
Every pool function takes an EIP-1193 provider as its first argument (window.ethereum or equivalent, not a viem client) and the pool address as its second.
getSwapQuote(provider, pool, tokenIn, tokenOut, amountIn: bigint): Promise<SwapQuote>
getCoverageRatio(provider, pool, token): Promise<bigint>
getLPBalance(provider, pool, user, token): Promise<bigint>
getPoolData(provider, pool, tokens: Array<{ address: Address; symbol: string; name: string }>, poolName: string): Promise<PoolData>
swap(provider, pool, { tokenIn, tokenOut, amountIn, minAmountOut, recipient, deadline? }): Promise<Hex>
deposit(provider, pool, { token, amount }): Promise<Hex>
withdraw(provider, pool, { token, lpAmount, minAmountOut, deadline? }): Promise<Hex>deadline defaults to defaultDeadline(), 600 seconds out. NO_DEADLINE is 0xffffffffn, a far-future sentinel that opts out, cheaper in calldata than type(uint256).max and semantically identical against the contract’s block.timestamp > deadline check.
Routing:
planToLegs(plan: SwapPlan, { slippageFrac, tokenOf, nativeIn?, nativeOut?, amountInUnits? }): ExecLeg[] | null
buildSwapCalls(legs: ExecLeg[], opts: BuildOpts): ExecCall[]The SDK exported
routeAsyncandquoteExactInAsyncas thin wrappers over/v1/routeand/v1/quote. Both were removed: they had no callers, and neither had ever worked: they serialised camelCase against a service that requires snake_case, and their pool payload was the SDK’sPoolStaterather than theNamedPoolWirethe endpoint accepts. Build the request body yourself against §3.1 and §3.2.planToLegsandbuildSwapCallsare unaffected and work on any plan of the right shape.
There are no first-class senders for withdrawTo, swapLiability, donate or Flash.flashLoan. Encode those with encodeFunctionData against POOL_ABI / FLASH_ABI from @btr-protocol/sdk/abis, or against a live ABI from /v1/abis.