TokensAPI

Errors & limits

The error envelope, status codes, every v2 details.code value, the provider lookup budget, cache windows and caps.

v2 shares the per-key rate limits, monthly quotas and error envelope with v1. This page covers the envelope once, then everything specific to v2.

Error envelope

Every non-2xx response is JSON with one error object:

{
  "error": {
    "_tag": "BadRequestError",
    "message": "Invalid mint",
    "details": { "code": "invalid_mint" }
  }
}
  • _tag names the error class and maps to the HTTP status (table below).
  • message is human-readable and may change; do not branch on it.
  • details is optional. When present it is an object; stable machine-readable cases carry a details.code. Branch on _tag first, then details.code.
  • Error responses are never cached (Cache-Control: no-store) and carry an x-request-id header.

Status codes

Status_tagWhen
400BadRequestErrorInvalid query, body or path parameter. details often lists allowed values.
401UnauthorizedErrorMissing or inactive API key.
403ForbiddenErrorThe key lacks a required scope (details.required, details.granted).
403AssetAdvisoryErrorThe requested token carries a compromised or blocked advisory where that is refused.
404NotFoundErrorUnknown resource. v1 also uses it for unregistered mints (details.code: "asset_not_registered").
429RateLimitedErrorPer-key rate limit, monthly quota, or a provider lookup budget was exceeded.
500UpstreamHttpError and othersAn upstream provider or internal error. Retry with backoff.
504CloudRunTimeoutErrorA backend call timed out. Retry with backoff.

The API sends no rate-limit headers and no Retry-After. On 429 and 5xx, back off exponentially with jitter (for example 250 ms, 500 ms, 1 s, 2 s, each plus up to 150 ms of jitter) and cap the attempts. Never retry 400, 401, 403 or 404 without changing the request.

What each status means on the v2 surfaces:

  • 400 — invalid params or body, a list-mutation domain error, or a malformed multichain reference (unknown chain, bad address, address-shaped {assetId}). See the code tables below.
  • 403 — insufficient scope, a list owned by another project, or a moderator-locked list.
  • 404 — an unknown list slug, a draft or archived community list, or a member that is not in the list. The body is "List not found" with resource: "token_list" in every list case, so branch on the status, not the message. On the asset surface a 404 always carries a details.code (asset_not_found, variant_not_found).
  • 429 — per-key throughput, monthly quota, or the provider lookup budget.

List mutation codes (details.code)

Domain errors from list writes are 400 unless noted. The code is stable; the message is not.

details.codeStatusWhen
invalid_slug400Slug does not match ^[a-z][a-z0-9-]{2,62}$
reserved_slug400Slug is a curated id or alias, or a route word (all, lists, curated, tokens, search-tokens, check-slug)
slug_conflict400A list with this slug exists in any status
slug_held400Slug was released recently and is reclaimable only by its previous owner during the hold-down window
invalid_mint400Not a base58 Solana mint address
unknown_mint400Mint not found in the registry, the token index, or the live provider
project_lists_limit400Project is at its list cap; delete a list first
list_full400List is at its member cap
batch_too_large400Batch exceeds the per-call mint cap
forbidden403Caller's project does not own the list (no details.code in the body)
admin_locked403List locked by moderators (no details.code in the body)
not_found404List does not exist, or is draft/archived and you are reading it

Batch add (POST /v2/lists/{slug}/members) reports per-mint failures inside failed[] using the same codes instead of failing the whole call.

Multichain asset codes (details.code)

Every 400 and 404 from /v2/chains, /v2/assets* and /v2/variants* carries a stable details.code; the extra details keys named below are there to build a message from.

details.codeStatusWhen
invalid_chain400chain=, {chain} or a key prefix is not a supported chain id; details.chains lists the valid ids (provider slugs such as polygon-pos are not accepted)
invalid_address400{address} or a key's address is not valid for the chain's family; details.expected is base58 or 0x40hex
invalid_key400keys is missing or empty, or an item is not <chain>:<address> (details.key)
too_many_keys400More than details.max (50) keys in one batch call (details.count)
invalid_asset_id400{assetId} is empty, over 120 chars, or address-shaped (a Solana or 0x address, or a <chain>:<address> key); details.hint points at /v2/variants/{chain}/{address}
invalid_list400list= is not all or a curated slug or alias; details.lists enumerates them
invalid_category400category= is not an asset category; details.values
invalid_include400include= names something other than variants (list) or executionQuality, pegHealth, preStocks (asset detail); details.include, details.values
invalid_sort400sort= is not liquidity, execution_quality or stock_redeemability
invalid_kind400kind= is not a variant kind; details.values
invalid_liquidity_tier400liquidityTier= is not tier1, tier2 or tier3
invalid_stock_variant_tier400stockVariantTier= is not share_redeemable, cash_redeemable or not_redeemable
invalid_primary_variant_strategy400primaryVariantStrategy= is not one of the three strategies; details.values
invalid_primary_scope400primaryScope= is not executable or global; details.values
invalid_query400q is longer than details.max (100) characters
asset_not_found404No asset, alias or rename matches {assetId} (details.assetId)
variant_not_found404A well-formed {chain}/{address} is not a registered variant (details.key, .chain, .address); the batch endpoint reports these in missing[] instead

