The Dirtanian API
Everything that powers off-road sport on Dirtanian — events, venues, outrides, activities, competitors, challenges, routes, segments and orders — over clean, predictable REST. Grab a key, make your first call in under a minute, and build something great.
Getting started
Three steps and you're pulling live data.
1 · Get a key
A Dirtanian admin issues keys, scoped to exactly what your integration needs. Ask for the
scopes you want (say, events:read) — you'll receive a key that looks like
dirt_live_4f…. It's shown once at creation, so store it somewhere safe
(a secret manager, not your source code).
2 · Make your first call
curl "https://api.dirtanian.com/v1/events?limit=3" \
-H "x-api-key: dirt_live_YOUR_KEY"
3 · Read the envelope
{
"ok": true,
"data": [ { "uid": "abc123", "exploreItemName": "Desert 1000", … } ],
"meta": { "count": 3, "limit": 3, "hasMore": true, "nextCursor": "abc123" }
}
That's it — every endpoint works the same way. The unauthenticated index at
GET /api/v1 lists every resource, live.
Authentication & keys
Two credentials are accepted; pick the one that fits.
API keys (integrations & partners)
Send your key on every request, either way works:
x-api-key: dirt_live_YOUR_KEY
# or
Authorization: Bearer dirt_live_YOUR_KEY
- Keys are stored hashed (SHA-256) — Dirtanian can't read your key back, and neither can anyone else.
- Keys carry scopes (below), an optional expiry, and can be revoked in seconds.
- Lost a key? No problem — it's revoked and reissued in moments. Nothing else changes.
Firebase ID tokens (the Dirtanian apps)
Signed-in calls with Authorization: Bearer <idToken> work too:
admins get full access including key management; a profile holding the
api role gets read access across the board. Roles are granted in the back office.
Managing keys (admins)
Admins manage keys right here in the API — sign in with your admin ID token (API keys deliberately can't mint more keys):
curl -X POST "https://api.dirtanian.com/v1/keys" \
-H "Authorization: Bearer $ADMIN_ID_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Race-results partner", "scopes": ["events:read", "activities:read"]}'
DELETE /v1/keys/{keyId}; usage (lastUsedAt,
requestCount) is visible on GET /v1/keys.Scopes & the api role
Access is opt-in per integration — a key does exactly what its scopes say, nothing more.
| Scope | Grants |
|---|---|
| {resource}:read | List + fetch that resource — e.g. events:read |
| {resource}:write | Create + update — e.g. venues:write |
| {resource}:delete | Delete — granted sparingly, on purpose |
| *:read / *:write / *:delete | The same, across every resource |
| keys:manage | Key administration — admins only, via ID token |
The api user role complements keys: grant it to a teammate's profile in the
back office and their own app sign-in can read the API (handy for internal dashboards and
exploration) — no key to circulate at all.
Responses & errors
One envelope, everywhere. You always know where to look.
Success
{ "ok": true, "data": …, "meta": { … } } // meta on lists
Errors
{ "ok": false, "error": { "code": "forbidden", "message": "Your key doesn't cover this yet — …" } }
| HTTP | code | When |
|---|---|---|
| 400 | invalid_request | The request needs a tweak — the message says exactly what. On a write, error.fields names each field in the wrong shape (see Write shapes). |
| 401 | unauthenticated | No credential, or one we couldn't verify. |
| 403 | forbidden | Valid credential, missing scope — the message names the scope to ask for. |
| 404 | not_found | No resource at that address — including a {uid} that isn't one plain document id (see below). |
| 405 | method_not_allowed | The Allow header lists what the endpoint speaks. |
| 429 | rate_limited | More than 600 requests in a minute on one credential. The Retry-After header says how many seconds until you're back in. |
| 500 | internal | Our side — logged, and worth a retry in a moment. |
Ids & rate limits
A {uid} in the path, and a cursor, is always one plain document
id — exactly what the API hands you. An id carrying a path separator (/, or
%2F encoded), .. or a malformed % escape is answered with
404 not_found (a cursor with 400 invalid_request), so a request can only
ever reach the record it names.
Each key, and each signed-in account, gets 600 requests a minute. That's room for any
sync job; past it, calls return 429 rate_limited with a Retry-After
header — wait that many seconds and carry on. Need more headroom for a big backfill? Ask a
Dirtanian admin.
Timestamps serialise to ISO-8601 strings; geo points to
{"latitude", "longitude"}. Money is ZAR, plain numbers.
Write shapes
We check what you send, and we never quietly change it.
Every field with a documented type is checked on POST and PATCH.
A value in the wrong shape comes back as 400 invalid_request — we
never coerce, because a value we silently changed is one your next
GET would disagree with. The error carries an extra
fields array naming each one, so you can point straight at it:
{ "ok": false, "error": {
"code": "invalid_request",
"message": "Nearly — a couple of fields came through in the wrong shape: distance should be number (got string).",
"fields": [ { "field": "distance", "expected": "number", "received": "string" } ]
} }
| Type | Send | Rejected |
|---|---|---|
string | A JSON string, within the field's length limit. | 42, ["a"], anything over the limit. |
number | A JSON number, finite. | "42" — a numeric string sorts and compares wrongly. |
boolean | true / false. | "yes", 1. |
string[] | An array of strings (empty is fine). | A bare string, or a mixed array. |
ISO string | ISO-8601, e.g. 2026-09-20T08:00:00Z — the same shape we return. | Epoch milliseconds, or free text. |
Nothing you already send will start failing. A field with no documented
type passes through untouched, null is always accepted (clearing a
field is a legitimate write), and code and message keep
their existing meaning — fields is purely additive.
Pagination & filtering
Cursor-based, stable, fast — the same on every list endpoint.
| Param | Meaning |
|---|---|
| limit | Page size, 1–100 (default 25). |
| cursor | Opaque cursor — pass the previous page's meta.nextCursor. |
| archived | true to include archived records (hidden by default). |
| filters | Each resource lists its exact-match filters below, e.g. ?discipline=enduro. |
let cursor;
do {
const url = new URL("https://api.dirtanian.com/v1/activities");
url.searchParams.set("limit", "100");
if (cursor) url.searchParams.set("cursor", cursor);
const page = await fetch(url, { headers: { "x-api-key": KEY } }).then(r => r.json());
handle(page.data);
cursor = page.meta.hasMore ? page.meta.nextCursor : null;
} while (cursor);
Data care & fair use
- Personal data never leaves the platform. Competitor endpoints return public fields only — email, phone, date of birth, address, medical information, dependents and payment details are never exposed, on any scope. Social reach comes back as counts, not follower lists. (POPIA-aligned by design.)
- The ledger stays authoritative. Orders are read-only; the payment engine owns every write, so what you read always matches the money that moved.
- Platform-managed fields (ids, timestamps, sold tickets/entries, ratings, commission rates, KOM standings, counters) are set by Dirtanian — writes that touch them are declined with a clear message.
- Fair use: paginate rather than hammer, cache what you can, and keep to the data you need. Every key's usage is visible to admins; keys can be scoped, expired and revoked.