Skip to content

Public API · v1

The data behind Desk, for anyone.

Perpl’s markets and their candles, top traders, a trader’s history, what a wallet holds and did on Monad, who an address is, and which tokens tracked wallets are buying. Read-only, no key, JSON, rate-limited.

Start here

Introduction

The same reads the Desk app makes, at https://trydesk.trade/api/v1, for anyone to call.

Everything served here is public chain data and public profiles, so there is nothing to sign up for and no key to send. It is read-only: nothing places an order, moves funds or knows a key. Desk’s trading key never leaves the phone it was made on.

All routes answer GET and HEAD; OPTIONS answers 204 for preflight. Every response carries Access-Control-Allow-Origin: * and exposes the rate-limit headers, so a browser can call the API straight from a page.

The envelope

Every successful answer, except health, wraps its payload the same way. meta.asOf is when the server produced the answer; meta.cached is true when it came from the server’s own memo rather than a fresh read.

{
  "data": { ... },
  "meta": {
    "asOf": "2026-09-21T03:06:49.038Z",
    "chain": "monad",
    "cached": false
  }
}

Errors are not wrapped. They are RFC 9457 Problem Details with a stable code; see Error codes.

Where the data comes from

Monad mainnet. Perpl’s public context for the market list, Perpl’s exchange contract for open positions, Perpl’s position events through HyperSync for closed trades, a HyperSync-indexed ledger for wallet history, Perpl’s market data for candles, OKX for wallet balances and token prices, and .nad, nad.fun, ENS and Farcaster for names. The API map says which route reads which.

Start here

Quickstart

Three steps and no setup.

  1. Pick a route.

    The index lists every route with its tier. Or start from the API map.

    curl https://trydesk.trade/api/v1
  2. Call it.

    No headers are required. Accept: application/json is polite, not necessary.

    curl "https://trydesk.trade/api/v1/traders/top?limit=5"
  3. Read data.

    The payload is under data; meta.asOf says how fresh it is. Anything that is not a 2xx is a problem document with a code to switch on.

    const res = await fetch("https://trydesk.trade/api/v1/traders/top?limit=5");
    const body = await res.json();
    if (!res.ok) throw new Error(body.code);
    console.log(body.meta.asOf, body.data.traders[0].address);

Every endpoint below has a Try it button that runs the request from this page and shows the status, headers and body.

AI assistants

Desk in ChatGPT & Claude

The same data as an MCP server: nine read-only tools, six of which show a card, at one URL.

https://trydesk.trade/mcp

It speaks the Model Context Protocol over streamable HTTP and needs no key or sign-in. Every tool only reads. None can trade, transfer or sign.

Connect

  • ChatGPT. Open Plugins, select the plus button, then Add custom MCP server. Paste the URL and choose no authentication.
  • Claude. In Settings, under Connectors, add a custom connector with the URL.
  • Claude Code. Run the command below.
  • Any other client that supports streamable HTTP connects the same way.
claude mcp add --transport http desk https://trydesk.trade/mcp

Tools

ToolWhat it answers
search_tokensTokens by name, symbol or contract, across Monad and other chains.
get_trending_tokensWhat’s trading most on a chain, Monad by default. Card.
get_tokenPrice, changes, market cap, liquidity, holders and Desk’s risk check. Card with a chart.
get_token_candlesPrice candles: 1H, 4H or 1D.
get_walletHoldings across chains, Monad trading record, Perpl positions and names. Takes an address or a name such as vitalik.eth. Card.
get_smart_moneyRecent trades by wallets with a winning 30-day record. Card.
get_top_perp_tradersPerpl’s traders with the most open profit, and their positions. Card.
get_perp_trader_statsA Perpl trader’s closed-trade record.
get_perp_marketsPerpl’s markets: mark, change, volume, open interest, funding, leverage. Card.

Cards

Six tools return a card through MCP Apps, so it renders in ChatGPT, Claude and other hosts that support it. The token chart switches between 1H, 4H and 1D, tapping a row asks the assistant about it, and Open in Desk opens the web app. Hosts without cards get the same answer as text.

Limits

The endpoint takes 600 requests a minute per address. Wallet reads take 20 a minute per ChatGPT user, or per address when the host sends no user ID. A limited call answers with how many seconds to wait. What the plugin receives and keeps is on the privacy page.

Start here

Access & rate limits

No key. Limits are per client IP over a sliding minute, in two tiers.

Tiers

TierLimitRoutes
default60 / minIndex, markets, candles, top traders, identity, token signals, health, stats
expensive10 / minTrader history, wallet resource — both read the chain

Two fixed windows stand in for a sliding one: the previous minute counts in proportion to how much of it still overlaps the last sixty seconds, so a burst at the end of one minute is not forgotten at the start of the next.

Headers

Every answer, including errors, carries the tier’s headers. They are exposed to browsers through Access-Control-Expose-Headers.

HeaderMeaning
X-RateLimit-LimitRequests allowed per minute on this route’s tier.
X-RateLimit-RemainingHow many are left in the current window; 0 when over.
X-RateLimit-ResetUnix seconds when the current window resets.
Retry-AfterOnly on 429: whole seconds to wait before trying again.
Cache-ControlHow long a shared cache may keep the answer. Health and every error are no-store.

