Skip to content
DEKA Results API

Documentation

DEKA Results API

A versioned JSON REST API for Spartan DEKA race data. Everything is under /v1, every response has the same shape, and every duration is an integer number of milliseconds.

Interactive reference

Every endpoint with a request builder that calls the real API from your browser.

Open the reference →

Use these docs with an assistant

The whole reference as one plain-text file, generated from the live plan ladder so it cannot drift from the API.

Quick start

  1. Create an account , free and with no card.
  2. Choose a plan on pricing. The data routes need one.
  3. Create a key in the dashboard. It is shown once.
  4. Call the API.
curl "https://api.dekaresultsapi.com/v1/events?country=US&limit=5" \
  -H "X-API-Key: dk_your_key"

Base URL: https://api.dekaresultsapi.com · OpenAPI: /v1/openapi.json

Authentication

Send your key in either header. They are equivalent; pick whichever your client makes easier.

X-API-Key: dk_...
Authorization: Bearer dk_...

Keys start dk_. We store only a SHA-256 hash and the first few characters, so a key cannot be recovered. If one is lost or leaked, revoke it and create another. You may hold five active keys; use one per environment so you can rotate staging without touching production.

Three routes need no key at all: GET /v1/health, GET /v1/formats and GET /v1/account/plans. That is enough to see the shape and freshness of the dataset before you pay for it.

