Base URL: https://roadclaim-api.codext.de (self-hosters: your own). JSON in and out. Auth: Authorization: Bearer <token>.
Errors: { "error": { "code": "string", "message": "string" } } with a matching HTTP status.
Times are ISO 8601 strings (UTC). Distances in metres, durations in seconds, speeds in m/s.
Common errors: 400 invalid_request (validation), 401 unauthorized, 404 not_found, 413 too_large, 422 inappropriate_name, 429 rate_limited (with Retry-After).
Rate limits (per server instance; IPv6 clients are grouped by /64): device signup 20/hour per IP, restore and delete-with-recovery 30/hour per IP, drive uploads 120/hour per user, backup uploads 600/hour per user, 600 requests/minute per IP, 300 requests/minute per user.
Name moderation: username, displayName, car.label, car.color, homeCity, club name and description are checked against a built-in English and German word list (leetspeak normalised); a match returns 422 inappropriate_name.
type PublicUser = {
id: string; username: string; displayName: string;
avatarColor: string; // hex, e.g. "#10B981"
car: { id: string; label: string; color?: string } | null;
homeCity: string | null; homeCountry: string | null; // ISO 3166-1 alpha-2
club: { id: string; name: string; color: string } | null;
};
type Me = PublicUser & {
inviteCode: string;
privacy: { localOnly: boolean; hideFromLeaderboards: boolean; cloudBackup: boolean };
createdAt: string;
};
type DriveUpload = {
id: string; // client uuid v4, idempotent
startedAt: string; endedAt: string;
distanceM: number; durationS: number; movingS: number;
maxSpeedMps: number; avgSpeedMps: number;
elevationGainM: number; elevationLossM: number;
brakePresses: number; turnsLeft: number; turnsRight: number; laneChanges: number; stops: number;
city: string | null; countryCode: string | null;
cells: string[]; // H3 res 9 indexes in travel order, deduped
utcOffsetMin?: number; // local UTC offset at start (e.g. 120); else the offset in startedAt, else UTC. Used for night/early-bird trophies and active days
};
type TerritoryResult = { claimed: number; stolen: number; refreshed: number; held: number };
type Trophy = {
id: string; // e.g. "distance_total"
tier: 0 | 1 | 2 | 3; // 0 = not earned, 1 bronze, 2 silver, 3 gold
value: number; // current progress value
next: number | null; // threshold for the next tier, null when gold
earnedAt: string | null;
};
type Period = "today" | "week" | "month" | "season";
type Metric = "distance" | "area" | "longest" | "trophies";
type Scope = "friends" | "city" | "country" | "world" | "club";
type BoardEntry = { rank: number; user: PublicUser; value: number };
type Club = { id: string; name: string; color: string; description: string; isPublic: boolean;
ownerId: string; memberCount: number; cellsHeld: number; inviteCode?: string /* members only */ };
type CellOwner = { cell: string; owner: "me" | "friend" | "club" | "other"; userId: string; name: string; color: string };GET /health->{ ok: true }GET /v1/config->{ features: { aiMods: boolean }, modsPerDay: number, h3Resolution: 9, season: "2026" }(modsPerDaydefaults to 3)
POST /v1/auth/device{ platform: "ios"|"android", locale?: string }->{ token: string, recoveryKey: string, me: Me }Creates an anonymous account with a generated username (driver+ 8 random digits, e.g.driver04817263). The recovery key is shown once in onboarding and in Settings.POST /v1/auth/restore{ recoveryKey }->{ token, me }(rotates the token; old tokens stay valid)GET /v1/auth/recovery-key->{ recoveryKey }(auth)POST /v1/auth/rotate-recovery->{ recoveryKey }(auth) issues a new recovery key (the old one stops working) and signs out every other device: all tokens except the one used for this call are revoked.POST /v1/auth/delete-with-recovery{ recoveryKey }->{ ok: true }(used by the website deletion page; CORS allowsCORS_ORIGINS, defaulthttps://roadclaim.codext.de) Recovery keys are accepted case-insensitively with or without theRC-prefix and dashes. Unknown keys: 404invalid_recovery_key.
GET /v1/me->MePATCH /v1/me{ username?, displayName?, avatarColor?, car?: {id,label,color?}|null, homeCity?, homeCountry?, privacy?: Partial<Me["privacy"]> }->Meusername: 3-20 chars[a-z0-9_.](lower-cased), unique (409username_taken). displayName 1-40 chars, homeCountry ISO alpha-2. Settingprivacy.cloudBackupto false deletes all stored backups. Settingprivacy.localOnlyto true removes all cells you hold from the map (they become unowned).DELETE /v1/me->{ ok: true }deletes everything.GET /v1/me/stats?period=season->{ distanceM, drives, longestM, cellsHeld, cellsEver, trophies, activeDays, elevationGainM, durationS, countries }Drive-based values cover drives started in the period;cellsHeld/cellsEverare current totals;trophies= trophy tiers earned in the period.
POST /v1/drivesDriveUpload->{ territory: TerritoryResult, newTrophies: Trophy[] }. Idempotent by id: 201 on first upload, 200 with the stored territory result andnewTrophies: []on repeats; 409drive_existswhen the id belongs to another account. 403local_onlywhen privacy.localOnly. Validation: avg speed <= 70 m/s, max speed <= 90 m/s, movingS <= durationS, distanceM <= maxSpeedMps * movingS * 1.1 + 500, cells.length <= distanceM/100 + 20, every cell a valid H3 res 9 index, consecutive cells at most 20 grid steps apart, total grid steps * 175 m <= distanceM * 1.5 + 1000, endedAt >= startedAt.DELETE /v1/drives/:id->{ ok: true }(removes it from stats; territory stays)- Backup (only when privacy.cloudBackup, else 403
backup_disabled):PUT /v1/backup/drives/:id(id:[A-Za-z0-9_-]{1,64}) body = drive JSON (any shape, <= 5 MB, 413 above) ->{ ok }; quota per account: 2,000 backups and 500 MB in total (413quota_exceeded, replacing an existing id counts its new size only);GET /v1/backup/drives->{ items: { id, updatedAt, size }[] };GET /v1/backup/drives/:id-> stored JSON;DELETE /v1/backup/drives/:id.
GET /v1/users/search?q=->{ items: PublicUser[] }(prefix match on username/displayName, max 20; excludes yourself, local-only and blocked users)GET /v1/users/:id->{ user: PublicUser, stats: <me/stats shape for season>, trophies: Trophy[], isFriend: boolean, requestPending: "in"|"out"|null }(404 for local-only users and when either side blocked the other, except for yourself)GET /v1/friends->{ items: PublicUser[] }GET /v1/friends/requests->{ incoming: {id, user: PublicUser, createdAt}[], outgoing: {id, user, createdAt}[] }POST /v1/friends/requests{ userId }->{ id }(auto-accepts when the other side already requested; 409already_friends; 403blockedwhen either side blocked the other)POST /v1/friends/requests/:id/accept/.../decline->{ ok }POST /v1/friends/invite/:code->{ friend: PublicUser }(instant friendship via invite link; 404invalid_invite, 400own_invite, 403blocked)DELETE /v1/friends/:userId->{ ok }GET /v1/compare/:userId->{ me: Stats, them: Stats, user: PublicUser }(season stats, seven measures; same 404 rules asGET /v1/users/:id)
POST /v1/reports{ targetType: "user"|"club", targetId, reason: "name"|"spam"|"cheating"|"other", note?: string (<= 1000) }->{ ok: true }(404 when the target does not exist)POST /v1/blocks{ userId }->{ ok: true }(also removes the friendship and pending requests both ways)DELETE /v1/blocks/:userId->{ ok: true }GET /v1/blocks->{ items: PublicUser[] }- Blocks work in both directions: the two users never see each other in leaderboards, season boards, search, profiles, compare or club member lists; the other user's cells are returned as
owner: "other"withname: ""and a neutral grey colour; neither side can send the other friend requests or use the other's invite link.
GET /v1/leaderboards?metric=&scope=&period=&clubId=->{ entries: BoardEntry[] (top 100), me: BoardEntry | null, scopeLabel: string }city/country use the caller's homeCity/homeCountry (else the city/country of their latest drive).areaignores period for held cells and uses cells claimed in the period that are still held.trophies= trophy tiers earned in the period.clubscope usesclubIdor the caller's club. Periods are UTC calendar ranges: today, week (from Monday), month, season (calendar year). Only users with a value > 0 are ranked; ties share a rank. Users withhideFromLeaderboardsappear only on the friends board (they still see themselves everywhere);localOnlyusers never appear.meis the caller's entry (value 0 and rank after the last entry when unranked) or null when the caller is not part of the scope. World and club boards are cached for 60 seconds, so new uploads can take up to a minute to show there. A privateclubIdreturns 404 for non-members.GET /v1/leaderboards/clubs?metric=area|distance&period=->{ entries: { rank, club: Club, value }[] }(club values are the sum over current members; private clubs are listed only for their own members; cached for 60 seconds)
GET /v1/territory/cells?bbox=minLat,minLng,maxLat,maxLng&clubId=->{ cells: CellOwner[] }(max 5,000; bbox larger than ~0.6 deg returns 413zoom_in)GET /v1/territory/mine->{ held: string[], ever: string[] }GET /v1/territory/season?board=overall|stealers|gainers|losers&month=YYYY-MM&scope=world|friends->{ entries: BoardEntry[], me: BoardEntry | null }monthdefaults to the current UTC month and must lie between 2026-10 (first season) and the current month (400invalid_month). overall = cells held now (current month) or held at the end of the month (past months); stealers = steals in the month; gainers = claims + steals minus cells lost in the month; losers = cells lost in the month. Same visibility rules as the leaderboards.GET /v1/territory/cells:ownerisme,friend,club(owner is in your club) orother;clubIdlimits the result to that club's turf (404 for a private club you are not in). Cells of local-only users are never returned. 400 for malformed bboxes.
POST /v1/clubs{ name (2-40), color (#RRGGBB), description? (<= 280), isPublic }->Clubwith status 201 (leaves the previous club)GET /v1/clubs/mine->{ club: Club | null }GET /v1/clubs/search?q=->{ items: Club[] }(public clubs)GET /v1/clubs/:id->{ club: Club, members: { user: PublicUser, role: "owner"|"member", distanceM: number | null }[] }(404 for private clubs unless you are a member). Unless the member is you or your friend: local-only members are left out, members hidden from leaderboards havedistanceM: null, blocked users are left out.POST /v1/clubs/join{ code }(404invalid_invite) orPOST /v1/clubs/:id/join(public, else 403private_club) ->ClubPOST /v1/clubs/:id/leave->{ ok };DELETE /v1/clubs/:id/members/:userId(owner) ->{ ok };DELETE /v1/clubs/:id(owner) ->{ ok };PATCH /v1/clubs/:id{ name?, color?, description?, isPublic? }(owner) ->Club. Non-owners get 403not_owner.- When the owner leaves or deletes their account, ownership passes to the longest-standing member; a club without members is deleted.
GET /v1/trophies->{ items: Trophy[] }(all definitions with my progress)GET /v1/trophies/definitions(no auth) ->{ items: { id, unit, tiers: number[], title: { en, de }, description: { en, de } }[] }Ids:first_drive,distance_total(km),distance_single(km),active_days,streak,cells_held,steals,night_drive(start 00-04 local),early_bird(04-06 local),climber(m in one drive),countries,club. Single-threshold trophies (first_drive,club) top out at tier 1 withnext: null. Earned tiers never drop.Trophy.valueandnextuse the definition's unit.
POST /v1/modsmultipart:style(widebody,stance,rally,track,matte_wrap,chrome_delete,offroad,retroorcustom; custom requiresprompt),prompt?(<= 500),carLabel,color?,photo?(jpeg/png <= 8 MB) ->{ id, imageUrl, createdAt }with status 201. Errors: 429limit_reached(per-userMODS_PER_DAY, default 3), 429global_limit(server-wideMODS_GLOBAL_PER_DAY, default 100, per UTC day), 503disabled, 502generation_failed(not counted towards the daily limit), 400invalid_photo, 413photo_too_large. Generation attempts, including failed ones, are also capped at 3x the daily limits (per user and server-wide) and never refunded; reaching that cap returns the same 429 codes. Limits are checked before the upload is read.GET /v1/mods->{ items: { id, style, prompt, carLabel, imageUrl, createdAt }[], remainingToday: number }(remainingToday= min(your remaining, server-wide remaining), including the attempt caps)GET /v1/mods/:id/image-> image/png or image/jpeg withX-Content-Type-Options: nosniff(the URL carries a signed?sig=so<Image>can load it without headers). Only PNG and JPEG output is stored.DELETE /v1/mods/:id->{ ok: true }(deleting does not give back the daily allowance)
GET /i/:codeon the website redirects toroadclaim://i/<code>with a store fallback page.