What a 429 looks like

Captured live by asking for a trader’s history eleven times inside a minute:

HTTP/2 429
Content-Type: application/problem+json
Cache-Control: no-store
Retry-After: 35
X-RateLimit-Limit: 10
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1789960140
{
  "type": "https://trydesk.trade/docs#error-rate_limited",
  "title": "Too many requests",
  "status": 429,
  "detail": "Limit is 10 requests per minute for this route. Try again in 35s.",
  "instance": "/api/v1/traders/0x95d2602d30da1179fd13274839e60345857ca648/history",
  "code": "rate_limited"
}

Backing off

Wait the seconds in Retry-After, then try once more. Do not retry in a loop without it: the window is a minute, and hammering only keeps Remaining at zero.

async function get(url, attempt = 0) {
  const res = await fetch(url, { headers: { Accept: "application/json" } });
  if (res.status === 429 && attempt < 2) {
    const wait = Number(res.headers.get("Retry-After") ?? 5);
    await new Promise((r) => setTimeout(r, wait * 1000));
    return get(url, attempt + 1);
  }
  return res;
}

Caching

Answers carry a server-side TTL — 10 s for markets, 30 s for the top list and stats, 60 s for the index, candles, history, identity and wallets, 15 s for signals — so a popular answer is often served by the edge without touching your quota, which is why X-RateLimit-Remaining sometimes does not move. Health is memoised on the server for 10 s but sent no-store, so a probe is never answered by a cache. If you need more than the tiers give, cache on your side using meta.asOf and the Cache-Control hints, and open an issue if that is not enough.

Start here

Error codes

Problem Details, served as application/problem+json with Cache-Control: no-store.

The code field is stable; match on it rather than on detail, which is written for people. type links to the row below. Captured live from /api/v1/identity/nope:

{
  "type": "https://trydesk.trade/docs#error-invalid_address",
  "title": "Invalid address",
  "status": 400,
  "detail": "Addresses are 0x followed by 40 hex characters.",
  "instance": "/api/v1/identity/nope",
  "code": "invalid_address"
}
CodeHTTPWhenWhat to do
invalid_address400The address in the path is not 0x + 40 hex characters. Refused before any lookup.Fix the address. Checksum case does not matter.
invalid_request400A query parameter is out of range, such as a window that is not 1h, 6h or 24h, or a bar that is not one of the six sizes.Use one of the documented values; detail names them.
not_found404The input is well-formed but the route has nothing for it: a market name that is not an open Perpl market, on candles. Reserved on identity and wallets; in practice both answer a record of nulls instead.Treat as an empty answer. For candles, pick a market from the list.
unknown_route404No such path under /api/v1.Check the API map; paths are case-sensitive.
method_not_allowed405Anything but GET, HEAD or OPTIONS.Use GET. Nothing here takes a body.
rate_limited429Over the tier’s limit for this client IP.Wait Retry-After seconds, then retry once. See Backing off.
upstream_unavailable503Monad, Perpl, HyperSync or the store could not be read in time.Retry with backoff; check /status if it persists.

Start here

Safe inputs

The API takes only things that are already public on chain.

  • Addresses — 0x followed by 40 hex characters, in any case. Anything else is refused with invalid_address before any lookup happens, so a typo never reaches the chain.
  • Market names — letters and digits, 1 to 12 characters, as listed by markets. Case does not matter. Anything else is not a route.
  • Small enumerations — a limit between 1 and 25, a window of 1h, 6h or 24h, a bar from 1m to 1D. Out-of-range values are clamped or refused with invalid_request; each parameter says which.

Never send a private key, a seed phrase, a passkey or a signature. No route accepts one, no route needs one, and no route takes a request body at all. Anything that asks you for those is not this API.

Names are resolved from addresses, never guessed: an ENS or Farcaster name is attached only when that account has verified the address.

Start here

API map

Every route, its tier, how long the edge may keep it, and what it reads.

RouteTierCacheBacked by
GET /api/v1default60 sStatic: name, links, route list.
GET /api/v1/marketsdefault10 sPerpl’s public context: every open market’s config, state and funding.
GET /api/v1/markets/{market}/candlesdefault60 sPerpl’s own candles for the market.
GET /api/v1/traders/topdefault30 sPerpl exchange contract on Monad, every open position on every market.
GET /api/v1/traders/{address}/historyexpensive60 sPerpl position events via HyperSync, folded into per-account records.
GET /api/v1/identity/{address}default60 s.nad names, nad.fun profiles, ENS, Farcaster; Perpl for the account id.
GET /api/v1/wallets/{address}expensive60 sOKX balances and prices across chains; HyperSync ledger on Monad; identity.
GET /api/v1/tokens/signalsdefault15 sLedgers of tracked and leading wallets; OKX prices.
GET /api/v1/healthdefaultno-storeProbes of Redis, Monad RPC, Perpl, the indexing worker, the alerts scan and the trader index.
GET /api/v1/statsdefault30 sCounters in Redis. No addresses.

The index answers with the same list, live:

