CorrConnectDocs
Early accessRequest access

Guides

Pagination and retries

Most CorrConnect calls are reads, and reads are safe to repeat. The few writes that aren't are exactly the ones you should never retry automatically.

Cursors

List endpoints return next_cursor. Pass it back as cursor with the same limit and source to get the next page; null means there are no more.

A cursor is signed and points at a position in the athlete's records (the last created_at and id you saw). That means:

  • Don't build, edit or decode one. A cursor that fails the signature check returns 400 invalid_cursor; start again without it.
  • Cursors don't expire, and a page never repeats a record from an earlier page.
  • Records stored after you started a walk appear on a fresh first page, not later in your walk. Start from the top to catch them, or use webhooks.

The webhook delivery log takes a limit (1 to 100, default 50) and has no cursor.

Which requests are safe to retry

RequestRetry?
GET anythingYes, with backoff.
POST /oauth/revoke, POST /oauth/revoke-tokenYes. Repeats answer {"status": "revoked"}.
POST /oauth/token with a codeNo. A code works once, and a second use withdraws the tokens from the first. Start a new authorization.
POST /oauth/token with a refresh tokenOnly within 10 seconds, and only if you'll keep the second response. After that a repeat ends the grant.
Console changes (create an app or subscription)Check first. Repeating a create makes a second app or subscription.

CorrConnect has no Idempotency-Key header. Creates are rare and done by you, so list before you retry one.

Backing off

On 429, wait the number of seconds in Retry-After, then try again. details.scope tells you whether your whole app (app) or one athlete within it (athlete) ran out; for service_capacity_exceeded the ceiling is shared by all apps and doesn't count against yours.

On a network error, a timeout or a 5xx, retry reads with exponential backoff and jitter, for example 1, 2, 4, 8 and 16 seconds plus a random fraction, and give up after five tries.

with-backoff.mjs
import { realpathSync } from "node:fs";
import { pathToFileURL } from "node:url";

export async function withBackoff(request, { tries = 5 } = {}) {
  for (let attempt = 0; ; attempt += 1) {
    let response;
    try {
      response = await request();
    } catch (error) {
      if (attempt + 1 >= tries) throw error; // network error
    }
    if (response && response.status !== 429 && response.status < 500) return response;
    if (attempt + 1 >= tries) return response;
    const retryAfter = Number(response?.headers.get("retry-after"));
    const wait = Number.isFinite(retryAfter) && retryAfter > 0
      ? retryAfter * 1000
      : 2 ** attempt * 1000 + Math.random() * 250;
    await new Promise((resolve) => setTimeout(resolve, wait));
  }
}

if (import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
  const response = await withBackoff(() =>
    fetch(`${process.env.CORRCONNECT_BASE}/v1/profile`, {
      headers: { Authorization: `Bearer ${process.env.ACCESS_TOKEN}` },
    }),
  );
  console.log(response.status, response.headers.get("ratelimit-remaining"));
}
with_backoff.py
import os
import random
import time

import requests


def with_backoff(send, tries: int = 5) -> requests.Response:
    for attempt in range(tries):
        try:
            response = send()
        except requests.ConnectionError:
            if attempt + 1 == tries:
                raise
            response = None
        if response is not None and response.status_code != 429 and response.status_code < 500:
            return response
        if attempt + 1 == tries:
            return response
        retry_after = response.headers.get("Retry-After") if response is not None else None
        time.sleep(int(retry_after) if retry_after else 2 ** attempt + random.random() / 4)
    raise AssertionError("unreachable")


if __name__ == "__main__":
    profile = with_backoff(lambda: requests.get(
        os.environ["CORRCONNECT_BASE"] + "/v1/profile", timeout=10,
        headers={"Authorization": f"Bearer {os.environ['ACCESS_TOKEN']}"}))
    print(profile.status_code, profile.headers.get("RateLimit-Remaining"))

Never put the token exchange inside a retry helper like this one.

Request IDs

Every response has an X-Request-ID header, and every error body repeats it as request_id. Log it with your own request; it's the fastest way for us to find what happened.