# Export tournament results with the GGS API

## Quick overview

Read this guide [as Markdown](/guides/api.md), for tools and assistants that do not run JavaScript.

1. Request API access, then create a key under [API keys](https://account.ggs.club/settings/api-keys) in your GGS account settings. Copy it straight away: it is shown once.
2. Call `GET /me` with the key to check it works.
3. Once a week, list what changed since your last sync: `GET /tournaments?state=completed&updatedSince=<last updatedAt minus 5 minutes>`, following `page.next` to the end. The margin catches changes that committed late (see [Keep a copy in sync](#keep-a-copy-in-sync)).
4. Read each changed tournament with `GET /tournaments/{id}?include=roster,rounds,standings`, and delete your copy of each one the feed reports as removed.
5. Match tournaments and players you already hold through `links`.
6. Save the last `updatedAt` you processed for next week.

Every endpoint, field and error is in the [API reference](/guides/api/reference). The same document as OpenAPI 3.1 JSON is at [/guides/api/openapi.json](/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:

```bash
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](https://account.ggs.club/settings/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.

```bash
curl -s "$GGS_API/me" \
  -H "Authorization: Bearer $GGS_KEY"
```

```json
{
  "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:

```json
{
  "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. The `WWW-Authenticate` header says `Bearer` when no key was sent and `Bearer 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. `details` lists 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 a `GET`; any other method gets this, with an `Allow: GET` header.
- `429 rate_limited`: slow down. Wait the number of seconds in `Retry-After`, then carry on. `details` carries the `limit` and its `window`.
- `500 internal`: something failed on our side. Quote the `requestId` when 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:

```bash
curl -s "$GGS_API/tournaments?state=completed&updatedSince=2026-10-12T17:59:31.207Z&limit=200" \
  -H "Authorization: Bearer $GGS_KEY"
```

- `updatedSince` returns changes strictly after the instant you pass, at the millisecond precision `updatedAt` has. Pass the last `updatedAt` you 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 an `updatedAt` just 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 `updatedSince` the list comes oldest change first (`sort=updatedAt`, the default; `sort=-updatedAt` reverses it, and any other `sort` is refused), so the last item you process is the newest change.
- Follow `page.next` until it is `null`. It is the same request, with the position of the next page, as a full URL.
- `limit` is 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:

```json
{ "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.

```bash
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:

```json
{
  "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).
- `relations` says how the two relate. On a tournament, `mirrored_to` means GGS holds the official results and the other record is a copy; `imported_from` means the other way round. `resultsAuthority` on the tournament says the same thing.
- `confidence` says how the link was made. `heuristic` means 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:

```bash
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:

```bash
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`:

```json
{
  "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:

```json
{
  "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:

```json
{
  "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:

```bash
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 `include` options 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.
