Exclusivo

Guides

Build a sales tracking bot

Everything in these docs that matters if all you want is “tell me when something sells.” This page is deliberately narrow: it covers the data side only — reading our sales feed correctly — and says nothing about how you build, host, or deliver the bot itself.

The shape of the problem

A sales bot is a loop: ask what has sold since last time, post anything new, remember where you got to. On Exclusivo that is one endpoint, polled on a timer, with a stored watermark. There is no key to obtain and no signup.

There is no websocket or webhook

We do not push events. Everything here is polling, and the sales endpoint is edge-cached for 30 seconds, so ~30 seconds is the realistic floor on alert latency. If your product promises sub-second alerts, this API cannot deliver that and no polling interval will fix it.

One endpoint

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

Sales come back newest first, cursor-paginated. The {id} accepts whatever you already have on file: our canonical slug, a raw contract address (EVM), or a collection mint (Solana). Two query parameters exist — limit (1–100, default 25) and cursor.

Response
{
  "data": [
    {
      "chain": "solana",
      "contractAddress": null,
      "tokenId": null,
      "mint": "7xKq…9dTf",
      "collectionId": "solana_85ghq5exuysxeu4aexgs7d44udegrf3bvf6gx8ysgame",
      "price": { "raw": "12500000000", "decimal": 12.5, "currency": "SOL", "usd": 2437.50 },
      "seller": "9WzD…kQ2m",
      "buyer": "4Nd8…pR7x",
      "marketplace": "EXC",
      "status": "sold",
      "timestamp": "2026-07-26T14:02:11.418Z",
      "orderId": "a1b2c3…",
      "tx": "5Jx9…qWm2"
    }
  ],
  "pagination": { "nextCursor": "eyJrIjoic2FsZS1kb2Mi…", "hasMore": true }
}

That is the whole surface. Everything below is about consuming it without producing duplicate or missing alerts.

The polling loop

Because sales are newest-first, you never page the whole history after the first run. Walk forward from the top, skip anything you have already seen, and stop once a sale is older than your watermark. In steady state that is a single request per collection per poll.

JavaScript — the data half of a bot
const BASE = "https://exclusivo.one/v1";

// Persist this between runs (file, KV, database — your choice).
// Keep BOTH: the timestamp alone is not a safe watermark (see pitfalls).
let state = { lastSeenAt: null, seenIds: new Set() };

async function fetchNewSales(collectionId) {
  const fresh = [];
  let cursor = null;

  outer: while (true) {
    const url = new URL(`${BASE}/collections/${collectionId}/sales`);
    url.searchParams.set("limit", "100");
    if (cursor) url.searchParams.set("cursor", cursor);   // never send cursor=null

    const res = await fetch(url);
    if (res.status === 429) {                              // backoff and retry later
      const wait = Number(res.headers.get("Retry-After") ?? 30);
      await new Promise((r) => setTimeout(r, wait * 1000));
      continue;
    }
    if (!res.ok) throw new Error((await res.json()).error.message);

    const { data, pagination } = await res.json();

    for (const sale of data) {
      // Stop only once a sale is STRICTLY older than the watermark. Sales
      // sharing a timestamp have no defined order between them, so stopping at
      // <= would drop the ones that happen to sort below a sale you've seen.
      if (state.lastSeenAt && sale.timestamp < state.lastSeenAt) break outer;
      // A known id is skipped, not a stop signal — for the same reason.
      if (state.seenIds.has(saleKey(sale))) continue;
      fresh.push(sale);
    }

    // Page on hasMore — NEVER on data.length (see pitfalls).
    if (!pagination.hasMore) break;
    cursor = pagination.nextCursor;
  }

  return fresh.reverse();   // oldest-first, so alerts post in chronological order
}

// Advance the watermark only AFTER delivery succeeds — crashing mid-post with an
// already-advanced watermark loses those alerts permanently.
function commitWatermark(delivered) {
  const EPOCH = "1970-01-01T00:00:00.000Z";
  for (const sale of delivered) {
    state.seenIds.add(saleKey(sale));
    // Skip epoch-0 rows: adopting one rewinds you to 1970 (see pitfalls).
    if (sale.timestamp !== EPOCH && (!state.lastSeenAt || sale.timestamp > state.lastSeenAt)) {
      state.lastSeenAt = sale.timestamp;
    }
  }
  persist({ lastSeenAt: state.lastSeenAt, seenIds: [...state.seenIds] });  // your store
}

On the very first run there is no watermark, so this would walk the entire history. Seed it instead: take page one, call commitWatermark on it, post nothing. Your bot starts announcing from the moment it goes live rather than replaying years of sales.

