Court server and cloud
The court server sits in the club: it takes the camera video, keeps the score, cuts highlights and computes stats. It sends only results to the cloud — score, aggregates, a compressed ball trajectory, clip metadata and the live score. Raw frames and full ball telemetry never leave the club.
Club token
Every method in this guide is called with the X-Club-Token: <club token> header. There is one token per club, issued by AISOLUS when courts are connected; the cloud keeps only a hash, so a lost token cannot be recovered — only re-issued (the old one stops working). A wrong token — 401 bad_club_token. Each method sees only the data of the token's club: another club's match, game or booking returns 404.
The court server checks the cloud link with the public GET https://your-server/api/v1/health.
Match lifecycle
- Before the game. The court terminal learns whose booking it is and the line-up:
GET /v1/cv/court-bookings. Player levels for the on-screen names —GET /v1/ingest/levels. For an americano or a tournament — the game or bracket match on this court:GET /v1/ingest/quick-games,GET /v1/ingest/bracket. - During the game. Live score for scoreboards —
POST /v1/ingest/liveon every change. Camera health —POST /v1/cv/healthevery 10 seconds; the response carries rally marks made by players in the app. A player close-up can be sent to the player right away —POST /v1/ingest/player-clip. - After the game. First the result —
POST /v1/ingest/match-result, which returnsmatchId. Then, with thatmatchId: statsPOST /v1/ingest/match-stats, movementPOST /v1/ingest/match-movement, ralliesPOST /v1/cv/rallyand the summaryPOST /v1/cv/match-summary, clipsPOST /v1/ingest/clips. Americano and bracket scores —POST /v1/ingest/quick-games/{game_id}/scoreandPOST /v1/ingest/bracket/{match_id}/result.
Match result
curl -X POST "https://your-server/api/v1/ingest/match-result" \
-H "X-Club-Token: <club token>" \
-H "Content-Type: application/json" \
-d '{
"court": "court4", "day": "2026-10-02", "time": "18:00",
"sets": "6:4,3:6,7:5",
"players": [
{"user_id": "3d6e9a10-5b2c-4f8e-a1d7-6c4b2e0f9a83", "team": 1},
{"phone": "+79000000001", "name": "Oleg", "team": 1},
{"user_id": "a52e7c14-90bd-4e3f-8c61-7d2f0b9e4a15", "team": 2},
{"pass_code": "<pass code>", "team": 2}
],
"points_auto": 54, "points_manual": 3
}'
# → {"matchId": "8c0f4a52-…", "winnerTeam": 1, "ratingDeltas": {"3d6e9a10-…": 12, …},
# "bookingId": "b71c2e94-…", "voided": false}sets— points “team1:team2” per set, comma-separated. Both teams are required, 2 to 4 players.- A player is given by
user_id(QR or binding),phone(entered at the terminal) orpass_code(a QR pass the court could not verify itself). An unknown phone creates a profile: the player sees the match after signing in with that number. An unverified pass that fails the check excludes the player from the match and the match from the rating. - Level sets (score not kept or not finished) — the match is accepted, video and stats stay available, but it does not count for rating:
voided: true, emptyratingDeltas. points_autoandpoints_manual— points scored by cameras and entered by a person. If the person entered more, the match is treated as manual and weighs half in the rating.booking_id— the booking the session was opened from. Omitted — the cloud finds the booking by court, day and time.
The match result is not idempotent: every successful call creates a new match. Retry only when there was no response, and keep the matchId of the first successful response.
Stats, movement, rallies
These methods refer to match_id from the result response and are safe to retry: a repeat updates the record rather than creating a second one.
# per-player stats
curl -X POST "https://your-server/api/v1/ingest/match-stats" \
-H "X-Club-Token: <club token>" -H "Content-Type: application/json" \
-d '{"match_id": "8c0f4a52-2d1e-4b7a-9f63-1a5e7d9b3c20", "avg_rally_shots": 6.8,
"players": [{"user_id": "3d6e9a10-5b2c-4f8e-a1d7-6c4b2e0f9a83", "max_ball_kmh": 112, "run_m": 2350,
"shots": [{"class": "smash", "count": 7, "avgKmh": 104}]}]}'
# one rally (a repeat of the same rally_no overwrites it)
curl -X POST "https://your-server/api/v1/cv/rally" \
-H "X-Club-Token: <club token>" -H "Content-Type: application/json" \
-d '{"match_id": "8c0f4a52-2d1e-4b7a-9f63-1a5e7d9b3c20", "court": "court4", "rally_no": 1,
"t_start": 1759418000.0, "t_end": 1759418012.4,
"path": [[312, 401], [355, 380]], "path_fw": 1024, "path_fh": 768,
"shots": [{"t": 1759418001.2, "v_kmh": 74.0, "x": 312, "y": 401}],
"max_kmh": 96.0, "avg_kmh": 58.2, "winner_team": "A"}'- Stats (
POST /v1/ingest/match-stats): the player must be in the match line-up. Players with a non-zero ball speed get a push that the analysis is ready. - Movement (
POST /v1/ingest/match-movement): distance, work rate, top speed, sprints, zone shares, coverage and a presence grid. An empty field does not erase a known value. A player recognized by phone after the score is added to the line-up without a rating change. - Rally (
POST /v1/cv/rally): the trajectory is thinned beforehand (up to 400 points) and given in frame pixelspath_fw × path_fh;winner_teamisAorB(1and2are accepted too). - Summary (
POST /v1/cv/match-summary): heat map[x, y, n]and ball routes[x1, y1, x2, y2, n]in frame pixels.
Clips and file upload
A clip is sent in two messages with the same clip_id: right after the match — metadata (the video is still on its way), then, once the file is in storage — with object_key. The second message updates the clip by clip_id; empty fields do not erase known ones.
# 1. metadata
curl -X POST "https://your-server/api/v1/ingest/clips" \
-H "X-Club-Token: <club token>" -H "Content-Type: application/json" \
-d '{"match_id": "8c0f4a52-2d1e-4b7a-9f63-1a5e7d9b3c20",
"clips": [{"clip_id": "rally-012-cam1", "kind": "highlight", "title": "Smash at the net",
"duration_s": 12.5, "group": "rally-012", "camera": "Camera 1"}]}'
# 2. the file (when the cloud stores video itself)
curl -X PUT "https://your-server/api/v1/ingest/clips/file?key=c1/8c0f4a52-2d1e-4b7a-9f63-1a5e7d9b3c20/rally-012-cam1.mp4" \
-H "X-Club-Token: <club token>" -H "Content-Type: video/mp4" \
--data-binary @rally-012-cam1.mp4
# → {"ok": true, "key": "c1/8c0f…/rally-012-cam1.mp4", "bytes": 18734211}
# 3. the object key — with the same clip_id
curl -X POST "https://your-server/api/v1/ingest/clips" \
-H "X-Club-Token: <club token>" -H "Content-Type: application/json" \
-d '{"match_id": "8c0f4a52-2d1e-4b7a-9f63-1a5e7d9b3c20",
"clips": [{"clip_id": "rally-012-cam1",
"object_key": "c1/8c0f4a52-2d1e-4b7a-9f63-1a5e7d9b3c20/rally-012-cam1.mp4"}]}'- Moments and angles. One moment shot by several cameras is a main clip (
kind: highlight) plus angles (kind: highlight_angle) sharing onegroup;camerais the camera button label in the player. A 9:16 vertical copy for stories iskind: highlight_vertical. The app shows them as one moment in the match card. - File.
PUT /v1/ingest/clips/fileworks only when the cloud stores video itself; otherwise the answer is400 local_storage_disabled: video lives in object storage, the court server uploads it there and sends onlyobject_keyto the cloud. The key must start with the token's club id (403 bad_key); the size is limited (413 clip_too_large). - Links for players are issued by the cloud: short-lived signed links by
object_keyin the match card. - Highlight during the match —
POST /v1/ingest/player-clip: the file is already in storage; the player (byuser_idor phone) gets a push with a one-week link.
Live score
curl -X POST "https://your-server/api/v1/ingest/live" \
-H "X-Club-Token: <club token>" -H "Content-Type: application/json" \
-d '{"court": "court4", "score": "6:4 3:2 · 30-15", "teams": "Ivan/Oleg — Anna/Maria",
"set_no": 2, "on_air": true}'
# → {"ok": true, "ttl_s": 180}The score lives 180 seconds: if the court goes silent, it disappears from scoreboards by itself. on_air says whether the court video stream is on. The live score is never queued — a stale score is not worth sending. See Live scores and replays for reading it.
Camera health and player marks
curl -X POST "https://your-server/api/v1/cv/health" \
-H "X-Club-Token: <club token>" -H "Content-Type: application/json" \
-d '{"court": "court4", "ts": 1759418400.0,
"cameras": [{"tag": "<camera role tag>", "online": true, "fps": 25.0, "last_frame_age_s": 0.1}],
"app": {"version": "1.2137", "uptime_s": 86400},
"host": {"disk_free_gb": 412.5, "disk_used_pct": 58.0, "gpu_temp_c": 61.0}}'
# → {"ok": true, "cameras": 1, "unknown_tags": [],
# "marks": [{"id": "0d4e8f21-…", "ts": 1759418400.5, "source": "app"}]}- Only the latest record per court is kept; read it with
GET /v1/cv/court/{court}/health— it includes the measurement ageageS. tagis the camera role from the agreed list (provided on connection). An unknown tag is not dropped but returned inunknown_tags— check the camera configuration.marksare “clip this” marks players made in the app (POST /v1/matches/mark) during the last 30 minutes. Each is delivered once — store it.
Court bookings
curl "https://your-server/api/v1/cv/court-bookings?court=court4&court=court5" \
-H "X-Club-Token: <club token>"Per court — the current booking (current) and those within three hours (upcoming), with the line-up: user_id, name, short name, who booked. No phone numbers. The court name is matched against the club courts in the cloud; an unknown one comes back with known: false instead of disappearing. Cancelled bookings are not returned.
Americano and tournament bracket
# americano: current-round game on the court ({"game": null} — none)
curl "https://your-server/api/v1/ingest/quick-games?court=court4" -H "X-Club-Token: <club token>"
curl -X POST "https://your-server/api/v1/ingest/quick-games/17/score" \
-H "X-Club-Token: <club token>" -H "Content-Type: application/json" \
-d '{"score1": 14, "score2": 10}'
# tournament: next unplayed bracket match on the court ({"match": null} — none)
curl "https://your-server/api/v1/ingest/bracket?court=court4" -H "X-Club-Token: <club token>"
curl -X POST "https://your-server/api/v1/ingest/bracket/8c0f4a52-2d1e-4b7a-9f63-1a5e7d9b3c20/result" \
-H "X-Club-Token: <club token>" -H "Content-Type: application/json" \
-d '{"score": "6:4,3:6,7:5", "winner": 1}'
# repeat after a connection drop → {"ok": true, "already": true}A score for an americano that is already finished — 409 finished. Game points are 0 to 64.
Levels and player pass
# levels for the terminal: up to 16 ids and up to 16 phones, comma-separated
curl "https://your-server/api/v1/ingest/levels?ids=3d6e9a10-5b2c-4f8e-a1d7-6c4b2e0f9a83&phones=%2B79000000001" \
-H "X-Club-Token: <club token>"
# → {"levels": {"3d6e9a10-…": {"value": 3.25, "step": "<step name>", "games": 14},
# "+79000000001": {…}}}
# a pass the terminal could not verify itself
curl -X POST "https://your-server/api/v1/ingest/player-pass" \
-H "X-Club-Token: <club token>" -H "Content-Type: application/json" \
-d '{"code": "<pass code>"}'
# → {"userId": "3d6e9a10-…", "name": "Ivan P."}Levels are returned only for rated players. Pass: 404 pass_not_found — code not recognized, 410 pass_expired — expired (the QR in the app refreshes itself).
Court license
curl -X POST "https://your-server/api/v1/license/court" \
-H "X-Club-Token: <club token>" -H "Content-Type: application/json" \
-d '{"fingerprint": "<machine sha256, 64 hex characters>", "version": "1.2137"}'
# → {"alg": "Ed25519", "payload": "<base64>", "signature": "<base64>",
# "license": {"v": 2, "club_id": "c1", "expires_at": …, "modules": {…}, "pass_keys": {…}, …}}- The court server gets a signed 30-day license bound to the machine fingerprint and verifies it with an embedded public key. Exactly the JSON bytes in
payload(base64) are signed — verify against them, not against re-serialized JSON. - The license carries the club modules per court and day keys for offline QR pass checks.
403 fingerprint_revoked— the club admin revoked this machine.503 license_key_not_configured— the signing key is not set in the cloud; this is not a license refusal.
Queue and retries
Club internet is unreliable, so the AISOLUS court server client works like this: a request that failed is put into an on-disk queue and resent later in order. Until matchId arrives, everything referring to it waits in the queue. Live score and health are never queued — they go stale immediately.
| Method | Safe to retry? | Why |
|---|---|---|
POST /v1/ingest/match-result | No | Every call creates a match |
POST /v1/ingest/match-stats | Yes | Values are overwritten |
POST /v1/ingest/match-movement | Yes | Update per player |
POST /v1/ingest/clips | Yes, with clip_id set | Update by clip_id; without it a new clip each time |
POST /v1/cv/rally | Yes | Overwrite by rally_no |
POST /v1/cv/match-summary | Yes | One summary per match |
POST /v1/ingest/bracket/{match_id}/result | Yes | A repeat answers already: true |
POST /v1/ingest/quick-games/{game_id}/score | Yes | Overwrites the score, rolling back the previous points |
POST /v1/ingest/player-clip | A repeat sends the push again | — |