> ## Documentation Index
> Fetch the complete documentation index at: https://www.dynamic.xyz/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Sync the Token Catalog

> Fetch current token metadata from Dynamic and avoid downloading unchanged catalog pages.

Use the token catalog endpoint to keep token names, symbols, decimals, contract addresses, and logos current in your backend. The endpoint returns the catalog as a sequence of pages across supported chains and networks.

## What you need

* A Dynamic API token with the `environment.settings.read` scope. See [API token permissions](/docs/platform/dashboard/api-token-permissions#environmentsettingsread).
* Server-side storage for the catalog and its `ETag` value.

<Warning>
  Keep the API token server-side. Read it from an environment variable or a secrets manager, never from client-side code or a committed file.
</Warning>

## Fetch the catalog

Call `GET /api/v0/tokenCatalog` with your API token. The endpoint paginates results: `limit` sets the page size (1 to 100, default 50) and `cursor` continues from a previous page:

```bash curl theme={"system"}
curl "https://app.dynamic.xyz/api/v0/tokenCatalog?limit=100" \
  --header "Authorization: Bearer $DYNAMIC_API_TOKEN"
```

A successful response contains one page of tokens, the cursor for the next page, and a version for the current catalog content:

```json theme={"system"}
{
  "nextCursor": "token-row-id-or-null",
  "tokens": [
    {
      "chain": "EVM",
      "contractAddress": "0xTokenContractAddress",
      "decimals": 6,
      "logoURI": "https://example.com/token-logo.png",
      "name": "Example USD",
      "networkId": 1,
      "symbol": "xUSD",
      "tokenId": "example-usd"
    }
  ],
  "version": "catalog-content-hash"
}
```

When `nextCursor` is a string, pass it as `cursor` on the next request to fetch the following page. When `nextCursor` is `null`, the page is the last one and the catalog is fully read.

The `logoURI` field is optional. Identify a token by its chain, network, and contract address instead of its symbol, because the same symbol can exist on multiple networks.

## Poll without downloading unchanged data

Each page response includes an `ETag` header and a `version` field. The `ETag` identifies one specific page request, so it only matches a later request with the same `limit` and `cursor`. The `version` hashes the entire catalog, so the first page's version changes whenever any token changes.

To poll efficiently, save the first page's `ETag` together with your stored catalog. On the next scheduled request, send that value in `If-None-Match` on the first page only:

* `304 Not Modified` means the catalog is unchanged. Keep using the saved copy.
* `200 OK` with a new `version` means the catalog changed. Read every page until `nextCursor` is `null` and replace the saved snapshot and `ETag`.

This TypeScript example accepts the previously saved snapshot so the same path works on the first run and later runs:

```typescript TypeScript theme={"system"}
type TokenCatalogEntry = {
  chain: string;
  contractAddress: string;
  decimals: number;
  logoURI?: string;
  name: string;
  networkId: number;
  symbol: string;
  tokenId: string;
};

type CatalogPage = {
  nextCursor: string | null;
  tokens: TokenCatalogEntry[];
  version: string;
};

type SavedCatalog = {
  etag: string;
  catalog: CatalogPage;
};

const ENDPOINT = 'https://app.dynamic.xyz/api/v0/tokenCatalog';

async function fetchPage(
  apiToken: string,
  cursor?: string,
  ifNoneMatch?: string,
): Promise<Response> {
  const url = new URL(ENDPOINT);
  url.searchParams.set('limit', '100');

  if (cursor) {
    url.searchParams.set('cursor', cursor);
  }

  return fetch(url, {
    headers: {
      Authorization: `Bearer ${apiToken}`,
      ...(ifNoneMatch ? { 'If-None-Match': ifNoneMatch } : {}),
    },
  });
}

function isCatalogPage(value: unknown): value is CatalogPage {
  if (!value || typeof value !== 'object') return false;

  const page = value as Record<string, unknown>;
  if (
    typeof page.version !== 'string' ||
    !(typeof page.nextCursor === 'string' || page.nextCursor === null) ||
    !Array.isArray(page.tokens)
  ) {
    return false;
  }

  return page.tokens.every((value) => {
    if (!value || typeof value !== 'object') return false;

    const token = value as Record<string, unknown>;
    return (
      typeof token.chain === 'string' &&
      typeof token.contractAddress === 'string' &&
      Number.isInteger(token.decimals) &&
      (token.logoURI === undefined || typeof token.logoURI === 'string') &&
      typeof token.name === 'string' &&
      Number.isInteger(token.networkId) &&
      typeof token.symbol === 'string' &&
      typeof token.tokenId === 'string'
    );
  });
}

async function syncTokenCatalog(
  previous?: SavedCatalog,
): Promise<SavedCatalog> {
  const apiToken = process.env.DYNAMIC_API_TOKEN;

  if (!apiToken) {
    throw new Error('DYNAMIC_API_TOKEN is required');
  }

  const firstResponse = await fetchPage(apiToken, undefined, previous?.etag);

  if (firstResponse.status === 304 && previous) {
    return previous;
  }

  if (!firstResponse.ok) {
    throw new Error(`Dynamic API returned ${firstResponse.status}`);
  }

  const etag = firstResponse.headers.get('etag');

  if (!etag) {
    throw new Error('Dynamic API response did not include an ETag');
  }

  const firstPage: unknown = await firstResponse.json();

  if (!isCatalogPage(firstPage)) {
    throw new Error('Dynamic API returned an invalid token catalog');
  }

  const tokens = [...firstPage.tokens];
  let nextCursor = firstPage.nextCursor;
  const { version } = firstPage;

  while (nextCursor !== null) {
    const response = await fetchPage(apiToken, nextCursor);

    if (!response.ok) {
      throw new Error(`Dynamic API returned ${response.status}`);
    }

    const page: unknown = await response.json();

    if (!isCatalogPage(page)) {
      throw new Error('Dynamic API returned an invalid token catalog');
    }

    tokens.push(...page.tokens);
    nextCursor = page.nextCursor;
  }

  return {
    etag,
    catalog: { nextCursor: null, tokens, version },
  };
}
```

Persist the returned object after each successful sync. If the process restarts, load both values before the next request; an `ETag` without its matching catalog is not useful.

## Handle errors

| Status | What to do                                                                          |
| ------ | ----------------------------------------------------------------------------------- |
| `400`  | Check that `limit` is an integer from 1 to 100 and `cursor` is a valid page cursor. |
| `401`  | Check that the token is present, valid, and uses the `Bearer ` prefix.              |
| `403`  | Grant the API token the `environment.settings.read` scope.                          |
| `429`  | Retry with exponential backoff and jitter. Keep serving the last saved catalog.     |
| `5xx`  | Retry with exponential backoff and jitter. Keep serving the last saved catalog.     |

Treat each successful sync as a complete replacement snapshot. Do not discard the last valid snapshot when a refresh fails.
