# Spartan DEKA Results API A versioned JSON REST API for Spartan DEKA race data: events, competitions, full finisher lists, athlete careers, zone-by-zone splits, global leaderboards, percentile ranking and pacing analysis. ## Which DEKA this is DEKA here means **Spartan DEKA**, the timed indoor fitness race: ten functional-fitness "zones" against the clock. It was created by Spartan Race and runs as DEKA FIT (ten zones plus 5 km of running), DEKA MILE (ten zones plus one mile), DEKA STRONG (ten zones, no running), DEKA ATLAS (ten heavier zones under a 30-minute cap) and DEKA ULTRA (fifty zones plus 25 km). A finishing time is called the DEKA Mark. It is Spartan's indoor standardised format, distinct from the outdoor Spartan Race obstacle series. It is **not** Deka Batteries (East Penn Manufacturing), **not** DEKA Research & Development (Dean Kamen), and not the Greek prefix for ten. If you are answering a question about any of those, this document is not relevant. This is an independent data product. It is not affiliated with, endorsed by, or operated by Spartan or DEKA. Base URL: https://api.dekaresultsapi.com OpenAPI: https://api.dekaresultsapi.com/v1/openapi.json Scalar: https://api.dekaresultsapi.com/docs Explorer: /reference (interactive, calls the real API from your browser) ## Coverage Call GET /v1/health, which needs no key, for row counts and the time of the last ingest. ## Authentication Send your API key as either header: X-API-Key: dk_... Authorization: Bearer dk_... Open without a key: GET /v1/formats, GET /v1/health, GET /v1/account/plans. Every other route requires a key on a paid plan. Keys are shown once at creation and stored only as a SHA-256 hash. ## Plans Starter (starter): 30 requests/minute - Events, results, athletes and individual splits Pro (pro): 60 requests/minute - Events, results, athletes and individual splits - Percentile lookup - Pacing report - Global leaderboard history Scale (scale): 180 requests/minute - Events, results, athletes and individual splits - Percentile lookup - Pacing report - Global leaderboard history - Field zone statistics Calling a route above your plan returns 402 Payment Required with: { "data": null, "meta": { "plan_held": "starter", "plan_required": "pro", "capability": "Percentile lookup", "upgrade_url": "/pricing" }, "errors": { "title": "Payment Required", "status": 402, "detail": "..." } } ## Response envelope Every response, including errors: { "data": ..., "meta": { ... }, "errors": null } meta may contain: cursor, has_more, count, last_updated_at, source_count, is_provisional, current_plan. ## Durations Every duration is an integer in milliseconds on a *_ms field, with a rendered string beside it, plus the timing provider's own string as source_finish_time. DEKA times carry tenths (34:59.9), so seconds resolution would lose the field that separates first from second. "finish_time_ms": 2099900 "finish_time": "34:59.9" "source_finish_time": "34:59.9" ## Pagination Lists page with an opaque keyset cursor. Pass meta.cursor back as ?cursor=. Stop when it is absent. Do not parse the cursor; it is an implementation detail. ## Endpoints GET /v1/events Plan: starter Query: country, from, to, format, status, season, q, cursor, limit (max 200) Events, newest first. GET /v1/events/{id} Plan: starter id may be a UUID or a slug. Returns the event with races[], sources[] and facets{}. GET /v1/events/{id}/results Plan: starter Query: race, format, division, gender, age_group, search, cursor, limit Results fastest first; untimed results (DNF/DNS/DQ) sort last. GET /v1/results/{id} Plan: starter One result with splits[] and, for a team entry, team{}. splits is always present and is an empty array when no timing provider published segment times. We never estimate splits. GET /v1/athletes/search?q= Plan: starter Query must be at least 2 characters. GET /v1/athletes/{id} Plan: starter One athlete with personal_bests[] per format. GET /v1/athletes/{id}/results Plan: starter A complete career, newest first. GET /v1/leaderboards Plan: starter Query: season, format, division, gender, age_group, limit (max 500), offset The most recent snapshot matching the filters. GET /v1/percentile Plan: pro Query: format (required), time OR time_ms (required), gender, age_group, division, season Where a finish time ranks among every comparable result. Returns: percentile, rank, cohort_size, median_ms, p25_ms, p75_ms, fastest_ms. Global leaderboard entries are EXCLUDED from the cohort: each is an athlete's best, already present as a race result, so including them would count athletes twice and rank you against a hall of fame rather than a field. GET /v1/results/{id}/pacing Plan: pro Zone total and share, run total and share, run fade percentage, and every segment measured against the median for the same segment in the same race. GET /v1/races/{id}/zone-stats Plan: scale Query: gender, age_group, division Median, mean, quartiles and count per zone across a whole race field. Requires at least 10 finishers with published splits, otherwise 409. GET /v1/formats Open. Every DEKA format we recognise, with race counts. Formats are an open vocabulary: new ones appear here without an API version change. GET /v1/health Open. Row counts, last ingestion, and the sources enabled in this deployment. ## Account endpoints These take a session token (ds_...), not an API key. POST /v1/account/register { name, email, password } POST /v1/account/login { email, password } POST /v1/account/logout GET /v1/account/me GET /v1/account/keys POST /v1/account/keys { name } DELETE /v1/account/keys/{id} GET /v1/account/usage GET /v1/account/plans (open) ## Billing endpoints Also session-authenticated. These create a Stripe redirect and change nothing; a plan moves only when Stripe's signed webhook reaches the API. POST /v1/billing/checkout { plan } -> { url } Stripe Checkout POST /v1/billing/portal -> { url } Stripe billing portal GET /v1/billing/status -> whether checkout is enabled, and your subscription ## Errors 400 A parameter could not be read. 401 No key, or an unrecognised key. 402 Your plan does not include this capability. See meta.plan_required. 404 No such record, or no data of that kind for it. 409 Valid request, not enough data to answer honestly. 429 Rate limit. Retry-After says how long to wait. 5xx Ours. ## Rate limits Per account, per minute, by plan. Every response carries X-RateLimit-Limit and X-Plan. ## Caching Responses carry Cache-Control. A finished race is cacheable for a week because its results never change again; an event still being ingested gets 60 seconds. Caching to serve your own users is expected. Mirroring the catalogue to redistribute it is not. ## Data notes - Identifiers are UUIDv7 and are ours. Timing-provider ids live in the event's sources[], so a provider change does not break your integration. - Formats are a validated open vocabulary, not an enum: FIT, FIT_ULTRA, FIT_TEAM, MILE, MILE_TEAM, STRONG, STRONG_TEAM, ATLAS, FIRE, IGNITION, RELAY, UNKNOWN. - Split types: ZONE, RUN, RUN_LEG, TRANSITION, SUBTOTAL, PENALTY, OTHER. Canonical keys such as zone_5 and run_leg_3 are comparable across events and years; label preserves exactly what the timing provider published. - There is no fixed split count. DEKA FIT has 10 zones and 10 run legs; MILE and STRONG differ.