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

Base URL
https://exclusivo.one/v1

Every 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.

BucketLimitEndpoints
List120 req/min/v1/collections, /v1/collections/{id}/listings, /v1/collections/{id}/sales
Item300 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:

HeaderValue
X-RateLimit-LimitThe cap for the bucket this request counted against.
X-RateLimit-RemainingRequests left in the current window.
X-RateLimit-ResetISO 8601 timestamp when the window resets — not a Unix epoch integer.
Retry-AfterSeconds to wait. Present on 429 responses only.

These headers appear on 429 responses only

Successful responses carry no 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.

Single item
{
  "data": { … }
}
List
{
  "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

Parsing 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
{
  "error": {
    "code": "bad_request",
    "message": "Invalid cursor."
  }
}
codeHTTPWhen you see it
bad_request400A 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_found404The 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_limited429You exceeded the bucket limit. The X-RateLimit-* headers and Retry-After are preserved on this response.
upstream_error502A 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

Error bodies are sent with 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.

Paging
# 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 /sales is rejected with bad_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=null or an empty cursor=. 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. nextCursor is null on the last page, so looping while nextCursor is truthy also works, but hasMore is the field that means what it says.

Cursors are not snapshots

A cursor records a position, not a frozen result set. New listings and sales land between your pages, so an item can be skipped or repeated across a long walk. Deduplicate on arrival — by 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.

Endpoints-maxagestale-while-revalidate
/v1/collections60s120s
/v1/collections/{id}30s60s
/v1/collections/{id}/floor10s20s
/v1/collections/{id}/listings15s30s
/v1/collections/{id}/sales30s60s
/v1/tokens/{chain}/{address}/{tokenId}30s60s
/v1 and /v1/openapi.json3600s7200s

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:

CORS headers
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, OPTIONS
Access-Control-Allow-Headers: Content-Type, Accept
Access-Control-Max-Age: 86400

Credentials 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.

Price
{
  "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
}
FieldTypeNotes
rawstringInteger 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.
decimalnumberIEEE-754 double. Convenient for display and sorting; it cannot represent every wei value exactly, so never settle against it.
currencystringSOL, 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.
usdnumber | nullAdvisory 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

If your ingest treats a missing 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.

FamilyCasing in responsesWhat 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

Base58 is case-significant: 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

The document id is a database key, and it is built by lowercasing the address — including on Solana, where lowercasing is otherwise forbidden. So the suffix of 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.

ChargeRateApplies to
Platform fee (Solana)3%Exclusivo-native Solana sales.
Platform fee (EVM)2%Exclusivo-native Seaport sales on every EVM chain.
Creator royaltyPer collectionSet by the collection, reported as royaltyBps on the collection object.
Aggregator fee2%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

Most marketplaces publish an ask floor. Comparing our 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.

FlagWhereMeans
meta.stale/collections/{id}/listingsAt 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}/floorThe 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

Two gaps are worth knowing. The listings endpoint mirrors Solana orders on roughly a two-minute cycle, and a lagging-but-reachable mirror is not flagged at all. And the floor endpoint only has a last-good store for Solana and Ethereum — on Base, Ink, Polygon, ApeChain and Abstract a failed resolve returns 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.