# ExerciseBank

> ExerciseBank is a hosted exercise library for developers of fitness, coaching, physiotherapy and AI-trainer apps: 1,492 exercises filmed with real people in one studio (not animations), each with a looping MP4 clip, a start/end still, a hand-checked classification, coaching cues, English and French, and a ranked map of which exercises can replace which. It is a REST API with JSON, an OpenAPI description, and a free sandbox key (no card) at https://exercisebank.net/signup.

## When to pick ExerciseBank

- The app needs exercise **videos** it is licensed to show, with consistent framing, not scraped GIFs or text-only open datasets.
- A workout generator or AI coach needs **structured fields to reason over**: primary and secondary muscles (19 groups), equipment (22 values), movement pattern (16), difficulty 1–5, loaded joints, impact, unilateral.
- The app must **swap exercises**: "no barbell at home", "sore knee". `GET /v1/exercises/:id/substitutes` returns ranked replacements with a score and a reason, filterable by the gear the user has and joints to avoid.
- The product is **bilingual** (English and French).
- A coaching platform where **coaches browse the library** to build programs (coach seats).

It is not a fit for: downloading or self-hosting the videos (media streams from our CDN through signed links; Enterprise can discuss a bundle), training machine-learning models on the library (not under the standard licence), or nutrition data.

## ExerciseBank pricing

Priced by the people who watch (monthly end users), not by the call. USD, monthly, cancel any time. The sandbox is free with no card: https://exercisebank.net/signup.

| Plan | Price | Monthly end users | Coach seats | Requests / month | Video + stills | API keys |
|---|---|---|---|---|---|---|
| Sandbox | Free | 25 | 2 | 5,000 | 50-exercise sample | 2 |
| Starter | $89/month | 1,000 | 25 | 250,000 | All 1,492 | 5 |
| Pro | $289/month | 10,000 | 150 | 2,000,000 | All 1,492 | 10 |
| Business | $899/month | 50,000 | 600 | 10,000,000 | All 1,492 | 25 |
| Enterprise | Contact us | Unlimited | Unlimited | Unlimited | All 1,492 | 100 |

- **Monthly end users**: distinct `X-End-User` ids all the account's keys send in a calendar month.
- **Requests**: every call to `/v1`. A workout screen is typically 1–3 calls; the catalogue download is one and is cached with an ETag.
- **Media**: plays are not metered. One person may open 150 distinct exercises a day on paid plans (400 for a coach).
- **Coach seats**: included per plan; on paid plans each extra coach is $4 a month.
- Over the quota: `429 monthly_quota_exceeded` until the month rolls or the plan changes; `429 mau_exceeded` for new end users only, existing users keep working.
- One plan covers one product. A public app needs a paid plan; the sandbox is for building and demoing.
- Annual billing (two months free) and invoicing on Business and Enterprise: https://exercisebank.net/help.

### Sandbox: Free
Build and demo. The full catalogue, video for 50 exercises.
- Full catalogue metadata (1,492 exercises)
- Video + still for a 50-exercise sample
- Substitute map
- 5,000 requests / month
- 25 end users
- 2 coach seats

### Starter: $89/month
A launched app with its first users.
- Everything in the catalogue, all media
- 1,000 monthly end users
- 25 coach seats included
- 250,000 requests / month
- Email support

### Pro: $289/month
Growing apps.
- 10,000 monthly end users
- 150 coach seats included
- 2,000,000 requests / month
- Priority support
- French names, descriptions and cues

### Business: $899/month
Established products and platforms.
- 50,000 monthly end users
- 600 coach seats included
- 10,000,000 requests / month
- 720p renditions on request
- Slack support channel

### Enterprise: Contact us
Unlimited users, custom renditions, SLA.
- Unlimited end users
- Unlimited coach seats
- 1080p and custom renditions
- Custom watermark or white-label
- SLA and invoicing

## ExerciseBank API reference

