REST API · v1

Stats Lab API

Read your stat sheets, or push numbers into them from other tools: GPS vests, fitness trackers, forms, spreadsheets or your own scripts. Use webhooks to hear about changes the moment they happen. Everything is JSON over HTTPS.

On this page

Quick start

  1. Sign in and open API keys. You need at least one stat sheet first.
  2. Name the key, pick the sheets it may use, tick Can write numbers if it should send data, and click Create key.
  3. Copy the key (it starts with slk_). It's shown once; store it in your app's secret settings.
  4. Make your first call:
curl -H "Authorization: Bearer $STATS_LAB_KEY" https://stats.tournamentmaster.space/api/v1/sheets

Base URL: https://stats.tournamentmaster.space/api/v1

Authentication

Send your key in the Authorization header on every request:

Authorization: Bearer slk_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
  • A key only works on the sheets you picked when you made it.
  • It acts as you, with your access today: if you're removed from a shared sheet, or your editor access becomes view-only, the key loses that access too. Read-only keys can never write, and a write key can't be made for a sheet you can only view.
  • Revoke a key on the API keys page and it stops working immediately. You can have up to 10 live keys.
  • The API is for servers and scripts. It sends no CORS headers, so it can't be called from a web page in someone's browser, and that's deliberate: a key in front-end code would be visible to everyone.

Limits

Requests120 per minute per key
Row imports (POST /rows)12 per minute per key (they also count toward the 120)
Rows or entries per request5,000
Request body4 MB, Content-Type: application/json
Players / sessions / stats per sheetSet by the sheet owner’s plan (free: 300 players), 1,000 sessions, 60 stats

Over the limit you get 429. Wait the number of seconds in the error message (or the Retry-After header when present) and try again. For bulk loads, send up to 5,000 rows per request rather than one request per row.

Errors

Errors return a JSON body with a readable message:

{ "error": "This key is read-only." }

Validation errors (422) also include details, listing each problem with its field path.

401Missing, malformed, invalid or revoked key
403Read-only key used to write, no edit access to the sheet, the account is disabled, or a plan limit was hit
404The sheet doesn’t exist, isn’t on this key, or you no longer have access
409A sheet limit was reached (players, sessions, stats)
413 / 415Body larger than 4 MB / not JSON
422Invalid body: unknown player or session id, bad date, duplicate row…
429Too many requests
500 / 503Our side. Retry with a back-off; if it keeps happening, contact support

How sheets are shaped

  • A sheet has metrics (the stat columns), players, sessions (matches, trainings, trials) and entries: one per player per session, holding that player's numbers.
  • Each metric has a key such as goals or distance_km. You read and write values by key. Keys are shown under each stat in Setup, and in GET /sheets/:id.
  • Metrics are input (numbers you send) or formula (calculated, like conversion rate). Only input values are stored; values you send for formula or unknown keys are ignored.
  • Values are plain numbers. Times are in seconds and percentages as you'd type them (75 for 75%).
  • Ids are UUIDs. Dates are YYYY-MM-DD.

List the sheets your key covers

GET/api/v1/sheetsany key

The sheets on this key that you can still open, with their metrics.

Response 200

{
  "data": [
    {
      "id": "1b2c3d4e-…",
      "name": "U17 — 2026 season",
      "sport": "football",
      "role": "owner",
      "updated_at": "2026-11-10T18:22:04Z",
      "metrics": [
        { "key": "minutes", "label": "Minutes", "kind": "input", "format": "int", "aggregate": "sum", "higher_is_better": true },
        { "key": "goals", "label": "Goals", "kind": "input", "format": "int", "aggregate": "sum", "higher_is_better": true },
        { "key": "conversion", "label": "Conversion rate", "kind": "formula", "formula": "goals / shots * 100", "format": "percent" }
      ]
    }
  ],
  "key": { "name": "GPS import", "can_write": true }
}

Read a whole sheet

GET/api/v1/sheets/:idany key

Everything needed to rebuild the sheet elsewhere: metrics, players, sessions and every entry. Attendance, scouting reports and sharing settings aren't included.

Response 200

