The API.
Every piece's owner, price, content, AI paint and full history, as JSON. No key, no signup, no auth. The data is already public on Solana; this is just a faster way to read it.
It is read-only: nothing here can change the page. Buys, takes and edits are ordinary Solana transactions you sign with your own wallet (see building a transaction). For what a piece is and how the market works, read the docs.
https://funny-site.vercel.app/api/v1
formatJSON, UTF-8. Lamport amounts are strings.
authnone
CORS*
rate limit120 requests per minute per IP
Try it
curl https://funny-site.vercel.app/api/v1/slots/11
{
"asOf": {
"block": "454237548",
"signature": "2kVJb7rbcQ2iWm8xs9bTXnLrwz1NuUQvHppRKeZ5ugJ9x3Y4aXgvA7LZ1fA5tQPpw9kQhZpNv5M3rCmQyQF1dXyF",
"serverTime": 1791381400,
"indexedAt": 1791381399,
"transactions": 4,
"synced": true,
"error": null
},
"slot": {
"id": 11,
"key": "hero.headline",
"band": "hero",
"kind": "text",
"label": "Hero headline",
"owner": "4Nd1mBQtrMjTzXbzWZzhqgxHx1r5Ve7jt6Wz9v6sQTXz",
"controller": "4Nd1mBQtrMjTzXbzWZzhqgxHx1r5Ve7jt6Wz9v6sQTXz",
"mode": "human",
"price": {
"base": "750000000",
"current": "1470000000",
"last": "1050000000",
"lastSale": "1050000000",
"multiplierBps": 0,
"pendingMultiplier": { "bps": 20000, "effectiveAt": 1791381600 },
"decayWeeks": 0,
"takeSplit": { "owner": "1207500000", "treasury": "262500000" }
},
"lastPurchaseTs": 1791381000,
"cooldownUntil": 1791381900,
"takes": 1,
"version": 3,
"listing": null,
"pendingListing": null,
"rental": null,
"rentFloorPerDay": "3675000",
"quarantined": false,
"content": { "t": "This is fine. I own this now." },
"ai": {
"epoch": 0,
"level": 22,
"meme": false,
"model": "phi",
"content": { "t": "Ship faster. Collaborate smarter. Grow together." },
"style": {}
}
}
}
That piece was bought for 0.75 SOL, taken once for 1.05 SOL, and its owner has asked for a 2× markup that lands at 1791381600. Until then anyone can take it for 1.47 SOL (after the cooldown at 1791381900), and the owner would receive 1.2075 SOL. The owner wrote their own headline, so the AI's paint (still the launch-day corporate copy) is not what visitors see.
Things to know before you build on it
asOf.block is a mirror, not the chain
Every response carries asOf: the Solana slot the indexer last saw, the newest treasury signature it processed, and whether its last poll failed. Without checking it you can't tell a piece nobody has touched in a week from an indexer that stopped an hour ago. Both look like a quiet row. If asOf.error is non-null, the data is the last good state; if asOf.synced is false, the indexer has never reached Solana since it started and ownership may be incomplete.
Lamports are strings
Every SOL amount is a whole number of lamports (1 SOL = 1,000,000,000 lamports) encoded as a decimal string, never a JSON number. Use BigInt (JS), int (Python) or your language's equivalent. Timestamps, ids, counts and basis points are plain JSON numbers.
price.current is advisory
It is what a buyer would pay if their transaction landed right now: the base price for an unclaimed piece, otherwise the decayed take price with the owner's multiplier applied. It moves with the clock (decay, pending multipliers taking effect) and with every purchase. Use /quote with fresh=1 right before you build a transaction, and remember the rules are applied at the block time your transaction actually lands.
content: null means the AI is painting it
content is what a human wrote (the owner's, or the tenant's during a rental). When it is null, the piece is in AI mode (mode: "ai") and ai.content is what the page shows. ai is always present, because the AI always controls the styling (ai.style) even when a human controls the words. To mirror the page: show content ?? ai.content, styled with ai.style.
Pending changes are separate fields
Multiplier and listing changes wait 5 minutes before they apply. The live value is in price.multiplierBps / listing; the queued one is in price.pendingMultiplier / pendingListing with its effectiveAt. The API settles pending changes against the server clock, so once effectiveAt has passed they move into the live fields.
Absent means null, except one
owner, listing, pendingListing, rental, cooldownUntil, price.last, price.takeSplit and friends are simply null when they don't apply. The one raw value is price.multiplierBps: 0 is the stored value meaning 1×, not a 0× multiplier. It is left raw so a value read here can be written straight back into a price memo.
Ids are forever
Piece ids 0 to 124 never change meaning. Memos reference pieces by id. Keys (like hero.headline) are stable too.
Endpoints
All /api/v1 endpoints are GET. Any of them accepts ?fresh=1, which makes the indexer poll Solana before answering (if it hasn't in the last 0.8 seconds) and disables caching on the response. Use it sparingly; it counts against the same rate limit and the same public RPC.
/api/v1/siteThe whole page in one response: every piece, the AI's current epoch and meme level, market stats and the 30 most recent events. This is the one to start with. The board itself is built from this endpoint.
curl https://funny-site.vercel.app/api/v1/site
{
"asOf": { "block": "454237548", "signature": "2kVJb7…", "serverTime": 1791381400, "indexedAt": 1791381399, "transactions": 4, "synced": true, "error": null },
"config": { …the Config object, see /config… },
"ai": { "epoch": 11, "memeLevel": 22, "epochSeconds": 300, "nextEpochAt": 1791381600 },
"stats": { "owned": 1, "volume": "1800000000", "accepted": 3, "rejected": 1 },
"slots": [ …125 Slot objects, in id order… ],
"recent": [ …up to 30 Event objects, newest first… ]
}
| Field | Meaning |
|---|---|
| stats.owned | Pieces with an owner |
| stats.volume | Lamports paid in all accepted buys, takes and rents, ever |
| stats.accepted / rejected | Count of accepted and rejected board transactions |
| slots | All 125 Slot objects; slots[i].id === i |
| recent | The last 30 Events, accepted and rejected, newest first |
/api/v1/slots/:idOne piece, the same Slot object as an entry in /site. :id is 0 to 124. Unknown ids return 404.
curl https://funny-site.vercel.app/api/v1/slots/8
{
"asOf": { … },
"slot": {
"id": 8, "key": "nav.link5", "band": "nav", "kind": "link", "label": "Nav link 5",
"owner": null, "controller": null, "mode": "ai",
"price": { "base": "50000000", "current": "50000000", "last": null, "lastSale": null,
"multiplierBps": 0, "pendingMultiplier": null, "decayWeeks": 0, "takeSplit": null },
"lastPurchaseTs": null, "cooldownUntil": null, "takes": 0, "version": 0,
"listing": null, "pendingListing": null, "rental": null, "rentFloorPerDay": null,
"quarantined": false,
"content": null,
"ai": { "epoch": 3, "level": 22, "meme": false, "model": "claude",
"content": { "t": "Pricing", "u": "#features" }, "style": {} }
}
}
/api/v1/slots/:id/eventsThat piece's full on-chain history, accepted and rejected, newest first.
| Param | Meaning |
|---|---|
| limit | 1 to 200, default 50 |
| cursor | The nextCursor from the previous page. See paging. |
curl "https://funny-site.vercel.app/api/v1/slots/11/events?limit=2"
{
"asOf": { … },
"slot": 11,
"events": [
{
"sig": "2kVJb7rbcQ2iWm8xs9bTXnLrwz1NuUQvHppRKeZ5ugJ9x3Y4aXgvA7LZ1fA5tQPpw9kQhZpNv5M3rCmQyQF1dXyF",
"ts": 1791381300, "block": 454237548,
"action": "price", "slot": 11,
"actor": "4Nd1mBQtrMjTzXbzWZzhqgxHx1r5Ve7jt6Wz9v6sQTXz",
"multiplierBps": 20000, "effectiveAt": 1791381600,
"ok": true, "version": 3
},
{
"sig": "5Rk1pQ3tYbLx8wVz2mHnC4eJ6aF9dG7sK1uN3oP5qR8tW2yX4zA6bC9eD1fH3jK5mN7pQ9rS2tU4vW6xY8zA1bC3",
"ts": 1791381200, "block": 454237511,
"action": "take", "slot": 11,
"actor": "7aXbQ1sZcV2nM4kP9rT6yU8wE3qL5jH1gF2dS4aZ6xCv",
"ok": false,
"reason": "slot is in its 15-minute cooldown",
"paid": {
"4Nd1mBQtrMjTzXbzWZzhqgxHx1r5Ve7jt6Wz9v6sQTXz": "1207500000",
"9xLUBtnozQ3eosq61oSuKjKw9uLGLyybwEHZsMr2b8G": "262500000"
}
}
],
"nextCursor": "5Rk1pQ3tYbLx8wVz2mHnC4eJ6aF9dG7sK1uN3oP5qR8tW2yX4zA6bC9eD1fH3jK5mN7pQ9rS2tU4vW6xY8zA1bC3"
}
/api/v1/eventsEvery board event across all pieces, newest first. The same paging as above, plus filters.
| Param | Meaning |
|---|---|
| limit | 1 to 200, default 50 |
| cursor | From the previous page's nextCursor |
| actor | Only events signed (fee-paid) by this address |
| ok | true for accepted only, false for rejected only |
curl "https://funny-site.vercel.app/api/v1/events?ok=false&limit=200"
{ "asOf": { … }, "events": [ …Event objects… ], "nextCursor": null }
?ok=false is the public list of rejected transactions, each with its reason and exactly what was paid to whom.
/api/v1/owners/:addressWhat one wallet holds and has done. :address must be a valid Solana address, otherwise 400.
curl https://funny-site.vercel.app/api/v1/owners/7aXbQ1sZcV2nM4kP9rT6yU8wE3qL5jH1gF2dS4aZ6xCv
{
"asOf": { … },
"address": "7aXbQ1sZcV2nM4kP9rT6yU8wE3qL5jH1gF2dS4aZ6xCv",
"owned": [],
"controlled": [],
"renting": [],
"spent": "750000000",
"receivedFromTakes": "862500000"
}
| Field | Meaning |
|---|---|
| owned | Ids of pieces this address owns |
| controlled | Ids of pieces this address can edit right now (owned and not rented out, or rented by it) |
| renting | Ids of pieces this address is currently a tenant of |
| spent | Lamports this address paid in accepted buys, takes and rents. Rejected transactions are not counted. |
| receivedFromTakes | Lamports this address received when its pieces were taken. Rent income is not included. |
/api/v1/quote/:idThe exact transfers a transaction needs for an action to be accepted if it landed right now, plus a memo template. This is what the board calls right before asking your wallet to sign. Never cached.
| Param | Meaning |
|---|---|
| action | buy (default), take, edit, price, list, rent or give |
| actor | The wallet that will sign and pay the fee. Needed for every action except buy: the rules depend on who you are (you can't take your own piece; only the owner can edit). Defaults to the System Program address. |
| days | For rent: 1 to 7, default 1 |
| fresh | 1 to re-poll Solana first. Recommended. |
curl "https://funny-site.vercel.app/api/v1/quote/11?action=take&actor=7aXbQ1sZcV2nM4kP9rT6yU8wE3qL5jH1gF2dS4aZ6xCv&fresh=1"
{
"asOf": { … },
"action": "take",
"slot": 11,
"version": 3,
"transfers": [
{ "to": "4Nd1mBQtrMjTzXbzWZzhqgxHx1r5Ve7jt6Wz9v6sQTXz", "lamports": "1207500000" },
{ "to": "9xLUBtnozQ3eosq61oSuKjKw9uLGLyybwEHZsMr2b8G", "lamports": "262500000" }
],
"memo": "tinam1:{\"a\":\"take\",\"s\":11}",
"memoProgram": "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr"
}
memo is a template with only a, s (and d for rent). Add the action's other fields yourself (c, m, r, to; see the memo spec) before sending. Free actions return a single 0-lamport transfer to the treasury; include it, because it is how the indexer finds your transaction.
If the action isn't allowed right now, you get 409 with the same reason the indexer would reject it with:
{ "asOf": { … }, "action": "take", "slot": 11, "error": "slot is in its 15-minute cooldown" }
If the indexer's last poll failed, you get 503 instead of a quote that might be stale. Retry after a few seconds.
/api/v1/aiThe AI's current epoch and meme level, and the council log for the last 8 epochs (newest first). The log marks pieces whose words are controlled by a human as kind: "owned", with text: null.
curl https://funny-site.vercel.app/api/v1/ai
{
"asOf": { … },
"epoch": 6,
"epochSeconds": 300,
"memeLevel": 22,
"epochStartedAt": 1791379800,
"nextEpochAt": 1791380100,
"council": [
{
"epoch": 6, "slot": 0, "model": "granite", "kind": "corp",
"line": "announcement bar back on-brand. Grok, stop.",
"text": "New: quarterly roadmap, now in dark mode",
"at": 1791379800
},
{
"epoch": 6, "slot": 20, "model": "kimi", "kind": "corp",
"line": "Restoring brand guidelines on the logo 2. Someone has to.",
"text": "Wayne",
"at": 1791379800
}
]
}
The AI is deterministic: you can compute any piece's paint at any epoch yourself with paint(id, epoch) from /lib/ai.js, and the epoch with epochAt(unixTime, config.launchTs). This endpoint and the ai field on every Slot are a convenience.
/api/v1/configNetwork, addresses and every rule constant. Doesn't touch the indexer, so it never fails because of the RPC. Cached for 5 minutes.
{
"name": "funny site",
"network": "mainnet-beta",
"treasury": "9xLUBtnozQ3eosq61oSuKjKw9uLGLyybwEHZsMr2b8G",
"protocol": "tinam1",
"memoProgram": "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr",
"launchTs": 1791378000,
"rpcProxy": "/api/rpc",
"slots": 125,
"rules": {
"floor": "5000000",
"takeMultiplier": 1.4,
"ownerPayoutMultiplier": 1.15,
"cooldownSeconds": 900,
"pendingDelaySeconds": 300,
"decayPerWeek": 0.9,
"decayMaxWeeks": 52,
"multiplierBps": { "min": 1000, "max": 40000 },
"rentFloorBpsPerDay": 25,
"rentFeeBps": 3500,
"rentMaxDays": 7,
"aiEpochSeconds": 300
}
}
/api/rpcA thin JSON-RPC proxy to the site's Solana RPC node, so browser clients can build and send transactions without bringing their own RPC. It forwards one request at a time (no batches) and only these methods:
curl -X POST https://funny-site.vercel.app/api/rpc \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"getLatestBlockhash","params":[{"commitment":"confirmed"}]}'
The response is the upstream node's response, status code and all. Any other method returns 400 with the allowed list; a non-POST returns 405. The proxy shares the 120 requests/minute limit. It is a convenience for this site's board: for anything heavy, use your own RPC provider.
Objects
Slot
| Field | Type | Meaning |
|---|---|---|
| id | number | 0 to 124, stable forever |
| key | string | Stable dotted name, e.g. hero.headline |
| band | string | Page section: announcement, nav, hero, logos, ticker, features, stats, gallery, quotes, pricing, faq, final, footer |
| kind | string | text, long, link, sticker or panel. Decides the content shape. |
| label | string | Human name, e.g. "Hero headline" |
| owner | address | null | Current owner; null if unclaimed |
| controller | address | null | Who can edit right now: the tenant during a rental, else the owner |
| mode | string | human if content is set, else ai |
| price.base | lamports | Fixed price of the piece when unclaimed |
| price.current | lamports | What a buy or take costs right now (advisory) |
| price.last | lamports | null | What the current owner paid |
| price.lastSale | lamports | null | The most recent buy or take price (same as last today; kept separate for future sale types) |
| price.multiplierBps | number | Live asking-price multiplier. 0 = 1×; otherwise 1000 to 40000 |
| price.pendingMultiplier | object | null | { bps, effectiveAt }, a queued multiplier change. bps: 0 = back to 1× |
| price.decayWeeks | number | Full stale weeks applied to the price (0 to 52) |
| price.takeSplit | object | null | { owner, treasury } lamports a take would pay right now; null if unclaimed |
| lastPurchaseTs | number | null | Block time of the last buy or take |
| cooldownUntil | number | null | Until when the piece can't be taken; null when not in cooldown |
| takes | number | How many times it has been taken |
| version | number | Increments on every accepted event on this piece |
| listing | object | null | { ratePerDay, since } if listed for rent |
| pendingListing | object | null | { ratePerDay, effectiveAt }, a queued listing change. ratePerDay: "0" = delisting |
| rental | object | null | { tenant, from, until, ratePerDay } during a tenancy |
| rentFloorPerDay | lamports | null | Minimum daily rate the owner could list at right now |
| quarantined | boolean | Moderated by the operator. If true, content is null and the page shows the AI's paint |
| content | object | null | Human content in the shape for kind (see below); null = AI mode |
| ai | object | The AI's paint right now: { epoch, level, meme, model, content, style } |
Content shapes, by kind:
| kind | shape | notes |
|---|---|---|
| text | { t } | up to 80 characters |
| long | { t } | up to 200 characters |
| link | { t, u } | label up to 40; u is an https URL (the AI may use relative links like #faq) |
| sticker | { e, t? } | emoji; optional label up to 24 |
| panel | { s, b, f, k } | subject emoji, background name, filter name, sticker array (0 to 4) |
ai fields: epoch is the epoch the AI last repainted this piece (not the current epoch); level is the meme level at that repaint; meme is whether it was a meme repaint; model is the persona id (see /lib/palette.js); style may contain font ("impact" | "comic"), rot (degrees), tone ("hot" | "acid" | "ink" | "sky") and wobble (true). An empty style means plain.
Event
One board transaction, accepted or rejected. Fields beyond the common ones appear only when relevant.
| Field | Type | Meaning |
|---|---|---|
| sig | string | Transaction signature. Also the paging cursor. |
| ts | number | Block time |
| block | number | Solana slot |
| action | string | buy | take | edit | price | list | rent | give |
| slot | number | Piece id |
| actor | address | Fee payer of the transaction |
| ok | boolean | Accepted (true) or rejected (false) |
| version | number | Accepted only: the piece's version after this event |
| reason | string | Rejected only: why |
| paid | object | Rejected only: { address: lamports } the actor actually sent |
| price | lamports | buy / take: price paid. rent: total rent. |
| from | address | take: previous owner |
| ownerPaid | lamports | take: what the previous owner received |
| mode | string | edit: human or ai (handed back) |
| multiplierBps | number | price: the requested multiplier |
| rate | lamports | list: the requested daily rate ("0" = delist) |
| effectiveAt | number | price / list: when the change applies |
| days | number | rent: term length |
| to | address | give: recipient |
| note | string | Accepted with a caveat, e.g. "content ignored: links must start with https://" |
Events don't include content. Read the memo from the transaction itself, or the piece's current content.
Quote
| Field | Type | Meaning |
|---|---|---|
| action | string | The action quoted |
| slot | number | Piece id |
| version | number | The piece's version the quote was computed against |
| transfers | array | [{ to, lamports }], one entry per recipient. Send at least these amounts, from the fee payer, as top-level System Program transfers. |
| memo | string | Memo template, e.g. tinam1:{"a":"take","s":11} |
| memoProgram | address | The SPL Memo program id |
| error | string | Only on 409 / 503 |
AI
| Field | Type | Meaning |
|---|---|---|
| epoch | number | Current epoch (0 at launch, +1 every 300 s) |
| epochSeconds | number | 300 |
| memeLevel | number | 0 to 100: chance (%) a repaint this epoch is a meme |
| epochStartedAt / nextEpochAt | number | Unix times of this epoch's start and the next repaint |
| council[] | array | { epoch, slot, model, kind, line, text, at }; kind is meme, corp or owned; text is the new words or emoji (null for owned pieces) |
Config
See /config above. rules.floor is lamports (string); the multipliers are plain numbers; multiplierBps, rentFloorBpsPerDay and rentFeeBps are basis points (1/100 of a percent).
asOf
| Field | Type | Meaning |
|---|---|---|
| block | string | Latest Solana slot the indexer has seen ("0" if it has never reached the RPC) |
| signature | string | null | Newest treasury signature processed |
| serverTime | number | Server clock. All derived values (decay, cooldown, pending changes, AI epoch) are computed at this time. |
| indexedAt | number | When the indexer last polled |
| transactions | number | Board transactions replayed (accepted + rejected) |
| synced | boolean | Whether the indexer has completed at least one poll since it started |
| error | string | null | Why the last poll failed, if it did. Data is the last good state. |
Paging through history
Follow nextCursor until it comes back null. The cursor is the signature of the last event on the page; pass it back verbatim. Don't page on ts: events in the same block share it.
const ORIGIN = 'https://funny-site.vercel.app';
let cursor = null;
const all = [];
do {
const url = new URL(`${ORIGIN}/api/v1/slots/11/events`);
url.searchParams.set('limit', '200');
if (cursor) url.searchParams.set('cursor', cursor);
const page = await (await fetch(url)).json();
all.push(...page.events);
cursor = page.nextCursor;
} while (cursor);
console.log(all.length, 'events', all.filter((e) => !e.ok).length, 'rejected');
An unknown cursor returns 400 { "error": "unknown cursor" }.
Building a transaction yourself
A board action is a normal transaction: the transfers from /quote, then one memo instruction, signed by the wallet that pays. The fee payer is the actor, so it must be the same wallet that sends the transfers.
import { Connection, PublicKey, SystemProgram, Transaction, TransactionInstruction } from '@solana/web3.js';
const ORIGIN = 'https://funny-site.vercel.app';
const connection = new Connection('https://YOUR-RPC-PROVIDER', 'confirmed');
const me = wallet.publicKey; // any wallet adapter / Keypair
// 1. ask for the exact transfers, against fresh chain state
const q = await (await fetch(`${ORIGIN}/api/v1/quote/11?action=take&actor=${me}&fresh=1`)).json();
if (q.error) throw new Error(q.error);
// 2. the memo: the template's fields plus yours
const body = { a: 'take', s: 11, c: { t: 'This is fine. I own this now.' } };
const memo = 'tinam1:' + JSON.stringify(body);
if (new TextEncoder().encode(memo).length > 600) throw new Error('memo too long');
// 3. transfers first, memo last
const tx = new Transaction();
for (const t of q.transfers) {
tx.add(SystemProgram.transfer({ fromPubkey: me, toPubkey: new PublicKey(t.to), lamports: BigInt(t.lamports) }));
}
tx.add(new TransactionInstruction({
programId: new PublicKey(q.memoProgram),
keys: [{ pubkey: me, isSigner: true, isWritable: false }],
data: Buffer.from(memo, 'utf8'),
}));
// 4. sign and send
const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash();
tx.feePayer = me;
tx.recentBlockhash = blockhash;
const sig = await wallet.sendTransaction(tx, connection);
await connection.confirmTransaction({ signature: sig, blockhash, lastValidBlockHeight }, 'confirmed');
// 5. ask the indexer what it made of it
for (let i = 0; i < 10; i++) {
const { events } = await (await fetch(`${ORIGIN}/api/v1/slots/11/events?limit=20&fresh=1`)).json();
const ev = events.find((e) => e.sig === sig);
if (ev) { console.log(ev.ok ? 'accepted' : `rejected: ${ev.reason}`); break; }
await new Promise((r) => setTimeout(r, 2000));
}
fresh=1 immediately before signing, keep the time between quote and send short, and check the result. See rejected transactions.Rules of thumb:
- Validate content locally before sending with
validateContent(kind, c)from/lib/protocol.js. For an edit, invalid content means a rejected transaction. - Use plain top-level
SystemProgram.transferinstructions. Transfers via CPI,transferWithSeed, wrapped SOL or tokens are not counted. - Overpaying is accepted but not refunded. Underpaying by one lamport is a rejection.
- Only the first memo with a valid
tinam1:body counts; anything after the JSON in that memo makes it unparseable, and an unparseable memo makes the whole transaction invisible to the board. - Priority fees (Compute Budget instructions) are fine; add them as you like.
Examples
curl: the 10 most expensive pieces right now
curl -s https://funny-site.vercel.app/api/v1/site \
| jq -r '.slots | sort_by(.price.current | tonumber) | reverse | .[:10][]
| "\(.id)\t\(.key)\t\(.price.current | tonumber / 1e9) SOL\t\(.owner // "unclaimed")"'
JavaScript: render the page as text, the way visitors see it
const { slots } = await (await fetch('https://funny-site.vercel.app/api/v1/site')).json();
for (const s of slots) {
const c = s.content ?? s.ai.content; // human words win, else the AI's
const shown = c.t ?? c.e ?? `${c.s} on ${c.b}`; // text, sticker or panel
console.log(`#${s.id} ${s.key.padEnd(22)} ${s.mode === 'human' ? 'human' : s.ai.model.padEnd(10)} ${shown}`);
}
JavaScript: watch for takes
let seen = new Set();
setInterval(async () => {
const { events } = await (await fetch('https://funny-site.vercel.app/api/v1/events?ok=true&limit=50')).json();
for (const e of events.reverse()) {
if (seen.has(e.sig)) continue;
seen.add(e.sig);
if (e.action === 'take') console.log(`${e.actor} took #${e.slot} for ${Number(e.price) / 1e9} SOL; ${e.from} got ${Number(e.ownerPaid) / 1e9}`);
}
}, 10_000);
Python: a wallet's portfolio
import requests
BASE = "https://funny-site.vercel.app/api/v1"
addr = "7aXbQ1sZcV2nM4kP9rT6yU8wE3qL5jH1gF2dS4aZ6xCv"
me = requests.get(f"{BASE}/owners/{addr}", timeout=10).json()
site = requests.get(f"{BASE}/site", timeout=10).json()
slots = {s["id"]: s for s in site["slots"]}
for i in me["owned"]:
s = slots[i]
paid, now = int(s["price"]["last"]), int(s["price"]["takeSplit"]["owner"])
print(f'#{i} {s["label"]}: paid {paid / 1e9:.4f} SOL, a take now pays you {now / 1e9:.4f} SOL')
print("spent", int(me["spent"]) / 1e9, "SOL; received from takes", int(me["receivedFromTakes"]) / 1e9, "SOL")
Python: is a piece takeable right now?
import requests, time
BASE = "https://funny-site.vercel.app/api/v1"
r = requests.get(f"{BASE}/quote/11", params={"action": "take", "actor": "YOUR_ADDRESS", "fresh": 1}, timeout=10)
if r.status_code == 200:
total = sum(int(t["lamports"]) for t in r.json()["transfers"])
print("takeable for", total / 1e9, "SOL")
else:
print("not now:", r.json()["error"])
Rate limits, caching & CORS
- 120 requests per minute per IP, across all of
/api/v1and/api/rpc. Every response carriesX-RateLimit-Limit,X-RateLimit-RemainingandX-RateLimit-Reset(unix seconds). Over the limit you get429withRetry-After. The limiter is per server instance, so treat it as a ceiling, not a guarantee. - Caching: successful reads are
s-maxage=5, stale-while-revalidate=25at the edge;/configis cached for 5 minutes;/quote,/api/rpc, errors and anything with?fresh=1areno-store. The indexer itself polls Solana at most every 3 seconds. Polling/sitemore often than every 5 seconds mostly buys you the same bytes. - CORS is open (
Access-Control-Allow-Origin: *, methodsGET, POST, OPTIONS), so browser apps work without a proxy. - The board's indexer uses a Solana RPC node that may itself be rate limited. When it is, responses still come back (
200) withasOf.errorset and the last good state.
Errors
Errors are JSON with an error string, and are never cached.
| Status | When |
|---|---|
| 400 | Bad input: unknown cursor, not a Solana address, invalid JSON or disallowed method on /api/rpc |
| 404 | Unknown route or piece id. The body lists the endpoints. |
| 405 | Non-GET on /api/v1, non-POST on /api/rpc |
| 409 | /quote: the action isn't allowed right now. error is the rule that would reject it. |
| 429 | Rate limited. Wait for Retry-After seconds. |
| 502 | Unexpected server or upstream failure |
| 503 | /quote: the indexer is lagging, so it won't quote. Retry shortly. |
What is and isn't stable
/api/v1 is a versioned surface: fields get added, existing ones don't change meaning or disappear. Piece ids and keys are permanent. The memo format tinam1 is permanent; a new format would get a new tag. /api/rpc exists for this site's board and may change its method list.
Anything else under /api/ is internal plumbing and will change without warning. If you found an endpoint that isn't on this page, it isn't for you to build on.
Moderated content goes through the same gate as the page: a quarantined piece reports quarantined: true and content: null. There is no way to read suppressed content through this API, which is deliberate. It is still in the public memo on Solana.
Read-only and free. If you build something with it, we'd love to see it.
funny site