{
  "data": {
    "name": "Desk API",
    "version": "1",
    "docs": "https://trydesk.trade/docs",
    "openapi": "https://trydesk.trade/openapi.json",
    "health": "/api/v1/health",
    "rateLimits": { "default": "60/min", "expensive": "10/min" },
    "endpoints": [
      { "path": "/api/v1/markets", "tier": "default", "description": "Every open Perpl market: mark, 24h change and volume, open interest, funding, leverage" },
      { "path": "/api/v1/markets/{market}/candles", "tier": "default", "description": "Perpl's own candles for a market. ?bar=1m|5m|15m|1H|4H|1D" },
      { "path": "/api/v1/traders/top", "tier": "default", "description": "Top traders on Perpl by unrealised PnL. ?limit=1..25" },
      { "path": "/api/v1/traders/{address}/history", "tier": "expensive", "description": "A trader's closed trades and statistics" }
    ]
  },
  "meta": { "asOf": "2026-09-21T03:06:42.396Z", "chain": "monad", "cached": false }
}

Markets

Markets

Every open market on Perpl: mark, 24h change and volume, open interest, TVL, funding, the leverage cap, fees and margin, as plain decimal numbers.

GET /api/v1/markets

Perpl publishes its market list as fixed-point integers at each market’s own scale; this route does the division so a page can print the numbers as they are. Prices come out in the market’s priceDecimals (BTC at one decimal, MON at six), money in the collateral token, and rates as fractions. Markets the venue has closed are left out, so isOpen is always true. The list is the venue: id is the same number the leaderboard’s positions[].marketId carries, and name is what candles takes.

Parameters

None.

Response

Captured live, trimmed to two markets. Content-Type: application/json.

{
  "data": {
    "collateral": "AUSD",
    "markets": [
      {
        "id": 1,
        "name": "BTC",
        "mark": 81336.1,
        "prev": 80450.8,
        "change": 0.011004241101393683,
        "volume24h": 40231735.848404,
        "openInterest": 444435.90425900003,
        "tvl": 165893.458031,
        "fundingRate": 0.00004,
        "fundingIntervalSec": 2580,
        "maxLeverage": 15,
        "priceDecimals": 1,
        "sizeDecimals": 5,
        "makerFee": 45,
        "takerFee": 345,
        "maintenanceMargin": 2500,
        "isOpen": true
      },
      {
        "id": 10,
        "name": "MON",
        "mark": 0.024766,
        "prev": 0.024106,
        "change": 0.027379075748776276,
        "volume24h": 455063.158205,
        "openInterest": 173152.47964,
        "tvl": 116062.974387,
        "fundingRate": 0,
        "fundingIntervalSec": 2580,
        "maxLeverage": 10,
        "priceDecimals": 6,
        "sizeDecimals": 0,
        "makerFee": 45,
        "takerFee": 345,
        "maintenanceMargin": 2000,
        "isOpen": true
      }
    ]
  },
  "meta": { "asOf": "2026-09-21T04:37:15.970Z", "chain": "monad", "cached": false }
}

Fields

data.collateralstring
Symbol of the collateral token every money figure below is in: AUSD.
data.markets[]array
Every open market, in the venue’s order. Eight at the time of writing: BTC, MON, ETH, SOL, HYPE, ZEC, LIT, PUMP.
markets[].idinteger
Perpl’s numeric perp id; the same number as positions[].marketId on top traders.
markets[].namestring
Market symbol, e.g. BTC. The value {market} takes on candles.
markets[].marknumber
Current mark price, already scaled: 81336.1 with priceDecimals 1, 0.024766 with 6.
markets[].prevnumber
The mark 24 hours ago, same scale.
markets[].changenumber | null
24h move as a fraction of prev: 0.011 is +1.1%. null when prev is zero.
markets[].volume24hnumber
Notional traded in the last 24 hours, in the collateral token.
markets[].openInterestnumber
Open interest in the collateral token: units open × mark.
markets[].tvlnumber
Collateral locked in the market.
markets[].fundingRatenumber | null
Funding as a fraction per interval: 0.00004 is 0.004% every fundingIntervalSec seconds. The contract calls it fundingRatePct100k. null when the venue reports none.
markets[].fundingIntervalSecinteger | null
Seconds between funding payments; 2580 is 43 minutes.
markets[].maxLeverageinteger
Whole-number leverage cap: 100 over the initial-margin percentage, rounded down.
markets[].priceDecimals markets[].sizeDecimalsinteger
Decimals the market quotes prices and sizes in. mark, prev and the candles are already scaled by the first.
markets[].makerFee markets[].takerFeeinteger
Fees in micros of notional: 45 is 0.0045%, 345 is 0.0345%.
markets[].maintenanceMargininteger
Perpl’s maintenance divisor in hundredths: the maintenance fraction is 100 / value, so 2500 is 4% and 2000 is 5%.
markets[].isOpenboolean
Always true here; closed markets are filtered out.

Rate limit tier default · 60 / min · Cache-Control public, s-maxage=10, stale-while-revalidate=60

Markets

Candles

Perpl’s candles for a market at one of six bar sizes, oldest first, up to 300 of them.

GET /api/v1/markets/{market}/candles

