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.
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.
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.
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.
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.
| 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 |
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.
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.
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.
| external_id required | string Example: otsl-2026 Your own id for the tournament — whatever you use as its primary key. |
required | object (TournamentInput) |
{- "tournament": {
- "name": "Online Twilight Struggle League",
- "parent_external_id": "otsl",
- "status": "closed",
- "admins": [
- "123456789012345678"
]
}
}{- "id": "tst_01ABCDEFGHIJKLMNOPQRSTUVWX",
- "external_id": "otsl-2026"
}Deletes the tournament and its games. This never touches Discord: result messages already posted for those games stay up.
| external_id required | string Example: otsl-2026 Your own id for the tournament — whatever you use as its primary key. |
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.
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.
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.
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.
| external_id required | string Example: 1024 Your |
required | object (GameInput) |
{- "game": {
- "tournament_external_id": "otsl-2026",
- "game_code": "R1",
- "game_date": "2026-07-20",
- "reported_at": "2026-07-24T10:00:00Z",
- "winning_side": "usa",
- "winning_turn": 6,
- "winning_method": "defcon",
- "usa": {
- "name": "Alice",
- "flag": "🇺🇸",
- "discord_id": "123456789012345678"
}, - "ussr": {
- "name": "Alice",
- "flag": "🇺🇸",
- "discord_id": "123456789012345678"
},
}
}{- "id": "tsg_01ABCDEFGHIJKLMNOPQRSTUVWX",
- "external_id": "1024"
}Deletes the game and the message shrkbot posted for it in every subscribing server, so this is how you retract a published result.
| external_id required | string Example: 1024 Your |