List a Hedera account's NFTs

Read an account's NFT holdings through the Hedera mirror API. Follow pagination, keep requests bounded and distinguish unavailable data from an empty wallet.

Content reviewed on

Use the account-NFT endpoint to read a wallet's holdings. The response is paginated, so one page is not necessarily the complete list.

This example makes bounded read-only requests to the public mainnet mirror service. Use a public account you are entitled to inspect and keep the provider's limits in mind.

The request

GET /api/v1/accounts/{accountId}/nfts?limit=100

The response contains nfts and a pagination link in links.next. Continue through those links until the result ends, or until your application's request budget is reached.

A bounded Node example

Save the following as list-nfts.mjs. Run it with a real mainnet account ID, for example node list-nfts.mjs YOUR_ACCOUNT_ID. The script requires a supported Node runtime with built-in fetch.

const account = process.argv[2];
if (!/^\d+\.\d+\.\d+$/.test(account ?? '')) {
  throw new Error('Pass a Hedera account ID such as shard.realm.number.');
}

const origin = 'https://mainnet.mirrornode.hedera.com';
const path = `/api/v1/accounts/${account}/nfts`;
let next = new URL(`${path}?limit=100`, origin);
const visited = new Set();
const rows = new Map();
let complete = false;

for (let page = 0; page < 10 && next; page += 1) {
  if (next.origin !== origin || next.pathname !== path ||
      next.username || next.password || next.hash) {
    throw new Error('Unexpected pagination URL.');
  }
  if (visited.has(next.href)) throw new Error('Repeated pagination URL.');
  visited.add(next.href);

  const response = await fetch(next, {
    signal: AbortSignal.timeout(10000),
    redirect: 'error'
  });
  if (!response.ok) {
    throw new Error(`Mirror HTTP ${response.status}; stop and check the provider.`);
  }
  const body = await response.json();
  if (!Array.isArray(body.nfts) || !body.links ||
      !Object.hasOwn(body.links, 'next')) {
    throw new Error('Unexpected mirror response.');
  }
  for (const nft of body.nfts) {
    if (!nft || typeof nft.token_id !== 'string' ||
        !Number.isSafeInteger(nft.serial_number) || nft.serial_number < 1) {
      throw new Error('Unexpected NFT identifier.');
    }
    rows.set(`${nft.token_id}/${nft.serial_number}`, {
      tokenId: nft.token_id,
      serial: nft.serial_number
    });
  }
  const link = body.links.next;
  if (link === null) {
    complete = true;
    next = null;
  } else {
    if (typeof link !== 'string' || link.length === 0) {
      throw new Error('Unexpected next link.');
    }
    next = new URL(link, origin);
  }
}

console.log(JSON.stringify({
  network: 'mainnet',
  account,
  pages: visited.size,
  complete,
  nfts: [...rows.values()]
}, null, 2));

The script stops after ten pages. If complete is false, you have a partial result. Do not label it as the account's entire portfolio.

It also stops on HTTP errors, unexpected pagination destinations and repeated links. It does not retry aggressively, submit transactions or fetch NFT media.

Understand the result

The script keeps token ID and serial together and removes repeated pairs. It does not sort by acquisition time or calculate value.

A wallet can change while pagination is in progress. Ordinary sequential page reads are not an atomic snapshot, so a holdings check used for an important decision needs an appropriate consistency design.

For a small display widget, show when the data was fetched and whether it is complete. Treat an error as unavailable data, not an empty wallet.

Before using this in production

Choose a provider whose limits and service terms fit the application. Add an overall runtime and response-size budget, appropriate caching and cancellation. Keep private keys out of the service because this read-only job does not need them.

Read an individual NFT's metadata or add marketplace data through the SentX API.

Reference: Hedera account NFT endpoint.