# Build a wallet assets page with the Data API: balances, transfers and token info

> Original page: https://docs.blockvectra.com/en/guides/wallet-assets/

## The three kinds of data a wallet assets page needs [#the-three-kinds-of-data-a-wallet-assets-page-needs]

A wallet assets page usually answers three questions: what an address holds right now, which token transfers it was involved in, and what each token is called and how precise it is. The Data API provides an endpoint for each:

* **Balances**: `GET /{chain}/addresses/{address}/balances` returns the address's non-zero ERC-20 balances, ordered by `token` address ascending, with token `symbol` and `decimals` included where available. An address with no balances returns `200` with `data: []`.
* **Transfers**: `GET /{chain}/addresses/{address}/transfers` returns token transfers involving the address within a required block window, ordered by `(block_number, log_index)` descending.
* **Token metadata**: `GET /{chain}/tokens/{token}` reads one token's name, symbol, decimals, and total supply by contract address; `POST /{chain}/tokens:batch` reads the same metadata for up to 100 addresses in one request.

All three use `https://dev-api.blockvectra.network/v1/data` as the base URL and the `x-api-key` request header, with `robinhood_mainnet` as the example chain. They belong to the `balances`, `transfers`, and `token_metadata` capabilities respectively; for chains that offer each capability, see the [Supported Chains](/en/chains/) page. On a chain without the capability, the endpoint returns `422 no_coverage`.

## Request 1: address balances [#request-1-address-balances]

This endpoint takes fewer parameters, which makes it a good first request for a page:

* `{chain}` (path parameter, required): chain identifier, the `chain` value of an entry in `GET /chains` (for example `robinhood_mainnet`). Matching is exact and case-sensitive; aliases and numeric chain IDs are not accepted.
* `{address}` (path parameter, required): 20-byte address; the `0x` prefix is optional and either case is accepted.
* `limit` (query parameter, optional): page size. Defaults to 50; values above 500 are clamped to 500; `0` or a non-integer returns `400 bad_request`.
* `cursor` (query parameter, optional): the previous response's `next_cursor`, passed back unchanged to fetch the next page. A cursor is valid only for the chain, endpoint, and query parameters that issued it; reusing it elsewhere returns `400 bad_request`.

It is keyset-paginated: `next_cursor` appears only when there is another page. On the last page the key is absent entirely, never `null`.

