Guides

Aggregator integration

The full ingest path for an aggregator or indexer: find our collections, backfill their order books and sale history, keep them fresh without wasting requests, and join our rows to the data you already hold.

Before you start

Everything below uses the public read-only API at https://exclusivo.one/v1. There is no key and no signup. Two facts shape the whole design of an Exclusivo integration:

  • Our EVM order book has no on-chain footprint. EVM listings are off-chain signed Seaport orders stored in our database, and cancels are database-only too. Neither produces a transaction. A crawler that only reads chain data will see our sales and never see a single listing.
  • Each endpoint has its own cache TTL. Polling faster than the TTL returns the same cached body while still consuming your rate-limit budget, so the TTLs below are the natural poll intervals.
DataEndpointCache TTLRate bucket
Collection discoveryGET /v1/collections60slist
Collection statsGET /v1/collections/{id}30sitem
FloorGET /v1/collections/{id}/floor10sitem
Active listingsGET /v1/collections/{id}/listings15slist
Sale historyGET /v1/collections/{id}/sales30slist
Single tokenGET /v1/tokens/{chain}/{address}/{tokenId}30sitem

The two rate buckets are counted separately per IP: 120 requests per minute on the list bucket and 300 per minute on the item bucket. All responses are CORS-open (Access-Control-Allow-Origin: *) and every route answers OPTIONS.

1. Discover collections

Start from the collections list. It is cursor-paginated, sorted by volume by default, and ranks Exclusivo-launched collections first. Pass chain to restrict it to one chain and sort to switch between volume, floor and recent.

curl
# first page, all chains
curl "https://exclusivo.one/v1/collections?limit=100"

# one chain, newest first
curl "https://exclusivo.one/v1/collections?chain=base&sort=recent&limit=100"

Store the id of every collection you see — it is the identifier every other endpoint accepts. The same endpoints also accept a raw contract address (EVM), a collection mint (Solana), or the platform slug, so you can address a collection with whatever you already have on file.

Discovery is cheap, run it rarely

The collections feed changes on the order of hours, not seconds. Walking the full list every 15 minutes is plenty; the results are what drives the per-collection work below.

2. Backfill a collection

Listings

/listings returns active listings merged from our own order book and from the external marketplaces we aggregate, deduplicated per asset and sorted cheapest first. The important limitation is that the paginated window is a bounded snapshot of roughly the 250 cheapest listings, not a complete dump of the order book. For most collections that is the entire book; for very large ones it is the depth near the floor.

curl
curl "https://exclusivo.one/v1/collections/solgods_/listings?limit=100"

Because the window is price-sorted and bounded, treat a full walk of it as one atomic observation: it is the state of the floor depth at that moment, not an append-only stream.

Sales

/sales is genuine history: completed sales, newest first, cursor-paginated all the way back. Walk it once to seed, record the newest timestamp you stored as a watermark, and never walk the whole thing again.

curl
curl "https://exclusivo.one/v1/collections/solgods_/sales?limit=100"

Sales include both Exclusivo-native fills and external purchases routed through our interface; the marketplace field names the source (EXC for our own, otherwise a slug such as opensea or magic-eden).

Never construct a cursor

Cursors are opaque and scoped to the endpoint that issued them. Pass back exactly the nextCursor you received, and omit the parameter entirely on the first page — sending cursor=null or an empty string returns 400 bad_request.

3. Incremental sync

Sales and listings need different strategies because only one of them is an append-only log.

Sales: walk until you hit your watermark

Page from the newest sale and stop as soon as you reach a row whose timestamp is at or before your stored watermark. In steady state that is a single request per collection per poll.

Listings: re-fetch the snapshot and diff

There is no listing-change feed and no cancel feed. Re-read the snapshot, upsert everything you see, and close out any listing you had stored for that collection whose orderId is now absent — an absent listing has been sold, cancelled, or has expired. Only close listings out when the whole page walk succeeded and was not flagged stale (see below); otherwise a transient upstream failure looks identical to the entire book being delisted.

Poll intervals that make sense

