{
  "openapi": "3.0.3",
  "info": {
    "title": "shrkbot Twilight Struggle API",
    "version": "1.0",
    "description": "A JSON API for [twilight-struggle.com](https://twilight-struggle.com) to push\ntournament and game-result data into shrkbot. shrkbot renders each game result\ninto a Discord message in the channels that servers have configured for that\ntournament. This page is the full external contract for that integration.\n\nThe machine-readable contract is an [OpenAPI 3 document](openapi.json). Request\nbodies are validated against it before they reach our code; a body that does not\nconform gets a `422` with an `errors` array.\n\n## Base URL\n\n```\nhttps://shrkbot.com/api/twilight-struggle/v1\n```\n\nEvery path below is relative to it, so a tournament upsert is\n`PUT https://shrkbot.com/api/twilight-struggle/v1/tournaments/otsl-2026`.\n\nAll requests and responses are JSON. Send `Content-Type: application/json`.\n\n**Types are strict — we do not coerce.** The one non-string field is\n`winning_turn`; send it as a JSON number (`7`), not a string (`\"7\"`). A string\nthere is a `422`.\n\n## Authentication\n\nEvery request needs a bearer token:\n\n```\nAuthorization: Bearer <key>\n```\n\nSeveral keys can be valid at the same time. To rotate a key: add the new key to\nour configuration, switch your integration over to it, then have us drop the old\none. Nothing rotates automatically and nothing expires on a timer — a key works\nuntil it is removed on our side.\n\nA missing or invalid token gets a `401` with no body.\n\n## Idempotent upserts\n\nBoth resources are `PUT` to a URL whose id you choose. There is no separate\ncreate call — the first `PUT` with a given id creates the row, every later `PUT`\nwith the same id updates that same row in place. Safe to retry.\n\n`DELETE` is idempotent too: deleting an id that never existed still answers\n`204`.\n\n## Ordering\n\nA tournament must be `PUT` before any game that references it, and a parent\ntournament before its children. Referencing an id we do not know about is a\n`422` — we never auto-create a stub tournament from a game payload.\n\n## Status codes\n\n| Code | Meaning |\n| --- | --- |\n| `201` | Created (first `PUT` for this id) |\n| `200` | Updated (id already existed) |\n| `204` | Deleted (`DELETE`, including deleting an id that never existed) |\n| `401` | Missing or invalid bearer token |\n| `422` | Validation failure or unknown reference — body has an `errors` array |\n"
  },
  "servers": [
    {
      "url": "https://shrkbot.com/api/twilight-struggle/v1"
    }
  ],
  "tags": [
    {
      "name": "Tournaments",
      "description": "A tournament is the unit servers subscribe to, and the thing a game result hangs\noff. `external_id` is your own id for it — whatever you use as the primary key on\nyour side.\n\nTournaments nest. `parent_external_id` groups a sub-event under a larger\ntournament. A server that subscribes to a parent receives the results of every\ntournament below it, and configuration (channel, message templates) is inherited\ndown the chain unless a level overrides it.\n\n### Organisers\n\nAn organiser whose Discord ID we hold can reach the Twilight Struggle plugin on\nany server where a shrkbot operator has granted the plugin, a server admin has\nswitched it on, and the organiser is a member — they do not need a server admin\nto subscribe for them. Which tournaments they may subscribe and configure there\nis a separate question, governed by the tournament chain: the ones they are named\non, plus that tournament's descendants. They can do everything a server admin can\non those tournaments except one thing — they cannot switch the plugin itself off,\nsince that would lock every organiser on the server out at once.\n\nSending `admins` replaces the whole set we hold for that tournament. Omitting the\nkey (or sending `null`) leaves our rows untouched — deliberate, so a partial\npayload cannot silently revoke every organiser. Send `[]` to clear.\n\nIt is opt-in. An organiser with no Discord ID on your side simply has no record\nwith us, and the server's own admins configure the tournament for them. We never\ninfer an organiser's Discord ID from anywhere else — in particular never from the\nplayer `discord_id`s in a game payload.\n\nIf that person deletes their shrkbot dashboard account, we delete the ID; a later\n`PUT` that still includes it will store it again.\n"
    },
    {
      "name": "Games",
      "description": "A game is one reported result. Its `external_id` must be your\n`game_results.id` (your bigint primary key) — **not** `game_code`. `game_code` is\na separate, display-only field (e.g. `\"R1\"`) that is only unique within one\ntournament, so it cannot identify a game on its own.\n\n**Friendly games:** omit `tournament_external_id` entirely and the game is\ngrouped under shrkbot's internal \"Friendly games\" tournament instead of being\nrejected.\n\n**Everything except the identifiers is render-only.** Player names, flags, the\nwinning side/method/turn, and the video URLs are used once to build the Discord\nmessage and are then discarded — shrkbot does not store them. Only the game's own\nid, its tournament link, and (once posted) the Discord channel/message id of each\nsubscribing server's posted result are kept — one pair per server. See the\n[privacy policy](https://shrkbot.com/privacy) for the full data inventory.\n\n## Posting results to Discord\n\nA game `PUT` fans out to every server subscribed to the game's tournament, or to\nany tournament above it in the parent chain — several servers may subscribe to\nthe same tournament feed. For each subscribing server, the result renders into a\nDiscord message when that server has a channel configured (on the tournament\nitself or inherited from an ancestor) *and* the Twilight Struggle plugin is\nenabled for that server. A server missing either is skipped; the others still get\nposted. One background job runs per subscribing server.\n\nSubscribing and configuration happen in shrkbot's dashboard, not through this\nAPI: the bot owner grants the plugin to a server, an admin of that server\nsubscribes to the tournament, then picks the channel and templates for that\nserver. Disabling the plugin stops every post for that server without losing any\nconfiguration; it has no effect on other subscribed servers.\n\nA repeat `PUT` for the same game re-renders each server's existing message in\nplace rather than posting a new one, tracked per server. `DELETE\n/games/{external_id}` deletes the posted Discord message in every subscribing\nserver it went to; `DELETE /tournaments/{external_id}` never touches Discord.\n\nPosting happens in a background job per server, so the response comes back\nwithout waiting on Discord and never carries a posting error — a `PUT` that\nanswers `200`/`201` means the game was stored, not that any message is up yet. If\nDiscord is unreachable or rate-limits us, that server's job retries with backoff;\nif the destination channel is gone or the bot was kicked for that server it gives\nup, because retrying cannot fix either — other servers' jobs are unaffected.\nNothing is reported back to you in that case.\n\nThe result payload lives only for as long as each job does. It is never written\nto the game record, so the only way to change a posted message is to send the\ngame again.\n\n## What the message looks like\n\nPlain text, matching the announcements the site already produces:\n\n```\nOTSL 2026 - Season 8: G372 - M B 🇵🇱 (USA) has defeated L S 🇦🇷 in Turn 7 (VP Track (+20))\nRATS Cup 2026: C204 - M N 🇦🇩 (USA) tied with D C 🇰🇷 in Turn 10 (Wargames)\nOTSL 2026 - Season 8: S378 - T B 🇵🇱 vs A S 🇸🇪 https://youtu.be/videolink\n```\n\nEach tournament has three templates, inherited from its parent tournament when\nunset: one for a decided game, one for a tie, and one for a game with a video. **A\ngame with `video_urls` always uses the video template, which is spoiler-free** —\nboth players and the link, no winner, turn or method. Video URLs are posted as\nbare links so Discord builds its own embed.\n\nTemplates are written with `{token}` placeholders. Unknown tokens are left alone,\nso a stray brace renders literally rather than breaking the message.\n\nA token whose field you did not send renders empty. It does not fail. The\npunctuation around the token stays, so the default templates, which end in\n`in {turn} ({winning_method})`, read `in  ()` for a game sent with neither\nfield. Send both fields, or write a template that matches what you send.\n\n| Token | Renders |\n| --- | --- |\n| `{tournament_name}` | the tournament's name |\n| `{game_id}` | the `game_code` you sent |\n| `{turn}` | `Turn 7`, or `Final Scoring` at turn 11; empty without a `winning_turn` |\n| `{winning_method}` | the `winning_method` you sent; empty without one |\n| `{winning_player}` / `{losing_player}` | name and flag; empty on a tie |\n| `{winning_side}` / `{losing_side}` | `USA` or `USSR`; empty on a tie |\n| `{usa_player}` / `{ussr_player}` | name and flag for that side |\n| `{usa_name}` / `{ussr_name}` / `{winning_name}` / `{losing_name}` | name, no flag |\n| `{usa_flag}` / `{ussr_flag}` / `{winning_flag}` / `{losing_flag}` | flag only |\n| `{videos}` | the video URLs, space separated |\n\nA tournament can be set to show Discord tags. Then every token that carries a\nplayer's name also carries their tag in brackets after it — `Alice 🇺🇸 (@alice)` —\nfor anyone whose `discord_id` you sent; players without one just get their name.\nThe name is never replaced, so a snowflake that has gone stale, or a player who\nhas left the server, still reads correctly.\n\n**No posted message ever notifies anyone.** Tags identify a player and link to\ntheir profile; they do not ping. A player named `@everyone` cannot ping the server\neither.\n"
    }
  ],
  "security": [
    {
      "bearerAuth": []
    }
  ],
  "paths": {
    "/games/{external_id}": {
      "parameters": [
        {
          "name": "external_id",
          "in": "path",
          "required": true,
          "description": "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.",
          "schema": {
            "type": "string",
            "example": "1024"
          }
        }
      ],
      "put": {
        "tags": [
          "Games"
        ],
        "summary": "Create or update a game",
        "description": "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.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "game"
                ],
                "properties": {
                  "game": {
                    "$ref": "#/components/schemas/GameInput"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Game updated",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/GameUpsertResponse"
                }
              }
            }
          },
          "201": {
            "description": "Game created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/GameUpsertResponse"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid bearer token"
          },
          "422": {
            "description": "Validation failed, or `tournament_external_id` names a tournament we do not know"
          }
        }
      },
      "delete": {
        "tags": [
          "Games"
        ],
        "summary": "Delete a game and its posted Discord message",
        "description": "Deletes the game and the message shrkbot posted for it in every subscribing server, so this is how you retract a published result.",
        "responses": {
          "204": {
            "description": "Game deleted, or no such game"
          },
          "401": {
            "description": "Missing or invalid bearer token"
          }
        }
      }
    },
    "/tournaments/{external_id}": {
      "parameters": [
        {
          "name": "external_id",
          "in": "path",
          "required": true,
          "description": "Your own id for the tournament — whatever you use as its primary key.",
          "schema": {
            "type": "string",
            "example": "otsl-2026"
          }
        }
      ],
      "put": {
        "tags": [
          "Tournaments"
        ],
        "summary": "Create or update a tournament",
        "description": "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.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "tournament"
                ],
                "properties": {
                  "tournament": {
                    "$ref": "#/components/schemas/TournamentInput"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Tournament updated",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TournamentUpsertResponse"
                }
              }
            }
          },
          "201": {
            "description": "Tournament created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TournamentUpsertResponse"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid bearer token"
          },
          "422": {
            "description": "Validation failed, or `parent_external_id` names a tournament we do not know"
          }
        }
      },
      "delete": {
        "tags": [
          "Tournaments"
        ],
        "summary": "Delete a tournament",
        "description": "Deletes the tournament and its games. This never touches Discord: result messages already posted for those games stay up.",
        "responses": {
          "204": {
            "description": "Tournament deleted, or no such tournament"
          },
          "401": {
            "description": "Missing or invalid bearer token"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer"
      }
    },
    "schemas": {
      "GameInput": {
        "type": "object",
        "required": [
          "usa",
          "ussr",
          "winning_side",
          "reported_at"
        ],
        "properties": {
          "tournament_external_id": {
            "type": "string",
            "description": "Omit it for a friendly game, which we group under shrkbot's internal \"Friendly games\" tournament. An id we do not know is a `422`.",
            "example": "otsl-2026"
          },
          "game_code": {
            "type": "string",
            "maxLength": 60,
            "description": "Display-only, shown in the posted message as `{game_id}`.",
            "example": "R1"
          },
          "game_date": {
            "type": "string",
            "description": "When the game was played.",
            "example": "2026-07-20"
          },
          "reported_at": {
            "type": "string",
            "description": "When the result was reported.",
            "example": "2026-07-24T10:00:00Z"
          },
          "winning_side": {
            "type": "string",
            "enum": [
              "usa",
              "ussr",
              "tie"
            ],
            "description": "These exact names, not your internal `1`/`2`/`3` side codes.",
            "example": "usa"
          },
          "winning_turn": {
            "type": "integer",
            "minimum": 1,
            "maximum": 11,
            "description": "A JSON number, not a string. Turn 11 renders as \"Final Scoring\".",
            "example": 6
          },
          "winning_method": {
            "type": "string",
            "maxLength": 60,
            "description": "Free text, rendered as sent. Omit it if you do not record one. Then `{winning_method}` renders empty, as `{turn}` does without a `winning_turn`. Check the punctuation around the token in your templates.",
            "example": "defcon"
          },
          "usa": {
            "$ref": "#/components/schemas/PlayerInput"
          },
          "ussr": {
            "$ref": "#/components/schemas/PlayerInput"
          },
          "video_urls": {
            "type": "array",
            "maxItems": 5,
            "description": "A game with any video URL is posted with the spoiler-free template: both players and the links, no winner, turn or method.",
            "example": [
              "https://youtu.be/dQw4w9WgXcQ"
            ],
            "items": {
              "type": "string",
              "pattern": "^https?://",
              "description": "Any scheme other than `http://` or `https://` is rejected.",
              "example": "https://youtu.be/dQw4w9WgXcQ"
            }
          }
        }
      },
      "PlayerInput": {
        "type": "object",
        "required": [
          "name"
        ],
        "properties": {
          "name": {
            "type": "string",
            "maxLength": 100,
            "example": "Alice"
          },
          "flag": {
            "type": "string",
            "maxLength": 8,
            "description": "Meant for a flag emoji.",
            "example": "🇺🇸"
          },
          "discord_id": {
            "type": "string",
            "description": "The player's Discord user ID, as a string — snowflakes exceed JSON's safe integer range. Tournaments set to show Discord tags render it alongside the player's name. We never store it.",
            "example": "123456789012345678"
          }
        }
      },
      "GameUpsertResponse": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "description": "shrkbot's own id, returned for your reference. You never send it back to us.",
            "example": "tsg_01ABCDEFGHIJKLMNOPQRSTUVWX"
          },
          "external_id": {
            "type": "string",
            "example": "1024"
          }
        }
      },
      "TournamentInput": {
        "type": "object",
        "required": [
          "name"
        ],
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "example": "Online Twilight Struggle League"
          },
          "parent_external_id": {
            "type": "string",
            "description": "Your id of an already-sent parent tournament, which groups this one under a larger event. An id we do not know is a `422`.",
            "example": "otsl"
          },
          "status": {
            "type": "string",
            "description": "Free text. We store it; nothing in shrkbot interprets it.",
            "example": "closed"
          },
          "admins": {
            "type": "array",
            "description": "Discord IDs of the tournament's organisers. Sending the key replaces the whole set; omitting it (or sending `null`) leaves our rows alone; `[]` clears them.",
            "items": {
              "type": "string",
              "pattern": "^[0-9]+$",
              "example": "123456789012345678"
            }
          }
        }
      },
      "TournamentUpsertResponse": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "description": "shrkbot's own id, returned for your reference. You never send it back to us.",
            "example": "tst_01ABCDEFGHIJKLMNOPQRSTUVWX"
          },
          "external_id": {
            "type": "string",
            "example": "otsl-2026"
          }
        }
      }
    }
  }
}