Keep seenIds bounded

Because a known id is now skipped rather than treated as a stop signal, seenIds is load-bearing — it is the only thing preventing a re-alert for a sale that shares its timestamp with the watermark. Retaining the ids from roughly the last day of sales per collection is plenty; a set that grows forever is a memory leak, and one trimmed to the last few entries will re-alert.

Fields a bot actually uses

FieldWhat to do with it
priceDisplay price.decimal with price.currency. Use price.raw (integer base units, exact) for any comparison or threshold. price.usd is best-effort and can be null.
buyer / sellerWallet addresses. Truncate for display; keep the full value for links and dedupe.
txTransaction hash (EVM) or signature (Solana). This is your “view on explorer” link, and half of the dedupe key.
marketplaceEXC for an Exclusivo sale; otherwise the source slug (opensea, magic-eden). Decide deliberately whether your bot announces external sales.
mint / tokenIdWhich item sold — mint on Solana, tokenId on EVM. One of the two is always null depending on chain.
timestampISO 8601 sale time, and your watermark. Read the pitfall about 1970 below before trusting it.

Price is the seller ask, not what the buyer paid

Our fees are charged to the buyer on top of the ask, so price is what the seller received. If your bot says “sold for X”, that is the honest number to use — just be aware the buyer's wallet moved more than X.

Not posting the same sale twice

Duplicate alerts are the most common failure of a bot like this, and they come from treating one field as an identity. Build a composite key:

JavaScript
function saleKey(sale) {
  // tx is the strongest identifier, but it can be null on older rows,
  // and one transaction can settle several items (a sweep).
  return [
    sale.tx ?? "no-tx",
    sale.orderId ?? "no-order",
    sale.mint ?? `${sale.contractAddress}:${sale.tokenId}`,
  ].join("|");
}
  • tx alone is not unique. A bulk purchase settles multiple items in one transaction, so keying on tx would collapse a five-item sweep into one alert.
  • Both can be null. orderId is null for sales with no recorded listing, and tx is null on rows with no on-chain transaction — hence the fallbacks above.
  • Never lowercase a Solana address or mint. Base58 is case-sensitive; normalising it the way you would an EVM address produces a different key and a duplicate alert. EVM values are already lowercase.

How often to poll

ConstraintValueWhat it means for you
Cache TTL30 secondsPolling faster returns the identical cached body while still spending rate limit. 30s is the natural interval.
Rate limit120 req/min per IPSales is on the list bucket, shared with /collections and /listings. One request per collection per poll in steady state.
Practical ceiling~40 collectionsAt a 30s cadence with headroom for retries. Beyond that, tier your collections: hot ones every 30s, the long tail every few minutes.

You cannot watch your remaining quota

X-RateLimit-* headers are only sent on 429 responses, so there is no counter to read on success. Budget against the documented limit and treat a 429 — with its Retry-After — as the correction signal, as the loop above does.

Five things that will bite you

1. A short page is not the end of the feed

Rows whose stored price cannot be interpreted are dropped after the page window is cut, so data.length can be smaller than limit — even zero — while hasMore is still true. Page on hasMore and nextCursor only. A bot that stops on a short page silently goes quiet.

2. A 1970 timestamp means the time was missing

When a record has no usable time we emit the Unix epoch (1970-01-01T00:00:00.000Z) rather than dropping the sale. If you set your watermark from it you will rewind to 1970 and replay everything. Ignore epoch-0 rows when advancing the watermark, and rely on your seenIds set to keep them from re-alerting.

3. cursor=null is a 400

Omit the parameter entirely on the first page. Serialising a nullable variable straight into the query string sends the literal string null, which fails validation. This is the single most common integration bug.

4. usd can be null, and null is not zero

The USD conversion is best-effort — null whenever the price feed is unavailable, and permanently null on chains we have no feed for (Polygon, ApeChain). Treating null as 0 produces "sold for $0" alerts. Omit the fiat line instead.

5. This is our activity, not the whole chain

The feed covers sales Exclusivo recorded — our own fills plus external purchases routed through our interface. A sale of the same collection that happened entirely on another marketplace, with no involvement from us, will not appear. If your bot claims to cover all sales of a collection everywhere, this endpoint alone will not back that claim.

Going further

  • Tracking many collections, or want listings too? Aggregator integration covers backfill, incremental sync and pacing at scale.
  • Want to prove a sale independently before announcing it? Verify listings on-chain decodes the same event from the transaction itself.
  • Every parameter, field and error in full: the sales endpoint reference.
  • Adding floor context to an alert: the floor endpoint — read the askFloor / buyerFloor distinction before you compare a sale to a floor.