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

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, nordicski, strength, 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, nordicski, strength, 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-06-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-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.

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

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 token
  • 404: activity not found
  • 429: 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 PATCH to 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 source garmin or wahoo were imported from external services.
  • The completed field 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. Fetch GET /activities/{plannedActivityId} to read the plan, or list with includePaired=true to 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 target based on the workout goal: “5k run” → distance, “20min ride” → time, “5k in 20min” → pace, structured intervals → steps.
  • Only include steps for 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 steps for strength training. Use the description field instead.
  • Always set load on planned workouts. Without it they do not appear on the load chart or in /training-status projection. 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

GET/api/v1/activities?from=2026-04-15&to=2026-04-21
[
  {
    "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.