shrkbot Twilight Struggle API (1.0)

Download OpenAPI specification:

A JSON API for twilight-struggle.com to push tournament and game-result data into shrkbot. shrkbot renders each game result into a Discord message in the channels that servers have configured for that tournament. This page is the full external contract for that integration.

The machine-readable contract is an OpenAPI 3 document. Request bodies are validated against it before they reach our code; a body that does not conform gets a 422 with an errors array.

Base URL

https://shrkbot.com/api/twilight-struggle/v1

Every path below is relative to it, so a tournament upsert is PUT https://shrkbot.com/api/twilight-struggle/v1/tournaments/otsl-2026.

All requests and responses are JSON. Send Content-Type: application/json.

Types are strict — we do not coerce. The one non-string field is winning_turn; send it as a JSON number (7), not a string ("7"). A string there is a 422.

Authentication

Every request needs a bearer token:

Authorization: Bearer <key>

Several keys can be valid at the same time. To rotate a key: add the new key to our configuration, switch your integration over to it, then have us drop the old one. Nothing rotates automatically and nothing expires on a timer — a key works until it is removed on our side.

A missing or invalid token gets a 401 with no body.

Idempotent upserts

Both resources are PUT to a URL whose id you choose. There is no separate create call — the first PUT with a given id creates the row, every later PUT with the same id updates that same row in place. Safe to retry.

DELETE is idempotent too: deleting an id that never existed still answers 204.

Ordering

A tournament must be PUT before any game that references it, and a parent tournament before its children. Referencing an id we do not know about is a 422 — we never auto-create a stub tournament from a game payload.

Status codes

Code Meaning
201 Created (first PUT for this id)
200 Updated (id already existed)
204 Deleted (DELETE, including deleting an id that never existed)
401 Missing or invalid bearer token
422 Validation failure or unknown reference — body has an errors array

Tournaments

A tournament is the unit servers subscribe to, and the thing a game result hangs off. external_id is your own id for it — whatever you use as the primary key on your side.

Tournaments nest. parent_external_id groups a sub-event under a larger tournament. A server that subscribes to a parent receives the results of every tournament below it, and configuration (channel, message templates) is inherited down the chain unless a level overrides it.

Organisers

An organiser whose Discord ID we hold can reach the Twilight Struggle plugin on any server where a shrkbot operator has granted the plugin, a server admin has switched it on, and the organiser is a member — they do not need a server admin to subscribe for them. Which tournaments they may subscribe and configure there is a separate question, governed by the tournament chain: the ones they are named on, plus that tournament's descendants. They can do everything a server admin can on those tournaments except one thing — they cannot switch the plugin itself off, since that would lock every organiser on the server out at once.

Sending admins replaces the whole set we hold for that tournament. Omitting the key (or sending null) leaves our rows untouched — deliberate, so a partial payload cannot silently revoke every organiser. Send [] to clear.

It is opt-in. An organiser with no Discord ID on your side simply has no record with us, and the server's own admins configure the tournament for them. We never infer an organiser's Discord ID from anywhere else — in particular never from the player discord_ids in a game payload.

If that person deletes their shrkbot dashboard account, we delete the ID; a later PUT that still includes it will store it again.

Create or update a tournament

Creates the tournament on the first call for this id and updates it in place on every later one. A tournament must exist before any game or child tournament that references it.

Authorizations:
bearerAuth
path Parameters
external_id
required
string
Example: otsl-2026

Your own id for the tournament — whatever you use as its primary key.

Request Body schema: application/json
required
required
object (TournamentInput)

Responses

Request samples

Content type
application/json
{
  • "tournament": {
    }
}

Response samples

Content type
application/json
{
  • "id": "tst_01ABCDEFGHIJKLMNOPQRSTUVWX",
  • "external_id": "otsl-2026"
}

Delete a tournament

Deletes the tournament and its games. This never touches Discord: result messages already posted for those games stay up.

Authorizations:
bearerAuth
path Parameters
external_id
required
string
Example: otsl-2026

Your own id for the tournament — whatever you use as its primary key.

Responses

Games

A game is one reported result. Its external_id must be your game_results.id (your bigint primary key) — not game_code. game_code is a separate, display-only field (e.g. "R1") that is only unique within one tournament, so it cannot identify a game on its own.

Friendly games: omit tournament_external_id entirely and the game is grouped under shrkbot's internal "Friendly games" tournament instead of being rejected.

Everything except the identifiers is render-only. Player names, flags, the winning side/method/turn, and the video URLs are used once to build the Discord message and are then discarded — shrkbot does not store them. Only the game's own id, its tournament link, and (once posted) the Discord channel/message id of each subscribing server's posted result are kept — one pair per server. See the privacy policy for the full data inventory.

