Skip to content

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). The account scope is mandatory on every key.
  • Auth failures are distinguishable by status:
    • Invalid/expired key → 401 with body { "text": "Invalid access token" }. Live-measured, key-format-independent (malformed or well-formed-fake), on /account, /account/materials, and /account/wallet alike; a missing header returns the same 401.
    • Valid key missing the required scope → 403 with body { "text": "requires scope <name>" } (e.g. requires scope inventories). Documented; the scope name can be read opportunistically from the body text, falling back to the endpoint's statically-known scope.
    • Map on status (401 vs 403), not body. This supersedes spec 016's documented claim that GW2 returns 403 for a bad key with no body; live GW2 returns 401 with a body. 016's code is unaffected (it maps both 401 and 403 to its unauthorized error), only its rationale — see docs/gaps/gw2-auth-status.md.
  • Scoped account endpoints: /v2/account/materials needs account + inventories; /v2/account/wallet needs wallet.

Rate limit ​

  • Per-IP token bucket: 300 burst, refill 5/sec (300/min), 429 on 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 eats 429s.
  • Retry-After on 429 is undocumented and not guaranteed. The recovery mechanism is bounded exponential backoff (with jitter); honor Retry-After only 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/prices alike.
  • /v2/items silently ignores unsupported query parameters — same class of trap as the off-by-one cap above. A live GET /v2/items?rarity=Legendary returns HTTP 200 with 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 returns HTTP 206 Partial Content with only the valid ids; invalid ids are silently omitted, not errored. Treat both 200 and 206 as 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/search returns 200 [] for no matches (never 404).

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_price is in copper.
  • /v2/currencies — id, name, icon (description, order present and stripped). Public, ?ids=-batchable, immutable — cache like /v2/items. No rarity (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). Includes count: 0 slots — 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.