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
One endpoint
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.
{
"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.
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
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
| Field | What to do with it |
|---|---|
price | Display 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 / seller | Wallet addresses. Truncate for display; keep the full value for links and dedupe. |
tx | Transaction hash (EVM) or signature (Solana). This is your “view on explorer” link, and half of the dedupe key. |
marketplace | EXC for an Exclusivo sale; otherwise the source slug (opensea, magic-eden). Decide deliberately whether your bot announces external sales. |
mint / tokenId | Which item sold — mint on Solana, tokenId on EVM. One of the two is always null depending on chain. |
timestamp | ISO 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
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:
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("|");
}txalone is not unique. A bulk purchase settles multiple items in one transaction, so keying ontxwould collapse a five-item sweep into one alert.- Both can be null.
orderIdis null for sales with no recorded listing, andtxis 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
| Constraint | Value | What it means for you |
|---|---|---|
| Cache TTL | 30 seconds | Polling faster returns the identical cached body while still spending rate limit. 30s is the natural interval. |
| Rate limit | 120 req/min per IP | Sales is on the list bucket, shared with /collections and /listings. One request per collection per poll in steady state. |
| Practical ceiling | ~40 collections | At 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.