ReplayLabs Developer API

Public integration documentation

This page documents the current public ReplayLabs API exactly as it is exposed today. It covers authentication, plans, limits, endpoints, payloads, and error behavior.

If you want product context beyond raw endpoints, start with the public use-case pages below.

Base path
/api/external/v1
Replays, replay groups, and teams. Reads cover replay payloads, group membership, team rosters, team statistics, and a team's groups. Writes cover creating and managing the teams your own account owns, which is what a league bot needs to keep rosters in step with its own signups.
Starter
EUR 3.99 / month
Tax not included
1 active API key
60 requests / minute
25,000 requests / month
Pro
EUR 9.99 / month
Tax not included
3 active API keys
180 requests / minute
150,000 requests / month
Authentication and scopes

Use a bearer API key

API keys are created in the developer portal. They are sent in the `Authorization` header and are tied to the owning ReplayLabs account.

curl -H "Authorization: Bearer YOUR_API_KEY" \
  https://api.replaylabs.app/api/external/v1/replays/227
Current scopes:
replays:readgroups:readteams:readteams:write
Endpoint reference
GET/api/external/v1/replays/:gameId
Required scopes: `replays:read`
Returns exactly one replay payload wrapped as `{ data: ... }`.
`:gameId` must parse as a base-10 integer or the API returns `400 BAD_REQUEST`.
A valid active key can currently fetch any existing replay id.
The replay payload always contains both `blue` and `orange` team objects, even if one side has no players.
GET/api/external/v1/groups/:groupId/replays
Required scopes: `groups:read`
Returns exactly one group payload wrapped as `{ data: ... }`.
Supported `view` values are `summary` and `ids`.
If `view` is omitted, the API uses `summary`.
Replay rows are ordered by `date DESC NULLS LAST, game_id DESC`.
GET/api/external/v1/teams
Required scopes: `teams:read`
Lists the teams the key's owner owns or plays for. No other team is visible to the key.
`limit` defaults to 50 and must be between 1 and 100; `offset` defaults to 0. Either out of range returns `400 BAD_REQUEST`.
`total` counts every team the key can see, not the page.
Teams are ordered by `created_at DESC NULLS LAST, id DESC`.
GET/api/external/v1/teams/:teamId
Required scopes: `teams:read`
Returns exactly one team payload wrapped as `{ data: ... }`, including the full roster.
A team the key cannot see returns `404 NOT_FOUND`, the same answer as a team that does not exist.
`members` is ordered starters first, then by `slot`, then username, then player id.
`slot` is null when no slot is assigned. It is never 0 for an unassigned member.
GET/api/external/v1/teams/:teamId/groups
Required scopes: `teams:read`, `groups:read`
Lists the replay groups filed under a team, wrapped as `{ data: ... }`.
Being on the team makes the list readable; each group is still filtered by its own visibility, so a private group belonging to someone else on the team is left out.
Groups are ordered by `created_at DESC NULLS LAST, id DESC`.
Each entry carries `link`, which is the group's own endpoint for fetching its replays.
GET/api/external/v1/teams/:teamId/stats
Required scopes: `teams:read`
Returns the team's record over the matches where its whole starting roster played on the same side.
`record` carries wins, losses, draws, goals for and against, goal difference, and per-match rates.
A rate is `null`, never `0`, when there is nothing to divide by - a team with no matches has no win rate.
`players` splits the same matches across the roster; `form` is the last 10 results, oldest first.
The scoreline is summed from player goal columns, so an own goal moves the real score without appearing here.
POST/api/external/v1/teams
Required scopes: `teams:write`
Creates a team owned by the account the key belongs to, and returns it as `{ data: ... }` with `201`.
`name` is required, at most 80 characters. `is_private` defaults to `true`.
`members` is optional and may carry up to 12 entries of `{ player_id, role, slot }`, so a full roster lands in one call.
`role` is one of `starter`, `sub`, `coach`, `manager` and defaults to `starter`. `slot` is optional and assigned automatically when omitted.
The team is kept even if some members are rejected; the response then carries a `rejected` array naming each player and why.
PATCH/api/external/v1/teams/:teamId
Required scopes: `teams:write`
Renames a team or changes its privacy. Pass `name`, `is_private`, or both.
Only the account that owns the team may change it. Any other team answers `404 NOT_FOUND`.
Sending neither field returns `400 BAD_REQUEST` rather than a silent no-op.
POST/api/external/v1/teams/:teamId/members
Required scopes: `teams:write`
Adds one player to the roster and returns them with `201`. No invite is sent - the player is on the team immediately.
Role limits are enforced: 3 starters, 2 subs, 1 coach, 1 manager. Exceeding one returns `400 BAD_REQUEST` naming the role.
`slot` is optional. When omitted the next free slot for that role is used; a taken slot returns `400 BAD_REQUEST`.
DELETE/api/external/v1/teams/:teamId/members
Required scopes: `teams:write`
Removes one player from one role. `player_id` and `role` may be sent in the body or the query string.
The removed player is notified, the same as when it happens on the site.
A player who is not on the team in that role returns `404 NOT_FOUND`.
Response contract

