Cross-chain NFT ownership, metadata, and transfer history for the Pentagon Games ecosystem.
Get up and running with the NFT Data API in three steps.
Register at cli.pentagon.games or contact the Pentagon Games team:
Include your App Key in all requests for higher rate limits:
X-PG-App-Key: pk_live_your_key
Look up ownership, metadata, and transfers across 14 chains with a single API call.
The Pentagon NFT Data API provides unified NFT ownership tracking across the Pentagon Games ecosystem. Query any wallet, any chain, any collection — one API, one answer.
Find all NFTs owned by any wallet address, filterable by chain, collection, or contract.
Ethereum, Polygon, BSC, Arbitrum, Pentagon Chain, and 9 more networks tracked in real-time.
Token metadata, traits, collection stats, holder counts, and transfer history.
Sub-second queries backed by a four-layer sync engine. Pentagon chain gets real-time updates.
https://nft-data.pentagon.games
Pass your app key via the X-PG-App-Key header:
curl -H "X-PG-App-Key: pk_live_your_key" \
"https://nft-data.pentagon.games/api/v1/nft/owner/0x1234..."
| Auth Level | Limit | Scope |
|---|---|---|
| No key (public) | 10 req/min | Per IP address |
| App key (web) | 60–100 req/min | Per key |
| App key (server) | 100–500 req/min | Per key |
Rate limits are configurable per app key. When exceeded, the API returns 429 Too Many Requests with a retry_after field.
| Method | Endpoint | Description |
|---|---|---|
| GET | / | Health check — returns service status |
| GET | /api/v1/nft/owner/:address | All NFTs owned by a wallet |
| GET | /api/v1/nft/:contract/:tokenId | Single NFT details + metadata |
| GET | /api/v1/collections | List all tracked collections |
| GET | /api/v1/collection/{contract}/items | Full item index of a collection (paginated) |
| GET | /api/v1/collection/:contract/stats | Collection ownership statistics |
| GET | /api/v1/nft/:contract/:tokenId/transfers | Transfer history for an NFT |
| GET | /api/v1/nft/owner/:address/chains | Lightweight per-chain NFT count summary |
| GET POST | /api/v1/nft/owners | Multi-address merged NFT lookup (max 10) |
| POST GET | /api/v1/report | Submit / list user "flag this" data-quality reports |
GET /api/v1/nft/owner/:wallet_address
Returns all NFTs owned by a wallet address across all tracked chains and collections.
| Parameter | Type | Required | Description |
|---|---|---|---|
wallet_address | path | Yes | Ethereum-style wallet address (0x...) |
chain_id | query | No | Filter by chain ID (e.g., 3344 for Pentagon) |
contract | query | No | Filter by contract address |
collection | query | No | Filter by collection name |
group_by | query | No | Set to chain to group results by chain ID |
page | query | No | Page number (default: 1) |
limit | query | No | Results per page (default: 50, max: 200) |
Example request:
curl "https://nft-data.pentagon.games/api/v1/nft/owner/0x37224cFD71347Da6f097c94B8649f9C552f803c9?chain_id=3344"
Response:
{
"owner": "0x37224cFD71347Da6f097c94B8649f9C552f803c9",
"total": 17,
"page": 1,
"limit": 50,
"nfts": [
{
"tokenId": "4201",
"name": "BCSH #4201",
"owner": "0x37224cfd71347da6f097c94b8649f9c552f803c9",
"collection": "BCSH OASYS",
"contractAddress": "0xcDAD57bFc48E8373280C6dc3039C5169353B6879",
"chain": 3344,
"standard": "ERC721",
"image": "https://images.pfpvault.com/3344/0xcdad.../4201.webp",
"fileType": "image",
"mediaType": "image",
"desc": "After shattering her katana...",
"transferable": true,
"updatedAt": "2026-05-11T04:15:39+00:00"
}
]
}
nfts[] entry now carries the fields a wallet needs to render and gate actions without a second call per token:
standard (ERC721 / ERC1155 — gate transfer/listing UI),
fileType (alias mediaType) — a normalized media type, currently always image for any token with a renderable image (see note below),
transferable (true = freely tradable, false = soulbound/locked; always present, defaults to false),
and desc (short description). For the full attribute list, license tier, and storage info, call the single-token endpoint below.
fileType / mediaType values: the API returns a normalized value so clients have one rule. Today the only value returned is image (for any token that has a renderable image), or null in the rare case a token has no image at all. The underlying sync stores an unnormalized none for tokens it didn't explicitly classify (e.g. some PEGNAMES SVGs, Gemry, EtherFantasy stills) — the API collapses that to image because the image URL still renders as a still. Treatment: always render the image URL; there is no current case where image should not be shown.
animation_url field for video / glTF / 3D assets (e.g. Gemry animations) is on the sync roadmap and will appear on both the owner-list and single-token endpoints once the ingestion pipeline stores it (it has no column in the DB today). When that lands, fileType/mediaType will start returning values like video or gltf. Until then, treat a missing animation_url as "still image only."
With ?group_by=chain:
{
"chains": [
{"chainId": 1, "count": 610, "nfts": [...]},
{"chainId": 3344, "count": 1, "nfts": [...]}
]
}
GET /api/v1/nft/:contract_address/:token_id
Returns details for a specific NFT including current owner, metadata, and trait attributes.
| Parameter | Type | Required | Description |
|---|---|---|---|
contract_address | path | Yes | NFT contract address |
token_id | path | Yes | Token ID |
Response:
{
"contract": "0xcDAD57bFc48E8373280C6dc3039C5169353B6879",
"tokenId": "5555000000553",
"collection": "BCSH OASYS",
"chain": 3344,
"owner": "0x6e28399aa9dd598caa49df9fa46d0ae87c0e7d57",
"transferable": true,
"standard": "ERC721",
"metadata": {
"name": "Dark Setsuko",
"description": "After shattering her katana...",
"image": "https://images.pfpvault.com/3344/0xcdad.../5555000000553.webp",
"imageSource": "cached",
"attributes": [
{"trait_type": "Background", "value": "Blue"}
]
},
"license": {
"schema": "erc721l-metadata-v1",
"rights_model": "base-plus-upgrade",
"current_tier": 4,
"current_name": "COMMERCIAL_DERIVS_NONEXCLUSIVE",
"base_tier": 2,
"upgraded_tier": 4,
"terms_uri": "https://nft-data.pentagon.games/licenses/tier-4",
"source": "override"
},
"storage": {
"image": {
"tier": "cached",
"provider": "s3+cloudfront",
"permanence": "managed",
"url": "https://images.pfpvault.com/3344/0xcdad.../5555000000553.webp",
"archivalOriginal": "s3://pg-nft-images/originals/3344/0xcdad.../5555000000553.png"
},
"metadata": {
"tier": "override",
"provider": "pg_nft_db",
"tokenUri": "https://api.bcsh.xyz/metadata/5555000000553"
}
},
"source": {
"tokenUri": "https://api.bcsh.xyz/metadata/5555000000553",
"lastSync": "2026-05-08T18:19:02+00:00"
}
}
transferable (SBT flag — false means soulbound/locked), standard (ERC721 or ERC1155), license (ERC-721L rights tier, see Metadata & Licensing), and storage (where the image and metadata physically live and how permanent they are). These are promoted to the top level for integrators; transferable and licensing values also remain mirrored inside metadata.attributes for backward compatibility.
image field returns the best available source — prefers per-token CDN (images.pfpvault.com) over on-chain metadata (arweave.net). The imageSource field tells you which tier was used: "cached" (pfpvault CDN), "onchain" (from tokenURI), or "original" (fallback). This ensures upgraded NFTs (e.g. Dark Setsuko, Obelith variants) display their correct artwork.
metadata field contains the override we serve (fast, normalized). The source.tokenUri points to the underlying on-chain / project metadata endpoint if you need a live fetch. See Metadata & Licensing for how the override layer works.
GET /api/v1/nft/owner/:address/chains
Returns a lightweight breakdown of how many NFTs a wallet holds on each chain, without fetching full NFT data. Useful for building chain selectors or quick portfolio overviews.
| Parameter | Type | Required | Description |
|---|---|---|---|
address | path | Yes | Ethereum-style wallet address (0x...) |
Example request:
curl "https://nft-data.pentagon.games/api/v1/nft/owner/0x37224cFD71347Da6f097c94B8649f9C552f803c9/chains"
Response:
{
"total": 611,
"chains": [
{"chainId": 1, "count": 610},
{"chainId": 3344, "count": 1}
]
}
/chains first to discover which chains a user has NFTs on, then fetch per-chain with ?chain_id=3344 on the owner endpoint. This avoids loading all NFTs across all chains upfront.
GET /api/v1/nft/owners?addresses=0xABC,0xDEF
POST /api/v1/nft/owners
Merges NFTs from multiple wallet addresses into a single response (max 10 addresses). Supports all the same filters as the single-address endpoint (chain_id, collection, contract, group_by).
| Parameter | Type | Required | Description |
|---|---|---|---|
addresses | query (GET) or body (POST) | Yes | Comma-separated addresses (GET) or JSON array (POST). Max 10. |
chain_id | query | No | Filter by chain ID |
collection | query | No | Filter by collection name |
contract | query | No | Filter by contract address |
group_by | query | No | Set to chain for per-chain grouping |
GET example:
curl "https://nft-data.pentagon.games/api/v1/nft/owners?addresses=0xABC...,0xDEF...&chain_id=3344"
POST example:
curl -X POST "https://nft-data.pentagon.games/api/v1/nft/owners" \
-H "Content-Type: application/json" \
-H "X-PG-App-Key: pk_live_your_key" \
-d '{"addresses": ["0xABC...", "0xDEF..."]}'
api.account.pentagon.games/user/nfts which auto-resolved linked wallets via user auth, you can now get the same result by fetching the user's linked addresses from the Pentagon Identity API, then passing them all to /api/v1/nft/owners. Same response format as the single-address endpoint.
GET /api/v1/collections
Returns the tracked NFT collections with contract address, chain, token standard, indexed token count, and a display logo. Useful for building collection-filter UIs.
| Parameter | Type | Required | Description |
|---|---|---|---|
chain | query | No | Filter by chain ID (alias chain_id), e.g. 3344 for Pentagon |
active | query | No | true (default), false, or all |
Example request:
curl "https://nft-data.pentagon.games/api/v1/collections?chain=1"
Response:
{
"count": 82,
"collections": [
{
"name": "MoonCats",
"logo": "https://images.pfpvault.com/1/0xc3f7.../.webp",
"logoIsFallback": true,
"logoFilename": null,
"contractAddress": "0xc3f733ca98e0dad0386979eb96fb1722a1a05e69",
"chain": 1,
"standard": "ERC721",
"count": 20921
},
{
"name": "Gunnies",
"logo": "https://pentagon.games/images/gunnies-logo.png",
"logoIsFallback": false,
"logoFilename": "https://pentagon.games/images/gunnies-logo.png",
"contractAddress": "0x...",
"chain": 3344,
"standard": "ERC721",
"count": 9093
}
]
}
Fields: name (display name), logo, logoIsFallback, logoFilename, contractAddress, chain (chain ID), standard (ERC721 / ERC1155 / etc), and count (indexed token count). Results are ordered by count descending.
logo is a ready-to-render absolute URL — clients never need to prepend a base path. Resolution order: (1) the collection's curated logo if it is already a full http(s) URL (logoIsFallback: false); otherwise (2) the first synced token's image, a full CDN/Arweave URL (logoIsFallback: true). It is only null when the collection has no renderable token image yet (unsynced or missing image data). The raw curated value from the registry (which may be a bare filename like azuki.png with no resolvable host) is preserved separately in logoFilename for future use, but you should render logo, not logoFilename.
GET /api/v1/collection/{contract}/items
Returns every item (token) in a collection, paginated — the collection
“item index.” This is the single source of truth for a collection's enumeration:
ownership + names + cached-CDN images + transferable/SBT flags, keyed by tokenId.
(contractAddress, tokenId).
No consumer should re-crawl the chain for NFT ownership or images — nft-data is the source of
truth (with image backfill + CDN), you maintain listing data only.
| Parameter | Type | Required | Description |
|---|---|---|---|
page | query | No | Page number (default 1) |
limit | query | No | Items per page (default 50, max 200); echoed as pageSize |
owner | query | No | Filter to a single holder within the collection |
include | query | No | Comma-list: traits (embeds attributes[]) and/or rarity (embeds rarity{}). Omit for a lean payload. Use this to bulk-ingest attributes + rarity instead of per-token crawling. |
transferable | query | No | true = tradable only, false = soulbound/non-tradable only. Omit for all. See Tradability. |
Example (bulk attributes + rarity, one call):
curl "https://nft-data.pentagon.games/api/v1/collection/0xed5af38...c544/items?limit=200&include=traits,rarity"
Response:
{
"contractAddress": "0xBF01e486...766",
"chain": 3344,
"collection": "EtherFantasy PentaPet",
"total": 10,
"page": 1, "limit": 50, "pageSize": 50,
"items": [
{
"tokenId": "10",
"name": "Pudde #10",
"image": "https://images.pfpvault.com/3344/0xbf01.../10.webp", // cached CDN
"imageOriginal": "https://api.etherfantasy.com/api/pets/image/10",
"fileType": "image", "mediaType": "image",
"standard": "ERC721",
"transferable": false,
"owner": "0x03ae56d4...9294",
"updatedAt": "2026-07-12T07:30:39+00:00"
}
]
}
Page through with page/limit until you have all total items. Returns 404 if the contract isn't tracked.
Every item carries a transferable boolean. nft-data is the source of truth for
whether an NFT can be traded — marketplaces should depend on this rather than deriving it.
| Value | Meaning | Marketplace behavior |
|---|---|---|
transferable: true | Standard tradable NFT (default) | Listable / buyable normally |
transferable: false | Soulbound / locked — cannot be transferred | Do NOT allow listing; hide or clearly flag any existing listing; block purchase |
?transferable=false (soulbound only) or ?transferable=true (tradable only)
on the item index. Use it to prevent buying a listing that's no longer tradable, or to power a
“non-tradable” filter. The flag is present on the item index, single-token, and owner endpoints.
Trait-frequency rarity is precomputed by nft-data (so every Pentagon surface shows the
same rank for a token) and returned on the single-token endpoint and via
?include=rarity on the item index. It ranks within the collection; rank 1 = rarest.
"rarity": {
"rank": 763, // 1 = rarest, within the collection
"totalSupply": 10000,
"traitCount": 7,
"rarityScore": 744.76, // rarity.tools style: Σ (total / count(trait value))
"infoContent": 34.16 // Σ −log2(freq) — less gameable than rarityScore
}
Two independent standards are exposed so consumers pick: rarityScore (familiar,
rarity.tools-compatible) and infoContent (information-theoretic, harder to game by
stuffing common traits). rarity is null for collections without indexed traits.
GET /api/v1/collection/:contract_address/stats
Returns ownership statistics for a collection including total supply, unique holder count, and top holders.
Response:
{
"contract": "0xcDAD57bFc48E8373280C6dc3039C5169353B6879",
"name": "BCSH OASYS",
"total_supply": 10000,
"unique_owners": 3421,
"top_holders": [
{"address": "0x...", "count": 150},
{"address": "0x...", "count": 87}
]
}
GET /api/v1/nft/:contract_address/:token_id/transfers
Returns the full transfer history for a specific NFT, from mint to present.
Response:
{
"transfers": [
{
"from": "0x0000000000000000000000000000000000000000",
"to": "0x37224cfd71347da6f097c94b8649f9c552f803c9",
"tx_hash": "0xabc123...",
"block_number": 1234567,
"timestamp": "2026-01-15T12:30:00Z"
}
]
}
POST /api/v1/report
Lets any API-enabled site (e.g. a marketplace or explorer) forward an end-user “flag this” report about a data-quality issue — a broken or wrong image, missing metadata, wrong owner, etc. Each project can surface reporting to its own users however it likes; reports land in one shared queue for staff to triage. Requires an app key (submit from your backend, not the browser, so the key is not exposed).
Body (JSON):
| Field | Type | Required | Description |
|---|---|---|---|
| contractAddress | string | yes | NFT contract address |
| type | string | yes | One of: broken_image, wrong_image, missing_metadata, wrong_trait, wrong_owner, not_synced, other |
| chain | integer | no | Chain ID (alias chainId) |
| tokenId | string | no | Specific token; omit for a collection-level report |
| message | string | no | Free text, max 2000 chars |
| reporter | string | no | Who reported (wallet, email, or username) |
Example:
curl -X POST https://nft-data.pentagon.games/api/v1/report \
-H "X-PG-App-Key: YOUR_KEY" -H "Content-Type: application/json" \
-d '{
"chain": 3344,
"contractAddress": "0x8F83c6122Dd4d275B53a7846B3D3dB29Cca1e698",
"tokenId": "9549",
"type": "broken_image",
"message": "grey background, transparency not removed",
"reporter": "0x37224cfd71347da6f097c94b8649f9c552f803c9"
}'
Response:
{ "status": "ok", "reportId": 42 }
List reports (staff triage): GET /api/v1/report (also app-key gated).
Filters: ?status=open&chain=3344&contract=0x..&type=broken_image&page=1&limit=50.
{
"count": 1, "page": 1, "limit": 50,
"reports": [
{ "id": 42, "chain": 3344, "contractAddress": "0x8f83...", "tokenId": "9549",
"type": "broken_image", "message": "grey background...", "reporter": "0x3722...",
"source": "nftmining", "status": "open", "createdAt": "2026-07-04T07:23:51Z" }
]
}
// 401 — Invalid or inactive app key
{"error": "Invalid or inactive app key"}
// 404 — NFT or collection not found
{"error": "NFT not found"}
// 429 — Rate limit exceeded
{"error": "Rate limit exceeded", "retry_after": 60}
The NFT Data API tracks collections across 14 blockchain networks. Pentagon Chain contracts get real-time WebSocket tracking (sub-second updates). All other chains use block-range scanning with 2–6 minute sync intervals.
| Chain | Chain ID | Sync Mode | Status |
|---|---|---|---|
| Pentagon | 3344 | Real-time + Scanner | Live |
| Ethereum | 1 | Scanner | Live |
| BSC | 56 | Scanner | Live |
| Polygon | 137 | Scanner | Live |
| Arbitrum | 42161 | Scanner | Live |
| Core | 1116 | Scanner | Live |
| OASYS | 248 | Scanner | Live |
| Monad | 143 | Scanner | Live |
| Avalanche | 43114 | Scanner | Live |
| SKALE Nebula | 1482601649 | Scanner | Live |
49 collections are actively tracked. Here are the key Pentagon Chain collections. Use GET /api/v1/collections for the full list.
| Collection | Contract | Type |
|---|---|---|
| BCSH OASYS | 0xcDAD57bFc48E8373280C6dc3039C5169353B6879 | ERC721 |
| BCSH Vaelion | 0x35A31E23FB1AAD207Ad4075C52e981dC9165059b | ERC721 |
| BCSH No_5 | 0x0A5FE002F2eD146415A1f4865DE1c180a39D599E | ERC721 |
| BCSH Tamago | 0x2444D26cC268848f2B1bd837456537510F1aac81 | ERC721 |
| BCSH Baiyi | 0xF8C869a5575f44fB9b68F670e6B158B30fB8Ccf5 | ERC721 |
| GCN NFT | 0x42D97d553Ee71deF76a131e48Fc42BCf8da3B141 | ERC721 |
| GCN Shards | 0xfd8276d1745761D3C5C55269fe4466FCD31D9dD3 | ERC1155 |
| Gunnies PFP | 0x7a8a3236e3783E7cC33b97729378e31Cf14d3Ebc | ERC721 |
| PentaPets | 0xe6BdE156369D209C4d420E966541eE17093705B5 | ERC721 |
| RugPull Art | 0xd77f88ef51b2589d132d6eb61068079f61dfe4a3 | ERC721 |
| Gemry | 0xc05b96b89Ce46c306223E3f4c413891d17E1De70 | ERC721 |
| PEGNAMES | 0xf97EB9f8293D1FD5587a809Eb74518c300738d07 | ERC721 |
| EtherFantasy | 0x8F83c6122Dd4d275B53a7846B3D3dB29Cca1e698 | ERC721 |
| Collection | Contract | Type |
|---|---|---|
| BCSH ETH | 0x53b719422f427Fe158f480Bfc3Cf32201e416F89 | ERC721 |
| Moonbirds | See GET /api/v1/collections for addresses | |
| Azuki | ” | |
| Doodles | ” | |
| CLONE X | ” | |
| Cool Cats | ” | |
| Lil Pudgys | ” | |
| World of Women | ” | |
| Nakamigos | ” | |
| 0N1 Force | ” | |
| + more | Full list via API | |
Want your collection tracked by the Pentagon NFT Data API? Contact the Pentagon Games team with:
Once registered, all four sync layers begin tracking your collection automatically. You'll receive an app key for API access.
The NFT Data API is backed by a four-layer sync engine that guarantees ownership accuracy. Every layer cross-checks the others — if they disagree, on-chain truth wins.
WebSocket subscriptions to on-chain Transfer events. Ownership updates within ~2 seconds. Currently active for Pentagon Chain contracts.
Periodic eth_getLogs() scanning from saved block checkpoint to chain head. Runs every 2–6 minutes. Covers all 14 chains. Never misses blocks — resumes from checkpoint even after downtime.
Periodic ownerOf() spot checks comparing database state to on-chain ground truth. Catches edge cases like contract upgrades or admin transfers.
Background image processor that downloads original artwork, converts to optimized 512px WebP, and uploads to the CDN. Detects same-image collections for efficient batch processing. Serves from CloudFront edge for sub-100ms global delivery.
When layers disagree on ownership:
ownerOf() on-chainimport requests
API_BASE = "https://nft-data.pentagon.games"
APP_KEY = "pk_live_your_key"
HEADERS = {"X-PG-App-Key": APP_KEY}
def get_nfts(wallet, chain_id=None):
"""Fetch all NFTs owned by a wallet."""
url = f"{API_BASE}/api/v1/nft/owner/{wallet}"
params = {}
if chain_id:
params["chain_id"] = chain_id
resp = requests.get(url, headers=HEADERS, params=params)
resp.raise_for_status()
return resp.json()
def get_nft_detail(contract, token_id):
"""Fetch metadata for a specific NFT."""
url = f"{API_BASE}/api/v1/nft/{contract}/{token_id}"
resp = requests.get(url, headers=HEADERS)
resp.raise_for_status()
return resp.json()
# Example: List all Pentagon chain NFTs for a wallet
wallet = "0x37224cFD71347Da6f097c94B8649f9C552f803c9"
data = get_nfts(wallet, chain_id=3344)
print(f"Found {data['total']} NFTs on Pentagon Chain")
for nft in data["results"]:
print(f" {nft['collection']} #{nft['token_id']} - {nft.get('name', 'N/A')}")
const API_BASE = "https://nft-data.pentagon.games";
const APP_KEY = "pk_live_your_key";
async function getNFTs(wallet, chainId) {
const params = new URLSearchParams();
if (chainId) params.set("chain_id", chainId);
const res = await fetch(
`${API_BASE}/api/v1/nft/owner/${wallet}?${params}`,
{ headers: { "X-PG-App-Key": APP_KEY } }
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
async function getNFTDetail(contract, tokenId) {
const res = await fetch(
`${API_BASE}/api/v1/nft/${contract}/${tokenId}`,
{ headers: { "X-PG-App-Key": APP_KEY } }
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
// Example: Check if a wallet holds any Gunnies PFPs
const GUNNIES = "0x7a8a3236e3783E7cC33b97729378e31Cf14d3Ebc";
const wallet = "0x37224cFD71347Da6f097c94B8649f9C552f803c9";
const data = await getNFTs(wallet, 3344);
const gunnies = data.results.filter(n => n.contract.toLowerCase() === GUNNIES.toLowerCase());
console.log(`Wallet holds ${gunnies.length} Gunnies PFP(s)`);
// Discord.js bot — NFT verification command
const { SlashCommandBuilder } = require("discord.js");
const API_BASE = "https://nft-data.pentagon.games";
const APP_KEY = "pk_live_your_key";
module.exports = {
data: new SlashCommandBuilder()
.setName("verify-nft")
.setDescription("Check NFT ownership for a wallet")
.addStringOption(opt =>
opt.setName("wallet").setDescription("Wallet address").setRequired(true)
)
.addStringOption(opt =>
opt.setName("collection").setDescription("Collection name filter")
),
async execute(interaction) {
await interaction.deferReply();
const wallet = interaction.options.getString("wallet");
const collection = interaction.options.getString("collection");
const params = new URLSearchParams();
if (collection) params.set("collection", collection);
const res = await fetch(
`${API_BASE}/api/v1/nft/owner/${wallet}?${params}`,
{ headers: { "X-PG-App-Key": APP_KEY } }
);
const data = await res.json();
if (data.total === 0) {
return interaction.editReply("No NFTs found for this wallet.");
}
const lines = data.results.slice(0, 10).map(
nft => `**${nft.collection}** #${nft.token_id}`
);
await interaction.editReply(
`Found **${data.total}** NFT(s):\n${lines.join("\n")}`
);
}
};
# Health check
curl "https://nft-data.pentagon.games/"
# Look up NFTs by owner (public, no key)
curl "https://nft-data.pentagon.games/api/v1/nft/owner/0x37224cFD71347Da6f097c94B8649f9C552f803c9"
# Look up NFTs with app key + chain filter
curl -H "X-PG-App-Key: pk_live_your_key" \
"https://nft-data.pentagon.games/api/v1/nft/owner/0x37224cFD71347Da6f097c94B8649f9C552f803c9?chain_id=3344"
# Single NFT detail
curl -H "X-PG-App-Key: pk_live_your_key" \
"https://nft-data.pentagon.games/api/v1/nft/0xcDAD57bFc48E8373280C6dc3039C5169353B6879/4201"
# List all tracked collections
curl -H "X-PG-App-Key: pk_live_your_key" \
"https://nft-data.pentagon.games/api/v1/collections"
# Collection stats
curl -H "X-PG-App-Key: pk_live_your_key" \
"https://nft-data.pentagon.games/api/v1/collection/0xcDAD57bFc48E8373280C6dc3039C5169353B6879/stats"
# Transfer history
curl -H "X-PG-App-Key: pk_live_your_key" \
"https://nft-data.pentagon.games/api/v1/nft/0xcDAD57bFc48E8373280C6dc3039C5169353B6879/4201/transfers"
The API uses a four-tier image fallback to ensure NFTs always display the best available artwork:
Pre-rendered 512×512 WebP images served from images.pfpvault.com via CloudFront. Optimized to ~30–50 KB per token (vs multi-MB originals). Includes correct artwork for upgraded/variant NFTs (Dark Setsuko, Obelith, etc). 150K+ NFTs covered and growing.
For collections that maintain their own thumbnail servers (e.g. Google Cloud Storage buckets), the cacher pulls from these fast, reliable sources instead of slow on-chain storage. Falls back here when CDN cache misses.
Example: Killer GF on-chain metadata points to Arweave (5.9 MB PNGs, frequent timeouts). The project hosts optimized thumbnails at storage.googleapis.com/kgf-thumbnails/{id}.jpg — the cacher uses these instead for reliable, fast ingestion.
Image URL from the token's on-chain metadata (tokenURI → JSON → image). May return a generic base image for collections with upgradeable metadata. Sources include IPFS, Arweave, and project APIs.
Fallback to the original image captured at first sync. Used when all other tiers are unavailable.
https://images.pfpvault.com/{chain_id}/{contract_address}/{token_id}.webpimmutable), served via CloudFront edgeThe image cacher detects when a collection shares a single source image across all tokens (common with PFP passes and hero collections). In these cases, the image is downloaded and converted once, then server-side copied to all token slots — caching thousands of tokens in seconds rather than hours.
For collections with variant artwork (e.g. BCSH OASYS with Setsuko, Dark Setsuko, and Obelith Setsuko), the cacher groups tokens by source image and handles each variant independently.
When the cacher downloads a source image, it stores the pre-conversion original in a separate S3 path before resizing to the CDN WebP. This gives us a full-fidelity copy that doesn't depend on external sources staying online.
s3://pg-nft-images/{chain}/{contract}/{token_id}.webp — optimized, served via CloudFronts3://pg-nft-images/originals/{chain}/{contract}/{token_id}.{ext} — full-size, not on CDN, archival onlyThe system will track dated snapshots of NFT images over time. When the cacher detects that a token's source image has changed (e.g. an NFT upgrade, evolution, or metadata update), it preserves the previous version with a timestamp.
s3://pg-nft-images/history/{chain}/{contract}/{token_id}/{timestamp}.webpGET /api/v1/nft/:contract/:tokenId/images — returns all dated captures with timestampsA queued metadata refresh system is planned for handling upgradeable NFT collections:
POST /api/v1/nft/refresh/:contract/:tokenId — rate-limited to 1 refresh per token per user per 10 minutes, queued via RabbitMQtransferable (SBT vs tradable), license (machine-readable rights tier per the ERC-721L standard), and storage (where the image and metadata actually live and how permanent they are).
For every collection we support, the API returns a metadata override — a normalized metadata object that we build and serve, regardless of what the underlying contract exposes. This mirrors the four-tier image system: the contract's tokenURI is one possible input, but it is not the source of truth our API hands back.
This matters because many collections (Gunnies PFP included) never wired working on-chain metadata — their tokenURI is empty or returns a generic base record. The override layer fills that gap with consistent, indexer-friendly fields across all 49 collections.
| Layer | Source | When used |
|---|---|---|
| 1. Override (primary) | Our resolver + pg_nft_db | Always returned in metadata. Normalized, fast, cached. |
| 2. Project / on-chain | Contract tokenURI or project API | Surfaced under source.tokenUri for live verification when the contract exposes one. |
tokenURI: it lets us serve correct, current metadata for upgradeable NFTs, collections with broken or missing on-chain metadata, and game NFTs whose state changes off-chain — all through one consistent API shape.
The top-level transferable boolean tells you whether a token is freely tradable (true) or soulbound/locked (false). It is promoted out of the trait list for easy filtering, and also kept inside metadata.attributes as a Transferable trait for backward compatibility.
"transferable": false // soulbound / locked
"transferable": true // freely tradable
For collections using a tiered-transfer model (e.g. Gunnies PFP: locked by default, unlockable per token), this flag reflects each token's current state.
The license object exposes machine-readable usage rights following the ERC-721L licensing-aware NFT standard. It lets wallets, marketplaces, games, and AI agents reliably understand what a holder is permitted to do with the content — commercial use, derivatives, exclusivity, and so on.
"license": {
"schema": "erc721l-metadata-v1",
"rights_model": "base-plus-upgrade",
"current_tier": 4,
"current_name": "COMMERCIAL_DERIVS_NONEXCLUSIVE",
"base_tier": 2,
"upgraded_tier": 4,
"terms_uri": "https://nft-data.pentagon.games/licenses/tier-4",
"source": "override"
}
| Field | Meaning |
|---|---|
current_tier / current_name | The effective rights tier for the token's current state (uint8 + canonical name). |
rights_model | single-tier, base-plus-upgrade, dynamic-tier, or metadata-only. |
base_tier / upgraded_tier | For tiered-transfer collections: rights in the locked vs unlocked state. Rights never decrease on upgrade (upgraded_tier >= base_tier). |
terms_uri | Canonical human-readable license terms for the tier. |
source | onchain if the contract implements ERC-721L (EIP-165 detected, on-chain getters win); override if served from our resolver mirror. |
| Tier | Name | Rights |
|---|---|---|
| 0 | UNSET / UNKNOWN | No rights declared — treat as no permission. |
| 1 | PERSONAL_USE | Personal display / identity use only. No commercial, no derivatives. |
| 2 | COMMERCIAL_NO_DERIVS | Commercial use of the unmodified artwork. No derivatives/remixes. |
| 3 | NONCOMMERCIAL_DERIVS | Derivatives allowed for non-commercial purposes only. |
| 4 | COMMERCIAL_DERIVS_NONEXCLUSIVE | Commercial derivatives allowed; non-exclusive grant. |
| 5 | COMMERCIAL_DERIVS_EXCLUSIVE | Exclusive commercial derivatives rights. |
| 6 | COMMERCIAL_DERIVS_SHAREALIKE | Commercial derivatives; downstream works must stay share-alike. |
| 7 | CC0 | Public domain / unrestricted use. |
Treat the license tier as a permission boundary. Before generating, publishing, selling, or remixing content derived from an NFT, read license.current_tier and verify the action is allowed: display is fine for tiers 1–7, commercial use of unmodified art for tiers 2/4/5/6/7, derivatives for tiers 3–7 (commercial only for 4–7). If an action isn't permitted, refuse and surface the minimum tier required.
Every collection also carries research-verified license provenance — the license's
official name and the canonical URL published by the project itself (never a blog or aggregator).
This is served on /api/v1/collections items and on the single-token endpoint, right alongside
contractAddress:
"license": {
"type": "commercial", // cc0 | commercial | limited_commercial | personal
// | proprietary | copyleft | custom | unknown
"name": "Azuki IP License", // human-readable license name
"url": "https://www.azuki.com/license", // official license/terms page (project's own domain)
"notes": "Confirm Elementals/Beanz enumerated" // one-line nuance, may be null
},
"officialUrl": "https://www.azuki.com" // project main site
| Value | Meaning |
|---|---|
license: null | Rights are unverified — clients must NOT assume any usage rights. |
type: unknown | A license is known to exist but its terms/URL could not be verified (e.g. project wound down). |
copyleft | Derivatives allowed but must carry the same license (e.g. Milady's Viral Public License). |
limited_commercial | Commercial use with caps/conditions (e.g. Doodles' revenue-capped holder license). |
notes field carries these
corrections — trust license.url over community folklore.
The storage field tells integrators not just what the image and metadata are, but where they live and how permanent they are — so you can reason about reliability and durability.
"storage": {
"image": {
"tier": "cached", // cached | project | onchain | original
"provider": "s3+cloudfront", // s3+cloudfront | gcs | arweave | ipfs | external
"permanence": "managed", // managed | arweave | ipfs | external
"url": "https://images.pfpvault.com/3344/0xcdad.../5555000000553.webp",
"archivalOriginal": "s3://pg-nft-images/originals/3344/0xcdad.../5555000000553.png"
},
"metadata": {
"tier": "override", // override | onchain-tokenuri | project-api
"provider": "pg_nft_db",
"tokenUri": "https://api.bcsh.xyz/metadata/5555000000553"
}
}
| Field | Meaning |
|---|---|
image.tier | Which of the four image fallback tiers served this token (see Image Resolution). |
image.provider | Underlying storage backend the image was served from. |
image.permanence | managed (our S3/CDN, we control uptime), arweave/ipfs (decentralized permanent), or external (a third-party source that could change or disappear). |
image.archivalOriginal | Our full-fidelity pre-conversion copy in S3 — insurance against external sources going offline. |
metadata.tier | Where the served metadata came from: our override, the contract tokenUri, or a project-api. |
managed and arweave are safe to hotlink; external should be re-fetched or cached locally because the source may vanish. For any token where the external source dies, our archivalOriginal keeps a recoverable copy.
Transfer-driven sync is blind to those changes by design, so mutable collections need an explicit refresh trigger. This section defines the two mutation types and how each is kept current.
The core sync engine (see How It Works) keys off ownership change — it reacts to Transfer events and re-checks ownerOf(). That is the right model for ownership, but a post-mint upgrade changes a token's state, not its owner. No transfer fires, so nothing re-indexes, and the cached metadata captured at first ingest goes stale. The fix is a separate refresh path, triggered by something other than a transfer.
| Type A — On-Chain State Change | Type B — Backend-Only Change | |
|---|---|---|
| Example | Gunnies PFP upgrade (SBT → transferable NFT) | Setsuko / BCSH OASYS variant upgrade (base → Dark / Obelith) |
| What changes | An on-chain flag flips (e.g. isUpgraded(tokenId) becomes true). Owner unchanged. | The project's own metadata API starts returning a new image / traits. Nothing changes on-chain. |
| Where truth lives | On-chain, via a project-provided read function (selector). | The project's metadata endpoint only. |
| How we verify | We call the read function ourselves and trust the chain. A callback is only a hint to re-check. | No on-chain verification possible. We re-pull the project endpoint and overwrite our cache. |
| Trigger options | Callback (verified) or an approved tx-scanning cron backstop. | Callback only — there is nothing on-chain to watch or scan. |
After a qualifying action completes (an upgrade tx confirms, or a backend upgrade finalizes), the project's frontend or backend notifies us with just the contract and token id. We enqueue the refresh (RabbitMQ) and run the appropriate recipe for that collection. This is a push model — no polling, and no guessing which token changed, because the project tells us.
# Type A — on-chain upgrade (we re-verify on-chain before writing)
POST /api/v1/nft/notify_upgrade
Authorization: project app key (scoped to the collection)
{ "contract": "0x7a8a3236...3Ebc", "tokenId": "550", "chainId": 3344 }
# Type B — backend-only upgrade (we re-pull the project metadata endpoint)
POST /api/v1/nft/notify_refresh
Authorization: project app key (scoped to the collection)
{ "contract": "0x10d2d273...9a01", "tokenId": "232", "chainId": 3344 }
| Rule | Type A | Type B |
|---|---|---|
| Authentication | Required (project app key) | Required (project app key) |
| Scope | Key may only refresh its own collection | Key may only refresh its own collection |
| Trust model | Callback is a hint; on-chain read is authoritative | Trust-based; project endpoint is the only source |
| Rate limit | Queued, deduped per (token, 10 min) | Queued, deduped per (token, 10 min) |
For Type A collections, an upgrade is a real on-chain write even when it emits no event (the call simply flips a storage flag). A project-specific cron scans the upgrade contract's transactions (not just Transfer logs), decodes the affected token id from calldata, and re-runs the on-chain read. This guarantees we catch upgrades even if a callback is never sent. Because it requires project-specific decoding logic and a read selector, each Type A collection submits its recipe for review and, once approved, gets a registered cron under a project-specific directory. Type B has no cron — there is nothing on-chain to scan.
A best-effort, rate-limited self-serve path (POST /api/v1/nft/refresh/:contract/:tokenId, 1 per token per user per 10 min) lets a holder force a re-check from a project UI. It runs the same recipe as the callback. Useful as a fallback, but the project callback is preferred because it fires automatically at the exact moment state changes.
Each mutable collection is described by a small recipe that the refresh worker reads to know how to re-derive state:
{
"collection": "Gunnies PFP",
"mutable": true,
"type": "A", // A = on-chain read, B = project-api pull
"refresh_source": "web3", // web3 | project_api
"read_fn": "isUpgraded(uint256)", // Type A: on-chain read function
"selector": "0x82f11a1a", // Type A: function selector
"maps_to_trait": "Transferable", // how the read result is stored
"api_url_template": null // Type B: ".../{collection}/{id}"
}
notify_* endpoint right after your upgrade completes so our copy — and everything downstream (marketplaces, mining, games) — reflects the new state immediately.
Join the Pentagon Games server for developer support and announcements.
For app key requests, collection listings, and partnership inquiries.