Public API
Base URL: https://hartiigallery.com. All endpoints are functions/api/** Cloudflare Pages
Functions. Responses are JSON unless noted. Examples below are trimmed real responses captured
live against production.
Service failures return an unavailable status with Cache-Control: no-store and fixed public copy, rather than provider exceptions. Public error codes, valid retry deadlines and transaction recovery facts remain available. Clients should map known codes to their own messages and use a safe fallback for unknown errors; do not display an exception or upstream response verbatim. JSON-RPC retains its request identifiers, numeric error codes and genuine revert bytes for decoding. A failed read is distinct from a successful empty response.
GET /api/collections
The whole catalogue in one response — factory-deployed collections plus externally-listed ones, each enriched with live floor price and mint stats.
{
"network": "mainnet",
"collections": [
{
"address": "0x000b37b2f7eac95224b81cc10f55de5db7d016d6",
"factory": "0x00265a93d7Bf94147d88e27EA28A2AC9dAd9eF04",
"name": "First",
"symbol": "FRST",
"totalSupply": 1,
"totalMinted": 1,
"maxSupply": 1,
"mintPrice": "1000000000000000000",
"creator": "0x0055555555555555555555555555555555555555",
"creatorThreshold": "1000000000000000000",
"creatorEarned": "950000000000000000",
"isPhaseTwo": false,
"royaltyBps": 100,
"coverImage": "/api/img?u=ipfs%3A%2F%2FQm...",
"marketStats": null,
"sampleItems": [{ "tokenId": "0", "name": "First #0", "image": "/api/img?u=..." }]
}
]
}Caching (Gallery 2.25.0 and later): a full answer sends Cache-Control: max-age=10, s-maxage=30, so the
edge keeps it for 30 seconds and a browser may reuse it for 10 (this is what lets the page reuse its own
preload of this endpoint). An answer built while the index is still filling sends
s-maxage=5, stale-while-revalidate=15. An edge hit carries X-Cache: HIT and no Age header. Treat the
header on the actual response you get as authoritative. Field availability is a whitelist on the frontend side
(useCollections) — a new field added here needs adding there too before it's used.
GET /api/floor-prices
Live floor per collection, computed from on-chain marketplace state (not the KV index).
{
"collections": {
"0x0010e039dfd4d44681cc984121dfd42a001ba45a": {
"floor": 6666.6666,
"floorWei": "6666666600000000000000",
"count": 1
}
},
"totalListings": "…",
"partial": false,
"failedMarketplaces": []
}floorWei is the exact on-chain value (string, to avoid float precision loss); floor is the
display-rounded float derived from it. Honesty contract: if all marketplace reads fail, the
endpoint returns 503 { error, partial: true } rather than a fabricated empty 200; a partial
failure sets partial: true and lists which venues failed in failedMarketplaces instead of
silently omitting their collections.
GET /api/sales
Durable sales history, backed by a D1 table fed by a discovery script — not a live getLogs
scan, so it has complete history rather than just a recent window.
| Param | Meaning |
|---|---|
limit | page size |
collection | filter to one collection address |
cursor | opaque pagination cursor from a previous response |
summary=1 | (with collection) return aggregate stats instead of a list |
{ "network": "mainnet", "collection": null, "count": 0, "sales": [], "nextCursor": null }Honesty contract: a store/read failure is 503 { error }, never a 200 with an empty array —
"no sales yet" and "could not read" are distinguishable.
NFT index API
Gallery 2.10.0 adds /api/nft/status and recovery metadata. indexing carries a state of live, catching-up, paused, stale, degraded or unknown, plus an optional reason and retryAt. A paused scope of background means history and maintenance have paused while head and sales reads can continue; all means the daily hard limit has stopped live reads too. A recent heartbeat does not imply a caught-up head: live requires the read head to be within the index's 20-block confirmation margin. Marketplace sales have their own 60-block confirmation margin; a known sales-scan failure or additional sales lag also prevents a current-state claim. History completeness and freshness are separate facts.
GET /api/nft/status returns shared metadata without scanning tokens or owners. It is a public read with a 30-second response cache. Successful public NFT responses use an explicit edge cache partitioned by URL/query, network and released version; X-Hartii-Index-Cache is hit or miss. An authorization-bearing request bypasses that cache. Error responses remain no-store.
Gallery 2.12.5: reads never hang. Each index read gives up after 6 seconds. If a read fails or times out, the API returns the last good answer for that exact request (kept at the edge for up to 24 hours) with X-Hartii-Index-Cache: stale and Cache-Control: no-store; the body's own asOfBlock/asOfTime fields show how old it is. Only when no good copy exists does it answer 503 index-unavailable. Client errors (4xx) are never replaced by a stale copy. GET /api/health caps every store read at 4 seconds and reports a stuck store as status: "error" instead of not answering.
When index capacity is temporarily exhausted, the response is HTTP 503 with error: "index-unavailable", reason: "capacity", an ISO retryAt, and a Retry-After header in seconds. Other availability failures use reason: "offline". Neither means that a collection has zero items or that no sale occurred. Clients should retain dated records only for the same query, respect the recovery deadline, and let a user check again. Initial route warmup, malformed responses and interrupted updates are distinct states.
/api/nft/* is a read API over an index of every ERC-721 and ERC-1155 collection on Quai
(Cyprus-1): every token, owner and transfer, read straight from the chain by a dedicated indexer.
It is separate from /api/collections and /api/sales. The home page's rooms and the market rail
read it. Every endpoint is GET, public and JSON, with Access-Control-Allow-Origin: *.
Rules for every response:
- Wei values and token ids are decimal strings. Addresses are lowercase
0x…. - Unknown is
null, never0. A missing value is never rounded to zero and a failed read is never an empty list. - Every successful response starts with the same metadata fields:
| Field | Meaning |
|---|---|
network | mainnet or orchard |
asOfBlock | The block the index has followed up to. null before its first read. |
asOfTime | ISO time of the last verified head read (older indexers fall back to their heartbeat time), or null. A budget-pause heartbeat does not redate old records. |
lagBlocks | How many blocks the chain tip was ahead of asOfBlock at that heartbeat, or null. |
history | { complete, readToBlock, targetBlock, percent }, see below. |
history says how much of Quai's earlier NFT history has been read. complete is true once every
block before the index's starting point has been read. readToBlock is how far the history read
has got (it can be null). targetBlock is the last block the history read must reach (null
while that is not known yet). percent runs from 0 to 100 with one decimal and is 100 when
complete. Until complete is true, treat network totals as incomplete. Quiet Gallery rooms
hang real collection art and collection-scoped figures while network totals are unavailable.
Freshness (since Gallery 2.9.3): on the current Cloudflare plan the indexer runs every 3 minutes, so a
new transfer or sale appears within about 3 minutes plus edge cache. The indexer keeps to a daily
database budget so it never crowds out listings and sales: at half of it, history reading pauses and
new blocks keep coming; at 70%, the indexer stops until 00:00 UTC, asOfBlock stops moving, and
/api/health shows heartbeats["nft-index"].paused with the reason.
| Endpoint | Query | Returns | Edge cache |
|---|---|---|---|
/api/nft/activity | limit, cursor, kind | items, nextCursor | 15 s |
/api/nft/status | none | shared metadata and indexing | 30 s |
/api/nft/stats | none | totals, totalsAsOfBlock, day, topCollectors | 60 s |
/api/nft/spotlights | limit | items | 60 s |
/api/nft/collections/:addr | none | collection, recent | 60 s |
/api/nft/tokens/:addr/:id | none | token, history | 300 s |
/api/nft/owners/:addr | limit, cursor | owner, holdings, nextCursor | 60 s |
/api/nft/search | q | collections, tokens | 60 s |
A 200 carries Cache-Control: public, s-maxage=<seconds> with the time in the last column.
Errors carry no-store. limit is capped at 100; a missing or invalid value uses the default (20
for activity, 50 for owners). spotlights takes 1 to 24 (default 6).
GET /api/nft/activity
Transfers newest first. kind omitted means every transfer. kind=sale means marketplace buys and
sells only. Any other kind is 400 with error: "unknown-kind".
curl -s 'https://hartiigallery.com/api/nft/activity?limit=20'
curl -s 'https://hartiigallery.com/api/nft/activity?kind=sale&limit=20'Each item:
| Field | Meaning |
|---|---|
kind | mint, transfer, burn or sale. A transfer in a transaction that matches a recorded sale is a sale. |
contract, collectionName | The collection address, and its name or null. |
standard | "721" or "1155". null on a sale row whose collection the index does not know yet. |
tokenId, amount | The token id, and the quantity moved (both strings). |
from, to | Addresses. For a sale, from is the seller and to the buyer. |
priceWei | The price for a sale, otherwise null. |
marketplace | Only on kind=sale rows: the marketplace contract address, or null. |
txHash, logIndex, block, blockTime | Where it happened. blockTime is an ISO time or null. |
image | The token image, or null. |
Page with nextCursor: pass it back as cursor unchanged and stop when it is null. A page can
hold fewer than limit rows. A malformed cursor is 400.
kind=sale works while history is still being read. Sales are recorded by the index itself from
block 7,640,402, the first block with code of the oldest Hartii marketplace contract, so prices and
volumes come straight from the chain.
GET /api/nft/stats
| Field | Meaning |
|---|---|
totals.nfts, totals.owners | From a periodic snapshot. null until a snapshot exists. |
totals.collections | Collections in the index. |
totals.transfers | Every transfer in the index. |
totalsAsOfBlock | The block the snapshot was taken at. null until a snapshot exists. |
topCollectors | Up to 10 of { address, count, collections } from the same snapshot. Empty until a snapshot exists. |
day.sales, day.volumeWei | Marketplace sales in the last 24 hours, and their summed price. null when they cannot be summed honestly. |
day.mints, day.transfers | The last 24 hours. null while recent transfers are still missing their block time. |
The snapshot is taken only once history.complete is true, so totals.nfts, totals.owners,
topCollectors and totalsAsOfBlock are null (or empty) until then. Before the index has read
any block (asOfBlock is null), every totals and day value is null.
GET /api/nft/spotlights
items are the busiest collections (by transfers, at least one), taken from a pool three times the
size of limit and rotated by the current UTC day. Each item has contract, name, symbol,
standard, items, owners, floorWei, vol24Wei, volTotalWei, image and up to four
sampleImages. floorWei is null for every collection: the index has no floor source.
Collection, token, owner and search
GET /api/nft/collections/:addrreturnscollection(the spotlight fields plusdeployBlockandtransfers) andrecent, the latest 20 activity items.404if the collection is not in the index.GET /api/nft/tokens/:addr/:idreturnstoken(contract,tokenId,standard,owner,supply,name,image,uri,mediaStatus) andhistory, up to 50 activity items.ownerisnullfor ERC-1155, andsupplyis"1"for ERC-721.400if the id is not a decimal number;404if the token is not in the index.GET /api/nft/owners/:addrreturnsownerandholdings(contract,tokenId,amount,name,image).nextCursorhas the form<contract>:<tokenId>.GET /api/nft/search?q=needs at least 2 characters. It returns up to 10collections(by name, symbol or full address; most transfers first) and up to 10tokens(by media name or token id). An address query returns collections only.
An address in a path that is not 0x plus 40 hex characters is 400.
Failure
If the index cannot be read, the answer is 503 with { "error": "index-unavailable", "message": "…" }
and Cache-Control: no-store. It is never an empty list and never a zero.
Excluded contracts
Quai Name Service registrations are not art. The four known name-service contracts, and any
contract whose name matches "name service" or whose symbol is QNS or QNNS, are never indexed,
never returned by any /api/nft/* endpoint and hidden from the collection wall.
Test collections
Test and sandbox collections (a reviewed address list plus the exact placeholder names Test,
Test1, Test NFT2 and TestingEdition) are left out of GET /api/collections, the sales feed,
the hall corridor list and the counts in GET /api/nft/stats. Unlike name-service contracts they
are still indexed, and their collection pages open by direct URL.
GET /api/studio/world
A machine-readable, versioned map of the Studio for AI agents. Public, no key, no D1 read for the base
document. Cache-Control: public, max-age=300, s-maxage=3600 (edge copy keyed by release).
{ "schema": "hartii-studio-world", "schemaVersion": 1, "appVersion": "2.27.0",
"network": { "name": "Quai mainnet", "chainId": 9, "zone": "Cyprus-1" },
"about": {…}, "rooms": {…}, "deepLinks": […], "readApis": […], "writeApi": {…},
"presence": {…}, "etiquette": {…}, "links": {…} }Top-level keys: about (what the Studio is, the self-describing surfaces /api/studio/world, the on-page text layer and
window.hartiiStudio, and that nothing here signs), rooms (hall, space:<checksummed address>, col:<lowercase address>),
deepLinks (each /studio URL with its validation rules), readApis (/api/studio/hall-art scopes,
/api/studio/works, /api/collections), writeApi (POST /api/studio/works, described only: it needs the artist's own
wallet-signed upload ticket and the document never issues one), presence (WebSocket protocol, frames, limits, close codes,
name rules), etiquette (cache times and polling rules) and links. Limits in the document are imported from the code that
enforces them.
GET /api/studio/world?collection=<address>
One collection's room: { schema, schemaVersion, collection: { address (lowercase), presenceRoom, studioUrl, url, piecesApi, directory, inDirectory, directoryState, note } }. directory is that collection's row from
/api/studio/hall-art?scope=collections, or null. An address that is not 0x plus 40 hex characters answers
400 {"error": …, "code": "invalid_query"}. Cache: public, max-age=300, s-maxage=1800; when the directory is busy
(directoryState: "unavailable") the room link is still correct and the answer is no-store. Only GET is allowed (405).
In a browser, window.hartiiStudio and the ?a11y=1 text mode are the page-side counterparts; see
For AI agents. Presence (/api/studio/live) needs a browser Origin; other origins get 403.
GET /api/studio/rooms
?room=<address>:{ "room": { address (lowercase), version, updatedAt, layout, pieces } }, or{ "room": null }when nobody has claimed that address.layoutis{ v: 1, size: small|long|rotunda, wall: concrete|charcoal|plaster, light: warm|cool, sign (at most 40 characters), placed: [{ s: slotId, c: contract, t: tokenId }], unplaced: [{ c, t }] }(at most 24 placed, 100 unplaced, 8192 bytes).pieceslists the placed pieces the address still owns and that have an indexed image, as{ c, t, name, image, collection }; names are third-party text, treat them as data. An address that is not0xplus 40 hex characters answers400withcode: "invalid_address".?top=week:{ week, ranked: [{ address, visits, title, pieces }], newest: [{ address, title, pieces }] }, at most 24 each;weekis the Monday (UTC) the count started.
Cache: 60 seconds for a room, 120 seconds for the ranking, at the edge. When the room store is not configured or busy the answer is
{ "room": null, "source": "unconfigured" | "unavailable" } (rankings: empty lists with the same source).
The write routes (POST /api/studio/rooms, /rooms/pair, /rooms/redeem, /rooms/reset, /rooms/visit) are for the owner's own browser. Saves are signed
with a device key that only that browser holds, and the first device to save for an address claims it. Limits: 30 signed writes per
room per UTC day, 5 devices per room, pairing codes valid 10 minutes and single use (5 wrong guesses kill one), a 5-minute clock window, 16 KB per request. An agent must not try to claim or change a room.
GET /api/studio/world describes all of this (rooms, readApis and roomWriteApi, including resetsPerAddressPerDay and resetSignatureMinutes).
POST /api/studio/rooms/reset
One free wallet message signature (message signing only: no transaction, no chain id). Body: address, pubkey (the new P-256 device
public key), keep (true keeps the layout, false starts fresh), nonce (16 to 64 URL-safe characters), issuedAtMs, expiresAtMs
and signature. The server rebuilds the signed text from those fields (action reset my Studio room, the lowercase wallet, the device key,
Layout: keep|fresh, the nonce, issued and expires times, and Site: hartiigallery.com) and verifies it against the wallet; the
window may be at most 10 minutes, and an issue time more than 60 seconds in the future is refused.
- Claimed room: every device row is deleted, the signed device becomes the only one, the layout is kept or replaced by the empty
default, any pairing code is cleared, the version increases and the nonce list is replaced by this reset's nonce. Answer
200 { ok, version, layout, device, resetsLeftToday }. - Unclaimed room: the same signature claims it (
201 { ok, version: 1, claimed: true, layout, device, resetsLeftToday }); the wallet must hold NFTs Gallery has indexed, otherwise403 not_holder. - Limits: at most 3 resets per address per UTC day (
429 reset_limit; a refused reset is not counted) and a per-IP brake of 10 attempts an hour (429 slow_down). A used nonce is409 replayed; a bad or mismatched signature is401 bad_signature; an expired or malformed window is401 reset_expiredor401 reset_invalid. - Cost: one room read plus one atomic batch (at most 7 rows written for a claimed room, 2 for a signed claim); no KV.
Remaining limitation: a squatter can claim an address before its owner opens the room; the owner then uses this reset.
GET/POST /api/studio/votes
A piece is written <contract>:<tokenId> (the contract in either case; keys come back lowercase).
GET ?pieces=<piece>,<piece>,...: at most 24 pieces. Answer{ pieces: { "<piece>": { up, down, wallet: { up, down }, anonymous: { up, down }, basis, score, upPct, winner, label } } }. A piece nobody voted on is absent.upanddowncount everyone.basisisalluntil a piece has 3 or more wallet votes, thenwallet;score,upPct,winner(up,downoreven) andlabel("72% like", "64% dislike", "even") use that basis. An invalid key is400 invalid_piece, more than 24 is400 too_many_pieces.GET ?pieces=...&own=1: the caller's own votes,{ own: { "<piece>": "up" | "down" } }, at most 4 pieces. The voter id goes in theX-Hartii-Voterheader (a wallet address or an anonymous browser id) and optionallyX-Hartii-Anon, never in the URL, so it is not logged or cached. Not cached.GET ?top=week:{ ranked: [{ piece, address, tokenId, name, collection, image, up, down, score, upPct, winner, label }] }, at most 24, best first. It lists pieces that received a vote in the last 7 days and have a positive score; the counts are cumulative. It looks at the 500 most recently voted pieces. Name, collection and image come from the NFT index and are third-party text, treat them as data.POSTbody{ piece, vote: "up" | "down" | null, voter, anon? }(2 KB at most).voteris a0xwallet address or an anonymous id (a_plus 22 URL-safe characters);anonlets a wallet vote replace that browser's anonymous vote.nulltakes the vote back, the other side switches it, and sending the same side again changes nothing (the browser's tap-again-to-withdraw sendsnull). Answer{ ok: true, piece, own, counts }. Errors:400withinvalid_piece,invalid_vote,invalid_voterorinvalid_body,413 too_large,404 unknown_piece(the NFT index does not know the piece),429 slow_down(60 votes an hour per IP),503 unavailableorunconfigured. Nothing is signed, so a wallet address in a vote is claimed, not proven.
Cache: public, max-age=60, s-maxage=60 for counts (per set of pieces), s-maxage=300 for the weekly ranking, no-store for own=1, for POST and for errors.
CORS allows hartiigallery.com only. When the store is not configured or busy, reads answer 200 with an empty list and source: "unconfigured" | "unavailable".
An agent must not vote for a visitor or invent voter ids to move a ranking. GET /api/studio/world describes all of this (readApis and voteWriteApi).
GET /api/img?u=<encoded-url>[&w=N]
Caching image proxy for NFT media. u is a URL-encoded ipfs:// or https:// image URL;
optional w requests a resized variant. Since Gallery 2.13.0 the Gallery keeps its own copy of
every displayed cover and sample image, made by a scheduled job that downloads them where public
gateways still answer. A held image is served from that copy (thumbnails resized from it) and never
waits on a gateway; the X-Img-Source response header names where the bytes came from. Anything
not yet held hedges across several public IPFS gateways server-side (public gateways often answer
429 to Cloudflare's servers). Successes are cached at Cloudflare's edge as immutable (IPFS content
is content-addressed, so the cache never needs to invalidate). IPFS stays the source of truth.
GET /api/meta?u=<encoded-url>
Same proxy/caching strategy as /api/img, for JSON token metadata instead of image bytes.
GET /api/health
Operator-facing status page, public and read-only, ~30s edge cache.
{
"status": "ok",
"network": "mainnet",
"build": "561c703a",
"index": { "store": "d1", "rows": 124, "cap": 50000, "capPct": 0.2, "nearCap": false },
"heartbeats": {
"quaiscan-scan": { "ageHours": 3.6, "stale": false },
"resolve-media": { "ageHours": 2.8 }
}
}status is ok / degraded / error — every check reports its own failure rather than being
silently skipped, so a component that can't be reached shows up as a finding, not a gap. A live
check at doc time observed Cache-Control: no-store on this endpoint despite the "~30s edge
cache" comment in source — verify the header on your own response rather than assuming.
GET /api/event-config
Public read of the currently-active promotional event's display config (name, dates, contract, hero image) — no auth. Example (trimmed, live):
{
"eventId": "money-moves",
"name": "MONEY MOVES",
"active": true,
"contract": "0x0070BE7e66246aC24eDA1E1D1E0a294d3B090837",
"chainId": 9,
"domain": "hartiigallery.com",
"maxSupply": 300
}GET/POST /api/click
Lightweight click/attribution tracking. POST with an address in the body; returns
{ ok: false, error: "invalid address" } for a malformed one (confirmed live) rather than a
generic 500.
GET /api/profile?address=0x…
Reads a stored social profile document for a wallet (profile:<address> in KV). CORS-restricted
to https://hartiigallery.com. Address must match ^0x[a-fA-F0-9]{40}$.
POST /api/upload, POST /api/upload-json, POST /api/upload-folder
Creator-facing uploads (collection image/metadata assets → IPFS pin). Since Gallery 2.3.0 every
request must carry a wallet-signed upload ticket in the X-Hartii-Upload-Ticket header: the
wallet signs one short message once per creation session (no transaction, no gas), valid for at
most 30 minutes, and the same ticket covers every file of that session. Without a valid ticket
the answer is 401 with a code of upload_auth_required, upload_ticket_invalid or
upload_ticket_expired.
Hartii sets no upload limits (Gallery 2.9.0). There are no daily quotas, no rate limits, no
per-image size cap, no cap on files per request and no cap on the files in a metadata folder. The
daily per-wallet and site-wide upload limits added in Gallery 2.3.0 are gone. The ticket says who
uploaded; it does not say how much they may upload. Each successful pin is still logged against the
signing wallet (wallet, kind, size, SHA-256 and CID), so a takedown can find who pinned what. If
the upload log storage is unavailable, the endpoints answer 503 with
code: "upload_log_unavailable" rather than pin without a record.
What still refuses an upload:
- A request over 95 MiB. That is the Cloudflare request body limit, the one size boundary left.
Hartii answers
413with JSON{ error, code: "upload_body_too_large", limitBytes }instead of an opaque edge error. For/api/upload, one image can be at most 94 MiB (98566144bytes, the 95 MiB body minus 1 MiB for the multipart envelope), and the message says to resize or compress it. For/api/upload-jsonand/api/upload-folder,limitBytesis99614720(95 MiB) and the message says to split the request into smaller batches. A metadata folder is one IPFS directory and cannot be split, but at roughly 400 bytes a token its ceiling is more than 200,000 tokens. - A file that is not an image.
/api/uploadtakes PNG, JPEG or WEBP only. It checks the MIME type and the file's leading bytes, and refuses content with embedded script markers, with400.
Provider limits are reported honestly. If the main IPFS provider refuses an image or a metadata
folder as too large, /api/upload and /api/upload-folder answer 413 with
code: "provider_file_too_large"; Hartii does not retry that file on the backup provider. When the
main provider is unavailable, uploads go to a backup IPFS provider whose request body limit is
1 MiB. An oversize file then gets 413 naming that limit (limitBytes is 1044480, the 1 MiB
body minus 4 KiB of envelope). If every provider fails, /api/upload answers 503 and
/api/upload-json and /api/upload-folder answer 502, each with
{ "error": "IPFS storage is temporarily unavailable." }.
Big collections go up in batches. The Create page sends images in sequential batches that each
stay at or under 90 MiB and 48 files (one image larger than that goes alone), in final token order,
up to six requests at a time, and shows "Batch X of Y". A batch that hits a transient failure (429, 502, 503, 504, a network error
or a timeout) is retried for the images that failed, after 1, 2 and 4 seconds, four attempts in
all. A refusal that retrying cannot fix (a bad ticket, a file that is too large, not an image)
stops at once. If a batch still fails, the page says which batch it stopped at and how many images
are saved; the images already pinned are remembered by content in that browser tab, so pressing
the deploy button again resumes from that batch instead of starting over. The metadata folder goes
up last, in one request.
The Gallery UI's Create page handles all of this; it is a write path meant to be used through that flow, and agents use the repository's signing helper rather than hand-building tickets.
GET /api/upload-status
A cheap readiness check for the storage providers, used by the Create page when it loads. No file
is uploaded. It answers 200 with Cache-Control: public, max-age=30 and
Access-Control-Allow-Origin: *; add ?fresh=1 to skip the cache.
| Field | Meaning |
|---|---|
status | ok, or degraded when the main provider is not configured or does not authenticate. |
primary | The main provider: provider, configured, reachable, and a short detail when unreachable. |
fallback | The backup provider: provider, configured and its maxImageBytes (1044480). It is not probed. |
primary.detail | Only when the main provider is not reachable: the status of its auth check and, since Gallery 2.10.1, its own short reason in brackets (for example Pinata auth check returned 403 (INVALID_API_KEYS)), at most 100 characters. |
maxImageBytes | The largest image the providers can take right now: 98566144 when ok, the backup's 1044480 when degraded. |
message, at | A plain sentence and an ISO time. |
The Create page uses maxImageBytes to reject an oversize image when you pick it, not at the end
of the form.
POST /api/collections-import
Adds an existing ERC-721 or ERC-1155 collection to the index by contract address. Since Gallery
2.3.0 it is no longer public: the caller proves control with a wallet signature over that
collection address (or presents the site's own server credential). It is rate limited per wallet
and site-wide per UTC day, and it writes a single index row without touching the rest of the
index. Answers 401 or 403 when the proof is missing or wrong.
POST /api/collections-register
Since Gallery 2.14.0 the Create page calls this right after a collection deploy confirms, so the
new collection is on the wall with its cover at once. Body: { "address": "0x…", "txHash": "0x…" }.
The server uses only the transaction: its receipt must be successful and contain a
CollectionDeployed event from one of the Gallery's factories for exactly that address. Name,
symbol and the cover (the image of token 0, where the chosen cover is placed) are read from the
chain. Answers 200 with { address, indexed, coverImage } (coverImage is null if the
metadata could not be read yet; the scheduled media job fills it later), 422 when the
transaction did not deploy that collection, 400 for malformed input and 429 past 12
requests a minute. An existing cover is never replaced.
POST /api/discord-alert, POST /api/discord-member-verify
Server-side Discord webhook/verification glue for the community server. Internal use by the frontend; no public contract beyond "exists and requires the site's own request shape."
GET /api/faucet, POST /api/faucet-drip
Testnet-only (Orchard) faucet drip. No-op / not meaningful on mainnet — FAUCET_ADDRESS is
null in the mainnet config by design.
Admin endpoints — operator only
functions/api/admin/event-config.js, event-drain.js, event-report.js manage the
promotional-event system's write side (create/edit an event, drain claims, pull a report).
Operator only — gated behind a constant-time-compared shared secret checked server-side; not
reachable without it. No further detail here per this doc's no-secrets policy.
Caching summary
| Endpoint class | Cache-Control (source intent) | Observed live at doc time |
|---|---|---|
/api/collections | max-age=10, s-maxage=30 (full); s-maxage=5, stale-while-revalidate=15 (index still filling) | checked live at 2.25.0 |
/api/floor-prices | short edge cache; no-store on partial/total failure | no-store |
/api/img, /api/meta | immutable (content-addressed IPFS) | not re-checked live per URL |
/api/health | ~30s edge cache | no-store |
/api/event-config | ~30s edge cache | not re-checked live |
/api/profile | no-store | matches |
/api/nft/* | s-maxage of 15 s (activity), 60 s (stats, spotlights, collections, owners, search) or 300 s (tokens); no-store on every error | not re-checked live |
/api/upload-status | max-age=30 | not re-checked live |
/api/studio/world | max-age=300, s-maxage=3600; with ?collection= s-maxage=1800 (no-store when the directory is busy) | checked live at 2.27.0 |
/api/studio/votes | s-maxage=60 (counts), s-maxage=300 (?top=week); no-store for ?own=1, POST and errors | not re-checked live |
The gap between source comments and the live headers observed above wasn't chased further — don't hard-code a caching assumption into an integration; read the response's own header.