EndpointSuggested intervalWhy
/collections10–15 minDiscovery; the ranking barely moves inside an hour.
/collections/{id}/listings20–30s hot, 5 min tailCached for 15s. Segment your collections — a long tail polled every 30s is almost all wasted requests.
/collections/{id}/sales60sCached for 30s; sales are the append-only feed.
/collections/{id}/floor15–30sCached for 10s. Cheapest way to detect that a book moved before paging it.
/collections/{id}5 minVolume, supply, owners and listedCount move slowly.

One extra constraint on Solana: our own Solana listings reach the read path through an indexer mirror that syncs roughly every two minutes. Polling Solana listings every five seconds cannot see changes any sooner than that mirror does.

4. Dedupe and join

orderId is the cross-system join key. It is the Seaport order hash on EVM and the listing id on Solana, and the same value appears on a listing and on the sale that fills it. If you index Seaport logs yourself, the orderHash in OrderFulfilled matches our orderId exactly, which is what lets you attach our order book to your own chain-derived sales without heuristics.

PurposeKey
Listing / sale identity(chain, orderId) when orderId is present.
Fallback identity(chain, contractAddress ?? mint, tokenId, seller, price.raw, timestamp) orderId is nullable on rows that predate the convention.
Asset identity (EVM)(chain, contractAddress, tokenId); mint is null.
Asset identity (Solana)(chain, mint); contractAddress and tokenId are null.
  • Address casing is not cosmetic. EVM addresses are returned lowercase and can be compared case-insensitively. Solana base58 addresses are case-sensitive; lowercasing one produces a different, invalid address, so never normalize them.
  • Compare prices as integers. price.raw is a decimal string of base units (wei or lamports) and is exact; parse it with BigInt. price.decimal is for display and loses precision on large wei values.
  • Filter on marketplace before you aggregate. Rows with a foreign marketplace slug are listings and sales we surface from another venue. If you already ingest that venue directly, counting our copy as well will double-count volume.

5. Stale and partial data

When an upstream source degrades we serve the last-good data and label it rather than failing or, worse, returning a silently empty result. Three flags exist:

FlagWhereMeaning
meta.staleList envelopesOne of the sources behind this page was unreachable. meta.staleSources names it.
stale/floorThe live floor resolve failed and buyerFloor came from the last-good stored value.
stale/collections/{id}Both indexer sources were unreachable; identity is real but window stats are absent or last-good.

A stale listings page is missing rows, not showing delistings

On an EVM collection, meta.staleSources containing indexer-mirror means the external-marketplace rows are missing. On a Solana collection it means every row is missing. Skip the diff-and-close step for that collection and retry on the next tick — do not record an empty book.

Similarly, a 502 upstream_error means a data source is temporarily down. Retry it; never persist it as "no listings". And treat price.usd as advisory: it is null whenever the price feed is unavailable and always null for chains without a feed.

6. Rate-limit-aware pacing

Limits are per IP, per minute, in two independent buckets: 120 on list endpoints (/collections, /listings, /sales) and 300 on single-item endpoints (/collections/{id}, /floor, /tokens/…). A 429 returns the standard error envelope with code rate_limited and carries Retry-After alongside X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (an ISO-8601 timestamp, not epoch seconds).

Successful responses carry no rate-limit headers

The X-RateLimit-* headers are set on 429 responses only, so you cannot watch X-RateLimit-Remaining tick down and slow up before you get blocked. Budget client-side against the documented limits and treat a 429 as the correction signal rather than the thing you steer by.

Budget from the list bucket, because that is the tighter one. At limit=100 a full listings walk costs one to three requests, so 120 requests per minute is roughly 40 collections fully re-read per minute. If you track more collections than that, tier them: poll the top by volume every 30 seconds and the tail every few minutes, and use the cheap /floor call — which draws on the 300/min item bucket — to decide whether a tail collection is worth paging at all.

JavaScript
const BASE = "https://exclusivo.one/v1";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