Current behavior and defaults

Replay behavior
`title` currently mirrors `replay_name` because the service reads both from the same stored value.
`created` is the earliest `parse_replay` task creation time found for that replay, not the replay match timestamp.
`playlist` is sourced from `game_entries.gamemode` exactly as stored.
`team_size` is nullable. `duration` is returned as a number and falls back to `0` if missing.
`groups` is ordered by group id ascending.
`blue.players` and `orange.players` are split from stored `team` values `0` and `1`.
Within each team, players are ordered by `last_known_username NULLS LAST`, then `player_id` ascending.
Team behavior
A team is visible to a key when the key's owner owns that team or is on its roster. There is no public team read.
`teams:read` is a separate scope because a roster is player identity data, not match statistics. Keys issued before this endpoint existed do not carry it - create a new key to use these routes.
`is_private` is reported as stored. It does not currently affect who may read the team through this API; membership does.
`created` is the team's `created_at`.
`member_count` counts every role; `starter_count` counts only starters.
Nullability and defaults
Nullable fields report `null`, not `0`. A replay with no recorded `team_size` and a roster member with no assigned `slot` both report `null`.
A rate is `null` when there is nothing to divide by. A team that has played no matches has no win rate; `0` would mean it lost every one.
The top-level response wrapper is always `{ data: ... }` on success.
If replay dates or upload timestamps are missing, `date` and `created` are returned as `null`.
Per-player boost and field sections are always present. Missing database rows become numeric zeroes.
Team stats are always present and are derived from the player payloads returned in that same response.
Replay response

Current payload shape

{
  "data": {
    "id": 227,
    "link": "https://api.replaylabs.app/api/external/v1/replays/227",
    "web_url": "https://app.replaylabs.app/match/227",
    "created": "2026-03-31T12:14:22Z",
    "title": "scrim game 2",
    "replay_name": "scrim_game_2",
    "replay_id": "CE3BF1B611F125E4BC11DC9AAF19E2AD",
    "playlist": "Private",
    "team_size": 3,
    "duration": 323,
    "date": "2026-03-30T19:15:00Z",
    "map_name": "ChampionsField_Day",
    "groups": [
      {
        "id": 22,
        "name": "Scrim block A",
        "link": "https://api.replaylabs.app/api/external/v1/groups/22/replays",
        "web_url": "https://app.replaylabs.app/groups/22"
      }
    ],
    "blue": {
      "name": "Blue",
      "players": [
        {
          "name": "Player 1",
          "id": {
            "platform": "steam",
            "id": "76561190000000001"
          },
          "stats": {
            "core": {
              "score": 512,
              "goals": 2,
              "assists": 1,
              "saves": 3,
              "shots": 5
            },
            "boost": {
              "amount_collected": 2713,
              "amount_used": 2289,
              "collected_per_minute": 503.9,
              "used_per_minute": 425.4,
              "small_pad_count": 55,
              "big_pad_count": 29
            },
            "field": {
              "avg_speed": 1540,
              "total_distance": 578827,
              "avg_distance_to_ball": 2865,
              "avg_distance_to_teammates": 3567
            },
            "demo": {
              "inflicted": 1,
              "taken": 0
            },
            "bump": {
              "inflicted": 4,
              "taken": 2
            }
          }
        }
      ],
      "stats": {
        "core": {
          "score": 990,
          "goals": 1,
          "assists": 1,
          "saves": 5,
          "shots": 10
        }
      }
    },
    "orange": {
      "name": "Orange",
      "players": [],
      "stats": {}
    }
  }
}
Top-level replay fields
`id`
`link`
`web_url`
`created`
`title`
`replay_name`
`replay_id`
`playlist`
`team_size`
`duration`
`date`
`map_name`
`groups`
`blue`
`orange`
Per-player identity
`name`
`id.platform`
`id.id`
Per-player stats sections
`stats.core`
`stats.boost`
`stats.field`
`stats.demo`
`stats.bump`
Player and team stats

Field-level reference

