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/race and 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

  1. Ask for a nonce

    Post the wallet address. You get back a nonce and the exact human-readable message to sign.
  2. 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.
  3. 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.

FieldMeaning
wallet
string
Checksummed or lowercase 0x address, 40 hex characters.
200 OK
{
  "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.

FieldMeaning
wallet
string
The same address the nonce was issued for.
signature
0x string
personal_sign output over the returned message.
200 OK, plus Set-Cookie: rhgp_session
{
  "wallet": "0x…",
  "tokenId": null,
  "ranked": false,
  "expiresAt": 1786100000000
}
FieldMeaning
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.
Response fields.

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.

FieldMeaning
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.
result: the object the engine hands to onFinish.
200 OK
{
  "accepted": true,
  "points": 27,
  "label": "Race win",
  "ranked": false,
  "verifiedMs": 98456
}
422 Unprocessable
{
  "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.

200 OK
{
  "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.

200 OK, signed out
{ "signedIn": false }
200 OK, signed in
{
  "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/.

RouteReturnsNotes
GET /metadata/[id]ERC-721 metadata JSONReads 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/pngAccepts /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]/svgimage/svg+xmlThe 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.

try it
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