Quickstart
Get a key on the signup page (free, no card). Then:
curl https://exercisebank.net/v1/exercises?q=goblet%20squat \
-H "Authorization: Bearer eb_live_…"
{
"data": [{
"id": "5d8b767cd4f40c7ac5214f2e",
"name": "Goblet squat",
"movementPattern": "squat",
"primaryMuscles": ["quadriceps", "glutes"],
"equipment": ["kettlebell"],
"difficulty": 2,
"cues": ["Elbows inside the knees", "Chest tall", …],
"media": { "signed": false, "mp4": "Exercises4K/1043/360p.mp4", "still": "Exercises4K/1043/static.jpg" }
}],
"total": 1, "offset": 0, "limit": 25, "next": null, "version": "2026-09-10"
}
Search returns data only. When a screen shows exercises, ask for their media and say who is watching:
curl https://exercisebank.net/v1/media/urls \
-H "Authorization: Bearer eb_live_…" \
-H "X-End-User: u_8f3a" \
-H "content-type: application/json" \
-d '{ "ids": ["5d8b767cd4f40c7ac5214f2e"] }'
{
"data": {
"5d8b767cd4f40c7ac5214f2e": {
"signed": true,
"expires": 1789000000,
"mp4": "https://media.exercisebank.net/Exercises4K/1043/360p.mp4?token=…&expires=…",
"still": "https://media.exercisebank.net/Exercises4K/1043/static.jpg?token=…&expires=…"
}
},
"coach": false,
"missing": []
}
Three rules and you are done: call from your server, ask for media only for what is on screen with X-End-User, store ids and not URLs.
Authentication
Every request to /v1 carries your key as Authorization: Bearer eb_live_… (or X-Api-Key). Keys are created and revoked in the dashboard; the secret is shown once. A key identifies your product and plan; it must live on your server, never in an app binary or a web bundle. If a key leaks, revoke it and create another; the swap takes effect immediately.
End users and media
Search, detail, substitutes and the catalogue return data only; media appears there as relative paths (Exercises4K/1043/360p.mp4) that are not links. Media leaves through one endpoint, POST /media/urls, and only for a named person: X-End-User: <opaque id>. Any stable string works; a hash of your user id is the usual choice. The response has mp4 and still as signed HTTPS URLs on our CDN, valid for ttl seconds (default 900, max 3600).
Ask for the exercises a screen actually shows: the stills of a list of twenty, the clip of the exercise someone opens. kinds: ["still"] fetches only stills, kinds: ["mp4"] only clips.
Signed URLs are ordinary MP4 and JPEG links. A <video> element, AVPlayer, ExoPlayer or any HTTP client plays them. Request them when a screen opens and forget them afterwards. Do not persist them: they expire, and a persisted link that stopped working is the most common support ticket.
The end-user id is what your plan is measured on (distinct ids per month, across all your keys) and what separates a workout from a crawl. Plays are not metered; what is limited is distinct exercises per person per day: 150 for a client on paid plans, where a hard workout is 20.
Coaches
People who build programs for others browse far more of the library than people who follow one. Mark them with X-End-User-Role: coach on their requests. A coach may open 400 distinct exercises a day instead of 150, and each coach active today raises your key's daily cap.
A coach is also an end user. Once a request marks someone a coach, they count as a coach for the rest of the calendar month. Each plan includes coach seats, counted as distinct coaches per month across your keys:
| Plan | Coach seats included |
|---|
On paid plans, coaches past the included seats keep working and are billed at $4 each per month, on the next renewal. The dashboard shows the count as it happens. The sandbox includes 2 coach seats; a third returns 429 coach_seats_exceeded.
Endpoints
Base URL https://exercisebank.net/v1. JSON in and out. Lists are paginated with limit (max 100) and offset; next is the next offset or null.
GET /exercises
Search and filter. All parameters optional; lists are comma-separated.
| Parameter | Meaning |
|---|---|
q | Text against name, French name, coach-style name and aliases. Exact and prefix matches rank first. |
muscle | Any of these as primary or secondary. glutes,hamstrings |
equipment | The gear the user has. Only exercises whose every piece is in the list (bodyweight always allowed). dumbbell,bench |
pattern | Movement pattern. squat,hinge |
maxDifficulty, minDifficulty | 1–5. |
excludeJoints | Skip anything that loads these. knee,lumbar-spine |
impact | low,moderate,high |
unilateral | true / false |
ids | Restrict to these ids (a saved program, for instance). |
limit, offset | Paging. |
GET /v1/exercises?muscle=glutes&equipment=dumbbell,bench&excludeJoints=knee&maxDifficulty=3&limit=10
GET /exercises/:id
One record. { "data": { … } }. 404 with not_found for unknown ids. Retired ids keep resolving for at least twelve months after a changelog lists them as removed.
GET /exercises/:id/substitutes
Ranked replacements, each with score (0–1) and a reason. Filter with equipment (gear the user has) and excludeJoints; limit up to 50. Data only, like search.
GET /v1/exercises/tc-2000/substitutes?excludeJoints=lumbar-spine&limit=5
{
"for": "tc-2000",
"data": [
{ "score": 0.92, "reason": "same core-flexion pattern, 100% primary-muscle overlap, no lumbar load",
"exercise": { "id": "…", "name": "…", … } }
]
}
POST /media/urls
Signed URLs for up to 50 exercises in one call, for a whole screen at once. X-End-User is required; add X-End-User-Role: coach for coaches. kinds is optional and defaults to both.
POST /v1/media/urls
X-End-User: u_8f3a
{ "ids": ["tc-2000", "5d8b767cd4f40c7ac5214f2e"], "kinds": ["still"], "ttl": 1800 }
{ "data": { "tc-2000": { "signed": true, "expires": 1789000000, "still": "…" }, … },
"coach": false,
"missing": [] }
GET /catalog
The whole catalogue in one response (about 1.6 MB, gzipped over the wire): { version, count, exercises }. The ETag is the version; send If-None-Match and you get 304 until a new version ships. The best way to keep a local copy.
GET /meta and GET /changelog
/meta: version, generated date, count, the sandbox sample ids, every enum value, the last ten changelog entries as counts, and your plan. /changelog: every version with full id lists.
POST /feedback, GET /feedback, GET /feedback/:id
Report a problem with an exercise or ask for one to be added. See Feedback.
Feedback
Your coaches and users see the library more closely than anyone. When something is wrong with an exercise, or one is missing, send it from your app or your support tool. Every report records the catalogue version and the exercise exactly as it was when you sent it, so it can be checked and fixed without a back-and-forth. Send X-End-User (and X-End-User-Role: coach) if a person reported it; both are optional here.
A problem with an exercise
POST /v1/feedback
X-End-User: u_8f3a
X-End-User-Role: coach
{
"kind": "issue",
"exerciseId": "tc-2443",
"categories": ["equipment", "video_quality"],
"details": "The clip shows a bench but equipment only lists kettlebell. The loop also jumps at the bottom.",
"media": { "kind": "mp4", "atSecond": 2.4 },
"suggestions": [{ "field": "equipment", "value": ["kettlebell", "bench"] }],
"hash": "the record's hash in your copy",
"reporter": { "locale": "fr-CA", "appVersion": "3.2.1" },
"externalRef": "SUPPORT-1182"
}
| Field | Required | Meaning |
|---|---|---|
exerciseId | yes | The exercise in the current catalogue. |
categories | yes | One or more of the categories below. |
details | yes | At least 10 characters. Write it for someone who cannot see your screen. |
relatedExerciseId | for substitutes, duplicate | The poor substitute, or the exercise this one duplicates. |
media | no | kind mp4 or still, and atSecond in the clip. |
suggestions | no | The correction you propose, as { field, value } in the record's own vocabulary. Fields: name, nameFr, description, descriptionFr, cues, cuesFr, aliases, primaryMuscles, secondaryMuscles, equipment, loadedJoints, movementPattern, impact, difficulty, unilateral. Values outside the catalogue's lists are kept and returned as warnings. |
hash | no | The record's hash in your copy. If it is older than ours you get a warning: the fix may already be out. |
reporter | no | role (coach, client, developer, other; the coach header wins), locale, appVersion. |
externalRef | no | Your own ticket id, echoed back and quoted in our emails. |
| Category | Use it when |
|---|
An exercise to add
POST /v1/feedback
{
"kind": "request",
"name": "Copenhagen plank",
"description": "Side plank with the top leg on a bench.",
"equipment": ["bench"],
"primaryMuscles": ["adductors"],
"secondaryMuscles": ["obliques"],
"movementPattern": "isometric",
"difficulty": 4,
"unilateral": true,
"similarTo": ["tc-2000"],
"useCase": "Groin injury prevention for soccer clients.",
"referenceUrl": "https://…",
"demand": 37,
"details": "Our physio coaches ask for this every week."
}
name, equipment (["bodyweight"] for none), primaryMuscles and details are required. similarTo lists existing exercises it is a variation of or an alternative to. demand is how many of your users asked, if you know. If the name matches an exercise we already have, the answer says so in warnings and request.possibleMatches.
Answers and follow-up
A new item answers 201 with { data, merged: false, warnings }. Sending the same open problem on the same exercise again, or the same request again, answers 200 with merged: true: the report is added to the existing item as a note and its reports count goes up. Up to 100 items per key per day.
GET /v1/feedback lists your account's items (filter with kind, status, exerciseId; paged like search). GET /v1/feedback/:id returns one. status moves from new to reviewing or accepted, then fixed or added with resolvedInVersion (and the new exercise's id in resolvedId), or declined or duplicate with a statusNote. We email your account when an item is resolved. The vocabulary is also in /v1/meta.feedback.
The record
| Field | Type | Notes |
|---|---|---|
id | string | Stable, never reused. |
name, nameFr | string | Client-facing name. French where available. |
variant | string | Coach-style name: Region- Movement- Position- Equipment. |
description, descriptionFr | string | How to perform it. Present on 1,312 records; the rest are being written. |
media.mp4, media.still | path or URL | Relative path, not a link. Signed URLs come from POST /media/urls, which answers sandbox: true for a sandbox key asking outside the sample. |
primaryMuscles, secondaryMuscles | string[] | 19 groups. |
equipment | string[] | 22 values. bodyweight means nothing needed. |
movementPattern | string | 16 patterns. |
difficulty | 1–5 | 1 complete beginner, 5 advanced lifter. |
loadedJoints | string[] | Joints under meaningful load; built for injury filters. |
impact | low / moderate / high | |
unilateral | boolean | One side at a time. |
cues, cuesFr | string[] | 3–4 short spoken-style lines. |
aliases | string[] | Other names people use. Searched by q. |
hash | string | Changes when any field changes. |
Values
Live from the catalogue (/v1/meta.enums):
Syncing
If you keep a local copy: fetch /v1/catalog with the last ETag; on 200 apply it, on 304 do nothing. To apply a new version without a full rewrite, read /v1/changelog for the ids added, changed and removed since your version, or compare each record's hash. New versions are announced by email to paid accounts. Removed ids keep resolving for twelve months so nothing in a saved program breaks on the day.
Limits and errors
Every response carries X-RateLimit-Limit (requests per minute on your plan), X-Quota-Month (used/allowed) and X-Catalog-Version.
| Status | error.code | When |
|---|---|---|
| 401 | missing_key, invalid_key | No key, unknown key, revoked key. |
| 400 | end_user_required, ids_required, kinds_invalid | Media asked for without a watcher; empty id list; kinds other than mp4 and still. |
| 404 | not_found | Unknown id or endpoint. |
| 400, 404 | kind_invalid, details_required, categories_invalid, exercise_not_found, related_exercise_required, suggestion_invalid, name_required, equipment_required, muscles_required, … | Feedback that is missing something; the message says what. |
| 429 | feedback_rate_limited | More than 100 feedback items from one key in a day. |
| 429 | rate_limited | Requests per minute. Retry-After is set. |
| 429 | monthly_quota_exceeded | Requests this month. Upgrade or wait for the month to roll. |
| 429 | mau_exceeded | A new end user beyond the plan. Existing users keep working. |
| 429 | coach_seats_exceeded | A new coach beyond the sandbox's seats. Paid plans bill extra coaches instead. |
| 429 | user_media_cap | One person opened more distinct exercises today than the plan allows (150 for a client, 400 for a coach on paid plans). |
| 429 | key_media_cap | Distinct exercises opened today across all users passed the key's cap. Real traffic that hits it gets raised on request. |
Errors are { "error": { "code", "message" } }. Messages are written for humans; codes are stable.
Sandbox vs paid
A sandbox key sees the whole catalogue and gets media for the 50 exercises listed in /v1/meta.sample; other ids answer /media/urls with sandbox: true and no URLs. Twenty-five end users, 5,000 requests a month. Upgrading changes nothing in your code; the same key starts returning media for everything.
Snippets
Node
const EB = "https://exercisebank.net/v1";
const headers = { authorization: `Bearer ${process.env.EB_KEY}` };
export async function screenMedia(user, ids, kinds = ["mp4", "still"]) {
const r = await fetch(`${EB}/media/urls`, {
method: "POST",
headers: {
...headers,
"content-type": "application/json",
"x-end-user": hash(user.id),
...(user.isCoach ? { "x-end-user-role": "coach" } : {}),
},
body: JSON.stringify({ ids, kinds }),
});
if (!r.ok) throw new Error((await r.json()).error.code);
return (await r.json()).data; // { [id]: { mp4, still, expires } }
}
Python
import requests, os
EB = "https://exercisebank.net/v1"
H = {"Authorization": f"Bearer {os.environ['EB_KEY']}"}
def search(**params):
return requests.get(f"{EB}/exercises", headers=H, params=params).json()["data"]
def media(user_id, ids, coach=False, kinds=("mp4", "still")):
h = {**H, "X-End-User": user_id, **({"X-End-User-Role": "coach"} if coach else {})}
r = requests.post(f"{EB}/media/urls", headers=h, json={"ids": ids, "kinds": list(kinds)})
r.raise_for_status()
return r.json()["data"]
Swift (playing a signed URL your server handed the app)
let player = AVPlayer(url: URL(string: media.mp4)!)
player.actionAtItemEnd = .none // loop
NotificationCenter.default.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: player.currentItem, queue: .main) { _ in
player.seek(to: .zero); player.play()
}
Kotlin (ExoPlayer)
val player = ExoPlayer.Builder(context).build()
player.setMediaItem(MediaItem.fromUri(media.mp4))
player.repeatMode = Player.REPEAT_MODE_ONE
player.prepare(); player.playWhenReady = true
Web
<video src="${media.mp4}" poster="${media.still}" muted loop playsinline autoplay></video>
OpenAPI
/openapi.json describes every endpoint for code generators and API clients.