Posting results to Discord

A game PUT fans out to every server subscribed to the game's tournament, or to any tournament above it in the parent chain — several servers may subscribe to the same tournament feed. For each subscribing server, the result renders into a Discord message when that server has a channel configured (on the tournament itself or inherited from an ancestor) and the Twilight Struggle plugin is enabled for that server. A server missing either is skipped; the others still get posted. One background job runs per subscribing server.

Subscribing and configuration happen in shrkbot's dashboard, not through this API: the bot owner grants the plugin to a server, an admin of that server subscribes to the tournament, then picks the channel and templates for that server. Disabling the plugin stops every post for that server without losing any configuration; it has no effect on other subscribed servers.

A repeat PUT for the same game re-renders each server's existing message in place rather than posting a new one, tracked per server. DELETE /games/{external_id} deletes the posted Discord message in every subscribing server it went to; DELETE /tournaments/{external_id} never touches Discord.

Posting happens in a background job per server, so the response comes back without waiting on Discord and never carries a posting error — a PUT that answers 200/201 means the game was stored, not that any message is up yet. If Discord is unreachable or rate-limits us, that server's job retries with backoff; if the destination channel is gone or the bot was kicked for that server it gives up, because retrying cannot fix either — other servers' jobs are unaffected. Nothing is reported back to you in that case.

The result payload lives only for as long as each job does. It is never written to the game record, so the only way to change a posted message is to send the game again.

What the message looks like

Plain text, matching the announcements the site already produces:

OTSL 2026 - Season 8: G372 - M B 🇵🇱 (USA) has defeated L S 🇦🇷 in Turn 7 (VP Track (+20))
RATS Cup 2026: C204 - M N 🇦🇩 (USA) tied with D C 🇰🇷 in Turn 10 (Wargames)
OTSL 2026 - Season 8: S378 - T B 🇵🇱 vs A S 🇸🇪 https://youtu.be/videolink

Each tournament has three templates, inherited from its parent tournament when unset: one for a decided game, one for a tie, and one for a game with a video. A game with video_urls always uses the video template, which is spoiler-free — both players and the link, no winner, turn or method. Video URLs are posted as bare links so Discord builds its own embed.

Templates are written with {token} placeholders. Unknown tokens are left alone, so a stray brace renders literally rather than breaking the message.

A token whose field you did not send renders empty. It does not fail. The punctuation around the token stays, so the default templates, which end in in {turn} ({winning_method}), read in () for a game sent with neither field. Send both fields, or write a template that matches what you send.

Token Renders
{tournament_name} the tournament's name
{game_id} the game_code you sent
{turn} Turn 7, or Final Scoring at turn 11; empty without a winning_turn
{winning_method} the winning_method you sent; empty without one
{winning_player} / {losing_player} name and flag; empty on a tie
{winning_side} / {losing_side} USA or USSR; empty on a tie
{usa_player} / {ussr_player} name and flag for that side
{usa_name} / {ussr_name} / {winning_name} / {losing_name} name, no flag
{usa_flag} / {ussr_flag} / {winning_flag} / {losing_flag} flag only
{videos} the video URLs, space separated

A tournament can be set to show Discord tags. Then every token that carries a player's name also carries their tag in brackets after it — Alice 🇺🇸 (@alice) — for anyone whose discord_id you sent; players without one just get their name. The name is never replaced, so a snowflake that has gone stale, or a player who has left the server, still reads correctly.

No posted message ever notifies anyone. Tags identify a player and link to their profile; they do not ping. A player named @everyone cannot ping the server either.

Create or update a game

Creates the game on the first call for this id and updates it in place on every later one. Each call re-renders the Discord message every subscribing server already has for this game rather than posting a new one. The response says the game was stored, not that a message is up; posting runs in a background job per server.

Authorizations:
bearerAuth
path Parameters
external_id
required
string
Example: 1024

Your game_results.id, not game_code. game_code is display-only and unique within one tournament, so it cannot identify a game on its own.

Request Body schema: application/json
required
required
object (GameInput)

Responses

Request samples

Content type
application/json
{
  • "game": {
    }
}

Response samples

Content type
application/json
{
  • "id": "tsg_01ABCDEFGHIJKLMNOPQRSTUVWX",
  • "external_id": "1024"
}

Delete a game and its posted Discord message

Deletes the game and the message shrkbot posted for it in every subscribing server, so this is how you retract a published result.

Authorizations:
bearerAuth
path Parameters
external_id
required
string
Example: 1024

Your game_results.id, not game_code. game_code is display-only and unique within one tournament, so it cannot identify a game on its own.

Responses