# Codebattle Public API v1

A small, me-centric HTTP API for bots, CLIs, editors and devices: see your live game and history,
start games against Codebot or a friend, join open games from the lobby, join tournaments and run your own.

- **Base URL:** `https://codebattle.hexlet.io/public_api/v1`
- **Format:** JSON in, JSON out (`Content-Type: application/json` on requests with a body)
- **Machine-readable spec:** [`/public_api/v1/openapi.json`](/public_api/v1/openapi.json) (OpenAPI 3.1)
- **This page as Markdown:** [`/api-docs.md`](/api-docs.md), handy for pasting into an LLM

## Quickstart

1. Open **Settings → API tokens** on codebattle and create a token. Copy it right away: it is
   shown only once and looks like `cbp_...`.
2. Call the API with the token in the `Authorization` header:

```bash
export CODEBATTLE_TOKEN=cbp_your_token_here

curl -s https://codebattle.hexlet.io/public_api/v1/me \
  -H "Authorization: Bearer $CODEBATTLE_TOKEN"
```

```json
{"user": {"id": 42, "name": "ada", "avatar_url": null, "rating": 1461, "rank": 12, "lang": "dlang"}}
```

3. Start a game against Codebot (needs the `games:write` scope) and watch it:

```bash
curl -s -X POST https://codebattle.hexlet.io/public_api/v1/games \
  -H "Authorization: Bearer $CODEBATTLE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"level": "easy", "opponent": "bot"}'

curl -s https://codebattle.hexlet.io/public_api/v1/me/active_game \
  -H "Authorization: Bearer $CODEBATTLE_TOKEN"
```

You solve the task in the browser at the game `url`; the API tells you what is going on.

The same from Ruby, without gems. Note the explicit `Content-Type`: without it `Net::HTTP`
sends the body as a form and the API answers `415`.

```ruby
require "net/http"
require "json"

uri = URI("https://codebattle.hexlet.io/public_api/v1/games")
req = Net::HTTP::Post.new(uri, "Authorization" => "Bearer #{ENV["CODEBATTLE_TOKEN"]}",
                               "Content-Type" => "application/json")
req.body = {level: "easy", opponent: "bot"}.to_json
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
puts JSON.parse(res.body)
```

## Authentication

Every request needs a personal token in the header (query parameters are never accepted):

```http
Authorization: Bearer cbp_...
```

A token belongs to one user, acts as that user and carries **scopes**:

| Scope | Allows |
|-------|--------|
| `read` | All `GET` endpoints |
| `games:write` | Create, join and cancel games; send, accept and cancel invites; join and leave tournaments |
| `tournaments:write` | Create, edit, start and cancel your tournaments |

- Tokens with write scopes must have an expiry (30, 90 or 365 days) and can be created only by
  accounts older than 7 days. A read-only token may live forever.
- A user can have up to 10 active tokens. Tokens are revoked when you change your password, and
  stop working immediately if the account is banned or archived.
- Treat a token like a password: keep it out of git and client-side code.

## Conventions

- **Times** are ISO 8601 in UTC, e.g. `2026-10-10T19:03:18Z`.
- **Countdowns:** endpoints that matter for timing return `server_time`. Compute remaining time as
  `ends_at - server_time` instead of trusting the device clock.
- **Privacy by 404:** an object that exists but is not related to you answers exactly like a missing
  one (`404 not_found`), so probing ids reveals nothing.
- **JSON bodies:** send request bodies as JSON with `Content-Type: application/json`. Many HTTP
  clients (Ruby `Net::HTTP`, `curl -d`) default to form encoding; such requests get `415`.
- **Strict bodies:** unknown fields in a request body are an error (`422`), not silently ignored.
- **Live games hide the opponent's progress:** `result_percent` of other players appears only after
  the game is over.
- **Versioning:** v1 only gets additive changes (new endpoints, new fields). Ignore fields you don't
  know.

## Pagination

`GET /me/games` uses keyset (cursor) pagination, which stays correct while new games keep arriving:

1. Request the first page without `cursor`: you get up to 50 games, newest first, and `next_cursor`.
2. Pass `next_cursor` back as `cursor` to get the next, older page.
3. `next_cursor: null` means there are no more pages.

The cursor is simply the id of the last game on the page. To get games from a period instead of
walking pages, use `from` and `to` (ISO 8601 dates or datetimes, by game start time). They combine
with `cursor` if a period has more than 50 games.

```bash
curl -s "https://codebattle.hexlet.io/public_api/v1/me/games?from=2026-10-01&to=2026-10-08" \
  -H "Authorization: Bearer $CODEBATTLE_TOKEN"
```

The tournament ranking for creators and moderators uses `page` / `page_size` instead.

## Errors

Every error has the same shape:

```json
{"error": {"code": "validation_failed", "message": "Invalid parameters: level is required", "details": {"level": "is required"}}}
```

For `validation_failed`, `message` lists every failed field and `details` maps each field to what
is wrong with it, for programs.

| HTTP | `code` | Meaning |
|------|--------|---------|
| 401 | `unauthorized` | No `Authorization: Bearer` header |
| 401 | `invalid_token` | The token is unknown, expired or revoked |
| 403 | `insufficient_scope` | The token lacks the scope this endpoint needs |
| 404 | `not_found` | Missing, or not related to you |
| 415 | `unsupported_media_type` | A request body sent without `Content-Type: application/json` |
| 422 | `validation_failed` | Bad or unknown parameters, or an action not allowed in the current state |
| 429 | `rate_limited` | Too many requests, see below |
| 429 | `quota_exceeded` | Too many games or tournaments created |
| 503 | `api_disabled` | The API (or its write operations) is temporarily switched off |

## Event stream

