Build a wallet assets page with the Data API: balances, transfers and token info
Build a wallet assets page with the Data API. Query address balances, transfers, and batch token metadata with cursor pagination and decimals scaling.
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}/balancesreturns the address's non-zero ERC-20 balances, ordered bytokenaddress ascending, with tokensymbolanddecimalsincluded where available. An address with no balances returns200withdata: []. - Transfers:
GET /{chain}/addresses/{address}/transfersreturns 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:batchreads 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 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, thechainvalue of an entry inGET /chains(for examplerobinhood_mainnet). Matching is exact and case-sensitive; aliases and numeric chain IDs are not accepted.{address}(path parameter, required): 20-byte address; the0xprefix is optional and either case is accepted.limit(query parameter, optional): page size. Defaults to 50; values above 500 are clamped to 500;0or a non-integer returns400 bad_request.cursor(query parameter, optional): the previous response'snext_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 returns400 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://dev-api.blockvectra.network/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:
| 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
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):erc20orerc721. Address-scoped queries do not covererc1155; passing it returns422 no_coverage.direction(query parameter, optional):in,out, orany; defaults toanyand filters by direction relative to the address.token(query parameter, optional): restrict results to one token contract.clamp(query parameter, optional): only the literal stringtrueenables it; any other value is treated asfalse.
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.
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"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.
limitdefaults to 50 and is capped at 500.- Pass
cursorback 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) or422 no_coverage.
The code below fetches every transfer in the window:
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);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:
| 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, returns400 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, whiledata.tokenscontains only the tokens whose metadata was found. - Duplicate addresses are deduplicated in both
tokensandmissing, each in first-occurrence request order.
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"]}'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.
decimalscomes from the balance item's ownsymbol/decimals, or fromGET /{chain}/tokens/{token}andPOST /{chain}/tokens:batch; it can benull.- These values may exceed
2^53, so do not do the arithmetic with a JSON number: useBigIntin TypeScript andDecimalin 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 indexed head the response's finality watermark was computed from.finalized_block: the highest block height that block-scoped endpoints will serve; it trailsas_of_blockby 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"whenclampnarrowed 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, andchain_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
Every method is billed by its CU weight, read from the platform plans API at build time; no figure is hardcoded in the text:
CU weight per call
| Method | CU per call |
|---|---|
data.address_balances | 25 |
data.address_transfers | 25 |
data.tokens_batch | 10 |
Weights are read from the platform plans API at build time.
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 that have not crossed the finality watermark, read Recent node data vs indexed history first before deciding whether to switch to eth_getLogs.
Next steps
- Browse the datasets directory to see every dataset BlockVectra indexes.
- See the free plan and pricing to check what your account includes.
- Log in to the console to create an API key.
Per-method pricing: reading CU and price per million calls
Understand BlockVectra's CU metering, method weight resolution rules, price per million calls formula, and how to estimate per-cycle usage and costs.
Transaction traces: debug_traceTransaction and the Data API trace endpoints
Reconstruct execution call trees for a transaction: the JSON-RPC debug_traceTransaction method with its allowed tracers and guards, and the Data API getTransactionTrace and getBlockTraces endpoints with their finality limits.