limit and offset keep the platform-wide behaviour: a non-integer or limit=0 is a 400 without a details.code; a limit above the route's maximum is clamped, not rejected.

Provider lookup budget

Three endpoint groups can reach the live market-data provider for mints unknown to the index, so they share a per-key budget on top of the standard rate limits:

BudgetCountsDefault
searchGET /v2/search, GET /v2/resolve, GET /v2/lists/search-tokens120 calls / 10 min
batchPOST /v2/lists/{slug}/members, PUT /v2/lists/{slug}/members/{mint}30 calls / 10 min
  • Every call is charged, not every lookup; the API cannot know in advance how many mints will miss the local index. Within a batch call, at most about 50 unknown mints are resolved against the provider; the rest fail individually as unknown_mint and can be retried in a later batch.
  • Exhausting a budget returns 429 with _tag: "RateLimitedError". There is no Retry-After; back off until the window resets (10 minutes by default) rather than retrying immediately.
  • The budget is keyed per API key, not per project. Defaults are server-tunable and may be raised for a project on request.
  • Identical search queries within the cache window (below) are served from cache and do not count.
  • If the budget store is unavailable the check fails open; the per-call caps still bound cost.

Caching and staleness

Successful GET responses carry Cache-Control and may be served from an edge cache:

Endpointmax-age
GET /v2/lists300 s
GET /v2/lists/{slug}, GET /v2/lists/tokens60 s
GET /v2/search, GET /v2/resolve, GET /v2/lists/search-tokens30 s
GET /v2/chains3600 s (stale-while-revalidate 86400 s)
GET /v2/assets60 s (stale-while-revalidate 300 s)
GET /v2/assets/{assetId}, GET /v2/variants/{chain}/{address}60 s (stale-while-revalidate 120 s)
GET /v2/assets/{assetId}/variants, GET /v2/variants?keys=30 s (stale-while-revalidate 60 s)
GET /v2/lists/check-slug, all writesnot cached

The registry routes have their own windows; see Registry endpoints.

The catalog, list detail and compose endpoints additionally keep a last-good copy: if the list backend is unavailable they return the most recent successful payload (up to 10 minutes old) with a 200 rather than a 5xx. Treat updatedAt as the freshness signal, and never cache a check-slug answer: a slug freed by a delete must read as available immediately.

The asset surface does the same per request shape: GET /v2/assets (15 min), GET /v2/assets/{assetId} (15 min), GET /v2/assets/{assetId}/variants (10 min) and GET /v2/variants/{chain}/{address} (15 min) serve the last healthy payload for the same params when the data layer is unavailable. A 404 is a real answer and is never kept as last-good. GET /v2/chains always serves its compiled metadata, with variantCount and assetCount as null when the counts cannot be read. Use market.lastFetchedAt and referenceMarket.asOf as the freshness signals.

Limits at a glance

LimitValue
Mints per batch call250 (batch_too_large)
Members per list5000 (list_full)
Lists per project100 (project_lists_limit)
Slugs per compose call (lists=)10
Members composed per list5000; larger lists return truncated: true on that list's summary
q length100 characters
Search limit1–50 (values above the max are clamped, not rejected)
List limit1–500 catalog, 1–2000 detail and compose (clamped)
List name80 characters
Member note500 characters
Keys per GET /v2/variants call50 (too_many_keys)
Asset limit1–500 list, 1–1000 /variants sub-route (clamped)
{assetId} length120 characters (invalid_asset_id)
solana asset detail variants[]yield and lst variants need at least $250k liquidity, max 500; the /variants sub-route is uncapped

Caps are enforced server-side and may change; branch on details.code, not on the numbers.