# auctionhouse.fyi API v1

The HTTP and WebSocket API of `https://auctionhouse.fyi`, used by the website, the desktop app, API-key users and wowzers (a partner). Source of truth: change this file and `protocol/api-fixtures/` first, then `shared/`, `cloud/`, `companion/`, `web/` together (AGENTS.md rule 10). Design: `docs/design/2026-09-29-auctionhouse-fyi-design.md`.

**Lineage.** The market rules and answer shapes are wowzers' (`protocol/sync-v1.md` at `31a5fc3`, sections "Auction scans", "Order books", "Candles", "Market"), with these changes: routes live under `/api/v1/`; a *member* is an **account** (a Clerk user); a *group's Room* is gone (accounts, devices and settings live in D1, markets in one Durable Object per market); the uploader's name is never shown (`by` is gone); every route is gated by `shared/src/entitlements.ts`. The ported rule text is in "Market rules" below.

## Conventions

### Public discovery and page representations

`GET /openapi.json` describes the supported integration subset of v1 (public market summaries,
item metadata, item prices, daily candles and book uploads), including schemas, credentials,
units and errors. `/docs/api/reference` remains the full endpoint directory. Discovery is at
`/.well-known/api-catalog` (RFC 9727 linkset), `/llms.txt`, `/llms-full.txt`, `/sitemap.xml`,
and `/AGENTS.md` (consumer guidance, not the repository's development instructions).
These documents and `/developers/` examples are public and require no credentials.

Public content pages and item pages support `GET` and `HEAD`. An explicit acceptable
`text/markdown` preference returns Markdown with `Content-Type: text/markdown; charset=utf-8`;
HTML remains the default. Both representations carry `Vary: Accept`. Quality values are
honored; a tie with HTML prefers HTML. `.md` URLs also expose the generated public content.
Unknown pages and malformed item paths return 404; an invalid item market returns 400.
Their Markdown errors link to discovery and docs. A valid item key with no observations
remains an item page and explicitly reports that no observed price is available.
HEAD returns the same headers and status as GET without a body. Page rendering always uses
anonymous, hourly data, even for authenticated visitors; it never exposes live entitlements
through a shared page cache. HTML and Markdown cache entries are distinct.

Public HTML includes readable content before JavaScript. Static content and documentation
are rendered at build time from the same components as the browser app. Private and dynamic
application routes remain client-rendered and are omitted from the sitemap. Unknown paths
must not fall back to a successful app-shell response. Discovery fixtures:
`protocol/api-fixtures/discovery.json`.

- JSON bodies and answers; times are Unix ms; prices are copper; days are UTC (`floor(ms / 86400000)`), hours `floor(ms / 3600000)`, minutes `floor(ms / 60000)`.
- Errors: `{error: <code>, message?}` with the status given. Common: 400 `bad_request`, 401 `unauthenticated`, 403 `forbidden` (`{error, feature}`: the feature the caller lacks), 404 `not_found`, 413 `too_large`, 429 `rate_limited` (with `Retry-After`), 503 `unavailable`.
- Every answer carries `now` (the server's time) where the wowzers shape had it.
- A path that isn't a route is 404 `not_found`; a route's path with another method is 405 `method_not_allowed`. Who may call each route is one table, `cloud/src/routes.ts` (`ROUTES`, `decide`), which the Worker gates every request with and the e2e suite reads.
- `market` is one of `forever.<normal|pvp|rp>.<alliance|horde>.us` (6 markets); anything else is 400 `bad_market`. `item` is an **item key** (below); 400 `bad_item` otherwise.

## Auth

A request is one of:

| Caller | How | Principal |
|---|---|---|
| Anonymous | nothing | roles `{anon}` |
| Website session | Clerk's `__session` cookie (same site) or `Authorization: Bearer <session JWT>` | the user; roles from the token's claims (`meta.contributorUntil`, `meta.grants`, `pla`) |
| Desktop app | `Authorization: Bearer ahf_dev_<43 base64url chars>` | the device's user; roles from D1 (the user's cached claims, refreshed from Clerk at most every 10 minutes) |
| API key | `Authorization: Bearer <Clerk API key>` (`ak_…`) | the key's user; verified with Clerk, cached 5 minutes |

The session JWT is verified locally: RS256 with the instance's public key (`CLERK_JWT_KEY`), `iss` the instance's frontend API (from the publishable key), `exp`/`nbf` with 5 s of skew, and `azp` one of the environment's origins (production: `https://auctionhouse.fyi` only; dev and previews: `https://dev.auctionhouse.fyi`, `http://localhost[:port]`, `http://127.0.0.1[:port]` and the dev Worker's `workers.dev` URLs, `https://[<preview or deployment>-]auctionhouse-fyi-dev.<subdomain>.workers.dev`). A bad or expired credential in `Authorization` is 401 `unauthenticated`, never a silent downgrade to anonymous. The one exception is the `__session` cookie: the browser sends it with every request by itself and Clerk refreshes it in the page, so an expired or invalid cookie counts as no credential (anonymous). Writes (not `GET`/`HEAD`) from a session need an `Origin` that is one of the environment's origins: 403 `bad_origin` otherwise. API keys are Clerk's (`ak_…`), of a user (an organization's key is 401); when Clerk can't be asked, 503 `unavailable`. A key Clerk refused is 401 without asking Clerk again for 60 s.

**Roles** come from `rolesOf` in `shared/src/entitlements.ts`. A route below names the **feature** it needs; without it: 403 `{error: "forbidden", feature}`. An API key also needs `api.read` for every read route (the public snapshot, item data and icons aside). "Signed in" routes answer 401 `unauthenticated` to anonymous callers. Rate limits: `apiPerMinute` per API key, device, session (per user) or (anonymous) IP, over every `/api/v1/` route; 429 `rate_limited` beyond, with `Retry-After: 60`.

## Freshness

Routes marked **snapshot** answer callers without `prices.live` (anonymous and `free`) with the market **as of the top of the hour** (`cut` = `floor(now / 3600000) × 3600000`), restricted as the route says, with `live: false`: nothing read at or after `cut` shows. A row read after `cut` is answered with the item's newest candle before it (minute candles, then hour candles, then day points; a sold-out mark isn't in the candles, so an item marked gone after `cut` shows its last listing before it), and `cut`'s own day is rebuilt from its hour candles before `cut`. The first answer of the hour is kept at the edge (per path, query and what the caller's roles restrict) and served until the hour ends; answers have `Cache-Control: public, max-age=<seconds to the next hour>` and `Vary: Authorization, Cookie`. Callers with `prices.live` get the live answer (`live: true`, `Cache-Control: private, no-store`).

