{"openapi":"3.1.0","info":{"title":"EXC Marketplace API","version":"1.0.0","contact":{"url":"https://exclusivo.one/developers"},"description":"Public, read-only REST API for EXC marketplace data across Solana, Ethereum, Polygon, Base, Ink, ApeChain, Abstract and Robinhood Chain.\n\nThis API is the authoritative feed for EVM listings and cancels (they settle off-chain via signed Seaport orders), for ApeChain activity, and for history predating the structured on-chain event conventions — see the normalized event schema doc.\n\nConventions:\n- EVM addresses are always lowercase in responses; Solana base58 addresses are never case-folded.\n- All amounts include a `raw` decimal string of integer base units (wei / lamports). `usd` values are best-effort and may be null.\n- Success responses are wrapped as `{ \"data\": ... }`, with `{ \"pagination\": { \"nextCursor\", \"hasMore\" } }` on list endpoints.\n- Errors are `{ \"error\": { \"code\", \"message\" } }` with codes bad_request, not_found, rate_limited, upstream_error. /v1/collections, /v1/collections/{id}/sales and /v1/collections/{id}/listings can emit upstream_error (502) — the listings endpoint only when EVERY source failed, since an empty 200 would read as \"nothing is listed\". Every other endpoint degrades to a 200 with null fields or a stale flag instead of failing.\n\nRate limits (per IP, per minute): 120 requests on list endpoints, 300 on single-item endpoints, counted in independent buckets. X-RateLimit-Limit / X-RateLimit-Remaining / X-RateLimit-Reset are returned on 429 responses ONLY — successful responses carry no rate-limit headers, so budget client-side rather than reading remaining quota off a 200. X-RateLimit-Reset is an ISO-8601 timestamp (e.g. 2026-07-26T10:31:02.114Z), not epoch seconds. 429 responses also include Retry-After, in seconds.\n\nFreshness: responses are CDN-cached briefly (Cache-Control headers per endpoint). Solana EXC-native listings are served from an indexer mirror that syncs roughly every 2 minutes, and that routine lag is never flagged — stale flags mean a source failed, not that a source is behind. Where a live source fails and last-good data is served, a `stale` flag is set; read each endpoint's field description, because the flags differ in scope and none of them are a general freshness signal."},"servers":[{"url":"https://exclusivo.one"}],"paths":{"/v1":{"get":{"operationId":"getApiIndex","summary":"API index","description":"Name, documentation link and endpoint list.","responses":{"200":{"description":"API index.","content":{"application/json":{"schema":{"type":"object"}}}},"429":{"description":"Rate limit exceeded (see X-RateLimit-* headers).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/openapi.json":{"get":{"operationId":"getOpenApiSpec","summary":"This OpenAPI 3.1 document","responses":{"200":{"description":"OpenAPI 3.1 document.","content":{"application/json":{"schema":{"type":"object"}}}},"429":{"description":"Rate limit exceeded (see X-RateLimit-* headers).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/collections":{"get":{"operationId":"listCollections","summary":"List collections","description":"Cursor-paginated collection browse with summary stats. EXC-launched collections are ranked first.","parameters":[{"name":"chain","in":"query","required":false,"description":"Filter to one chain.","schema":{"type":"string","enum":["solana","ethereum","polygon","base","ink","apechain","abstract","robinhood"]}},{"name":"cursor","in":"query","required":false,"description":"Opaque pagination cursor from a previous response.","schema":{"type":"string"}},{"name":"limit","in":"query","required":false,"description":"Page size (default 25, max 100).","schema":{"type":"integer","minimum":1,"maximum":100,"default":25}},{"name":"sort","in":"query","required":false,"description":"Sort order (default volume).","schema":{"type":"string","enum":["volume","floor","recent"],"default":"volume"}}],"responses":{"200":{"description":"One page of collections.","content":{"application/json":{"schema":{"type":"object","required":["data","pagination"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/CollectionSummary"}},"pagination":{"$ref":"#/components/schemas/Pagination"}}}}}},"400":{"description":"Invalid query or path parameters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded (see X-RateLimit-* headers).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"Upstream data source unavailable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/collections/{id}":{"get":{"operationId":"getCollection","summary":"Get a collection with stats","description":"Identity comes from the stored collection doc; stats are merged from two indexer sources, each independently timed out. A failing source degrades the response (null fields, and `stale: true` only when both fail) — this endpoint never returns 502.","parameters":[{"name":"id","in":"path","required":true,"description":"Collection identifier — accepts the canonical platform slug, a raw contract address (EVM) or collection mint (Solana), or a `<chain>_<contract>` doc id. The `collectionId` returned in responses is such a doc id and is always accepted here; note it is lowercased on every chain, so round-tripping it works but slicing an address out of it does not.","schema":{"type":"string"}}],"responses":{"200":{"description":"Collection detail: floor (native currency of its chain), volume, supply, owners, listedCount.","content":{"application/json":{"schema":{"type":"object","required":["data"],"properties":{"data":{"$ref":"#/components/schemas/CollectionDetail"}}}}}},"404":{"description":"Collection or token not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded (see X-RateLimit-* headers).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/collections/{id}/floor":{"get":{"operationId":"getCollectionFloor","summary":"Real-time floor price","description":"Returns BOTH floors explicitly: `askFloor` (cheapest seller ask, from the indexer) and `buyerFloor` (buyer all-in cost of the cheapest fill across EXC + external marketplaces). `stale: true` marks a last-good `buyerFloor`, and only on solana / ethereum — see the field description. Every upstream here is timed out and falls back rather than failing, so this endpoint never returns 502; a total failure surfaces as null floors with 200. Returns 404 when the id does not resolve to a collection, or when it resolves to one with no usable chain.","parameters":[{"name":"id","in":"path","required":true,"description":"Collection identifier — accepts the canonical platform slug, a raw contract address (EVM) or collection mint (Solana), or a `<chain>_<contract>` doc id. The `collectionId` returned in responses is such a doc id and is always accepted here; note it is lowercased on every chain, so round-tripping it works but slicing an address out of it does not.","schema":{"type":"string"}}],"responses":{"200":{"description":"Floor data.","content":{"application/json":{"schema":{"type":"object","required":["data"],"properties":{"data":{"$ref":"#/components/schemas/FloorResponse"}}}}}},"404":{"description":"Collection or token not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded (see X-RateLimit-* headers).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/collections/{id}/listings":{"get":{"operationId":"listCollectionListings","summary":"Active listings","description":"Active listings merged from the EXC order book (Firestore — Seaport rows on EVM, escrow rows on Solana) and the indexer mirror (external marketplaces, plus a lagged copy of our own rows). Deduped per asset — the EXC order-book row wins a collision — and sorted cheapest first. The paginated window is a bounded snapshot (up to ~250 cheapest listings); the cursor is an offset into it, and an offset beyond that ceiling is rejected with 400. Sources are timed out independently and degrade. `meta.staleSources` names each one that failed: `indexer-mirror` means external-marketplace inventory is missing; `order-book` means EXC-native listings are missing. A source that succeeded and returned nothing is not stale — that is a genuinely empty book. When EVERY source fails the response is 502, NOT an empty 200. An empty 200 is indistinguishable from \"nothing is listed\", and those are very different facts — treat a 502 here as \"unknown\", never as \"no listings\".","parameters":[{"name":"id","in":"path","required":true,"description":"Collection identifier — accepts the canonical platform slug, a raw contract address (EVM) or collection mint (Solana), or a `<chain>_<contract>` doc id. The `collectionId` returned in responses is such a doc id and is always accepted here; note it is lowercased on every chain, so round-tripping it works but slicing an address out of it does not.","schema":{"type":"string"}},{"name":"cursor","in":"query","required":false,"description":"Opaque pagination cursor from a previous response.","schema":{"type":"string"}},{"name":"limit","in":"query","required":false,"description":"Page size (default 25, max 100).","schema":{"type":"integer","minimum":1,"maximum":100,"default":25}}],"responses":{"200":{"description":"One page of active listings. `meta` is present only when a source degraded.","content":{"application/json":{"schema":{"type":"object","required":["data","pagination"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/MarketItem"}},"pagination":{"$ref":"#/components/schemas/Pagination"},"meta":{"type":"object","description":"Present ONLY when a source degraded; absent entirely on a healthy response (do not expect `meta.stale: false`).","required":["stale"],"properties":{"stale":{"type":"boolean","const":true,"description":"Always true when `meta` is present — the object exists only to report degradation, so it never carries false."},"staleSources":{"type":"array","items":{"type":"string"},"description":"Identifiers of the degraded sources, e.g. `indexer-mirror`."}}}}}}}},"400":{"description":"Invalid query or path parameters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Collection or token not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded (see X-RateLimit-* headers).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"Upstream data source unavailable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/collections/{id}/sales":{"get":{"operationId":"listCollectionSales","summary":"Historical sales","description":"Completed sales for a collection, newest first, cursor-paginated. Includes EXC-native sales and aggregated external purchases (the `marketplace` field names the source). Paging wart: the page window is cut FIRST and rows whose stored price cannot be interpreted are dropped afterwards, so `data.length` can be smaller than `limit` — even zero — while `hasMore` is true. Always page on `hasMore` / `nextCursor`; never treat a short page as the end of the feed. Returns 404 both when the id does not resolve and when it resolves to a collection with no `contractAddress` stored, since sales are keyed by that address.","parameters":[{"name":"id","in":"path","required":true,"description":"Collection identifier — accepts the canonical platform slug, a raw contract address (EVM) or collection mint (Solana), or a `<chain>_<contract>` doc id. The `collectionId` returned in responses is such a doc id and is always accepted here; note it is lowercased on every chain, so round-tripping it works but slicing an address out of it does not.","schema":{"type":"string"}},{"name":"cursor","in":"query","required":false,"description":"Opaque pagination cursor from a previous response.","schema":{"type":"string"}},{"name":"limit","in":"query","required":false,"description":"Page size (default 25, max 100).","schema":{"type":"integer","minimum":1,"maximum":100,"default":25}}],"responses":{"200":{"description":"One page of sales.","content":{"application/json":{"schema":{"type":"object","required":["data","pagination"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/MarketItem"}},"pagination":{"$ref":"#/components/schemas/Pagination"}}}}}},"400":{"description":"Invalid query or path parameters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Collection or token not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded (see X-RateLimit-* headers).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"Upstream data source unavailable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/tokens/{chain}/{address}/{tokenId}":{"get":{"operationId":"getToken","summary":"Get a single token","description":"Token identity, beneficial owner (the seller while listed — never the shared custody wallet), rarity rank, active listing and last sale. For Solana, `{address}` is the collection mint and `{tokenId}` is the asset mint. `lastSale` carries a price and nothing else. A token the indexer does not know returns 404; the Firestore listing lookup is timed out and degrades to the indexer overlay, so this endpoint never returns 502.","parameters":[{"name":"chain","in":"path","required":true,"schema":{"type":"string","enum":["solana","ethereum","polygon","base","ink","apechain","abstract","robinhood"]}},{"name":"address","in":"path","required":true,"description":"EVM contract address, or the Solana collection mint.","schema":{"type":"string"}},{"name":"tokenId","in":"path","required":true,"description":"EVM token id, or the Solana asset mint.","schema":{"type":"string"}}],"responses":{"200":{"description":"Token detail.","content":{"application/json":{"schema":{"type":"object","required":["data"],"properties":{"data":{"$ref":"#/components/schemas/TokenDetail"}}}}}},"400":{"description":"Invalid query or path parameters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Collection or token not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded (see X-RateLimit-* headers).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}}},"components":{"schemas":{"Price":{"type":"object","description":"Cross-chain price. `raw` is a decimal string of integer base units (wei / lamports); `usd` is best-effort and null when no price feed is available.","required":["raw","decimal","currency","usd"],"properties":{"raw":{"type":"string","description":"Integer base units (wei / lamports) as a decimal string.","examples":["1250000000"]},"decimal":{"type":"number","description":"Human-readable native amount.","examples":[1.25]},"currency":{"type":"string","description":"Native currency symbol.","examples":["SOL","ETH"]},"usd":{"type":["number","null"],"description":"Best-effort USD conversion; null on price-feed failure."}}},"MarketItem":{"type":"object","description":"The cross-chain marketplace item shape shared by listings, sales and token listing state. EVM addresses are lowercase; Solana base58 addresses are case-preserved.","required":["chain","contractAddress","tokenId","mint","collectionId","price","seller","buyer","marketplace","status","expiresAt","timestamp","orderId","tx"],"properties":{"chain":{"type":"string","enum":["solana","ethereum","polygon","base","ink","apechain","abstract","robinhood"]},"contractAddress":{"type":["string","null"],"description":"EVM contract address (lowercase); null for Solana items."},"tokenId":{"type":["string","null"],"description":"EVM token id as a decimal string; null for Solana items."},"mint":{"type":["string","null"],"description":"Solana asset mint / Core asset / cNFT asset id (base58); null for EVM items."},"collectionId":{"type":"string","description":"Canonical collection id — the stored collection document id, normally `<chain>_<contractAddress>`. TRAP: the address half is lowercased for EVERY chain, including Solana. Solana base58 is case-sensitive, so the suffix of a Solana `collectionId` is NOT a usable mint address — slicing it produces an invalid address. Treat this as an opaque key: pass it back to /v1/collections/{id}, compare it for equality, and read the real address from that endpoint's `contractAddress` (case-preserved), never from the id."},"price":{"$ref":"#/components/schemas/Price"},"seller":{"type":["string","null"]},"buyer":{"type":["string","null"]},"marketplace":{"type":"string","description":"'EXC' for EXC-native rows; external rows keep their source slug (e.g. 'opensea', 'magic-eden').","examples":["EXC","opensea","magic-eden"]},"status":{"type":"string","enum":["active","filled","cancelled","expired","sold"]},"expiresAt":{"type":["string","null"],"format":"date-time"},"timestamp":{"type":"string","format":"date-time","description":"On a sale row this is the on-chain BLOCK time of the transaction, so it can be compared directly against the chain. Expect a small positive drift on rows written before 2026-08-23: they were dated at write time instead, which is a few seconds out on the normal path and further out on deferred writes. On a listing row this is when the listing was confirmed (escrow custody landed) or, failing that, when the row was created."},"orderId":{"type":["string","null"],"description":"Opaque, stable join key for this row. It is NOT one well-defined identifier and its format varies by source: EXC-native EVM listings carry the order-book document id (`<chain>_<contract>_<tokenId>_<epochMillis>`); Solana and external rows arrive through the indexer mirror and carry that mirror's id (EXC-Solana rows are prefixed `exclusivo_`); sale rows carry the id of the listing the sale came from, which may be null for sales with no recorded listing. A genuine Seaport order hash appears only on the token endpoint when the active listing came from the indexer listing overlay rather than the EXC order book. Compare it for equality; never parse it, slice it, or infer chain/contract/token from it."},"tx":{"type":["string","null"],"description":"Tx hash (EVM) or signature (Solana); null for off-chain-only rows."}}},"CollectionSummary":{"type":"object","required":["id","chain","contractAddress","name","slug","stats"],"properties":{"id":{"type":"string","description":"Canonical collection id — same value and same caveats as `MarketItem.collectionId`: the address half is lowercased on every chain, so a Solana id does not contain a usable mint. Use `contractAddress` below for the real address."},"chain":{"type":["string","null"],"enum":["solana","ethereum","polygon","base","ink","apechain","abstract","robinhood",null]},"contractAddress":{"type":["string","null"],"description":"EVM contract address (lowercase) or Solana collection mint (base58, case preserved). This is the address to use on-chain — never derive one from `id`."},"name":{"type":"string"},"slug":{"type":["string","null"],"description":"Platform slug usable at /collection/{slug} and as a /v1 collection {id}."},"image":{"type":["string","null"]},"banner":{"type":["string","null"]},"description":{"type":["string","null"]},"tokenStandard":{"type":["string","null"]},"royaltyBps":{"type":["number","null"]},"stats":{"type":"object","properties":{"floor":{"oneOf":[{"$ref":"#/components/schemas/Price"},{"type":"null"}],"description":"Seller-ask floor."},"volumeAllTime":{"type":["number","null"],"description":"All-time traded volume in native units (best-effort)."},"supply":{"type":["number","null"]},"owners":{"type":["number","null"]}}}}},"CollectionDetail":{"allOf":[{"$ref":"#/components/schemas/CollectionSummary"},{"type":"object","properties":{"stats":{"type":"object","properties":{"floor":{"oneOf":[{"$ref":"#/components/schemas/Price"},{"type":"null"}]},"volume":{"type":"object","description":"Traded volume in native units.","properties":{"allTime":{"type":["number","null"]},"sevenDay":{"type":["number","null"]},"twentyFourHour":{"type":["number","null"]}}},"sales":{"type":"object","properties":{"allTime":{"type":["number","null"]},"sevenDay":{"type":["number","null"]},"twentyFourHour":{"type":["number","null"]}}},"supply":{"type":["number","null"]},"owners":{"type":["number","null"]},"listedCount":{"type":["number","null"]}}},"stale":{"type":"boolean","description":"True only when BOTH indexer sources failed (collection stats AND analytics). Identity is still served from the stored collection doc, and the window stats are absent or last-good. It is deliberately narrow: if only one of the two failed, this stays false while the fields that source feeds go silently null — analytics failing nulls the volume/sales windows, stats failing nulls owners/listedCount. A null stat therefore never means \"zero\"; it means \"not known right now\", regardless of this flag."}}}]},"FloorResponse":{"type":"object","description":"\"Floor\" is ambiguous, so both figures are returned explicitly: askFloor is the cheapest seller ask; buyerFloor is the buyer all-in cost of the cheapest fill (ask + platform fee + royalty, or the external-marketplace buyer total).","required":["collectionId","askFloor","buyerFloor","aggregated","updatedAt","stale"],"properties":{"collectionId":{"type":"string"},"askFloor":{"oneOf":[{"$ref":"#/components/schemas/Price"},{"type":"null"}]},"buyerFloor":{"oneOf":[{"$ref":"#/components/schemas/Price"},{"type":"null"}]},"aggregated":{"type":"boolean","const":true,"description":"Hardcoded `true` on every response. It states the intent of the endpoint — floors are resolved across EXC-native listings plus the external marketplaces we aggregate (OpenSea / Magic Eden) — and is not a per-response signal. It does NOT go false when an external source is missing or times out, so there is nothing to branch on here."},"updatedAt":{"type":"string","format":"date-time","description":"The time this RESPONSE was generated, not the time the floor was observed. It is a fresh `now` on every request, including requests where `stale: true` returns a persisted value that may be up to ~30 minutes old. Do not use it for staleness checks or for ordering successive polls — read `stale` instead."},"stale":{"type":"boolean","description":"Applies to `buyerFloor` only, and only on two chains. It is true when the live buyer-floor resolve failed AND a last-good value was found in the persisted floor store, which is kept for `solana` and `ethereum` alone. On base, ink, polygon, apechain and abstract a failed resolve returns `buyerFloor: null` with `stale: false` — on those chains false does not mean fresh, so treat a null `buyerFloor` as the failure signal. `askFloor` is never covered by this flag: it falls back from live analytics to the indexer row to the stored snapshot silently."}}},"TokenDetail":{"type":"object","required":["chain","contractAddress","tokenId","mint","collectionId","name","owner","listing","lastSale"],"properties":{"chain":{"type":"string","enum":["solana","ethereum","polygon","base","ink","apechain","abstract","robinhood"]},"contractAddress":{"type":["string","null"]},"tokenId":{"type":["string","null"]},"mint":{"type":["string","null"]},"collectionId":{"type":"string","description":"Canonical collection id — same lowercasing caveat as `MarketItem.collectionId`; on Solana its suffix is not a usable mint."},"name":{"type":["string","null"]},"image":{"type":["string","null"]},"rarity":{"type":"object","properties":{"rank":{"type":["number","null"],"description":"Rank 1 = rarest."}}},"owner":{"type":"object","description":"Beneficial owner. For a listed token this is the SELLER, never the shared custody/escrow wallet that holds the asset on-chain.","properties":{"address":{"type":["string","null"]},"kind":{"type":"string","enum":["wallet","custody","unknown"]},"label":{"type":["string","null"],"description":"Custody label when kind='custody' (address is withheld)."}}},"listing":{"oneOf":[{"$ref":"#/components/schemas/MarketItem"},{"type":"null"}],"description":"Active listing, if any."},"lastSale":{"type":["object","null"],"description":"Last recorded sale, carrying `price` and nothing else — there is no sale date, buyer, seller, marketplace or transaction hash here. Use /v1/collections/{id}/sales if you need those. Null when no sale is recorded for the token, or when the recorded price could not be interpreted.","properties":{"price":{"$ref":"#/components/schemas/Price"}}}}},"Pagination":{"type":"object","required":["nextCursor","hasMore"],"properties":{"nextCursor":{"type":["string","null"],"description":"Opaque cursor for the next page; null on the last page. Pass it back verbatim as `cursor` — it is signed/encoded and a hand-built value is rejected with 400."},"hasMore":{"type":"boolean","description":"Whether another page exists. This is the ONLY termination signal: page until `hasMore` is false, never on `data.length < limit`. Rows that fail serialization are dropped after the page window is cut, so a page can be short — or empty — while `hasMore` is still true."}}},"Error":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","enum":["bad_request","not_found","rate_limited","upstream_error"]},"message":{"type":"string"}}}}}}}}