Marketplace data
Real-time notifications
Stream per-wallet notices (sales, offers, coins, NFTs, airdrops, alerts) and live market activity over server-sent events, with resume.
The API pushes two kinds of real-time data over server-sent events (SSE):
SSE is plain HTTP: one long GET that the server keeps open and writes frames to. It works with the browser's EventSource, with fetch in Node, with requests in Python and with curl -N.
Wallet notices
Subscribing
Pass addr, device or both. With neither, the request fails with 400 bad_request ("Pass addr or device"). An invalid address or device id is also a 400.
No API key or session is needed, and CORS is open (Access-Control-Allow-Origin: *), so a web page on any origin can subscribe. Notices are built from public chain data, so any address can be watched. Opening a stream counts as one request toward your rate limit, and a client can hold 3 streams open at once per IP address, or 10 with an API key (pass it as x-api-key, or as ?apiKey= from an EventSource).
Notices are produced only for watched addresses. The first time an address is subscribed (on /notify/stream or /notify/recent), it is added to the watch list and notices start a few seconds later. Nothing is produced for events before that. An address stays watched for 30 days after its last subscription; an open stream keeps renewing it.
Frames
The stream starts with a reconnect hint and a hello event, then sends one notice event per notice. A comment line keeps the connection alive every 15 seconds.
retry: 3000
event: hello
data: {"now":1793067060773,"addrs":["0x16386f6d07a85631e419e8f83b1d99a13949515a6af7ba3f81f3cfb0ec7e5543"]}
event: notice
data: {"id":"48213","kind":"sold","addr":"0x16386f6d07a85631e419e8f83b1d99a13949515a6af7ba3f81f3cfb0ec7e5543","title":"Popkins #4104 sold for 72 SUI", ...}
id: 48213
: hb
Notice ids are numeric strings that only grow. Compare them as big integers, not as strings.
Notice object
{
"id": "48213",
"kind": "sold",
"addr": "0x16386f6d07a85631e419e8f83b1d99a13949515a6af7ba3f81f3cfb0ec7e5543",
"title": "Popkins #4104 sold for 72 SUI",
"body": "Sold into an offer from 0x1e74...1ea2 on Tradeport",
"imageUrl": "https://storage.claynosaurz.com/popkins/images/0x909c8f92d9556480466180c327f677fb69a9811ad8dd004d3b1c39bf3f09db5a",
"amount": {
"raw": "72000000000",
"decimals": 9,
"symbol": "SUI",
"coinType": "0x0000000000000000000000000000000000000000000000000000000000000002::sui::SUI",
"usd": 65.6172
},
"item": {
"id": "0x909c8f92d9556480466180c327f677fb69a9811ad8dd004d3b1c39bf3f09db5a",
"name": "Popkins #4104",
"imageUrl": "https://storage.claynosaurz.com/popkins/images/0x909c8f92d9556480466180c327f677fb69a9811ad8dd004d3b1c39bf3f09db5a",
"collection": { "slug": "popkins", "name": "Popkins" }
},
"action": {
"kind": "item",
"itemId": "0x909c8f92d9556480466180c327f677fb69a9811ad8dd004d3b1c39bf3f09db5a",
"slug": "popkins"
},
"digest": "DhTWmsU2jGPGVs2q8MhA1nQttgqw5Le6vQfH5CEkVhDR",
"cpTs": 1793066412383,
"createdAt": 1793066413105,
"sentAt": 1793066413241
}Unlike the rest of the API, notice times are epoch milliseconds (numbers), not ISO strings.
Notice kinds
Venue names in body are Rare, Tradeport, Drip and Sui Kiosk. Addresses are shortened to 0x plus 4 characters, ... and the last 4.
What is filtered out, so you do not have to:
- Coins: only coins sent by another address, summed per coin per transaction. Amounts under 0.1 SUI, or under $1 for other coins, are dropped, and so are spam coins and coins with no known price. Coins that are part of a trade you already get a
soldorboughtnotice for are not repeated. - Collection offers: only when the new offer is the highest open collection offer on that collection, and at most one notice per collection per 15 minutes. Bidding bots outbid each other every few seconds.
- Your own transactions: offers you place, and coins or items that arrive in a transaction you sent yourself, do not notify you.
- One per event: when one event concerns several of the addresses on a connection (a collection offer on a collection two of your wallets hold), that connection gets it once.
- Perps: a position notifies when it gets within 10% of its liquidation price, again within 5%, and re-arms once it moves more than 15% away. Positions are checked every 15 seconds, and only for addresses with an open stream.
Actions
action.kind tells you where to send the user:
Resume
Every notice frame has an id: line. To pick up where you left off after a disconnect, send the last id you processed:
- the
Last-Event-IDrequest header, or - the
afterquery parameter (?after=48213).
When both are present, the header wins. On connect, the server first sends every notice newer than that id from the last 7 days (at most 200, oldest first), then continues live. Notices are kept for 14 days; read older ones with /notify/recent.
The browser's EventSource sends Last-Event-ID by itself when it reconnects, but it forgets the id when the page reloads. Store the last id yourself and pass it as after on a fresh page load.
Dedupe by id on the client. A notice that arrives live while the backlog is being read can show up near it, and /notify/recent overlaps with the stream.
Reconnecting
EventSourcereconnects automatically after the 3 secondretryhint and resumes withLast-Event-ID. You do not need to do anything.- With
fetchorrequests, reconnect yourself: wait, sendLast-Event-ID, and back off (for example 3, 6, 12, up to 30 seconds) while the server is unreachable. - Treat 45 seconds without any bytes (heartbeats included) as a dead connection.
- A
400will not succeed on retry. Fix the request instead. - A slow reader that lets more than 500 frames queue up on one connection loses the newer ones. Reconnect with
Last-Event-IDto fetch them.
Examples
Browser (EventSource)
const API = "https://rare.market/api/v1";
const addrs = ["0x16386f6d07a85631e419e8f83b1d99a13949515a6af7ba3f81f3cfb0ec7e5543"];
const LAST_KEY = `rare.notify.last.${addrs.join(",")}`;
type Notice = {
id: string;
kind: "sold" | "bought" | "offer_received" | "coin_received" | "nft_received" | "airdrop" | "perp_liquidation" | "price_alert";
addr: string | null;
title: string;
body: string | null;
imageUrl: string | null;
amount: { raw: string; decimals: number; symbol: string; coinType: string; usd: number | null } | null;
item: { id: string; name: string; imageUrl: string | null; collection: { slug: string; name: string } | null } | null;
action:
| { kind: "item"; itemId: string; slug: string | null }
| { kind: "collection"; slug: string }
| { kind: "position"; marketId: string; symbol: string }
| { kind: "coin"; coinType: string }
| { kind: "tx"; digest: string };
digest: string | null;
cpTs: number | null;
createdAt: number;
sentAt: number;
};
function readLast(): string | null {
try {
return localStorage.getItem(LAST_KEY);
} catch {
return null;
}
}
function saveLast(id: string) {
try {
localStorage.setItem(LAST_KEY, id);
} catch {
/* storage unavailable: resume only within this page */
}
}
const seen = new Set<string>();
function show(n: Notice) {
if (seen.has(n.id)) return;
seen.add(n.id);
console.log(`[${n.kind}] ${n.title}${n.body ? ` (${n.body})` : ""}`);
}
const url = new URL(`${API}/notify/stream`);
url.searchParams.set("addr", addrs.join(","));
const last = readLast();
if (last) url.searchParams.set("after", last); // fresh page load: resume from storage
const es = new EventSource(url);
es.addEventListener("hello", (e) => {
const hello = JSON.parse((e as MessageEvent<string>).data) as { now: number; addrs: string[] };
console.log("subscribed", hello.addrs);
});
es.addEventListener("notice", (e) => {
const msg = e as MessageEvent<string>;
show(JSON.parse(msg.data) as Notice);
if (msg.lastEventId) saveLast(msg.lastEventId);
});
es.onerror = () => {
// EventSource retries on its own after 3 s and sends Last-Event-ID.
// readyState CLOSED means it gave up (for example a 400): fix the request.
if (es.readyState === EventSource.CLOSED) console.error("notify stream closed");
};
// Later: es.close();Node.js (fetch)
Node 18 or later, no dependencies. The sse helper is a complete SSE client (parser, heartbeat watchdog, resume and backoff) and also works for /stream.
const API = "https://rare.market/api/v1";
type Frame = { event: string; data: string; id?: string };
/** Connects to an SSE endpoint and keeps it connected. Resolves only when `signal` aborts. */
async function sse(
url: URL,
onFrame: (f: Frame) => void,
opts: { lastEventId?: string; signal?: AbortSignal } = {},
): Promise<void> {
let lastId = opts.lastEventId ?? null;
let retryMs = 3000;
let failures = 0;
while (!opts.signal?.aborted) {
const ctrl = new AbortController();
const onStop = () => ctrl.abort();
opts.signal?.addEventListener("abort", onStop);
let watchdog = setTimeout(() => ctrl.abort(), 45_000);
try {
const headers: Record<string, string> = { accept: "text/event-stream" };
if (lastId) headers["last-event-id"] = lastId;
const res = await fetch(url, { headers, signal: ctrl.signal });
if (res.status === 400) throw Object.assign(new Error(await res.text()), { fatal: true });
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
failures = 0;
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
let event = "message";
let data: string[] = [];
let id: string | undefined;
for (;;) {
const { value, done } = await reader.read();
if (done) break;
clearTimeout(watchdog);
watchdog = setTimeout(() => ctrl.abort(), 45_000);
buf += decoder.decode(value, { stream: true });
let nl: number;
while ((nl = buf.indexOf("\n")) >= 0) {
let line = buf.slice(0, nl);
buf = buf.slice(nl + 1);
if (line.endsWith("\r")) line = line.slice(0, -1);
if (line === "") {
// Blank line: dispatch the frame.
if (data.length) {
if (id) lastId = id;
onFrame({ event, data: data.join("\n"), id });
}
event = "message";
data = [];
id = undefined;
continue;
}
if (line.startsWith(":")) continue; // heartbeat comment
const i = line.indexOf(":");
const field = i < 0 ? line : line.slice(0, i);
let val = i < 0 ? "" : line.slice(i + 1);
if (val.startsWith(" ")) val = val.slice(1);
if (field === "event") event = val;
else if (field === "data") data.push(val);
else if (field === "id") id = val;
else if (field === "retry" && /^\d+$/.test(val)) retryMs = Number(val);
}
}
} catch (e) {
if ((e as { fatal?: boolean }).fatal) throw e;
if (!opts.signal?.aborted) {
failures += 1;
console.error("stream dropped:", (e as Error).message);
}
} finally {
clearTimeout(watchdog);
opts.signal?.removeEventListener("abort", onStop);
}
if (opts.signal?.aborted) return;
const wait = Math.min(30_000, retryMs * 2 ** Math.min(failures, 4));
await new Promise((r) => setTimeout(r, wait));
}
}
// ---------- wallet notices ----------
const addrs = ["0x16386f6d07a85631e419e8f83b1d99a13949515a6af7ba3f81f3cfb0ec7e5543"];
const url = new URL(`${API}/notify/stream`);
url.searchParams.set("addr", addrs.join(","));
const seen = new Set<string>();
await sse(url, (f) => {
if (f.event === "hello") {
console.log("subscribed", JSON.parse(f.data).addrs);
return;
}
if (f.event !== "notice") return;
const n = JSON.parse(f.data) as { id: string; kind: string; title: string; body: string | null };
if (seen.has(n.id)) return;
seen.add(n.id);
console.log(`[${n.kind}] ${n.title}${n.body ? ` (${n.body})` : ""}`);
// Persist f.id here and pass it back as { lastEventId } after a restart.
});Save it as notify.mts and run node notify.mts (Node 23.6 or later runs TypeScript directly; on older versions use npx tsx notify.mts).
Python (requests)
import json
import time
import requests
API = "https://rare.market/api/v1"
def sse(url, params, on_frame, last_event_id=None):
"""Connects to an SSE endpoint and keeps it connected. Calls on_frame(event, data, id)."""
retry = 3.0
failures = 0
while True:
headers = {"Accept": "text/event-stream"}
if last_event_id:
headers["Last-Event-ID"] = last_event_id
try:
# Read timeout 45 s: heartbeats arrive every 15 s, so silence means a dead link.
with requests.get(url, params=params, headers=headers, stream=True, timeout=(10, 45)) as resp:
if resp.status_code == 400:
raise SystemExit(resp.json()["error"]["message"])
resp.raise_for_status()
resp.encoding = "utf-8"
failures = 0
event, data, frame_id = "message", [], None
# chunk_size=None hands over bytes as they arrive (no 512-byte buffering).
for line in resp.iter_lines(chunk_size=None, decode_unicode=True):
if line == "":
if data:
if frame_id:
last_event_id = frame_id
on_frame(event, "\n".join(data), frame_id)
event, data, frame_id = "message", [], None
continue
if line.startswith(":"):
continue # heartbeat comment
field, _, value = line.partition(":")
if value.startswith(" "):
value = value[1:]
if field == "event":
event = value
elif field == "data":
data.append(value)
elif field == "id":
frame_id = value
elif field == "retry" and value.isdigit():
retry = int(value) / 1000
except requests.RequestException as e:
failures += 1
print("stream dropped:", e)
time.sleep(min(30, retry * 2 ** min(failures, 4)))
# ---------- wallet notices ----------
addrs = ["0x16386f6d07a85631e419e8f83b1d99a13949515a6af7ba3f81f3cfb0ec7e5543"]
seen = set()
def on_frame(event, data, frame_id):
if event == "hello":
print("subscribed", json.loads(data)["addrs"])
elif event == "notice":
n = json.loads(data)
if n["id"] in seen:
return
seen.add(n["id"])
print(f"[{n['kind']}] {n['title']}" + (f" ({n['body']})" if n["body"] else ""))
# Persist frame_id here and pass it back as last_event_id after a restart.
sse(f"{API}/notify/stream", {"addr": ",".join(addrs)}, on_frame)curl
# Live notices for two wallets (-N turns off buffering)
curl -N "https://rare.market/api/v1/notify/stream?addr=0x16386f6d07a85631e419e8f83b1d99a13949515a6af7ba3f81f3cfb0ec7e5543,0x1e74849ee17df6746ca3911cfbfe7c80e4d2e85d4395c0ca06c2f56d8f4d1ea2"
# Resume after notice 48213
curl -N -H "Last-Event-ID: 48213" \
"https://rare.market/api/v1/notify/stream?addr=0x16386f6d07a85631e419e8f83b1d99a13949515a6af7ba3f81f3cfb0ec7e5543"Recent notices
The newest notices for the same addr and device parameters, as JSON. Use it to fill a notification list on load, then keep it current with the stream.
curl "https://rare.market/api/v1/notify/recent?addr=0x16386f6d07a85631e419e8f83b1d99a13949515a6af7ba3f81f3cfb0ec7e5543&limit=20"{
"data": [
{
"id": "48213",
"kind": "coin_received",
"addr": "0x16386f6d07a85631e419e8f83b1d99a13949515a6af7ba3f81f3cfb0ec7e5543",
"title": "12.5 SUI received",
"body": "From 0x2b9c...07fe",
"imageUrl": "https://rare.market/sui.svg",
"amount": {
"raw": "12500000000",
"decimals": 9,
"symbol": "SUI",
"coinType": "0x0000000000000000000000000000000000000000000000000000000000000002::sui::SUI",
"usd": 11.39
},
"item": null,
"action": { "kind": "tx", "digest": "HtxpgowKkK6hubtatrP2Qmmcu7dXDvaWGBHq9yRPw3nj" },
"digest": "HtxpgowKkK6hubtatrP2Qmmcu7dXDvaWGBHq9yRPw3nj",
"cpTs": 1793066448622,
"createdAt": 1793066449310,
"sentAt": 1793067060912
}
],
"nextBefore": null
}data is newest first. nextBefore is set when the page was full; pass it as before for the next page. It is null at the end.
Price alerts
Price alerts belong to a device id, not to a wallet. Generate a random id once per install (16 to 64 characters of A-Z a-z 0-9 _ -), keep it, and pass it as device on the stream to receive the price_alert notices. Anyone who knows the device id can read and delete its alerts, so treat it like a secret.
dir is above or below; target is a price in US dollars. coinType is a full coin type such as 0x2::sui::SUI. addr is optional and is echoed as the notice's addr. Prices are checked every 15 seconds. An alert fires once (active becomes false, firedAt is set), and a device can have up to 50 active alerts. Errors are 400 bad_request with messages such as dir must be above or below, No price for this coin and Up to 50 active alerts.
curl -X POST "https://rare.market/api/v1/notify/alerts" \
-H "content-type: application/json" \
-d '{"device":"k3T9xQ2mV7pL0aZ4wR8yB1nC","coinType":"0x2::sui::SUI","dir":"above","target":1.25}'
curl -N "https://rare.market/api/v1/notify/stream?device=k3T9xQ2mV7pL0aZ4wR8yB1nC"Delivery health
{
"listening": true,
"subscribers": 5,
"owners": 56,
"lastNoticeLagMs": 1038,
"p50LagMs": 1119,
"p95LagMs": 2767,
"samples": 221
}subscribers is open stream connections and owners the distinct addresses and devices they cover. The lag figures are the time from a notice's checkpoint to the moment it was written to a stream, over the last 500 notices.
Public live stream
The market-wide stream that powers live grids and activity panels. Pass up to 50 comma-separated topics:
New events are picked up every 2 seconds. floor and listing follow any activity that can change listings (sales, lists, delists, transfers, mints), not offers. A comment line : heartbeat is sent every 15 seconds.
Resume works with the Last-Event-ID header only (no query parameter), and only for activity: send the last activity id and the server replays the matching events of up to the last hour (at most 200) before going live. This stream sends no retry line, so EventSource uses the browser's default reconnect delay.
curl -N "https://rare.market/api/v1/stream?topics=collection:popkins,status"event: status
data: {"suiUsd":0.9123,"tps":122,"checkpoint":338534372,"degraded":false}
event: activity
data: {"id":"338534515:6357925207:0","kind":"offer_cancel","venue":"tradeport","item":{"id":"","name":"Mystic Yetis","imageUrl":"https://indexer.mypinata.cloud/ipfs/bafkreid6owvnhp2v2muf4yrk4v3hjonhposfgsnd5zirfzgbyutxoasxay","rarity":null},"collection":{"slug":"mystic-yetis","name":"Mystic Yetis","imageUrl":"https://indexer.mypinata.cloud/ipfs/bafkreid6owvnhp2v2muf4yrk4v3hjonhposfgsnd5zirfzgbyutxoasxay","verified":true},"priceMist":"15200100000","from":"0xa7b32af76de025a0080904a498071d64548581f15b20be3e1ec052dc4908cc13","to":null,"fromName":null,"toName":null,"txDigest":"DKU3QgyZ2R52o6wHkqh8PWbhSjPXZMkR1juhYg757x8k","at":"2026-10-27T02:10:47.419Z"}
id: 338534515:6357925207:0
: heartbeat
With the sse helpers above:
curl -N -H "Last-Event-ID: 338534515:6357925207:0" \
"https://rare.market/api/v1/stream?topics=collection:popkins"/stream is rare.market's own live feed: it is not in the API reference, sends no CORS headers, and can change without notice. Open streams count toward the same per-client cap as /notify/stream. For a stable feed in your app, use /notify/stream, or poll GET /events with caching.
