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.
-
Pick a route.
The index lists every route with its tier. Or start from the API map.
curl https://trydesk.trade/api/v1 -
Call it.
No headers are required.
Accept: application/jsonis polite, not necessary.curl "https://trydesk.trade/api/v1/traders/top?limit=5" -
Read
data.The payload is under
data;meta.asOfsays how fresh it is. Anything that is not a 2xx is a problem document with acodeto 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
| Tool | What it answers |
|---|---|
search_tokens | Tokens by name, symbol or contract, across Monad and other chains. |
get_trending_tokens | What’s trading most on a chain, Monad by default. Card. |
get_token | Price, changes, market cap, liquidity, holders and Desk’s risk check. Card with a chart. |
get_token_candles | Price candles: 1H, 4H or 1D. |
get_wallet | Holdings across chains, Monad trading record, Perpl positions and names. Takes an address or a name such as vitalik.eth. Card. |
get_smart_money | Recent trades by wallets with a winning 30-day record. Card. |
get_top_perp_traders | Perpl’s traders with the most open profit, and their positions. Card. |
get_perp_trader_stats | A Perpl trader’s closed-trade record. |
get_perp_markets | Perpl’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
| Tier | Limit | Routes |
|---|---|---|
default | 60 / min | Index, markets, candles, top traders, identity, token signals, health, stats |
expensive | 10 / min | Trader 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.
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Requests allowed per minute on this route’s tier. |
X-RateLimit-Remaining | How many are left in the current window; 0 when over. |
X-RateLimit-Reset | Unix seconds when the current window resets. |
Retry-After | Only on 429: whole seconds to wait before trying again. |
Cache-Control | How 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"
}
| Code | HTTP | When | What to do |
|---|---|---|---|
invalid_address | 400 | The address in the path is not 0x + 40 hex characters. Refused before any lookup. | Fix the address. Checksum case does not matter. |
invalid_request | 400 | A 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_found | 404 | The 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_route | 404 | No such path under /api/v1. | Check the API map; paths are case-sensitive. |
method_not_allowed | 405 | Anything but GET, HEAD or OPTIONS. | Use GET. Nothing here takes a body. |
rate_limited | 429 | Over the tier’s limit for this client IP. | Wait Retry-After seconds, then retry once. See Backing off. |
upstream_unavailable | 503 | Monad, 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 —
0xfollowed by 40 hex characters, in any case. Anything else is refused withinvalid_addressbefore 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
limitbetween 1 and 25, awindowof1h,6hor24h, abarfrom1mto1D. Out-of-range values are clamped or refused withinvalid_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.
| Route | Tier | Cache | Backed by |
|---|---|---|---|
GET /api/v1 | default | 60 s | Static: name, links, route list. |
GET /api/v1/markets | default | 10 s | Perpl’s public context: every open market’s config, state and funding. |
GET /api/v1/markets/{market}/candles | default | 60 s | Perpl’s own candles for the market. |
GET /api/v1/traders/top | default | 30 s | Perpl exchange contract on Monad, every open position on every market. |
GET /api/v1/traders/{address}/history | expensive | 60 s | Perpl position events via HyperSync, folded into per-account records. |
GET /api/v1/identity/{address} | default | 60 s | .nad names, nad.fun profiles, ENS, Farcaster; Perpl for the account id. |
GET /api/v1/wallets/{address} | expensive | 60 s | OKX balances and prices across chains; HyperSync ledger on Monad; identity. |
GET /api/v1/tokens/signals | default | 15 s | Ledgers of tracked and leading wallets; OKX prices. |
GET /api/v1/health | default | no-store | Probes of Redis, Monad RPC, Perpl, the indexing worker, the alerts scan and the trader index. |
GET /api/v1/stats | default | 30 s | Counters 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.
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[].marketIdon top traders. markets[].namestring- Market symbol, e.g.
BTC. The value{market}takes on candles. markets[].marknumber- Current mark price, already scaled:
81336.1withpriceDecimals1,0.024766with 6. markets[].prevnumber- The mark 24 hours ago, same scale.
markets[].changenumber | null- 24h move as a fraction of
prev:0.011is +1.1%.nullwhenprevis 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.00004is 0.004% everyfundingIntervalSecseconds. The contract calls itfundingRatePct100k.nullwhen 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[].priceDecimalsmarkets[].sizeDecimalsinteger- Decimals the market quotes prices and sizes in.
mark,prevand the candles are already scaled by the first. markets[].makerFeemarkets[].takerFeeinteger- Fees in micros of notional:
45is 0.0045%,345is 0.0345%. markets[].maintenanceMargininteger- Perpl’s maintenance divisor in hundredths: the maintenance fraction is
100 / value, so2500is 4% and2000is 5%. markets[].isOpenboolean- Always
truehere; closed markets are filtered out.
Over 60 requests in a minute from one IP. Carries Retry-After. Shape as the server sends it; the live capture is under Access & rate limits.
{
"type": "https://trydesk.trade/docs#error-rate_limited",
"title": "Too many requests",
"status": 429,
"detail": "Limit is 60 requests per minute for this route. Try again in 35s.",
"instance": "/api/v1/markets",
"code": "rate_limited"
}
Perpl’s public context did not answer or answered something that was not a market list. detail ends with what went wrong. Shape as the server sends it.
{
"type": "https://trydesk.trade/docs#error-upstream_unavailable",
"title": "Upstream unavailable",
"status": 503,
"detail": "Perpl's market list could not be read: Perpl HTTP 502",
"instance": "/api/v1/markets",
"code": "upstream_unavailable"
}
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.
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
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.
BTCETHSOLMONHYPEZECLITPUMPVVVNEARCandle size. Case matters: minutes are lowercase, hours and days uppercase. Anything else is refused with invalid_request. Example: ?bar=1H.
1m5m15m1H4H1DResponse
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
barapart. candles[].opencandles[].highcandles[].lowcandles[].closenumber- Prices on Perpl, already scaled by the market’s
priceDecimals. candles[].volumenumber- AUSD traded on Perpl in the candle; about
810Kin the first above.
Captured live from ?bar=2H. Checked before the market name, so an unknown market with a bad bar answers this.
{
"type": "https://trydesk.trade/docs#error-invalid_request",
"title": "Invalid request",
"status": 400,
"detail": "bar must be one of 1m, 5m, 15m, 1H, 4H, 1D.",
"instance": "/api/v1/markets/BTC/candles",
"code": "invalid_request"
}
Captured live from /api/v1/markets/DOGE/candles: a well-formed name that is not an open Perpl market. No candles are fetched.
{
"type": "https://trydesk.trade/docs#error-not_found",
"title": "Not found",
"status": 404,
"detail": "No candles for DOGE.",
"instance": "/api/v1/markets/DOGE/candles",
"code": "not_found"
}
Over 60 requests in a minute from one IP. Carries Retry-After.
{
"type": "https://trydesk.trade/docs#error-rate_limited",
"title": "Too many requests",
"status": 429,
"detail": "Limit is 60 requests per minute for this route. Try again in 35s.",
"instance": "/api/v1/markets/BTC/candles",
"code": "rate_limited"
}
Perpl did not answer or refused the request. detail ends with what went wrong. Shape as the server sends it.
{
"type": "https://trydesk.trade/docs#error-upstream_unavailable",
"title": "Upstream unavailable",
"status": 503,
"detail": "Candles could not be read: Perpl HTTP 503",
"instance": "/api/v1/markets/BTC/candles",
"code": "upstream_unavailable"
}
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.
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
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 mostlimitentries. 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
nullon this route; the leaderboard does not read account balances. traders[].positions[]array- Open positions, largest
valuefirst. 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 | nullpnl / collateral × 100;nullif 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.
Over 60 requests in a minute from one IP. Carries Retry-After. Shape as the server sends it; the live capture is under Access & rate limits.
{
"type": "https://trydesk.trade/docs#error-rate_limited",
"title": "Too many requests",
"status": 429,
"detail": "Limit is 60 requests per minute for this route. Try again in 35s.",
"instance": "/api/v1/traders/top",
"code": "rate_limited"
}
Perpl’s market list or the Monad RPC did not answer. Shape as the server sends it.
{
"type": "https://trydesk.trade/docs#error-upstream_unavailable",
"title": "Upstream unavailable",
"status": 503,
"detail": "Perpl mainnet could not be read right now.",
"instance": "/api/v1/traders/top",
"code": "upstream_unavailable"
}
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.
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
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 | nullnullwhen there is no account or no indexed record yet.stats.tradesinteger- Closed round trips (a close or a liquidation ends one).
stats.winsstats.lossesinteger- Round trips that ended at or above zero, and below zero.
stats.winRatenumber | nullwins / trades, 0–1.nullwith no trades.stats.profitFactornumber | nullgrossProfit / grossLoss.nullwhen there are profits but no losses;0when there is neither.stats.realisednumber- Cumulative realised PnL in collateral units, including partial closes.
stats.grossProfitstats.grossLossnumber- Sum of winning and of losing round trips; both non-negative.
stats.maxDrawdownnumber- Largest peak-to-trough fall in cumulative realised PnL.
stats.bestStreakstats.worstStreakstats.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.sincestats.lastActiveinteger | null- Unix seconds of the first and last event seen.
stats.bestMarketstats.worstMarketobject | null{ symbol, pnl, count }: the market with the highest positive, and lowest negative, realised PnL.nullwhen 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.nullwith 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[].entrytrades[].exitnumber | null- Entry and exit price.
nullwhen 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.
The path segment is not an address. Refused before any lookup. Captured live from /api/v1/identity/nope; the same body with this route’s instance.
{
"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/traders/nope/history",
"code": "invalid_address"
}
Captured live: the eleventh request in a minute on this route. Headers were Retry-After: 35, X-RateLimit-Limit: 10, X-RateLimit-Remaining: 0.
{
"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"
}
The record store or the Monad RPC did not answer. Shape as the server sends it.
{
"type": "https://trydesk.trade/docs#error-upstream_unavailable",
"title": "Upstream unavailable",
"status": 503,
"detail": "Trader history could not be read right now.",
"instance": "/api/v1/traders/0x95d2602d30da1179fd13274839e60345857ca648/history",
"code": "upstream_unavailable"
}
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.
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
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 ENScom.twitterrecord. 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.
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"
}
Reserved for an address the resolver has no record for. In practice every valid address resolves to a record, so expect nulls in a 200 rather than this. Shape as the server sends it.
{
"type": "https://trydesk.trade/docs#error-not_found",
"title": "Not found",
"status": 404,
"detail": "Nothing is known about this address.",
"instance": "/api/v1/identity/0x0000000000000000000000000000000000000001",
"code": "not_found"
}
Over 60 requests in a minute from one IP. Carries Retry-After.
{
"type": "https://trydesk.trade/docs#error-rate_limited",
"title": "Too many requests",
"status": 429,
"detail": "Limit is 60 requests per minute for this route. Try again in 35s.",
"instance": "/api/v1/identity/0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
"code": "rate_limited"
}
The name services or the RPC did not answer. Shape as the server sends it.
{
"type": "https://trydesk.trade/docs#error-upstream_unavailable",
"title": "Upstream unavailable",
"status": 503,
"detail": "The upstream request failed.",
"instance": "/api/v1/identity/0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
"code": "upstream_unavailable"
}
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.
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
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
nullif the resolver failed. data.portfolionumber | null- USD value of every priced holding across chains;
nullwhen nothing could be priced. data.chains[]array{ chainIndex, chain, value }per chain with priced holdings, largest first.data.heldnull- Always
nullon 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"readywhen the Monad ledger reaches the chain tip;indexingwhen the worker is still catching up;unavailablewhen it could not be read at all, in which case onlydetailandbehindaccompany it.ledger.indexedAtinteger- Unix milliseconds of the last index pass.
ledger.behindinteger | null- Blocks between the ledger and the chain tip;
0when ready. ledger.realizedledger.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.last7dledger.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;nullon a buy). data.labels[]array- Descriptions drawn from the ledger, never verdicts:
{ code, text }withcodeone ofbot(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:".
The path segment is not an address. Refused before any lookup.
{
"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/wallets/nope",
"code": "invalid_address"
}
Reserved for a wallet the reader has nothing on. In practice an unseen wallet is indexed and answers a 200 with an empty ledger. Shape as the server sends it.
{
"type": "https://trydesk.trade/docs#error-not_found",
"title": "Not found",
"status": 404,
"detail": "Nothing is known about this address.",
"instance": "/api/v1/wallets/0x0000000000000000000000000000000000000001",
"code": "not_found"
}
Over 10 requests in a minute from one IP. Carries Retry-After.
{
"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/wallets/0x95d2602d30da1179fd13274839e60345857ca648",
"code": "rate_limited"
}
The balance API, HyperSync or the store did not answer in time. Shape as the server sends it.
{
"type": "https://trydesk.trade/docs#error-upstream_unavailable",
"title": "Upstream unavailable",
"status": 503,
"detail": "The wallet could not be read right now.",
"instance": "/api/v1/wallets/0x95d2602d30da1179fd13274839e60345857ca648",
"code": "upstream_unavailable"
}
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.
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
How far back to look. Anything else is refused with invalid_request.
1h6h24hResponse
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[].strongbooleanscore >= 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
nullif unpriced.
Captured live from ?window=2h.
{
"type": "https://trydesk.trade/docs#error-invalid_request",
"title": "Invalid request",
"status": 400,
"detail": "window must be one of 1h, 6h, 24h.",
"instance": "/api/v1/tokens/signals",
"code": "invalid_request"
}
Over 60 requests in a minute from one IP. Carries Retry-After.
{
"type": "https://trydesk.trade/docs#error-rate_limited",
"title": "Too many requests",
"status": 429,
"detail": "Limit is 60 requests per minute for this route. Try again in 35s.",
"instance": "/api/v1/tokens/signals",
"code": "rate_limited"
}
The ledger store did not answer. Shape as the server sends it.
{
"type": "https://trydesk.trade/docs#error-upstream_unavailable",
"title": "Upstream unavailable",
"status": 503,
"detail": "Token signals could not be computed right now.",
"instance": "/api/v1/tokens/signals",
"code": "upstream_unavailable"
}
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.
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.
failif Redis or the worker fails;warnif 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) andtime; a failed probe addsoutputwith the reason. checks["redis:responseTime"]ms- Round trip to the store. Critical.
checks["monad-rpc:responseTime"]mseth_blockNumberagainst the Monad RPC; addsblock.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
walletstracked and blocksbehind. 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
blockit reached and how many blocks it wasbehind. Not critical.
Health is rate-limited like everything else: 60 a minute per IP. A monitor polling every 10 s is fine.
{
"type": "https://trydesk.trade/docs#error-rate_limited",
"title": "Too many requests",
"status": 429,
"detail": "Limit is 60 requests per minute for this route. Try again in 35s.",
"instance": "/api/v1/health",
"code": "rate_limited"
}
A critical check failed. The body is still the health document, with status: "fail" and output on the failing check. Shape as the server sends it.
{
"status": "fail",
"version": "1",
"releaseId": "",
"time": "2026-09-21T03:06:48.408Z",
"checks": {
"redis:responseTime": [
{ "status": "fail", "observedValue": 2500, "observedUnit": "ms", "time": "2026-09-21T03:06:48.408Z", "output": "timeout" }
]
}
}
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.
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/v1routed 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.
Over 60 requests in a minute from one IP. Carries Retry-After.
{
"type": "https://trydesk.trade/docs#error-rate_limited",
"title": "Too many requests",
"status": 429,
"detail": "Limit is 60 requests per minute for this route. Try again in 35s.",
"instance": "/api/v1/stats",
"code": "rate_limited"
}
The counter store is not configured or did not answer. Shape as the server sends it.
{
"type": "https://trydesk.trade/docs#error-upstream_unavailable",
"title": "Upstream unavailable",
"status": 503,
"detail": "Counters are not configured on this server.",
"instance": "/api/v1/stats",
"code": "upstream_unavailable"
}
Rate limit tier default · 60 / min · Cache-Control public, s-maxage=30, stale-while-revalidate=300