`stats.core`
`score`
`goals`
`assists`
`saves`
`shots`
`stats.boost`
`amount_collected`
`amount_used`
`collected_per_minute`
`used_per_minute`
`wasted_supersonic`
`overfill`
`small_pad_count`
`big_pad_count`
`time_0_boost`
`time_100_boost`
`time_0_25`
`time_25_50`
`time_50_75`
`time_75_100`
`stats.field`
`avg_speed`
`total_distance`
`avg_distance_to_ball`
`avg_distance_to_teammates`
`time_supersonic`
`time_boost_speed`
`time_slow_speed`
`time_ground`
`time_low_air`
`time_high_air`
`time_defensive_half`
`time_offensive_half`
`time_defensive_third`
`time_neutral_third`
`time_offensive_third`
`time_behind_ball`
`time_in_front_of_ball`
`powerslide_time`
`powerslide_average`
`powerslide_count`
`stats.demo` and `stats.bump`
`inflicted`
`taken`
Notes:
`amount_collected` and `amount_used` are derived from stored per-minute values and replay duration.
Team `boost.collected_per_minute` and `boost.used_per_minute` are sums across returned players, not team-wide averages.
Team `field.avg_speed`, `field.avg_distance_to_ball`, `field.avg_distance_to_teammates`, and `field.powerslide_average` are averages across returned players.
Other team field and boost metrics are sums across returned players.
Group response

Summary view and ids view

`view=summary`
{
  "data": {
    "id": 22,
    "name": "Scrim block A",
    "link": "https://api.replaylabs.app/api/external/v1/groups/22/replays",
    "web_url": "https://app.replaylabs.app/groups/22",
    "replay_count": 3,
    "replays": [
      {
        "id": 220,
        "link": "https://api.replaylabs.app/api/external/v1/replays/220",
        "web_url": "https://app.replaylabs.app/match/220",
        "date": "2026-03-28T18:02:00Z",
        "title": "series game 1",
        "replay_name": "series_game_1",
        "replay_id": "8BE4CE464D5A8EB097E472834941DF59",
        "playlist": "Private",
        "map_name": "DFH Stadium"
      }
    ]
  }
}
`view=ids`
{
  "data": {
    "id": 22,
    "name": "Scrim block A",
    "link": "https://api.replaylabs.app/api/external/v1/groups/22/replays",
    "web_url": "https://app.replaylabs.app/groups/22",
    "replay_count": 3,
    "replay_ids": [220, 221, 219]
  }
}
Team response

Rosters, statistics and groups

A key sees a team when the account behind it owns that team or is on its roster. A team it cannot see answers 404 NOT_FOUND, the same as one that does not exist, so the API cannot be used to find out which team ids are taken.

`GET /teams`
{
  "data": {
    "teams": [
      {
        "id": 12,
        "name": "Bandits",
        "link": "https://api.replaylabs.app/api/external/v1/teams/12",
        "web_url": "https://replaylabs.app/teams/12",
        "is_private": true,
        "created": "2026-08-14T18:02:11.000Z",
        "member_count": 4,
        "starter_count": 3
      }
    ],
    "total": 1,
    "limit": 50,
    "offset": 0
  }
}
`GET /teams/:teamId`
{
  "data": {
    "id": 12,
    "name": "Bandits",
    "link": "https://api.replaylabs.app/api/external/v1/teams/12",
    "web_url": "https://replaylabs.app/teams/12",
    "groups_link": "https://api.replaylabs.app/api/external/v1/teams/12/groups",
    "is_private": true,
    "created": "2026-08-14T18:02:11.000Z",
    "members": [
      {
        "name": "Ana",
        "id": { "platform": "steam", "id": "76561198000000001" },
        "role": "starter",
        "slot": 1
      },
      {
        "name": "Bo",
        "id": { "platform": "epic", "id": "epic|bo" },
        "role": "coach",
        "slot": null
      }
    ]
  }
}
`GET /teams/:teamId/stats`
{
  "data": {
    "id": 12,
    "name": "Bandits",
    "link": "https://api.replaylabs.app/api/external/v1/teams/12",
    "web_url": "https://replaylabs.app/teams/12",
    "record": {
      "games": 10,
      "wins": 7,
      "losses": 3,
      "draws": 0,
      "win_rate": 0.7,
      "goals_for": 30,
      "goals_against": 18,
      "goal_difference": 12,
      "goals_for_per_match": 3,
      "goals_against_per_match": 1.8,
      "shot_accuracy": 0.35,
      "saves_per_match": 2.2,
      "assists_per_match": 1.5,
      "score_per_match": 400
    },
    "players": [
      {
        "name": "Ana",
        "id": { "platform": "steam", "id": "76561198000000001" },
        "matches": 10,
        "wins": 7,
        "win_rate": 0.7,
        "goals": 12,
        "assists": 4,
        "saves": 16,
        "shots": 30,
        "score": 3200,
        "goals_per_match": 1.2,
        "saves_per_match": 1.6,
        "score_per_match": 320
      }
    ],
    "form": [
      {
        "game_id": 227,
        "link": "https://api.replaylabs.app/api/external/v1/replays/227",
        "web_url": "https://replaylabs.app/match/227",
        "date": "2026-08-20T19:41:00.000Z",
        "playlist": "Ranked Standard",
        "goals_for": 4,
        "goals_against": 2,
        "result": "win"
      }
    ]
  }
}
`GET /teams/:teamId/groups`
{
  "data": {
    "id": 12,
    "name": "Bandits",
    "link": "https://api.replaylabs.app/api/external/v1/teams/12",
    "web_url": "https://replaylabs.app/teams/12",
    "group_count": 1,
    "groups": [
      {
        "id": 44,
        "name": "Week 1 vs Comets",
        "link": "https://api.replaylabs.app/api/external/v1/groups/44/replays",
        "web_url": "https://replaylabs.app/groups/44",
        "is_private": false,
        "created": "2026-08-20T20:05:00.000Z",
        "replay_count": 5
      }
    ]
  }
}
Team writes

