Partner API
Read-only HTTPS access to your organization's MakoSwim data: workouts as written and as swum, races with splits and stroke rates, attendance, test sets and wellness. This page documents v1: authentication, conventions, the twelve endpoints, and the OpenAPI contract.
Overview
The API is organized around twelve list resources under /v1. Every endpoint returns rows in the same JSON envelope, authenticates with the same bearer key, and uses standard HTTP response codes. GET is the only method. No SDK is required.
https://europe-west3-makoswim-prod-9ab4d.cloudfunctions.net/dataApi/v1
Workouts are exposed in three layers: the text exactly as the coach wrote it (workout-content), the parsed structure of every line (structured=true), and the per-athlete volumes the training engine computed when the session was saved (workouts, workout-segments, workout-set-volumes). Attribution is joint: a segment row carries zone, stroke, style and equipment together, so "fly kick at zone 2 in July, per athlete" is one filter and one group-by on workout-segments.
Getting access
The Data API ships with the MakoSwim federation tier. Your federation authorizes access in writing, and we issue an organization-scoped key with exactly the scopes you request. Keys are shown once at issuance and stored only as a SHA-256 hash on our side, so treat the key like a password and put it straight into your secrets manager.
A key carries only the scopes that were requested:
| Scope | Unlocks |
|---|---|
| roster | Athletes and teams |
| workouts | Workout aggregates, segments, content, set volumes, terminology |
| attendance | Session attendance |
| performances | Race results with splits, stroke counts and rates |
| metrics | Test-set definitions and results |
| wellness | Daily wellness surveys. Health data: never granted by default, only on explicit written instruction from your federation |
To request access or add a scope, write to eric@makoapp.io from your federation account. To roll a key, email us: we issue the replacement first, and revoke the old one after you switch.
Authentication
Send the key as a bearer token on every request. Keys look like mk_live_ followed by 43 to 60 characters, and identify your organization on our side: the API never takes an org identifier from the caller, so a key can only ever read the organization it was issued for.
A missing, malformed, unknown or revoked key returns 401 unauthorized. A valid key whose organization is not on the federation tier returns 403 forbidden_federation. A valid key used outside its scopes returns 403 forbidden_scope and names the missing scope.
curl -H "Authorization: Bearer mk_live_YOUR_KEY" \ "$BASE/v1/athletes?limit=50"
{ "code": "unauthorized", "title": "Unauthorized", "detail": "Provide an API key: Authorization: Bearer mk_live_…", "status": 401 }
Envelope & pagination
Every list response is the same shape: data holds the rows, has_more tells you whether to keep going, and next_cursor is an opaque token you pass back as cursor. Walk until has_more is false.
Cursors are opaque on purpose. Do not parse or construct them; a tampered cursor returns 400 invalid_cursor. Page size is limit, from 1 to 500, defaulting to 100. Every endpoint accepts limit and cursor; the endpoint sections list only resource-specific parameters.
{ "data": [ …rows… ], "has_more": true, "next_cursor": "eyJkIjoiMjAyNi0wOC0xMiIsImlkIjoi…" }
curl -H "Authorization: Bearer $KEY" \ "$BASE/v1/workouts?cursor=eyJkIjoiMjAyNi0wOC0xMiIsImlkIjoi…"
Filters & dates
Where a resource has dates, from and to are inclusive YYYY-MM-DD bounds. Most resources also filter by teamId and athleteId. Training dates are calendar dates, deliberately without timezone; timestamps, where present, are RFC 3339 UTC.
Responses are gzip-compressed when the client sends Accept-Encoding: gzip, and carry Cache-Control: no-store because rows contain personal data.
Rate limits
Per key: a burst of 20 requests, refilling at 10 per second, inside a cap of 3,000 per hour. Every response carries the headers below; a 429 adds Retry-After. Back off with jitter. At 500 rows per page, the hourly cap covers 1.5 million rows.
X-RateLimit-Limit: 3000 X-RateLimit-Remaining: 2984 X-RateLimit-Reset: 2711 Retry-After: 1 # on 429 only
Errors
Errors are RFC 9457 application/problem+json, with a stable machine-readable code. Branch on the code; log the detail for debugging.
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_param | A query parameter failed validation. The response names it. |
| 400 | invalid_cursor | The cursor is not one we issued. Restart the walk. |
| 401 | unauthorized | Missing, malformed, unknown or revoked key. |
| 403 | forbidden_federation | The organization is not on the federation tier. |
| 403 | forbidden_scope | The key does not carry the required scope. |
| 404 | not_found | Unknown endpoint. The response lists the known ones. |
| 405 | method_not_allowed | The API is read-only. Use GET. |
| 429 | rate_limited | Rate limit exceeded. Honor Retry-After before retrying. |
| 500 | internal | Unexpected server error, logged on our side with the request. |
GET/v1/athletesscope roster
One row per athlete: name, birth date, main stroke, and team and training-group membership. Other resources reference people by athleteId and join here. Contact fields are never exposed.
| teamId | Limit to one team |
curl -H "Authorization: Bearer $KEY" \ "$BASE/v1/athletes?teamId=t8xK2p&limit=100"
{ "id": "aQ71Lm", "firstName": "Lena", "lastName": "Fischer", "gender": "F", "dateOfBirth": "2008-03-21", "mainStroke": "fly", "teamId": "t8xK2p", "trainingGroupId": "g_sprint", "status": "active", "hasAccount": true }
GET/v1/teamsscope roster
Teams with their site and training groups. In a federation setup a site is typically a training base and teams are the squads inside it. Training-group ids from here appear on athlete rows and workout rosters.
{ "id": "t8xK2p", "name": "National Training Base Berlin", "siteId": "s_berlin", "siteName": "Berlin", "trainingGroups": [ { "id": "g_sprint", "name": "Sprint", "color": "#596AF7" } ] }
GET/v1/workoutsscope workouts
One row per athlete per workout: total volume, training load, and marginal breakdowns by energy zone, stroke, training style and equipment, as computed by the engine when the session was saved.
| from, to | Inclusive date bounds |
| teamId | One team |
| athleteId | One athlete |
{ "workoutId": "w_2026-08-12_am", "date": "2026-08-12", "session": "AM", "poolType": "LCM", "athleteId": "aQ71Lm", "totalVolume": 5400, "trainingLoad": 312, "byEnergy": { "BZ1": 2600, "BZ2": 2000, "BZ5": 800 }, "byStroke": { "free": 4200, "fly": 1200 }, "byTrainingStyle": { "swim": 4600, "kick": 800 }, "estimatedDurationSeconds": 6930 }
GET/v1/workout-segmentsscope workouts
The attribution behind those marginals, in long format: one row per athlete, workout and segment, carrying volume, zone, stroke, style and equipment together. Marginals cannot answer cross-dimension questions (800m of kick and 800m at BZ2 may or may not be the same 800); segment rows can.
medley=resolved (default) distributes IM across its strokes; medley=preserved keeps it as im.
| from, to | Inclusive date bounds (YYYY-MM-DD) |
| teamId | Limit to one team |
| athleteId | Limit to one athlete |
| medley | resolved (default) or preserved |
curl -H "Authorization: Bearer $KEY" \ "$BASE/v1/workout-segments?from=2026-07-01&to=2026-07-31"
{ "workoutId": "w_2026-08-12_am", "date": "2026-08-12", "athleteId": "aQ71Lm", "volume": 400, "zone": "BZ2", "stroke": "fly", "style": "kick", "equipment": ["fins"] }
GET/v1/workout-contentscope workouts
The prescription itself, one row per workout: every set exactly as the coach wrote it, with rounds, set type, and which groups or athletes each set targets. Set text is byte-verbatim, including leading whitespace: indentation encodes nesting depth and line breaks delimit lines. Never normalize it.
The row also carries the resolved roster state: reassignments between training groups and per-athlete stroke overrides, keyed by athleteId. Names never appear here; join the roster. Coach post-workout reflections are private and are not exposed.
With structured=true, every set also carries a parsed, line-by-line structure; see the next section.
| from, to | Inclusive date bounds |
| teamId | One team |
| structured | true to parse each set with the production engine |
{ "workoutId": "w_2026-08-12_am", "name": "Main aerobic block", "workoutTypeName": "Endurance", "sets": [{ "id": "set_1", "type": "main", "rounds": 3, "groups": ["g_sprint"], "content": "8 x 50 fly BZ2 @0:50\n 4 x 25 kick\n> 200 easy" }], "roster": { "flexRoster": { "aQ71Lm": { "assignedGroup": "g_dist", "originalGroup": "g_sprint" } } } }
Structured parsing
With structured=true, each set's text is parsed by the same engine the app itself uses, against your organization's own terminology. Every line comes back with its engine-computed volume, its attribution segments, intervals, skill counts and flags for post-round lines and nesting depth.
The accuracy contract: every field is either produced by the engine or proven against it before it ships, and anything unprovable is null. reps and distance are emitted only when their product equals the engine's volume for that line. A sum line like 500 + 5x100 + 500 therefore keeps its volume (1500) and its three segments while both scalars stay null; no single reps-and-distance pair describes that line. segments is always the authoritative decomposition.
textstays verbatim;indentreports nesting depth.intervalslists every interval on the line with parsed seconds;intervalSecondsis set only when there is exactly one, never collapsed from many.flags.postRoundmarks lines that execute once after all rounds;roundsstays a set-level fact and lines are never multiplied out.- Skill lines (
# 8 Starts @1:00) carryvolume: 0, matching the app, which does not count skill work as swim volume. The skill meters live inskillVolume, with per-skill counts alongside. - A team without a terminology config gets
structured: null; the verbatim text is still returned.
{ "text": "500 + 5x100 + 500", "volume": 1500, "reps": null, "distance": null, "segments": [ { "volume": 500, "zone": "BZ1", "stroke": "free" }, { "volume": 500, "zone": "BZ1", "stroke": "free" }, { "volume": 500, "zone": "BZ1", "stroke": "free" } ], "flags": { "postRound": false, "skillLine": false } }
{ "volume": 400, "reps": 8, "distance": 50, "intervalSeconds": 50, "segments": [{ "volume": 400, "zone": "BZ2", "stroke": "fly", "style": "swim" }] }
GET/v1/workout-set-volumesscope workouts
One row per athlete, workout and set: total volume, training load and breakdowns for that set alone. setId joins to the sets in /v1/workout-content, so you can compare the prescribed set with what each athlete swam.
| from, to | Inclusive date bounds (YYYY-MM-DD) |
| teamId | Limit to one team |
| athleteId | Limit to one athlete |
{ "workoutId": "w_2026-08-12_am", "setId": "set_1", "athleteId": "aQ71Lm", "totalVolume": 1800, "trainingLoad": 96, "byEnergy": { "BZ2": 1800 } }
GET/v1/terminologyscope workouts
Your organization's terminology: energy zones with RPE ranges, strokes, training styles, skills and workout types, each with the aliases coaches actually write. Zone, stroke and style ids used in segment rows and parsed lines resolve here.
{ "name": "Federation terms", "energySystems": [{ "id": "es_bz2", "term": "BZ2", "rpeMin": 3, "rpeMax": 5, "aliases": ["GA1", "aerobic"] }], "strokes": [ … ], "skills": [ … ] }
GET/v1/attendancescope attendance
One row per athlete per session. status is one of present, late, absent, excused, planned_absence or made_up, with the training group at the time and the make-up date where relevant. made_up counts as present in the app's own summaries.
| from, to | Inclusive date bounds (YYYY-MM-DD) |
| teamId | Limit to one team |
| athleteId | Limit to one athlete |
{ "date": "2026-08-12", "session": "am", "athleteId": "aQ71Lm", "status": "made_up", "madeUpOn": "2026-08-14", "workoutId": "w_2026-08-12_am" }
GET/v1/performancesscope performances
Event swims with total time, reaction time and AquaPoints, plus race-analysis detail where it exists: cumulative taggedTimes at marked distances, with strokeCounts and strokeRates at measurement points. type separates competition swims from training swims, timed race-style efforts done at practice.
| from, to | Inclusive date bounds (YYYY-MM-DD) |
| teamId | Limit to one team |
| athleteId | Limit to one athlete |
| type | competition or training |
{ "athleteId": "aQ71Lm", "date": "2026-07-19", "type": "competition", "event": "100 Fly", "poolType": "LCM", "totalTimeSeconds": 59.84, "reactionTimeSeconds": 0.68, "aquaPoints": 812, "taggedTimes": { "15m": 6.91, "50m": 27.93 }, "strokeRates": { "25m": 52.4 } }
GET/v1/metricsscope metrics
The catalog of coach-defined test sets and measurements: time or count, unit, distance, pool type, and for composite tests the full rep structure. Read this once to interpret /v1/metric-results.
{ "id": "m_7x200", "name": "7 x 200 step test", "type": "time", "poolType": "LCM", "isComposite": true, "compositeStructure": { "repGroups": [ … ] } }
GET/v1/metric-resultsscope metrics
Recorded results against those definitions, including per-rep values and split times for composite tests. Filter by metricId to pull one test's full history across the squad.
| from, to | Inclusive date bounds (YYYY-MM-DD) |
| teamId | Limit to one team |
| athleteId | Limit to one athlete |
| metricId | Limit to one metric definition |
{ "athleteId": "aQ71Lm", "metricId": "m_7x200", "date": "2026-08-05", "displayValue": "2:14.6 avg", "repValues": [139.2, 137.8, 136.1, 134.6, 133.0, 131.4, 128.9] }
GET/v1/wellnessscope wellness
Daily athlete surveys: energy, sleep hours and quality, muscle soreness, life stress and willingness to train on 1 to 5 scales (soreness and stress read lower-is-better), plus the composite wellnessScore from 0 to 100 and the mood quadrant.
Wellness rows are health data and are handled separately from everything else. Rows are name-free: you get athleteId and join the roster only where your legal basis allows it. Athletes whose consent is withheld or withdrawn are absent from every response, including direct queries for their id. The wellness scope is granted only on explicit written instruction from your federation.
| from, to | Inclusive date bounds (YYYY-MM-DD) |
| teamId | Limit to one team |
| athleteId | Limit to one athlete |
{ "athleteId": "aQ71Lm", "date": "2026-08-12", "energy": 4, "sleepHours": 7.5, "sleepQuality": 4, "muscleSoreness": 2, "lifeStress": 1, "willingnessToTrain": 5, "wellnessScore": 87, "moodQuadrant": "green" }
Pulling into pandas
Rows are long-format. The cursor walk is identical for every resource; the helper below works for all twelve endpoints.
import requests, pandas as pd BASE = "https://europe-west3-makoswim-prod-9ab4d.cloudfunctions.net/dataApi/v1" H = {"Authorization": "Bearer mk_live_YOUR_KEY"} def pull(resource, **params): rows, cursor = [], None while True: r = requests.get(f"{BASE}/{resource}", headers=H, params={**params, "cursor": cursor, "limit": 500}).json() rows += r["data"] if not r["has_more"]: return pd.DataFrame(rows) cursor = r["next_cursor"] segments = pull("workout-segments", **{"from": "2026-07-01", "to": "2026-07-31"}) fly_kick = segments.query("stroke == 'fly' and style == 'kick'") \ .groupby("athleteId").volume.sum()
Data protection
This section summarizes the processing posture. The full annex, with technical and organizational measures, is part of your federation agreement.
| Residency | The API and its database run in Frankfurt (europe-west3). Your request and your data stay on EU infrastructure end to end. |
| Read-only | GET is the only method. The API cannot modify, delete or write anything. |
| Tenancy | A key reads exactly one organization, resolved server-side from the key itself. No caller parameter can move it. |
| Keys | Issued once, stored only as SHA-256 hashes, revocable in one call, scoped deny-by-default. |
| Health data | Wellness is a separate scope granted only on written instruction. Rows are name-free and per-athlete consent withdrawal is enforced in the API itself. |
| Audit | Every request is logged: key prefix, endpoint, parameters, row count, status and origin, retained for 365 days for your controller audits. |
| Minimization | Serializers are explicit whitelists; contact details, account identifiers and free-text coach notes are never emitted. |
For the current annex, write to eric@makoapp.io.
OpenAPI spec
The v1 contract in OpenAPI 3.1: every endpoint, parameter, row schema and error shape. Use it to generate a typed client or to wire the API into your tooling.