These come from Perpl’s own market data, the same venue as the mark, so a market has a chart from the day it lists. Any open market in markets works; any other name answers not_found. The last candle is still forming and moves until its bar closes.

Parameters

market path string required letters and digits · 1 to 12

A market name from markets. Case does not matter; the answer uppercases it. A name that is not an open Perpl market is refused with not_found; anything that is not letters and digits is not a route. Example: BTC.

BTCETHSOLMONHYPEZECLITPUMPVVVNEAR
bar query string optional default 15m

Candle size. Case matters: minutes are lowercase, hours and days uppercase. Anything else is refused with invalid_request. Example: ?bar=1H.

1m5m15m1H4H1D

Response

Captured live for BTC with ?bar=1H, trimmed to the last two of 300 candles. Content-Type: application/json.

{
  "data": {
    "market": "BTC",
    "bar": "1H",
    "candles": [
      {
        "time": 1790870400,
        "open": 84113.8,
        "high": 84582.8,
        "low": 84035.9,
        "close": 84155.1,
        "volume": 809881.97941
      },
      {
        "time": 1790874000,
        "open": 84155.1,
        "high": 85022.7,
        "low": 84154.5,
        "close": 84983.5,
        "volume": 400567.996151
      }
    ]
  },
  "meta": { "asOf": "2026-10-01T17:48:04.262Z", "chain": "monad", "cached": false }
}

Fields

data.marketstring
The market asked for, uppercased.
data.bar"1m" | "5m" | "15m" | "1H" | "4H" | "1D"
The candle size the answer covers.
data.candles[]array
Oldest first, at most 300. The last one is still forming.
candles[].timeinteger
Unix seconds the candle opened. Consecutive candles are one bar apart.
candles[].open candles[].high candles[].low candles[].closenumber
Prices on Perpl, already scaled by the market’s priceDecimals.
candles[].volumenumber
AUSD traded on Perpl in the candle; about 810K in the first above.

Rate limit tier default · 60 / min · Cache-Control public, s-maxage=60, stale-while-revalidate=300

Traders

Top traders

Traders on Perpl ranked by unrealised PnL across their open positions, with every position.

GET /api/v1/traders/top

The exchange keeps every open position in a list per market; this route pages through all of them, sums each account’s unrealised PnL and sorts. Ties and empty books are ordered by position value so the list is stable between refreshes.

Parameters

limit query integer optional default 25 · 1 to 25

How many traders to return. Values outside the range are clamped, not refused; anything that is not a number falls back to 25. Example: ?limit=5.

Response

Captured live with ?limit=3, trimmed to two traders. Content-Type: application/json.

{
  "data": {
    "observedAt": 1789960004040,
    "traders": [
      {
        "accountId": "3901",
        "address": "0x95D2602d30DA1179fd13274839e60345857ca648",
        "pnl": "9995.72",
        "balance": null,
        "positions": [
          {
            "market": "ETH",
            "marketId": 20,
            "side": "long",
            "entry": "1847.99",
            "mark": "2666.85",
            "size": "5",
            "collateral": "2309.98",
            "value": "13334.25",
            "pnl": "4122.97",
            "pnlPercent": 178.48,
            "leverage": 4,
            "entryBlock": 90179398
          },
          {
            "market": "ZEC",
            "marketId": 50,
            "side": "long",
            "entry": "535.24",
            "mark": "1519.32",
            "size": "6",
            "collateral": "1278.72",
            "value": "9115.92",
            "pnl": "5778.24",
            "pnlPercent": 451.87,
            "leverage": 2.5,
            "entryBlock": 88359155
          }
        ]
      },
      {
        "accountId": "2104",
        "address": "0xcCdDcC9869456aFBdEE89d302e064d63e4132406",
        "pnl": "2674.26",
        "balance": null,
        "positions": [
          {
            "market": "BTC",
            "marketId": 1,
            "side": "long",
            "entry": "76730",
            "mark": "81391",
            "size": "0.6",
            "collateral": "6484.22",
            "value": "48834.6",
            "pnl": "2674.26",
            "pnlPercent": 41.24,
            "leverage": 7,
            "entryBlock": 105648520
          }
        ]
      }
    ]
  },
  "meta": { "asOf": "2026-09-21T03:06:44.040Z", "chain": "monad", "cached": false }
}

Fields

data.observedAtinteger
Unix milliseconds when the book was scanned.
data.traders[]array
Ranked by pnl, highest first; at most limit entries.
traders[].accountIdstring
The trader’s Perpl account id, as a decimal string.
traders[].addressstring
The wallet that owns the account, EIP-55 checksummed.
traders[].pnlstring
Unrealised PnL summed across open positions, in collateral units (USD-denominated, 2 decimals). A string so precision is never lost.
traders[].balancestring | null
Always null on this route; the leaderboard does not read account balances.
traders[].positions[]array
Open positions, largest value first. One per market at most.
positions[].marketstring
Market symbol, e.g. BTC.
positions[].marketIdinteger
Perpl’s numeric perp id for the market.
positions[].side"long" | "short"
Direction of the position.
positions[].entrystring
Average entry price, in the market’s price decimals.
positions[].markstring
Current mark price the venue values the position at.
positions[].sizestring
Position size in the base asset (lots, scaled by the market’s size decimals).
positions[].collateralstring
Collateral posted for the position, 2 decimals.
positions[].valuestring
Notional at the mark price: size × mark, 2 decimals.
positions[].pnlstring
Unrealised PnL on this position, 2 decimals.
positions[].pnlPercentnumber | null
pnl / collateral × 100; null if no collateral is recorded.
positions[].leveragenumber | null
Entry notional over collateral, one decimal — what the venue sized the position with.
positions[].entryBlockinteger
Monad block at which the position was opened.

