API
Five routes are meant for other people's software. They are listed here in full, and they are the only ones this hub supports. Everything else the server answers is internal – accounts, sessions, passkeys, export and erasure – and will change without notice.
Start here
Base URL http://pd.zroot.it. Everything is JSON over plain HTTP, everything is
GET except the upload, and only the upload needs a key.
curl 'http://pd.zroot.it/api/v1/health'
Need a key? Get one from your account. Reading needs no key at all,
so if you only want the data, start with the two quest routes below or take the whole guide set
in one download: http://pd.zroot.it/api/v1/export/community.json.
GET /api/v1/health
no key needed
The liveness check, and the version handshake a companion does before it uploads: sync_format_version is the shape of the payload POST /api/v1/ingest expects.
Response
| Field | Type | Meaning |
|---|---|---|
status | string | "ok" when the process is answering. |
schema_version | integer | The database schema this hub is running. |
sync_format_version | integer | The PandaQuestSync payload version the ingest endpoint understands. |
database | string | "sqlite" or "external". |
Example
curl 'http://pd.zroot.it/api/v1/health'
Answers 200 with status, schema_version, sync_format_version, database.
Authentication
None.
Rate limit
600 requests a minute, per address. Shared by every cheap read. These answer from one indexed row or from memory.
Over it you get 429 with a Retry-After header in seconds; wait that
long rather than retrying immediately.
GET /api/v1/quests
no key needed
The list behind the site's own quest table. One row per quest guide: how many runs it was built from, the median time, the death rate and how confident the analysis is.
Request
| Parameter | Type | Meaning |
|---|---|---|
search | string | Quest name, or a quest id. Matched case-insensitively; % and _ are literal characters, not wildcards. |
zone | integer | uiMapID. Matches the quest's own map or any map it was run in. |
minLevel / maxLevel | integer | Quest level range, 0-200. |
minRuns | integer | Only guides built from at least this many runs. |
sort | string | popularity (default), duration, slowest, recent, id. |
page | integer | 1-based. |
pageSize | integer | 1-100. |
Response
| Field | Type | Meaning |
|---|---|---|
items[] | array | questId, name, level, mapId, runs, completedRuns, medianSeconds, avgSeconds, deathRate, tips, confidence, updatedAt. |
total | integer | Matching guides, counted up to the result ceiling. |
totalCapped | boolean | true means total stopped at the ceiling: read it as “at least”. |
searchTooShort | boolean | true means the term was under the minimum and nothing was searched for. |
offsetCapped | boolean | true means the page was deeper than the hub will page; items is empty but total still counts. |
page / pageSize / pages / sort | - | The query as it was actually applied. |
Example
curl 'http://pd.zroot.it/api/v1/quests?pageSize=3&sort=popularity'
Answers 200 with items, total, page, pageSize, pages, sort.
Authentication
None.
Rate limit
120 requests a minute, per address. One budget shared by all of them, not one each: these search, group or aggregate over whole tables, so moving between them does not buy more requests. The hub also caps how many may run at once, whoever asks; over that you get 503 with a Retry-After.
Over it you get 429 with a Retry-After header in seconds; wait that
long rather than retrying immediately.
Worth knowing
- Only quests that pass the publication rule appear here: a guide is written when enough distinct characters, from more than one account, have done the quest. A hub with few uploaders legitimately returns an empty list.
- Every timing is a median over real runs and carries the sample size it came from. None of it is authoritative game data.
GET /api/v1/quests/{questId}
no key needed
The full guide: objective order, hotspots, where people died, how long it took and the tips derived from that. addonEntry is the exact table this quest contributes to the addon's Community.lua, which is what an addon author usually wants.
Request
| Parameter | Type | Meaning |
|---|---|---|
questId | integer | In the path. 1-2000000. |
Response
| Field | Type | Meaning |
|---|---|---|
questId, name, level, mapId | - | Identity, when a companion has uploaded the quest database. |
runs, completedRuns, medianSeconds, avgSeconds | - | The sample and its timings. |
guide | object | The analyser's output: duration, objectives, hotspots, deaths, level, tips, confidence. |
addonEntry | object | The same guide in the addon's own table shape. |
recentRuns[] | array | The last ten completions, of public characters only, newest first: character, seconds, level, deaths. No turn-in time, because a character's own route lists the quests it turned in. |
wowhead | string | A link out, for the static quest facts this hub does not hold. |
Example
curl 'http://pd.zroot.it/api/v1/quests/1'
Answers 404 as written, with a detail
string saying why – fill in the parts in angle brackets and it answers 200.
Authentication
None.
Rate limit
600 requests a minute, per address. Shared by every cheap read. These answer from one indexed row or from memory.
Over it you get 429 with a Retry-After header in seconds; wait that
long rather than retrying immediately.
Worth knowing
- 404 when no guide has been published for that quest. That is also the answer when a quest has runs but not from enough different people: whether a row exists is itself the fact being withheld.
GET /api/v1/stats
no key needed
What the dashboard header prints: how much data this hub is built on, and how recent it is.
Response
| Field | Type | Meaning |
|---|---|---|
accounts, characters, sessions, events | integer | How much has been uploaded. |
questRuns, completedRuns, quests, deaths | integer | What was derived from it. |
guides, guidesWithTimings | integer | How many quests have a published guide. |
medianQuestSeconds | number|null | The median of the per-quest medians -- really the median, over the guidesWithTimings quests beside it. null when nothing has been timed yet, never 0. |
lastUploadAt | string|null | ISO-8601, or null on a hub nobody has uploaded to. |
topQuests[], slowestQuests[] | array | Five each, with their run counts. |
Example
curl 'http://pd.zroot.it/api/v1/stats'
Answers 200 with accounts, characters, questRuns, guides, topQuests.
Authentication
None.
Rate limit
120 requests a minute, per address. One budget shared by all of them, not one each: these search, group or aggregate over whole tables, so moving between them does not buy more requests. The hub also caps how many may run at once, whoever asks; over that you get 503 with a Retry-After.
Over it you get 429 with a Retry-After header in seconds; wait that
long rather than retrying immediately.
Worth knowing
- A figure the data cannot support is null, not zero. An empty hub answers with zeroes for the counts and null for the medians, which is the honest distinction between “nothing happened” and “nothing is known”.
POST /api/v1/auth/companion
key required
How a desktop companion gets its credential without a person copying one out of a browser: send the player's email address, their password and a name for the machine, and get back one API key named after that machine.
Request
| Parameter | Type | Meaning |
|---|---|---|
email | string | The address the account was registered with. |
password | string | The account password. Never stored by the hub in any form. |
device | string | 2-64 characters, a name for this computer. It becomes the key's name and is shown on the key page, so use something the player would recognise. |
Response
| Field | Type | Meaning |
|---|---|---|
apiKey | string | The key, shown exactly once. Send it as X-API-Key. |
account | integer | The key's row id, for naming it in a support question. |
name | string | The name it was stored under: device, trimmed. |
hubUrl | string | Where this hub answers. A client that already works may ignore it. |
Example
curl \
-X POST \
-H 'Content-Type: application/json' \
-d '{"email":"[email protected]","password":"<your password>","device":"living room PC"}' \
'http://pd.zroot.it/api/v1/auth/companion'
Answers 401 as written, with a detail
string saying why – fill in the parts in angle brackets and it answers 200.
Authentication
None to call it; the body is the credential.
Rate limit
10 attempts a minute per address, and 20 failed attempts an hour per account. The address bucket is spent on arrival and once more in middleware, before the body is parsed at all. The account bucket counts failures only and a correct password clears it, so somebody guessing at your address can slow you down but can never lock you out of your own companion.
Over it you get 429 with a Retry-After header in seconds; wait that
long rather than retrying immediately.
Worth knowing
- One key per device. Signing in again with the same device revokes that device's key and mints another, so a reinstall does not leave a second key behind; signing in from a second computer with a different name leaves both working.
- Every refusal carries a stable code beside its sentence, so a client can branch on the code and print the sentence: insecure_transport (403), rate_limited_address and rate_limited_account (429), login_failed and email_unverified (401), too_many_keys and too_many_keys_for_address (429). A 422 has no code -- it means the body was the wrong shape. login_failed is the one answer for a wrong password, an unknown address, a disabled account and an account that has no password: the hub does not say which.
- Over plain http the request is refused unless the caller is on the local network (loopback, a private range or link-local). Use the hub's https address from anywhere else; the password is not worth less than the key it fetches. The same refusal comes back when the hub sits behind a proxy it has not been told to trust, because it cannot then tell which connection the request arrived on -- that one is the operator's to fix (PQ_TRUST_FORWARDED_FOR), and the hub's own log says so.
- Resetting the account password revokes every key it has minted, and so does “sign out every device” on the key page. Both are immediate: the next upload is answered 401.
POST /api/v1/ingest
key required
The one write on this hub. The body is the JSON projection of the addon's PandaQuestSync saved variable: the characters table, plus every session that has not been acknowledged yet.
Request
| Parameter | Type | Meaning |
|---|---|---|
version | integer | The sync format version; see /api/v1/health. |
characters | object | Keyed by "Name-Realm": class, race, faction, level, lastSeen. |
sessions[] | array | id, character, start, end, and the event stream as {e, t, ...} records. |
Response
| Field | Type | Meaning |
|---|---|---|
accepted | integer | Sessions stored. |
duplicates | integer | Sessions this account had already uploaded; re-uploading is a no-op. |
ackUploadedThrough | number | Everything up to this timestamp is held; the addon prunes by it. |
sessions[] | array | Per session: whether it was stored, and why not when it was not. |
Example
curl \
-X POST \
-H 'Content-Type: application/json' \
-H 'X-API-Key: pq_<your key>' \
-d '{"version":1,"characters":{},"sessions":[]}' \
'http://pd.zroot.it/api/v1/ingest'
Answers 401 as written, with a detail
string saying why – fill in the parts in angle brackets and it answers 200.
Authentication
Required. X-API-Key, from /account/keys. Sign in to mint one.
Rate limit
60 uploads a minute per address, and 30 a minute per API key. The per-address bucket is applied before the body is read, so an oversized or too-frequent upload costs the hub nothing. Bodies over 8 MB are refused with 413 before parsing.
Over it you get 429 with a Retry-After header in seconds; wait that
long rather than retrying immediately.
Worth knowing
- Uploading requires the uploader's consent to be on record. Without it the answer is 403 and nothing is stored -- see the privacy statement.
- Idempotent by (account, session id): a companion may safely resend a saved variable it is not sure got through.
- Bodies over the size limit are refused with 413 before anything is parsed, and the per-request caps on sessions, characters and events are enforced before any write.
POST /api/v1/auctions/ingest
key required
The second write. The body is the JSON projection of the addon's PandaQuestAH saved variable: the price scans it has taken, and the mail receipts for what you sold and bought. The full contract is docs/08 C1, which this endpoint implements exactly.
Request
| Parameter | Type | Meaning |
|---|---|---|
version | integer | The PandaQuestAH format version; 1 today. |
scans[] | array | 0-5 scans: id, realm, faction, times, and items as {itemKey: "m,v,q,n,nb"}. |
sales[] | array | 0-500 receipts: id, kind, item, count, price, buyout, the sold-only figures. |
Response
| Field | Type | Meaning |
|---|---|---|
accepted | integer | Scans and receipts stored by this request, together. |
duplicates | integer | Ids this account had already uploaded; resending one is a no-op. |
rejected | integer | Items that break the contract. A rejection is final. |
scans[], sales[] | array | Every item exactly once, in request order: id, status, and why not. |
acceptsSales | boolean | Whether this hub stores your own trades. False means none were kept. |
ackUploadedThrough | integer | The largest finishedAt this hub holds for you, or null. |
Example
curl \
-X POST \
-H 'Content-Type: application/json' \
-H 'X-API-Key: pq_<your key>' \
-d '{"version":1,"scans":[],"sales":[]}' \
'http://pd.zroot.it/api/v1/auctions/ingest'
Answers 401 as written, with a detail
string saying why – fill in the parts in angle brackets and it answers 200.
Authentication
Required. X-API-Key, from /account/keys -- the same key the telemetry upload uses.
Rate limit
60 uploads a minute per address, and 20 a minute per API key. A budget of its own, not shared with the telemetry upload: a companion round does both, and one must never be the other's rate limit. This path also allows a larger body -- 16 MB, because one full scan of a busy auction house is several of them.
Over it you get 429 with a Retry-After header in seconds; wait that
long rather than retrying immediately.
Worth knowing
- Auction data has its own consent, separate from telemetry: without it the answer is 403 and nothing is stored. Your own sales need a second consent on top of it, which is what acceptsSales reports.
- Idempotent by (account, item id): a scan keeps the id the addon gave it, and a receipt's id is derived from its own fields, so resending is always safe.
- The empty body {"version":1,"scans":[],"sales":[]} is a valid probe and answers 200. This path never answers 404 or 405 -- those mean the hub has no auction API at all.
- One item breaking a rule is that item's rejected, not an error for the request. The request fails whole only on a malformed envelope (422) or over the caps (413).
GET /api/v1/auctions/realms
no key needed
The index of the price API: one entry per auction house, with the realm and faction spellings every other auction call expects back. Start here, then search one of them.
Response
| Field | Type | Meaning |
|---|---|---|
houses[] | array | region, regionLabel, realm, realmLabel, faction, items -- items being how many distinct items have a published 7-day figure, counted as /sources counts them (a suffix variant is a second price series, not a second item). |
windowDays | array | The window lengths every price here is a median over: 7 and 30. |
Example
curl 'http://pd.zroot.it/api/v1/auctions/realms'
Answers 200 with houses, windowDays.
Authentication
None.
Rate limit
600 requests a minute, per address. Shared by every cheap read. These answer from one indexed row or from memory.
Over it you get 429 with a Retry-After header in seconds; wait that
long rather than retrying immediately.
Worth knowing
- No freshness anywhere in this answer, and that is deliberate rather than missing. On a hub with a handful of uploaders, “last scanned two hours ago” is one person's afternoon; the windows are whole UTC days and nothing here carries a date.
- An empty list is a correct answer and the ordinary one on a new hub: prices appear at the first daily rebuild after somebody scans.
GET /api/v1/auctions/search
no key needed
The price list behind the site's own /auctions table: median cheapest buyout, median market value, quantity and average stack for each item, over the last 7 complete UTC days, with the change against the 7 days before them.
Request
| Parameter | Type | Meaning |
|---|---|---|
realm | string | Required. The realm name, matched case-insensitively. |
faction | string | Required. Horde, Alliance or Neutral. |
region | string | Only needed when one realm name has prices in more than one region; without it that case is a 400. |
q | string | 1-9 digits is an item id; anything else is matched against the item name. % and _ are literal characters, not wildcards. |
sort | string | name (default), item, min, market, quantity, change. |
order | string | asc (default) or desc. |
page | integer | 1-based. |
pageSize | integer | 1-100. |
Response
| Field | Type | Meaning |
|---|---|---|
house | object | The auction house this answer is about, as /realms names it. |
items[] | array | itemId, suffixId, name, minUnitBuyout, marketUnitValue, quantity, auctions, averageStack, change7d, scansAtLeast, thin, wowhead. |
windowDays | integer | 7 -- the window this list is a median over. |
scansAtLeast | integer | How many scans are behind the figure, rounded down to a band (1, 2, 3, 5, 10, 25, 50, 100, 250, 500, 1000). Never the exact count. |
thin | boolean | true under three scans: a real observation, not yet a market price. |
itemNamesImported | boolean | false on a hub where the operator has not imported item names; every name is then null and only item ids can be searched for. |
total / totalCapped / searchTooShort / offsetCapped | - | The same four caps the quest list reports, with the same meanings. |
Example
curl 'http://pd.zroot.it/api/v1/auctions/search?realm=hoptallus&faction=Horde&pageSize=3'
Answers 200 with house, items, total, windowDays, itemNamesImported.
Authentication
None.
Rate limit
120 requests a minute, per address. One budget shared by all of them, not one each: these search, group or aggregate over whole tables, so moving between them does not buy more requests. The hub also caps how many may run at once, whoever asks; over that you get 503 with a Retry-After.
Over it you get 429 with a Retry-After header in seconds; wait that
long rather than retrying immediately.
Worth knowing
- Not one field in this answer is a time. No date, no hour, no scan id, no upload time, no “last seen”: a price is a fact about the auction house, but the moment somebody saw it is a fact about them. See the privacy statement.
- A suffix variant is its own price series and its own row, because a random enchantment makes it a different thing to buy.
- 404 when the realm and faction name no auction house with published prices -- which is also the answer for a house that exists and has published nothing yet.
GET /api/v1/auctions/item/{itemId}
no key needed
What the item page draws: the 7- and 30-day medians side by side, the previous window they are compared against, the suffix variants that are their own series, and the same item's price in every other auction house this hub has.
Request
| Parameter | Type | Meaning |
|---|---|---|
itemId | integer | In the path. 1-2147483647. |
realm / faction | string | Required, as on /search. |
region | string | Only when the realm name is ambiguous. |
suffix | integer | The random-enchantment suffix id; 0 (the default) is the base item. |
Response
| Field | Type | Meaning |
|---|---|---|
itemId, suffixId, name, itemLevel | - | Identity, when item names have been imported. |
windows[] | array | One per window: days, minUnitBuyout, marketUnitValue, quantity, auctions, averageStack, previousMarketUnitValue, change, scansAtLeast, thin. |
suffixes[] | array | Every variant of this item the house publishes a price for. |
mixedSpecies | boolean | true for an id whose median covers things that are not one thing -- the caged battle pet, which reaches the hub as one item id for every species at once. |
otherHouses[] | array | The same item elsewhere: the house, and its 7-day figures. |
wowhead | string | A link out, to the base item. |
Example
curl 'http://pd.zroot.it/api/v1/auctions/item/72092?realm=hoptallus&faction=Horde'
Answers 200 with itemId, house, windows, otherHouses, suffixes.
Authentication
None.
Rate limit
600 requests a minute, per address. Shared by every cheap read. These answer from one indexed row or from memory.
Over it you get 429 with a Retry-After header in seconds; wait that
long rather than retrying immediately.
Worth knowing
- There is no days= parameter and there will not be one. A public price series would be a list of dates, and the dates are the uploader's, not the item's.
- 404 when this auction house has no 7-day figure for that exact item and suffix.
Not built yet
These are planned and are not implemented. There is no request or response shape for them here, because a contract published before the code exists is a contract nobody has to keep – and a client written against it would break on the day it became real.
GET /api/v1/auctions/export/prices.lua- The community prices as a Lua table, for an addon to read prices out of the auction house it has not scanned. Not implemented: nothing would read it -- the addon has no loader and no TOC entry for it, the companion does not download it, and no format has been agreed. The prices themselves are here already, under /api/v1/auctions/search.
Limits
All of these are configuration on this hub, shown here with the values it is actually running. A well-behaved client never reaches any of them.
Rate limits
| Applies to | Limit | Notes |
|---|---|---|
/api/v1/health/api/v1/quests/{id}/api/v1/live/recent/api/v1/auctions/realms/api/v1/auctions/item/{itemId} |
600 requests a minute, per address | Shared by every cheap read. These answer from one indexed row or from memory. |
/api/v1/quests/api/v1/characters/api/v1/characters/{key}/api/v1/stats/api/v1/auctions/search |
120 requests a minute, per address | One budget shared by all of them, not one each: these search, group or aggregate over whole tables, so moving between them does not buy more requests. The hub also caps how many may run at once, whoever asks; over that you get 503 with a Retry-After. |
/api/v1/ingest |
60 uploads a minute per address, and 30 a minute per API key | The per-address bucket is applied before the body is read, so an oversized or too-frequent upload costs the hub nothing. Bodies over 8 MB are refused with 413 before parsing. |
/api/v1/auctions/ingest |
60 uploads a minute per address, and 20 a minute per API key | A budget of its own, not shared with the telemetry upload: a companion round does both, and one must never be the other's rate limit. This path also allows a larger body -- 16 MB, because one full scan of a busy auction house is several of them. |
/api/v1/auth/companion |
10 attempts a minute per address, and 20 failed attempts an hour per account | The address bucket is spent on arrival and once more in middleware, before the body is parsed at all. The account bucket counts failures only and a correct password clears it, so somebody guessing at your address can slow you down but can never lock you out of your own companion. |
/api/v1/export/community.lua/api/v1/export/community.json |
60 downloads a minute, per address | Renders are limited separately (6 a minute): the default parameters are cached and shared, so a client that asks for the default shape and sends If-None-Match effectively never renders anything. |
Parameter ceilings
| What | Cap |
|---|---|
| Page size | 1-100 rows. Larger is a 422, not a truncation. |
| Page depth | (page - 1) x pageSize must not exceed 10000. A deeper page returns no rows and sets offsetCapped -- total still says how many matched, so it never reads as “there is no more”. Take the export rather than walking the pages. |
| Search term | At least 2 characters, unless it is all digits (a quest id). A shorter term matches nothing and the response sets searchTooShort. |
| Result count | total stops at 1000 and sets totalCapped. It is a bound on the count, not on the paging. |
| Upload body | 8388608 bytes, checked against Content-Length before the body is read and counted again while it streams. |
| Auction upload body | 16777216 bytes on POST /api/v1/auctions/ingest alone -- one full scan of a busy auction house is several megabytes, and that path is the only one allowed to be. Everything else keeps the ceiling above. |
| Concurrent heavy work | 4 searches, character pages, stats or export renders at once, hub-wide. Over that a request waits up to 5s and is then answered 503 with a Retry-After. |
| Live feed | 5 websockets per address, 60 client frames a minute and 4096 bytes a frame. A connection is closed after 3600s; reconnect, or poll /api/v1/live/recent. |
What a refusal looks like
| Status | When | What to do |
|---|---|---|
413 | The upload body is over the size limit. | Send fewer sessions per request. The body is refused before it is parsed, so nothing was stored. |
422 | A parameter is outside its cap, or the body does not validate. | Read detail and errors; both name the offending field. |
429 | A rate limit. | Wait Retry-After seconds. Do not spread the same work over more addresses or keys. |
503 | The hub is already doing as much expensive work as it will do at once. | Wait Retry-After seconds and repeat the request; nothing is wrong with it. |
Terms of use
- The data is self-reported
- Every number here came from players running an addon that they installed themselves. It is not authoritative game data, it can be wrong, and it can be gamed. Say where it came from if you republish it.
- Aggregates only, and no identification
- The quest guides are published only when they are aggregates over several different people. Hidden characters are absent from every response, and so are the characters of anyone who has restricted processing of their data or asked to be erased -- a client that stops seeing a character is being told nothing about why. Do not use this API to work out where a particular player is or was, and do not try to re-identify anyone from what it returns. That is the one use of this service that is not allowed.
- Stay inside the limits
- The limits above are the contract. Cache what you fetch, send If-None-Match on the export, and do not spread requests over addresses or keys to get around a bucket. A client that does is a client the operator will block.
- No warranty, no stability promise
- This is a hobby hub run by one person. It can go away, lose data, or change these routes. Nothing here is a service level agreement. Only the routes on this page are supported at all -- anything else the server answers is internal and will change without notice.
- Not affiliated with Blizzard
- World of Warcraft and its content are Blizzard Entertainment's. This is a fan project and has no connection to them.
The full text is on the terms page, and what the hub does with personal data is in the privacy statement.