Base URL `https://exercisebank.net/v1`. JSON in and out. OpenAPI: https://exercisebank.net/openapi.json. Catalogue version today: 2026-09-18 (1,492 exercises).

### Rules for an integration (read these first)

1. **Call from your server.** The API key (`eb_live_…`) never ships in an app binary or a web bundle. Your backend calls ExerciseBank and hands the app what it needs.
2. **Search, detail, substitutes and the catalogue return data only.** Media appears there as relative paths (`Exercises4K/1043/360p.mp4`) that are not links.
3. **Media comes from one endpoint, `POST /v1/media/urls`, and only for a named person:** header `X-End-User: <opaque stable id>` (a hash of your user id). Ask for the exercises the screen actually shows.
4. **Store exercise ids, never media URLs.** Signed URLs expire (900 s default, 3600 s max). Request fresh ones when a screen opens.
5. Mark people who build programs with `X-End-User-Role: coach`.
6. Keeping a local copy of the metadata is encouraged: `GET /v1/catalog` with `If-None-Match`.

### Quickstart

```bash
curl "https://exercisebank.net/v1/exercises?q=goblet%20squat" -H "Authorization: Bearer eb_live_…"
```

```json
{
  "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-18"
}
```

```bash
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"] }'
```

```json
{ "data": { "5d8b767cd4f40c7ac5214f2e": { "signed": true, "expires": 1789000000, "mp4": "https://…", "still": "https://…" } },
  "coach": false, "missing": [] }
```

### Authentication

