GW2 API v2 — client facts
Durable facts about the external Guild Wars 2 API v2, graduated from specs/005-gw2-api-client/research.md (step 6) so later specs don't re-derive them. Verified against the live API and the official wiki (API:Best_practices, API:2) on 2026-07-27. GW2 can change policy or drift static shapes with game patches — re-verify against the live API if something here stops holding.
All GW2 access goes through the one budgeted client, apps/api/src/gw2 (Gw2Service). Never call the GW2 API directly from a service (stack.md).
Authentication & scoped reads
Graduated from specs/018-holdings-and-prices/research.md (live-verified 2026-08-16).
- Auth scheme:
Authorization: Bearer <key>(query?access_token=<key>also works but is avoided — a credential in a URL leaks into logs/caches). Theaccountscope is mandatory on every key. - Auth failures are distinguishable by status:
- Invalid/expired key →
401with body{ "text": "Invalid access token" }. Live-measured, key-format-independent (malformed or well-formed-fake), on/account,/account/materials, and/account/walletalike; a missing header returns the same401. - Valid key missing the required scope →
403with body{ "text": "requires scope <name>" }(e.g.requires scope inventories). Documented; the scope name can be read opportunistically from the bodytext, falling back to the endpoint's statically-known scope. - Map on status (
401vs403), not body. This supersedes spec 016's documented claim that GW2 returns403for a bad key with no body; live GW2 returns401with a body. 016's code is unaffected (it maps both401and403to its unauthorized error), only its rationale — seedocs/gaps/gw2-auth-status.md.
- Invalid/expired key →
- Scoped account endpoints:
/v2/account/materialsneedsaccount+inventories;/v2/account/walletneedswallet.
Rate limit
- Per-IP token bucket: 300 burst, refill 5/sec (300/min),
429on overflow. This is authoritative — hard-code it; overflow is structurally avoidable by budgeting every request through the bucket. - A live response carries
x-rate-limit-limit: 600, which contradicts the documented 300 burst. Do not trust that header — sizing the budget from it doubles the real capacity and eats429s. Retry-Afteron429is undocumented and not guaranteed. The recovery mechanism is bounded exponential backoff (with jitter); honorRetry-Afteronly opportunistically if it happens to appear.
Batching (?ids=)
- Documented as "up to 200 ids per call", but the server rejects at 200 with
HTTP 400 "id list too long; this endpoint is limited to 200 ids at once"— the message is off by one. The effective cap is 199; chunk multi-id reads to ≤199. - Batched
?ids=works on/v2/items,/v2/recipes, and/v2/commerce/pricesalike. /v2/itemssilently ignores unsupported query parameters — same class of trap as the off-by-one cap above. A liveGET /v2/items?rarity=LegendaryreturnsHTTP 200with all 74,054 ids, byte- identical to a bare/v2/items; the parameter isn't rejected, so a caller assuming it filters gets a wrong answer with no error (spec 012 research V5).
Partial and missing ids
- A batched
?ids=where some ids don't exist returnsHTTP 206 Partial Contentwith only the valid ids; invalid ids are silently omitted, not errored. Treat both200and206as success. - Only when every requested id is invalid does the API return
404. A caller asking for a non-existent id gets it omitted from the result, not an exception. /v2/recipes/searchreturns200 []for no matches (never404).
Response shapes (the fields the client depends on)
Validated with Zod at the boundary; unknown extra fields are tolerated (stripped).
/v2/items—id,name,type,rarity,flags[],vendor_value./v2/recipes—id,type,output_item_id,output_item_count,disciplines[],min_rating,flags[],ingredients[] { item_id, count }./v2/recipes/search?input=<id>/?output=<id>— a bare array of recipe ids (number[])./v2/commerce/prices—id,whitelisted,buys { quantity, unit_price },sells { quantity, unit_price }.unit_priceis in copper./v2/currencies—id,name,icon(description,orderpresent and stripped). Public,?ids=-batchable, immutable — cache like/v2/items. Norarity(rarity is an item concept; a wallet view joins currencies for name/icon only). Graduated from spec 018 (live 2026-08-16)./v2/account/materials(auth,inventories) —id(item id),category(material-category id, resolvable against/v2/materials),count,binding?("Account"or omitted). Includescount: 0slots — the raw read returns storage slots the player has zero of. Graduated from spec 018./v2/account/wallet(auth,wallet) —id(currency id, resolvable against/v2/currencies),value. Graduated from spec 018.
Caching posture
Per stack.md: static data (items, recipes) is immutable — cache hard; prices are volatile — short TTL. The client uses in-memory caches: items/recipes/recipe-search with no expiry, prices with a 60 s TTL, each bounded by a size cap. Supporting evidence: /v2/items responses carry cache-control: public, max-age=3600 — upstream itself treats item data as cacheable for an hour.
Per-user authenticated reads (materials/wallet, spec 018) are per-account, not public, so they get a separate cache keyed by <endpoint>:<sha256(apiKey)> with a 5-min TTL — never the bare URL (every user hits the same URL, so a URL-keyed entry would serve one player's holdings to another). The raw key never appears in a cache key (only its hash), a log line, or a thrown error. Cache the raw per-user body and enrich from the static item/currency caches on egress, so display data can't be pinned stale inside a per-user entry.