.md

Get NFT transfers for a Stacks collection

Every NFT transfer is decoded and indexed, so one collection's history is a filtered read. Each row names the token, its sender and its recipient. One key, one call:

$curl "$SECONDLAYER_API_URL/v1/index/nft-transfers?contract_id=SP2N959SER36FZ5QT1CX9BR63W3E8X35WQCMBYYWC.leo-cats&order=desc&limit=1" \
-H "Authorization: Bearer sk-sl_…"
200response.jsonjson
{
"events": [
{
"cursor": "9346444:0",
"block_height": 9346444,
"block_time": "2026-10-28T12:06:24.000Z",
"tx_id": "0xdbe15200c985136b4a673d52e6eb72f0f0fdf1904548820a471c9a8b1287db4f",
"tx_index": 0,
"event_index": 0,
"event_type": "nft_transfer",
"contract_id": "SP2N959SER36FZ5QT1CX9BR63W3E8X35WQCMBYYWC.leo-cats",
"asset_identifier": "SP2N959SER36FZ5QT1CX9BR63W3E8X35WQCMBYYWC.leo-cats::leo-cats",
"sender": "SP3Z7511VWR5WG9J3MAKER3NRZYKWT83K2XTP36EV",
"recipient": "SPV9K21TBFAK4KNRJXF5DFP8N7W46G4V9RCJDC22.leo-nft-market-faktory",
"value": "0x01000000000000000000000000000007ec"
}
],
"next_cursor": "desc:9346444:0",
"tip": { "block_height": 9348996, "finalized_height": 9348069, "lag_seconds": 24 },
"reorgs": []
}

value is the token id as a serialized Clarity value in hex, not a plain number. A collection's ids are usually uint, which decodes to the number you know from the marketplace.

Decode value with @secondlayer/stacks; the row above is token 2028.

import { cvToValue, deserializeCV } from "@secondlayer/stacks/clarity";
const value = "0x01000000000000000000000000000007ec"; // a row's value
const tokenId = cvToValue(deserializeCV(value)); // 2028n

A read with no fromHeight or cursor covers only the most recent ~17,280 blocks, so pass a start to go further back.

Narrow byParam
One collectioncontractId
One NFT type in a contractassetIdentifier
Who sent it, who received itsender, recipient
A block rangefromHeight, toHeight
Newest firstorder: "desc"
  • contractId is one contract. A contract that defines several NFT types needs assetIdentifier to pick one.
  • There is no token id filter: to follow one token, filter on the decoded id after the read.
  • Mints and burns are separate feeds, not transfers (Index).

Pass the last next_cursor back as the next cursor. It comes back null only on an empty page, which ends the loop.

import type { NftTransfersEnvelope } from "@secondlayer/sdk";
let cursor: string | null = null;
do {
const page: NftTransfersEnvelope = await index.nftTransfers.list({
contractId,
fromHeight: cursor ? undefined : 0,
cursor,
limit: 1000,
});
for (const transfer of page.events) await save(transfer);
cursor = page.next_cursor;
} while (cursor);

Pass a start height or a cursor, never both: drop the height once you hold a cursor. A cursor only resumes under the order that issued it. For a one-off export, walk() does the paging for you; it keeps no checkpoint (list, walk, consume).

Hosted reads are metered, with a free monthly allowance (What it costs). A popular collection means many rows, so narrow by height before you page, and request fields to trim each row.

Every filter and the response schema: List NFT transfers. The decoded feeds are described in Index.

Edit on GitHub