Hartii developer docs

Gallery

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.

json
{
  "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).

json
{
  "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.

ParamMeaning
limitpage size
collectionfilter to one collection address
cursoropaque pagination cursor from a previous response
summary=1(with collection) return aggregate stats instead of a list
json
{ "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, never 0. 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:
FieldMeaning
networkmainnet or orchard
asOfBlockThe block the index has followed up to. null before its first read.
asOfTimeISO 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.
lagBlocksHow 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.

EndpointQueryReturnsEdge cache
/api/nft/activitylimit, cursor, kinditems, nextCursor15 s
/api/nft/statusnoneshared metadata and indexing30 s
/api/nft/statsnonetotals, totalsAsOfBlock, day, topCollectors60 s
/api/nft/spotlightslimititems60 s
/api/nft/collections/:addrnonecollection, recent60 s
/api/nft/tokens/:addr/:idnonetoken, history300 s
/api/nft/owners/:addrlimit, cursorowner, holdings, nextCursor60 s
/api/nft/searchqcollections, tokens60 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".

bash
curl -s 'https://hartiigallery.com/api/nft/activity?limit=20'
curl -s 'https://hartiigallery.com/api/nft/activity?kind=sale&limit=20'

Each item:

FieldMeaning
kindmint, transfer, burn or sale. A transfer in a transaction that matches a recorded sale is a sale.
contract, collectionNameThe 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, amountThe token id, and the quantity moved (both strings).
from, toAddresses. For a sale, from is the seller and to the buyer.
priceWeiThe price for a sale, otherwise null.
marketplaceOnly on kind=sale rows: the marketplace contract address, or null.
txHash, logIndex, block, blockTimeWhere it happened. blockTime is an ISO time or null.
imageThe 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

FieldMeaning
totals.nfts, totals.ownersFrom a periodic snapshot. null until a snapshot exists.
totals.collectionsCollections in the index.
totals.transfersEvery transfer in the index.
totalsAsOfBlockThe block the snapshot was taken at. null until a snapshot exists.
topCollectorsUp to 10 of { address, count, collections } from the same snapshot. Empty until a snapshot exists.
day.sales, day.volumeWeiMarketplace sales in the last 24 hours, and their summed price. null when they cannot be summed honestly.
day.mints, day.transfersThe 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.

  • GET /api/nft/collections/:addr returns collection (the spotlight fields plus deployBlock and transfers) and recent, the latest 20 activity items. 404 if the collection is not in the index.
  • GET /api/nft/tokens/:addr/:id returns token (contract, tokenId, standard, owner, supply, name, image, uri, mediaStatus) and history, up to 50 activity items. owner is null for ERC-1155, and supply is "1" for ERC-721. 400 if the id is not a decimal number; 404 if the token is not in the index.
  • GET /api/nft/owners/:addr returns owner and holdings (contract, tokenId, amount, name, image). nextCursor has the form <contract>:<tokenId>.
  • GET /api/nft/search?q= needs at least 2 characters. It returns up to 10 collections (by name, symbol or full address; most transfers first) and up to 10 tokens (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).

json
{ "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. layout is { 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). pieces lists 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 not 0x plus 40 hex characters answers 400 with code: "invalid_address".
  • ?top=week: { week, ranked: [{ address, visits, title, pieces }], newest: [{ address, title, pieces }] }, at most 24 each; week is 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, otherwise 403 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 is 409 replayed; a bad or mismatched signature is 401 bad_signature; an expired or malformed window is 401 reset_expired or 401 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. up and down count everyone. basis is all until a piece has 3 or more wallet votes, then wallet; score, upPct, winner (up, down or even) and label ("72% like", "64% dislike", "even") use that basis. An invalid key is 400 invalid_piece, more than 24 is 400 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 the X-Hartii-Voter header (a wallet address or an anonymous browser id) and optionally X-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.
  • POST body { piece, vote: "up" | "down" | null, voter, anon? } (2 KB at most). voter is a 0x wallet address or an anonymous id (a_ plus 22 URL-safe characters); anon lets a wallet vote replace that browser's anonymous vote. null takes the vote back, the other side switches it, and sending the same side again changes nothing (the browser's tap-again-to-withdraw sends null). Answer { ok: true, piece, own, counts }. Errors: 400 with invalid_piece, invalid_vote, invalid_voter or invalid_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 unavailable or unconfigured. 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.

json
{
  "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):

json
{
  "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 413 with 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 (98566144 bytes, the 95 MiB body minus 1 MiB for the multipart envelope), and the message says to resize or compress it. For /api/upload-json and /api/upload-folder, limitBytes is 99614720 (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/upload takes PNG, JPEG or WEBP only. It checks the MIME type and the file's leading bytes, and refuses content with embedded script markers, with 400.

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.

FieldMeaning
statusok, or degraded when the main provider is not configured or does not authenticate.
primaryThe main provider: provider, configured, reachable, and a short detail when unreachable.
fallbackThe backup provider: provider, configured and its maxImageBytes (1044480). It is not probed.
primary.detailOnly 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.
maxImageBytesThe largest image the providers can take right now: 98566144 when ok, the backup's 1044480 when degraded.
message, atA 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 classCache-Control (source intent)Observed live at doc time
/api/collectionsmax-age=10, s-maxage=30 (full); s-maxage=5, stale-while-revalidate=15 (index still filling)checked live at 2.25.0
/api/floor-pricesshort edge cache; no-store on partial/total failureno-store
/api/img, /api/metaimmutable (content-addressed IPFS)not re-checked live per URL
/api/health~30s edge cacheno-store
/api/event-config~30s edge cachenot re-checked live
/api/profileno-storematches
/api/nft/*s-maxage of 15 s (activity), 60 s (stats, spotlights, collections, owners, search) or 300 s (tokens); no-store on every errornot re-checked live
/api/upload-statusmax-age=30not re-checked live
/api/studio/worldmax-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/votess-maxage=60 (counts), s-maxage=300 (?top=week); no-store for ?own=1, POST and errorsnot 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.

Quai Network mainnet · chain 9 · Cyprus-1. Figures marked "read on" a date were read from the chain that day; re-read before relying on them.