Vortex Data API — Reference ============================ Base URL: https://api.vx.tools Methods: every endpoint accepts BOTH GET and POST. GET reads its parameters from the query string; POST reads them from a JSON body (`content-type: application/json`). Field names are identical in both modes (camelCase on the wire). Response: application/json; charset=utf-8 Auth: none (public read-only API) This document is the canonical machine-readable reference. It's served verbatim at /llms.txt. Field names below are wire-format (camelCase). Quick example: curl https://api.vx.tools/epochs/current # => {"epoch": 970} # GET form — share-friendly URL curl 'https://api.vx.tools/epochs/leaderboard/voting?epoch=969' \ | jq '.records[0:2]' # POST form — equivalent curl -sX POST https://api.vx.tools/epochs/leaderboard/voting \ -H 'content-type: application/json' \ -d '{"epoch": 969}' | jq '.records[0:2]' GET-specific gotchas: - List parameters (today only `slots` on /blocks/insights) must be passed as a comma-separated string: `?slots=1,2,3` — `serde_urlencoded` doesn't decode repeated keys. POST bodies still use a JSON array. - Field names are the camelCase wire names: `?voteKey=...&limit=5`, not `?vote_key=...`. - Optional fields can simply be omitted from the query string; the server treats them as null (same as POST `{}`). ================================================================ SHARED TYPES ================================================================ These types appear in multiple endpoint responses. Wire format uses camelCase regardless of the Rust source names. BlockIncome ----------- { "baseFees": u64, // lamports from base transaction fees "priorityFees": u64, // lamports from priority (compute-unit) fees "mevTips": u64 // lamports captured as MEV via tip programs } Note: "totalFees" = baseFees + priorityFees; "totalIncome" adds mevTips. LatencyDistributionEntry ------------------------ { "latency": u8, // 0..=31; see "Latency buckets" gotcha "stakePct": f64, // 0..1; share of voting stake in this bucket "validatorCount": u64 // distinct validators in this bucket } ================================================================ ENDPOINTS ================================================================ ---------------------------------------------------------------- GET / ---------------------------------------------------------------- Health-check / hello. Returns a static "Hello, World!" string. $ curl https://api.vx.tools/ Hello, World! ---------------------------------------------------------------- GET|POST /epochs/current ---------------------------------------------------------------- Current mainnet epoch. $ curl https://api.vx.tools/epochs/current {"epoch": 970} Response: { "epoch": u64 } ---------------------------------------------------------------- GET|POST /validators ---------------------------------------------------------------- Directory of every validator we know about. Sorted by nodeAddress for byte-identical responses across refreshes. Cached with Cache-Control: public, max-age=60, stale-while-revalidate=300. $ curl -s https://api.vx.tools/validators | jq '.validators[0]' { "nodeAddress": "Foo...", "voteAddress": "Bar...", // null if not registered "name": "Acme" // null if no display name } Response: { "validators": [ { "nodeAddress": string, "voteAddress": string | null, "name": string | null }, ... ] } Gotcha: the synthetic ideal-voter baseline (the all-zeroes address) is already filtered out of this list — it is NOT a real validator. ---------------------------------------------------------------- GET|POST /blocks/income ---------------------------------------------------------------- Per-slot income for a single validator within one epoch. $ curl -s 'https://api.vx.tools/blocks/income?identity=Foo...&epoch=969' \ | jq '.[0]' $ curl -sX POST https://api.vx.tools/blocks/income \ -H 'content-type: application/json' \ -d '{"identity": "Foo...", "epoch": 969}' | jq '.[0]' { "slot": 435456000, "leader": "Foo...", "income": { "baseFees": 12345, "priorityFees": 67890, "mevTips": 0 } } Request: { "identity": string, // base58 node address "epoch": u64 } Response: array of { "slot": u64, "leader": string, "income": BlockIncome | null } // null = skipped or not yet ingested Errors: 400 if identity isn't a valid pubkey; 500 on bigtable failure. ---------------------------------------------------------------- GET|POST /blocks/insights ---------------------------------------------------------------- Per-slot leader + skip + vote-latency distribution for arbitrary slots. $ curl -s 'https://api.vx.tools/blocks/insights?slots=435456000,435456001' \ | jq '.results[0]' $ curl -sX POST https://api.vx.tools/blocks/insights \ -H 'content-type: application/json' \ -d '{"slots": [435456000, 435456001]}' | jq '.results[0]' { "slot": 435456000, "isSkipped": false, "noVotes": false, "leaderNodeAddress":"Foo...", "distribution": [ { "latency": 0, "stakePct": 0.12, "validatorCount": 89 }, { "latency": 1, "stakePct": 0.58, "validatorCount": 1240 }, ... ] } Request: { "slots": [u64, ...] } // max 1000 per request; GET form uses // a comma-separated string Response: { "results": [ { "slot": u64, "isSkipped": bool | null, // null = unknown / not ingested "noVotes": bool | null, // true = block had no vote txs "leaderNodeAddress": string | null, "distribution": [LatencyDistributionEntry, ...] | null }, ... ] } Gotchas: - `distribution` is non-null only for confirmed slots whose vote histogram has been computed. Skipped, pending, and unknown collapse to null. - Requests exceeding 1000 slots are rejected. ---------------------------------------------------------------- GET|POST /epochs/income ---------------------------------------------------------------- Per-epoch income summary for one validator over the last N epochs. $ curl -s 'https://api.vx.tools/epochs/income?identity=Foo...&limit=5' \ | jq '.[0]' $ curl -sX POST https://api.vx.tools/epochs/income \ -H 'content-type: application/json' \ -d '{"identity": "Foo...", "limit": 5}' | jq '.[0]' { "epoch": 970, "stake": 123456789012, "totalSlots": 1728, "confirmedSlots": 1701, "skippedSlots": 27, "totalIncome": BlockIncome, "medianIncome": BlockIncome, "minIncome": BlockIncome, "maxIncome": BlockIncome } Request: { "identity": string, "limit": u64 } // clamped to 1..=100 Response: array of GetEpochIncomeResponseItem ordered newest-first, covering [current_epoch - limit + 1, current_epoch]. ---------------------------------------------------------------- GET|POST /epochs/vote-stats ---------------------------------------------------------------- Per-epoch voting performance for one VOTE account (note: voteKey, not node identity) over the last N epochs. $ curl -s 'https://api.vx.tools/epochs/vote-stats?voteKey=Vote...&limit=5' \ | jq '.[0]' $ curl -sX POST https://api.vx.tools/epochs/vote-stats \ -H 'content-type: application/json' \ -d '{"voteKey": "Vote...", "limit": 5}' | jq '.[0]' { "epoch": 970, "votedSlots": 429000, "earnedCredits": 1500000, "totalLatency": 880000, "totalExcessLatency": 12000, "perfectSlots": 400000, "okSlots": 25000, "slowSlots": 4000, "skippedSlots": 3000, "lastUploadedBucket": 432000, "ranking": 1 } Request: { "voteKey": string, "limit": u64 } // clamped to 1..=100 Notes: - `perfectSlots` = votedSlots - okSlots - slowSlots (server-derived). - `skippedSlots` = idealVoter.votedSlots - votedSlots (server-derived; the server fetches the ideal-voter row in the same request). - `totalExcessLatency` is skip-aware: 0 means "not yet populated", not "perfect performance". Combine with `lastUploadedBucket` to detect in-progress backfills. - Sentinel for not-yet-classified rows: `okSlots == 0 && slowSlots == 0 && votedSlots > 0` — older epochs that haven't been classified yet. ---------------------------------------------------------------- GET|POST /epochs/votes ---------------------------------------------------------------- Base64-encoded per-slot vote history for a validator in one epoch. Accepts EITHER an `identity` (node) OR a `voteKey` (vote account); if both supplied, `voteKey` wins. $ curl -s 'https://api.vx.tools/epochs/votes?identity=Foo...&epoch=969' $ curl -sX POST https://api.vx.tools/epochs/votes \ -H 'content-type: application/json' \ -d '{"identity": "Foo...", "epoch": 969}' { "epoch": 969, "votesBase64": "AAAAAAEAAAA..." // null if epoch un-uploaded } Request: { "identity": string | null, "voteKey": string | null, "epoch": u64 | null } // default: current epoch Response: { "epoch": u64, "votesBase64": string | null } Errors: 404 with `votesBase64: null` when the row hasn't been ingested. The base64 payload is truncated to (completedBuckets * BUCKET_SIZE) bytes — trailing zero buckets are not transmitted. ---------------------------------------------------------------- GET|POST /epochs/leaderboard (alias) GET|POST /epochs/leaderboard/voting ---------------------------------------------------------------- Voting performance leaderboard for one epoch. The two routes are identical; /epochs/leaderboard is kept for backwards compat. $ curl -s 'https://api.vx.tools/epochs/leaderboard/voting?epoch=969' \ | jq '.records[0:2]' $ curl -sX POST https://api.vx.tools/epochs/leaderboard/voting \ -H 'content-type: application/json' \ -d '{"epoch": 969}' | jq '.records[0:2]' { "epoch": 969, "records": [ { "nodeName": "(optimal record)", "nodeAddress": "111...", "voteAddress": "111...", "votedSlots": 432000, "earnedCredits": 1700000, "totalLatency": 864000, "totalExcessLatency": 0, "perfectSlots": 432000, "okSlots": 0, "slowSlots": 0, "skippedSlots": 0, "rank": 0, "datacenter": null, "continent": null, "country": null, "provider": null, "softwareBase": null, "softwareFlavor": null, "softwareVersion": null }, { "nodeName": "Acme", "nodeAddress": "Foo...", "voteAddress": "Vote...", "votedSlots": 429000, ... "rank": 1, "datacenter": "20473-US-Atlanta", "continent": "North America", "country": "USA", "provider": "Vultr", "softwareBase": "Agave", "softwareFlavor": "jito", "softwareVersion": "3.1.14" } ] } Request: { "epoch": u64 | null } // default: current epoch Notes: - `rank` is competition-ranked: 0-indexed, ties share, next distinct record jumps past the tie group (0, 1, 1, 3, 4, ...). The server pre-computes rank; don't re-derive from response position. - The first record is always the synthetic "(optimal record)" row with `rank: 0`. It is a cluster-wide ideal-voter baseline, NOT a real validator. Filter by `nodeAddress == "11111111111111111111111111111111"` before ranking real validators. - See gotchas section for cache TTLs, slot derivation, provider semantics. ---------------------------------------------------------------- GET|POST /epochs/leaderboard/income ---------------------------------------------------------------- Block-income leaderboard for one epoch. $ curl -s 'https://api.vx.tools/epochs/leaderboard/income?epoch=969' \ | jq '.records[0:2]' $ curl -sX POST https://api.vx.tools/epochs/leaderboard/income \ -H 'content-type: application/json' \ -d '{"epoch": 969}' | jq '.records[0:2]' { "epoch": 969, "records": [ { "nodeName": "(cluster stats)", "nodeAddress": "111...", "stake": 0, "totalSlots": 432000, "confirmedSlots": 430000, "skippedSlots": 2000, "totalIncome": BlockIncome, "medianIncome": BlockIncome, "minIncome": BlockIncome, "maxIncome": BlockIncome, "datacenter": null, ... "provider": null, "softwareBase": null, ... }, { "nodeName": "Acme", "nodeAddress": "Foo...", "stake": 123456789012, ... "provider": "Vultr", ... } ] } Request: { "epoch": u64 | null } Notes: first row is the synthetic "(cluster stats)" baseline (same filter rule as the voting leaderboard). ================================================================ FIELD GLOSSARY ================================================================ datacenter Format: "--", e.g. "20473-US-Atlanta". The leading number is the ASN of the validator's IP. `null` when validators.app couldn't geolocate the IP (in which case the special sentinel "0--Unknown" may appear instead of null in upstream data). continent, country Coarse geo derived from the country code in `datacenter`. `null` whenever `datacenter` is null OR the country code isn't in our continent map. provider Hosting brand when the validator runs on a managed-hosting provider in our curated allowlist: "Vultr", "OVH", "Hetzner", "Latitude.sh", "TeraSwitch", "Allnodes", "Cherry Servers", "AWS", "Hivelocity", "Leaseweb", "CDN77", and similar. The literal string "Self-hosted" otherwise — covers Tier-1 transit carriers (Cogent, Lumen, NTT, Arelion), consumer/business ISPs (Charter/Spectrum, ARTERIA, Vorboss), validator-operator-owned ASNs (RockawayX, Galaxy Digital, Twinstake, Kraken), AND the long-tail unclassified set. The specific carrier/operator brand is NOT surfaced via the API. `null` only when `datacenter` is null, ASN is 0, or the lookup file is missing. softwareBase Coarse client cohort: "Agave" - Agave, AgaveBam, JitoLabs, Rakurai, HarmonicAgave "Frankendancer" - Frankendancer, HarmonicFrankendancer "Firedancer" - pure C reimplementation null - unknown / not recognized / synthetic ideal row softwareFlavor Variant on top of the base: "bam", "jito", "harmonic", "rakurai". `null` for stock distributions (plain Agave, plain Firedancer); callers can render absence as "" or " vanilla". softwareVersion Binary version string (e.g. "3.1.14", "0.820.30113"). perfectSlots / okSlots / slowSlots / skippedSlots `okSlots`, `slowSlots` are stored directly on the row. `perfectSlots = votedSlots - okSlots - slowSlots` (server-derived). `skippedSlots = idealVoter.votedSlots - validator.votedSlots` (server-derived from the synthetic ideal-voter row). Sentinel: `okSlots == 0 && slowSlots == 0 && votedSlots > 0` means the row predates the slot-classification backfill — treat as "no data" rather than "perfect performance". rank Competition rank, 0-indexed, ties shared, next distinct record jumps past the tie group. The synthetic ideal row always sits at rank 0; real validators start at rank 1. totalExcessLatency Skip-aware aggregate vote latency. Value `0` is ambiguous: it can mean "perfect performance" OR "not yet populated by the backfill". Combine with `lastUploadedBucket` to disambiguate. votesBase64 Per-slot vote history packed into a binary buffer, then base64- encoded. Truncated to (completedBuckets * BUCKET_SIZE) bytes; trailing all-zero buckets are not transmitted. ================================================================ GOTCHAS ================================================================ Ideal-voter / cluster-stats baseline row Voting and income leaderboards prepend a synthetic row representing cluster-wide optimal performance. Its nodeAddress is the all-zeroes pubkey (base58: "11111111111111111111111111111111"), nodeName is "(optimal record)" or "(cluster stats)" depending on the leaderboard. ALWAYS filter this out before ranking real validators — don't treat it as the winner. It also surfaces in /epochs/vote-stats if you happen to query the zero pubkey (don't). Cache TTLs on leaderboards Current-epoch leaderboard is refreshed every 10s by a background task; a foreground request stale-refetches if the entry exceeds 30s (only happens when the background task is wedged). Past-epoch leaderboards are cached lazily on first request with a 10-min TTL so backfill rewrites (vote-excess-latency, fill-epoch) surface quickly. Skipped slots are derived, not stored For both vote-stats and voting leaderboard, `skippedSlots` is computed as (ideal_voted - voted). If the ideal-voter row is missing from a given epoch (very rare, in-progress backfill), `skippedSlots` will be 0 — interpret as "unknown", not "no skips". "provider": "Self-hosted" is a category, not a brand See the field glossary. If you need the actual carrier name (Cogent, Spectrum, etc.), it's not exposed via the API by design — only managed-hosting brands are passed through. The source map is in api-server/data/dc_providers.json in the repo if you need it for ops. Latency buckets in /blocks/insights bucket 0 = "didn't vote" for this slot buckets 1..=30 = raw latency in slots bucket 31 = saturated (actual latency >= 31) Empty leaderboard responses If the block-ingestor is wedged, a current-epoch leaderboard request can return `records: []`. Past-epoch requests don't have this failure mode. The server emits a Slack alert when this happens; the response itself is otherwise normal. ASN 0 in datacenter The literal sentinel "0--Unknown" sometimes appears as the `datacenter` value (validators.app couldn't geolocate the IP). The validator is still real — just unlocated. provider and continent are null in this case. Epoch boundaries Solana mainnet has 432,000 slots per epoch. The /epochs/current endpoint returns the current value but DOES change mid-request when an epoch flips. Endpoints that default `epoch` to the current value capture the value at request entry, so a flip mid-response is impossible. ================================================================ DATA SOURCES & REFRESH CADENCE ================================================================ Per-validator metadata (name, vote_account, data_center_key, softwareClient, softwareVersion): Source: validators.app public API Refresh: hourly (background task on the api-server) Vote stats, voting leaderboard, income, vote history: Source: Solana RPC -> block-ingestor -> Google BigTable Refresh: per-slot ingestion in real time; aggregates computed per-epoch on the boundary Provider mapping (ASN -> brand/kind): Source: hand-curated allowlist in scripts/update-dc-providers.py, validated against RIPEstat + PeeringDB Refresh: manual (run `python3 scripts/update-dc-providers.py`, commit the regenerated api-server/data/dc_providers.json, restart the api-server) ================================================================ REPORTING ================================================================ Reach out on Discord — validators will know how.