Account management (/v1/account/*) uses a session token starting ds_, issued by POST /v1/account/login. An API key is deliberately not accepted there, so a leaked key cannot mint more keys or read billing details.

Plans and gating

Every route requires a capability, and every capability sits on a plan. The ladder resolves upward: a capability requiring Pro is satisfied by Scale.

CapabilityPlanRoutes
Events, results, athletes, individual splitsStarter/v1/events, /v1/results, /v1/athletes, /v1/leaderboards
Percentile lookupPro/v1/percentile
Pacing reportPro/v1/results/{id}/pacing
Global leaderboard historyPro/v1/leaderboards?snapshot=
Field zone statisticsScale/v1/races/{id}/zone-stats

Calling above your plan returns 402 with everything you need to act on it programmatically:

{
  "data": null,
  "meta": {
    "plan_held": "starter",
    "plan_required": "pro",
    "capability": "Percentile lookup",
    "upgrade_url": "/pricing"
  },
  "errors": {
    "title": "Payment Required",
    "status": 402,
    "detail": "Percentile lookup requires the Pro plan or higher; this key is on Starter."
  }
}

An upgrade applies to keys you have already deployed. You never rotate a credential to get what you just bought, and the rate limit moves with the plan.

Response envelope

Every response has the same three keys, including errors.

{ "data": ..., "meta": { ... }, "errors": null }

meta may carry cursor, has_more, count, last_updated_at, source_count, is_provisional and current_plan. On a refusal it carries the gating fields above.

is_provisional is worth handling: it means the event is still being ingested or the provider has marked results unofficial, so ranks can still move. A finished race is not provisional and its rows never change again.

Durations

Every duration is an integer of milliseconds on a *_ms field, with a rendered string beside it and the timing provider’s own string kept as source_finish_time.

"finish_time_ms": 2099900,
"finish_time": "34:59.9",
"source_finish_time": "34:59.9"

DEKA times carry tenths, and podium places are regularly decided by them. Rounding to seconds would lose the field that separates first from second. Keeping the provider string as well means you can always show exactly what the timing company published, even where its formatting is inconsistent.

Identifiers

Our identifiers are UUIDv7 and they are ours. Timing-provider ids live in the event’s sources[] array, so a provider changing its scheme is our problem rather than a migration in your database.

Events also have a stable slug, and /v1/events/{id} accepts either: /v1/events/2026-deka-fit-austin-tx works as well as the UUID.

Pagination

Lists page with an opaque keyset cursor. Pass meta.cursor back as ?cursor= and stop when it is absent.

GET /v1/events?limit=100
  -> meta.cursor = "eyJzIjoiMjAyNi0wOS0xMiIsImkiOiIwMWEwYjk2OSJ9"

GET /v1/events?limit=100&cursor=eyJzIjoiMjAyNi0wOS0xMiIsImkiOiIwMWEwYjk2OSJ9
  -> meta.cursor = null   // done

Do not construct or parse a cursor; its contents are an implementation detail and will change. Keyset rather than offset because offset pagination skips or repeats rows when the underlying list changes mid-walk, which it does constantly during an event.

Endpoints

MethodPathPlanNotes
GET/v1/eventsStartercountry, from, to, format, season, q, cursor, limit
GET/v1/events/{id}StarterUUID or slug; races[], sources[], facets{}
GET/v1/events/{id}/resultsStarterrace, format, division, gender, age_group, search
GET/v1/results/{id}Startersplits[] and, for a team entry, team{}
GET/v1/athletes/searchStarterq (2+ chars)
GET/v1/athletes/{id}Starterpersonal_bests[] per format
GET/v1/athletes/{id}/resultsStartercomplete career, newest first
GET/v1/leaderboardsStarterseason, format, division, gender, age_group
GET/v1/percentileProformat + time required
GET/v1/results/{id}/pacingProneeds a result that has splits
GET/v1/races/{id}/zone-statsScaleneeds 10+ finishers with splits
GET/v1/formatsOpenno key
GET/v1/healthOpenno key

Operational endpoints sit outside /v1: /healthz, /readyz, and /metrics behind an internal header.

Splits

A split is a typed, canonically-keyed row rather than a cell in a rendered table. The canonical key is comparable across events and years; label preserves exactly what the timing provider published.

{ "key": "zone_5", "label": "Zone 5 - SkiErg", "type": "ZONE",
  "index": 10, "time_ms": 159600, "time": "2:39.6" }

Types: ZONE, RUN, RUN_LEG, TRANSITION, SUBTOTAL, PENALTY, OTHER. There is no fixed split count: DEKA FIT has ten zones and ten run legs, MILE and STRONG differ, and a new format does not need an API version change.

Coverage, said plainly

Splits are not universal. Only HQ-timed events publish per-participant segment times; most gym-hosted DEKA STRONG and MILE events publish a finish time only. A result with no splits comes back with splits: [], never an estimate, never an interpolation. Check race.has_splits before you build a screen that assumes them.

Errors

StatusMeaning
400A parameter could not be read.
401No key, or an unrecognised key.
402Your plan does not include this capability. See meta.plan_required.
404No such record, or no data of that kind for it.
409A valid request we cannot answer honestly, usually too small a sample.
429Rate limited. Retry-After says how long to wait.
5xxOurs.

409 is deliberate. Asking for zone statistics on a race with four finishers would produce a median that means nothing, so the API refuses rather than returning a confident-looking number.

Rate limits

Per account, per minute: Starter 30, Pro 60, Scale 180. The limit is on the account, not the key, so extra keys are for separating environments rather than buying throughput.

Every response carries X-RateLimit-Limit and X-Plan, so you never have to trigger a 429 to discover your own ceiling. A 429 carries Retry-After in seconds.

Caching

Responses carry a Cache-Control chosen from how likely the data is to change: a finished race is cacheable for a week because its results never change again, while an event still ingesting gets sixty seconds.

Caching to serve your own users is expected and encouraged. Mirroring the catalogue in order to redistribute it is not.

Freshness

GET /v1/health is open, needs no key, and reports what we hold right now: row counts, the time of the last ingestion and which sources are enabled. Call it if you want to size the catalogue before you build against it.

Ingestion runs continuously. Every response carries meta.last_updated_at and meta.is_provisional, so a settled result is distinguishable from one that can still change.

Something missing?

If an endpoint does not do what you need, say so. We would rather add a parameter than have you scrape around us. Try the endpoints first; it is usually faster than reading.

Documentation · Spartan DEKA Results API