{
  "data": {
    "sheet": { "id": "1b2c…", "name": "U17 — 2026 season", "sport": "football", "metrics": [ … ], "settings": { "minutes_key": "minutes", "per_minutes": 90 } },
    "players": [
      { "id": "9f9f…", "name": "Thandi Mokoena", "position": "ST", "shirt_number": 9, "group_name": "First team", "archived": false }
    ],
    "sessions": [
      { "id": "3a3a…", "label": "vs City FC", "played_on": "2026-03-05", "kind": "match", "opponent": "City FC", "result": "2–1" }
    ],
    "entries": [
      { "session_id": "3a3a…", "player_id": "9f9f…", "values": { "minutes": 90, "goals": 1, "shots": 4 } }
    ]
  }
}

Write by names (easiest)

POST/api/v1/sheets/:id/rowswrite key

Send rows that name the player and the session. Players and sessions are matched by name (case-insensitive) and created if they don't exist. Values merge into what's already there: only the stats you send change.

Request

{
  "rows": [
    { "player": "Thandi Mokoena", "session": "Training 14 May", "played_on": "2026-05-14", "values": { "distance_km": 7.4, "sprints": 18 } },
    { "player": "Leo Dlamini",    "session": "Training 14 May", "played_on": "2026-05-14", "values": { "distance_km": 5.9, "sprints": 9 } }
  ],
  "new_metrics": [
    { "key": "sprints", "label": "Sprints", "kind": "input", "format": "int", "aggregate": "sum", "higher_is_better": true }
  ]
}
  • Optional per row: played_on (used when the session is new), position and group_name (used when the player is new).
  • new_metrics is optional: add stat columns that don't exist yet. Values for keys that aren't on the sheet (and aren't in new_metrics) are ignored.
  • New sessions are created as matches; change the type in the app if needed.

Response 200

{ "data": { "players_added": 0, "sessions_added": 1, "metrics_added": 1, "entries": 2 } }

Write by ids (precise)

PUT/api/v1/sheets/:id/entrieswrite key

Use ids from GET /sheets/:id when names might be ambiguous. Each item changes only the cells it lists: a number sets the cell, null clears it, and keys you leave out are untouched.

Request

{
  "entries": [
    { "session_id": "3a3a…", "player_id": "9f9f…", "values": { "goals": 2, "yellow_cards": null } }
  ]
}

Response 200

{ "saved": 1 }

Every player and session must belong to the sheet, and each player/session pair may appear once per request (otherwise 422).

Code examples

JavaScript (Node 18+)

const BASE = 'https://stats.tournamentmaster.space/api/v1';
const headers = { Authorization: `Bearer ${process.env.STATS_LAB_KEY}`, 'Content-Type': 'application/json' };

// 1. Find the sheet
const { data: sheets } = await (await fetch(`${BASE}/sheets`, { headers })).json();
const sheet = sheets.find((s) => s.name === 'U17 — 2026 season');

// 2. Send today's GPS numbers
const res = await fetch(`${BASE}/sheets/${sheet.id}/rows`, {
  method: 'POST',
  headers,
  body: JSON.stringify({
    rows: [{ player: 'Thandi Mokoena', session: 'Training 14 May', played_on: '2026-05-14', values: { distance_km: 7.4 } }],
  }),
});
if (!res.ok) throw new Error((await res.json()).error);
console.log(await res.json());

Python (requests)

import os, requests

BASE = "https://stats.tournamentmaster.space/api/v1"
headers = {"Authorization": f"Bearer {os.environ['STATS_LAB_KEY']}"}

sheets = requests.get(f"{BASE}/sheets", headers=headers, timeout=30).json()["data"]
sheet = next(s for s in sheets if s["name"] == "U17 — 2026 season")

r = requests.post(
    f"{BASE}/sheets/{sheet['id']}/rows",
    headers=headers,
    json={"rows": [{"player": "Thandi Mokoena", "session": "Training 14 May",
                    "played_on": "2026-05-14", "values": {"distance_km": 7.4}}]},
    timeout=30,
)
r.raise_for_status()
print(r.json())

Export a sheet to CSV (Python)

import csv, os, requests

BASE = "https://stats.tournamentmaster.space/api/v1"
h = {"Authorization": f"Bearer {os.environ['STATS_LAB_KEY']}"}
d = requests.get(f"{BASE}/sheets/SHEET_ID", headers=h, timeout=60).json()["data"]
players = {p["id"]: p["name"] for p in d["players"]}
sessions = {s["id"]: s["label"] for s in d["sessions"]}
keys = [m["key"] for m in d["sheet"]["metrics"] if m["kind"] == "input"]

with open("stats.csv", "w", newline="") as f:
    w = csv.writer(f)
    w.writerow(["player", "session", *keys])
    for e in d["entries"]:
        w.writerow([players[e["player_id"]], sessions[e["session_id"]], *[e["values"].get(k, "") for k in keys]])

Webhooks

A webhook makes Stats Lab call your server when numbers on a sheet change: from the grid, live logging, imports, tournament sync, player self-entry or this API. The sheet owner adds them in the sheet's Share → API & webhooks (up to 5 per sheet, https only), and Test sends a ping.

What you receive

POST https://your-server.example/hooks/stats-lab
Content-Type: application/json
User-Agent: StatsLab-Webhooks/1
X-StatsLab-Event: entries.updated
X-StatsLab-Signature: t=1763000000,v1=5f1c…(hex)

{
  "id": "6c1e…",
  "event": "entries.updated",
  "sheet_id": "1b2c…",
  "created_at": "2026-11-10T18:22:04Z",
  "data": {
    "source": "app",            // app | import | tournament_sync | self_entry | api
    "changes": [ { "session_id": "3a3a…", "player_id": "9f9f…", "values": { "goals": 2 } } ],
    "truncated": false          // true when more than 500 rows changed at once
  }
}
  • changes lists the cells that changed: a number is the new value and null means cleared. If truncated is true, re-read the sheet with GET /sheets/:id.
  • Reply with any 2xx within 5 seconds. Do slow work afterwards (queue it). Redirects aren't followed.
  • There are no automatic retries. After 20 failures in a row the webhook switches itself off; turn it back on once your endpoint is fixed.
  • Deliveries can arrive out of order or, rarely, twice. Use id to skip duplicates.

Always check the signature

The signature is an HMAC-SHA256 of <t>.<raw body> using the secret shown when you added the webhook. Compare in constant time, and reject requests whose t is more than 5 minutes old (stops replays).

Node (Express)

import crypto from 'node:crypto';
import express from 'express';

const app = express();
app.post('/hooks/stats-lab', express.raw({ type: 'application/json' }), (req, res) => {
  const sig = req.get('X-StatsLab-Signature') ?? '';
  const t = Number(/t=(\d+)/.exec(sig)?.[1]);
  const v1 = /v1=([0-9a-f]{64})/.exec(sig)?.[1] ?? '';
  const expected = crypto.createHmac('sha256', process.env.STATS_LAB_WEBHOOK_SECRET)
    .update(`${t}.${req.body}`).digest('hex');
  const fresh = Math.abs(Date.now() / 1000 - t) < 300;
  if (!fresh || v1.length !== 64 || !crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected))) {
    return res.sendStatus(401);
  }
  const event = JSON.parse(req.body);
  res.sendStatus(204);          // answer fast…
  queue.push(event);            // …then do the work
});

