{"openapi":"3.1.0","info":{"title":"Exclusivo Marketplace API","version":"1.0.0","contact":{"url":"https://exclusivo.one/developers"},"description":"Public, read-only REST API for Exclusivo marketplace data across Solana, Ethereum, Base, Ink, Polygon, ApeChain and Abstract.\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. Only /v1/collections and /v1/collections/{id}/sales can emit upstream_error (502); 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 Exclusivo-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. Exclusivo-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"]}},{"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 Exclusivo + 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 Exclusivo order book (Firestore Seaport rows for EVM) and the indexer mirror (external marketplaces + Solana). Deduped per asset — the Exclusivo EVM 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. Exclusivo-native Solana listings are read from a mirror that syncs roughly every 2 minutes, so they can lag the live order book by about that much. That lag is NOT flagged: `meta.stale` fires only when the mirror is unreachable, never when it is merely behind. Sources are timed out and degrade (a dead mirror yields `meta.stale` and, on Solana, an empty book), so 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"}},{"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"}}}}}}},"/v1/collections/{id}/sales":{"get":{"operationId":"listCollectionSales","summary":"Historical sales","description":"Completed sales for a collection, newest first, cursor-paginated. Includes Exclusivo-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"]}},{"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"]},"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 Exclusivo-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"},"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: Exclusivo-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 (Exclusivo-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 Exclusivo 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",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 Exclusivo-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"]},"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"}}}}}}}}