CorrConnectDocs
Early accessRequest access

Guides

Read activities

Two endpoints cover everything: a newest-first list with an opaque cursor, and a single-record read for the IDs your webhooks give you.

List the newest activities

GET /v1/activities returns up to limit records (default 25, 1 to 100), newest first by when CorrConnect stored them, plus a next_cursor. When next_cursor is null, you have everything.

The loop below walks every page. Each page costs one request, so 100 per page is the cheapest way to backfill.

cURL
# First page
curl "$CORRCONNECT_BASE/v1/activities?limit=100" \
  -H "Authorization: Bearer $ACCESS_TOKEN"

# Next page: pass next_cursor back exactly as you got it
curl -G "$CORRCONNECT_BASE/v1/activities" \
  --data-urlencode "limit=100" \
  --data-urlencode "cursor=$NEXT_CURSOR" \
  -H "Authorization: Bearer $ACCESS_TOKEN"
all-activities.mjs
import { realpathSync } from "node:fs";
import { pathToFileURL } from "node:url";

export async function* allActivities(accessToken) {
  let cursor = null;
  do {
    const url = new URL("/v1/activities", process.env.CORRCONNECT_BASE);
    url.searchParams.set("limit", "100");
    if (cursor) url.searchParams.set("cursor", cursor);
    const response = await fetch(url, { headers: { Authorization: `Bearer ${accessToken}` } });
    if (!response.ok) throw new Error(`${response.status} ${(await response.json()).code}`);
    const page = await response.json();
    yield* page.data;
    cursor = page.next_cursor;
  } while (cursor);
}

if (import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
  let count = 0;
  for await (const activity of allActivities(process.env.ACCESS_TOKEN)) {
    count += 1;
    console.log(activity.id, activity.activity_type, activity.started_at);
  }
  console.log(`${count} activities`);
}
all_activities.py
import os
from collections.abc import Iterator

import requests


def all_activities(access_token: str) -> Iterator[dict]:
    cursor = None
    while True:
        params = {"limit": 100}
        if cursor:
            params["cursor"] = cursor
        response = requests.get(os.environ["CORRCONNECT_BASE"] + "/v1/activities", params=params,
                                headers={"Authorization": f"Bearer {access_token}"}, timeout=10)
        response.raise_for_status()
        page = response.json()
        yield from page["data"]
        cursor = page["next_cursor"]
        if cursor is None:
            return


if __name__ == "__main__":
    activities = list(all_activities(os.environ["ACCESS_TOKEN"]))
    for activity in activities:
        print(activity["id"], activity["activity_type"], activity["started_at"])
    print(len(activities), "activities")
AllActivities.swift
import Foundation

struct Activity: Decodable {
    let id: String
    let source: String
    let activity_type: String
    let started_at: String
    let duration_seconds: Double
    let distance_meters: Double?
    let title: String?
    let created_at: String
}

struct Page: Decodable {
    let data: [Activity]
    let next_cursor: String?
}

func allActivities(base: String, accessToken: String) async throws -> [Activity] {
    var results: [Activity] = []
    var cursor: String?
    repeat {
        var components = URLComponents(string: base + "/v1/activities")!
        components.queryItems = [URLQueryItem(name: "limit", value: "100")]
        if let cursor { components.queryItems!.append(URLQueryItem(name: "cursor", value: cursor)) }
        var request = URLRequest(url: components.url!)
        request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
        let (data, response) = try await URLSession.shared.data(for: request)
        guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw URLError(.badServerResponse) }
        let page = try JSONDecoder().decode(Page.self, from: data)
        results += page.data
        cursor = page.next_cursor
    } while cursor != nil
    return results
}

let env = ProcessInfo.processInfo.environment
let activities = try await allActivities(base: env["CORRCONNECT_BASE"]!, accessToken: env["ACCESS_TOKEN"]!)
for activity in activities { print(activity.id, activity.activity_type, activity.started_at) }
print(activities.count, "activities")

The Swift Activity decodes only the fields every source can have; add the optional ones you use as optionals. Decoding still succeeds when CorrConnect adds new fields.

Keep up without re-reading everything

Records are ordered by created_at, the time CorrConnect stored them, not by when the workout happened. A file uploaded today with last month's ride comes first. So to catch up after a backfill:

  1. Remember the id values (or the newest created_at) you have.
  2. Read from the first page until you reach an id you already have, then stop.

Better still, subscribe to activity.created and fetch each new ID as it arrives. See webhooks.

Fetch one activity

GET /v1/activities/{activity_id} returns one record in the same shape as list items. Use it with the activity_id from an activity.created event.

cURL
curl "$CORRCONNECT_BASE/v1/activities/$ACTIVITY_ID" \
  -H "Authorization: Bearer $ACCESS_TOKEN"
one-activity.mjs
const response = await fetch(
  `${process.env.CORRCONNECT_BASE}/v1/activities/${process.env.ACTIVITY_ID}`,
  { headers: { Authorization: `Bearer ${process.env.ACCESS_TOKEN}` } },
);
if (response.status === 404) {
  console.log("Not this athlete's activity, or it no longer exists");
} else {
  console.log(response.status, await response.json());
}
one_activity.py
import os

import requests

response = requests.get(
    f"{os.environ['CORRCONNECT_BASE']}/v1/activities/{os.environ['ACTIVITY_ID']}",
    headers={"Authorization": f"Bearer {os.environ['ACCESS_TOKEN']}"},
    timeout=10,
)
if response.status_code == 404:
    print("Not this athlete's activity, or it no longer exists")
else:
    print(response.status_code, response.json())
OneActivity.swift
import Foundation

let env = ProcessInfo.processInfo.environment
var request = URLRequest(url: URL(string: env["CORRCONNECT_BASE"]! + "/v1/activities/" + env["ACTIVITY_ID"]!)!)
request.setValue("Bearer \(env["ACCESS_TOKEN"]!)", forHTTPHeaderField: "Authorization")

let (data, response) = try await URLSession.shared.data(for: request)
let status = (response as! HTTPURLResponse).statusCode
print(status == 404 ? "Not this athlete's activity, or it no longer exists" : String(decoding: data, as: UTF8.self))

An ID that belongs to another athlete answers exactly like one that doesn't exist: 404 activity_not_found.

Filter by source

Add source to get one kind of record, for example ?source=fit. Today's values are manual_json, gpx, tcx and fit; an unknown value fails with 400 invalid_source. Leave it out to get everything.

Parse defensively

  • Accept source, activity_type and basis values you don't know. Show them as "Other" and carry on.
  • Treat distance_meters and title as optional and nullable.
  • Ignore fields you don't use. New ones will appear.
  • Store id and match on it; webhooks and retries can deliver the same ID twice.