## Item keys

`key = item + 2147483648 × v`, `v = 0` for no suffix, `2s` for suffix `s > 0`, `2|s| − 1` for `s < 0`; `s` is the suffix's `ItemNameDescription` id (a browse result's `itemKey.itemSuffix`; from a link, the suffix bonus of its bonus lists). Keys are 1..2⁴⁷−1. Full rule: "Market rules › Item keys".

**Battle pets** (reserved, 2026-09-29; whether caged pets sell on Forever is UNVERIFIED): a browse result with `battlePetSpeciesID` > 0 is keyed `item + 2147483648 × (65536 + species)`, with `item` the result's `itemID` (the cage). Such keys go up to 2⁵³−1, still exact as a JSON number, a double, an SQLite integer and a Lua 5.1 number. `itemKey.itemLevel` is not part of a key: the client's bonus lists never change item level (ledger); the addon counts results whose level differs from the item's and `/ahf status` shows the count, so we find out if that's wrong.

## Routes

### Environment

| Method, path | Feature | Answer |
|---|---|---|
| `GET /api/v1/config` | — (public) | `{env: "production" \| "dev" \| "preview", clerkPublishableKey, apiBase, cdnBase}`: what the pages and the desktop app need to talk to this deployment (`apiBase` is the deployment's URL; a preview's is its own origin; `cdnBase` where its CDN files are, `protocol/cdn-v1.md` "Hosts and bases"). `Cache-Control: public, max-age=300` |
| `POST /api/v1/admin/snapshot` | `admin` (a feature no role lists: only admins); not in production | writes the hourly snapshots now (Previews run no crons): `{written: [{market, at, bytes}], now}`. 404 in production, for everyone |

Deployments: production `https://auctionhouse.fyi`, dev `https://dev.auctionhouse.fyi`, and PR previews (Workers Previews of the dev Worker). Each has its own Clerk instance (production: prod; dev and previews: dev), data and device tokens; a credential from one is 401 on another.

### Account

| Method, path | Feature | Answer |
|---|---|---|
| `GET /api/v1/me` | — | `{user: {id, email} \| null, roles: Role[], features: Feature[], limits: {…every limit}, contributor: {until: int \| null, scans7d: int}, now}`: `until` the contributor window's end (D1, the source of truth; the session claim may lag by up to an hour), `scans7d` credited uploads in 7 days |
| `GET /api/v1/contributions?from` | signed in | `{scans: [{market, at, kind: "scan" \| "book", items, complete, credited: bool, reason?}], until: int \| null, now}`: the caller's own uploads received since `from` (at most 30 days back, at most 1000), newest first. 400 `bad_query` |
| `POST /api/v1/devices/pair` `{name}` | — (the app, anonymous) | `{code, pairId, expires}`: a 10-minute pairing code (8 characters, `A-Z2-9` without `IO01`) the app shows |
| `POST /api/v1/devices/confirm` `{code}` | signed in (session) | `{device: {id, name}}`: binds the pairing to the caller (the code is taken in any case, with spaces or dashes; confirming again answers the same device). 403 `sessions_only` for a device or API key, 404 `unknown_code` (also a code another account confirmed), 410 `expired` |
| `GET /api/v1/devices/pair/:pairId` | — (the app) | `{status: "waiting"}` or, once, `{status: "paired", token}` (then the pairing is gone). 404 after expiry |
| `GET /api/v1/devices` | signed in | `{devices: [{id, name, created, lastSeen}]}`, newest first (`lastSeen` is updated at most every 10 minutes); a device whose app never collected its token isn't listed and is deleted after 2 hours |
| `DELETE /api/v1/devices/:id` | signed in | `{deleted: bool}` (the token stops working at once and its live sockets close with 4401) |

API keys are made and revoked on the account page with Clerk's components (feature `api.keys`); the Worker only verifies them.

### Chart windows (2026-09-29)

`historyDays` is 7 for anonymous and free callers and 90 for contributor and premium: day and hour candles are served that far back. Hourly candles are feature `candles.hourly` (everyone; without `prices.live` only finished hours, as the hourly view has them); minute candles are `candles.minute` (paid). `candles.intraday` is gone: the batch candles and intraday routes, which feed live dashboards and the screener, need `prices.live`. The hourly snapshot's `days` covers 7 days. Public CDN files: `h1` blocks of finished hours for the last 7 days need no cap; the public `d1` file covers 7 days, the capped one 90 (cdn-v1 "Candle blocks").

### Market depth is paid

Overall quantity is free: every tier gets an item's quantity listed (`AuctionRow`'s quantity, and the quantity on candles it may read). Deeper market data is paid: feature `depth` (`shared/src/entitlements.ts`; contributor and premium) covers volume estimates (`volume`: listed, sold, expired), sell-through and days of supply; feature `books` covers order books and per-level depth and density. Without them those fields and routes are absent or 403, and the hourly snapshot and public CDN files never carry them (cdn-v1).

### Markets (reads)

