Export tournament results with the GGS API
Quick overview
Read this guide as Markdown, for tools and assistants that do not run JavaScript.
- Request API access, then create a key under API keys in your GGS account settings. Copy it straight away: it is shown once.
- Call
GET /mewith the key to check it works. - Once a week, list what changed since your last sync:
GET /tournaments?state=completed&updatedSince=<last updatedAt minus 5 minutes>, followingpage.nextto the end. The margin catches changes that committed late (see Keep a copy in sync). - Read each changed tournament with
GET /tournaments/{id}?include=roster,rounds,standings, and delete your copy of each one the feed reports as removed. - Match tournaments and players you already hold through
links. - Save the last
updatedAtyou processed for next week.
Every endpoint, field and error is in the API reference. The same document as OpenAPI 3.1 JSON is at /guides/api/openapi.json, for code generators and API tools.
What the API is for
The API exports the results of tournaments run on GGS Play: who played, every round's pairings and results, and the final standings.
It is built for a scheduled export, typically a weekly poll that picks up newly completed tournaments and any corrections to ones you already hold. It is not built for live scoreboards or for searching across events: there is no flat list of games or players.
You see exactly what the public tournament page shows: published, public events run on GGS Play, cancelled ones included. Private and unpublished events, demos, templates and listings copied from other platforms' calendars never appear. Roster entries appear once accepted, and stay whether active, dropped or withdrawn; pending and rejected registrations never appear. Army lists and factions follow the event's own list visibility. Standings exist once the organiser completes the event; before that they are null.
Every request is a GET under one base URL:
https://data.ggs.club/play/v1
It runs every request next to our database in Ireland.
The examples below keep it and your key in two shell variables:
export GGS_API=https://data.ggs.club/play/v1
export GGS_KEY=ggs_live_...
Get access
API access is granted on request. Sign in to your GGS account, open API keys in the account settings and send the access request with a line about what you are building. We reply by email.
Once your account has access, create a key on the same page. Name it after the integration that will use it: you can hold several keys and revoke each on its own. The key (it starts ggs_live_) is shown once, when you create it. Store it as a secret, never in a web page or a public repository. If you lose it, revoke it and create another.
Your first call
GET /me confirms the key, what it may read and its rate limit.
curl -s "$GGS_API/me" \
-H "Authorization: Bearer $GGS_KEY"
{
"ok": true,
"data": {
"user": { "id": "5b0c…", "handle": "your-handle", "displayName": "Your Name" },
"key": {
"id": "c41e…",
"name": "Results export",
"prefix": "ggs_live_Ab3",
"scopes": ["play:read"],
"rateLimitPerMinute": 30,
"createdAt": "2026-10-19T09:12:44.512Z",
"expiresAt": null
}
},
"requestId": "b7f2…"
}
rateLimitPerMinute is the limit that applies to your key: 30 requests a minute, unless your key has been given its own.
Authentication and errors
Send the key on every request as Authorization: Bearer ggs_live_.... Results need the play:read scope.
Every response uses one envelope. Success is { "ok": true, "data": ..., "requestId": "..." }, with page added on a list. Failure is:
{
"ok": false,
"error": { "code": "not_found", "message": "Tournament not found", "details": null },
"requestId": "b7f2…"
}
Branch on error.code, never on the message. Quote the requestId (also sent as the X-Request-Id header) when you report a problem to us.
401 unauthenticated: no key, or a key we refused. TheWWW-Authenticateheader saysBearerwhen no key was sent andBearer error="invalid_token"when the key is wrong, revoked or expired.403 insufficient_scope: the key works but lacks the scope for this route.404 not_found: no such tournament, or one that is not public. A private tournament answers exactly as a missing one does.400 invalid_input: a path or query parameter is wrong.detailslists each problem as{ "path": ["limit"], "message": "..." }. Every route refuses a query parameter it does not take (so check the spelling), and one sent twice that may be sent only once.405 method_not_allowed: every route is aGET; any other method gets this, with anAllow: GETheader.429 rate_limited: slow down. Wait the number of seconds inRetry-After, then carry on.detailscarries thelimitand itswindow.500 internal: something failed on our side. Quote therequestIdwhen you report it.503 unavailable: a service the request depends on did not answer. Nothing was changed: wait a moment and try again.
The limit is per key, per minute: 30 requests unless your key carries its own (see GET /me). Every response to a key carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset.
Keep a copy in sync
List tournaments by when they last changed, starting a few minutes before the last change you processed:
curl -s "$GGS_API/tournaments?state=completed&updatedSince=2026-10-12T17:59:31.207Z&limit=200" \
-H "Authorization: Bearer $GGS_KEY"
updatedSincereturns changes strictly after the instant you pass, at the millisecond precisionupdatedAthas. Pass the lastupdatedAtyou saw minus a few minutes (five is plenty), not that instant itself: a change is stamped with the moment its database transaction started, so one that takes a while to commit can show up with anupdatedAtjust before your previous poll. Leave it out on your first run.- The margin means you will see some items again. That is expected and safe: applying the same tournament, or the same tombstone, a second time leaves your copy as it was.
- With
updatedSincethe list comes oldest change first (sort=updatedAt, the default;sort=-updatedAtreverses it, and any othersortis refused), so the last item you process is the newest change. - Follow
page.nextuntil it isnull. It is the same request, with the position of the next page, as a full URL. limitis the page size, up to 200.
A tournament's updatedAt moves when its record changes, when its standings table is recomputed, and, once the event is completed, when anything in its roster, teams, rounds, pairings or games changes or its links do (its records on other platforms, its stats publication). So the feed brings you corrections after the event as well as new events: a corrected result, a renamed entry, a changed faction or list, a newly linked BCP record. Joins and pairings while the event runs do not move it; to follow a live event, re-read its roster and rounds with their ETag. Nor does a change outside the event, such as an organiser's or player's GGS profile or the venue's details: re-read the tournament (cheap with its ETag) when you need those current.
An organiser can reopen a completed event to correct it. Until they complete it again it is no longer completed, so it drops out of a state=completed feed without a tombstone: keep your copy. It comes back with a newer updatedAt once completed again.
Tournaments that disappear
When a tournament stops being public (the organiser deletes it, makes it private or unpublishes it, or it is turned into a demo, template or listing), the feed reports it once as a tombstone instead of a tournament:
{ "id": "8d4e…", "slug": "autumn-rtt", "deleted": true, "deletedAt": "2026-10-14T20:11:02.318Z", "updatedAt": "2026-10-14T20:11:02.318Z" }
Check for deleted: true before reading anything else from an item, and delete your copy of that tournament. Tombstones only appear when you pass updatedSince, and then they come through whatever other filters you set, so a state=completed poll still sees them. A tombstone's updatedAt equals its deletedAt, so save your position from the last item's updatedAt whatever it was.
We keep tombstones for about 30 days. If your last sync is older than that, you may have missed a removal: run a full sync (no updatedSince) and drop every tournament in your copy that it does not return.
Cheap re-reads
Every 200 carries an ETag. Send it back as If-None-Match on the same request and an unchanged resource answers 304 Not Modified with no body. Send the value back as you received it: a weak tag such as W/"3f9a…", quotes included.
export ID=<tournament id>
# Read the tournament once, saving the body and keeping its ETag
ETAG=$(curl -s -o tournament.json -D - "$GGS_API/tournaments/$ID?include=roster,rounds,standings" \
-H "Authorization: Bearer $GGS_KEY" | tr -d '\r' | awk 'tolower($1) == "etag:" { print $2 }')
# Later: 304 while nothing has changed, 200 with the new body when it has
curl -s -o /dev/null -w "%{http_code}\n" "$GGS_API/tournaments/$ID?include=roster,rounds,standings" \
-H "Authorization: Bearer $GGS_KEY" \
-H "If-None-Match: $ETAG"
For a completed tournament that has its final results, the 304 from /tournaments/{id} and its /roster, /rounds, /standings and /links routes is answered without reading the event and does not count toward your rate limit, so checking a stored copy costs you nothing. Every other 304 still counts: one for an event that is not completed, any from the /tournaments list, and one answered to If-None-Match: *. A key already over its limit gets 429 before any 304.
Match records you already hold
If you already hold an event or a player from another platform, use links to find the GGS record that matches it rather than matching on names. Each link is the same thing on another platform:
{
"platform": "bcp",
"kind": "tournament",
"externalId": "Ab12Cd34Ef",
"url": "https://www.bestcoastpairings.com/event/Ab12Cd34Ef",
"relations": ["mirrored_to"],
"confidence": "platform"
}
- Match on
platform+kind+externalId. - A tournament's links name the event (
kind: tournament). A roster entry's links name that entry on the other platform (kind: entry) and the player's account there (kind: player, the same id in every event they play). relationssays how the two relate. On a tournament,mirrored_tomeans GGS holds the official results and the other record is a copy;imported_frommeans the other way round.resultsAuthorityon the tournament says the same thing.confidencesays how the link was made.heuristicmeans matched by name: treat it with care.
url is the record's page on the other platform where we know it exactly, otherwise null. Build nothing from it: match on externalId.
To check up to 50 BCP events in one call:
curl -s "$GGS_API/tournaments?platform=bcp&externalId=Ab12Cd34Ef&externalId=Gh56Ij78Kl" \
-H "Authorization: Bearer $GGS_KEY"
Read the results
One call gets a tournament with everything it finished with:
curl -s "$GGS_API/tournaments/$ID?include=roster,rounds,standings" \
-H "Authorization: Bearer $GGS_KEY"
roster, rounds, standings and links also have routes of their own under /tournaments/{id}/, which suit a very large event better.
Roster
Each entry is one player in the event: its id, the player's names, playerId (the GGS account behind the entry, when one has claimed it), faction, status, the team on a team or doubles event and the links described above. A faction carries its kind (a faction, an army of renown or infamy, or a legacy army) and its parent faction where it has one.
Rounds
Each round lists its pairings. A pairing has sides, one per player, each with the rosterId, the score, the result (win, draw, loss, or null while the game is unplayed) and the battlePoints:
{
"id": "e0a1…",
"isBye": false,
"status": "completed",
"sides": [
{ "rosterId": "71c2…", "score": 2766, "result": "win", "battlePoints": 16 },
{ "rosterId": "9f30…", "score": 1076, "result": "loss", "battlePoints": 4 }
]
}
Take the outcome from result, not by comparing scores: the result is authoritative, and some formats decide a game on more than its score.
battlePoints is what the game earned each side under the event's points table (a 20-0 battle points table, The Old World's 6-0 victory points table, 3 for a win and 1 for a draw, and so on), exactly as the standings count it. It is null while the game is unplayed and on an event that ranks without a points table. Once the event is completed, the battle points come from the same computation that froze the standings, so summing a player's games gives the figure in their standings row.
A bye has isBye: true and a single side, with no opponent. Its score and result are the ones the standings count, as the event's rules say: an automatic bye is a win with the event's default bye score (which depends on the game system), a bye the organiser scored by hand carries what they entered instead, and a bye the event does not count as a game has no score and no result:
{
"id": "4b7d…",
"isBye": true,
"status": "completed",
"sides": [
{ "rosterId": "c530…", "score": 51, "result": "win", "battlePoints": 11 }
]
}
A bye's battle points are at least the smallest win on the event's table, whatever its score converts to, as the standings count it.
Nobody played a bye, so leave it out of game-level and faction statistics (win rates, matchups, average scores), while still counting it where you rebuild a player's results.
On a team or doubles event each round also has matchups, one per pair of teams, whose sides carry the teamId, the games each team won (including boards the other team could not fill), the team's battlePoints and the matchup result; each pairing names its matchupId and its board within the matchup as slot. A doubles matchup is a single game, so it has one pairing, in slot 1. Once the event is completed, its matchups are the ones frozen with its standings, so a roster or settings change made afterwards cannot make a round disagree with the table.
A team matchup's battlePoints are its games' battle points plus the table maximum for each board the other team could not fill, so its pairings add up to it. That holds where the event's points table counts margins (a 20-0 battle points table, say). A table that counts results only (3 for a win, 1 for a draw) gives each game its points but leaves the matchup's battlePoints null: the team standings have no battle points to count then.
Two optional extras: include=breakdown adds each side's score components, and include=setup adds the round's name, times, mission details, boards and table numbers. Name several extras comma-separated (include=breakdown,setup) or by repeating the key (include=breakdown&include=setup); both work everywhere include does. On /tournaments/{id} they only take effect alongside rounds (include=rounds,breakdown); on /tournaments/{id}/rounds they work on their own.
Standings
standings.players holds columns and rows. The columns describe the table the event's scoring method produces, in display order: each has a key, a label, a format and rank (true when it decides placings). A column that joins other figures, such as a win-draw-loss record, lists their keys in parts. Today only the team table (standings.teams) has such columns: the player table shows every figure in a column of its own.
Each row has its placing, rosterId, status, a record (played, wins, draws, losses) and figures, one { key, value } pair (the value may be null) for every column key:
{
"placing": 1,
"rosterId": "71c2…",
"status": "active",
"record": { "played": 5, "wins": 5, "draws": 0, "losses": 0 },
"figures": [
{ "key": "games", "value": 5 },
{ "key": "points", "value": 15 },
{ "key": "vp_for", "value": 412 },
{ "key": "vp_against", "value": 300 }
]
}
Not every roster entry gets a row: a spare who never played a game is not ranked, and neither is a player who dropped before playing. A row whose record.played is 0 is an entry that never played but stayed on the roster (a no-show the organiser did not drop).
To draw the table the organiser saw, turn each row's figures into a map (Object.fromEntries(row.figures.map(f => [f.key, f.value])) in JavaScript), then walk columns in order and read each column's key from it. A column with parts (in the team table: a win-draw-loss record, games won-drawn-lost, VP for and against) has no figure under its own key: read each of its parts from the map instead and show them together, joined with hyphens for a record (3-1-0) and an en dash for a pair (40–20). Columns differ from event to event, so read them rather than expecting fixed keys. On a team event, standings.teams has the same layout with teamId and name in place of rosterId. On a doubles event each player row is a pair: team names it, and rosterId is the captain's entry (null if the pair has no entry left on the roster).
Rows carry no names. Join rosterId to the roster's id, which include=roster gives you in the same response:
curl -s "$GGS_API/tournaments/$ID?include=roster,standings" \
-H "Authorization: Bearer $GGS_KEY" |
jq '.data as $t
| ($t.roster | map({(.id): .fullName}) | add) as $names
| ($t.standings.players.rows // [])[]
| {placing, name: (.team.name // $names[.rosterId // ""]), figures: (.figures | map({(.key): .value}) | add)}'
It prints nothing while the event has no standings (standings is null), names a doubles row after its pair (team), and copes with a rosterId of null. A row whose rosterId is not on the roster belongs to an entry that has since left the public roster: keep its placing and show no name.
Names and identifiers
A roster entry carries firstName and lastName as the player typed them (either can be null on older entries) and fullName, which is always set. Use fullName to show a player, never to match one: match on playerId (the same GGS account in every event) or on the entry's links (the player on another platform).
Store tournaments by id. The slug follows the event's name and changes when the organiser renames it; webUrl uses the id, so it keeps working. GET /tournaments/{id} also accepts the current slug (in any case) in place of the id, but the routes under it (/roster, /rounds, /standings, /links) take the id only.
Compatibility
We add to this API without warning, so build your client to cope:
- Ignore fields you do not recognise.
- Expect new values in any enumeration (a new game system, platform or round status, say) and handle an unknown one gracefully, for example by skipping the item or storing the raw value.
- Expect new
includeoptions and new routes.
None of these count as breaking changes. Removing or renaming a field, or changing what one means, would be, and does not happen within this version.
Keeping copies current
Using the API comes with one condition: keep your copy current. Re-sync at least as often as you show the data (weekly is the expected rhythm), and when something changes or disappears here, apply it to your copy. That means corrected results and names, entries removed from an event, and tournaments the feed reports as removed. Players and organisers fix and withdraw things after the fact, and they expect the correction to reach every copy.