Python (Flask)

import hashlib, hmac, os, re, time
from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["STATS_LAB_WEBHOOK_SECRET"].encode()

@app.post("/hooks/stats-lab")
def stats_lab():
    sig = request.headers.get("X-StatsLab-Signature", "")
    t = re.search(r"t=(\d+)", sig)
    v1 = re.search(r"v1=([0-9a-f]{64})", sig)
    if not t or not v1 or abs(time.time() - int(t.group(1))) > 300:
        abort(401)
    expected = hmac.new(SECRET, f"{t.group(1)}.".encode() + request.get_data(), hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, v1.group(1)):
        abort(401)
    event = request.get_json()
    # … queue the work, reply fast
    return "", 204

Keeping it safe

  • Keep keys and webhook secrets in environment variables or a secrets manager. Never put them in front-end code, mobile apps, spreadsheets you share, or Git.
  • Make one key per integration (e.g. “GPS import”, “Club website”), so you can revoke one without breaking the others.
  • Use read-only keys unless the integration really needs to write, and give each key only the sheets it needs.
  • If a key or secret might have leaked, revoke the key (or delete and re-add the webhook) straight away, then make a new one.
  • Player data is personal data, often about children. Only send it to systems you're allowed to share it with.

Questions about the API? See the user guide or contact Tournament Master support.