Wallet token balances API: list ERC-20 assets for an address

List a wallet's non-zero ERC-20 token balances with one GET /addresses/{address}/balances request, then add transfer history and token metadata.

One request to GET /{chain}/addresses/{address}/balances returns a wallet's non-zero ERC-20 token balances; add the Token Transfers API for history. Developers and AI agents use the same authenticated requests, and the Token Balances API page lists the fields.

curl -s "https://api.blockvectra.com/v1/data/robinhood_mainnet/addresses/0x1111111111111111111111111111111111111111/balances?limit=50" \
  -H "x-api-key: $BLOCKVECTRA_API_KEY"

Before querying, read GET /v1/status and check the selected chain's data_features and data_status; balance coverage varies by chain. Request parameters and response schemas are in the Data API reference.

Tasks this guide helps you complete

The three kinds of data a wallet assets page needs

A wallet assets page can show an address's ERC-20 token balances, token transfer history and token metadata. 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://api.blockvectra.com/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 page. On a chain without the capability, the endpoint returns 422 no_coverage.

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.

export BLOCKVECTRA_API_KEY=rgw_your_api_key

curl -s "https://api.blockvectra.com/v1/data/robinhood_mainnet/addresses/0x1111111111111111111111111111111111111111/balances?limit=50" \
  -H "x-api-key: $BLOCKVECTRA_API_KEY"

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

FieldTypeDescription
tokenstring (address)Token contract address; canonical form is 0x plus 40 lowercase hex digits.
balancestring (decimal)Raw integer balance, which may exceed 2^53, returned as a plain decimal string — never a JSON number, scientific notation, or hex.
symbolstring or nullToken symbol, or null when unavailable.
decimalsinteger or nullToken decimals, 0–255, or null when unavailable.

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 as_of_block returns 409 not_indexed_yet unless clamp=true truncates it down to as_of_block; a window wider than the chain's limit (limits.max_window_blocks from GET /chains) 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 as_of_block, 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.

export BLOCKVECTRA_API_KEY=rgw_your_api_key

# 1) Read as_of_block from the balances response.
AS_OF_BLOCK=$(curl -s "https://api.blockvectra.com/v1/data/robinhood_mainnet/addresses/0x1111111111111111111111111111111111111111/balances" \
  -H "x-api-key: $BLOCKVECTRA_API_KEY" | grep -o '"as_of_block":[0-9]*' | cut -d: -f2)

# 2) clamp=true truncates a too-wide window, or a to_block above as_of_block, instead of returning 409.
curl -s "https://api.blockvectra.com/v1/data/robinhood_mainnet/addresses/0x1111111111111111111111111111111111111111/transfers?standard=erc20&from_block=0&to_block=$AS_OF_BLOCK&direction=any&clamp=true" \
  -H "x-api-key: $BLOCKVECTRA_API_KEY"

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.

The code below fetches every transfer in the window:

const address = "0x1111111111111111111111111111111111111111";
const head = await fetch(
  `https://api.blockvectra.com/v1/data/robinhood_mainnet/addresses/${address}/balances`,
  { headers: { "x-api-key": process.env.BLOCKVECTRA_API_KEY! } },
).then((r) => r.json());
const asOfBlock = head.meta.as_of_block;
const transfers: unknown[] = [];
let cursor: string | undefined;

do {
  const url = new URL(
    `https://api.blockvectra.com/v1/data/robinhood_mainnet/addresses/${address}/transfers`,
  );
  url.searchParams.set("standard", "erc20");
  url.searchParams.set("from_block", "0");
  url.searchParams.set("to_block", String(asOfBlock));
  url.searchParams.set("limit", "500");
  // 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);

Request 3: token metadata and tokens:batch

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:

FieldTypeDescription
addressstring (address)Token contract address.
standardstringerc20, erc721, or unknown.
namestring or nullToken name, or null when unavailable.
symbolstring or nullToken symbol, or null when unavailable.
decimalsinteger or nullToken decimals, 0–255, or null when unavailable.
total_supplystring or nullRaw total supply; the API does not apply decimals scaling. null when unavailable.
first_seen_blockinteger (int64)Block height where the token was first seen.
metadata_updated_atstring (timestamp)UTC time when the metadata was last updated.
metadata_blockinteger (int64)Block height at which the metadata was read.
metadata_statusstringok, partial, or unavailable.
metadata_issuesobjectPer-field issue records keyed by name, symbol, decimals, total_supply, with values reverted, no_data, invalid_encoding, or temporarily_unavailable.

An unknown or not-public {chain} returns HTTP 404 with error.code: not_found and error.data.reason: unknown_chain before token or request-body validation. On a served chain, a {token} that is not a valid 20-byte address returns 400 bad_request, and an unknown {token} returns 404 not_found.

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.
export BLOCKVECTRA_API_KEY=rgw_your_api_key

# Single token
curl -s "https://api.blockvectra.com/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://api.blockvectra.com/v1/data/robinhood_mainnet/tokens:batch" \
  -H "x-api-key: $BLOCKVECTRA_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"addresses":["0x1111111111111111111111111111111111111111","0x2222222222222222222222222222222222222222"]}'

Scaling amounts by decimals

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

  • 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 int in Python, parsing the decimal string as-is to avoid precision loss.
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);

Data freshness

Every chain-scoped success response carries meta:

  • as_of_block: the chain's newest fully written block. Block-scoped endpoints serve data up to this height.
  • safe_block: a marker indicating the node's consensus safe block tag (null while unknown). Never below finalized_block, and does not truncate, reject, or delay responses.
  • finalized_block: a marker indicating the node's consensus finalized block tag (null while unknown). It does not truncate, reject, or delay responses; clients decide what safety they need from the marker (such as confirmation status).
  • 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). May be null: null means the update time of the data is unknown and it should be treated as stale; block-based endpoints always return a value.
  • It also repeats chain, chain_slug, and chain_external_id.

A common pattern: read meta.as_of_block from any first response to read up to the newest indexed block, and check meta.safe_block / meta.finalized_block if you want to display confirmed status.

CU estimate for one page load

Every method is billed by its CU weight, read from the platform plans API:

CU weight per call

MethodCU per call
data.address_balances25
data.address_transfers25
data.tokens_batch10

One page load (estimated)

1 balances request + 3 transfer pages + 1 tokens:batch request(s), 5 calls total, about 110 CU. Actual usage depends on the number of pages and tokens.

For billing decisions and non-billed error responses, see billing rules. If what you need is not indexed transfer history but logs from the most recent blocks, read Recent node data vs indexed history first before deciding whether to switch to eth_getLogs.

Next steps

Last updated:

On this page