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

FieldTypeMeaning
statusstring"ok" when the process is answering.
schema_versionintegerThe database schema this hub is running.
sync_format_versionintegerThe PandaQuestSync payload version the ingest endpoint understands.
databasestring"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

ParameterTypeMeaning
searchstringQuest name, or a quest id. Matched case-insensitively; % and _ are literal characters, not wildcards.
zoneintegeruiMapID. Matches the quest's own map or any map it was run in.
minLevel / maxLevelintegerQuest level range, 0-200.
minRunsintegerOnly guides built from at least this many runs.
sortstringpopularity (default), duration, slowest, recent, id.
pageinteger1-based.
pageSizeinteger1-100.

Response

FieldTypeMeaning
items[]arrayquestId, name, level, mapId, runs, completedRuns, medianSeconds, avgSeconds, deathRate, tips, confidence, updatedAt.
totalintegerMatching guides, counted up to the result ceiling.
totalCappedbooleantrue means total stopped at the ceiling: read it as “at least”.
searchTooShortbooleantrue means the term was under the minimum and nothing was searched for.
offsetCappedbooleantrue 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

ParameterTypeMeaning
questIdintegerIn the path. 1-2000000.

Response

FieldTypeMeaning
questId, name, level, mapId-Identity, when a companion has uploaded the quest database.
runs, completedRuns, medianSeconds, avgSeconds-The sample and its timings.
guideobjectThe analyser's output: duration, objectives, hotspots, deaths, level, tips, confidence.
addonEntryobjectThe same guide in the addon's own table shape.
recentRuns[]arrayThe 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.
wowheadstringA 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

FieldTypeMeaning
accounts, characters, sessions, eventsintegerHow much has been uploaded.
questRuns, completedRuns, quests, deathsintegerWhat was derived from it.
guides, guidesWithTimingsintegerHow many quests have a published guide.
medianQuestSecondsnumber|nullThe median of the per-quest medians -- really the median, over the guidesWithTimings quests beside it. null when nothing has been timed yet, never 0.
lastUploadAtstring|nullISO-8601, or null on a hub nobody has uploaded to.
topQuests[], slowestQuests[]arrayFive 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

ParameterTypeMeaning
emailstringThe address the account was registered with.
passwordstringThe account password. Never stored by the hub in any form.
devicestring2-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

FieldTypeMeaning
apiKeystringThe key, shown exactly once. Send it as X-API-Key.
accountintegerThe key's row id, for naming it in a support question.
namestringThe name it was stored under: device, trimmed.
hubUrlstringWhere 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

ParameterTypeMeaning
versionintegerThe sync format version; see /api/v1/health.
charactersobjectKeyed by "Name-Realm": class, race, faction, level, lastSeen.
sessions[]arrayid, character, start, end, and the event stream as {e, t, ...} records.

Response

FieldTypeMeaning
acceptedintegerSessions stored.
duplicatesintegerSessions this account had already uploaded; re-uploading is a no-op.
ackUploadedThroughnumberEverything up to this timestamp is held; the addon prunes by it.
sessions[]arrayPer 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

ParameterTypeMeaning
versionintegerThe PandaQuestAH format version; 1 today.
scans[]array0-5 scans: id, realm, faction, times, and items as {itemKey: "m,v,q,n,nb"}.
sales[]array0-500 receipts: id, kind, item, count, price, buyout, the sold-only figures.

Response

FieldTypeMeaning
acceptedintegerScans and receipts stored by this request, together.
duplicatesintegerIds this account had already uploaded; resending one is a no-op.
rejectedintegerItems that break the contract. A rejection is final.
scans[], sales[]arrayEvery item exactly once, in request order: id, status, and why not.
acceptsSalesbooleanWhether this hub stores your own trades. False means none were kept.
ackUploadedThroughintegerThe 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

FieldTypeMeaning
houses[]arrayregion, 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).
windowDaysarrayThe 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/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

ParameterTypeMeaning
itemIdintegerIn the path. 1-2147483647.
realm / factionstringRequired, as on /search.
regionstringOnly when the realm name is ambiguous.
suffixintegerThe random-enchantment suffix id; 0 (the default) is the base item.

Response

FieldTypeMeaning
itemId, suffixId, name, itemLevel-Identity, when item names have been imported.
windows[]arrayOne per window: days, minUnitBuyout, marketUnitValue, quantity, auctions, averageStack, previousMarketUnitValue, change, scansAtLeast, thin.
suffixes[]arrayEvery variant of this item the house publishes a price for.
mixedSpeciesbooleantrue 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[]arrayThe same item elsewhere: the house, and its 7-day figures.
wowheadstringA 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 toLimitNotes
/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

WhatCap
Page size1-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 termAt least 2 characters, unless it is all digits (a quest id). A shorter term matches nothing and the response sets searchTooShort.
Result counttotal stops at 1000 and sets totalCapped. It is a bound on the count, not on the paging.
Upload body8388608 bytes, checked against Content-Length before the body is read and counted again while it streams.
Auction upload body16777216 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 work4 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 feed5 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

StatusWhenWhat to do
413The upload body is over the size limit.Send fewer sessions per request. The body is refused before it is parsed, so nothing was stored.
422A parameter is outside its cap, or the body does not validate.Read detail and errors; both name the offending field.
429A rate limit.Wait Retry-After seconds. Do not spread the same work over more addresses or keys.
503The 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.