# 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`.