Rate limit tier default · 60 / min · Cache-Control public, s-maxage=30, stale-while-revalidate=300

Traders

Trader history

A trader’s closed trades on Perpl with win rate, profit factor, drawdown, hold time, leverage and a one-line reading of their style.

GET /api/v1/traders/{address}/history

Perpl lists open positions but keeps no trade history, so Desk folds the exchange’s open, close, decrease and liquidation events into one record per account. A wallet with no Perpl account answers stats: null and an empty trades; an account whose events have not been indexed yet answers the same with its accountId filled in.

Parameters

address path string required 0x + 40 hex

The trader’s wallet address. Case does not matter. Anything else is refused with invalid_address. Example: 0x95d2602d30da1179fd13274839e60345857ca648.

Response

Captured live for 0xa8805F0471c128270c49aD5B8D62111c7117c9c9, an account with one closed trade on record.

{
  "data": {
    "address": "0xa8805F0471c128270c49aD5B8D62111c7117c9c9",
    "accountId": "3679",
    "stats": {
      "trades": 1,
      "wins": 1,
      "losses": 0,
      "winRate": 1,
      "profitFactor": null,
      "realised": 0.48,
      "grossProfit": 0.48,
      "grossLoss": 0,
      "maxDrawdown": 0,
      "bestStreak": 1,
      "worstStreak": 0,
      "currentStreak": 1,
      "averageHoldSeconds": null,
      "averageLeverage": null,
      "liquidations": 0,
      "volume": 0,
      "funding": 0.23,
      "since": 1789248594,
      "lastActive": 1789248594,
      "bestMarket": { "symbol": "SOL", "pnl": 0.483118, "count": 1 },
      "worstMarket": null,
      "tags": [],
      "score": 0
    },
    "summary": "Trader. Wins 100% of 1 closed trades, best on SOL.",
    "summarySource": "figures",
    "trades": [
      {
        "time": 1789248594,
        "market": "SOL",
        "side": "long",
        "entry": null,
        "exit": 101.432,
        "pnl": 0.483118,
        "holdSeconds": null,
        "leverage": null,
        "liquidated": false
      }
    ]
  },
  "meta": { "asOf": "2026-09-21T03:07:43.859Z", "chain": "monad", "cached": false }
}

Fields

data.addressstring
The address asked for, as given.
data.accountIdstring
Perpl account id. Absent when the wallet has no Perpl account.
data.statsobject | null
null when there is no account or no indexed record yet.
stats.tradesinteger
Closed round trips (a close or a liquidation ends one).
stats.wins stats.lossesinteger
Round trips that ended at or above zero, and below zero.
stats.winRatenumber | null
wins / trades, 0–1. null with no trades.
stats.profitFactornumber | null
grossProfit / grossLoss. null when there are profits but no losses; 0 when there is neither.
stats.realisednumber
Cumulative realised PnL in collateral units, including partial closes.
stats.grossProfit stats.grossLossnumber
Sum of winning and of losing round trips; both non-negative.
stats.maxDrawdownnumber
Largest peak-to-trough fall in cumulative realised PnL.
stats.bestStreak stats.worstStreak stats.currentStreakinteger
Longest run of wins, longest run of losses (positive), and the run in progress (negative while losing).
stats.averageHoldSecondsnumber | null
Mean seconds between open and close, over trades whose open was seen.
stats.averageLeveragenumber | null
Mean leverage at open, over opens seen.
stats.liquidationsinteger
Positions closed by the venue.
stats.volumenumber
Notional opened, in USD, over opens seen with a priceable market.
stats.fundingnumber
Net funding paid or received across closes.
stats.since stats.lastActiveinteger | null
Unix seconds of the first and last event seen.
stats.bestMarket stats.worstMarketobject | null
{ symbol, pnl, count }: the market with the highest positive, and lowest negative, realised PnL. null when none qualifies.
stats.tagsstring[]
Style words from the figures: Scalper, Day trader, Swing trader, High leverage, Low leverage, <SYM> specialist, Long-biased, Short-biased, Liquidated once / Liquidated N×.
stats.scoreinteger | null
0–100. Win rate, profit factor and drawdown, weighted by how many trades and how much volume back them, less 5 per liquidation. It counts in full from 50 closed trades, so read it beside stats.trades. null with no trades.
data.summarystring
One or two sentences about the style, built only from the figures.
data.summarySource"ai" | "figures"
Whether a language model phrased it (from the same figures) or the server did.
data.trades[]array
Newest first, at most the last 20 closed round trips.
trades[].timeinteger
Unix seconds the round trip closed.
trades[].marketstring
Market symbol.
trades[].side"long" | "short"
Direction.
trades[].entry trades[].exitnumber | null
Entry and exit price. null when the open predates the index or the market cannot be priced.
trades[].pnlnumber
Realised PnL for the whole round trip, partial closes included.
trades[].holdSecondsnumber | null
Seconds held, when the open was seen.
trades[].leveragenumber | null
Leverage at open, when seen.
trades[].liquidatedboolean
Whether the venue closed it.

