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
- Create an account , free and with no card.
- Choose a plan on pricing. The data routes need one.
- Create a key in the dashboard. It is shown once.
- 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.
| Capability | Plan | Routes |
|---|---|---|
| Events, results, athletes, individual splits | Starter | /v1/events, /v1/results, /v1/athletes, /v1/leaderboards |
| Percentile lookup | Pro | /v1/percentile |
| Pacing report | Pro | /v1/results/{id}/pacing |
| Global leaderboard history | Pro | /v1/leaderboards?snapshot= |
| Field zone statistics | Scale | /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 // doneDo 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
| Method | Path | Plan | Notes |
|---|---|---|---|
| GET | /v1/events | Starter | country, from, to, format, season, q, cursor, limit |
| GET | /v1/events/{id} | Starter | UUID or slug; races[], sources[], facets{} |
| GET | /v1/events/{id}/results | Starter | race, format, division, gender, age_group, search |
| GET | /v1/results/{id} | Starter | splits[] and, for a team entry, team{} |
| GET | /v1/athletes/search | Starter | q (2+ chars) |
| GET | /v1/athletes/{id} | Starter | personal_bests[] per format |
| GET | /v1/athletes/{id}/results | Starter | complete career, newest first |
| GET | /v1/leaderboards | Starter | season, format, division, gender, age_group |
| GET | /v1/percentile | Pro | format + time required |
| GET | /v1/results/{id}/pacing | Pro | needs a result that has splits |
| GET | /v1/races/{id}/zone-stats | Scale | needs 10+ finishers with splits |
| GET | /v1/formats | Open | no key |
| GET | /v1/health | Open | no 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
| Status | Meaning |
|---|---|
| 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 | A valid request we cannot answer honestly, usually too small a sample. |
| 429 | Rate limited. Retry-After says how long to wait. |
| 5xx | Ours. |
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.
