Reference
Race API
Five endpoints behind the Pit Wall, plus the routes that serve images and metadata. All JSON, all same origin, all thin wrappers over the handlers in the rewards package.
Before you start
- Everything lives under
/api/raceand speaks JSON both ways. Errors are{ error: string }unless noted. - Authentication is a signed HttpOnly cookie named
rhgp_session, SameSite Lax, twelve hour lifetime, Secure in production. Send credentials from the same origin. There is no bearer token and no API key. - This is the site's own API, not a public product. Shapes can change without a version bump. If you build on it, pin a commit.
The sign-in flow
Ask for a nonce
Post the wallet address. You get back a nonce and the exact human-readable message to sign.Sign it in the wallet
The message states what signing does and that it approves no transaction and costs no gas. No SIWE dependency, no hidden payload.Exchange the signature for a session
Post the wallet and the signature. The server verifies it, deletes the nonce whether or not it matched, and sets the cookie.
Nonces live in memory for five minutes and are single use. A server restart invalidates pending logins, which is fine: the wallet just signs again.
POST /api/race/nonce
No session200400
Issues a nonce and the exact message to sign.
| Field | Meaning |
|---|---|
| wallet string | Checksummed or lowercase 0x address, 40 hex characters. |
{
"nonce": "9f1c0a7e5b2d43c88a6e0f1b7d4c2a35",
"message": "RH Grand Prix race session\n\nWallet: 0x…\nNonce: 9f1c…\n\nSigning this proves you control this address so your race results can be\ncredited to it. It does not approve any transaction and costs no gas."
}400 when the address is missing or not 40 hex characters.
POST /api/race/login
No session200400401
Verifies the signature against the message that was issued, then sets the session cookie.
| Field | Meaning |
|---|---|
| wallet string | The same address the nonce was issued for. |
| signature 0x string | personal_sign output over the returned message. |
{
"wallet": "0x…",
"tokenId": null,
"ranked": false,
"expiresAt": 1786100000000
}| Field | Meaning |
|---|---|
| tokenId number | null | The racer this wallet may drive. Null before the collection mints. |
| ranked boolean | False while tokenId is null. Unranked runs are recorded and kept out of the standings. |
| expiresAt number | Epoch milliseconds. Twelve hours out. |
400 when a field is missing. 401 when the signature does not match, the nonce expired, or no login was started for that address.
POST /api/race/submit
Session required200400401422
Submits a finished run. The body is the engine's RaceResult under a result key, unmodified.
| Field | Meaning |
|---|---|
| trackId "vault-alley" | "the-trenches" | "night-vision-swamp" | Anything else is rejected as unknown-track. |
| faction FactionId | One of the nine engine ids, lowercase and unspaced. |
| mode "grand-prix" | "time-trial" | Decides which scoring table applies. |
| laps number[] | Per-lap milliseconds. Must be non-empty. |
| totalMs number | Race clock from lights out, so it exceeds the sum of laps by the time spent reaching the line. |
| position number | 1-based finishing position. Always 1 in time trial. |
| driftCount number | Completed drift boosts. Re-derived from the replay before it is paid. |
| seed number | The RNG seed the race ran on. Single use per wallet per season. |
| inputLog string | Semicolon-separated base36 tick.buttons pairs, recorded only when the button state changes. |
{
"accepted": true,
"points": 27,
"label": "Race win",
"ranked": false,
"verifiedMs": 98456
}{
"accepted": false,
"reason": "replay-mismatch",
"detail": "Replay finished in 104233ms against a claimed 91002ms"
}400 with no result or no lap times. 401 without a session. 422 with a reason from the rejection table. Note that a rejection is a 422 with accepted: false, not an error field, because it is a verdict rather than a malformed request.
points is awarded from the replay, so it can differ from what the client previewed. verifiedMs is the replayed time, which is the number worth showing a player.
GET /api/race/standings
Public200
Public. Top 100 of the open season plus the faction championship. Always dynamic, never cached.
{
"seasonId": 1,
"standings": [
{ "rank": 1, "wallet": "0x…", "tokenId": null, "faction": "pipedog", "points": 68 }
],
"factions": [
{ "faction": "pipedog", "averagePoints": 41.5, "racers": 2 }
],
"totalRuns": 37
}Unranked runs are counted in totalRuns and appear nowhere in standings.
GET /api/race/me
Session optional200
Session optional. Signed out it returns a single field, which is what lets the Pit Wall render before anyone connects anything.
{ "signedIn": false }{
"signedIn": true,
"wallet": "0x…",
"tokenId": null,
"ranked": false,
"rank": null,
"points": 0,
"personalBests": [
{ "trackId": "vault-alley", "bestMs": 104233, "points": 18 }
]
}rank and points come from the same standings computation the public endpoint uses, so they cannot disagree with the table.
Image and metadata routes
Three routes serve the collection itself. They are unauthenticated and cacheable, and two of them exist mainly so 3,000 images never have to be copied into public/.
| Route | Returns | Notes |
|---|---|---|
| GET /metadata/[id] | ERC-721 metadata JSON | Reads the generator output in development. In production METADATA_BASE points at the pinned CID and the same helper fetches from there. Cached one hour. 404 outside 1 to 3000. |
| GET /racers/[id] | image/png | Accepts /racers/1 and /racers/1.png, because the .png form matches the IPFS path. Immutable cache. Development only: with NEXT_PUBLIC_IMAGE_BASE set, this route is never hit. |
| GET /racers/[id]/svg | image/svg+xml | The composed art with procedural animation derived from the token's own traits. Generative tokens only, IDs 1 to 2940. Legendaries and one of ones 404 here and the client falls back to the PNG. |
400 for a non-integer or out-of-range ID on all three, so a bad path fails as a bad request rather than a missing file.
curl -s localhost:3000/metadata/1 | head -5
curl -sI localhost:3000/racers/1.png | grep -i cache-control
curl -sI localhost:3000/racers/2950/svg | head -1 # legendary, expect 404