Create a fencing bout
const url = 'http://localhost:8000/api/bouts/';const options = { method: 'POST', headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'}, body: '{"name":"Pool vs Smith","weapon":"foil","handedness":"right","ours_side":"left"}'};
try { const response = await fetch(url, options); const data = await response.json(); console.log(data);} catch (error) { console.error(error);}curl --request POST \ --url http://localhost:8000/api/bouts/ \ --header 'Authorization: Bearer <token>' \ --header 'Content-Type: application/json' \ --data '{ "name": "Pool vs Smith", "weapon": "foil", "handedness": "right", "ours_side": "left" }'Starts a bout: the fencer, the weapon, our fencer’s weapon hand and the side of the strip they start on. Costs nothing — quota is spent once, on the first Analyse.
Authorizations
Section titled “Authorizations”Request Body
Section titled “Request Body”object
The fencer being analysed. Optional, as for a session.
Examples
Start a bout
{ "name": "Pool vs Smith", "weapon": "foil", "handedness": "right", "ours_side": "left"}object
The fencer being analysed. Optional, as for a session.
object
The fencer being analysed. Optional, as for a session.
Responses
Section titled “Responses”object
object
object
Send it back with a touch edit; a list that has moved since answers 409.
object
The touch’s pair metrics and our fencer’s footwork and hand, number or category text by key (bout_distance_at_attack_m, bout_first_to_close, bout_strip_position_m, bout_first_to_extend, bout_extension_lead_ms, step_count, advance_count, retreat_count, step_rate, weapon_hand_peak_speed).
object
object
Touches whose analysis came back with metrics.
object
Null until a touch reads it.
object
Null until a touch reads it.
object
Advances over the analysed touches, before each attack; null with none read.
Retreats over the analysed touches; null with none read.
Steps per minute, mean over touches.
object
Foil when a white lamp was read on any touch (only foil has an off-target lamp); null when the lamps cannot tell. Never overrides the declared weapon.
Sessions of quota the bout’s one Analyse debits.
The longest period video the lamp read takes, seconds; the uploader checks it before uploading.
Examples
A bout
{ "id": "6d0f3c2a-1b4e-4c8a-9f21-7e5d3a9b0c11", "client": null, "name": "Pool vs Smith", "weapon": "foil", "handedness": "right", "opponent_handedness": "", "ours_side": "left", "periods": [ { "id": "a3e1c9f0-52b7-4d16-8c0e-91f4b2d7e6a5", "index": 0, "video_url": "https://media.example.com/teams/9b1c7e2a/sessions/2026/10/0c5b1d7e-6a2f-4e3b-8d19-5f0a7c2e4b61.mp4", "duration_s": 312.4, "calibration": { "lamps": [ [ 0.44, 0.49, 0.47, 0.52 ], [ 0.51, 0.49, 0.54, 0.52 ] ] }, "detect_status": "done", "detect_error": "" } ], "touches": [ { "id": "3f2a9c1d0b7e", "period": 0, "onset_s": 13.8, "lamps": [ "green", "red" ], "window_s": [ 7.8, 14.8 ], "double": true, "source": "lamps", "include": true, "ours_declared": null, "ours": "left", "check_sides": false, "session_id": null } ], "touches_version": "5be1c0a92f3d7e64", "touch_sessions": [], "quota_units": 3, "max_period_s": 480, "aggregates": { "touches": 1, "analysed": 0, "doubles": 1, "opponent_handedness": null, "lamps": { "red": 1, "green": 1, "white": 0 }, "distance_at_attack_mean_m": null, "first_to_close": null, "first_to_extend": null, "extension_lead_mean_ms": null, "steps_per_touch_mean": null, "advances": null, "retreats": null, "step_rate_mean": null, "weapon_hand_peak_speed_mean_m_s": null, "weapon_hand_peak_speed_max_m_s": null, "strip_positions": [], "weapon_suggestion": null, "weapon_mismatch": false }, "analysed_at": null, "created_at": "2026-10-26T10:00:00Z"}object
Human-readable message, or a stable machine code for the cases a client branches on. The standard envelope for 400 (validation — a field-keyed object may appear instead), 401 (missing / invalid credentials), 403 (authenticated but not permitted), and 404 (absent — cross-team records are collapsed to 404 so the API never leaks the existence of another team’s data).
Example generated
{ "detail": "example"}object
Human-readable message, or a stable machine code for the cases a client branches on. The standard envelope for 400 (validation — a field-keyed object may appear instead), 401 (missing / invalid credentials), 403 (authenticated but not permitted), and 404 (absent — cross-team records are collapsed to 404 so the API never leaks the existence of another team’s data).
Example generated
{ "detail": "example"}object
One of eight canonical strings. The front-end paywall switches copy + CTA on this value (exact string equality — do not localise). quota_exhausted / seat_limit_reached / client_limit_reached / feature_not_in_plan lead to an upgrade CTA; subscription_suspended leads to a Billing Portal CTA so the buyer can update their payment method; exercise_not_in_plan leads to a contact-us CTA — the movement list is sales-managed, no tier adds to it (#2030). trial_expired / access_ended mean the team is read-only (#2901): everything opens, nothing new is created; they lead to a subscribe CTA and always carry reset_at: null.
quota_exhausted- Session quota for the period reached 0subscription_suspended- Stripe dunning in flight (past_due / unpaid)seat_limit_reached- Adding another team member would exceed plan.max_seatsclient_limit_reached- Adding another Client would exceed plan.max_client_profilesfeature_not_in_plan- The tier does not include this whole feature surfaceexercise_not_in_plan- The (activity, exercise) is not in the team’s exercise grantstrial_expired- The team is read-only: its self-serve trial endedaccess_ended- The team is read-only: its paid plan or granted access ended
ISO-8601 UTC timestamp the team’s current period ends. Null when the team has no live subscription (legacy un-migrated row). On Free + paid this is the renewal moment; quota refreshes to exactly plan.session_quota (NOT additive — leftover sessions don’t roll over).
The active plan’s role string (free, starter, professional, organization, strategic). Same value as current_role — kept as two separate fields for forward-compatibility with a future named-plan split where current_plan could carry an SKU and current_role the tier label.
The active plan’s role string. See current_plan.
Which feature surface is locked. Present ONLY on feature_not_in_plan, so one modal can name what’s gated without a detail string per feature (#773). The only value today is agents (aikynetix/agents/services.py::AGENTS_FEATURE).
Example
{ "detail": "quota_exhausted"}object
Human-readable message, or a stable machine code for the cases a client branches on. The standard envelope for 400 (validation — a field-keyed object may appear instead), 401 (missing / invalid credentials), 403 (authenticated but not permitted), and 404 (absent — cross-team records are collapsed to 404 so the API never leaks the existence of another team’s data).
Example generated
{ "detail": "example"}