Rate limit tier expensive · 10 / min · Cache-Control public, s-maxage=60, stale-while-revalidate=600

Identity

Resolve an address

Who an address is: a .nad name, a nad.fun profile, an ENS name or the Farcaster account that verified it, plus its Perpl account id.

GET /api/v1/identity/{address}

Sources are asked in order — .nad, nad.fun, ENS, Farcaster — and the first with a name wins name and source. An avatar, bio or X handle that source lacks is borrowed from the others. Only a Farcaster account that has verified the address counts; a nad.fun profile that is just the default picture and the address as nickname is not a profile. A wallet with none of them answers nulls, and the app shows the address. Answers are remembered for a day.

Parameters

address path string required 0x + 40 hex

The wallet to name. Case does not matter; the answer lowercases it. Example: 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045.

Response

Captured live. A wallet with nothing attached answers the same keys with null values (and perplAccount set when it trades on Perpl).

{
  "data": {
    "address": "0xd8da6bf26964af9d7eed9e03e53415d37aa96045",
    "name": "vitalik.eth",
    "source": "ens",
    "avatar": "https://euc.li/vitalik.eth",
    "bio": "hullo",
    "x": "VitalikButerin",
    "farcaster": "vitalik.eth",
    "perplAccount": null
  },
  "meta": { "asOf": "2026-09-21T03:06:45.121Z", "chain": "monad", "cached": false }
}

Fields

data.addressstring
The address, lowercased.
data.namestring | null
Display name from the winning source.
data.source"nad" | "nadfun" | "ens" | "farcaster" | null
Which source supplied name.
data.avatarstring | null
Picture URL, from the named source or the first source that has one.
data.biostring | null
Profile text, from nad.fun or Farcaster.
data.xstring | null
X handle without the @, from the ENS com.twitter record.
data.farcasterstring | null
Farcaster username of the account that verified this address; the most followed if several did.
data.perplAccountstring | null
Perpl account id when the wallet has one, else null.

Rate limit tier default · 60 / min · Cache-Control public, s-maxage=60, stale-while-revalidate=600

Wallets

Wallet resource

Everything Desk knows about a wallet in one answer: identity, holdings on every chain it reads, and its trade ledger on Monad with realised and unrealised PnL.

GET /api/v1/wallets/{address}

A wallet seen for the first time is indexed on the spot, within the request’s time budget, and continued by a worker afterwards; ledger.status says whether the ledger is complete or still being built. The first call for an unseen wallet can take up to 25 seconds. Balances and identity never wait on the chain.

Parameters

address path string required 0x + 40 hex

The wallet to read. Case does not matter; the answer lowercases it. Example: 0x95d2602d30da1179fd13274839e60345857ca648.

Response

Captured live; ledger.trades trimmed to two of six. This wallet holds nothing OKX prices, so portfolio is null and holdings is empty.

{
  "data": {
    "address": "0x95d2602d30da1179fd13274839e60345857ca648",
    "observedAt": 1789960007335,
    "identity": {
      "address": "0x95d2602d30da1179fd13274839e60345857ca648",
      "name": null,
      "source": null,
      "avatar": null,
      "bio": null,
      "x": null,
      "farcaster": null,
      "perplAccount": "3901"
    },
    "portfolio": null,
    "chains": [],
    "held": null,
    "holdings": [],
    "ledger": {
      "status": "ready",
      "indexedAt": 1789960006791,
      "behind": 0,
      "realized": -3.868253284869727,
      "unrealized": 0,
      "value": 0,
      "winRate": 0.6666666666666666,
      "last7d": { "realized": 0, "trades": 2, "winRate": 1 },
      "last30d": { "realized": -3.868253284869727, "trades": 6, "winRate": 0.6666666666666666 },
      "tokens": [
        {
          "token": "0x00000000efe302beaa2b3e6e1b18d08d69a9012a",
          "symbol": "AUSD",
          "holding": 0,
          "unpriced": 0,
          "averageCost": null,
          "price": 1.0022532779189006,
          "value": 0,
          "realized": -3.868253284869727,
          "unrealized": 0
        }
      ],
      "trades": [
        {
          "time": 1789797877000,
          "hash": "0x171fdca313496c0757fb42e093fbd2fe57fefa82367606453beb7b462adf078d",
          "token": "0x00000000efe302beaa2b3e6e1b18d08d69a9012a",
          "symbol": "AUSD",
          "side": "sell",
          "amount": 2039.77,
          "price": 0.9983990165892015,
          "value": 2036.5043620681556,
          "gain": 0
        },
        {
          "time": 1789797730000,
          "hash": "0xfadf1a569b44448b252cc8b6c4480c35d148ce6f88e8578f87c0798ab914c3dc",
          "token": "0x00000000efe302beaa2b3e6e1b18d08d69a9012a",
          "symbol": "AUSD",
          "side": "buy",
          "amount": 2039.77,
          "price": 0.9983990165892015,
          "value": 2036.5043620681556,
          "gain": null
        }
      ]
    },
    "labels": [
      { "code": "perpl", "text": "Trades perps here · Perpl #3901" }
    ],
    "logos": {
      "143:0x00000000efe302beaa2b3e6e1b18d08d69a9012a": "https://static.oklink.com/cdn/web3/currency/token/large/143-0x00000000efe302beaa2b3e6e1b18d08d69a9012a-105/type=default_90_0?v=1789931285453"
    }
  },
  "meta": { "asOf": "2026-09-21T03:06:47.373Z", "chain": "monad", "cached": false }
}

