API reference
Conventions
The rules that hold for every endpoint. Read this once and the six endpoint pages become mostly parameter tables.
Base URL & methods
https://exclusivo.one/v1Every endpoint is GET. Each one also answers OPTIONS for CORS preflight and nothing else — a POST, PUT or DELETE to a /v1 path is not handled by the API and will not do what you expect.
Two endpoints exist purely to help you discover the rest: GET /v1 returns the endpoint list, and GET /v1/openapi.json returns the OpenAPI 3.1 document. The spec document is served bare, without the data envelope described below, because it is the artifact your codegen tool consumes.
Authentication
There is none. The API is public and read-only: no key, no bearer token, no signup, no origin allowlist. Do not send an Authorization header — it is ignored, and building your client around one will make migration harder if we ever add scoped keys.
Because there is no key, everything is scoped to your IP address instead. That is the unit the rate limiter counts.
Rate limits
Limits are per IP, per fixed 60-second window, and split into two independent counters. A burst of listing pages cannot exhaust your budget for single-token lookups, and vice versa.
| Bucket | Limit | Endpoints |
|---|---|---|
| List | 120 req/min | /v1/collections, /v1/collections/{id}/listings, /v1/collections/{id}/sales |
| Item | 300 req/min | /v1, /v1/collections/{id}, /v1/collections/{id}/floor, /v1/tokens/{chain}/{address}/{tokenId}, /v1/openapi.json |
When you exceed a bucket the response is a 429 carrying the standard error envelope with code rate_limited, plus these headers:
| Header | Value |
|---|---|
X-RateLimit-Limit | The cap for the bucket this request counted against. |
X-RateLimit-Remaining | Requests left in the current window. |
X-RateLimit-Reset | ISO 8601 timestamp when the window resets — not a Unix epoch integer. |
Retry-After | Seconds to wait. Present on 429 responses only. |
These headers appear on 429 responses only
X-RateLimit-* headers, so you cannot watch Remaining fall and throttle before you are blocked. Budget client-side against the limits above and treat the 429 as your correction signal.Parse Reset as a date
X-RateLimit-Reset is an ISO timestamp such as 2026-07-26T14:03:00.000Z. Feeding it to a routine that expects epoch seconds produces a nonsense backoff, so read Retry-After when you are handling a 429 and use Reset only for display.If a production integration needs more headroom, email dev@psukhe.media rather than sharding across IPs.
Response envelope
Successful responses are always an object with a data key. Single-item endpoints put an object there; list endpoints put an array there and add a pagination object.
{
"data": { … }
}{
"data": [ … ],
"pagination": {
"nextCursor": "eyJrIjoibGlzdGluZ3Mtb2Zmc2V0IiwidiI6IjI1In0",
"hasMore": true
}
}A third key, meta, appears only when a data source degraded — see Staleness flags. Treat it as optional: it is absent on a healthy response, and today only the listings endpoint ever emits it, even though the OpenAPI schema permits it on any list endpoint.
Never read data as the whole body
await res.json() straight into your model will break the moment you touch a list endpoint. Destructure — const { data, pagination } = await res.json() — so new envelope keys are additive rather than breaking.Errors
Errors are a single consistent shape. There is no partial-success mode: a request either returns data or returns error.
{
"error": {
"code": "bad_request",
"message": "Invalid cursor."
}
}| code | HTTP | When you see it |
|---|---|---|
bad_request | 400 | A query parameter failed validation (limit out of range, unknown chain, unknown sort), or the cursor is malformed, expired, or was minted by a different endpoint. |
not_found | 404 | The collection identifier or token could not be resolved. Also returned when a collection resolves but lacks the field an endpoint needs — see the per-endpoint pages. |
rate_limited | 429 | You exceeded the bucket limit. The X-RateLimit-* headers and Retry-After are preserved on this response. |
upstream_error | 502 | A data source we depend on was unavailable and the endpoint has no last-good fallback for that request. |
Those four codes are the complete set. message is human-readable and may change wording at any time — branch on code, never on message.
Error responses are never cached
Cache-Control: no-store. A 502 will not be served from the CDN to the next caller, so a retry after a short backoff is genuinely a fresh attempt rather than a replay of the same cached failure.Upstream failure text is deliberately not passed through. If you need to know why a source degraded, the meta.staleSources array names it; the message will not.
Cursor pagination
Every list endpoint takes limit (default 25, minimum 1, maximum 100) and cursor. Cursors are opaque base64url strings. Internally they encode which pagination strategy minted them and a position value, but that encoding is an implementation detail and will change.
# page 1
curl "https://exclusivo.one/v1/collections?limit=50"
# page 2 — pass back exactly what you received
curl "https://exclusivo.one/v1/collections?limit=50&cursor=eyJrIjoiY29sbGVjdGlvbnMiLCJ2IjoiMjUifQ"Four rules
- Never construct a cursor. Decoding one and incrementing the value inside it happens to work on some endpoints today and will stop working without notice.
- Never replay a cursor across endpoints. Each cursor is stamped with the endpoint strategy that minted it. A listings cursor sent to
/salesis rejected withbad_request, not silently misinterpreted — which is the behaviour you want, but it does mean a shared paging helper must keep cursors separated per endpoint. - Never send
cursor=nullor an emptycursor=. Both fail validation with a 400. Omit the parameter entirely on the first page. This is the single most common integration bug, and it comes from serialising a nullable variable straight into a query string. - Stop on
hasMore: false.nextCursorisnullon the last page, so looping whilenextCursoris truthy also works, buthasMoreis the field that means what it says.
Cursors are not snapshots
orderId for listings, by tx plus orderId for sales — rather than assuming a clean traversal.Caching
Responses are cached at the CDN edge, not in your browser. Each endpoint sends Cache-Control: public, s-maxage=N, stale-while-revalidate=2N, so a stale copy can be served for a further N seconds while a fresh one is fetched in the background.
| Endpoint | s-maxage | stale-while-revalidate |
|---|---|---|
/v1/collections | 60s | 120s |
/v1/collections/{id} | 30s | 60s |
/v1/collections/{id}/floor | 10s | 20s |
/v1/collections/{id}/listings | 15s | 30s |
/v1/collections/{id}/sales | 30s | 60s |
/v1/tokens/{chain}/{address}/{tokenId} | 30s | 60s |
/v1 and /v1/openapi.json | 3600s | 7200s |
Polling faster than s-maxage spends rate limit for a response that cannot have changed. Floor at 10 seconds is the fastest-moving figure we publish; nothing here is a substitute for a websocket, and we do not offer one.
CORS
The API is callable directly from a browser. Every response — success and error alike — carries:
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, OPTIONS
Access-Control-Allow-Headers: Content-Type, Accept
Access-Control-Max-Age: 86400Credentials are not supported and not needed. Because Allow-Headers is limited to Content-Type and Accept, adding a custom header to your fetch will fail preflight — send a plain GET.
The price object
Every monetary value in the API — floors, listing prices, sale prices, last sale — is the same four-field object.
{
"raw": "1250000000", // integer base units (lamports / wei), decimal string
"decimal": 1.25, // human-readable native amount
"currency": "SOL", // native symbol of the item's chain
"usd": 243.75 // best-effort; null when no feed is available
}| Field | Type | Notes |
|---|---|---|
raw | string | Integer base units as a decimal string. 9 decimals on Solana, 18 on every EVM chain. Use this for anything financial and parse it as a big integer. |
decimal | number | IEEE-754 double. Convenient for display and sorting; it cannot represent every wei value exactly, so never settle against it. |
currency | string | SOL, ETH (Ethereum, Base, Ink), MATIC, APE. Base and Ink both report ETH — do not infer the chain from the symbol, read the item chain field. |
usd | number | null | Advisory only. Null whenever the price feed is unavailable, and permanently null for chains we have no feed for. |
usd being null is normal, not an error
usd as a failed response you will drop good data every time a feed hiccups. Compute your own USD from raw plus your own price source when USD accuracy matters to you.Address casing
Two chain families, two rules, and they are not interchangeable.
| Family | Casing in responses | What you must do |
|---|---|---|
| EVM (0x…) | Always lowercase. | Compare case-insensitively. We never return checksummed addresses, so a checksummed value from another source will not match on a naive string compare. |
| Solana (base58) | Exactly as on-chain, mixed case preserved. | Compare byte-for-byte. Never lowercase, never uppercase, never normalise. |
Lowercasing a base58 address destroys it
85Ghq5… and 85ghq5… are different keys, and only one of them exists. A pipeline that lowercases every address for consistency will silently fail to match any Solana wallet, mint, or seller — the joins produce zero rows rather than an error, so this tends to be discovered weeks later.Collection identifiers
The {id} path segment on every collection endpoint accepts three forms, all resolving to the same collection:
- the canonical platform slug, e.g.
solgods_; - a raw contract address (EVM) or collection mint (Solana);
- a
<chain>_<contract>document id, e.g.ethereum_0x8a90cab2b38dba80c64b7734e58ee1db38b8992e.
Responses return the document id in id (on collections) and collectionId (on listings, sales and tokens). Feeding that value straight back in as {id} always works, which is what makes it the right join key to store.
Do not slice an address out of collectionId
solana_85ghq5exuysxeu4aexgs7d44udegrf3bvf6gx8ysgame is not a valid mint and will not resolve anywhere on-chain. Read contractAddress when you need the real address; treat collectionId as opaque.Who pays the fees
Exclusivo charges the buyer on top of the seller ask. A listing price is exactly what the seller receives, in every response, on every endpoint.
| Charge | Rate | Applies to |
|---|---|---|
| Platform fee (Solana) | 3% | Exclusivo-native Solana sales. |
| Platform fee (EVM) | 2% | Exclusivo-native Seaport sales on every EVM chain. |
| Creator royalty | Per collection | Set by the collection, reported as royaltyBps on the collection object. |
| Aggregator fee | 2% | External marketplace purchases (Magic Eden, OpenSea) routed through the Exclusivo UI. |
The practical consequence: the price on a listing and the askFloor on a collection are seller-side numbers, and a buyer will pay more than either. The one place we publish the buyer-side figure is buyerFloor on the floor endpoint.
Comparing across marketplaces
buyerFloor to their ask floor makes us look systematically more expensive by the fee stack. Compare askFloor to ask floor, and show buyerFloor only where a user is about to spend money.Staleness flags
We would rather serve last-known-good data with a flag than fail a request, so several endpoints degrade instead of erroring. The flag is not uniform, and each variant means something different.
| Flag | Where | Means |
|---|---|---|
meta.stale | /collections/{id}/listings | At least one listing source was unreachable, so the page is a partial book. meta.staleSources names which — currently only "indexer-mirror". |
stale | /collections/{id} | Both indexer calls failed. Identity fields are still correct (they come from our own store) but the volume, sales and owner windows are absent or last-good. |
stale | /collections/{id}/floor | The live buyer-floor resolve failed and buyerFloor came from a persisted last-good value up to 30 minutes old. askFloor is unaffected by this flag. |
Absence of a flag is not proof of freshness
buyerFloor: null with stale: false. Treat a null value as unknown, not as zero and not as absent.The collections list endpoint has no degraded mode at all: if the index is unreachable it returns upstream_error rather than a partial page.