Shapes are wowzers' (`AhMarketsAnswer`, `AhTableAnswer`, `AhItemAnswer`, `AhCandlesAnswer`, …, now in `shared/src/schema.ts`) plus `live`, minus `by`.

| Method, path | Feature | Answer and tier rules |
|---|---|---|
| `GET /api/v1/ah` | **snapshot** | Every market with data, newest scan first: `{markets: [{market, at, items, listed, scans, contributors7d, book?}], now, live}`: `at` its newest scan, `items` keys with a row, `listed` those with a quantity, `scans` scans in the 24 h before (`cut` for the hourly view), `contributors7d` accounts that uploaded in 7 days (never who), `book` its current order book (only for callers with `books`) |
| `GET /api/v1/ah/:market/snapshot` | — (public, **snapshot** always) | The hourly snapshot (below), for everyone including paid callers; served from R2 (gzip when accepted), `Cache-Control: public, max-age=<seconds to the next hour + 120>`. 404 before the first |
| `GET /api/v1/ah/:market?since` | `prices.live` | The live table since `since` (`AhTableAnswer` plus `seq`: the market's newest merge, `protocol/cdn-v1.md`). Pages and the desktop app use the CDN files instead; this is the fallback of the gap rule |
| `GET /api/v1/ah/:market/files` | — (per role) | Where this market's CDN files are (`protocol/cdn-v1.md`), as the caller may know: `{market, cdnBase, snapshot: url, d1: url, sealed: {m1, h1, d1}, live?: {seq, at, checkpoint: {seq, url} \| null, changes: [seq, url][]}, caps?: Caps, books?: [{at, complete, url}], now}`: `snapshot` the pointer, `d1` the newest public day file; `live` (every change after the newest checkpoint) with `prices.live`; `caps` as the live `hello` has them (cdn-v1 "Caps"); `books` (the last 30 days) with `books`. `Cache-Control: private, no-store` (anonymous: `public, max-age=60`). Fixture `protocol/cdn-fixtures/files-answer.json` |
| `GET /api/v1/ah/:market/:item` | **snapshot** | `AhItemAnswer` plus `live`; `days` limited to `historyDays`; `volume` only with `depth` (else `[]`). Without `prices.live`: the row and days as of the hour |
| `GET /api/v1/ah/:market/:item/candles?res&from` | `candles.daily`; `res=h1` needs `candles.hourly`, `res=m1` `candles.minute` | `AhCandlesAnswer` plus `live`; `d1` and `h1` limited to `historyDays`; `volume` only with `depth` (else `[]`). **snapshot** for `d1` and `h1` without `prices.live` (`h1`: finished hours only) |
| `GET /api/v1/ah/:market/candles?items&res&from` | `prices.live`; `res=m1` also `candles.minute` | `AhCandlesBatchAnswer` (1..50 items); `d1` and `h1` limited to `historyDays` |
| `GET /api/v1/ah/:market/intraday?from` | `prices.live` | `AhIntradayAnswer` |
| `GET /api/v1/ah/:market/history?from` | **snapshot** | `AhHistoryAnswer` plus `live`; days limited to `historyDays`; `volume` only with `depth` (else `[]`) |
| `GET /api/v1/ah/:market/:item/book` | `books` | `AhItemBookAnswer` |
| `GET /api/v1/ah/:market/books?from&to` | `books` | `AhBooksAnswer` (never who uploaded a book), each book with `url`: its CDN file (cdn-v1 "Order books") |
| `GET /api/v1/ah/:market/books/:at` | `books` | the stored snapshot (gzip when accepted), `Cache-Control: private, max-age=86400`. 404 `unknown_book` |
| `GET /api/v1/items?build` | — (public) | the item data (`items.json`, below) merged with the catalogue: the current build's, or the named build's; `X-AHF-Build` names it; `Cache-Control: public, max-age=600` (the catalogue moves, so no build's answer is immutable). 400 `bad_build`, 404 |
| `GET /api/v1/items/search-index?build` | — (public) | the compact search index for Cmd+K, merged the same way |
| `GET /api/v1/items/:key` | — (public) | One item's record from the merged data, for client-side navigation without `items.json`: `{build, key, item: {n, q, il?, c?, sc?, ic?, fi?, sell?, buy?, st?, rs?, bt?, fl?} \| null, variant?: {n, q, fi?}, now}` (`item` the key's item id's record; `variant` the key's own name when it's a suffix or pet key). `Cache-Control: public, max-age=600`. 400 `bad_item` |
| `POST /api/v1/items/seen` | `upload.device` (device) or `api.upload` (API key) | the catalogue upload (below). 200 `{added, known}`; 401, 403 `devices_only` for a session, 400 `bad_request` |
| `GET /api/v1/items/unknown` | — (public) | `{items: int[], now}`: every key with a row in any market and no name (neither generated nor accepted by the catalogue), ascending; computed at most every 10 minutes per edge location, `Cache-Control: public, max-age=600` |

**Item data** (`GET /api/v1/items`, R2 `items/v1/<build>/items.json.gz`; the current build is `{build}` in R2 `items/v1/current.json`, written last by the upload; generated by `scripts/gen-items.mjs` from the client's DB2 tables on wago.tools only, AGENTS.md rule 6). The field names are wowzers' `items.json`'s, so the market code reads both:

`{v: 1, build: "1.60.1.70009", source, suffixes: {[suffixId]: name}, statNames: {[statId]: name}, bonusLists: {[listId]: [suffixId, [[statId, amount]…]]}, bonusTrees: {[treeId]: listId[]}, items: {[itemId]: {n, q, il, c, sc, ic, sell?, buy?, st?, rs?, bt?}}}`

`n` name, `q` quality, `il` item level, `c`/`sc` class/subclass, `ic` icon file name (lowercase, no extension), `sell`/`buy` vendor prices in copper, `st` max stack, `rs` true when it rolls a suffix, `bt` its bonus tree ids. Only items that can be traded: not bind-on-pickup or quest-bound (`Bonding` 1, 4, 5); an item's class alone never excludes it (quest-class goods such as Un'Goro Soil and Shredder Operating Manual pages sell). The generated file is only the starting point: **the catalogue** (below) adds everything seen on an auction house.

**The catalogue** (2026-09-29; why: wowzers' Normal Horde market on 2026-09-29 listed 2,134 item ids, 49 of them missing from the generated data and 22 from wowzers' own, and the client's tables name neither Forever's streamed items nor every suffix the server rolls). The auction house names everything it lists: the addon reads `C_AuctionHouse.GetItemKeyInfo(itemKey)` (`itemName` with the variant's suffix, `quality`, `iconFileID`, `isCommodity`, `isEquipment`, `isPet`; exists in the `forever` UI source, UNVERIFIED in game) for each browse result, and the desktop app uploads what the server doesn't know yet: `POST /api/v1/items/seen` (feature `upload.device` or `api.upload`) `{build, items: [[key, name, quality, iconFileID, flags]]}` (≤ 5000 per request; `flags` bit 0 commodity, 1 equipment, 2 pet), answered `{added, known}`. The server keeps per key the name, quality, icon file id and flags, when first and last seen and by how many accounts (`items_seen` in D1); a name counts once two accounts agree, or one when the generated data has no name at all. `GET /api/v1/items` merges it: generated fields win where they exist; seen items fill the gaps, and a variant key gets its own name (`"Severing Axe of Magic"`) under `variants: {[key]: {n, q}}`. `GET /api/v1/items/unknown` (public) lists keys with readings but no name. Icons come from the file id: `GET /icons/id/<fileID>.webp` (the item data generator converts every icon file id it finds; seen ids are queued for the next run).

How the server does it (cloud, 2026-09-29):
- **Upload:** `build` is optional and informational. An entry is `[key, name (1..200 characters, trimmed), quality (0..10), iconFileID (0 = none), flags (0..7)]`; a request with more than 5000 entries, or any bad entry, is 400 `bad_request` (optionally `Content-Encoding: gzip`, ≤ 4 MiB). A key given twice counts once (the last). `added` counts keys new to the catalogue, `known` the rest. Each account's latest word per key is kept (D1 `items_seen_by`, never answered); the key's row (D1 `items_seen`: name, quality, icon file id, flags, first and last seen, how many accounts) is named again after every upload.
- **The naming rule:** the name most accounts gave (a tie: the name seen first) counts when at least two accounts gave it, or when one did and the generated data has no name for the key (variant and pet keys never have one there). Quality, icon and flags come from the newest upload of that name.
- **The merge:** a plain key missing from the generated `items` is added as `{n, q, fi?, fl}` (`fi` the icon file id, for `/icons/id/<fi>.webp`; `fl` the flags); a generated entry gets only the fields it lacks (`n`, `q`, `fi` when it has no `ic`, `fl`); a variant or pet key goes under `variants: {[key]: {n, q, fi?}}`. The answer also carries `catalogue: {changed, named}`. The search index is the generated one plus every added item and variant, sorted by name (case-insensitive, then key); an entry is `[key, name, quality, icon, class, subclass]`, and `icon` is an icon name (string, `icons/<icon>.webp` on the CDN) or a file id (number, `icons/id/<id>.webp`; 0 none).
- **Rebuilds:** the merged files are kept in R2 (`items/v1/<build>/merged.json.gz`, `merged-search.json.gz`) and rebuilt on request when missing, or when an accepted name changed since the last build and that build is at least 10 minutes old.

**Search index** (`GET /api/v1/items/search-index`, R2 `items/v1/<build>/search.json.gz`, merged with the catalogue): `{v: 1, build, items: [key, name, quality, icon, class, subclass][]}` sorted by name, for Cmd+K and the home page's category chips; the pages load it on first use. `icon` is an icon name (string) or a file id (number, 0 none); `class` and `subclass` are the item's (a variant's are its item's; null when unknown).

**Icons:** `GET /icons/<icon>.webp` (56×56 WebP from the client's icon files, R2 `icons/<icon>.webp`, `Cache-Control: public, max-age=31536000, immutable`).

**Hourly snapshot** (`GET /api/v1/ah/:market/snapshot`; R2 `snap/v1/<market>/latest.json.gz`, written by the cron at minute 1 of every hour, with a copy of each as `snap/v1/<market>/<hour>.json.gz` (`hour` = `floor(at / 3600000)`) kept 7 days; in previews, which run no crons, `POST /api/v1/admin/snapshot`):

`{v: 1, market, at: int /* the hour's start */, scan: int | null /* newest scan merged by then */, rows: AuctionRow[], days: [item, [day, close, quantity][]][], now}`

`rows` are the market's rows as of `at` by the hourly view's rule ("Freshness"; every key whose reading is at most 30 days older than `at`), ascending by key; `days` the daily closes (`last`) and quantities of the 7 days before `at`'s day (the anonymous role's `historyDays`) plus that day so far (from its hour candles before `at`), per item with any, items ascending. Each copy is written at minute 1 of the hour, so the hour's first minute of merges is left out by design. The CurseForge release, the desktop app's free feed and anonymous pages read it.

### Uploads

**Books only.** A scan is the act of reading the auction house; the upload is always an order book with observed auction detail. `POST /api/v1/ah/scans` is retired and returns 404 without merging or crediting anything. Do not manufacture book entries from per-item minimum prices and totals: that cannot recover stacks, price levels or auction counts. The service derives its per-item price summaries from accepted books; existing read responses may still call the derived observation `scan`.

| Method, path | Feature | Body and answer |
|---|---|---|
| `POST /api/v1/ah/books` | `upload.device` (device) or `api.upload` (API key) | wowzers' book upload (`{market, at, complete, auctions, entries: BookEntry[]}`, gzip, ≤ 32 MiB, ≤ 200000 entries). 200 `{at, credited, reason?}`; 409 `too_soon` (5 min); 400 `bad_book`; 503 `storage_failed`, `no_storage` |

A browser session can't upload (403 `devices_only`); anonymous callers get 401. The *caller* is the device or the API key (so an account's two PCs don't limit each other). A retry of an upload the caller already made (same account, caller, market, kind and `at`) gets the first answer again and changes nothing; a book whose market and `at` someone else already stored is answered `{at, credited: false, reason: "duplicate"}` (nothing stored twice).

Credit follows `CONTRIBUTION` in `shared/src/entitlements.ts` (design "Accepted scan"), checked by the market object in this order (a book counts as its scan, "Order books" rule 3):

1. `disagrees`: the market has readings by *other* accounts (rows with a price, read at most `consensusAgeMs` = 2 h before the server's clock, whose newest reading another account made: an account's own earlier scans never count toward agreement with its new one) for at least `consensusMinItems` = 20 of the upload's items, and the lower median of `|ln(price / reading)|` over them is above `consensusMaxLog` = ln 2. The upload is **not merged**: it is kept 7 days (`quarantineMs`) for review (R2 `quarantine/v1/<market>/<at>-<id>.json.gz` and D1) and deleted by the hourly cron.
2. `incomplete`: `complete` is false.
3. `too_small`: under `minItems` = 100 keys, and (when the market has items listed with a reading in the last day) under `minShare` = a quarter of them. With no such items, only the 100 keys count.
4. `too_soon_credit`: the account already had a credited upload of this market in the last `creditEveryMs` = 5 minutes.

Everything but `disagrees` is merged whether or not it's credited. A credited upload sets the account's contributor window to `now + windowMs` (14 days; it only ever grows) in D1, the source of truth, and writes it to the user's Clerk public metadata as `contributorUntil` (merged, never replacing other keys) at most once per `claimEveryMs` = 1 hour (the first credit at once), so session tokens carry it. Live sockets of the account hear the new window at once. (`stale`, listed before, never happens: an upload outside the clock window is 400.)

**Partners** (wowzers): an API key whose user has `partner: true` in public metadata may add `"via": "<opaque id>"` (1..64 of `A-Za-z0-9_.:-`) to uploads, to rate-limit per their own members (one book per `via` per market per 5 min, instead of per key). Credit goes to the partner's account. `via` from anyone else is 403 `not_partner`; a malformed one 400 `bad_via`.

### Settings (per account)

Wowzers' "Market" section ("Market rules" › Market below), per account instead of per member: `GET/PUT /api/v1/market/watch` (feature `watchlist`, size `limit(watchlist)`), `GET/POST /api/v1/market/alerts`, `PUT/DELETE /api/v1/market/alerts/:id` (feature `alerts`, count `limit(alerts)`), `GET/PUT /api/v1/market/boards` (feature `boards`, `limit(boards)` dashboards, 50 screens, 64 KiB). Reading needs only a signed-in caller; every write needs the feature (403 `forbidden`). Past the roles' limit: 409 `too_many` `{error, limit}`. Losing the feature keeps the data but stops alerts firing (an alert fires only while its account has `alerts`, as D1 knows it), and writes answer 403. Other methods: 405.

Web Push (signed in; wowzers' rules for the `market` kind, "Market rules" › Push):

| Method, path | Answer |
|---|---|
| `GET /api/v1/push/key` | `{publicKey: string \| null}` (null: push isn't set up) |
| `GET /api/v1/push/subscription` | `{subscriptions: [{endpoint, prefs, created}]}`: the account's own |
| `PUT /api/v1/push/subscription` `{subscription: PushSubscriptionJSON, prefs?}` | `{subscription: {endpoint, prefs, created}}`: subscribes the browser, or replaces its prefs (another account subscribing the same endpoint takes it over); at most 10 per account, the oldest go. 400 `bad_request`, 503 `push_unconfigured` |
| `DELETE /api/v1/push/subscription` `{endpoint}` | `{deleted: bool}`. 503 `push_unconfigured` |

### Item pages and downloads

- **`GET /item/<key>?m=<market>`** (`m` defaults to `forever.normal.alliance.us`): the site's page with the item inline, for a fast first paint and search engines: `<title>` and `description`/`og:` meta with the item's name and price, and `<script type="application/json" id="ahf-item">{market, key, item, variant?, row, days, now}</script>` (`item`/`variant` as `GET /api/v1/items/:key`; `row` and `days` as the anonymous `GET /api/v1/ah/:market/:key` answers, the hourly view). Cached at the edge per hour (`Cache-Control: public, max-age=<seconds to the next hour>`). HTML also contains a visible item heading and observation summary; Markdown carries the same public facts. Malformed item paths are 404, invalid markets are 400, and valid keys without observations explicitly report missing data. Optional item-name slugs are accepted. GET and HEAD share headers, and HEAD has no body.
- **Desktop releases** (files on the CDN under `releases/`): `GET /api/v1/update/latest.json` (the updater manifest, never cached) and `GET /api/v1/downloads/desktop/latest` (302 to the current installer on the CDN, `<cdnBase>releases/downloads/<installer>`). The old `/update/latest.json` and `/downloads/*` paths are no longer release routes and return 404.

### Live

`GET /api/v1/live?market=<market>` upgrades to a WebSocket for **one market** (feature `live`; a session, device or API key: 401 anonymous, 403 without `live`, 400 `bad_market` for a missing or unknown market, 426 `upgrade_required` without `Upgrade: websocket`). The Worker checks the credential once and hands the socket to one of the market's hub objects (`hub:<market>:<shard>`, at most 2,000 sockets each); to follow several markets, open one socket per market. The messages are `protocol/cdn-v1.md` "Live messages" (fixture `protocol/cdn-fixtures/live-messages.json`): `hello` first (the newest `seq`, checkpoint, changes, sealed periods and caps), `ah` after every merge (`seq`, `url` of the change file, `at`, `book` for a book), `sealed` when a candle file is written, `alert` for the caller's alerts on this market, `roles` when the caller's features change, and `{t: "auth", until}` / `{t: "error", error}` (`bad_message`, `unauthenticated`, `unavailable`) in answer to the client. The data itself is never in a message: the client fetches the URLs from the CDN and catches up by the gap rule (cdn-v1 "Catching up").

The client may send `{t: "auth", token}` with a fresh credential of the same user and kind. The server closes with 4401 when the credential stops being valid and 4403 when the caller loses `live`. A session socket is valid until its token's `exp` plus 10 minutes, an API key's for 5 minutes (the verification cache), each extended by `auth`; a device's until the device is deleted. (`{t: "sub"}` is gone: a socket is one market.)

## Market rules

Ported from wowzers `protocol/sync-v1.md` at `31a5fc3` ("Auction scans", "Order books", "Candles", "Market", and "Push" for the `market` kind) with the changes listed under "Lineage": an account instead of a member, no Rooms or groups, no `by`, no reset (this store started fresh with item keys), and the routes under `/api/v1/`. The fixtures `ah-merge.json`, `ah-candles.json`, `ah-keys.json`, `ah-volume.json`, `market-alerts.json` and `push.json` in `protocol/api-fixtures/` are wowzers' unchanged; `shared/` and `cloud/` test against them.

### Markets

AHledger's ids, `forever.<ruleset>.<faction>.us` with ruleset `normal`, `pvp` or `rp` and faction `alliance` or `horde` (6 markets). The strip carries the faction; the ruleset is the desktop app's setting (the game doesn't say, so a wrong setting files scans under the wrong market). Any other string is 400 `bad_market`. Each market is one Durable Object (`idFromName(<market>)`); the markets never interact.

### Item keys

Items with a random suffix ("Birchwood Maul of Stamina", "… of the Whale") are different goods at different prices, so the market keeps each suffix apart. Wherever these rules say *item* or *id*, they mean an **item key**:

`key = item + 2147483648 × v`, with `v = 0` for no suffix, `v = 2s` for a suffix `s > 0` and `v = 2|s| − 1` for `s < 0`,

where `item` is the item id (1..2³¹−1) and `s` the **suffix id** (−32768..32767); a caged battle pet is `v = 65536 + species` (species ≥ 1; "Item keys" › "Battle pets" above). Forever's client has no random property or suffix tables: a variant is an item bonus list, whose suffix bonus (an `ItemBonus` of type 5) names it by an `ItemNameDescription` id (14311 is "of Stamina", 14302 "of the Bear", always positive). So `s` is, in a browse result, `itemKey.itemSuffix`; from an item link, the `ItemNameDescription` id of the suffix bonus in the link's bonus lists (the addon's bonus list → suffix table, from the client's `ItemBonus`), else the link's classic suffix field, else 0. (Verified on Forever by wowzers 2026-09-28: `itemKey.itemSuffix` is that id, and a real auction's link carries it as its bonus list; `GetReplicateItemLink` is assumed to give the same links.) Both stat rolls of one suffix share the id, so they are one item here as in the game's Browse list. Negative `s` doesn't occur on Forever; the zigzag keeps the space for it. So a suffix key's `v` is 0..65535 (keys 1..2⁴⁷−1) and a pet key's 65537..4194303 (keys up to 2⁵³−1), all exact as a JSON number, a double and an SQLite integer; paths take keys of up to 16 digits. `item = key mod 2147483648` and `v = floor(key / 2147483648)`. A key whose item is 0, or whose `v` is 65536, is invalid. Keys sort by `v`, then item. Fixture `ah-keys.json`.

### AuctionRow and merge

**AuctionRow:** `[id, price | null, quantity, at]`: an item's newest reading in a market: the lowest price in copper (`null` = none listed at the last complete scan), the quantity listed (0 with a `null` price), and Unix ms of the scan it came from.

**Merge** (fixture `ah-merge.json`, tested in TS and Rust): a market's table holds one row per item. A merged scan at `T`:

1. If it is `complete` and has at least one item: every row older than `T` whose item isn't in the scan becomes `[id, null, 0, T]` (sold out or expired). (A scan with no items changes no rows, but it is logged and counts as the market's newest scan.)
2. Every item of the scan replaces its row when there is none or the row's `at` is ≤ `T`; a row newer than `T` stays (a late scan never overwrites a newer reading).
3. Its UTC day `d` = `floor(T / 86400000)` gets a **day point** per scan item: `low` = the lowest price of the day's scans, and `last` and `quantity` those of the day's newest scan (of scans with the same `at`, the one merged last; a point is `[day, low, last, quantity]`). Items missing from a complete scan get no point.

A client keeping its own table merges rows from `GET /api/v1/ah/:market?since=<the last answer's now>` row by row: a row replaces its item's row when there is none or its `at` is ≥ the stored one's. `since` selects the rows the server *changed* at or after it (not the rows read after it), so a late scan's older readings still arrive.

A market's **newest scan** (`at` in routes and in `ah` notices) is the newest `at` in its scan log, else its newest row's `at` once the log has aged out.

**Retention** (checked at most every 6 hours by the market object's alarm): rows whose reading is older than 30 days, day points (with their candles) and volumes of days before `floor(now / 86400000) − 90`, hour candles of hours before `floor(now / 3600000) − 336` (14 days), minute candles of minutes before `floor(now / 60000) − 4320` (72 hours), and book snapshots (R2 and log) older than 30 days are deleted. The scan log (market, `at`, items, complete, credited, and who uploaded it: the caller and the account) keeps 30 days, for the upload limits, credit and the contributor count. None of it carries a character, and who uploaded is never answered.

### Order books

The whole order book of a market, every auction grouped by (item, unit price, stack, time left), from the addon's `ReplicateItems`.

**BookEntry:** `[item, price, stack, band, n]`: item key, unit buyout in copper (`0` = bid only; 0..2⁵³−1), stack size (1..2³¹−1), time left (`0` unknown, `1` short … `4` very long, as the strip's band), and `n` auctions like it (1..2³¹−1). A book's entries are unique and in ascending (item, price, stack, band) order; `auctions` is the sum of `n`. The upload rules are under "Uploads".

**What the market does with a book** (the Worker puts the snapshot in R2 first):

1. **Snapshot:** the body (gzip JSON, the upload's fields only) at its CDN file `books/v1/<market>/<at>-<cap>.json.gz` (immutable, behind a capability; `protocol/cdn-v1.md` "Order books"), logged with `at`, `complete`, `auctions`, `entries` and who uploaded it. Kept 30 days.
2. **Current book:** a `complete` book newer than the market's current one replaces it (writing only the entries that changed). A late book, or one that isn't `complete`, is only a snapshot and a scan. The current book never expires.
3. **Scan:** every book, late or not, counts as a scan at `at` (Merge above, logged as the book's derived price summary; the five-minute book interval applies to ingestion) with, per item that has an entry with a buyout, `[item, the lowest unit buyout, the sum of n × stack over all its entries (at most 2³¹−1)]`, `complete` as the book's. Consensus and credit judge this scan.
4. **Volume:** when the book replaces a current book at most 6 hours older, each item gets estimates for the new book's UTC day (added to what the day has): per (price, stack) key, `prev` and `next` are the auctions in the two books and `short` those in the old book with band `1`: `gone = max(0, prev − next)`, `new = max(0, next − prev)`, `expired = min(gone, short)`, `sold = gone − expired`; the item's `listed`, `sold` and `expired` are those counts times the stack, summed. They are estimates: a cancelled auction counts as sold, a repost at a new price as sold plus listed. Items in only one of the books count too. Kept 90 days. Fixture `ah-volume.json`.
5. `ah` to live sockets, like after a scan.

`GET /api/v1/ah/:market/:item/book` answers `{market, item, at: int | null, entries: [price, stack, band, n][], now}` (the item's entries in the current book; `null` and none without one); `GET /api/v1/ah/:market/books?from&to` the logged snapshots with `at` in `[from, to]` (defaults: the last 30 days, now), newest first, `{market, books: [{at, complete, auctions, entries}], now}` (400 `bad_query`, also for `from` > `to`); `GET /api/v1/ah/:market/books/:at` the stored snapshot (404 `unknown_book`). `GET /api/v1/ah/:market/:item` has `volume: [day, listed, sold, expired][]` (days from `floor(now / 86400000) − 90` on, oldest first).

### Candles

Every scan item that gets a day point also updates three **candles**: one for its UTC day `floor(T / 86400000)`, one for its UTC hour `floor(T / 3600000)` and one for its UTC minute `floor(T / 60000)`, by the same rule (fixture `ah-candles.json`):

- a new candle is `open = high = low = close = price`, `quantity` the item's quantity, `openAt = lastAt = T`;
- otherwise `low` = the lower and `high` = the higher of the two prices; when `T` < `openAt`, `open` = price and `openAt` = `T` (of scans with the same `at`, the one merged first keeps `open`); when `T` ≥ `lastAt`, `close` = price, `quantity` = quantity and `lastAt` = `T` (the one merged last).

A day candle is its day point plus `open` and `high`. On the wire a candle is `[t, open, high, low, close, quantity]`, `t` its start in Unix ms (in `history`, the day).

- `GET /api/v1/ah/:market/:item/candles?res&from`: `{market, item, res: "m1" | "h1" | "d1", candles, volume, now, live}`, oldest first: `d1` (the default) the day candles from day `floor(now / 86400000) − 90` on (`historyDays` for the caller), `h1` the hour candles from hour `floor(now / 3600000) − 336` on (and within the caller's `historyDays`), `m1` the minute candles from minute `floor(now / 60000) − 1440` (24 hours) on. `from` (Unix ms): only candles starting at or after it, where a `from` older than the resolution's retention (90 days, 336 hours, 4320 minutes) is served as the retention's start. 400 `bad_market`, `bad_item`, `bad_query` (`res` not `m1`, `h1` or `d1`, or `from` not a non-negative integer).
- `GET /api/v1/ah/:market/candles?items&res&from`: `{market, res, items: [item, candles][], now}`: up to 50 items' candles by the one-item rules, without volume; `items` a comma-separated list of 1..50 distinct keys, one entry per key in the request's order (an empty list without candles). 400 `bad_market`, `bad_query`.
- `GET /api/v1/ah/:market/intraday?from`: `{market, from: int, items: [item, [hour, close, quantity][]][], now}`: every item's hour candles on or after UTC hour `from` (default `floor(now / 3600000) − 24`; an earlier hour than `floor(now / 3600000) − 336` is served as that hour, and `from` says so), items ascending, each list oldest first. 400 `bad_market`, `bad_query`.
- `GET /api/v1/ah/:market/history?from`: `{market, from: int, items: [item, [day, open, high, low, close, quantity][], [day, listed, sold, expired][]][], now, live}`: every item with a day candle or a volume estimate on or after UTC day `from` (default `floor(now / 86400000) − 30`; clamped to 90 days and the caller's `historyDays`, and `from` says so), items ascending, lists oldest first. 400 `bad_market`, `bad_query`.

### Market (watchlist, alerts, boards)

An account's own settings, in D1; no one else sees them.

**Watchlist.** Up to `limit(watchlist)` item keys (200 at most), in the account's order. `GET /api/v1/market/watch` → `{items}`; `PUT` `{items}` → `{items}` (400 `bad_request` for duplicates or a bad key).

**MarketAlertInput:** `{market, item: int, kind, value: int, name?: string, note?: string, enabled?: bool}`:

| `kind` | `value` | Holds when (for the item's row `[price, quantity, at]`) |
|---|---|---|
| `below` | copper, 1..2⁵³−1 | `price` ≠ null and `price` ≤ `value` |
| `above` | copper | `price` ≠ null and `price` ≥ `value` |
| `rise` | percent, 1..1000 | `price` ≠ null, `ref` exists and `price × 100 ≥ ref × (100 + value)` |
| `fall` | percent, 1..99 | `price` ≠ null, `ref` exists and `price × 100 ≤ ref × (100 − value)` |
| `supplyBelow` | quantity, 0..2³¹−1 | the item has a row and `quantity` ≤ `value` |
| `supplyAbove` | quantity, 1..2³¹−1 | the item has a row and `quantity` ≥ `value` |
| `deal` | percent, 1..99 | `price` ≠ null, `fair` exists and `price × 100 ≤ fair × (100 − value)` |

`item` is an item key, or `0` for a `deal` over the whole market (only `deal` may use 0). `name` (at most 80 characters) is the item's name as the page knows it, used only in the notification's text (absent: `Item <id>`, the key's item id). `note` at most 200 characters. `enabled` defaults to true. `ref` is the `close` of the item's newest day candle before the evaluation's day, and `fair` the lower median of the `close`s of its newest 7 day candles before that day, when there are at least 3; only candles of the 30 days before that day count, and the evaluation's day is `floor(at / 86400000)` of the merged scan.

**MarketAlert:** the input plus `{id: string, created: int, armed: bool, last: {at: int, hits: [item, price: int | null, quantity][]} | null}`. `GET /api/v1/market/alerts` → `{alerts, now}` (newest first); `POST` → `{alert}`; `PUT /api/v1/market/alerts/:id` → `{alert}` (replaces the input, re-arms it and forgets its deal set; `last` stays; 404 `unknown_alert`); `DELETE` → `{deleted}`.

**Evaluation** (fixture `market-alerts.json`): after every merge of a market (a scan or a book, `disagrees` ones aside), the market object evaluates every enabled alert of that market whose account has `alerts` now, at the server's time `now`:

- A single-item alert **fires** when its condition holds, it is `armed`, and `last` is null or `now − last.at ≥ 3600000`: then `armed` = false and `last` = `{at: now, hits: [[item, price, quantity]]}`. When the condition doesn't hold, `armed` = true. (Holding but not allowed to fire changes nothing.)
- A `deal` alert keeps the set of items that held at its last evaluation (initially empty). The items that hold now and weren't in that set fire: `last` = `{at: now, hits}` with at most 20 of them, the deepest discount first (then by item id). Then the set becomes the items that hold now. `armed` stays true.
- Firing sends `{t: "alert", alert}` to the account's live sockets and queues a `market` push (below).

**Boards.** `{dashboards: MarketBoard[], screens: MarketScreen[]}`, one document per account. **MarketBoard:** `{id, name, widgets: object[]}`: `id` 1..40 of `[A-Za-z0-9_-]`, unique among the boards; `name` 1..60 characters; at most 24 widgets, each an object with `id` (1..40 of `[A-Za-z0-9_-]`, unique in its board) and `type` (1..32 of `[a-z0-9_-]`); its other fields are the page's, kept as given. **MarketScreen:** `{id, name, filters: object}`. At most `limit(boards)` boards (20), 50 screens and 65536 bytes of JSON for the whole document as stored. `GET /api/v1/market/boards` (both empty until the first `PUT`); `PUT` → the stored document (400 `bad_request`, 413 `too_large`).

### Push (the `market` kind)

Standard Web Push (RFC 8030 delivery, RFC 8291 `aes128gcm` encryption, RFC 8292 VAPID), done by the Worker itself. Keys are Worker secrets `VAPID_PUBLIC_KEY` (the uncompressed P-256 point, base64url), `VAPID_PRIVATE_KEY` (the 32-byte scalar, base64url) and `VAPID_SUBJECT`; without both keys push is off. Subscriptions are per account and browser endpoint, with **PushPrefs** (`shared/src/push.ts`; only `market` and `quietHours` matter here).

- **What is pushed:** only fired alerts, to the account's subscriptions whose prefs want `market`. Title: for a single item `"<name>: <price>"` (or `"<name> is gone"` / `"<name>: <quantity> listed"` for supply alerts), for a whole-market deal `"<n> deal(s) in <market label>"`; body: what crossed (`"At or below your 12g · 34 listed · Normal Alliance"`); `url` the site path `/item/<key>?m=<market>`, or `/market/alerts` for a whole-market deal (`alertNotice` in `shared/src/market.ts`).
- **Rate and coalescing:** at most one push per account per 30 s. What arrives inside that window waits and goes out together as one notification (`"<n> market alerts"`, kind `group`, url `#/market/alerts`) when the window ends; alerts of one merge go out as one. Pushes held back by quiet hours are dropped, not queued.
- **Payload** (encrypted, JSON): `{v: 1, kind: "market" | "group", title, body, tag: "ahf-market", url, ts}`; `url` is a path of the site (starts with `/`).
- **Delivery:** `POST <endpoint>` with `TTL: 3600`, `Urgency: normal`, `Content-Encoding: aes128gcm` and `Authorization: vapid t=<JWT>, k=<VAPID_PUBLIC_KEY>` (ES256 over `{aud: <endpoint origin>, exp: now + 12 h, sub: VAPID_SUBJECT}`). A 404 or 410 deletes the subscription; any other failure is logged and dropped. The market object that evaluated the alert delivers; its alarm delivers what waited for the window, and the hourly cron sweeps anything left.
