go-wcl is a Go client for the Warcraft Logs v2 GraphQL API.
It handles OAuth 2.0 authentication, request retries, and rate-limit inspection.
Typed methods are generated from the API schema, and Client.Execute runs
arbitrary queries for anything the typed methods do not cover.
- OAuth 2.0 client-credentials, authorization-code, and PKCE flows.
- Type-safe methods and models generated from the live schema with genqlient.
Executeruns any raw GraphQL query the generated methods don't cover.- Automatic retries with exponential backoff, honoring
Retry-After. - Rate-limit inspection and typed error helpers.
- Minimal runtime dependencies.
- Go 1.25 or later.
- A Warcraft Logs API client. Create one on the client management page to get a client ID and secret.
go get github.com/math280h/go-wclpackage main
import (
"context"
"errors"
"fmt"
"log"
warcraftlogs "github.com/math280h/go-wcl"
)
func main() {
ctx := context.Background()
client, err := warcraftlogs.New(ctx,
warcraftlogs.WithClientCredentials("client-id", "client-secret"))
if err != nil {
log.Fatal(err)
}
char, err := client.CharacterByName(ctx, "Asmongold", "area-52", "us")
if errors.Is(err, warcraftlogs.ErrNotFound) {
log.Fatal("character not found")
} else if err != nil {
log.Fatal(err)
}
fmt.Printf("%s - %s (%s)\n", char.Name, char.Server.Name, char.Server.Region.Name)
}Lookups that resolve to a missing entity return
ErrNotFound, as
shown above. An unknown report code is rejected by the API itself and arrives
as a GraphQL error instead. See
GraphQLErrors.
Public data is accessed with the client-credentials flow against
ClientEndpoint:
client, err := warcraftlogs.New(ctx,
warcraftlogs.WithClientCredentials(id, secret))Private data (a user's own reports, CurrentUser) requires the
authorization-code or PKCE flow against UserEndpoint. Use OAuthConfig to
build the authorization URL and exchange the returned code, then pass the token
to the client:
cfg := warcraftlogs.OAuthConfig(id, secret, "https://example.com/callback")
// 1. Redirect the user to the authorization URL.
url := cfg.AuthCodeURL("state-token")
// 2. On the callback, exchange the code for a token.
tok, err := cfg.Exchange(ctx, codeFromCallback)
if err != nil {
log.Fatal(err)
}
// 3. Build a client for the private API.
client, err := warcraftlogs.New(ctx,
warcraftlogs.WithTokenSource(cfg.TokenSource(ctx, tok)),
warcraftlogs.WithEndpoint(warcraftlogs.UserEndpoint))For PKCE (clients that cannot hold a secret), pass an empty secret and use the
verifier options from golang.org/x/oauth2:
cfg := warcraftlogs.OAuthConfig(id, "", "https://example.com/callback")
verifier := oauth2.GenerateVerifier()
url := cfg.AuthCodeURL("state-token", oauth2.S256ChallengeOption(verifier))
tok, err := cfg.Exchange(ctx, codeFromCallback, oauth2.VerifierOption(verifier))examples/userauth is a runnable version of this flow: a
local redirect server, state validation, the PKCE exchange, and a
CurrentUser call against UserEndpoint.
go run ./examples/userauth
go run ./examples/userauth -redirect http://localhost:9000/callbackMethods cover characters, guilds, reports, world data, game data, and users.
// Report header only.
report, err := client.Report(ctx, "aBcDeFgHiJkLmN01", false)
// World data.
zones, err := client.Zones(ctx, 0) // 0 = all expansions
encounter, err := client.Encounter(ctx, 3009)Reports lists a guild's uploaded logs. Identify the guild by GuildID, or by
the name/slug/region trio; UserID lists a user's personal logs instead.
page, err := client.Reports(ctx, warcraftlogs.ReportsParams{
GuildName: "Skill Issue",
GuildServerSlug: "tarren-mill",
GuildServerRegion: "eu",
})
for _, r := range page.Data {
fmt.Printf("%s %s\n", r.Code, r.Title)
}page.Data holds ReportSummary values: code, title, start and end time, zone,
guild and owner. Call Client.Report or Client.ReportWithFights with a code
for the rest. Walk the pages by incrementing Page while page.HasMorePages is
true; Total, PerPage, CurrentPage, LastPage, From and To are also
available.
ReportWithFights returns the header, the fights and the encounter phases
together:
report, err := client.ReportWithFights(ctx, warcraftlogs.ReportWithFightsParams{
Code: "aBcDeFgHiJkLmN01",
KillType: warcraftlogs.KillTypeKills,
})
fmt.Println(report.Title, len(report.Fights))Fields the schema marks nullable are pointers, because for several of them the
Go zero value is also a legal answer. On a trash fight Kill, Difficulty and
Size are all nil; on a boss fight Kill is false for a wipe:
for _, f := range report.Fights {
switch {
case f.Kill == nil:
fmt.Printf("%s (trash)\n", f.Name)
case *f.Kill:
fmt.Printf("%s (kill)\n", f.Name)
default:
fmt.Printf("%s (wipe at %.1f%%)\n", f.Name, *f.FightPercentage)
}
}report.Phases carries the phase metadata for every encounter in the log.
Join it against a fight's observed transitions to answer which phase the raid
was in at a given report-relative timestamp:
fight := report.Fights[0]
if pt, ok := fight.PhaseAt(fight.EndTime); ok {
for _, p := range report.PhasesFor(fight.EncounterID) {
if p.Id == pt.Id {
fmt.Printf("ended in %s\n", p.Name) // e.g. "Stage Three"
}
}
}Prefer PhaseTransitions over the LastPhase* fields: a fight can re-enter a
phase it has already been in, and LastPhase numbers normal phases and
intermissions separately.
ReportMasterData returns every actor in a report alongside the full ability
table. ReportActors filters server-side:
players, err := client.ReportActors(ctx, warcraftlogs.ReportActorsParams{
Code: "aBcDeFgHiJkLmN01",
Type: warcraftlogs.ActorPlayer,
})SubType narrows further: a class for players ("DeathKnight"), or
ActorBoss for NPCs. Leaving a field empty omits that filter.
Rankings, tables, graphs, events, and player details are returned as
json.RawMessage, matching the API's JSON type. Decode them into your own
structs.
data, err := client.CharacterZoneRankings(ctx, warcraftlogs.ZoneRankingsParams{
Character: warcraftlogs.CharacterRef{Name: "Asmongold", ServerSlug: "area-52", ServerRegion: "us"},
Metric: warcraftlogs.CharacterPageRankingMetricTypeDps,
})
table, err := client.ReportTable(ctx, warcraftlogs.TableDataTypeDamageDone,
warcraftlogs.ReportAnalysisParams{Code: "aBcDeFgHiJkLmN01"})CharacterZoneRankings and CharacterEncounterRankings rank a named character.
EncounterLeaderboard is the inverse - the leaderboard for one boss:
top, err := client.EncounterLeaderboard(ctx, warcraftlogs.EncounterLeaderboardParams{
EncounterID: 3009,
ClassName: "Mage",
SpecName: "Fire",
Metric: warcraftlogs.CharacterRankingMetricTypeDps,
})Events are paginated. ReportEventsAll follows the cursor for you and yields
one event at a time, so you never hold more than a page in memory:
params := warcraftlogs.ReportEventsParams{Code: "aBcDeFgHiJkLmN01", FightIDs: []int{12}}
for raw, err := range client.ReportEventsAll(ctx, warcraftlogs.EventDataTypeDeaths, params) {
if err != nil {
log.Fatal(err)
}
var e deathEvent
if err := json.Unmarshal(raw, &e); err != nil {
log.Fatal(err)
}
// ... handle e ...
}Breaking out of the loop stops immediately without fetching another page. If
the API ever returns a cursor that does not move forward, iteration stops with
ErrPageNotAdvancing instead of re-requesting the same page.
ReportEvents returns a single page if you want to drive pagination yourself;
NextPageTimestamp is zero on the last page. Either way, events require either
FightIDs or an explicit StartTime/EndTime range.
examples/analysis is a runnable walkthrough of a real
log: report metadata, a per-boss pull summary, the damage breakdown of a kill,
and every death joined against report master data to resolve actor and ability
names.
go run ./examples/analysis
go run ./examples/analysis -report aBcDeFgHiJkLmN01Execute runs any query and decodes the data field into a pointer. Use it for
operations not covered by the typed methods.
var resp struct {
WorldData struct {
Regions []struct {
ID int `json:"id"`
Name string `json:"name"`
} `json:"regions"`
} `json:"worldData"`
}
err := client.Execute(ctx, `query { worldData { regions { id name } } }`, nil, &resp)The API uses an hourly point budget. Inspect it at any time:
limit, err := client.RateLimit(ctx)
fmt.Printf("%.1f / %d points used, resets in %ds\n",
limit.PointsSpentThisHour, limit.LimitPerHour, limit.PointsResetIn)Requests that return HTTP 429 or 5xx are retried automatically with backoff
(configurable via WithMaxRetries), including the Cloudflare-specific 520-527
range. A Cloudflare challenge page is reported as a CDNError rather than
surfacing as a JSON decode failure.
Helpers classify errors returned by any method:
if _, err := client.Report(ctx, code, false); err != nil {
switch {
case warcraftlogs.IsRateLimited(err):
// HTTP 429.
case warcraftlogs.IsUnauthorized(err):
// HTTP 401 or 403.
case warcraftlogs.IsBlocked(err):
// Cloudflare served a challenge page; the request never reached the API.
var cdn *warcraftlogs.CDNError
errors.As(err, &cdn)
log.Printf("blocked: HTTP %d %q", cdn.StatusCode, cdn.Title)
}
for _, ge := range warcraftlogs.GraphQLErrors(err) {
// e.g. "graphql: reportData.report: This report does not exist."
log.Printf("graphql: %s: %s", ge.Path, ge.Message)
}
if status, ok := warcraftlogs.HTTPStatus(err); ok {
log.Printf("http status: %d", status)
}
}The classifiers key off HTTP status only. The API sends no extensions on its
GraphQL errors and reports a report you may not read as though it does not
exist, so a failure that carries only a GraphQL error is not classifiable
beyond its message. Read GraphQLErrors directly for those.
New accepts functional options:
| Option | Purpose |
|---|---|
WithClientCredentials(id, secret) |
Client-credentials authentication. |
WithTokenSource(ts) |
Authenticate with a caller-provided oauth2.TokenSource. |
WithHTTPClient(hc) |
Use a preconfigured *http.Client verbatim. Supersedes the other auth and transport options, so combining them is an error. |
WithEndpoint(url) |
Override the GraphQL endpoint (e.g. UserEndpoint). |
WithTokenURL(url) |
Override the OAuth token endpoint (default TokenURL). |
WithScopes(scopes...) |
Scopes for the client-credentials flow. |
WithUserAgent(ua) |
Set the User-Agent header. |
WithMaxRetries(n) |
Retry attempts for 429/5xx responses (default 3). |
WithTimeout(d) |
Per-request timeout (default 60s). |
WithBaseTransport(rt) |
http.RoundTripper beneath the retry and auth layers. |
WithLogger(l) |
Log retried requests to a *slog.Logger. Silent by default. |
The typed layer is generated from a committed copy of the schema
(schema/schema.graphql) and the operations under operations/.
Copy .env.example to .env and fill in your credentials:
task # list tasks
task check # fmt check, vet, build, and unit tests
task regenerate # refresh the schema, then regenerate the typed client
task test:integration # run tests against the live APIEach task maps to plain Go commands if you prefer to run them directly:
go generate ./... # regenerate from operations + schema
go -C tools run ./introspect # refresh schema/schema.graphql
go test ./... # unit tests
go test -tags integration ./... # live API tests (skipped without credentials)This project is not affiliated with or endorsed by Warcraft Logs or Blizzard Entertainment. It's an unofficial client, maintained independently. All trademarks belong to their respective owners.
You're bound by the Warcraft Logs terms of service when using their API through this library.
See LICENSE.