Skip to content
CrossFitAPI.com

Docs

API reference

Base URL https://api.crossfitapi.com/v1. JSON in, JSON out, RFC 3339 dates in UTC, and the same error shape on every route.

Paste it into an assistant and it will know the endpoints, the auth scheme and the response shape. Read it first

Quickstart

Create an account, copy the key you are shown once, and make a request. The goal is a real response in under five minutes.

curl "https://api.crossfitapi.com/v1/athletes/search?q=Jeffrey%20Adler" \
  -H "Authorization: Bearer cfapi_live_..."

JavaScript:

const res = await fetch(
  "https://api.crossfitapi.com/v1/open/2026/leaderboard?division=men&limit=25",
  { headers: { Authorization: `Bearer ${process.env.CF_API_KEY}` } },
);
const { data, pagination } = await res.json();

Python:

import os, requests

r = requests.get(
    "https://api.crossfitapi.com/v1/athletes/search",
    params={"q": "Jeffrey Adler"},
    headers={"Authorization": f"Bearer {os.environ['CF_API_KEY']}"},
    timeout=15,
)
r.raise_for_status()
print(r.json()["data"])

Authentication

A bearer token on every request. Keys look like cfapi_live_… for production and cfapi_test_… for testing. Test keys reach the same data and do not count against your quota, so you can wire up CI without paying for it.

The secret is shown once, at creation, and cannot be recovered afterwards. Revoke and reissue if you lose it.

Response shape

Every field is always present. A value that does not apply is null; it is never left out. A leaderboard row has the same keys whether or not that athlete has a gym, an age or a team, on page one and on page four hundred.

This matters more than it sounds. Omitted keys make row["gym"]["name"] raise on some rows and not others, give pandas.DataFrame(rows) ragged columns that differ per page, and make a generated client type the field as optional when the schema says nullable — and all of it surfaces deep into a backfill rather than on the first request.

The full contract is published as an OpenAPI 3.1 document at https://api.crossfitapi.com/v1/openapi.json. It needs no key. Generate a client from it, or import it into Postman or Insomnia.

Pagination

Cursor-based. Pass limit (default 50; the maximum is set by your plan) and the next_cursor from the previous page. A limit above your plan’s maximum is a 400 invalid_parameter naming that maximum, not a silently truncated page — quietly serving 200 rows to a client that asked for 1,000 is how a job comes to believe it has read a whole leaderboard.

{
  "data": [ ... ],
  "pagination": { "next_cursor": "eyJrIjo1MCwiaSI6IjAxYTAu...", "has_more": true }
}

Cursors are opaque and keyed on the sort value plus a tiebreaker id, so depth costs nothing, and tied ranks survive a page boundary intact. A malformed or stale cursor returns invalid_cursor instead of quietly restarting at page one.

Filtering and sorting

Common filters: year, division, scaled, country, gym, type.

Divisions use a canonical key: men, women, men-45-49, women-70plus, team. The upstream numeric ids move between seasons, so the key is what lets one query work across years. List a season’s keys at /v1/open/{year}/divisions. An unknown key is a 400 invalid_division, not an empty leaderboard that reads as “nobody competed”.

scaled accepts rx, scaled, foundations or all. It defaults to rx, matching the source; without a default the three variants interleave and the first page has three rank-1 rows.

Sorting is an allowlist per resource, so an arbitrary column name is a 400.

Errors

{
  "error": {
    "code": "athlete_not_found",
    "message": "No athlete was found for this identifier."
  }
}

Branch on code; it will not change. Messages may be reworded.

CodeStatus
invalid_api_key401
invalid_parameter400
invalid_cursor400
invalid_year400
invalid_division400
athlete_not_found404
competition_not_found404
gym_not_found404
result_not_found404
workout_not_found404
endpoint_not_available_on_plan403
rate_limit_exceeded429
monthly_quota_exceeded429
internal_error500

endpoint_not_available_on_plan includes required_plan in the body, so you can render an upgrade prompt without a second lookup.