Creating a team with its roster

One call, so a bot handed a full lineup by a signup command does not have to issue five requests and end up with half a team if one of them fails. Members that are rejected come back named, with the reason, and the team is kept.

Request
POST /api/external/v1/teams
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json

{
  "name": "Bandits",
  "is_private": true,
  "members": [
    { "player_id": "76561198000000001", "role": "starter" },
    { "player_id": "76561198000000002", "role": "starter" },
    { "player_id": "76561198000000003", "role": "starter" },
    { "player_id": "epic|bo", "role": "coach" }
  ]
}
Response
201 Created

{
  "data": {
    "id": 12,
    "name": "Bandits",
    "link": "https://api.replaylabs.app/api/external/v1/teams/12",
    "web_url": "https://replaylabs.app/teams/12",
    "groups_link": "https://api.replaylabs.app/api/external/v1/teams/12/groups",
    "is_private": true,
    "created": "2026-08-14T18:02:11.000Z",
    "members": [ "..." ],
    "added_count": 3,
    "rejected": [
      { "player_id": "epic|bo", "reason": "Role limit reached for coach" }
    ]
  }
}
Writes need `teams:write`, which is separate from `teams:read`. A key issued for reading a league table cannot rewrite rosters.
A key may only write to teams its own account owns. Being a manager of a team is not enough - managers act through the site, where each action is attributable to a person.
There is no invite step on this path. A player added through the API is on the roster straight away, so only add players who asked to be there.
Team ownership is not transferable through the API. A team a bot creates stays owned by the bot's account; give the captain the `manager` role so they can administer it on the site.
Errors and enforcement
Current error codes:
`BAD_REQUEST`
`UNAUTHORIZED`
`FORBIDDEN`
`NOT_FOUND`
`INTERNAL_ERROR`
`PLAN_INACTIVE`
`MONTHLY_QUOTA_EXCEEDED`
Monthly quota error body
{
  "error": {
    "code": "MONTHLY_QUOTA_EXCEEDED",
    "message": "Monthly API request quota reached for this plan",
    "limit": 25000,
    "used": 25000,
    "period_start": "2026-04-01"
  }
}
Per-minute rate-limit error body
{
  "error": "Too many API requests for this plan. Please slow down.",
  "limit": 60,
  "remaining": 0,
  "resetAt": "2026-04-25T12:35:00.000Z"
}
All keys are subject to a hard safety cap of 240 requests / minute by default.
Starter and Pro plans enforce their own per-minute caps and monthly quotas.
If a request exceeds a rate limiter, the body uses a plain top-level error string, not an { error: { ... } } object.
If a request exceeds the monthly quota, the body uses the structured { error: { ... } } shape shown above.
If the owning user no longer has an active API subscription, owned keys stop working.
Successful reads answer `200`. Creating a team or adding a member answers `201`; renaming or removing answers `200`.
A team your key may not see, and a team that does not exist, both answer `404 NOT_FOUND`. Writing to a team you do not own answers the same, so the API cannot be used to probe for team ids.
A key with no account behind it cannot write at all and answers `403 FORBIDDEN`.
Role limits, taken slots and unknown roles answer `400 BAD_REQUEST` with the reason in the message.
Developer portal workflow
1. Log in and open the developer portal.
2. Buy `Starter` or `Pro` through Stripe.
3. Create an API key.
4. Store the raw token immediately. It is shown once.
5. Use the token as a bearer key against the external API.
Keys are issued with the scopes that existed when they were created. A key made before teams were added does not carry `teams:read` or `teams:write` and answers `403 FORBIDDEN` on those routes - create a new key to use them.