Fields

data.addressstring
The wallet, lowercased.
data.observedAtinteger
Unix milliseconds when the answer was assembled.
data.identityobject | null
The same record identity returns, or null if the resolver failed.
data.portfolionumber | null
USD value of every priced holding across chains; null when nothing could be priced.
data.chains[]array
{ chainIndex, chain, value } per chain with priced holdings, largest first.
data.heldnull
Always null on this route; it is filled only when the app asks about one contract.
data.holdings[]array
Token balances, largest value first, risk-flagged tokens removed. Each: chainIndex (string, OKX chain index; Monad is "143"), chain (name or null), contract (lowercased; "" for the native coin), symbol, balance (number), value (USD or null).
data.ledger.status"ready" | "indexing" | "unavailable"
ready when the Monad ledger reaches the chain tip; indexing when the worker is still catching up; unavailable when it could not be read at all, in which case only detail and behind accompany it.
ledger.indexedAtinteger
Unix milliseconds of the last index pass.
ledger.behindinteger | null
Blocks between the ledger and the chain tip; 0 when ready.
ledger.realized ledger.unrealizednumber
Realised PnL from sells, and unrealised PnL on what is still held, in USD.
ledger.valuenumber
USD value of tokens the ledger tracks, at current prices.
ledger.winRatenumber | null
Winning sells over all sells with a decided outcome, 0–1.
ledger.last7d ledger.last30dobject
{ realized, trades, winRate } over the last 7 and 30 days.
ledger.tokens[]array
Per token, largest value first: token (contract), symbol, holding (units held), unpriced (units acquired without a known cost), averageCost (USD per unit or null), price (current USD or null), value, realized, unrealized (null when unpriced).
ledger.trades[]array
Newest first, at most 60: time (Unix ms), hash (transaction), token, symbol, side (buy | sell), amount (units), price (USD per unit), value (USD), gain (realised USD on a sell; null on a buy).
data.labels[]array
Descriptions drawn from the ledger, never verdicts: { code, text } with code one of bot (50+ trades today), contract (receives but never sends), top (wins 55%+ of 20+ closed trades), perpl (has a Perpl account), fresh (first trade under a week ago).
data.logosobject
Picture URLs keyed "chainIndex:contract" for the tokens above; the native coin is keyed "143:".

Rate limit tier expensive · 10 / min · Cache-Control public, s-maxage=60, stale-while-revalidate=600

Signals

Token signals

Tokens that tracked and leading wallets have been buying, scored by how many bought, how much net, how early and how concentrated.

GET /api/v1/tokens/signals

The wallets listened to are the Perpl leaderboard, wallets people follow in the app, and tracked wallets that have proven themselves (20+ trades at a 55%+ win rate); anything trading like a bot is dropped. A token needs at least two buyers of $100 or more and a score of 50 to appear; stablecoins and MON wrappers are excluded. Up to 25 signals, best first. Recomputed at most once a minute.

Parameters

window query string optional default 6h

How far back to look. Anything else is refused with invalid_request.

1h6h24h

Response

Captured live: a quiet window answers an empty list, which is a real answer, not an error.

{
  "data": {
    "observedAt": 1789960007855,
    "window": "6h",
    "signals": []
  },
  "meta": { "asOf": "2026-09-21T03:06:47.885Z", "chain": "monad", "cached": false }
}

One entry of signals, in the shape the server builds it:

{
  "token": "0x1234567890abcdef1234567890abcdef12345678",
  "symbol": "CHOG",
  "chainIndex": "143",
  "score": 82,
  "strong": true,
  "summary": "3 top traders bought CHOG in the last 6 hours · +$4.2K net",
  "buyers": [
    { "address": "0x95d2602d30da1179fd13274839e60345857ca648" },
    { "address": "0xccddcc9869456afbdee89d302e064d63e4132406" }
  ],
  "distinct": 3,
  "netUsd": 4210.5,
  "firstBuyShare": 0.667,
  "warning": null,
  "price": 0.0412
}

Fields