There is no source_temporarily_unavailable. Every endpoint serves stored data and none proxy a source at request time, so a request here cannot fail because an upstream is down.

Rate limits and quota

Rate limits are per second and per key; quota is per calendar month in UTC. Exceeding either returns 429 with Retry-After.

Every response carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset (seconds until the quota window rolls over), so your remaining budget is on every response. An unlimited plan omits the limit instead of reporting a very large number. See pricing for the numbers.

Athletes

GET /v1/athletes/search?q=       search by name
GET /v1/athletes/{id}            profile
GET /v1/athletes/{id}/results    results with workout breakdowns
GET /v1/athletes/{id}/career     every season and stage
GET /v1/athletes/source/crossfit/{source_id}

Search ranks exact, then prefix, then substring on a normalised name. It is never fuzzy: this is the query people build “is this the same athlete” features on, and a confident wrong hit is worse than a miss.

Athlete paths accept either the canonical id or the slug, so a URL copied off the website works in the API.

Competitions

GET /v1/competitions?year=&type=
GET /v1/competitions/{id}
GET /v1/competitions/{id}/divisions
GET /v1/competitions/{id}/workouts
GET /v1/competitions/{id}/leaderboard

CrossFit Open

GET /v1/open
GET /v1/open/{year}
GET /v1/open/{year}/divisions
GET /v1/open/{year}/workouts
GET /v1/open/{year}/leaderboard?division=&scaled=&country=&gym=

These are convenience views over canonical competitions and share one handler, so anything true of /v1/competitions/{id}/leaderboard is true here.

CrossFit Games

GET /v1/games
GET /v1/games/{year}
GET /v1/games/{year}/divisions
GET /v1/games/{year}/workouts
GET /v1/games/{year}/leaderboard?division=&country=

The same shape as the Open, over a different competition type. Two differences worth knowing: the Games have no scaled category, so there is no scaled filter; and events are scored in points, so reps, time_seconds and the derived time cap are all null while score_raw and score_display carry the published value verbatim, including formats like 07:35.20 (03:21).

Workouts

GET /v1/workouts/{id}
GET /v1/workouts/{id}/leaderboard?division=&scaled=

A workout’s own leaderboard ranks on that workout alone. The upstream rank is per division and per scaled variant, so division and scaled apply here exactly as they do on an overall leaderboard, and scaled defaults to rx for the same reason.

time_cap_seconds is derived, not published by the source. It is decoded from the score encoding and only set when every score in the workout implied the same cap; when they disagreed, the field is absent and time_cap_uncertain is true.

Results

GET /v1/results/{id}

Every workout result carries score_raw, always. The decoded fields (reps, time_seconds, tiebreak_seconds, points) may be null where the encoding for that season and competition is one we have yet to verify. The raw value is the record; the decode is a convenience that is allowed to fail.

Gyms

GET /v1/gyms?q=&country=&city=
GET /v1/gyms?near={lat},{lon}&radius_km=25
GET /v1/gyms/{id}
GET /v1/gyms/{id}/athletes

near is latitude first, the order you copy out of a map, even though GeoJSON is the other way round. Distances are great-circle in kilometres, returned as distance_km rounded to 100 m. Maximum radius 500 km.

The directory publishes no website, phone or opening status, so those keys are absent here. A null would read as an answer.

Provenance and derived data

Records carry a source object naming where the data came from and, where available, a URL you can open.

Anything this API computed carries calculated_by. Anything the source published does not. Where CrossFit publishes a percentile we serve it as source_percentile, in a field of its own.

Results also carry region.scheme. Before 2019 “region” means the old competitive region; after, a continental grouping. The scheme tag is what stops the two being aggregated together by mistake.

Versioning

/v1 is additive only: new fields and new endpoints, and no removed or retyped field. A breaking change would be /v2, announced on the changelog, with /v1 supported for at least twelve months.

Get a key and try it

Plans from $15 a month.

Get API Key