Ethyrial Public API
Read-only JSON over HTTPS. No key, no signup: every endpoint below is public.
- Response shape UTF-8 JSON with lowercase camelCase keys. Prices are integer gold per unit, never floating-point; percentages are rounded to 2 decimals, and timestamps are ISO 8601.
- Base URL
https://ethyrial-web.ryan-fec.workers.dev. All paths below use this same-origin market-data mirror, the only public base URL today. A public game API host will be announced at rollout. - Methods
GETandHEAD. Anything else answers405 {"error": "method not allowed"}with anAllowheader. - Errors HTTP status identifies the failure: 400 for an invalid parameter,
404 for a missing resource, 405 for an unsupported method, and 502 when the upstream is
unreachable with no fallback. JSON bodies carry
error, such as{"error": "invalid page"}; upstream failures addmessage:{"error": "<code>", "message": "<reason>"}. Invalid requests never silently return empty data. - Keys and limits No authentication or published rate limit. Please be reasonable; any future requirements will be documented here.
Markets (public, no key)
Trading post reads. Every response is a JSON object; list endpoints carry page, pageSize, total and rows.
| Endpoint | Returns |
|---|---|
GET /api/markets |
A usage document: the resource vocabulary below, so a caller who guesses the base path gets a map instead of a 404. |
GET /api/markets/catalog |
Every market-tracked item with live price, daily change and a price sparkline, plus the category counts for the whole set. Params page, pageSize (default 24), q (min 2 chars), category, sort = name|price|change|supply|demand, dir = asc|desc. |
GET /api/markets/items |
Item search for pickers. Params q (min 2 chars), limit. |
GET /api/markets/orders |
The live order book for one item, fully-filled orders excluded. Sell side cheapest first, buy side highest bid first. Params itemId (required), side = sell|buy (required), page, pageSize. |
GET /api/markets/trades |
The executed trade ledger for one item, newest first. Params itemId (required), page, pageSize. |
GET /api/markets/bars |
Price bars, oldest to newest. Params itemId (required), interval = daily|weekly (required), take. |
GET /api/markets/summary/{itemId} |
One item's market snapshot: current and previous price, daily change, min and max, supply and demand depth, last trade. An item with no market data answers 404 {"error": "item not found"}. |
GET /api/markets/index |
The whole tracked name list in one cacheable answer, for pickers and deep links that must not page the catalogue. Takes no parameters. rows is an array of compact tuples [itemId, name, category, wikiSlug, presetName] sorted by display name (wikiSlug and presetName are empty strings when absent), with total and linked (rows that carry a wiki slug) counts and source: "fixture". When no index is available the envelope is {"live": false, "reason": "unavailable", "total": 0, "linked": 0, "rows": []} so a caller falls back to /api/markets/items search. |
Site data (public, no key)
The endpoints this website reads for its own rankings and armory surfaces. They take no token today; if that ever changes, this page changes with it.
| Endpoint | Returns |
|---|---|
GET /api/leaderboards |
The rankings snapshot: every board with its categories, metadata and current standings, plus the compute timestamp. |
GET /api/rank-history |
One character's rank and progression on one board, read from every retained snapshot. Params name (required), category (default Combat Level), limit (default 120, max 365). Every point carries the snapshotId and the snapshot's own computedAt, so a series is always traceable to the ingest it came from. Without a D1 binding, or for a name no snapshot carries, the envelope is {"live": false, "reason": "no-d1"|"no-history", "points": []}. |
GET /api/armory |
Without ?name=, the character roster this site can answer for. With ?name=<character>, one character's public sheet: level, vocation, playtime, guild standing, tracked stats and per-slot equipment. An unknown name answers 404 {"error": "Character not found.", "code": "CHARACTER_NOT_FOUND"}. |
GET /api/freshness |
When the game-data snapshot behind these surfaces was generated: generatedAt, a content hash and the source revision. Every page's freshness line reads this. Also answers server (the live cluster stamp, null when unaskable) and parity (match/mismatch/unknown against the pipeline revision). |
GET /api/comments?entity=<wiki path> |
The comment thread for one wiki entity, threaded one level. A removed comment returns with deleted: true and an empty body. The response carries no author identifier, only a display name. Writing happens on the account surface, not here. |
GET /api/guides?slug=<slug> |
The published player guides, or one guide with its rendered body when slug is given. Bodies are rendered from a small markdown subset after escaping; links carry rel="nofollow ugc noopener". No author identifier is returned, and a hidden guide answers 404. |
GET /api/builds?id=<id> |
The published player builds, or one build including its planner payload when id is given. The list never carries a payload. No author identifier is returned, and a hidden build answers 404. |
Example
curl "https://ethyrial-web.ryan-fec.workers.dev/api/markets/orders?itemId=213&side=sell&page=1&pageSize=2"
{
"itemId": 213, "side": "sell", "page": 1, "pageSize": 2, "total": 4,
"rows": [
{ "orderId": 51195, "itemName": "Barbed Arrows", "side": "sell",
"pricePerUnit": 231, "unitsTotal": 220, "unitsRemaining": 220,
"sellerName": "Seedmage", "orderTime": "2026-08-12T04:37:07.000Z" },
{ "orderId": 51201, "itemName": "Barbed Arrows", "side": "sell",
"pricePerUnit": 233, "unitsTotal": 226, "unitsRemaining": 226,
"sellerName": "Seedmage", "orderTime": "2026-08-25T13:19:16.000Z" }
]
}
sellerName is null when the listing character was
since deleted; the row still shows. dailyChangePct is null for items
without a previous price, and spark is empty until an item has two or more daily bars.
Conventions
- Pagination Lists return
page,pageSize,totalandrows.pagemust be a whole number of 1 or more;pageSizemust be 1 to 100. Invalid values are rejected, never clamped:400 {"error": "invalid page"}or400 {"error": "invalid pageSize"}. Past the last page, expectrows: []and the truetotal. - Unknown filter values An unmatched
categoryreturns200withtotal: 0androws: []. Empty results are valid; malformed parameters are errors. - Sorting Sort keys are validated, and each names itself in the error: an
unknown
sortanswers400 {"error": "invalid sort"}, an unknowndiranswers400 {"error": "invalid dir"}. Change-sorted rows place items without a previous price last in both directions. - Deleted data Order-book and trade-ledger rows survive character
deletion. A deleted or missing character record leaves its name
null.
Caching and availability
These endpoints are cached and they tell you how old their data is. A degraded answer says so in its headers rather than quietly looking healthy.
- Cache lifetime Data reads (
/api/markets/*and/api/leaderboards) answerCache-Control: public, max-age=300, stale-while-revalidate=3600: fresh for five minutes, then servable while a refresh happens behind it. The per-request reads (/api/armory,/api/freshness) answerCache-Control: no-storeand are never cached. - Conditional requests
/api/leaderboardssendsETagandLast-Modified. Return the tag inIf-None-Match: an unchanged snapshot answers304with no body./api/markets/*has no validator; conditional requests still return200with the full body, never304. - Cross-origin Every endpoint sends
Access-Control-Allow-Origin: *, so a browser may call these directly. There is no preflight to satisfy: onlyGETandHEADare accepted. - How old is this data Read
x-data-computed-atfor the snapshot time andx-data-age-secondsfor its age. Beyond the freshness window,x-data-stale: truemarks stale data.x-data-sourcenames its origin, not its freshness. Trust these headers over body timestamps. - When the game API is unreachable A last known-good copy returns
200withx-data-stale: true,x-data-stale-reason: upstream-unavailableandCache-Control: no-store. Without a copy, the response is502:{"error": "upstream-unavailable", "message": "<the human readable reason>"}. Branch on the stableerrorcode, not the changeablemessage. Data is never invented, and every outage200is flagged in the headers.
Versioning and limits
Every endpoint states which version of the contract answered, and the one path that costs an upstream read tells a caller when to come back.
- API version Every
/api/response carriesx-api-version: 1. A breaking change to a published shape bumps the number and is documented here; an additive change does not. Pin on the header rather than on a date. - Caller budget on the live reads
/api/live/*is the only path with a caller budget, because it is the only one that spends an upstream credential. When the budget is spent the response is429 {"error": "Too many requests.", "code": "RATE_LIMITED", "retryAfter": 20}withRetry-Afterandx-ratelimit-limit. The window is fixed and the budget is generous by default. The limiter fails open when its ledger is unavailable, so do not pace a client on the assumption that a 429 will arrive: obeyRetry-Afterwhen it does, and back off on5xx.
Webhooks (outbound)
Register an https endpoint on your account and we POST a signed JSON envelope when a selected event happens. The signing secret is shown once.
- Envelope
{"id": "<24 hex>", "type": "leaderboard-change", "occurredAt": "<ISO 8601>", "site": "<origin>", "data": { ... }}.typeis one ofleaderboard-change,patch-notes,market-alertordowntime.datacarries display fields only; an account identifier never appears in a payload. - Headers
X-Ethyrial-Eventnames the type,X-Ethyrial-Deliveryis the envelope id (deduplicate on it),X-Ethyrial-Timestampis when it was signed, andX-Ethyrial-SignatureisHMAC-SHA256(secret, timestamp + "." + body). Verify the signature before trusting the body, and reject a timestamp far from your own clock so a captured delivery cannot be replayed. - Retries A
2xxis accepted.429is retried afterRetry-After,5xxand network errors are retried with backoff, and4xxis not retried. Three attempts total. Answer quickly and process asynchronously. - Endpoints https only, port 443, a public hostname. IP literals,
localhost,.internal, credentials in the URL and fragments are refused, because a subscription is a URL this worker fetches.