data.observedAtinteger
Unix milliseconds when the signals were computed.
data.window"1h" | "6h" | "24h"
The window the answer covers.
data.signals[]array
Best score first, then net inflow; at most 25.
signals[].tokenstring
Token contract on Monad, lowercased.
signals[].symbolstring
Token symbol as last seen in a trade.
signals[].chainIndex"143"
Always Monad.
signals[].scoreinteger
0–100: up to 40 for distinct buyers, 25 for net inflow, 15 for first-time buys, 20 for freshness, minus 30 when one wallet is over 70% of the inflow.
signals[].strongboolean
score >= 75.
signals[].summarystring
One line for people, e.g. “3 top traders bought CHOG in the last 6 hours · +$4.2K net”.
signals[].buyers[]array
Up to three { address }, biggest buyer first.
signals[].distinctinteger
How many qualified wallets bought.
signals[].netUsdnumber
Qualified buys minus sells over the window, USD, 2 decimals.
signals[].firstBuySharenumber
Share of buys that were a wallet’s first ever in the token, 0–1.
signals[].warningstring | null
Set when one wallet is over 70% of the inflow, e.g. “1 wallet is 84% of it”.
signals[].pricenumber | null
Current USD price, or null if unpriced.

Rate limit tier default · 60 / min · Cache-Control public, s-maxage=15, stale-while-revalidate=120

Monitoring

Health

Service health in the application/health+json shape: Redis, Monad RPC, Perpl, the indexing worker, the alerts scan and the trader index.

GET /api/v1/health

The one route that is not wrapped in the envelope: it follows the IETF health-check draft so monitors can read it directly. 200 on pass or warn, 503 on fail. Redis and the worker are critical; the rest only warn. Probes time out at 2.5 s; the report is memoised for 10 s and sent no-store. HEAD gives the status code alone. Rendered at /status.

Parameters

None.

Response

Shape as the server sends it, in a warn state: the alerts scan had not run for 52 minutes. Content-Type: application/health+json.

{
  "status": "warn",
  "version": "1",
  "releaseId": "",
  "time": "2026-09-21T03:06:48.408Z",
  "checks": {
    "redis:responseTime": [
      { "status": "pass", "observedValue": 7, "observedUnit": "ms", "time": "2026-09-21T03:06:48.408Z" }
    ],
    "monad-rpc:responseTime": [
      { "status": "pass", "observedValue": 16, "observedUnit": "ms", "time": "2026-09-21T03:06:48.408Z", "block": 106637740 }
    ],
    "perpl:responseTime": [
      { "status": "pass", "observedValue": 36, "observedUnit": "ms", "time": "2026-09-21T03:06:48.408Z" }
    ],
    "worker:heartbeatAge": [
      { "status": "pass", "observedValue": 81, "observedUnit": "s", "time": "2026-09-21T03:06:48.408Z", "wallets": 22, "behind": 0 }
    ],
    "cron:lastRunAge": [
      { "status": "warn", "observedValue": 3119, "observedUnit": "s", "time": "2026-09-21T03:06:48.408Z" }
    ],
    "trader-index:lastAdvanceAge": [
      { "status": "pass", "observedValue": 412, "observedUnit": "s", "time": "2026-09-21T03:06:48.408Z", "block": 106637700, "behind": 0 }
    ]
  }
}

Fields

status"pass" | "warn" | "fail"
Overall. fail if Redis or the worker fails; warn if anything else is not passing.
versionstring
API version, "1".
releaseIdstring | null
Short git commit of the deployment; empty when the platform did not supply one.
timestring
When the probes ran, ISO 8601.
checksobject
Each key maps to an array with one check. Every check has status (pass | warn | fail | unknown), observedValue (number or null), observedUnit (ms | s) and time; a failed probe adds output with the reason.
checks["redis:responseTime"]ms
Round trip to the store. Critical.
checks["monad-rpc:responseTime"]ms
eth_blockNumber against the Monad RPC; adds block.
checks["perpl:responseTime"]ms
Perpl’s public context endpoint.
checks["worker:heartbeatAge"]s
Seconds since the indexing worker last reported; warn over 4 min, fail over 10. Adds wallets tracked and blocks behind. Critical.
checks["cron:lastRunAge"]s
Seconds since the alerts scan last ran; warn over 15 min, fail over 2 h.
checks["trader-index:lastAdvanceAge"]s
Seconds since the trader index behind scores and trader histories last moved forward; warn over 45 min, fail over 3 h. Adds the block it reached and how many blocks it was behind. Not critical.

Rate limit tier default · 60 / min · Cache-Control no-store

Monitoring

Public stats

Counters only: requests served today, wallets being tracked, alert subscriptions. No addresses, ever.

GET /api/v1/stats

Parameters

None.

Response

Captured live.

{
  "data": {
    "day": "2026-09-21",
    "requestsToday": 14,
    "trackedWallets": 22,
    "alertSubscriptions": 3
  },
  "meta": { "asOf": "2026-09-21T03:06:49.038Z", "chain": "monad", "cached": false }
}

Fields

data.daystring
The UTC day the counter belongs to, YYYY-MM-DD.
data.requestsTodayinteger
Requests to /api/v1 routed so far today, across every client.
data.trackedWalletsinteger
Wallets whose Monad ledger the worker keeps up to date.
data.alertSubscriptionsinteger
Devices subscribed to trade alerts.

Rate limit tier default · 60 / min · Cache-Control public, s-maxage=30, stale-while-revalidate=300