<Tabs groupId="code-lang" items="['cURL', 'TypeScript', 'Python']">
  <Tab value="cURL">
    ```bash
    export BLOCKVECTRA_API_KEY=rgw_your_api_key

    curl -s "https://dev-api.blockvectra.network/v1/data/robinhood_mainnet/addresses/0x1111111111111111111111111111111111111111/balances?limit=50" \
      -H "x-api-key: $BLOCKVECTRA_API_KEY"
    ```
  </Tab>

  <Tab value="TypeScript">
    ```ts
    const address = "0x1111111111111111111111111111111111111111";
    const url = new URL(
      `https://dev-api.blockvectra.network/v1/data/robinhood_mainnet/addresses/${address}/balances`,
    );
    url.searchParams.set("limit", "50");

    const res = await fetch(url, {
      headers: { "x-api-key": process.env.BLOCKVECTRA_API_KEY! },
    });
    const balanceBody = await res.json();
    console.log(balanceBody.data, balanceBody.meta);

    // npx tsx example.mts
    ```
  </Tab>

  <Tab value="Python">
    ```python
    import os
    import requests

    address = "0x1111111111111111111111111111111111111111"
    res = requests.get(
        f"https://dev-api.blockvectra.network/v1/data/robinhood_mainnet/addresses/{address}/balances",
        params={"limit": 50},
        headers={"x-api-key": os.environ["BLOCKVECTRA_API_KEY"]},
    )
    res.raise_for_status()
    balance_body = res.json()
    print(balance_body["data"], balance_body["meta"])
    ```
  </Tab>
</Tabs>

The response envelope is `AddressBalanceListEnvelope`, containing `data` and `meta`. Each item of `data` is an `AddressBalance`:

| Field      | Type                | Description                                                                                                                          |
| ---------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `token`    | `string` (address)  | Token contract address; canonical form is `0x` plus 40 lowercase hex digits.                                                         |
| `balance`  | `string` (decimal)  | Raw integer balance, which may exceed `2^53`, returned as a plain decimal string — never a JSON number, scientific notation, or hex. |
| `symbol`   | `string` or `null`  | Token symbol, or `null` when unavailable.                                                                                            |
| `decimals` | `integer` or `null` | Token decimals, `0`–`255`, or `null` when unavailable.                                                                               |

## Request 2: address transfers [#request-2-address-transfers]

The transfers endpoint requires an explicit block window: `from_block` and `to_block` are both required and must satisfy `from_block <= to_block`. It takes a few more parameters:

* `standard` (query parameter, required): `erc20` or `erc721`. Address-scoped queries do not cover `erc1155`; passing it returns `422 no_coverage`.
* `direction` (query parameter, optional): `in`, `out`, or `any`; defaults to `any` and filters by direction relative to the address.
* `token` (query parameter, optional): restrict results to one token contract.
* `clamp` (query parameter, optional): only the literal string `true` enables it; any other value is treated as `false`.

Window bounds and finality: an explicit `to_block` above `finalized_block` returns `409 finality_exceeded` unless `clamp=true` truncates it down to `finalized_block`; a window wider than 100,000 blocks returns `409 window_too_large` unless `clamp=true` truncates from the older end (raising `from_block` and keeping `to_block` fixed). If `from_block` itself is already past the watermark, it stays a hard `409` even with `clamp=true`. When the window is clamped or partially covered, the response `meta.coverage` is `"partial"`; otherwise it is `"full"`.

In the transfer records, ERC-20 items add `amount`; ERC-721 items add `token_id`. Both include `token`, `standard`, `from`, `to`, `block_number`, `block_timestamp`, `tx_hash`, `tx_index`, and `log_index`.

<Tabs groupId="code-lang" items="['cURL', 'TypeScript', 'Python']">
  <Tab value="cURL">
    ```bash
    export BLOCKVECTRA_API_KEY=rgw_your_api_key

    # Read meta.finalized_block from any previous response and use it as the upper bound.
    # clamp=true truncates a too-wide window, or a to_block above finalized_block, instead of returning 409.
    curl -s "https://dev-api.blockvectra.network/v1/data/robinhood_mainnet/addresses/0x1111111111111111111111111111111111111111/transfers?standard=erc20&from_block=0&to_block=$FINALIZED_BLOCK&direction=any&clamp=true" \
      -H "x-api-key: $BLOCKVECTRA_API_KEY"
    ```
  </Tab>

  <Tab value="TypeScript">
    ```ts
    const address = "0x1111111111111111111111111111111111111111";

    // 1) Read the finality watermark from any previous response's meta.
    const head = await fetch(
      `https://dev-api.blockvectra.network/v1/data/robinhood_mainnet/addresses/${address}/balances`,
      { headers: { "x-api-key": process.env.BLOCKVECTRA_API_KEY! } },
    ).then((r) => r.json());

    // 2) Use finalized_block as the transfer window's upper bound.
    const url = new URL(
      `https://dev-api.blockvectra.network/v1/data/robinhood_mainnet/addresses/${address}/transfers`,
    );
    url.searchParams.set("standard", "erc20");
    url.searchParams.set("from_block", "0");
    url.searchParams.set("to_block", String(head.meta.finalized_block));
    url.searchParams.set("direction", "any");
    url.searchParams.set("clamp", "true");

    const res = await fetch(url, {
      headers: { "x-api-key": process.env.BLOCKVECTRA_API_KEY! },
    });
    const body = await res.json();
    console.log(body.data, body.meta);

    // npx tsx example.mts
    ```
  </Tab>

  <Tab value="Python">
    ```python
    import os
    import requests

    address = "0x1111111111111111111111111111111111111111"
    headers = {"x-api-key": os.environ["BLOCKVECTRA_API_KEY"]}

    # 1) Read the finality watermark from any previous response's meta.
    head = requests.get(
        f"https://dev-api.blockvectra.network/v1/data/robinhood_mainnet/addresses/{address}/balances",
        headers=headers,
    ).json()

    # 2) Use finalized_block as the transfer window's upper bound.
    res = requests.get(
        f"https://dev-api.blockvectra.network/v1/data/robinhood_mainnet/addresses/{address}/transfers",
        params={
            "standard": "erc20",
            "from_block": 0,
            "to_block": head["meta"]["finalized_block"],
            "direction": "any",
            "clamp": "true",
        },
        headers=headers,
    )
    res.raise_for_status()
    body = res.json()
    print(body["data"], body["meta"])
    ```
  </Tab>
</Tabs>

## Paginating through all transfers [#paginating-through-all-transfers]

The address transfers endpoint's `next_cursor` is optimistic: it appears only when the page returned exactly `limit` rows, so a page can carry a `next_cursor` and still turn out to be the last page. Do not stop when a page is empty; follow `next_cursor` until the key is absent.

* `limit` defaults to 50 and is capped at 500.
* Pass `cursor` back unchanged; a cursor is valid only for the chain, endpoint, and query parameters that issued it. Switching chains or changing parameters means starting over from the first page.
* The cursor itself carries a block position: if the chain's first indexed block moves forward between pages, the next page becomes `partial` (rows below that position are gone) or `422 no_coverage`.

The code below fetches every transfer in the window:

<Tabs groupId="code-lang" items="['TypeScript', 'Python']">
  <Tab value="TypeScript">
    ```ts
    const address = "0x1111111111111111111111111111111111111111";
    const finalizedBlock = head.meta.finalized_block;
    const transfers: unknown[] = [];
    let cursor: string | undefined;

    do {
      const url = new URL(
        `https://dev-api.blockvectra.network/v1/data/robinhood_mainnet/addresses/${address}/transfers`,
      );
      url.searchParams.set("standard", "erc20");
      url.searchParams.set("from_block", "0");
      url.searchParams.set("to_block", String(finalizedBlock));
      url.searchParams.set("limit", "500");
      // Windows wider than the spec limit return 409 window_too_large; clamp truncates from the older end
      url.searchParams.set("clamp", "true");
      if (cursor) url.searchParams.set("cursor", cursor);

      const res = await fetch(url, {
        headers: { "x-api-key": process.env.BLOCKVECTRA_API_KEY! },
      });
      const page = await res.json();
      transfers.push(...page.data);
      cursor = page.next_cursor; // absent on the last page
    } while (cursor);
    ```
  </Tab>

  <Tab value="Python">
    ```python
    import os
    import requests

    address = "0x1111111111111111111111111111111111111111"
    finalized_block = head["meta"]["finalized_block"]
    transfers = []
    cursor = None

    while True:
        params = {
            "standard": "erc20",
            "from_block": 0,
            "to_block": finalized_block,
            "limit": 500,
            # Windows wider than the spec limit return 409 window_too_large; clamp truncates from the older end
            "clamp": "true",
        }
        if cursor:
            params["cursor"] = cursor
        res = requests.get(
            f"https://dev-api.blockvectra.network/v1/data/robinhood_mainnet/addresses/{address}/transfers",
            params=params,
            headers={"x-api-key": os.environ["BLOCKVECTRA_API_KEY"]},
        )
        res.raise_for_status()
        page = res.json()
        transfers.extend(page["data"])
        cursor = page.get("next_cursor")  # absent on the last page
        if not cursor:
            break
    ```
  </Tab>
</Tabs>

## Request 3: token metadata and tokens:batch [#request-3-token-metadata-and-tokensbatch]

Read a single token with `GET /{chain}/tokens/{token}`; the path takes only `{chain}` and `{token}`, with no pagination. The response envelope is `TokenEnvelope`, and `data` is a `Token`:

| Field                 | Type                 | Description                                                                                                                                                         |
| --------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `address`             | `string` (address)   | Token contract address.                                                                                                                                             |
| `standard`            | `string`             | `erc20`, `erc721`, or `unknown`.                                                                                                                                    |
| `name`                | `string` or `null`   | Token name, or `null` when unavailable.                                                                                                                             |
| `symbol`              | `string` or `null`   | Token symbol, or `null` when unavailable.                                                                                                                           |
| `decimals`            | `integer` or `null`  | Token decimals, `0`–`255`, or `null` when unavailable.                                                                                                              |
| `total_supply`        | `string` or `null`   | Raw total supply; the API does not apply `decimals` scaling. `null` when unavailable.                                                                               |
| `first_seen_block`    | `integer` (int64)    | Block height where the token was first seen.                                                                                                                        |
| `metadata_updated_at` | `string` (timestamp) | UTC time when the metadata was last updated.                                                                                                                        |
| `metadata_block`      | `integer` (int64)    | Block height at which the metadata was read.                                                                                                                        |
| `metadata_status`     | `string`             | `ok`, `partial`, or `unavailable`.                                                                                                                                  |
| `metadata_issues`     | `object`             | Per-field issue records keyed by `name`, `symbol`, `decimals`, `total_supply`, with values `reverted`, `no_data`, `invalid_encoding`, or `temporarily_unavailable`. |

A `{token}` that is not a valid 20-byte address returns `400 bad_request`; an unknown `{token}` returns `404 not_found`; an unknown `{chain}` returns `404 unknown_chain`.

The balances endpoint already includes `symbol` and `decimals` where available, but both can be `null`. To fill in the name and decimals for every token in a wallet, use `POST /{chain}/tokens:batch`:

* The request body is `{"addresses": [...]}` with at most 100 addresses per request; more than 100 entries, or an entry that is not a valid 20-byte address, returns `400 bad_request` (it fails on the first invalid address it walks to).
* Addresses that are not found do not trigger an error; they are listed in `data.missing`, while `data.tokens` contains only the tokens whose metadata was found.
* Duplicate addresses are deduplicated in both `tokens` and `missing`, each in first-occurrence request order.

<Tabs groupId="code-lang" items="['cURL', 'TypeScript', 'Python']">
  <Tab value="cURL">
    ```bash
    export BLOCKVECTRA_API_KEY=rgw_your_api_key

    # Single token
    curl -s "https://dev-api.blockvectra.network/v1/data/robinhood_mainnet/tokens/0x1111111111111111111111111111111111111111" \
      -H "x-api-key: $BLOCKVECTRA_API_KEY"

    # Batch: up to 100 addresses per request
    curl -s -X POST "https://dev-api.blockvectra.network/v1/data/robinhood_mainnet/tokens:batch" \
      -H "x-api-key: $BLOCKVECTRA_API_KEY" \
      -H 'Content-Type: application/json' \
      -d '{"addresses":["0x1111111111111111111111111111111111111111","0x2222222222222222222222222222222222222222"]}'
    ```
  </Tab>

  <Tab value="TypeScript">
    ```ts
    // Single token
    const single = await fetch(
      "https://dev-api.blockvectra.network/v1/data/robinhood_mainnet/tokens/0x1111111111111111111111111111111111111111",
      { headers: { "x-api-key": process.env.BLOCKVECTRA_API_KEY! } },
    ).then((r) => r.json());
    console.log(single.data);

    // Batch: group by 100 addresses to enrich the tokens from the balances response
    const BATCH_SIZE = 100;
    const addresses = balanceBody.data.map((item: { token: string }) => item.token);
    const tokens = new Map<string, unknown>();
    const missing: string[] = [];

    for (let i = 0; i < addresses.length; i += BATCH_SIZE) {
      const res = await fetch("https://dev-api.blockvectra.network/v1/data/robinhood_mainnet/tokens:batch", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "x-api-key": process.env.BLOCKVECTRA_API_KEY!,
        },
        body: JSON.stringify({ addresses: addresses.slice(i, i + BATCH_SIZE) }),
      });
      const body = await res.json();
      for (const token of body.data.tokens) tokens.set(token.address, token);
      missing.push(...body.data.missing);
    }

    // npx tsx example.mts
    ```
  </Tab>

  <Tab value="Python">
    ```python
    import os
    import requests

    headers = {"x-api-key": os.environ["BLOCKVECTRA_API_KEY"]}

    # Single token
    single = requests.get(
        "https://dev-api.blockvectra.network/v1/data/robinhood_mainnet/tokens/0x1111111111111111111111111111111111111111",
        headers=headers,
    ).json()
    print(single["data"])

    # Batch: group by 100 addresses to enrich the tokens from the balances response
    BATCH_SIZE = 100
    addresses = [item["token"] for item in balance_body["data"]]
    tokens = {}
    missing = []

    for i in range(0, len(addresses), BATCH_SIZE):
        res = requests.post(
            "https://dev-api.blockvectra.network/v1/data/robinhood_mainnet/tokens:batch",
            json={"addresses": addresses[i : i + BATCH_SIZE]},
            headers={**headers, "Content-Type": "application/json"},
        )
        res.raise_for_status()
        body = res.json()
        for token in body["data"]["tokens"]:
            tokens[token["address"]] = token
        missing.extend(body["data"]["missing"])
    ```
  </Tab>
</Tabs>

## Scaling amounts by decimals [#scaling-amounts-by-decimals]

The balance field `balance` and the ERC-20 transfer field `amount` are raw integers rendered as decimal strings (`UInt256String`); the spec for a token's `total_supply` also states explicitly that it is a raw on-chain integer with no `decimals` scaling applied. To show a human-readable quantity, divide by that token's `decimals`.

* `decimals` comes from the balance item's own `symbol`/`decimals`, or from `GET /{chain}/tokens/{token}` and `POST /{chain}/tokens:batch`; it can be `null`.
* These values may exceed `2^53`, so do not do the arithmetic with a JSON number: use `BigInt` in TypeScript and `Decimal` in Python, parsing the decimal string as-is to avoid precision loss.

<Tabs groupId="code-lang" items="['TypeScript', 'Python']">
  <Tab value="TypeScript">
    ```ts
    function toDisplayAmount(raw: string, decimals: number | null): string {
      if (decimals === null) return raw; // no decimals metadata: keep the raw integer
      const value = BigInt(raw);
      const base = 10n ** BigInt(decimals);
      const whole = value / base;
      const fraction = (value % base)
        .toString()
        .padStart(decimals, "0")
        .replace(/0+$/, "");
      return fraction ? `${whole}.${fraction}` : whole.toString();
    }

    // balance.balance is a raw decimal string; decimals comes from the same item or tokens:batch.
    const display = toDisplayAmount(balance.balance, balance.decimals);
    ```
  </Tab>

  <Tab value="Python">
    ```python
    from decimal import Decimal


    def to_display_amount(raw: str, decimals: int | None) -> str:
        if decimals is None:
            return raw  # no decimals metadata: keep the raw integer
        value = Decimal(raw)  # parse the decimal string exactly
        return format(value.scaleb(-decimals).normalize(), "f")


    # balance["balance"] is a raw decimal string; decimals comes from the same item or tokens:batch.
    display = to_display_amount(balance["balance"], balance["decimals"])
    ```
  </Tab>
</Tabs>

## Data freshness [#data-freshness]

Every chain-scoped success response carries `meta`:

* `as_of_block`: the indexed head the response's finality watermark was computed from.
* `finalized_block`: the highest block height that block-scoped endpoints will serve; it trails `as_of_block` by a fixed number of blocks set per chain. It is a reorg-safety watermark, not a consensus finality signal.
* `coverage`: `"full"` or `"partial"`. Address transfers and similar endpoints report `"partial"` when `clamp` narrowed the served window, or when the window starts before the chain's first indexed block.
* `refreshed_at`: when the data behind the response was last updated (UTC).
* It also repeats `chain`, `chain_slug`, and `chain_external_id`.

Snapshot and metadata endpoints with no natural block scope, such as balances and token metadata, still report `as_of_block` and `finalized_block` but do not compare the request against them. The transfers endpoint only serves data at or below `finalized_block`.

A common pattern: read `meta.finalized_block` from any first response and use it as the transfer window's `to_block`, so you never hardcode a block height.

## CU estimate for one page load [#cu-estimate-for-one-page-load]

Every method is billed by its CU weight, read from the platform plans API at build time; no figure is hardcoded in the text:

<WalletAssetsUsageEstimate lang="en" />

For billing decisions and non-billed error responses, see [billing rules](/en/guides/billing-rules/). If what you need is not indexed transfer history but logs from the most recent blocks that have not crossed the finality watermark, read [Recent node data vs indexed history](/en/guides/logs-vs-transfers/) first before deciding whether to switch to `eth_getLogs`.

## Next steps [#next-steps]

* [Browse the datasets directory](https://blockvectra.com/en/data/) to see every dataset BlockVectra indexes.
* [See the free plan and pricing](https://blockvectra.com/en/pricing/#free) to check what your account includes.
* [Log in to the console](https://console.blockvectra.com/en/login/?next=%2Fen%2Fkeys%2F) to create an API key.