`GET /me/events` is a live stream of everything that happens to you, delivered as
[Server-Sent Events](https://html.spec.whatwg.org/multipage/server-sent-events.html) (SSE). It is
an ordinary HTTP request that stays open: the server keeps writing events into the response as
they happen. Use it instead of polling to react to invites, game starts and tournament rounds
within a second.

```bash
curl -sN https://codebattle.hexlet.io/public_api/v1/me/events \
  -H "Authorization: Bearer $CODEBATTLE_TOKEN" \
  -H "Accept: text/event-stream"
```

### Wire format

The response is `text/event-stream`. Each event is a few `field: value` lines followed by a blank
line; `data` is always one line of JSON with a `server_time`:

```text
id: 1791702012345
event: invite.created
data: {"invite":{"id":9001,"direction":"incoming","creator":{"id":77,"name":"grace","rating":1520,"avatar_url":null},...},"server_time":"2026-10-11T07:00:12Z"}

: ping
```

- Lines starting with `:` are comments. `: ping` arrives every 15 seconds so proxies keep the
  connection; ignore it (or use it to detect a dead connection: no bytes for 45 s → reconnect).
- `retry: 3000` at the start tells `EventSource` clients to reconnect after 3 seconds.
- `id` is a timestamp in milliseconds, for logging. Events are **not replayed**: after a reconnect
  the `hello` event brings the current state instead, so nothing is lost.

### Lifecycle

1. You connect and immediately get `hello` with your current state.
2. Events arrive as things happen.
3. After an hour the server sends `bye` and closes the stream. Reconnect right away.
4. If the connection drops for any other reason, reconnect after a few seconds.

At most 2 streams per user can be open at the same time; a third request gets `429 rate_limited`.

### Events

| Event | When | Payload (`data`) |
|-------|------|--------|
| `hello` | Right after connecting | `user` (Player), `active_game` (Game or `null`), `invites` (Invite[]), `tournaments` (unfinished Tournament[] you play in or run) |
| `invite.created` | Someone invited you, or you sent an invite | `invite` |
| `invite.accepted` | An invite between you and someone was accepted | `invite` with `game_id`, `game_url` |
| `invite.canceled` | Declined by the recipient or withdrawn by the creator | `invite` |
| `invite.dropped` | Dropped because one of you started another game | `invite` |
| `game.created` | You created a game that waits for an opponent | `game` |
| `game.started` | A game with you started: against a bot, from an invite, someone joined your game, or a tournament round paired you | `game` |
| `game.finished` | Your game ended (`game_over` or `timeout`); results are visible | `game` |
| `game.canceled` | Your game was canceled before it finished | `game` |
| `tournament.joined` | You joined a tournament | `tournament` |
| `tournament.left` | You left a tournament | `tournament` |
| `tournament.player_joined` | Someone joined a tournament you created or moderate | `tournament`, `player` (Player) |
| `tournament.player_left` | Someone left a tournament you created or moderate | `tournament`, `player_id` |
| `tournament.started` | A tournament you play in or run started | `tournament` |
| `tournament.round_started` | A new round began; your game arrives as `game.started` | `tournament` |
| `tournament.round_finished` | A round ended; `tournament.break.active` and `ends_at` tell when the next round starts | `tournament` |
| `tournament.finished` | The tournament is over | `tournament` |
| `tournament.canceled` | The tournament was canceled | `tournament` |
| `tournament.restarted` | An admin restarted the tournament | `tournament` |
| `bye` | The server is closing the stream; reconnect | `reason` |

`game`, `tournament` and `invite` have the same fields as in the REST endpoints (see
**Objects**). New event types may appear in v1; skip the ones you don't know.

### Clients

**Ruby**, standard library only:

```ruby
require "net/http"
require "json"

uri = URI("https://codebattle.hexlet.io/public_api/v1/me/events")

loop do
  Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 45) do |http|
    req = Net::HTTP::Get.new(uri, "Authorization" => "Bearer #{ENV["CODEBATTLE_TOKEN"]}",
                                  "Accept" => "text/event-stream")
    http.request(req) do |res|
      buffer = +""
      res.read_body do |chunk|
        buffer << chunk
        while (block = buffer.slice!(/\A.*?\n\n/m))
          name = block[/^event: (.+)$/, 1]
          data = block[/^data: (.+)$/, 1]
          next unless name && data # pings and retry hints

          event = JSON.parse(data)
          case name
          when "invite.created"
            puts "Invite from #{event["invite"]["creator"]["name"]}" if event["invite"]["direction"] == "incoming"
          when "game.started"
            system("firefox", "--kiosk", event["game"]["url"])
          end
        end
      end
    end
  end
rescue StandardError => e
  warn "stream dropped: #{e.message}"
ensure
  sleep 3
end
```

**Python** with `requests`:

```python
import json, os, time, requests

URL = "https://codebattle.hexlet.io/public_api/v1/me/events"
HEADERS = {"Authorization": f"Bearer {os.environ['CODEBATTLE_TOKEN']}", "Accept": "text/event-stream"}

while True:
    try:
        with requests.get(URL, headers=HEADERS, stream=True, timeout=(10, 45)) as res:
            name = None
            for line in res.iter_lines(decode_unicode=True):
                if line.startswith("event: "):
                    name = line[7:]
                elif line.startswith("data: ") and name:
                    print(name, json.loads(line[6:]))
                elif line == "":
                    name = None
    except requests.RequestException as error:
        print("stream dropped:", error)
    time.sleep(3)
```

**JavaScript** (Node 18+ or a browser; `EventSource` can't send the `Authorization` header, so
read the stream with `fetch`):

```js
const res = await fetch("https://codebattle.hexlet.io/public_api/v1/me/events", {
  headers: { Authorization: `Bearer ${process.env.CODEBATTLE_TOKEN}`, Accept: "text/event-stream" },
});
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = "";
for (;;) {
  const { value, done } = await reader.read();
  if (done) break; // reconnect here
  buffer += value;
  let end;
  while ((end = buffer.indexOf("\n\n")) >= 0) {
    const block = buffer.slice(0, end);
    buffer = buffer.slice(end + 2);
    const name = block.match(/^event: (.+)$/m)?.[1];
    const data = block.match(/^data: (.+)$/m)?.[1];
    if (name && data) console.log(name, JSON.parse(data));
  }
}
```

If your environment can't keep a connection open (some corporate proxies, cron jobs), use
long polling on `GET /me/invites?wait=25` instead, or poll the REST endpoints.

## Limits

Limits are per user, shared by all of the user's tokens.

| What | Limit |
|------|-------|
| Requests | 60 per minute. Responses carry `x-ratelimit-limit` and `x-ratelimit-remaining` |
| `404` responses | More than 30 in 10 minutes blocks the API for an hour |
| Games created | 5 per minute, 30 per day |
| Tournaments created | 5 per day, at most 3 unfinished at a time |
| Event streams | 2 open at a time; each lives up to an hour |

Polling `GET /me/active_game` every 5–10 seconds fits comfortably in the limit, but the
[event stream](#event-stream) is better: one connection, events within a second.

## Recipes

**Device that vibrates 10 minutes before the end of a game.** Listen to the event stream: on
`game.started` store `game.ends_at - server_time` as the remaining time and count down locally;
`game.finished` or `game.canceled` stops the countdown. (Without a stream, poll
`GET /me/active_game`.)

**Join an open game from the lobby.** List the games waiting for an opponent, pick one and join
it (needs `games:write`):

```bash
curl -s "https://codebattle.hexlet.io/public_api/v1/games/live?state=waiting_opponent" \
  -H "Authorization: Bearer $CODEBATTLE_TOKEN" | jq '.games[] | {id, level, timeout_seconds, players}'

curl -s -X POST https://codebattle.hexlet.io/public_api/v1/games/12345/join \
  -H "Authorization: Bearer $CODEBATTLE_TOKEN"
```

The game starts at once: open its `url`. The task of an open game is hidden until someone joins.
You can play one game at a time; if you are in another one, the join answers `422` with
`details.reason` `already_in_a_game` and `details.active_game_id`: finish that game, or cancel it
with `POST /games/{id}/cancel` while it still waits for an opponent. To wait for someone to join
your own game instead, create one with `{"opponent": "link"}` and share its `join_url`.

**Join a tournament without keeping its page open.** Find it in `GET /tournaments/schedule` (or
`GET /tournaments/live`) and join it (needs `games:write`); for a private tournament pass the
`access_token` from its `invite_url`:

```bash
curl -s -X POST https://codebattle.hexlet.io/public_api/v1/tournaments/777/join \
  -H "Authorization: Bearer $CODEBATTLE_TOKEN"

curl -s -X POST https://codebattle.hexlet.io/public_api/v1/tournaments/778/join \
  -H "Authorization: Bearer $CODEBATTLE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"access_token": "from_the_invite_url"}'
```

The card comes back with `joined: true` (joining twice is fine). Now listen to the
[event stream](#event-stream) and open the browser only on `game.started`: its `game.url` is your
first game. A phone saves a lot of battery compared with a tournament tab open for ten minutes
before the start. Changed your mind? `POST /tournaments/{id}/leave` works until the start. A
failed join answers `422` with `details.reason`: `not_open`, `full` or `not_running`.

**Bot that runs a private tournament.** `POST /tournaments` with `name`, `description`,
`starts_at`, `level`, send players the returned `invite_url`, then `POST /tournaments/{id}/start`
when everyone has joined, and read `GET /tournaments/{id}/ranking`.

**React to invites when you can't hold a stream open.** The [event stream](#event-stream) is
the first choice. Without it, long-poll `GET /me/invites`:

```bash
version=""
while true; do
  resp=$(curl -s "https://codebattle.hexlet.io/public_api/v1/me/invites?wait=25&version=$version" \
    -H "Authorization: Bearer $CODEBATTLE_TOKEN")
  version=$(echo "$resp" | jq -r .version)
  echo "$resp" | jq '.invites[] | select(.direction == "incoming")'
done
```

The request returns as soon as the list changes (or after 25 s), so a new invite shows up within
a second at about two requests a minute. Accept it with `POST /invites/{id}/accept` and open
`game_url`.

**One voice command to a private tournament.** `POST /tournaments` with your preset of fields,
show `invite_url` as a QR code (the tournament page has a "Show QR" button too), wait for
`tournament.player_joined` on the [event stream](#event-stream), then
`POST /tournaments/{id}/start` and open the game from the `game.started` event.

**A game on a chosen task.** List the tasks of a level with `GET /tasks?level=medium` (add
`tags=strings,math` or `name=partition` to narrow it), then pass the `id` or the exact `name`:
`POST /games` with `{"task_name": "minimal_time_partition", "opponent": "bot"}`. The level comes
from the task. Invites take the same `task_id` / `task_name`. You only see and can pick public
tasks and your own.

**Export your history.** Walk `GET /me/games` with `cursor` until `next_cursor` is `null`, or ask for
one period with `from` / `to`.

## Endpoints

All paths are relative to `https://codebattle.hexlet.io/public_api/v1`.

| Method | Path | What it does | Scope |
|--------|------|--------------|-------|
| `GET` | `/me` | Current user | `read` |
| `GET` | `/me/active_game` | Current live game (null if none). Count down with ends_at - server_time. | `read` |
| `GET` | `/me/games` | My finished games, newest first | `read` |
| `GET` | `/me/tournaments` | Tournaments I created, moderate or play | `read` |
| `GET` | `/me/invites` | My pending invites, incoming and outgoing | `read` |
| `GET` | `/me/events` | Live stream of everything that happens to you (SSE) | `read` |
| `GET` | `/tournaments/schedule` | Public upcoming and active tournaments for the next 14 days | `read` |
| `GET` | `/tournaments/live` | Tournaments running now or open for joining | `read` |
| `POST` | `/tournaments` | Create a private tournament (always grade open, tasks by level) | `tournaments:write` |
| `GET` | `/tournaments/{id}` | Tournament card: related to you, or public and scheduled | `read` |
| `PATCH` | `/tournaments/{id}` | Edit your tournament before it starts | `tournaments:write` |
| `GET` | `/tournaments/{id}/ranking` | Ranking: full and paginated for the creator/moderators, top 20 plus your row for participants | `read` |
| `POST` | `/tournaments/{id}/start` | Start your tournament | `tournaments:write` |
| `POST` | `/tournaments/{id}/cancel` | Cancel your tournament | `tournaments:write` |
| `POST` | `/tournaments/{id}/join` | Join a tournament as a player | `games:write` |
| `POST` | `/tournaments/{id}/leave` | Leave a tournament before it starts | `games:write` |
| `GET` | `/tasks` | Tasks you can play | `read` |
| `GET` | `/games/live` | Live games | `read` |
| `POST` | `/games` | Create a game: against a bot, or waiting for an opponent who joins by link | `games:write` |
| `POST` | `/games/{id}/join` | Join a game that waits for an opponent | `games:write` |
| `POST` | `/games/{id}/cancel` | Cancel your game that waits for an opponent | `games:write` |
| `POST` | `/invites` | Invite a user to a game | `games:write` |
| `POST` | `/invites/{id}/accept` | Accept an invite sent to you | `games:write` |
| `POST` | `/invites/{id}/cancel` | Decline or withdraw an invite | `games:write` |
| `GET` | `/users/online` | Who is online | `read` |

## Me

### GET /me

**Current user**

Who the token belongs to. A cheap way to check that a token works.

Scope: `read`

**Example request**

```bash
curl -s "https://codebattle.hexlet.io/public_api/v1/me" \
  -H "Authorization: Bearer $CODEBATTLE_TOKEN"
```

**Response `200`**

```json
{
  "user": {
    "id": 42,
    "name": "ada",
    "avatar_url": "https://avatars.githubusercontent.com/u/1?v=4",
    "rating": 1461,
    "rank": 12,
    "lang": "dlang"
  }
}
```

Errors: `401`, `403`, `404`, `429`

### GET /me/active_game

**Current live game (null if none). Count down with ends_at - server_time.**

Your live game, or `null`: the one you are playing, or one you created that still waits for an opponent (`state: waiting_opponent`). Poll it (every few seconds is fine) to notice a game started from the web or by a tournament. Compute the remaining time as `ends_at - server_time`, not against your own clock.

Scope: `read`

**Example request**

```bash
curl -s "https://codebattle.hexlet.io/public_api/v1/me/active_game" \
  -H "Authorization: Bearer $CODEBATTLE_TOKEN"
```

**Response `200`**

```json
{
  "game": {
    "id": 324274,
    "url": "https://codebattle.hexlet.io/games/324274",
    "state": "playing",
    "level": "easy",
    "type": "duo",
    "starts_at": "2026-10-10T18:40:00Z",
    "timeout_seconds": 600,
    "ends_at": "2026-10-10T18:50:00Z",
    "finishes_at": null,
    "tournament_id": null,
    "task": {
      "id": 1201,
      "name": "pair_with_maximum_product",
      "level": "easy",
      "tags": [
        "collections"
      ],
      "visibility": "public",
      "url": "https://codebattle.hexlet.io/tasks/1201"
    },
    "players": [
      {
        "id": 42,
        "name": "ada",
        "avatar_url": null,
        "rating": 1461,
        "rank": 12,
        "lang": "python",
        "is_bot": false,
        "result": null,
        "result_percent": 60.0
      },
      {
        "id": -1,
        "name": "Codebot",
        "avatar_url": null,
        "rating": 1200,
        "rank": null,
        "lang": "js",
        "is_bot": true,
        "result": null
      }
    ]
  },
  "server_time": "2026-10-10T18:43:20Z"
}
```

Errors: `401`, `403`, `404`, `429`

### GET /me/games

**My finished games, newest first**

Finished games (`game_over` or `timeout`), 50 per page, newest first. Pagination is keyset-based: `next_cursor` is the id of the last game on the page; pass it back as `cursor` to get the next, older page. `next_cursor` is `null` on the last page. `from`/`to` narrow the result by game start time and combine with `cursor`.

Scope: `read`

**Query parameters**

| Name | Type | Description |
|------|------|-------------|
| `cursor` | integer | `next_cursor` from the previous page: returns games with a smaller id. |
| `from` | datetime | Only games started at or after this moment. ISO 8601 date (`2026-10-01`, midnight UTC) or datetime. |
| `to` | datetime | Only games started before this moment. ISO 8601 date or datetime. |

**Example request**

```bash
curl -s "https://codebattle.hexlet.io/public_api/v1/me/games?from=2026-10-01&to=2026-10-10T18:00:00Z" \
  -H "Authorization: Bearer $CODEBATTLE_TOKEN"
```

**Response `200`**

```json
{
  "games": [
    {
      "id": 324274,
      "url": "https://codebattle.hexlet.io/games/324274",
      "state": "game_over",
      "level": "easy",
      "type": "duo",
      "starts_at": "2026-10-10T18:40:00Z",
      "timeout_seconds": 600,
      "ends_at": "2026-10-10T18:50:00Z",
      "finishes_at": "2026-10-10T18:46:12Z",
      "tournament_id": null,
      "task": {
        "id": 1201,
        "name": "pair_with_maximum_product",
        "level": "easy",
        "tags": [
          "collections"
        ],
        "visibility": "public",
        "url": "https://codebattle.hexlet.io/tasks/1201"
      },
      "players": [
        {
          "id": 42,
          "name": "ada",
          "avatar_url": null,
          "rating": 1461,
          "rank": 12,
          "lang": "python",
          "is_bot": false,
          "result": "won",
          "result_percent": 100.0
        },
        {
          "id": -1,
          "name": "Codebot",
          "avatar_url": null,
          "rating": 1200,
          "rank": null,
          "lang": "js",
          "is_bot": true,
          "result": "lost",
          "result_percent": 40.0
        }
      ]
    }
  ],
  "next_cursor": 324100
}
```

Errors: `401`, `403`, `404`, `422`, `429`

### GET /me/tournaments

**Tournaments I created, moderate or play**

Up to 50 tournaments you created, moderate or joined as a player, newest first.

Scope: `read`

**Query parameters**

| Name | Type | Description |
|------|------|-------------|
| `state` | string | Filter by state, e.g. `active` or `finished`. |

**Example request**

```bash
curl -s "https://codebattle.hexlet.io/public_api/v1/me/tournaments" \
  -H "Authorization: Bearer $CODEBATTLE_TOKEN"
```

**Response `200`**

```json
{
  "tournaments": [
    {
      "id": 5775,
      "name": "Friday practice",
      "url": "https://codebattle.hexlet.io/tournaments/5775",
      "type": "swiss",
      "state": "active",
      "level": "easy",
      "grade": "open",
      "access_type": "token",
      "starts_at": "2026-10-10T19:00:00Z",
      "started_at": "2026-10-10T19:00:04Z",
      "players_count": 8,
      "players_limit": 16,
      "rounds_limit": 3,
      "current_round": {
        "position": 1,
        "started_at": "2026-10-10T19:12:00Z",
        "ends_at": "2026-10-10T19:22:00Z"
      },
      "break": {
        "active": false,
        "ends_at": null
      },
      "access_token": "Xy7pQw2LmN4rTz8aB",
      "invite_url": "https://codebattle.hexlet.io/tournaments/5775?access_token=Xy7pQw2LmN4rTz8aB"
    }
  ]
}
```

Errors: `401`, `403`, `404`, `429`

### GET /me/invites

**My pending invites, incoming and outgoing**

Pending invites where you are the creator or the recipient. Supports long polling instead of frequent polls: pass `wait` (seconds, up to 30) and the `version` from your previous response. If the list already differs from that version the answer is immediate; otherwise the request is held until an invite is created, accepted or canceled, or until `wait` runs out, and then returns the current list. Loop on it to react to invites within a second with about two requests a minute. An accepted invite leaves the list; the creator finds the game via `GET /me/active_game`.

Scope: `read`

**Query parameters**

| Name | Type | Description |
|------|------|-------------|
| `wait` | integer | Hold the request up to this many seconds (0..30) while nothing changes. |
| `version` | string | `version` from the previous response. |

**Example request**

```bash
curl -s "https://codebattle.hexlet.io/public_api/v1/me/invites?wait=25&version=1x3kq9" \
  -H "Authorization: Bearer $CODEBATTLE_TOKEN"
```

**Response `200`**

```json
{
  "invites": [
    {
      "id": 9001,
      "state": "pending",
      "direction": "incoming",
      "level": "easy",
      "timeout_seconds": 600,
      "creator": {
        "id": 77,
        "name": "grace",
        "avatar_url": null,
        "rating": 1520
      },
      "recipient": {
        "id": 42,
        "name": "ada",
        "avatar_url": null,
        "rating": 1461
      },
      "game_id": null,
      "game_url": null,
      "created_at": "2026-10-10T19:01:00Z"
    }
  ],
  "version": "1x3kq9",
  "server_time": "2026-10-10T19:01:05Z"
}
```

Errors: `401`, `403`, `404`, `422`, `429`

### GET /me/events

**Live stream of everything that happens to you (SSE)**

A Server-Sent Events stream: one request that stays open. It starts with `hello` (your current state: active game, pending invites, unfinished tournaments), then sends one event per change: invites, your games, tournaments you play in or run. A `: ping` comment comes every 15 seconds. The server closes the stream after an hour with a `bye` event; reconnect and you get a fresh `hello`. At most 2 open streams per user. See **Event stream** above for every event and client examples.

Scope: `read`

**Example request**

```bash
curl -sN "https://codebattle.hexlet.io/public_api/v1/me/events" \
  -H "Authorization: Bearer $CODEBATTLE_TOKEN" \
  -H "Accept: text/event-stream"
```

**Response `200`** (`text/event-stream`, stays open)

```text
retry: 3000

id: 1791702000000
event: hello
data: {"user":{"id":42,"name":"ada",...},"active_game":null,"invites":[],"tournaments":[],"server_time":"2026-10-11T07:00:00Z"}

id: 1791702012345
event: invite.created
data: {"invite":{"id":9001,"direction":"incoming","creator":{"id":77,"name":"grace",...},...},"server_time":"2026-10-11T07:00:12Z"}

: ping

id: 1791702020000
event: game.started
data: {"game":{"id":324274,"state":"playing","url":"https://codebattle.hexlet.io/games/324274",...},"server_time":"2026-10-11T07:00:20Z"}
```

Errors: `401`, `403`, `429`

## Tournaments

### GET /tournaments/schedule

**Public upcoming and active tournaments for the next 14 days**

Public tournaments that start between 1 day ago and 14 days ahead and are not finished yet: what the lobby shows as upcoming. For everything running right now use `GET /tournaments/live`.

Scope: `read`

**Example request**

```bash
curl -s "https://codebattle.hexlet.io/public_api/v1/tournaments/schedule" \
  -H "Authorization: Bearer $CODEBATTLE_TOKEN"
```

**Response `200`**

```json
{
  "tournaments": [
    {
      "id": 5775,
      "name": "Friday practice",
      "url": "https://codebattle.hexlet.io/tournaments/5775",
      "type": "swiss",
      "state": "active",
      "level": "easy",
      "grade": "open",
      "access_type": "token",
      "starts_at": "2026-10-10T19:00:00Z",
      "started_at": "2026-10-10T19:00:04Z",
      "players_count": 8,
      "players_limit": 16,
      "rounds_limit": 3,
      "current_round": {
        "position": 1,
        "started_at": "2026-10-10T19:12:00Z",
        "ends_at": "2026-10-10T19:22:00Z"
      },
      "break": {
        "active": false,
        "ends_at": null
      },
      "access_token": "Xy7pQw2LmN4rTz8aB",
      "invite_url": "https://codebattle.hexlet.io/tournaments/5775?access_token=Xy7pQw2LmN4rTz8aB"
    }
  ]
}
```

Errors: `401`, `403`, `404`, `429`

### GET /tournaments/live

**Tournaments running now or open for joining**

Currently running tournament processes in `waiting_participants` or `active` state: public ones plus private ones you created, moderate or play in. Uses live state and participant counts, excluding abandoned database records. A running tournament can have an old `starts_at`. Newest first, at most 100.

Scope: `read`

**Example request**

```bash
curl -s "https://codebattle.hexlet.io/public_api/v1/tournaments/live" \
  -H "Authorization: Bearer $CODEBATTLE_TOKEN"
```

**Response `200`**

```json
{
  "tournaments": [
    {
      "id": 5775,
      "name": "Friday practice",
      "url": "https://codebattle.hexlet.io/tournaments/5775",
      "type": "swiss",
      "state": "active",
      "level": "easy",
      "grade": "open",
      "access_type": "token",
      "starts_at": "2026-10-10T19:00:00Z",
      "started_at": "2026-10-10T19:00:04Z",
      "players_count": 8,
      "players_limit": 16,
      "rounds_limit": 3,
      "current_round": {
        "position": 1,
        "started_at": "2026-10-10T19:12:00Z",
        "ends_at": "2026-10-10T19:22:00Z"
      },
      "break": {
        "active": false,
        "ends_at": null
      },
      "access_token": "Xy7pQw2LmN4rTz8aB",
      "invite_url": "https://codebattle.hexlet.io/tournaments/5775?access_token=Xy7pQw2LmN4rTz8aB"
    }
  ]
}
```

Errors: `401`, `403`, `404`, `429`

### POST /tournaments

**Create a private tournament (always grade open, tasks by level)**

Creates a private (`access_type: token`) tournament of grade `open`; tasks are picked by `level`. Share `invite_url` with players. Quota: 5 per day and at most 3 unfinished API-created tournaments.

Scope: `tournaments:write`

**Request body**

| Field | Type | Description |
|-------|------|-------------|
| `name` *(required)* | string | 3..100 characters |
| `description` *(required)* | string | 3..2000 characters |
| `starts_at` *(required)* | datetime | In the future, at most 30 days ahead |
| `level` *(required)* | string | One of `elementary`, `easy`, `medium`, `hard` |
| `players_limit` | integer | 2..64 |
| `rounds_limit` | integer | 1..10 |
| `timeout_mode` | string | One of `per_task`, `per_round_fixed` |
| `match_timeout_seconds` | integer | 60..3600 |
| `round_timeout_seconds` | integer | 60..3600 |
| `break_duration_seconds` | integer | 0..600 |
| `use_chat` | boolean |  |

**Example request**

```bash
curl -s -X POST "https://codebattle.hexlet.io/public_api/v1/tournaments" \
  -H "Authorization: Bearer $CODEBATTLE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"ada","description":"Weekly practice for our team","starts_at":"2026-10-10T19:03:18Z","level":"elementary"}'
```

**Response `201`**

```json
{
  "tournament": {
    "id": 5775,
    "name": "Friday practice",
    "url": "https://codebattle.hexlet.io/tournaments/5775",
    "type": "swiss",
    "state": "active",
    "level": "easy",
    "grade": "open",
    "access_type": "token",
    "starts_at": "2026-10-10T19:00:00Z",
    "started_at": "2026-10-10T19:00:04Z",
    "players_count": 8,
    "players_limit": 16,
    "rounds_limit": 3,
    "current_round": {
      "position": 1,
      "started_at": "2026-10-10T19:12:00Z",
      "ends_at": "2026-10-10T19:22:00Z"
    },
    "break": {
      "active": false,
      "ends_at": null
    },
    "access_token": "Xy7pQw2LmN4rTz8aB",
    "invite_url": "https://codebattle.hexlet.io/tournaments/5775?access_token=Xy7pQw2LmN4rTz8aB"
  }
}
```

Errors: `401`, `403`, `404`, `415`, `422`, `429`

### GET /tournaments/{id}

**Tournament card: related to you, or public and scheduled**

Works for tournaments related to you and for public ones. The creator and moderators also get `access_token` and `invite_url`.

Scope: `read`

**Example request**

```bash
curl -s "https://codebattle.hexlet.io/public_api/v1/tournaments/123" \
  -H "Authorization: Bearer $CODEBATTLE_TOKEN"
```

**Response `200`**

```json
{
  "tournament": {
    "id": 5775,
    "name": "Friday practice",
    "url": "https://codebattle.hexlet.io/tournaments/5775",
    "type": "swiss",
    "state": "active",
    "level": "easy",
    "grade": "open",
    "access_type": "token",
    "starts_at": "2026-10-10T19:00:00Z",
    "started_at": "2026-10-10T19:00:04Z",
    "players_count": 8,
    "players_limit": 16,
    "rounds_limit": 3,
    "current_round": {
      "position": 1,
      "started_at": "2026-10-10T19:12:00Z",
      "ends_at": "2026-10-10T19:22:00Z"
    },
    "break": {
      "active": false,
      "ends_at": null
    },
    "access_token": "Xy7pQw2LmN4rTz8aB",
    "invite_url": "https://codebattle.hexlet.io/tournaments/5775?access_token=Xy7pQw2LmN4rTz8aB"
  }
}
```

Errors: `401`, `403`, `404`, `429`

### PATCH /tournaments/{id}

**Edit your tournament before it starts**

Only the fields you send change. Allowed while the tournament is `upcoming` or `waiting_participants`.

Scope: `tournaments:write`

**Request body**

| Field | Type | Description |
|-------|------|-------------|
| `name` | string | 3..100 characters |
| `description` | string | 3..2000 characters |
| `starts_at` | datetime | In the future, at most 30 days ahead |
| `level` | string | One of `elementary`, `easy`, `medium`, `hard` |
| `players_limit` | integer | 2..64 |
| `rounds_limit` | integer | 1..10 |
| `timeout_mode` | string | One of `per_task`, `per_round_fixed` |
| `match_timeout_seconds` | integer | 60..3600 |
| `round_timeout_seconds` | integer | 60..3600 |
| `break_duration_seconds` | integer | 0..600 |
| `use_chat` | boolean |  |

**Example request**

```bash
curl -s -X PATCH "https://codebattle.hexlet.io/public_api/v1/tournaments/123" \
  -H "Authorization: Bearer $CODEBATTLE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

**Response `200`**

```json
{
  "tournament": {
    "id": 5775,
    "name": "Friday practice",
    "url": "https://codebattle.hexlet.io/tournaments/5775",
    "type": "swiss",
    "state": "active",
    "level": "easy",
    "grade": "open",
    "access_type": "token",
    "starts_at": "2026-10-10T19:00:00Z",
    "started_at": "2026-10-10T19:00:04Z",
    "players_count": 8,
    "players_limit": 16,
    "rounds_limit": 3,
    "current_round": {
      "position": 1,
      "started_at": "2026-10-10T19:12:00Z",
      "ends_at": "2026-10-10T19:22:00Z"
    },
    "break": {
      "active": false,
      "ends_at": null
    },
    "access_token": "Xy7pQw2LmN4rTz8aB",
    "invite_url": "https://codebattle.hexlet.io/tournaments/5775?access_token=Xy7pQw2LmN4rTz8aB"
  }
}
```

Errors: `401`, `403`, `404`, `415`, `422`, `429`

### GET /tournaments/{id}/ranking

**Ranking: full and paginated for the creator/moderators, top 20 plus your row for participants**

The creator and moderators get the full ranking with `page` / `page_size` (max 100). Participants get the top 20 and their own row in `me`.

Scope: `read`

**Query parameters**

| Name | Type | Description |
|------|------|-------------|
| `page` | integer |  |
| `page_size` | integer |  |

**Example request**

```bash
curl -s "https://codebattle.hexlet.io/public_api/v1/tournaments/123/ranking" \
  -H "Authorization: Bearer $CODEBATTLE_TOKEN"
```

**Response `200`**

```json
{
  "ranking": [
    {
      "id": 42,
      "name": "ada",
      "place": 1,
      "score": 42,
      "lang": "python"
    }
  ],
  "me": {
    "id": 42,
    "name": "ada",
    "place": 1,
    "score": 42,
    "lang": "python"
  },
  "page_info": {
    "page_number": 1,
    "page_size": 1,
    "total_entries": 1
  }
}
```

Errors: `401`, `403`, `404`, `429`

### POST /tournaments/{id}/start

**Start your tournament**

Starts your tournament now with the players who have joined.

Scope: `tournaments:write`

**Example request**

```bash
curl -s -X POST "https://codebattle.hexlet.io/public_api/v1/tournaments/123/start" \
  -H "Authorization: Bearer $CODEBATTLE_TOKEN"
```

**Response `200`**

```json
{
  "tournament": {
    "id": 5775,
    "name": "Friday practice",
    "url": "https://codebattle.hexlet.io/tournaments/5775",
    "type": "swiss",
    "state": "active",
    "level": "easy",
    "grade": "open",
    "access_type": "token",
    "starts_at": "2026-10-10T19:00:00Z",
    "started_at": "2026-10-10T19:00:04Z",
    "players_count": 8,
    "players_limit": 16,
    "rounds_limit": 3,
    "current_round": {
      "position": 1,
      "started_at": "2026-10-10T19:12:00Z",
      "ends_at": "2026-10-10T19:22:00Z"
    },
    "break": {
      "active": false,
      "ends_at": null
    },
    "access_token": "Xy7pQw2LmN4rTz8aB",
    "invite_url": "https://codebattle.hexlet.io/tournaments/5775?access_token=Xy7pQw2LmN4rTz8aB"
  }
}
```

Errors: `401`, `403`, `404`, `422`, `429`

### POST /tournaments/{id}/cancel

**Cancel your tournament**

Cancels your tournament and finishes its games.

Scope: `tournaments:write`

**Example request**

```bash
curl -s -X POST "https://codebattle.hexlet.io/public_api/v1/tournaments/123/cancel" \
  -H "Authorization: Bearer $CODEBATTLE_TOKEN"
```

**Response `200`**

```json
{
  "tournament": {
    "id": 5775,
    "name": "Friday practice",
    "url": "https://codebattle.hexlet.io/tournaments/5775",
    "type": "swiss",
    "state": "active",
    "level": "easy",
    "grade": "open",
    "access_type": "token",
    "starts_at": "2026-10-10T19:00:00Z",
    "started_at": "2026-10-10T19:00:04Z",
    "players_count": 8,
    "players_limit": 16,
    "rounds_limit": 3,
    "current_round": {
      "position": 1,
      "started_at": "2026-10-10T19:12:00Z",
      "ends_at": "2026-10-10T19:22:00Z"
    },
    "break": {
      "active": false,
      "ends_at": null
    },
    "access_token": "Xy7pQw2LmN4rTz8aB",
    "invite_url": "https://codebattle.hexlet.io/tournaments/5775?access_token=Xy7pQw2LmN4rTz8aB"
  }
}
```

Errors: `401`, `403`, `404`, `429`

### POST /tournaments/{id}/join

**Join a tournament as a player**

Same as the Join button on the tournament page: works for a public tournament from the schedule, or a private one when you pass the `access_token` from its `invite_url` (without it, a private tournament you are not related to answers 404). Possible while the tournament is `waiting_participants`, and for swiss and ladder also while it is `active`. Joining twice is not an error: you get the card with `joined: true`. Then wait for `tournament.started` / `game.started` on `GET /me/events` and open the game `url` only when it begins, instead of keeping the tournament page open. A failed join answers 422 `validation_failed` with `details.reason`: `not_open` (finished, canceled or not open yet), `full` (`players_limit` reached), `not_running` (the tournament process is stopped; an organizer has to restart it) or `rejected` (the tournament didn't accept the join).

Scope: `games:write`

**Request body**

| Field | Type | Description |
|-------|------|-------------|
| `access_token` | string | The `access_token` query parameter of a private tournament's `invite_url` |

**Example request**

```bash
curl -s -X POST "https://codebattle.hexlet.io/public_api/v1/tournaments/123/join" \
  -H "Authorization: Bearer $CODEBATTLE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

**Response `200`**

```json
{
  "tournament": {
    "id": 5775,
    "name": "Friday practice",
    "url": "https://codebattle.hexlet.io/tournaments/5775",
    "type": "swiss",
    "state": "active",
    "level": "easy",
    "grade": "open",
    "access_type": "token",
    "starts_at": "2026-10-10T19:00:00Z",
    "started_at": "2026-10-10T19:00:04Z",
    "players_count": 8,
    "players_limit": 16,
    "rounds_limit": 3,
    "current_round": {
      "position": 1,
      "started_at": "2026-10-10T19:12:00Z",
      "ends_at": "2026-10-10T19:22:00Z"
    },
    "break": {
      "active": false,
      "ends_at": null
    },
    "access_token": "Xy7pQw2LmN4rTz8aB",
    "invite_url": "https://codebattle.hexlet.io/tournaments/5775?access_token=Xy7pQw2LmN4rTz8aB"
  }
}
```

Errors: `401`, `403`, `404`, `422`, `429`

### POST /tournaments/{id}/leave

**Leave a tournament before it starts**

Removes you from the players while the tournament is `waiting_participants`. Leaving a tournament you don't play in returns the card with `joined: false`. After the start it answers 422 `validation_failed` with `details.reason` `already_started`; `not_running` means the tournament process is stopped.

Scope: `games:write`

**Example request**

```bash
curl -s -X POST "https://codebattle.hexlet.io/public_api/v1/tournaments/123/leave" \
  -H "Authorization: Bearer $CODEBATTLE_TOKEN"
```

**Response `200`**

```json
{
  "tournament": {
    "id": 5775,
    "name": "Friday practice",
    "url": "https://codebattle.hexlet.io/tournaments/5775",
    "type": "swiss",
    "state": "active",
    "level": "easy",
    "grade": "open",
    "access_type": "token",
    "starts_at": "2026-10-10T19:00:00Z",
    "started_at": "2026-10-10T19:00:04Z",
    "players_count": 8,
    "players_limit": 16,
    "rounds_limit": 3,
    "current_round": {
      "position": 1,
      "started_at": "2026-10-10T19:12:00Z",
      "ends_at": "2026-10-10T19:22:00Z"
    },
    "break": {
      "active": false,
      "ends_at": null
    },
    "access_token": "Xy7pQw2LmN4rTz8aB",
    "invite_url": "https://codebattle.hexlet.io/tournaments/5775?access_token=Xy7pQw2LmN4rTz8aB"
  }
}
```

Errors: `401`, `403`, `404`, `422`, `429`

## Tasks

### GET /tasks

**Tasks you can play**

Public active tasks plus your own, ordered by level and name. Use an `id` or `name` from here in `POST /games` or `POST /invites`.

Scope: `read`

**Query parameters**

| Name | Type | Description |
|------|------|-------------|
| `level` | string | Only tasks of this level. |
| `tags` | string | Comma-separated; tasks having any of these tags. |
| `name` | string | Case-insensitive substring of the task name. |

**Example request**

```bash
curl -s "https://codebattle.hexlet.io/public_api/v1/tasks?level=medium&tags=strings,math" \
  -H "Authorization: Bearer $CODEBATTLE_TOKEN"
```

**Response `200`**

```json
{
  "tasks": [
    {
      "id": 42,
      "name": "minimal_time_partition",
      "level": "medium",
      "tags": [
        "math",
        "collections"
      ],
      "visibility": "public",
      "url": "https://codebattle.hexlet.io/tasks/42"
    }
  ]
}
```

Errors: `401`, `403`, `422`, `429`

## Games

### GET /games/live

**Live games**

What the lobby shows: games being played and games waiting for an opponent (usually a few Codebot games are waiting), newest first. Tournament games and other people's link games are not listed. Pass `state=waiting_opponent` for just the games you can join, then join one with `POST /games/{id}/join`; their task stays hidden until someone joins. Changes often; poll every 10–30 seconds.

Scope: `read`

**Query parameters**

| Name | Type | Description |
|------|------|-------------|
| `state` | string | Only games in this state: `waiting_opponent` (open to join) or `playing`. |

**Example request**

```bash
curl -s "https://codebattle.hexlet.io/public_api/v1/games/live?state=waiting_opponent" \
  -H "Authorization: Bearer $CODEBATTLE_TOKEN"
```

**Response `200`**

```json
{
  "games": [
    {
      "id": 324274,
      "url": "https://codebattle.hexlet.io/games/324274",
      "state": "game_over",
      "level": "easy",
      "type": "duo",
      "starts_at": "2026-10-10T18:40:00Z",
      "timeout_seconds": 600,
      "ends_at": "2026-10-10T18:50:00Z",
      "finishes_at": "2026-10-10T18:46:12Z",
      "tournament_id": null,
      "task": {
        "id": 1201,
        "name": "pair_with_maximum_product",
        "level": "easy",
        "tags": [
          "collections"
        ],
        "visibility": "public",
        "url": "https://codebattle.hexlet.io/tasks/1201"
      },
      "players": [
        {
          "id": 42,
          "name": "ada",
          "avatar_url": null,
          "rating": 1461,
          "rank": 12,
          "lang": "python",
          "is_bot": false,
          "result": "won",
          "result_percent": 100.0
        },
        {
          "id": -1,
          "name": "Codebot",
          "avatar_url": null,
          "rating": 1200,
          "rank": null,
          "lang": "js",
          "is_bot": true,
          "result": "lost",
          "result_percent": 40.0
        }
      ]
    }
  ],
  "server_time": "2026-10-10T19:03:18Z"
}
```

Errors: `401`, `403`, `422`, `429`

### POST /games

**Create a game: against a bot, or waiting for an opponent who joins by link**

`opponent: bot` starts the game immediately against Codebot. `opponent: link` creates a hidden game waiting for an opponent; send them `join_url`. Pick the task with `task_id` or `task_name` (see `GET /tasks`), or let the server pick a random one of `level`. Only tasks you may play are accepted: public ones and your own; any other answers 422 `no such task`. You can have only one active game at a time. Quota: 5 games per minute, 30 per day.

Scope: `games:write`

**Request body**

| Field | Type | Description |
|-------|------|-------------|
| `level` | string | Required unless `task_id` or `task_name` is given; with a task it must equal the task's level.. One of `elementary`, `easy`, `medium`, `hard` |
| `task_id` | integer | Play this task (from `GET /tasks`). Pass this or `task_name`; without both a random task of `level` is picked. |
| `task_name` | string | Exact task name, e.g. `minimal_time_partition` |
| `opponent` *(required)* | string | One of `bot`, `link` |
| `timeout_seconds` | integer | 60..3600 |

**Example request**

```bash
curl -s -X POST "https://codebattle.hexlet.io/public_api/v1/games" \
  -H "Authorization: Bearer $CODEBATTLE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"opponent":"bot"}'
```

**Response `201`**

```json
{
  "game": {
    "id": 324274,
    "url": "https://codebattle.hexlet.io/games/324274",
    "state": "waiting_opponent",
    "level": "easy",
    "type": "duo",
    "starts_at": "2026-10-10T19:03:18Z",
    "timeout_seconds": 600,
    "ends_at": "2026-10-10T19:03:18Z",
    "finishes_at": "2026-10-10T19:03:18Z",
    "tournament_id": null,
    "task": {
      "id": 42,
      "name": "minimal_time_partition",
      "level": "medium",
      "tags": [
        "math",
        "collections"
      ],
      "visibility": "public",
      "url": "https://codebattle.hexlet.io/tasks/42"
    },
    "players": [
      {
        "id": 42,
        "name": "ada",
        "avatar_url": null,
        "rating": 1461,
        "rank": 12,
        "lang": "python",
        "is_bot": false,
        "result": "won",
        "result_percent": 100.0
      }
    ],
    "join_url": "string"
  }
}
```

Errors: `401`, `403`, `404`, `415`, `422`, `429`

### POST /games/{id}/join

**Join a game that waits for an opponent**

Joins a game from `GET /games/live?state=waiting_opponent`, or a `link` game whose id someone shared with you. The game starts at once; open its `url`. A failed join answers 422 `validation_failed` with `details.reason`: `not_waiting_opponent` (the game already started), `already_in_a_game` (you play another game: `details.active_game_id`; finish it or `POST /games/{id}/cancel` it if it still waits) or `already_joined` (you are already a player of this game).

Scope: `games:write`

**Example request**

```bash
curl -s -X POST "https://codebattle.hexlet.io/public_api/v1/games/123/join" \
  -H "Authorization: Bearer $CODEBATTLE_TOKEN"
```

**Response `200`**

```json
{
  "game": {
    "id": 324274,
    "url": "https://codebattle.hexlet.io/games/324274",
    "state": "game_over",
    "level": "easy",
    "type": "duo",
    "starts_at": "2026-10-10T18:40:00Z",
    "timeout_seconds": 600,
    "ends_at": "2026-10-10T18:50:00Z",
    "finishes_at": "2026-10-10T18:46:12Z",
    "tournament_id": null,
    "task": {
      "id": 1201,
      "name": "pair_with_maximum_product",
      "level": "easy",
      "tags": [
        "collections"
      ],
      "visibility": "public",
      "url": "https://codebattle.hexlet.io/tasks/1201"
    },
    "players": [
      {
        "id": 42,
        "name": "ada",
        "avatar_url": null,
        "rating": 1461,
        "rank": 12,
        "lang": "python",
        "is_bot": false,
        "result": "won",
        "result_percent": 100.0
      },
      {
        "id": -1,
        "name": "Codebot",
        "avatar_url": null,
        "rating": 1200,
        "rank": null,
        "lang": "js",
        "is_bot": true,
        "result": "lost",
        "result_percent": 40.0
      }
    ]
  }
}
```

Errors: `401`, `403`, `404`, `422`, `429`

### POST /games/{id}/cancel

**Cancel your game that waits for an opponent**

Only a game in `waiting_opponent` state can be canceled.

Scope: `games:write`

**Example request**

```bash
curl -s -X POST "https://codebattle.hexlet.io/public_api/v1/games/123/cancel" \
  -H "Authorization: Bearer $CODEBATTLE_TOKEN"
```

**Response `200`**

```json
{
  "game": {
    "id": 324274,
    "state": "canceled"
  }
}
```

Errors: `401`, `403`, `404`, `422`, `429`

## Invites

### POST /invites

**Invite a user to a game**

Sends an invite that the recipient sees in the lobby and in `GET /me/invites`. One pending invite per pair of users.

Scope: `games:write`

**Request body**

| Field | Type | Description |
|-------|------|-------------|
| `recipient_id` | integer | Whom to invite. Pass this or `recipient_name`. |
| `recipient_name` | string | Exact user name, handy for voice commands |
| `level` | string | One of `elementary`, `easy`, `medium`, `hard` |
| `task_id` | integer | Play this task (from `GET /tasks`). Pass this or `task_name`; without both a random task of `level` is picked. |
| `task_name` | string | Exact task name, e.g. `minimal_time_partition` |
| `timeout_seconds` | integer | Game length, default 3600. 60..3600 |

**Example request**

```bash
curl -s -X POST "https://codebattle.hexlet.io/public_api/v1/invites" \
  -H "Authorization: Bearer $CODEBATTLE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

**Response `201`**

```json
{
  "invite": {
    "id": 9001,
    "state": "pending",
    "direction": "incoming",
    "level": "easy",
    "timeout_seconds": 600,
    "creator": {
      "id": 77,
      "name": "grace",
      "avatar_url": null,
      "rating": 1520
    },
    "recipient": {
      "id": 42,
      "name": "ada",
      "avatar_url": null,
      "rating": 1461
    },
    "task_id": null,
    "game_id": null,
    "game_url": null,
    "created_at": "2026-10-10T19:01:00Z"
  }
}
```

Errors: `401`, `403`, `404`, `415`, `422`, `429`

### POST /invites/{id}/accept

**Accept an invite sent to you**

Starts the game for both players; the response has `game_id` and `game_url`. Fails with `422` if one of you is already in a game (the invite is then dropped).

Scope: `games:write`

**Example request**

```bash
curl -s -X POST "https://codebattle.hexlet.io/public_api/v1/invites/123/accept" \
  -H "Authorization: Bearer $CODEBATTLE_TOKEN"
```

**Response `200`**

```json
{
  "invite": {
    "id": 9001,
    "state": "pending",
    "direction": "incoming",
    "level": "easy",
    "timeout_seconds": 600,
    "creator": {
      "id": 77,
      "name": "grace",
      "avatar_url": null,
      "rating": 1520
    },
    "recipient": {
      "id": 42,
      "name": "ada",
      "avatar_url": null,
      "rating": 1461
    },
    "task_id": null,
    "game_id": null,
    "game_url": null,
    "created_at": "2026-10-10T19:01:00Z"
  }
}
```

Errors: `401`, `403`, `404`, `422`, `429`

### POST /invites/{id}/cancel

**Decline or withdraw an invite**

The recipient declines, the creator withdraws. Only pending invites.

Scope: `games:write`

**Example request**

```bash
curl -s -X POST "https://codebattle.hexlet.io/public_api/v1/invites/123/cancel" \
  -H "Authorization: Bearer $CODEBATTLE_TOKEN"
```

**Response `200`**

```json
{
  "invite": {
    "id": 9001,
    "state": "pending",
    "direction": "incoming",
    "level": "easy",
    "timeout_seconds": 600,
    "creator": {
      "id": 77,
      "name": "grace",
      "avatar_url": null,
      "rating": 1520
    },
    "recipient": {
      "id": 42,
      "name": "ada",
      "avatar_url": null,
      "rating": 1461
    },
    "task_id": null,
    "game_id": null,
    "game_url": null,
    "created_at": "2026-10-10T19:01:00Z"
  }
}
```

Errors: `401`, `403`, `404`, `422`, `429`

## Users

### GET /users/online

**Who is online**

Registered users with codebattle open right now, highest rating first, with what they are doing. Changes slowly: polling every 30–60 seconds is plenty.

Scope: `read`

**Example request**

```bash
curl -s "https://codebattle.hexlet.io/public_api/v1/users/online" \
  -H "Authorization: Bearer $CODEBATTLE_TOKEN"
```

**Response `200`**

```json
{
  "users": [
    {
      "id": 77,
      "name": "grace",
      "avatar_url": null,
      "rating": 1520,
      "rank": 8,
      "lang": "dlang",
      "state": "playing",
      "url": "https://codebattle.hexlet.io/games/324274"
    }
  ],
  "server_time": "2026-10-10T19:03:18Z"
}
```

Errors: `401`, `403`, `404`, `429`

## Objects

Fields of the objects the endpoints return. Nullable fields may be `null`.

### Player

| Field | Type | Description |
|-------|------|-------------|
| `id` | integer |  |
| `name` | string |  |
| `avatar_url` | string, nullable |  |
| `rating` | integer, nullable |  |
| `rank` | integer, nullable | Place in the global rating |
| `lang` | string, nullable | Preferred programming language |

### Game

| Field | Type | Description |
|-------|------|-------------|
| `id` | integer |  |
| `url` | string |  |
| `state` | string | One of `waiting_opponent`, `playing`, `game_over`, `timeout`, `canceled` |
| `level` | string |  |
| `type` | string |  |
| `starts_at` | datetime, nullable |  |
| `timeout_seconds` | integer |  |
| `ends_at` | datetime, nullable | starts_at + timeout_seconds: when the game times out |
| `finishes_at` | datetime, nullable |  |
| `tournament_id` | integer, nullable |  |
| `task` | Task, nullable | `null` while the game waits for an opponent, unless it is your game |
| `players` | array of GamePlayer |  |

### GamePlayer

Every field of `Player`, plus its own.

| Field | Type | Description |
|-------|------|-------------|
| `id` | integer |  |
| `name` | string |  |
| `avatar_url` | string, nullable |  |
| `rating` | integer, nullable |  |
| `rank` | integer, nullable | Place in the global rating |
| `lang` | string, nullable | Language of the player's editor in this game |
| `is_bot` | boolean, nullable |  |
| `result` | string, nullable | `null` until the player's game is decided. One of `won`, `lost`, `gave_up`, `timeout`, `` |
| `result_percent` | number, nullable | Hidden for opponents while the game is live |

### Task

| Field | Type | Description |
|-------|------|-------------|
| `id` | integer |  |
| `name` | string | Unique, e.g. `minimal_time_partition`; usable as `task_name` |
| `level` | string | One of `elementary`, `easy`, `medium`, `hard` |
| `tags` | array of string |  |
| `visibility` | string | `hidden` tasks are listed only for their creator. One of `public`, `hidden` |
| `url` | string |  |

### Tournament

| Field | Type | Description |
|-------|------|-------------|
| `id` | integer |  |
| `name` | string |  |
| `url` | string |  |
| `type` | string |  |
| `state` | string | One of `upcoming`, `waiting_participants`, `active`, `finished`, `canceled`, `timeout` |
| `level` | string |  |
| `grade` | string | `open` for user tournaments; season grades for official ones |
| `access_type` | string | One of `public`, `token` |
| `starts_at` | datetime, nullable |  |
| `started_at` | datetime, nullable |  |
| `players_count` | integer, nullable |  |
| `players_limit` | integer, nullable |  |
| `rounds_limit` | integer, nullable |  |
| `current_round` | object, nullable | Fields: `position`, `started_at`, `ends_at` |
| `break` | object | Fields: `active`, `ends_at` |
| `joined` | boolean | Whether you are a player of this tournament |
| `access_token` | string | Creator and moderators only |
| `invite_url` | string | Creator and moderators only |

### RankingEntry

| Field | Type | Description |
|-------|------|-------------|
| `id` | integer |  |
| `name` | string |  |
| `place` | integer |  |
| `score` | number |  |
| `lang` | string, nullable |  |

### Invite

| Field | Type | Description |
|-------|------|-------------|
| `id` | integer |  |
| `state` | string | One of `pending`, `accepted`, `canceled`, `expired`, `dropped` |
| `direction` | string | `incoming`: you are the recipient and can accept. One of `incoming`, `outgoing` |
| `level` | string, nullable |  |
| `timeout_seconds` | integer, nullable |  |
| `creator` | Player, nullable |  |
| `recipient` | Player, nullable |  |
| `task_id` | integer, nullable | Task chosen by the creator, if any |
| `game_id` | integer, nullable | Set once accepted |
| `game_url` | string, nullable |  |
| `created_at` | datetime |  |

### OnlineUser

Every field of `Player`, plus its own.

| Field | Type | Description |
|-------|------|-------------|
| `id` | integer |  |
| `name` | string |  |
| `avatar_url` | string, nullable |  |
| `rating` | integer, nullable |  |
| `rank` | integer, nullable | Place in the global rating |
| `lang` | string, nullable | Preferred programming language |
| `state` | string | The busiest of the user's open tabs. One of `online`, `lobby`, `task`, `tournament`, `watching`, `playing` |
| `url` | string, nullable | Game or tournament the user is on, if any |

### Error

| Field | Type | Description |
|-------|------|-------------|
| `error` | object | Fields: `code`, `message`, `details` |