// One shared fetcher: honours Retry-After, backs off on transient upstream
// errors, and throws on anything a retry cannot fix.
async function apiGet(path, { retries = 4 } = {}) {
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(BASE + path, { headers: { accept: "application/json" } });

    if (res.status === 429) {
      if (attempt >= retries) throw new Error("rate limited");
      const retryAfter = Number(res.headers.get("retry-after")) || 2;
      await sleep(retryAfter * 1000);
      continue;
    }

    // 502 upstream_error is transient — never persist it as "no data".
    if (res.status === 502) {
      if (attempt >= retries) throw new Error("upstream unavailable");
      await sleep(2 ** attempt * 1000);
      continue;
    }

    if (!res.ok) throw new Error((await res.json()).error.message);
    return res.json();
  }
}

Ask for more

These limits suit a normal production integration. If your ingest genuinely needs more, email dev@psukhe.media with your egress IPs and we will raise them.

A worked sync loop

Putting it together: a paging helper, a sales walk that stops at the watermark, and a listings walk that only closes out rows when the read was complete.

JavaScript
// Yields whole pages so callers can inspect pagination and meta.
async function* pages(path) {
  let cursor = null;
  do {
    const query = new URLSearchParams({ limit: "100" });
    if (cursor) query.set("cursor", cursor);   // omit it entirely on page 1
    const body = await apiGet(`${path}?${query}`);
    yield body;
    cursor = body.pagination.hasMore ? body.pagination.nextCursor : null;
  } while (cursor);
}

function listingKey(item) {
  if (item.orderId) return `${item.chain}:${item.orderId}`;
  const asset = item.mint ?? `${item.contractAddress}:${item.tokenId}`;
  return `${item.chain}:${asset}:${item.seller}:${item.price.raw}`;
}

async function syncListings(collectionId, store) {
  const seen = new Set();
  let degraded = false;

  for await (const page of pages(`/collections/${collectionId}/listings`)) {
    if (page.meta?.stale) degraded = true;
    for (const item of page.data) {
      const key = listingKey(item);
      seen.add(key);
      store.upsertListing(key, item);
    }
  }

  // Absent rows mean sold / cancelled / expired — but only when the read was
  // complete. A degraded page is missing rows, not reporting delistings.
  if (!degraded) store.closeListingsMissingFrom(collectionId, seen);
}

async function syncSales(collectionId, store) {
  const watermark = store.newestSaleTimestamp(collectionId); // ISO string or null

  for await (const page of pages(`/collections/${collectionId}/sales`)) {
    for (const sale of page.data) {
      if (watermark && sale.timestamp <= watermark) return;  // caught up
      store.upsertSale(`${sale.chain}:${sale.orderId ?? sale.tx}`, sale);
    }
  }
}

async function tick(collectionIds, store) {
  // Serial per collection keeps the list bucket (120/min) predictable. There is
  // no remaining-quota header on success, so raise concurrency only against a
  // budget you track yourself.
  for (const id of collectionIds) {
    await syncSales(id, store);
    await syncListings(id, store);
  }
}

What to watch out for

A chain-only crawler will silently miss our order book

This is the single most common integration failure. EVM listings and cancels exist only as signed orders in our database — there is no Listed event, no cancel transaction, nothing to crawl. If your pipeline is built purely on chain events you will index our sales, conclude our listings feed is empty, and never see an error. The API is the authoritative feed for EVM listings and cancels.
  • The listings window is bounded. Roughly the 250 cheapest active listings per collection. Do not treat a completed walk as proof that no deeper listings exist.
  • There is no cancel event of any kind. Disappearance from the listings snapshot is the only cancel signal, and it does not distinguish sold from cancelled from expired. Join against /sales by orderId to tell them apart.
  • Sales cursors reference a specific row. A cursor that can no longer be resolved returns 400 bad_request. Handle it by restarting that collection's walk from the newest page rather than retrying the cursor.
  • ApeChain activity is API-only. It routes through a different fulfilment path and carries none of our on-chain attribution markers, so nothing about it is reconstructible from chain data.
  • Prices are the seller ask. Marketplace fee and royalty are charged to the buyer on top, so a listing's price is exactly what the seller receives — see Floor for the ask-versus-buyer distinction.

Once you are ingesting, the natural next step is proving it: Verify listings on-chain shows how to confirm any sale we report directly from chain data.