---
title: "Bulk, idempotent ingest of a device's workouts"
method: POST
path: "/activities/batch"
tags: ["Activities"]
---

# Bulk, idempotent ingest of a device's workouts

`POST /activities/batch`

## Request

- Base URL: `https://bodylab.lol/api/v1`
- URL: `https://bodylab.lol/api/v1/activities/batch`
- Auth: HTTP bearer

For Apple Health/Health Connect syncing: each item upserts by (source, externalId), so a retried or re-synced request is safe — a second identical request reports every item "unchanged". At most 500 items; a bad item is reported "rejected" with a reason and the rest of the batch still runs, so only a malformed request as a whole is a 400. `source` is never "manual" or "garmin" — those keep their own paths.

## Headers

- `X-Client-Platform` string
- `X-Client-Version` string

## Request body

- object
  - `items` object[], required
    - `source` string, required: Which device integration a batch ingest item came from. Known values: apple_health, health_connect. Treat unknown values as "other".
    - `externalId` string, required: A HealthKit workout UUID, or Health Connect's equivalent.
    - `activityType` string, required: Unknown sports map to "other", the same as an unrecognized FIT/GPX sport.
    - `startTime` string, date-time, required: An instant: ISO-8601 with offset, e.g. 2026-09-13T06:30:00Z.
    - `endTime` string, date-time, required: An instant: ISO-8601 with offset, e.g. 2026-09-13T06:30:00Z.
    - `durationS` number, required: Timer time, as the device reports it — not necessarily endTime minus startTime.
    - `distanceM` number, nullable
    - `activeEnergyKcal` number, nullable
    - `avgHeartRateBpm` integer, nullable
    - `maxHeartRateBpm` integer, nullable
    - `heartRateSamples` object[]: When present, load and an HR histogram derive from this stream instead of the average/duration fallback.
      - `offsetS` number, required: Seconds since the activity's startTime.
      - `bpm` integer, required
    - `indoor` boolean, nullable: Whether the source marked the workout as done indoors (HealthKit's HKMetadataKeyIndoorWorkout). An indoor workout's weather is never read as the air it was done in.
    - `weatherTemperatureC` number, nullable: The weather temperature the source attached to the workout, °C (HealthKit's HKMetadataKeyWeatherTemperature): a weather report, not a sensor on the athlete. Kept only alongside heartRateSamples or a route.
    - `weatherHumidityPct` number, nullable: The weather humidity the source attached to the workout, 0-100 (HealthKit's HKMetadataKeyWeatherHumidity). Kept only alongside heartRateSamples or a route.
    - `elevationGainM` number, nullable: Cumulative ascent over the workout, metres (HealthKit's HKMetadataKeyElevationAscended). How much was climbed, never the altitude it was done at.
    - `route` object: The workout's route at full resolution, or one part of it. Omitted: this client doesn't read routes, and a route stored earlier is kept. A route longer than one item holds is sent as the same item several times, each with the next part (fromIndex 0 first), in order; the route is used once every location has arrived. A part that doesn't follow what's stored is rejected with route_out_of_order, and sending the route again from fromIndex 0 recovers.
      - `pointCount` integer, required: Locations in the whole route. 0 says the workout has no route, and clears one stored earlier.
      - `fromIndex` integer, required: Where this part's first location sits in the whole route.
      - `points` object[], required: Locations fromIndex onward, in the order recorded.
        - `offsetS` number, required: Seconds from the activity's startTime; negative for a location recorded just before it.
        - `latitude` number, required: Degrees. Meaningless when horizontalAccuracyM is null.
        - `longitude` number, required: Degrees. Meaningless when horizontalAccuracyM is null.
        - `altitudeM` number, required: Metres above sea level, as the source measured it (barometric, GPS or both). Meaningless when verticalAccuracyM is null.
        - `horizontalAccuracyM` number, nullable: Radius of uncertainty of the coordinate, metres. Null: the coordinate is invalid.
        - `verticalAccuracyM` number, nullable: Uncertainty of the altitude, metres. Null: the altitude is invalid.
        - `speedMS` number, nullable: Metres per second. Null: invalid.
        - `speedAccuracyMS` number, nullable: Metres per second. Null: invalid.
        - `courseDeg` number, nullable: Direction of travel, degrees from true north. Null: invalid.
        - `courseAccuracyDeg` number, nullable: Degrees. Null: invalid.
    - `deleted` boolean: True removes this source's activity (HealthKit's anchored queries report deletions), through the same path DELETE /activities/{id} uses.

## Response `200`

OK

- object
  - `results` object[], required
    - `externalId` string, required
    - `result` string, required: What happened to one item in a batch ingest request. created/updated/unchanged/deleted describe what changed; rejected means this item alone was invalid (see its reason) — the rest of the batch still went through. Known values: created, updated, unchanged, deleted, rejected. Treat unknown values as "other".
    - `activityId` string, nullable, required: Opaque identifier.
    - `reason` string, nullable, required: Why one activity item was rejected. unsupported_source: not an allowed device source. invalid_time_range: durationS isn't positive, or endTime isn't after startTime. invalid_route: the route part runs past its pointCount. route_out_of_order: the route part doesn't continue what's stored (an earlier part is missing); resend from fromIndex 0. malformed: the item didn't match the schema above, so the rest of the batch ran without it. unexpected_error: the server failed on this item alone and nothing was stored for it; the rest of the batch still went through, and sending it again is safe. Known values: unsupported_source, invalid_time_range, invalid_route, route_out_of_order, malformed, unexpected_error. Treat unknown values as "other".

## Other responses

- `401`: Error: UNAUTHORIZED.

---

[API](https://skmtc.dev/bodylab/apis/body-lab-api.md) · [All operations](https://skmtc.dev/bodylab/apis/body-lab-api/llms.txt) · [OpenAPI document](https://skmtc.dev/bodylab/apis/body-lab-api/revisions/544c201eaf88?raw)
