NFT metadata

Fetch NFT metadata, list collection contents, and look up current owners

Four endpoints for ERC-721 and ERC-1155 collections: per-token metadata, paginated collection listings, current owner lookup, and the inverse - all NFTs held by an account.

All four are read-only and use the standard api-key header (see API Keys).

GET /api/nft/metadata/ - one NFT

Returns metadata for a specific NFT. Cache-first: if we already fetched it we serve the cached row. Otherwise we call token_uri / tokenURI on the contract, parse the response (handles both Cairo 1 ByteArray and legacy Cairo 0 felt arrays), follow the URI to its JSON body, and cache the result.

ParameterRequired
token_addressyes - the NFT contract address
nft_token_idyes - the specific NFT id within the collection
Code
bash
1curl 'https://api.tokenkithq.io/api/nft/metadata/?token_address=0x020c9638...&nft_token_id=430' \
2 -H 'api-key: YOUR_API_KEY'
Code
json
1{
2 "success": true,
3 "data": {
4 "token_address": "0x020c9638...",
5 "nft_token_id": "430",
6 "name": "Doodle #430",
7 "description": "A community-driven collectibles project...",
8 "image": "https://ipfs.io/ipfs/QmX3XNKi8s.../430.png",
9 "external_url": null,
10 "animation_url": null,
11 "attributes": [
12 { "trait_type": "face", "value": "content" },
13 { "trait_type": "hair", "value": "blue alfalfa" }
14 ],
15 "metadata_uri": "https://api.comoco.xyz/metadata/0x020c9638.../430"
16 }
17}

image is already resolved from ipfs:// to a usable HTTPS gateway URL. metadata_uri is the raw URI returned by the contract - useful if you want to fetch the source JSON yourself.

If the source contract reverts (token id never minted, broken metadata logic), you get a 404 with the on-chain reason in detail.

GET /api/nft/collection/ - NFTs in one collection

Paginated list of NFTs we have cached metadata for. This pulls from cached rows only - it does not fetch missing NFTs on the fly. Use /api/nft/metadata/ for that.

ParameterRequiredNotes
token_addressyesThe collection's contract address
pagenoDefault 1
page_sizenoDefault 20, max 100
Code
bash
1curl 'https://api.tokenkithq.io/api/nft/collection/?token_address=0x020c9638...&page=1&page_size=20' \
2 -H 'api-key: YOUR_API_KEY'
Code
json
1{
2 "success": true,
3 "token_address": "0x020c9638...",
4 "page": 1,
5 "page_size": 20,
6 "count": 247,
7 "data": [
8 {
9 "nft_token_id": "1",
10 "name": "Doodle #1",
11 "description": "...",
12 "image": "https://ipfs.io/ipfs/.../1.png",
13 "external_url": null,
14 "animation_url": null,
15 "attributes": [...],
16 "metadata_uri": "https://..."
17 }
18 ]
19}

GET /api/nft/owner/ - current owner of one NFT

Returns the address that currently holds a specific NFT. Derived from the on-chain transfer log (most recent recipient wins).

ParameterRequired
token_addressyes
nft_token_idyes
Code
bash
1curl 'https://api.tokenkithq.io/api/nft/owner/?token_address=0x020c9638...&nft_token_id=430' \
2 -H 'api-key: YOUR_API_KEY'
Code
json
1{
2 "success": true,
3 "data": {
4 "token_address": "0x020c9638...",
5 "nft_token_id": "430",
6 "owner_address": "0x05d0f2e1fceecd2c890c64fb9bef1804a8af31e8a259fdfdc0994e6f5fd942a5",
7 "last_transfer_block": 7888,
8 "last_transfer_timestamp": "2022-11-05T07:34:07+01:00",
9 "last_transfer_txhash": "0x01ce26493a91ba6440f43e9e1d1011ee6ae3be432521a3828f07ad52fb861237"
10 }
11}

Returns 404 if no transfers exist for that NFT (it was probably never minted).

For ERC-1155 collections this only tells you the most recent recipient, not all current holders - quantities can be split across many addresses. Use the transfers endpoint for the full picture.

GET /api/nft/owned-by/ - all NFTs an account holds

Returns every NFT currently held by a given account, across every NFT collection (or scoped to one if you pass token_address). Always carries the parent collection's name/symbol/logo; optionally merges in cached per-NFT metadata.

ParameterRequiredNotes
account_addressyesThe wallet whose NFTs you want
token_addressnoLimit to one collection
pagenoDefault 1
page_sizenoDefault 20, max 100
include_metadatanotrue (default) merges in per-NFT name/image/attributes; false returns just the ownership + collection identity
Code
bash
1curl 'https://api.tokenkithq.io/api/nft/owned-by/?account_address=0x05d0f2e1...&page_size=20' \
2 -H 'api-key: YOUR_API_KEY'

Response shape

Every entry in data always includes the same eight identity fields. When include_metadata=true (default) and we have a cached is_fetched=True row for that NFT, five extra fields are merged in.

