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 examples

User-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

PropertyValue
AuthNone. No key, no header, no signed request
Rate limitNone. No RateLimit headers are emitted
CORSAllowlist, not wildcard; see below
CachingGET responses are edge-cached for a few seconds; polling faster returns the same body
VersioningMajor version pinned in the path. Fields are added, never removed or retyped, within /v1. A breaking change ships as /v2 alongside
DeprecationRFC 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.

StatusMeaningBody
400Malformed request: unparseable body, or a query value the handler rejectedJSON on /v1/quote, /v1/route; plain text on /v1/md/ohlc and GET /stream
404No such path, wrong method (the POST endpoints 404 on GET), or an unindexed resourceJSON
422Well-formed request, rejected value: an unknown preset, or a body that failed to deserializeJSON on /v1/indicators; plain text on /v1/depth
502Upstream panic. Not always your fault, and not always retryable; see belowplain 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

PathReturnsQuery
/healthLiveness and readiness-
/v1/md/tickersCurrent oracle mark per tracked ticker-
/v1/md/ohlc/{sym}OHLC barstf (required)
/v1/indicatorsOne technical-indicator seriessymbol, preset (both required); tf (default 30), fast, slow, signal, from (lookback ms, default 30d)
/v1/assetsMetadata for named assetsaddresses (required)
/v1/assets/poolsDeployed pools and their assetschainId
/v1/activityRecent swap, deposit and withdraw eventslimit (1–200, default 200), before, pool, asset, action, payer, receiver, token_in, token_out
/v1/liquidityLiquidity flow bucketed over timeasset
/v1/abis/{name}Lean ABI array for one contract-
/v1/venuesDeployed contracts, tokens, feeds and pools, keyed by chain id-
/v1/summaryProtocol totals-
/v1/poolsDeployed pools + symbol rosters-
/v1/pools/metricsPer-pool metrics-
/v1/timeseriesOne metric bucketedmetric, grain
/v1/oracle/rosterOracle address, signer set, event count-
/v1/oracle/historyFeed price historyfeed 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

PathPurpose
/v1/quotePrice one exact-in leg against supplied leg state
/v1/routeRank routes and splits across supplied pool states
/v1/depthBuild a depth ladder across supplied pool states
/v1/timeseries/bulkSeveral 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/venues gives 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 0x hex strings. A JSON number above 2^53 loses precision and JavaScript stringifies large integers as 1e+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:

FieldTypeNotes
curveobject{ header: string, segs: SegWire[], m: number }; SegWire = {c0,c1,c2,c3,c4,s}, all 0x hex
min_dispersion_pbpsu32
vega_bpsu16
min_fee_pbpsu32
kappa_cov_bpsu16
amount_in0x hex
reserves0x hex u128native raw units of the token
liabilities0x hex u128native raw units
mark0x hex U256WAD (1e18)
sigma_pbpsu32
sellingbool
counterpartyobject{reserves, liabilities, vega_bps, kappa_cov_bps} — the swap’s OTHER endpoint; see below
confidence_bpsu16
stale_excessu32seconds of staleness beyond TTL
proto_share_pctu8

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 runs 0 < κ ≤ 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

FieldTypeRequired
poolsNamedPoolWire[]yes
token_instringyes
token_outstringyes
amount_in0x hex U256yes
slicesu32no
min_gain_bpsu64no
max_routesusizeno

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 /route and /depth form of counterparty above, 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_address and base_decimals are individually optional but not jointly omissible: a wire carrying neither is rejected with 400 {"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

FieldTypeRequired
poolsNamedPoolWire[]yes
fromstringyes
tostringyes
base_reserves0x hexno: per-pool base_reserves wins when both are present
samplesu32no; 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:

  1. ticker is a numeric id, not a symbol. The id→symbol mapping is not served by this endpoint. Resolve symbols through /v1/assets/pools or /v1/pools, which speak symbols directly.
  2. Gate on status and age_ms. A ticker whose feed has gone stale reports status: "dead" and keeps its last mid. A client that reads mid without checking status will 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.

ImportSurface
@btr-protocol/sdkRe-exports ./pool, ./router, ./amm, ./venues, ./oracle, ./eth, ./types and the ./utils helpers
@btr-protocol/sdk/poolgetSwapQuote, swap, deposit, withdraw, getPoolData, getAsset, getCoverageRatio, getLPBalance, defaultDeadline, NATIVE_TOKEN, NO_DEADLINE, DEFAULT_DEADLINE_S, POOL_ABI, storage readers, types
@btr-protocol/sdk/routerplanToLegs, buildSwapCalls, buildApprovalCalls, buildSwapExecCalls, buildDepositCalls, buildRedeemCalls, refloorLeg, totalValue, rankDeposit, rankRedeem, applySlip
@btr-protocol/sdk/ammquoteExactIn, rankSwap, poolStateFrom, SwapPlan
@btr-protocol/sdk/abisPOOL_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/ethContract, RPC client helpers
@btr-protocol/sdk/eth/walletsWallet helpers
@btr-protocol/sdk/oracleSigned-quote decode / digest / recover / quorum
@btr-protocol/sdk/governanceGovernance helpers
@btr-protocol/sdk/venuesStatic deployment registry
@btr-protocol/sdk/utilsapplySlip, applySlippage, maths / format / encoding helpers
@btr-protocol/sdk/utils/format · /utils/logger · /typesFormatting, 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 routeAsync and quoteExactInAsync as thin wrappers over /v1/route and /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’s PoolState rather than the NamedPoolWire the endpoint accepts. Build the request body yourself against §3.1 and §3.2. planToLegs and buildSwapCalls are 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.


Quotes & Routing · Cookbook · Basic Operations