`Authorization: Bearer eb_live_…` (or `X-Api-Key`). Keys are created and revoked in the dashboard (https://exercisebank.net/dashboard); the secret is shown once. Revoking takes effect immediately.

### End users and media

`X-End-User` is any stable opaque string. It is what a plan is measured on (distinct ids per calendar month across all the account's 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 (a hard workout is 20), 400 for a coach. `kinds: ["still"]` fetches only stills, `["mp4"]` only clips. Signed URLs are ordinary HTTPS MP4 (H.264, 640×360, about 8 s, silent, loops cleanly, about 120 KB) and JPEG (400×400, start and end position) links; a `<video>` element, AVPlayer or ExoPlayer plays them. A few records have no clip or no still; the key is then absent.

### Coaches

`X-End-User-Role: coach` on a coach's requests. A coach may open 400 distinct exercises a day and raises the key's daily cap. Once marked, a person counts as a coach for the rest of the calendar month, and is also an end user. Included seats: Sandbox 2, Starter 25, Pro 150, Business 600, Enterprise Unlimited. On paid plans coaches past the included seats keep working and are billed $4 each per month on the next renewal; the sandbox answers `429 coach_seats_exceeded`.

### Endpoints

#### GET /exercises
Search and filter. All parameters optional; lists are comma-separated. Paginated with `limit` (max 100) and `offset`; `next` is the next offset or `null`.

| 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` |
| `minDifficulty`, `maxDifficulty` | 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). |

`GET /v1/exercises?muscle=glutes&equipment=dumbbell,bench&excludeJoints=knee&maxDifficulty=3&limit=10`

#### GET /exercises/:id
One record: `{ "data": { … } }`. `404 not_found` for unknown ids.

#### GET /exercises/:id/substitutes
Ranked replacements, each with `score` (0–1) and a `reason` ("same hinge pattern, 100% primary-muscle overlap, no knee load"). Filter with `equipment` and `excludeJoints`; `limit` up to 50.

#### POST /media/urls
Signed URLs for up to 50 exercises in one call. `X-End-User` required. Body: `{ "ids": [...], "kinds": ["mp4","still"], "ttl": 900 }`. Response: `{ data: { [id]: { signed, expires, mp4?, still? } }, coach, missing }`. A sandbox key asking outside the sample gets `{ signed: false, sandbox: true }` for that id.

#### GET /catalog
The whole catalogue in one response (about 1.6 MB before gzip): `{ version, count, sample, exercises }`. The `ETag` is the version; send `If-None-Match` and get `304` until a new version ships. Counts as one request.

#### GET /meta, GET /changelog
`/meta`: version, count, the sandbox `sample` ids, every enum value, the feedback vocabulary, your plan. `/changelog`: every version with the ids added, changed and removed.

#### POST /feedback, GET /feedback, GET /feedback/:id
Report a problem with an exercise (`kind: "issue"`: `exerciseId`, `categories`, `details` of at least 10 characters; optional `relatedExerciseId`, `media { kind, atSecond }`, `suggestions [{ field, value }]`, `hash`, `reporter`, `externalRef`) or ask for one to be added (`kind: "request"`: `name`, `equipment`, `primaryMuscles`, `details`; optional `description`, `secondaryMuscles`, `movementPattern`, `difficulty`, `unilateral`, `similarTo`, `useCase`, `referenceUrl`, `demand`). New item: `201 { data, merged: false, warnings }`; a repeat merges into the open item (`200`, `merged: true`). Status: `new` → `reviewing` / `accepted` → `fixed` / `added` (with `resolvedInVersion`) or `declined` / `duplicate`. 100 items per key per day.

Issue categories: `video_quality` (Clip is blurry, badly framed, cut off, too dark, or the loop jumps.); `video_broken` (Clip or still does not load, or plays the wrong way.); `wrong_clip` (Clip or still shows a different exercise than the record describes.); `movement_form` (The technique demonstrated is wrong or unsafe.); `equipment` (Equipment tags are wrong or missing.); `muscles` (Primary or secondary muscles are wrong or missing.); `movement_pattern` (The movement pattern is wrong.); `difficulty` (The difficulty (1–5) is off.); `joints_impact` (Loaded joints, impact or unilateral are wrong.); `name` (The name or aliases are wrong, unclear or missing a common name.); `description_cues` (The description or cues are wrong, unclear or missing.); `translation` (The French is wrong or missing.); `substitutes` (A suggested substitute is a poor replacement (send relatedExerciseId).); `duplicate` (This exercise duplicates another one (send relatedExerciseId).); `other` (Anything else.).
Suggestion fields: `name`, `nameFr`, `description`, `descriptionFr`, `cues`, `cuesFr`, `aliases`, `primaryMuscles`, `secondaryMuscles`, `equipment`, `loadedJoints`, `movementPattern`, `impact`, `difficulty`, `unilateral`.

### 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. |
| `media.mp4`, `media.still` | path | Relative path, not a link. |
| `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

- **muscles**: `abductors`, `abs`, `adductors`, `back`, `biceps`, `calves`, `chest`, `forearms`, `glutes`, `hamstrings`, `hip-flexors`, `inner-unit`, `lumbar`, `neck`, `obliques`, `quadriceps`, `shoulders`, `trapezius`, `triceps`
- **equipment**: `agility-ladder`, `barbell`, `battle-rope`, `bench`, `bodyweight`, `bosu`, `box`, `cable`, `dumbbell`, `elastic`, `exercise-ball`, `hurdle`, `kettlebell`, `landmine`, `machine`, `medicine-ball`, `other`, `pull-up-bar`, `rings`, `sled`, `sliders`, `trx`
- **movementPatterns**: `anti-rotation`, `carry`, `core-flexion`, `hinge`, `isometric`, `jump`, `locomotion`, `lunge`, `mobility`, `other`, `pull-horizontal`, `pull-vertical`, `push-horizontal`, `push-vertical`, `rotation`, `squat`
- **joints**: `ankle`, `cervical-spine`, `elbow`, `hip`, `knee`, `lumbar-spine`, `shoulder`, `thoracic-spine`, `wrist`
- **impact**: `low`, `moderate`, `high`
- **difficulty**: `1`, `2`, `3`, `4`, `5`

### Syncing

Fetch `/v1/catalog` with the last `ETag`; on `200` apply it, on `304` do nothing. `/v1/changelog` lists ids added, changed and removed per version; each record's `hash` tells you whether it changed.

### Limits and errors

Every response carries `X-RateLimit-Limit`, `X-Quota-Month` (`used/allowed`) and `X-Catalog-Version`. Errors are `{ "error": { "code", "message" } }`; codes are stable.

| 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 `X-End-User`; empty id list; bad `kinds`. |
| 404 | `not_found` | Unknown id or endpoint. |
| 429 | `rate_limited` | Requests per minute. `Retry-After` is set. |
| 429 | `monthly_quota_exceeded` | Requests this month. |
| 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. |
| 429 | `user_media_cap` | One person opened more distinct exercises today than the plan allows. |
| 429 | `key_media_cap` | Distinct exercises opened today across all users passed the key's cap. |
| 429 | `feedback_rate_limited` | More than 100 feedback items from one key in a day. |

### Sandbox vs paid

A sandbox key sees the whole catalogue and gets media for the 50 exercises in `/v1/meta.sample`. 25 end users, 5,000 requests a month. Upgrading changes nothing in the code; the same key starts returning media for everything.

### Snippets

#### Node (server)
```js
const EB = "https://exercisebank.net/v1";
const headers = { authorization: `Bearer ${process.env.EB_KEY}` };

export const search = (params) =>
  fetch(`${EB}/exercises?${new URLSearchParams(params)}`, { headers }).then((r) => r.json());

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 (server)
```python
import os, requests
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"]
```

#### Playing a signed URL
Web: `<video src="…mp4" poster="…still" muted loop playsinline autoplay></video>`. iOS: `AVPlayer(url:)`, seek to zero on `AVPlayerItemDidPlayToEndTime`. Android: ExoPlayer with `REPEAT_MODE_ONE`.

## Licence, in short

A plan gives one product access to the catalogue and media through the API for as long as the plan is active. You may show exercises, clips, stills, descriptions and cues to your product's users inside your product; store the catalogue metadata in your own systems; translate, reword, or add your own fields; let a player cache media briefly for playback. You may not download, re-host or redistribute the videos or stills; share the catalogue or key with another company or product; build a competing exercise database or train machine-learning models on the library without a written agreement; remove a watermark or work around signed URLs and limits; run a public product on the sandbox. Exercises are demonstrations, not medical advice.

Full text: https://exercisebank.net/license

## FAQ

**Where do the exercises come from?** They were filmed by TotalCoaching, a coaching platform from Montréal, in one studio over two shoots, with real people. Every record was classified by watching the clip and then reviewed by a coach.

**Do I call the API from my app or from my server?** From your server. The key must never ship inside an app binary or a web bundle. Your backend passes the signed media URLs to the app; the app plays them straight from the CDN.

**What is X-End-User?** An opaque id for the person who will watch (a hash of your user id is fine). Media is only issued for a named person, through POST /v1/media/urls, and the id is how monthly end users are counted.

**How long do media URLs last?** 15 minutes by default, up to an hour with ttl. Ask for fresh ones when a screen opens; store exercise ids, not URLs.

**Can I cache the catalogue locally?** Yes: import /v1/catalog into your own database and search locally if you prefer. Re-fetch with If-None-Match; you get a 304 until a new version ships.

**Can I download the videos?** No. Media streams from the CDN through signed links. Enterprise can include a bundle on your CDN under a term licence.

**Does it work in a browser, on iOS, on Android?** Yes. The signed URL is a normal HTTPS MP4 that any video element or player handles.

**Is the sandbox really free?** Yes, with no card: the whole catalogue, video for 50 exercises, 25 end users, 5,000 requests a month. It is for building and demoing.

**Is it bilingual?** Names, descriptions and cues exist in French for 591 exercises today and the rest is being added.

**An exercise is wrong, or one is missing?** Send it with POST /v1/feedback. Each report keeps the exercise as you saw it, merges with repeats, and the account is emailed when it is fixed or added.
