API quickstart
Everything lives under https://fitaio.app/api/v1. JSON in, JSON out, snake_case fields, kilograms and centimetres, RFC 3339 timestamps in UTC, days as YYYY-MM-DD.
1. Create a key
In the app, open Settings, then Developer, and create a key. Pick read for tools that only look, or read and write for anything that logs. Choose an expiry of never, 90 days or one year; a key past its expiry stops working on its own, no revoke needed. The key is shown once; it looks like fitaio_ followed by 40 characters. You can hold ten keys and revoke any of them on the same screen.
2. Your first request
GET /me returns the account behind the key.
curl https://fitaio.app/api/v1/me \
-H "Authorization: Bearer fitaio_YOUR_KEY"const response = await fetch('https://fitaio.app/api/v1/me', {
headers: { Authorization: `Bearer ${process.env.FITAIO_API_KEY}` },
});
const me = await response.json();
console.log(me.display_name, me.goal);import os, requests
headers = {"Authorization": f"Bearer {os.environ['FITAIO_API_KEY']}"}
me = requests.get("https://fitaio.app/api/v1/me", headers=headers, timeout=10).json()
print(me["display_name"], me["goal"])Response:
{
"id": "u_5d8e",
"display_name": "Alex",
"email": "alex@example.com",
"height_cm": 181,
"birth_date": "1994-03-12",
"gender": "male",
"goal": "recomp",
"created_at": "2026-04-02T09:12:00Z"
}The api-key: fitaio_YOUR_KEY header is accepted as an alternative to the bearer token.
3. Conventions
| Topic | Rule |
|---|---|
| Units | Weight in kg, lengths in cm, energy in kcal, macros in g. The app converts for display. |
| Time | Timestamps are RFC 3339 in UTC (2026-09-18T17:00:00Z). Day-based resources use YYYY-MM-DD. |
| Ids | Opaque strings. Exercise ids come from GET /exercises; never invent one. |
| Pagination | ?limit= from 1 to 100 (default 25). Responses carry next_cursor; pass it back as ?cursor=. null means the last page. |
| Filtering | since and until on list endpoints, timestamps for workouts and body logs, days for nutrition and supplement logs. |
| Writes | POST creates and returns 201 with the full record, except an idempotent write like a supplement log, which returns 200 on a repeat call. PUT replaces, PATCH changes fields, DELETE returns 204. |
4. Page through workouts
const BASE = 'https://fitaio.app/api/v1';
const headers = { Authorization: `Bearer ${process.env.FITAIO_API_KEY}` };
async function* workouts(since) {
let cursor = null;
do {
const url = new URL(`${BASE}/workouts`);
url.searchParams.set('limit', '100');
if (since) url.searchParams.set('since', since);
if (cursor) url.searchParams.set('cursor', cursor);
const page = await (await fetch(url, { headers })).json();
yield* page.workouts;
cursor = page.next_cursor;
} while (cursor);
}
for await (const workout of workouts('2026-09-01T00:00:00Z')) {
console.log(workout.started_at, workout.title, `${workout.volume_kg} kg`);
}Each workout carries its exercises and sets:
{
"id": "wk_9f2c",
"title": "Push",
"notes": "",
"started_at": "2026-09-18T17:00:00Z",
"duration_seconds": 3420,
"volume_kg": 640,
"plan_id": "pl_1a2b",
"plan_name": "Lean Cut PPL",
"exercises": [
{
"exercise_id": "barbell-bench-press",
"name": "Barbell Bench Press",
"muscle_group": "Chest",
"notes": "",
"superset_id": null,
"sets": [
{ "type": "warmup", "weight_kg": 40, "reps": 10, "completed": true, "rpe": null },
{ "type": "working", "weight_kg": 80, "reps": 8, "completed": true, "rpe": 8 }
]
}
],
"created_at": "2026-09-18T18:02:11Z",
"updated_at": "2026-09-18T18:02:11Z"
}5. Log a workout
Find the exercise id first, then post the session. Set type defaults to working; warm-ups count for nothing.
curl "https://fitaio.app/api/v1/exercises?q=bench&limit=3" \
-H "Authorization: Bearer fitaio_YOUR_KEY"curl -X POST https://fitaio.app/api/v1/workouts \
-H "Authorization: Bearer fitaio_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Push",
"started_at": "2026-09-19T17:00:00Z",
"duration_seconds": 3300,
"exercises": [
{
"exercise_id": "barbell-bench-press",
"sets": [
{ "type": "warmup", "weight_kg": 40, "reps": 10 },
{ "weight_kg": 82.5, "reps": 8, "rpe": 8 },
{ "weight_kg": 82.5, "reps": 8, "rpe": 8.5 },
{ "weight_kg": 82.5, "reps": 7, "rpe": 9 }
]
}
]
}'The response is 201 Created with the stored workout, including volume_kg computed from the completed working sets. Writes need a key with read and write scope.
6. Errors
Every error uses one envelope:
{
"error": {
"code": "invalid_request",
"message": "The request is invalid.",
"details": [{ "path": "body.exercises[0].sets[1].reps", "message": "is required" }]
}
}| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_request |
A field is missing or malformed; details lists each problem |
| 401 | invalid_key |
Missing, malformed, revoked or unrecognized key |
| 401 | expired_key |
The key was created with an expiry and that date has passed |
| 403 | insufficient_scope |
A write with a read-only key |
| 404 | not_found |
No such record in this account |
| 405 | method_not_allowed |
Wrong HTTP method for the path |
| 413 | payload_too_large |
Body over 256 KB |
| 415 | unsupported_media_type |
Send Content-Type: application/json |
| 429 | rate_limited |
Over 120 requests a minute for this key, or 300 for this IP |
| 503 | unavailable |
Temporary; retry after a moment |
7. Rate limits
120 requests a minute per key and 300 a minute per IP address. A 429 carries the rate_limited code and a Retry-After: 60 header; wait a minute and continue. Use limit=100 on list endpoints and the since filter to keep request counts low.
8. Coming from Hevy
Scripts that send api-key: <key> work without changes to the header. Differences to plan for: every weight is in kilograms, exercise ids are Fitaio’s own (search GET /exercises?q=), and there is no bulk import endpoint; post workouts one at a time.
Next
- Recipes: export everything, log from a script, sync weight, weekly report.
- MCP server: let Claude, ChatGPT, Cursor or VS Code use the same data.
- API reference: every endpoint and schema.