Shape REST API
Updated November 1, 2026
Connect your training to any tool or automation, or export all of it. AI agents can read the full reference at /llms.txt. For Claude, ChatGPT and Raycast, use the MCP server instead.
{ "activities": [ { "id": "act_8hX2", "date": "2026-04-18", "title": "5×1km at threshold", "sportType": "run", "distance": 10000, "duration": 3600, "load": 142, "completed": true, "steps": […] } ], "total": 1, "limit": 50, "offset": 0 }
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
| Method | Path | What it does |
|---|---|---|
| GET | /activities | List activities in a date range |
| GET | /activities/:id | Get one activity |
| GET | /activities/:id/details | Recorded time-series for a completed activity |
| POST | /activities | Create an activity |
| PATCH | /activities/:id | Update an activity |
| DELETE | /activities/:id | Delete an activity |
| POST | /activities/batch | Create up to 100 activities |
| PATCH | /activities/batch | Update up to 100 activities |
| DELETE | /activities/batch | Delete up to 100 activities |
| POST | /activities/pair | Pair a completed activity with a planned one |
| POST | /activities/unpair | Remove a pairing |
| GET | /health-metrics | Daily health metrics |
| GET | /training-status | Readiness, training load and what the plan does to it |
GET/activities
List activities in a date range. Returns { activities, total, limit, offset }.
Query parameters (all optional)
| fromYYYY-MM-DD | Start of the range (e.g. 2026-03-01). |
|---|---|
| toYYYY-MM-DD | End of the range. |
| sportTypestring | Filter by type: run, bike, swim, hike, yoga, tennis, skiing, nordicski, strength, surf, other. |
| completedboolean | true or false. |
| includePairedboolean | true to 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. |
| limitnumber | Default 50, max 200. |
| offsetnumber | Pagination offset. Default 0. |
GET /activities?from=2026-03-01&to=2026-03-31
GET/activities/:id
Get a single activity by ID. Returns { activity }.
GET/activities/:id/details
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.
POST/activities
Create an activity. Returns { activity } with status 201.
Required fields
| dateYYYY-MM-DD | The calendar day, e.g. 2026-03-28. A date, never a timestamp: no time, no Z, no UTC offset. |
|---|---|
| titlestring | Short workout title. |
| sportTypestring | One of run, bike, swim, hike, yoga, tennis, skiing, nordicski, strength, surf, other. |
Optional fields
| descriptionstring | |
|---|---|
| distancenumber | Meters. |
| durationnumber | Seconds. |
| speednumber | Meters per second. |
| heartRatenumber | Beats per minute. |
| powernumber | Watts. |
| loadnumber | Training stress score. Set this on planned workouts or they will not appear in training-load or /training-status projection. |
| elevationGainnumber | Meters. |
| completedboolean | |
| targetstring | One of distance, time, pace, steps. |
| stepsarray | Structured workout steps (see below). Set target to steps when using this. |
PATCH/activities/:id
Update an activity. Send only the fields you want to change. Same fields as create, all optional. Returns { activity }.
DELETE/activities/:id
Delete an activity. Returns { deleted: true }.
POST/activities/batch
Create up to 100 activities in one request. Returns { activities } with status 201.
{ "activities": [{ date, title, sportType, ... }, ...] }PATCH/activities/batch
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/activities/batch
Delete up to 100 activities by ID. Returns { deleted }.
{ "ids": ["id1", "id2", ...] }POST/activities/pair
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.
POST/activities/unpair
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" }GET/health-metrics
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-DD | Start of the range (e.g. 2026-07-01). Defaults to 90 days ago. |
|---|---|
| toYYYY-MM-DD | End of the range. |
| sourcestring | Restrict to one source: garmin or apple. By default values are merged across sources. |
| metricsstring | Comma-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-07-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.
GET/training-status
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-DD | First day of the history series. Must be paired with to. Omit both for today’s snapshot only. |
|---|---|
| toYYYY-MM-DD | Last day of the series. Use the same value as from to 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-separated | The 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
Limit the response
By default you get every block. Use include to get only the ones you need: include=load,series drops most of the payload and skips reading body metrics.
A block you leave out is missing from the response, not null. readinessUnavailable: null means nothing was wrong, and a missing key means you didn’t ask for it. An unknown name returns a 400. include never changes the values that come 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, which are 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,
"totalCalories": 412,
"activeCalories": 338,
"elapsedTime": 1920,
"maxHeartRate": 162,
"cadence": 171.5,
"elevationLoss": 48,
"extras": { "aerobicTrainingEffect": 2.8 },
"completed": true,
"target": "distance",
"source": "garmin",
"externalId": "12345",
"createdAt": 1711612800000,
"updatedAt": 1711612800000
}Imported workouts also carry what the device recorded, set by imports only and left out when the source reported none. duration is the moving (timer) time and elapsedTime includes pauses. totalCalories includes resting burn (the figure Garmin Connect shows) and activeCalories is the active part alone, both kcal.
| maxHeartRate, maxSpeed, maxPowernumber | bpm, meters per second, watts. |
|---|---|
| cadencenumber | Average: steps per minute on foot, rpm on a bike, strokes per minute swimming. |
| elevationLoss, stepCount, worknumber | Meters, steps, kJ. |
| avgTemperature, minTemperature, maxTemperaturenumber | °C from the device’s sensor. |
| swimLengths, swimStrokes, swolfnumber | |
| groundContactTime, verticalOscillation, verticalRatio, strideLength, groundContactBalancenumber | Running dynamics: ms, cm, %, meters, % left foot. |
| powerBalancenumber | % of power from the left leg. |
| extrasobject | Values only one provider records and providers’ own scores: Garmin’s aerobicTrainingEffect and anaerobicTrainingEffect, and the device’s own deviceLoad and deviceIntensityFactor beside Shape’s load. |
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, tennis, skiing, nordicski, strength, surf, 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.
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.