Shape API
Shape ships with a REST API so you can plug your training into any tool, AI agent, or automation, or export everything. No lock-in.
The full machine-readable reference is at /llms.txt. Point any LLM or agent at it and it can plan, create, and manage your workouts for you.
Using an MCP-compatible client (Claude, ChatGPT, Raycast)? See the MCP server docs for the one-click OAuth setup.
Authentication
Every request needs your API token in the Authorization header. Tokens start with shape_ and can be created from API access.
Authorization: Bearer shape_...
Base URL
https://shapecalendar.com/api/v1
Endpoints
List activities in a date range. Returns { activities, total, limit, offset }.
Query parameters (all optional)
fromYYYY-MM-DDStart of the range (e.g.
2026-03-01).toYYYY-MM-DDEnd of the range.
sportTypestringFilter by type:
run,bike,swim,hike,yoga,nordicski,strength,other.completedbooleantrueorfalse.includePairedbooleantrueto include planned activities that have a paired completed activity (hidden by default). The plan's description and steps live on that planned row, not the completed one.limitnumberDefault 50, max 200.
offsetnumberPagination offset. Default 0.
GET /activities?from=2026-03-01&to=2026-03-31
Get a single activity by ID. Returns { activity }.
Recorded time-series for a completed activity: heart rate, pace, cadence, power, elevation and GPS as parallel arrays indexed against time, plus lap splits and time in zone. Optional channels, points (10-2000, default chosen by duration), includeLaps and includeZones params. Only activities recorded by a connected device have this; otherwise returns 404 with a reason code and message.
Create an activity. Returns { activity } with status 201.
Required fields
dateYYYY-MM-DDThe calendar day, e.g.
2026-03-28. A date, never a timestamp: no time, noZ, no UTC offset.titlestringShort workout title.
sportTypestringOne of
run,bike,swim,hike,yoga,nordicski,strength,other.
Optional fields
descriptionstringdistancenumberMeters.
durationnumberSeconds.
speednumberMeters per second.
heartRatenumberBeats per minute.
powernumberWatts.
loadnumberTraining stress score. Set this on planned workouts or they will not appear in training-load or
/training-statusprojection.elevationGainnumberMeters.
completedbooleantargetstringOne of
distance,time,pace,steps.stepsarrayStructured workout steps (see below). Set target to
stepswhen using this.
Update an activity. Send only the fields you want to change. Same fields as create, all optional. Returns { activity }.
Delete an activity. Returns { deleted: true }.
Create up to 100 activities in one request. Returns { activities } with status 201.
{ "activities": [{ date, title, sportType, ... }, ...] }Update up to 100 activities in one request. Each object must include id plus the fields to change. Returns { updated }.
{ "activities": [{ "id": "id1", "completed": true }, ...] }Delete up to 100 activities by ID. Returns { deleted }.
{ "ids": ["id1", "id2", ...] }Pair a completed activity with a planned one. The completed row gets plannedActivityId set; the planned row gets completedActivityId set. After pairing, the planned activity is hidden from the default list view. Returns { paired: true, completedActivityId, plannedActivityId }.
{ "completedActivityId": "id1", "plannedActivityId": "id2" }The completed activity must have completed: true and the planned activity must have completed: false. 400 otherwise. Idempotent when the same pair is sent twice. Fails with 409 if either side is already paired with a different activity. Call /activities/unpair first.
Remove the pairing between an activity and its partner. Pass either side; both rows are unlinked. If the partner row no longer exists (e.g. it was deleted), only this side is cleared and partnerId is returned as null. Returns { unpaired: true, activityId, partnerId }.
{ "activityId": "id1" }List daily health metrics (sleep, HRV, resting heart rate, stress, body battery, VO2max, body composition, steps), merged across your connected sources into one record per day. Returns { days, total }.
Query parameters (all optional)
fromYYYY-MM-DDStart of the range (e.g.
2026-06-01). Defaults to 90 days ago.toYYYY-MM-DDEnd of the range.
sourcestringRestrict to one source:
garminorapple. By default values are merged across sources.metricsstringComma-separated whitelist, e.g.
sleepDurationSec,restingHR,hrvMs. Names must match the field ids in the response exactly; an unrecognized one returns a 400 listing the valid options. Omit it to get every metric recorded that day.
GET /health-metrics?from=2026-06-01&metrics=restingHR,sleepScore
Response day object
{
"date": "2026-07-10",
"values": { "restingHR": 42, "sleepDurationSec": 28800, "steps": 6941 },
"sources": { "restingHR": "garmin", "sleepDurationSec": "garmin", "steps": "garmin" },
"hrvMethod": "rmssd",
"sleepScoreQualifier": "good"
}values holds the chosen number per metric and sources records which provider it came from. Raw provider payloads are never returned.
Readiness, training load, and what the plan does to it, in one call. Use it to ask whether you should train hard today, whether you are ramping too fast, why readiness is low, or what this week's plan does to your load.
Query parameters (all optional)
fromYYYY-MM-DDFirst day of the history series. Must be paired with
to. Omit both for today’s snapshot only.toYYYY-MM-DDLast day of the series. Use the same value as
fromto ask about a single past day. At most 366 days per call, and no more than 56 days past today — beyond that the projection would be curves the model does not produce.includecomma-separatedThe blocks you want back:
readiness,load,projection,providerScores,series,series.drivers. Omit it for all of them.
GET /training-status?from=2026-07-15&to=2026-08-15
Asking for less
The default is the whole verdict, because that is what the question “should I train hard today” actually needs. include is for the other kind of caller — a chart of fitness over a year, a weekly digest — that already knows what it wants. include=load,series drops most of the payload and skips the body-metrics read altogether.
An excluded block is absent, not null. The difference matters: readinessUnavailable: null means we looked and nothing was wrong, where a missing key means nobody asked. An unknown name is a 400 rather than a quiet omission, and nothing you do to include changes a number that comes back.
Response
{
"date": "2026-08-15",
"dateInferred": false,
"readiness": {
"basis": "current",
"score": 74,
"band": "high",
"drivers": [
{ "key": "restingHR", "label": "Resting HR", "value": 42, "baseline": 42.7,
"z": 0.5, "weight": 0.3, "unit": "bpm", "source": "garmin" }
]
},
"readinessUnavailable": null,
"load": { "fitness": 61.2, "fatigue": 74.8, "form": -13.6, "ratio": 1.22, "band": "safe" },
"loadUnavailable": null,
"loadCoverage": { "withLoad": 38, "total": 40 },
"projection": { "date": "2026-08-19", "ratio": 1.44 },
"providerScores": [
{ "source": "garmin", "key": "sleepScore", "label": "Sleep score", "value": 81, "scale": "0-100" }
]
}Two readiness numbers, deliberately
The snapshot includes training already completed today, so it falls through the day as you train — that is the “can I do more right now” number. Every day in series is morning readiness and never includes it, because a trend carrying it would just redraw your training schedule. So a past day’s series score is how you woke up, not how the day ended, and the two can differ for the same date. Each says which it is in basis.
When there is no score
readiness is null and readinessUnavailable says why, because the fix differs completely: insufficient_baseline means wait, no_readings_today means sync your watch, and no_body_metrics means connect one. loadUnavailable does the same for load, separating no_workouts from no_load_data — workouts that could carry load and do not.
dateInferred is true when we had to guess which calendar day is yours. Your timezone is recorded when you open the app; until then the day is inferred from your most recent data and clamped to within a day of UTC.
The week ahead, and a longer range
With no dates, series is today plus the next 7 days of fitness, fatigue, form and ratio. No readiness: that cannot be projected. projection is the peak of that week.
Passing from and to replaces that strip with the requested history: one entry per day, including morning readiness and its drivers. Future days still carry projected: true and no readiness. hasPlannedLoad separates a day a planned workout actually contributes load to from the rest days where the curves merely decay.
This returns the plan’s consequence, not the plan. For what is actually on the calendar, call /activities.
Activity object
{
"id": "abc-123",
"date": "2026-03-28",
"title": "Easy morning run",
"description": "Recovery run",
"sportType": "run",
"distance": 5000,
"duration": 1800,
"speed": 2.78,
"heartRate": 140,
"power": null,
"load": 45,
"elevationGain": 50,
"completed": true,
"target": "distance",
"source": "garmin",
"externalId": "12345",
"createdAt": 1711612800000,
"updatedAt": 1711612800000
}Structured workout steps
Activities can include structured steps for interval training. Set target to steps and provide a steps array.
Step types
warmup, cooldown, interval, recovery, repeat
End conditions
lap.button, time, distance, iterations, heart.rate, power, open
Target types
no.target, heart.rate, pace, power, speed, cadence
Step object
{
"stepType": "interval",
"displayName": "400m fast",
"description": null,
"endCondition": "distance",
"endConditionValue": 400,
"targetType": "pace",
"targetValueOne": 210,
"targetValueTwo": 240,
"zoneNumber": null,
"secondaryTargetType": null,
"secondaryTargetValueOne": null,
"secondaryTargetValueTwo": null,
"secondaryZoneNumber": null
}Repeat group
{
"stepType": "repeat",
"numberOfIterations": 5,
"skipLastRestStep": false,
"workoutSteps": [
{ "stepType": "interval", "displayName": "400m fast", "endCondition": "distance", "endConditionValue": 400, "targetType": "pace", "targetValueOne": 210, "targetValueTwo": 240 },
{ "stepType": "recovery", "displayName": "90s jog", "endCondition": "time", "endConditionValue": 90, "targetType": "no.target" }
]
}Example: 5×400m interval workout
{
"date": "2026-03-30",
"title": "400m Repeats",
"sportType": "run",
"distance": 4000,
"duration": 2400,
"target": "steps",
"steps": [
{ "stepType": "warmup", "displayName": "Warmup jog", "endCondition": "time", "endConditionValue": 600, "targetType": "no.target" },
{
"stepType": "repeat",
"numberOfIterations": 5,
"workoutSteps": [
{ "stepType": "interval", "displayName": "400m fast", "endCondition": "distance", "endConditionValue": 400, "targetType": "pace", "targetValueOne": 210, "targetValueTwo": 240 },
{ "stepType": "recovery", "displayName": "90s jog", "endCondition": "time", "endConditionValue": 90, "targetType": "no.target" }
]
},
{ "stepType": "cooldown", "displayName": "Cooldown jog", "endCondition": "time", "endConditionValue": 600, "targetType": "no.target" }
]
}Sport types
run, bike, swim, hike, yoga, nordicski, strength, other
Errors
Errors always return { "error": "message" }.
400: validation error (bad request body or query params)401: missing or invalid token404: activity not found429: rate limit exceeded
Unknown fields in a request body are rejected rather than ignored, and the error names them. Send distanceKm instead of distance and you get a 400 saying so, not a workout that quietly has no distance.
Tips for LLMs
- Always include a date range when listing activities to avoid fetching everything.
- Use
PATCHto update single fields (e.g. mark completed) rather than sending the full object. - Use batch endpoints when creating or deleting multiple activities at once.
- Distance is always in meters, duration in seconds.
- Activities with
sourcegarminorwahoowere imported from external services. - The
completedfield distinguishes planned workouts (false) from completed ones (true). - When a planned activity is completed, both exist linked via
completedActivityId/plannedActivityId. The completed row does not carry the plan's description or steps. FetchGET /activities/{plannedActivityId}to read the plan, or list withincludePaired=trueto see both rows.
Creating workouts: conventions
- Keep titles short. Don’t include distance/duration in the title: “Easy 5k run” → “Easy run”, “bike 100km” → “Bike”.
- Set
targetbased on the workout goal: “5k run” →distance, “20min ride” →time, “5k in 20min” →pace, structured intervals →steps. - Only include
stepsfor workouts with a specific interval structure (e.g. “5×400m at 5k pace with 60s rest”). Don’t create steps for simple descriptions like “5k run” or “20min easy bike ride”. - Never include
stepsfor strength training. Use thedescriptionfield instead. - Always set
loadon planned workouts. Without it they do not appear on the load chart or in/training-statusprojection. TSS:(duration_seconds / 3600) × effort² × 100, where effort is 0.25 recovery, 0.5 easy, 0.75 tempo, 1.0 threshold, 1.25 VO2, 1.5 race. Example: 30min easy run = (1800/3600) × 0.5² × 100 = 13 TSS.
Example response
[ { "id": "act_8hX2", "date": "2026-04-18", "title": "5×1km at threshold", "sportType": "run", "distance": 10000, "duration": 3600, "load": 142, "completed": true, "steps": […] } ]
Rate limits
30 requests per minute and 200 per hour, counted across every endpoint — reads included. Exceeding either returns a 429. Use batch endpoints to stay under the cap when creating or deleting many activities, and prefer one /training-status call with a range over polling it per day.