TokensAPI

Errors & limits

The error envelope, status codes, the unregistered-asset 404, and how to retry.

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.

Unregistered assets

v1 serves registered assets only. A mint that is not a variant of a registered asset returns 404 with _tag: "NotFoundError" and details.code: "asset_not_registered":

{
  "error": {
    "_tag": "NotFoundError",
    "message": "Asset not registered",
    "details": {
      "code": "asset_not_registered",
      "mint": "<mint>",
      "hint": "v1 serves registered assets only. To track other tokens, add them to a token list (POST /v2/lists, then PUT /v2/lists/{slug}/members/{mint}) and read them with GET /v2/lists/tokens?lists={slug}."
    }
  }
}
  • Returned by GET /v1/assets/resolve, GET /v1/assets/risk-summary, GET /v1/assets/search filters, and GET /v1/assets/{assetId} with every route under it.
  • The batch endpoints never fail the request. POST /v1/assets/market-snapshots keeps the row as { address, token: null, hasMarket: false } and GET /v1/assets/variant-markets keeps it with assetId, chain, market and executionQuality all null.
  • Do not retry. A mint does not become registered with time. Track the token with a Token List instead; see Registered assets only for what counts as registered.

Advisory refusals

Read endpoints never refuse a flagged mint; they return it with its advisory so you can show the warning. The AssetAdvisoryError tag (403, details.code of advisory_compromised or advisory_blocked) is reserved for surfaces that would act on a token rather than describe it. Do not retry it: the flag is lifted by an operator, not by time. See Advisories.

Rate limits and quotas

Limits are enforced per API key:

  • a short burst window and a sustained window on request rate,
  • a monthly request quota,
  • on the live risk read behind GET /v1/assets/risk-summary, a per-key budget of live provider lookups per 10 minutes. When it is spent the endpoint still answers 200 with hasInsufficientData: true rather than failing.

Exceeding a rate limit or the quota returns 429 RateLimitedError. The API does not send rate-limit headers or Retry-After; back off exponentially with jitter and keep parallel fan-out bounded. Cached data (curated lists, charts, profiles) is safe to re-request; the response Cache-Control header tells you how long it stays fresh.

Retrying

export async function fetchWithRetry(url: string, init: RequestInit, maxRetries = 4): Promise<Response> {
  for (let attempt = 0; ; attempt += 1) {
    const res = await fetch(url, init);
    const retryable = res.status === 429 || (res.status >= 500 && res.status < 600);
    if (!retryable || attempt >= maxRetries) return res;
    const delay = 250 * 2 ** attempt + Math.floor(Math.random() * 150);
    await new Promise((resolve) => setTimeout(resolve, delay));
  }
}

Retry 429 and 5xx. Never retry 400, 401, 403 or 404 without changing the request.