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" }
}
}_tagnames the error class and maps to the HTTP status (table below).messageis human-readable and may change; do not branch on it.detailsis optional. When present it is an object; stable machine-readable cases carry adetails.code. Branch on_tagfirst, thendetails.code.- Error responses are never cached (
Cache-Control: no-store) and carry anx-request-idheader.
Status codes
| Status | _tag | When |
|---|---|---|
400 | BadRequestError | Invalid query, body or path parameter. details often lists allowed values. |
401 | UnauthorizedError | Missing or inactive API key. |
403 | ForbiddenError | The key lacks a required scope (details.required, details.granted). |
403 | AssetAdvisoryError | The requested token carries a compromised or blocked advisory where that is refused. |
404 | NotFoundError | Unknown resource. v1 also uses it for unregistered mints (details.code: "asset_not_registered"). |
429 | RateLimitedError | Per-key rate limit, monthly quota, or a provider lookup budget was exceeded. |
500 | UpstreamHttpError and others | An upstream provider or internal error. Retry with backoff. |
504 | CloudRunTimeoutError | A 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, adraftorarchivedcommunity list, or a member that is not in the list. The body is"List not found"withresource: "token_list"in every list case, so branch on the status, not the message. On the asset surface a404always carries adetails.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.code | Status | When |
|---|---|---|
invalid_slug | 400 | Slug does not match ^[a-z][a-z0-9-]{2,62}$ |
reserved_slug | 400 | Slug is a curated id or alias, or a route word (all, lists, curated, tokens, search-tokens, check-slug) |
slug_conflict | 400 | A list with this slug exists in any status |
slug_held | 400 | Slug was released recently and is reclaimable only by its previous owner during the hold-down window |
invalid_mint | 400 | Not a base58 Solana mint address |
unknown_mint | 400 | Mint not found in the registry, the token index, or the live provider |
project_lists_limit | 400 | Project is at its list cap; delete a list first |
list_full | 400 | List is at its member cap |
batch_too_large | 400 | Batch exceeds the per-call mint cap |
forbidden | 403 | Caller's project does not own the list (no details.code in the body) |
admin_locked | 403 | List locked by moderators (no details.code in the body) |
not_found | 404 | List 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.code | Status | When |
|---|---|---|
invalid_chain | 400 | chain=, {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_address | 400 | {address} or a key's address is not valid for the chain's family; details.expected is base58 or 0x40hex |
invalid_key | 400 | keys is missing or empty, or an item is not <chain>:<address> (details.key) |
too_many_keys | 400 | More than details.max (50) keys in one batch call (details.count) |
invalid_asset_id | 400 | {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_list | 400 | list= is not all or a curated slug or alias; details.lists enumerates them |
invalid_category | 400 | category= is not an asset category; details.values |
invalid_include | 400 | include= names something other than variants (list) or executionQuality, pegHealth, preStocks (asset detail); details.include, details.values |
invalid_sort | 400 | sort= is not liquidity, execution_quality or stock_redeemability |
invalid_kind | 400 | kind= is not a variant kind; details.values |
invalid_liquidity_tier | 400 | liquidityTier= is not tier1, tier2 or tier3 |
invalid_stock_variant_tier | 400 | stockVariantTier= is not share_redeemable, cash_redeemable or not_redeemable |
invalid_primary_variant_strategy | 400 | primaryVariantStrategy= is not one of the three strategies; details.values |
invalid_primary_scope | 400 | primaryScope= is not executable or global; details.values |
invalid_query | 400 | q is longer than details.max (100) characters |
asset_not_found | 404 | No asset, alias or rename matches {assetId} (details.assetId) |
variant_not_found | 404 | A 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:
| Budget | Counts | Default |
|---|---|---|
search | GET /v2/search, GET /v2/resolve, GET /v2/lists/search-tokens | 120 calls / 10 min |
batch | POST /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_mintand can be retried in a later batch. - Exhausting a budget returns
429with_tag: "RateLimitedError". There is noRetry-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:
| Endpoint | max-age |
|---|---|
GET /v2/lists | 300 s |
GET /v2/lists/{slug}, GET /v2/lists/tokens | 60 s |
GET /v2/search, GET /v2/resolve, GET /v2/lists/search-tokens | 30 s |
GET /v2/chains | 3600 s (stale-while-revalidate 86400 s) |
GET /v2/assets | 60 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 writes | not 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
| Limit | Value |
|---|---|
| Mints per batch call | 250 (batch_too_large) |
| Members per list | 5000 (list_full) |
| Lists per project | 100 (project_lists_limit) |
Slugs per compose call (lists=) | 10 |
| Members composed per list | 5000; larger lists return truncated: true on that list's summary |
q length | 100 characters |
Search limit | 1–50 (values above the max are clamped, not rejected) |
List limit | 1–500 catalog, 1–2000 detail and compose (clamped) |
List name | 80 characters |
Member note | 500 characters |
Keys per GET /v2/variants call | 50 (too_many_keys) |
Asset limit | 1–500 list, 1–1000 /variants sub-route (clamped) |
{assetId} length | 120 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.