# Arbitron API and MCP server

Read-only market data for crypto perpetual futures across the venues Arbitron tracks: current
funding rates, funding history, cross-venue spreads (the live print and the scanner's backtest)
and the listings calendar. Two
doors to the same data: a REST API for programs (key auth) and an MCP server for AI assistants
(the same key, or OAuth 2.1). The human page for this document is https://arbitron.app/developers.

- Base URL: `https://arbitron.app/api/v1`
- Repository with the OpenAPI document, the MCP manifest, client configs and examples:
  https://github.com/arbitron-team/arbitron-api
- Discovery (anonymous JSON): `GET https://arbitron.app/api/v1`
- OpenAPI 3.1: `https://arbitron.app/api/v1/openapi.json`
- Interactive reference: `https://arbitron.app/developers/reference`
- MCP endpoint: `https://arbitron.app/mcp`
- Keys: `https://arbitron.app/settings/api` (a free Demo key on every account)
- Plans: `https://arbitron.app/developers#plans`
- Errors: `https://arbitron.app/developers/errors#<code>`
- Site index for agents: `https://arbitron.app/llms.txt`

## Authentication

Send an API key as a bearer token. The `X-Api-Key` header is accepted too. Keys are never read
from the query string.

```
Authorization: Bearer arb_live_<43 base62 chars>_<6 char checksum>
```

- Keys start with `arb_live_` (production) or `arb_test_` (local development only). The last
  six characters are a CRC32 checksum, so a truncated key is rejected before any lookup.
- A key is shown once at creation. The server stores a keyed hash (HMAC-SHA256 with a pepper
  that lives outside the repository), never the plaintext. `Authorization` headers are never
  logged.
- Keys are scoped. Scopes: `funding:read`, `history:read`, `spreads:read`, `listings:read`,
  `usage:read`. The default is all read scopes. There is no write scope.
- Keys expire after one year by default (editable). Rotation is dual-accept: rotating issues a
  new key and the old one keeps working for 24 hours. Revoke stops a key immediately.
- Missing key: `401` with `WWW-Authenticate: Bearer realm="arbitron-api"`. Unknown, revoked or
  expired key: `401` with `error="invalid_token"`. Missing scope: `403` with
  `error="insufficient_scope"` and the scope named.
- More than 30 rejected keys from one address in a minute answers `429` for that address.

## Response envelope

Every successful response is an envelope. Percentages end in `_pct` and are percent values
(`0.01` = one basis point); timestamps are UTC ISO 8601.

```json
{
  "data": { },
  "meta": {
    "as_of": "2026-09-20T11:10:53Z",
    "delayed_seconds": 0,
    "source": "arbitron.app",
    "url": "https://arbitron.app/funding-rates/coin/btc?utm_source=api",
    "count": 18,
    "total": 18,
    "next_cursor": null
  }
}
```

- `meta.as_of`: the snapshot time. Cite it with any number you republish.
- `meta.delayed_seconds`: how far behind the live snapshot this response is (`0` on paid plans,
  `900` on the Demo plan).
- `meta.url`: the human page for the same data. Cite that page.
- `meta.count`, `meta.total`, `meta.next_cursor`: page size, total rows, and the cursor to pass
  as `cursor` for the next page. `next_cursor` is absent or null on the last page.

Every data response also carries `ETag`, `Cache-Control: private, max-age=15` (live) or
`max-age=300` (delayed), `Vary: Authorization, X-Api-Key`, a `Link` header to the OpenAPI
document (`rel="describedby"`), and `X-Request-Id`. CORS is open for `GET` and `OPTIONS`.

## Endpoints

All endpoints are `GET`. Pagination parameters, where present: `limit` (1 to 500; the Demo plan
caps pages at 50; default 100) and `cursor` (`meta.next_cursor` from the previous page).
Venue names are spelled as `/exchanges` spells them (`Binance`, `Bybit`, `Gateio`, ...);
tickers are case-insensitive.

Conventions shared by every list endpoint:

- `coin`, `exchange` and `exclude_exchange` take one value or a comma-separated list of up to 20
  (`exchange=Binance,Bybit`). Where a row has two venues, `exchange_match=any` (default) keeps a
  row with either leg in the list and `exchange_match=all` keeps only rows with both legs in it.
- A `min_*` / `max_*` pair bounds a numeric field inclusively. A bound on a nullable field drops
  the rows where it is null. A pair that crosses (`min` above `max`) is a `400`.
- `sort` names a field and a direction (`rate_desc`). Each endpoint lists its vocabulary; an
  unknown value is a `400` that repeats the list. The default sort costs nothing: the snapshot is
  already in that order.
- Filtering happens in memory on a snapshot that is seconds to a minute old; a filtered request
  is as fast as an unfiltered one, and the response is cached per URL for the same few seconds.
- `fields=exchange,coin,rate_pct` keeps only the named fields on every row and drops the rest;
  `meta` and objects outside the row list are untouched. An unknown name is a `400` that lists
  the row's fields. REST only: an MCP tool result always matches its advertised schema.

### `GET /api/v1/funding`

Scope `funding:read`. One row per perpetual contract on every venue, sorted by absolute rate.

| Parameter | Type | Meaning |
|---|---|---|
| `coin` | list | Tickers, e.g. `BTC,ETH` |
| `exchange` | list | Keep rows on these venues |
| `exclude_exchange` | list | Drop rows on these venues |
| `min_rate_pct`, `max_rate_pct` | number | Signed bounds on `rate_pct`; `max_rate_pct=-0.01` is "negative funding only" |
| `min_abs_rate_pct`, `max_abs_rate_pct` | number | Bounds on the absolute rate |
| `min_apr_pct`, `max_apr_pct` | number | Signed bounds on `apr_pct` |
| `min_sum_7d_pct`, `max_sum_7d_pct` | number | Signed bounds on the 7-day sum |
| `min_sum_30d_pct`, `max_sum_30d_pct` | number | Signed bounds on the 30-day sum |
| `interval_hours` | list | Settlement intervals to keep, e.g. `1` or `4,8` |
| `is_inverse` | boolean | `true` = coin-margined contracts only, `false` = linear only |
| `min_volume_usdt`, `max_volume_usdt` | number | Bounds on 24h volume |
| `settles_within_minutes` | integer | Keep rows whose next settlement is within this many minutes (1 to 1440) |
| `sort` | string | `abs_rate_desc` (default), `abs_rate_asc`, `rate_desc`, `rate_asc`, `apr_desc`, `apr_asc`, `sum_7d_desc`, `sum_7d_asc`, `sum_30d_desc`, `sum_30d_asc`, `volume_desc`, `volume_asc`, `next_funding_asc`, `next_funding_desc`, `coin_asc`, `exchange_asc` |
| `limit`, `cursor` | | Pagination |

Examples: `?max_rate_pct=-0.01&min_volume_usdt=1000000&sort=rate_asc` (the most negative funding
on liquid contracts), `?settles_within_minutes=30&min_abs_rate_pct=0.05` (large rates settling
in the next half hour), `?coin=BTC&interval_hours=1` (hourly BTC contracts).

Row fields: `exchange`, `symbol`, `coin`, `is_inverse`, `rate_pct` (percent per settlement),
`apr_pct` (annualised, net of costs; nullable), `sum_7d_pct`, `sum_30d_pct`, `interval_hours`,
`next_funding_at`, `volume_24h_usdt`.

### `GET /api/v1/funding/{coin}`

Scope `funding:read`. One coin across venues. Linear contracts rank first; inverse venues are
listed separately.

| Parameter | Type | Meaning |
|---|---|---|
| `exchange` | list | Rank only these venues |
| `exclude_exchange` | list | Leave these venues out of the ranking |
| `min_volume_usdt` | number | Rank only venues whose 24h volume on the coin is at least this |

Fields: `coin`, `venue_count`, `spread_pct` (highest minus lowest rate), `highest` and `lowest`
(`exchange`, `symbol`, `rate_pct`), `best_short_exchange`, `best_long_exchange`,
`total_volume_24h_usdt`, `inverse_venues`, `rates` (rows as in `/funding`).

Errors: `404 unknown-coin` with `suggestions` (up to 8 tickers sharing the first two letters).

### `GET /api/v1/funding/history`

Scope `history:read`.

| Parameter | Type | Meaning |
|---|---|---|
| `coin` | string | Required |
| `exchange` | list | Venues to include: a list for `1d`, exactly one for `settlement` |
| `exclude_exchange` | list | Venues to leave out (`1d`) |
| `resolution` | string | `1d` (default) or `settlement` |
| `from`, `to` | ISO 8601 UTC | Window. Defaults: 30 days before `to` (`1d`), 7 days (`settlement`); `to` defaults to now |
| `days` | integer | Shortcut for `from`: this many days before `to` (1 to 180). Ignored when `from` is given |
| `order` | string | `asc` (default, oldest first) or `desc` (newest first) |
| `limit` | integer | Points per series at most (1 to 5000), taken after ordering: `order=desc&limit=10` is the last ten |

- `resolution=1d`: one annualised APR point per day for each venue quoting the coin, up to 180
  days per call. Series: `exchange`, `symbol`, `daily[]` of `{ day, apr_pct }`.
- `resolution=settlement`: every settlement print for one venue, up to 92 days per call. Series:
  `exchange`, `symbol`, `settlements[]` of `{ time, rate_pct, interval_hours }`.
- How far back you may reach is the plan's history depth (`403 plan-limit` past it).

### `GET /api/v1/spreads`

Scope `spreads:read`. The spread between two venues' perpetuals on the same coin right now, from
the engine's last print (seconds old). One row per venue pair and symbol, best spread first. This
is what the signals page shows; it says nothing about how often the spread paid, which is the
backtest below.

| Parameter | Type | Meaning |
|---|---|---|
| `coin` | list | Tickers |
| `exchange` | list | Keep pairs with these venues; `exchange_match=all` keeps only pairs with both legs in the list |
| `exclude_exchange` | list | Drop pairs with any of these venues on either leg |
| `min_spread_pct`, `max_spread_pct` | number | Bounds on `spread_pct`; a `max` of 5 drops dead markets that print a phantom spread |
| `direction` | string | `long_a_short_b` or `short_a_long_b`: keep pairs whose better side is that one |
| `min_volume_usdt` | number | Keep pairs where both legs' 24h volume is at least this |
| `min_strength` | integer | Keep pairs whose `strength` is at least this (0 to 5) |
| `max_age_seconds` | integer | Keep pairs printed within this many seconds of `meta.as_of` (1 to 120) |
| `sort` | string | `spread_desc` (default), `spread_asc`, `volume_desc`, `volume_asc` (the smaller leg), `strength_desc`, `updated_desc`, `coin_asc` |
| `limit`, `cursor` | | Pagination |

Response: rows with `symbol`, `coin`, `exchange_a`, `exchange_b`, `spread_pct` (the better of the
two entry directions, percent of price), `direction` (`long_a_short_b` = buy on `exchange_a` and
sell on `exchange_b`, or `short_a_long_b`), `long_a_short_b_pct`, `short_a_long_b_pct`, `bid_a`,
`ask_a`, `bid_b`, `ask_b`, `volume_a_usdt`, `volume_b_usdt`, `funding_rate_a_pct`,
`funding_rate_b_pct` (the venue's current rate per settlement, percent; `null` when unknown),
`funding_interval_hours_a`, `funding_interval_hours_b`, `next_funding_at_a`, `next_funding_at_b`,
`strength` (0 to 5) and `updated_at`. The Demo plan sees the print 15 minutes late.

Examples: `?min_volume_usdt=500000&max_spread_pct=3&min_strength=2` (tradeable pairs only),
`?exchange=Binance,Bybit,Okx&exchange_match=all` (spreads among your three venues),
`?coin=SOL&sort=updated_desc` (the freshest SOL prints).

### `GET /api/v1/spreads/backtest`

Scope `spreads:read`. The public scanner's ranked spreads, priced against real order-book depth
for an order of `size_usd` over the scan window: how often the spread opened and closed and what
a round trip made after fees. A backtest, not the live print.

| Parameter | Type | Meaning |
|---|---|---|
| `size_usd` | integer | `25`, `100` or `500`. Default `100` |
| `coin` | list | Tickers |
| `exchange` | list | Keep spreads with these venues; `exchange_match=all` keeps only pairs with both legs in the list |
| `exclude_exchange` | list | Drop spreads with any of these venues on either leg |
| `min_profit_pct`, `max_profit_pct` | number | Bounds on `est_profit_pct` |
| `min_avg_profit_pct` | number | Floor on `avg_profit_pct` |
| `min_cycles` | integer | Keep spreads that opened and closed at least this many times |
| `min_confidence` | integer | Floor on `confidence` (0 to 100) |
| `min_volume_usdt` | number | Keep spreads where both legs' 24h volume is at least this |
| `active_within_hours` | integer | Keep spreads whose last cycle closed within this many hours (1 to 720) |
| `sort` | string | `profit_desc` (default), `profit_asc`, `avg_profit_desc`, `cycles_desc`, `confidence_desc`, `volume_desc`, `spread_range_desc`, `last_cycle_desc`, `coin_asc` |
| `limit`, `cursor` | | Pagination |

Response: `size_usd`, `withheld_top` (rows withheld on the Demo plan; `0` on paid plans),
`spreads[]` with `symbol`, `coin`, `exchange_a`, `exchange_b`, `est_profit_pct` (per round trip
after fees), `avg_profit_pct`, `fee_pct_cycle`, `spread_range_pct`, `cycles`, `confidence`,
`volume_a_usdt`, `volume_b_usdt`, `last_cycle_at`.

### `GET /api/v1/listings`

Scope `listings:read`. Announced listings and delistings.

| Parameter | Type | Meaning |
|---|---|---|
| `coin` | list | Tickers |
| `exchange` | list | Keep rows on these venues |
| `exclude_exchange` | list | Drop rows on these venues |
| `type` | string | `listing` or `delisting`. Default: both |
| `status` | string | Exact status as the row carries it, e.g. `preopen`, `newly listed`, `delisted` |
| `source` | string | `announcement`, `instruments` or `diff`: how the row was detected |
| `from`, `to` | ISO 8601 UTC | Keep rows effective in `[from, to)` |
| `days_ahead` | integer | Shortcut: effective between now and this many days ahead (1 to 365). Ignored when `from` or `to` is given |
| `detected_after` | ISO 8601 UTC | Keep rows first seen after this time: what is new since your last poll |
| `sort` | string | `nearest` (default: upcoming soonest first, then past most recent first, undated last), `effective_asc`, `effective_desc`, `detected_desc`, `detected_asc`, `coin_asc` |
| `limit`, `cursor` | | Pagination |

Row fields: `exchange`, `symbol`, `coin`, `kind`, `status`, `effective_at`, `source`,
`announcement_url`, `detected_at`.

Polling recipe: call with `detected_after=<the meta.as_of of your previous call>` and
`sort=detected_asc`; an empty `data` means nothing new.

### `GET /api/v1/exchanges`

No scope beyond a valid key. Every venue the API serves: `exchange`, `name`, `icon_url`,
`tracked_instruments`, `max_leverage`, `taker_fee_pct`, `trading_enabled`, `has_spot`, `fee_url`,
`specs_url`, `funding_url`.

| Parameter | Type | Meaning |
|---|---|---|
| `q` | string | Start of a venue name, e.g. `Bi` matches Binance, BingX, Bitget |
| `trading_enabled` | boolean | `true` keeps venues Arbitron can trade on |
| `has_spot` | boolean | `true` keeps venues with a spot market |
| `sort` | string | `name_asc` (default), `instruments_desc`, `fee_asc`, `leverage_desc` |

### `GET /api/v1/coins`

No scope beyond a valid key. Ticker lookup.

| Parameter | Type | Meaning |
|---|---|---|
| `q` | string | Text to match against the ticker (at most 24 characters), e.g. `PE` matches `PEPE` and `PENDLE` |
| `match` | string | `prefix` (default) or `contains` (`PEPE` also finds `1000PEPE`) |
| `exchange` | list | Keep coins quoted on these venues; `exchange_match=all` requires every one of them |
| `min_venues` | integer | Keep coins quoted on at least this many venues |
| `min_volume_usdt` | number | Floor on 24h perpetual volume across venues |
| `sort` | string | `volume_desc` (default), `volume_asc`, `venues_desc`, `venues_asc`, `coin_asc` |
| `limit`, `cursor` | | Pagination |

Row fields: `coin`, `venue_count`, `exchanges` (the venues quoting a linear perpetual on it),
`volume_24h_usdt`, `funding_url`.

### `GET /api/v1/usage`

Scope `usage:read`. Never cached (`Cache-Control: no-store`). Fields: `plan`, `key_prefix`,
`scopes`, `month` and `minute` (`used`, `limit`, `remaining`, `resets_at`), `history_days`
(null = everything held), `delay_seconds`, `commercial_use`, `upgrade_url`.

## MCP server

- Endpoint: `https://arbitron.app/mcp`. Streamable HTTP, stateless.
- Auth: `Authorization: Bearer arb_live_...` (the same key as the REST API), or OAuth 2.1 with
  PKCE S256 for assistants. Protected-resource metadata is at
  `/.well-known/oauth-protected-resource/mcp`; the authorization server is the web app itself.
- Tools (all read-only, fixed order): `arbitron_search_coins`, `arbitron_get_funding_rates`,
  `arbitron_get_coin_funding`, `arbitron_get_funding_history`, `arbitron_get_spreads` (the live
  print), `arbitron_get_spread_backtest` (the scanner), `arbitron_get_listings`,
  `arbitron_get_exchanges`, `arbitron_get_usage`. Each maps to the REST endpoint of the same name and returns structured
  content plus text; the text ends with `meta.url`, the page to cite.
- Errors come back as `isError` results with the same `detail` wording as the REST problem.
- One tool call is one request against the same minute and month windows as the REST API.

Configuration for Claude Desktop, Cursor and ChatGPT:

```json
{
  "mcpServers": {
    "arbitron": {
      "url": "https://arbitron.app/mcp",
      "headers": { "Authorization": "Bearer arb_live_..." }
    }
  }
}
```

## Plans

Four tiers: Demo (free, on every account) and three paid tiers. The current figures (requests
per month, requests per minute, keys, history depth, delay, commercial use) are rendered from
the plan catalog on https://arbitron.app/developers#plans and returned by `GET /api/v1/usage`
for your own key; this document does not repeat them so it cannot go stale.

- Demo: delayed snapshot (`meta.delayed_seconds` = 900), pages capped at 50 rows, the top
  spread rows withheld, one key, attribution to Arbitron required with a link to `meta.url`.
- Paid tiers: live snapshot, full pages, all spread rows, more keys, deeper history. Commercial
  use is included from the second paid tier; the first paid tier covers personal and internal
  use.
- Prime (the top trading plan) includes the first paid API tier at no extra charge.
- Quotas are hard caps. Nothing is billed as overage.

## Rate limits and caching

Two windows per key, both advertised on every response in the IETF fields, plus the
`X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` trio for the minute window:

```
RateLimit-Policy: "minute";q=120;w=60, "month";q=100000;w=2592000
RateLimit:        "minute";r=118;t=41, "month";r=99871;t=1209600
```

- `q` is the quota, `w` the window in seconds, `r` the remaining count, `t` seconds to reset.
- `429 rate-limited` when the minute is spent; `429 quota-exhausted` when the month is spent.
  Both carry `Retry-After` in seconds.
- The month resets on the first day at 00:00 UTC.
- A `304 Not Modified` (send `If-None-Match` with the `ETag` you hold) is refunded on both
  windows.
- One request is one request regardless of endpoint; an MCP tool call counts like a REST call.

## Errors

`application/problem+json` (RFC 9457). Fields: `type` (a URL under
`https://arbitron.app/developers/errors#`), `title`, `status`, `detail` (what to change),
`instance` (the request id, also in `X-Request-Id`), `code`, `docs`, and `suggestions` where a
close match exists.

```json
{
  "type": "https://arbitron.app/developers/errors#unknown-coin",
  "title": "Unknown coin",
  "status": 404,
  "detail": "No venue quotes a perpetual on 'ETHEREUM'. GET /api/v1/coins?q=ETHEREUM lists close matches.",
  "instance": "0HN7Q2K3J9R4P:00000001",
  "code": "unknown-coin",
  "docs": "https://arbitron.app/developers",
  "suggestions": ["ETH", "ETC", "ETHFI"]
}
```

| Status | `code` | Meaning |
|---|---|---|
| 400 | `invalid-parameter` | A query value is out of range or the wrong shape; `detail` names it |
| 401 | `unauthenticated` | No key, or a mistyped, revoked, expired or rotated-out key |
| 403 | `scope-missing` | The key lacks the scope this endpoint needs |
| 403 | `plan-limit` | History deeper than the plan, or a page larger than the Demo plan returns |
| 404 | `unknown-coin` | No venue quotes that ticker; `suggestions` carries close matches |
| 404 | `unknown-exchange` | Not a venue the API serves; `suggestions` lists them |
| 404 | `not-found` | No endpoint at that path under `/api/v1` |
| 429 | `rate-limited` | Minute window spent, or too many rejected keys from one address |
| 429 | `quota-exhausted` | Month spent; resets on the 1st at 00:00 UTC |
| 503 | `snapshot-unavailable` | The snapshot is warming up after a restart; retry in a minute |
| 503 | `api-disabled` | The API is not enabled on this host (a server-side configuration fault) |

## Versioning

- `/api/v1` is the contract. Adding fields, endpoints, enum values or optional parameters is
  not a version change. Removing or renaming anything, changing a type or changing a default is
  `v2`, and `v1` keeps working for twelve months after `v2` ships.
- A deprecated endpoint or field ships `Deprecation: @<unix time>` and `Sunset: <http-date>`
  headers plus `Link: <https://arbitron.app/developers#versioning>; rel="deprecation"`.
- The MCP server's `serverInfo.version` follows the API's version; tool names never change
  within a major version.
- The OpenAPI document at `/api/v1/openapi.json` is versioned with the API.

Changelog:

- 2026-09-22: `fields=` on every list endpoint; response examples in the OpenAPI document;
  `/.well-known/mcp/server-card.json` for directories that cannot scan an authenticated server.
- 2026-09-21: every list endpoint takes the full filter set above: comma-separated `coin`,
  `exchange` and `exclude_exchange`, `min`/`max` bounds on each numeric field, `sort` on every
  field, `exchange_match`, `settles_within_minutes`, `max_age_seconds`, `detected_after`,
  `days`/`order`/`limit` on history, `match=contains` on coins. `/coins` rows carry
  `exchanges`; `/spreads` rows carry the funding intervals and next settlement per leg. The
  MCP tools take the same arguments.
- 2026-09-21: `/spreads` now returns the live print (current spreads, both directions, bid and
  ask per leg); the scanner's backtest moved to `/spreads/backtest`. MCP gained
  `arbitron_get_spread_backtest`; `arbitron_get_spreads` follows the REST change.
- 2026-09-20: v1 published (funding, funding history, spreads, listings, exchanges, coins,
  usage). MCP server with eight tools, key or OAuth.

## Citing

Attribute republished figures as "Data: Arbitron" with a link to `meta.url`, and keep
`meta.as_of` with the number. Name the exchange and the settlement interval beside any funding
rate: the same coin carries different rates on different venues, and a rate quoted without its
interval cannot be compared with another.