FieldTypeAlways present?Source
token_addressstringyesClickHouse transfer log
nft_token_idstringyesClickHouse transfer log
last_transfer_blocknumberyesClickHouse transfer log
collection_namestring | nullyesToken.name
collection_symbolstring | nullyesToken.symbol
collection_logostring | nullyesToken.logo
is_erc721booleanyesToken.is_erc721
is_erc1155booleanyesToken.is_erc1155
namestring | nullonly if metadata cachedNftMetadata.name
imagestring | nullonly if metadata cachedNftMetadata.image (gateway-resolved)
descriptionstring | nullonly if metadata cachedNftMetadata.description
attributesarrayonly if metadata cachedNftMetadata.attributes
metadata_uristring | nullonly if metadata cachedraw URI returned by the contract

The identity fields let a wallet render a useful card ("Thomas Sinclair and Son ยท #3") even before per-NFT metadata has been fetched. The metadata fields fill in the picture name and image when available.

Warning

ERC-1155 caveat. For ERC-1155 collections the response identifies "the most recent recipient" of each (token, nft_token_id) โ€” it does not report per-holder balances. A 1155 token id can be held by many wallets simultaneously, each with a different quantity, and this endpoint can't currently represent that fan-out. Use is_erc1155: true to switch the wallet UX (don't show "you own this NFT" โ€” show "you received N at block X") until the quantity-correct endpoint lands.

Example - per-NFT metadata cached

Code
json
1{
2 "success": true,
3 "page": 1,
4 "page_size": 20,
5 "count": 1,
6 "data": [
7 {
8 "token_address": "0x020c9638...",
9 "nft_token_id": "430",
10 "last_transfer_block": 7888,
11 "collection_name": "Doodles",
12 "collection_symbol": "DOOD",
13 "collection_logo": "https://.../doodles-logo.png",
14 "is_erc721": true,
15 "is_erc1155": false,
16 "name": "Doodle #430",
17 "image": "https://ipfs.io/ipfs/.../430.png",
18 "description": "A community-driven collectibles project...",
19 "attributes": [
20 { "trait_type": "face", "value": "content" }
21 ],
22 "metadata_uri": "https://api.comoco.xyz/metadata/0x020c9638.../430"
23 }
24 ]
25}

Example - per-NFT metadata not cached yet

The name / image / description / attributes / metadata_uri keys are simply absent. Collection identity is still there.

Code
json
1{
2 "success": true,
3 "page": 1,
4 "page_size": 100,
5 "count": 2,
6 "data": [
7 {
8 "token_address": "0x0111e08c883b8387e7ecbe2b4b6a502a294f7443d1cc8075df34130439f5f0cf",
9 "nft_token_id": "3",
10 "last_transfer_block": 10023047,
11 "collection_name": "Thomas Sinclair and Son",
12 "collection_symbol": "TSAS",
13 "collection_logo": null,
14 "is_erc721": true,
15 "is_erc1155": false
16 },
17 {
18 "token_address": "0x06ad4d55ed2d12ad5d6d55587d800874ef807b051bfefe13497e60ec5b019369",
19 "nft_token_id": "26",
20 "last_transfer_block": 10022994,
21 "collection_name": "MediolanoIntelctualProperty",
22 "collection_symbol": "MIP",
23 "collection_logo": null,
24 "is_erc721": true,
25 "is_erc1155": false
26 }
27 ]
28}

A wallet that wants the full picture can either (a) call /api/nft/metadata/ per missing NFT to trigger an on-demand fetch + cache, or (b) just render with what's there - the background warmer will eventually fill the cache (see How metadata caching works).

How metadata caching works

We fetch metadata three ways:

  1. On-transfer. Whenever a transfer of an NFT lands in the indexer, we queue a one-shot fetch for that specific (token, nft_token_id). Most NFTs people actually move get cached automatically within seconds.
  2. On-demand. First call to /api/nft/metadata/ for an uncached NFT triggers a synchronous fetch. Subsequent calls are cache hits.
  3. Periodic sweeps. A 5-minute background task and an hourly catch-up pick up any NFTs that were missed (e.g. seen in transfers before the contract was classified as ERC-721, or contracts whose metadata host was offline at indexing time).

Retry policy for failed fetches

Failed fetches are bounded so one broken metadata host can't monopolize the warmer budget:

  • Every failure increments an attempt_count and stamps last_attempt_at.
  • Retries are spaced out by a backoff curve - failed rows are skipped until that window elapses.
  • After 10 failed attempts an (token, nft_token_id) is considered permanently broken and the background sweepers stop touching it. A successful fetch at any point resets the counter to 0.

Practical consequences for API consumers:

  • An NFT whose contract returns an offline URI may show up in /api/nft/owned-by/ without per-NFT metadata for a while. The collection identity (collection_name / collection_symbol) is still there immediately.
  • /api/nft/metadata/ triggers a fetch on cache miss, but a row that's still inside the backoff window or already past the 10-attempt cap is treated as "not ready yet" - the endpoint returns a 404 without re-hitting the failing host. This is intentional: it stops a hammering wallet from re-amplifying the load on a broken metadata server. When the host comes back, the next scheduled retry caches the row and subsequent calls succeed.

Contract-side URI repair

A handful of contracts store malformed URIs (e.g. https://ipfs.io/ipfs/ipfs://bafkrei... - a gateway URL prepended over an ipfs:// URI). The resolver strips the duplicated ipfs:// and resolves the underlying CID, so these collections still cache correctly without contract changes.

Dynamic metadata

If the source contract returns dynamic metadata (soulbound tokens that mutate, NFTs whose URI flips after reveal), the cached value can lag the on-chain state. We do not currently expose a force-refresh on the API; ask the team if you have a use case.

See also