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:

PlanCoach 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.

ParameterMeaning
qText against name, French name, coach-style name and aliases. Exact and prefix matches rank first.
muscleAny of these as primary or secondary. glutes,hamstrings
equipmentThe gear the user has. Only exercises whose every piece is in the list (bodyweight always allowed). dumbbell,bench
patternMovement pattern. squat,hinge
maxDifficulty, minDifficulty1–5.
excludeJointsSkip anything that loads these. knee,lumbar-spine
impactlow,moderate,high
unilateraltrue / false
idsRestrict to these ids (a saved program, for instance).
limit, offsetPaging.
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"
}
FieldRequiredMeaning
exerciseIdyesThe exercise in the current catalogue.
categoriesyesOne or more of the categories below.
detailsyesAt least 10 characters. Write it for someone who cannot see your screen.
relatedExerciseIdfor substitutes, duplicateThe poor substitute, or the exercise this one duplicates.
medianokind mp4 or still, and atSecond in the clip.
suggestionsnoThe 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.
hashnoThe record's hash in your copy. If it is older than ours you get a warning: the fix may already be out.
reporternorole (coach, client, developer, other; the coach header wins), locale, appVersion.
externalRefnoYour own ticket id, echoed back and quoted in our emails.
CategoryUse 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

FieldTypeNotes
idstringStable, never reused.
name, nameFrstringClient-facing name. French where available.
variantstringCoach-style name: Region- Movement- Position- Equipment.
description, descriptionFrstringHow to perform it. Present on 1,312 records; the rest are being written.
media.mp4, media.stillpath or URLRelative path, not a link. Signed URLs come from POST /media/urls, which answers sandbox: true for a sandbox key asking outside the sample.
primaryMuscles, secondaryMusclesstring[]19 groups.
equipmentstring[]22 values. bodyweight means nothing needed.
movementPatternstring16 patterns.
difficulty1–51 complete beginner, 5 advanced lifter.
loadedJointsstring[]Joints under meaningful load; built for injury filters.
impactlow / moderate / high
unilateralbooleanOne side at a time.
cues, cuesFrstring[]3–4 short spoken-style lines.
aliasesstring[]Other names people use. Searched by q.
hashstringChanges 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.

Statuserror.codeWhen
401missing_key, invalid_keyNo key, unknown key, revoked key.
400end_user_required, ids_required, kinds_invalidMedia asked for without a watcher; empty id list; kinds other than mp4 and still.
404not_foundUnknown id or endpoint.
400, 404kind_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.
429feedback_rate_limitedMore than 100 feedback items from one key in a day.
429rate_limitedRequests per minute. Retry-After is set.
429monthly_quota_exceededRequests this month. Upgrade or wait for the month to roll.
429mau_exceededA new end user beyond the plan. Existing users keep working.
429coach_seats_exceededA new coach beyond the sandbox's seats. Paid plans bill extra coaches instead.
429user_media_capOne person opened more distinct exercises today than the plan allows (150 for a client, 400 for a coach on paid plans).
429key_media_capDistinct 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.