Scribblehood
API

API

Every route under /api with its method, body, response and status codes. The game, the site and the share cards use nothing else.

$ curl -s -X POST https://scribblehood.vercel.app/api/summon -H 'content-type: application/json' -d '{"prompt":"golden gpu"}'
{ "summon": { "id": "3f1c...", "name": "GOLDEN GPU", "basket": [ { "ticker": "NVDA", "weight": 0.36, "why": "GPU design" }, ... ],
             "physics": { "glow": "gold", ... }, "sprite": { "base": "box", ... }, "quotes": [ ... ], "basketChangePct": 1.2, "notional": 101.2 },
  "summonsLeft": 14 }
The route everything else is built on. The id in the response is the cache key, and it is what share links and the OG card use.

Conventions

  • Every route speaks JSON. Errors are { "error": "a sentence" } with the status in the HTTP code.
  • Unexpected failures answer 500 with { "error": "Something broke on our side. Try again in a moment." } and no stack trace.
  • Rate limits are a fixed one-minute window per IP; over the limit is 429 with a sentence.
  • Routes that touch the quota or the session set cookies: sh_vid (a one-year visitor id) and sh_session (seven days, after sign in). Both are HttpOnly and SameSite=Lax.
  • Types for every request and response live in src/lib/types.ts. The shapes below use those names.

Summons

POST/api/summon

body{ prompt: string }
response{ summon: LiveSummon, summonsLeft: number | null }. summonsLeft is the free summons left after this one; null for holders.
statuses200. 400 empty prompt or over 80 characters. 429 rate limit (30 a minute per IP). 429 quota used up, with body { error, code: "quota", summonsLeft: 0, threshold, tokenLive }.
notesResolves through the cache, the model, then the dictionary, and attaches live quotes. Spends one summon.

GET/api/summon?id=

queryid
response{ summon: LiveSummon }
statuses200. 400 missing id. 404 unknown id.
notesNo quota, no cookie. Used by the share page and remakes.

POST/api/craft

body{ ids: string[], prompts: string[] }
responseSame as summon: { summon: LiveSummon, summonsLeft }.
statuses200. 400 fewer than two prompts, more than four, or any over 80 characters. 429 rate limit (20 a minute). 429 quota, same body as summon.
notesThe component baskets are handed to the model so it recomposes rather than invents; offline, the baskets are averaged. Spends one summon.

Market

GET/api/prices?tickers=

querytickers, comma separated, at most 40
response{ quotes: Quote[] }
statuses200 (cached 30 seconds). 400 no tickers, too many, or an unknown one, naming it.

GET/api/weather

response{ weather: Weather, quotes: Quote[] }: kind, headline, detail, movers, asOf, plus the 26 weather quotes.
statuses200 (server cache 60 seconds, browser cache 30).

GET/api/universe

response{ assets: Asset[] }: ticker, name, sector, emoji, Stock Token address when known, reference price.
statuses200 (cached an hour).

GET/api/logo/{ticker}

responseAn image: the company mark as PNG when one exists, otherwise an SVG lettermark. The x-logo-source header says which.
statuses200 (a day in the browser, a week at the edge). 404 unknown ticker.

Daily and world

GET/api/daily?date=

querydate as YYYY-MM-DD, optional, defaults to today UTC
response{ daily: { date, challenges: Challenge[3] } }
statuses200 (cached five minutes). 400 malformed date.

GET/api/world

response{ entries: WorldEntry[] }, the newest 60, each with its remake count.
statuses200, never cached.

POST/api/world

body{ summon: Summon, author?: string } to post, or { remakeOf: string } to count a remake.
response{ entry: WorldEntry } with 201 for a post; { id, remakes } for a remake.
statuses201 posted. 200 remake counted. 400 not JSON, no summon id, a summon the server never resolved, or a bad remake id. 429 rate limit (20 a minute).
notesOnly summons the server itself resolved may enter the world; the client copy is not trusted. Author names are cut to 16 characters. A remake of a holder's post awards them 2 Ink.

Share cards and NFTs

GET/api/og?id=

queryid, optional
responseA 1200 by 630 PNG: paper background, the sprite, the name, the top five basket lines with weights and logos, and the top ticker as a sticker. An unknown or missing id renders the generic Scribblehood card.
statuses200.

GET/api/nft/{id}

responseERC-721 metadata for a ScribbleObjects token: name, description, image (the OG card), external_url (the share page) and attributes for kind, glow, prompt, every basket line, the primary Stock Token and the mint date.
statuses200 (five minutes in the browser, an hour at the edge). 400 non-numeric id. 404 ScribbleObjects not configured, no such token, or the summon is gone. 502 the chain read failed.

Auth and me

GET/api/me

response{ me: Me }: address when signed in, tier, balance, threshold, tokenAddress, tokenLive, summonsLeft (null for holders), freePerDay, ink, claimedToday.
statuses200. Sets sh_vid when missing.

GET/api/auth/nonce

response{ nonce, message }
statuses200. 429 over 30 a minute.
notesNonces live ten minutes and burn on use.

POST/api/auth/verify

body{ address, signature, nonce }
response{ me: Me }, with the tier read fresh from chain, and the session cookie.
statuses200. 400 not JSON. 401 bad address, bad signature, stale nonce, or a signature that does not match. 429 over 10 a minute.

POST/api/auth/logout

response{ ok: true }
statuses200, and the session cookie is cleared.

Ink and rewards

POST/api/ink/claim

body{ challengeId: string }
response{ ink: number, awarded: number }
statuses200. 400 no challenge id, or not one of today's. 401 not signed in. 403 not a holder. 409 already claimed today. 429 over 20 a minute.

GET/api/leaderboard

response{ entries: LeaderboardEntry[], you?: LeaderboardEntry }: the top 20 as rank, address, ink; you is filled when signed in and on the board at all.
statuses200, never cached.

GET/api/rewards/proof?address=

queryaddress
response{ round, root, decimals, amount, proof, total, publishedAt }
statuses200. 400 not an address. 404 no round published yet, or, with { error, round }, this wallet is not in the latest round.