For timer companies
Timer API
Sync race rosters straight into your timing software — and push bibs, check-ins, and results back. The v1 Timer API gives your company key-authenticated access to registrations (with delta sync and pagination) plus write-back endpoints for bib/chip assignment, race-day check-in, and live results.
Authentication
Every request is authenticated with your company’s API key — a string that looks like rt_ followed by 40 hex characters. Pass it as the X-RT-Api-Key request header:
X-RT-Api-Key: rt_...— the only accepted method. Akeyquery parameter is not accepted (URLs land in proxy/CDN logs and browser history, and this key grants the full roster including minors’ dates of birth — it never belongs in a URL).
Keys are minted from your Timer Dashboard → API tab. Each company holds one key at a time; the plaintext key is shown once, right after you mint or rotate it, and can never be retrieved again — only a hash is stored. If you lose it, rotate to get a new one.
Keep your key secret
What you can access
The API is scoped by assignment: a request succeeds only for events where your company is the currently assigned timer for the current edition. When a director renews an event for a new year, the assignment clears and API access clears with it until you’re re-hired. Read responses never include payment or contact data — only the timing-scoped fields listed below. Write endpoints touch exactly the fields they document (bib, chip, check-in, results) and nothing else.
The sync loop (quick start)
The recommended integration is the classic roster-sync pattern: one full pull, then a light delta poll through race week.
Mint your key
Open your Timer Dashboard → API tab and click Create API key. Copy the key immediately — it’s shown only once.Discover the race
Call/api/v1/timer/race?race={slug}to get the sub-event ids, registration counts, and the race’s start time.Full pull, page by page
Call/api/v1/timer/registrations?race={slug}. If the response has"hasMore": true, repeat withafter=<nextCursor>untilhasMoreisfalse. Remember theserverTimeof the FIRST page.Poll for changes every ~30 seconds
Call the same endpoint withmodified_after=<lastSync>, wherelastSync= the previous poll’sserverTimeMsminus about 10 seconds (clock-skew + in-flight-write cushion). Apply the returned rows — including"status": "canceled"rows — in order, page untilhasMoreisfalse, then store the newserverTimeMsfor the next poll.Push data back as you work
POST bib/chip assignments to/api/v1/timer/bibs, race-day check-ins to/api/v1/timer/check-in, and live results to/api/v1/timer/results.
Always use serverTime, never your own clock
modified_after from the previous response’s serverTime/serverTimeMs minus ~10 seconds. Your machine’s clock can drift, and writes in flight during your poll can commit with a slightly earlier timestamp — the 10-second overlap means you may re-see a row you already have (harmless; apply it again) but you never miss one.GET /api/v1/timer/race
Race metadata plus the event’s sub-events (distances/heats), so your software can discover the sub-event ids to pass to the registrations endpoint — plus live registration and check-in counts.
- race
- Required. The event’s slug — the last part of its Run This URL (
runthis.run/e/{slug}).
curl -H "X-RT-Api-Key: rt_YOUR_API_KEY" \
"https://runthis.run/api/v1/timer/race?race=my-race-5k"Response:
{
"race": {
"slug": "my-race-5k",
"name": "My Race 5K",
"date": "2026-09-12T13:00:00.000Z",
"startDateMs": 1789218000000,
"timeZone": "America/Chicago",
"status": "live"
},
"counts": { "total": 412, "checkedIn": 88 },
"subEvents": [
{ "id": "5k", "name": "5K", "registeredCount": 305 },
{ "id": "1-mile", "name": "1 Mile Fun Run", "registeredCount": 107 }
],
"serverTime": "2026-09-12T12:41:03.512Z",
"serverTimeMs": 1789217463512
}counts.totalis the sum of the sub-events’ current-edition registration counters;counts.checkedInis a live checked-in count (it can benullif the count is momentarily unavailable — never treat that as zero).registeredCountper sub-event comes from the same cached counters the dashboard shows.
GET /api/v1/timer/registrations
The roster feed for an event — paginated, with optional delta sync via modified_after. Full pulls come back in bib-then-name order within each page; delta pages come back in modification order (apply them in order). Rosters change right up to the gun, so responses are never cached (cache-control: no-store).
Query parameters
- race
- Required. The event slug, same as above.
- event
- Optional. A sub-event id (from /race) to filter the roster to one distance or heat.
- modified_after
- Optional. Unix milliseconds (e.g.
1789217463512) or an ISO-8601 datetime (e.g.2026-09-12T12:41:03Z). Returns only registrations changed strictly AFTER that instant — including cancellations. This is the delta-sync switch. - limit
- Optional. Page size, 1–1000. Default
500. - after
- Optional. The opaque cursor from a prior response’s
nextCursor(or theX-RT-Next-Cursorheader for CSV). A cursor only works with the samemodified_aftermode it was minted in. - includeCanceled
- Optional. Canceled registrations are included by default (each row carries a
statusfield so you can filter). Pass0to drop them. Don’t drop them in a delta poll — the cancellation IS the update your software needs. - checkedInOnly
- Optional. Pass
1(ortrue) to return only athletes who’ve been checked in. - format
- Optional. Pass
csvto get a CSV download instead of JSON. Pagination still applies — see the CSV section.
curl -H "X-RT-Api-Key: rt_YOUR_API_KEY" \
"https://runthis.run/api/v1/timer/registrations?race=my-race-5k&event=5k"Response:
{
"race": {
"slug": "my-race-5k",
"name": "My Race 5K",
"date": "2026-09-12T13:00:00.000Z"
},
"generatedAt": "2026-09-12T12:41:03.512Z",
"serverTime": "2026-09-12T12:41:03.512Z",
"serverTimeMs": 1789217463512,
"count": 2,
"hasMore": false,
"nextCursor": null,
"registrations": [
{
"id": "reg_abc123",
"registrationId": "reg_abc123",
"firstName": "Jane",
"lastName": "Doe",
"gender": "F",
"dateOfBirth": "1992-04-18",
"city": "Springfield",
"state": "TN",
"subEventId": "5k",
"subEventName": "5K",
"bibNumber": 101,
"chipNum": "200145",
"checkedIn": true,
"checkedInAt": "2026-09-12T12:05:44.000Z",
"teamName": "Road Runners",
"status": "active",
"lastModified": "2026-09-12T12:05:44.000Z",
"lastModifiedMs": 1789215344000
},
...
]
}Registration fields
- id / registrationId
- The registration id (stable per registrant per edition). Both fields carry the same value — use registrationId in write-back calls.
- firstName / lastName
- The registrant's name.
- gender
- Self-reported gender, as entered at registration.
- dateOfBirth
YYYY-MM-DD, ornullif not collected.- city / state
- The registrant's city and US state.
- subEventId / subEventName
- Which distance or heat they're in.
- bibNumber
- Assigned bib, or
nullif none yet. Assign via POST/api/v1/timer/bibsor from the event dashboard. - chipNum
- Chip/transponder number, or
null. Separate from the bib — see Chip vs bib below. - checkedIn / checkedInAt
- Race-day check-in status;
checkedInAtis an ISO timestamp ornull. - teamName
- Team name, or
nullfor individual entries. - status
activeorcanceled. A canceled registration was refunded/released — pull it from your start list.- lastModified / lastModifiedMs
- When the registration last changed (ISO + epoch ms), or
nullon old rows that predate change stamping. This is the fieldmodified_aftercompares against.
Pagination
Every response carries hasMore and, when there are more pages, an opaque nextCursor. Pass it back as after to get the next page. Keep your other parameters identical between pages.
- Keep paging until
hasMoreisfalse— a filtered page (sub-event filter,checkedInOnly, canceled dropped) can return fewer rows thanlimit, even zero, while more pages still exist.hasMoreis the only end-of-data signal; an emptyregistrationsarray is not. - A cursor is mode-specific: one minted on a full pull can’t be used with
modified_after(and vice versa) — you get400 cursor-mode-mismatch. - Treat the cursor as an opaque string; its format may change.
Delta sync (modified_after)
Pass modified_after to get only registrations that changed after that instant: new signups, edits, bib/chip changes, check-ins, and cancellations (as status: "canceled" rows). The recommended loop:
- Full pull (no
modified_after), paging untilhasMoreisfalse. Save the first page’sserverTimeMsaslastSync. - Every ~30s:
GET …&modified_after={lastSync − 10000}, paging untilhasMoreisfalse. - Apply returned rows in order (upsert by
registrationId). - Set
lastSyncto the response’sserverTimeMsand repeat.
# First poll after the full pull (serverTimeMs was 1789217463512):
curl -H "X-RT-Api-Key: rt_YOUR_API_KEY" \
"https://runthis.run/api/v1/timer/registrations?race=my-race-5k&modified_after=1789217453512"- Delta pages are ordered oldest-change-first — apply them in order so the last write wins.
- The ~10-second overlap means you may re-receive rows you already applied. Upsert by
registrationIdand this is a no-op. - An empty delta response still returns a fresh
serverTimeMs— use it for the next poll. - Rows that predate change stamping (
lastModified: null) only appear in full pulls — do one full pull first, then poll.
Chip vs bib
Bibs and chips are independent fields with different rules:
- Bib (
bibNumber) — numeric, unique across the WHOLE event (not just a sub-event). The one exception: a relay team whose bib plan has “relay teams share one bib” enabled — assigning any member propagates the number to every current teammate, and teammates may share it. - Chip (
chipNum) — a free-form string (transponder ids are often alphanumeric), up to 16 characters. A chip already worn by a different active registration in the same event is rejected; there is no relay carve-out for chips. To move a chip between runners, clear it from the old runner first (or reassign both in one call — intra-batch swaps are allowed).
Write-back endpoints
Three POST endpoints let your software push data into Run This. All of them: authenticate with the same API key, require your company to be the assigned timer, are rate-limited (see the rate policy below), and use lenient batch semantics — a bad row gets a per-row error and the rest of the batch still lands. Send JSON with Content-Type: application/json.
POST /api/v1/timer/bibs
Assign or clear bibs and chips, up to 200 assignments per call.
curl -X POST -H "X-RT-Api-Key: rt_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"race": "my-race-5k",
"assignments": [
{ "registrationId": "reg_abc123", "bib": 101, "chip": "200145" },
{ "registrationId": "reg_def456", "bib": null },
{ "registrationId": "reg_ghi789", "chip": "200146" }
]
}' \
"https://runthis.run/api/v1/timer/bibs"- bib
- A positive integer (or digit string, ≤16 chars).
nullor""clears the bib. Omit the field to leave it untouched. - chip
- A string, 1–16 chars (numbers accepted and stringified).
nullor""clears. Omit to leave untouched. Each row must include at least one ofbib/chip.
Response:
{
"updated": 4,
"results": [
{ "registrationId": "reg_abc123", "ok": true, "bib": 101, "chip": "200145" },
{ "registrationId": "reg_def456", "ok": true, "bib": null, "chip": null },
{ "registrationId": "reg_ghi789", "ok": false, "bib": 55, "chip": "200099",
"error": "chip-conflict", "wearerRegistrationId": "reg_xyz000" }
]
}updatedcounts every registration written — relay propagation included, so it can exceed the number of rows you sent.- Each result row echoes the bib/chip the registration wears AFTER the call.
- On
bib-conflict/chip-conflict,wearerRegistrationIdtells you which registration currently wears the number (never their name). A row is all-or-nothing: a bib conflict drops that row’s chip write too. - Per-row errors:
registration-not-found,past-edition-read-only,canceled-registration,bib-conflict,chip-conflict.
POST /api/v1/timer/check-in
Set or undo race-day check-in, up to 200 entries per call. An entry can carry a bib and/or chip too — one call checks the runner in AND assigns their number (same rules as the bibs endpoint).
curl -X POST -H "X-RT-Api-Key: rt_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"race": "my-race-5k",
"entries": [
{ "registrationId": "reg_abc123", "checkedIn": true, "bib": 101 },
{ "registrationId": "reg_def456", "checkedIn": false }
]
}' \
"https://runthis.run/api/v1/timer/check-in"Response:
{
"updated": 2,
"results": [
{ "registrationId": "reg_abc123", "ok": true, "checkedIn": true,
"bib": 101, "chip": null },
{ "registrationId": "reg_def456", "ok": true, "checkedIn": false }
]
}- Check-ins are attributed to your company in the event’s check-in log (the director sees the timer did it).
- When a row’s check-in lands but its bib/chip is rejected, the row comes back
ok: falsewith the bib error — andcheckedInstill set. The check-in itself stuck; only the number was refused. updatedcounts check-in writes (bib propagation writes aren’t double-counted).
POST /api/v1/timer/results
Push results for ONE sub-event per call, up to 500 rows. Two modes:
upsert(the default) — the live-results pattern. Post finishers as they cross; rows are matched (byregistrationIdfirst, then bybibwithin the sub-event) and merged in place, unmatched rows are created. Omitted fields on a matched row stay untouched.replace— swap the whole sub-event’s results for exactly this payload (rows not re-asserted are deleted; other sub-events untouched).
curl -X POST -H "X-RT-Api-Key: rt_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"race": "my-race-5k",
"event": "5k",
"mode": "upsert",
"results": [
{ "registrationId": "reg_abc123", "chipTime": "21:47.3",
"gunTime": "21:52.1", "place": 1 },
{ "bib": 214, "firstName": "Sam", "lastName": "Park",
"gender": "M", "chipTime": 1352400 }
]
}' \
"https://runthis.run/api/v1/timer/results"- race / event
- Both required.
eventis the sub-event id (from/race). - registrationId / bib
- Each row needs at least one of these to be matchable. Prefer registrationId — it's stable even when bibs get reassigned.
- chipTime / gunTime
- A time string (
"21:47.3","1:02:15") or a number of milliseconds. - place
- Optional overall place (1 = winner), a positive integer.
- metricValue
- Optional. For sub-events ranked by a manual metric (laps, points, distance) instead of time.
- firstName / lastName / gender
- Optional. When a row carries a registrationId, the registration fills these (and links the runner's Run This account) when omitted.
Response:
{
"created": 1,
"updated": 1,
"total": 2,
"published": false,
"runnerNotifications": true,
"errors": [
{ "index": 2, "registrationId": "reg_bad999",
"error": "registration-not-found" }
]
}- This endpoint never publishes results. Rows land behind the event’s publish gate; you or the director publish from the dashboard.
publishedtells you whether results are currently public. runnerNotificationsis the race’s current Notify runners switch (email + push; on by default) — whentrueand results are published, rows you post alert the matched runners.errorsappears only when rows were skipped (e.g. aregistrationIdthat doesn’t exist); the rest of the batch still lands.- A concurrent import or re-rank from the dashboard returns
409 results-busy— wait a moment and retry.
Webhooks
Instead of polling for changes, register an HTTPS endpoint and Run This pushes events to your server the moment they happen. Endpoints are managed from your Timer Dashboard → API tab (company owner only, up to 5 endpoints). Each endpoint picks which events it wants and gets its own signing secret — shown once when you create it, like the API key.
Event catalog
registration.created— a new registration landed (checkout, transfer-in, or a director import).registration.updated— a registration changed (director edits, sub-event moves/transfers).registration.canceled— a registration was canceled (refund flow released the spot).checkin.updated— someone was checked in (or a check-in was undone).bib.updated— a bib or chip number was assigned, changed, or cleared.
Events fire only for races where your company is the currently assigned timer — the same scoping as the rest of the API. Result events are not in the v1 catalog yet.
Delivery format
Each delivery is a POST to your URL with a JSON body and three headers:
POST /your-endpoint HTTP/1.1
Content-Type: application/json
X-RT-Event: checkin.updated
X-RT-Delivery: whd_8Ck2fA91bXw3
X-RT-Signature: sha256=6b1f0c6b6e2f4e...
{
"id": "whd_8Ck2fA91bXw3",
"event": "checkin.updated",
"createdAt": "2026-09-12T12:05:44.000Z",
"race": "my-race-5k",
"data": {
"registrationId": "reg_abc123",
"subEventId": "5k",
"checkedIn": true,
"updatedAtMs": 1789233944000
}
}Payloads are thin references — by design
registrationId and the changed fields. When you receive one, fetch the full row from /api/v1/timer/registrations (a delta poll with your stored serverTimeMs works perfectly). This keeps personal data out of webhook traffic and your request logs.Verifying the signature
Every delivery (including test pings) is signed with your endpoint’s secret: X-RT-Signature: sha256=<HMAC-SHA256 hex of the raw body>. Recompute it over the raw bytes you received and compare before trusting the payload:
// Node.js
const crypto = require("crypto");
function verify(rawBody, signatureHeader, secret) {
const expected =
"sha256=" +
crypto.createHmac("sha256", secret).update(rawBody, "utf8").digest("hex");
return (
expected.length === signatureHeader.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signatureHeader))
);
}Retries, failures & auto-disable
- Answer with any 2xx within 10 seconds to acknowledge. Do your processing async — respond first, work later.
- Anything else (non-2xx, timeout, unreachable) is retried with exponential backoff (about 1, 2, 4, 8, then 16 minutes).
- An endpoint that fails many attempts in a row is automatically disabled; the API tab shows why. Fix your server, send a test ping, then re-enable it there.
- Deliveries can occasionally arrive more than once or out of order — treat them as “something changed, re-sync this registration,” keyed by
registrationId, and you’re naturally idempotent.
Test pings
The Send test button on the API tab POSTs a signed ping event ("race": null) to your endpoint synchronously and shows you the HTTP status it answered with — verify your signature check against it before race week.
CSV format
Add format=csv to the registrations endpoint and the same roster comes back as a UTF-8 CSV attachment (RFC-4180 quoting) with a header row — importable directly into most timing software:
curl -H "X-RT-Api-Key: rt_YOUR_API_KEY" \
"https://runthis.run/api/v1/timer/registrations?race=my-race-5k&format=csv"registration_id,first_name,last_name,gender,date_of_birth,city,state,sub_event_id,sub_event,bib,checked_in,checked_in_at,team,status,chip,last_modified
reg_abc123,Jane,Doe,F,1992-04-18,Springfield,TN,5k,5K,101,true,2026-09-12T12:05:44.000Z,Road Runners,active,200145,2026-09-12T12:05:44.000Z- New columns are only ever APPENDED (never reordered), so positional parsers keep working. The v2 additions are
status,chip, andlast_modifiedat the end. - Pagination still applies to CSV — check the
X-RT-Has-Moreresponse header and passX-RT-Next-Cursorback asafter. TheX-RT-Server-Timeheader carries the poll timestamp.
Ready-made URLs in your dashboard
Rate limits & polling policy
- Read endpoints (registrations, race): 30 requests/second sustained per key, with a burst allowance of 60. A healthy integration never gets close — poll
modified_afterevery ~30 seconds rather than re-pulling the full roster, and keep to at most 2 concurrent requests. Full re-pulls are for startup and recovery, not the steady-state loop. - Write endpoints (bibs, check-in, results): 10 requests/second sustained per key, with a burst allowance of 20. Batch your rows (up to 200 per bibs/check-in call, 500 per results call) instead of posting one row per request.
- 429 handling: exceeding either limit returns
429 rate-limitedwith aRetry-Afterheader (seconds) — wait that long, then retry. Don’t hammer through 429s: a loop that keeps hitting them is polling far faster than the data changes. - Response cache: identical read requests (same key + same query) within 5 seconds may be served from a short server-side cache — the
X-RT-Cacheresponse header sayshitormiss.serverTimeis always fresh, and the 5-second window sits inside the recommended “serverTime− 10s” delta cushion, so the sync recipe is unaffected. - On any
5xxor network error, back off (a few seconds, doubling on repeats) and resume the loop — your next delta poll picks up whatever you missed.
Errors
Errors are JSON: { "error": "..." } with a matching HTTP status.
- 400 bad-race
- The
raceparameter is missing or isn’t a valid slug. - 400 bad-modified-after
modified_afterisn’t Unix milliseconds or ISO-8601.- 400 bad-limit / bad-cursor / cursor-mode-mismatch
limitout of range, an unreadableaftercursor, or a cursor from the other (full vs delta) mode.- 400 bad-json / no-assignments / no-entries / no-results / bad-assignment-N / bad-entry-N / bad-result-N / missing-event / bad-mode
- A write body failed shape validation (N is the offending row index). Shape problems fail the whole request; data problems (unknown registration, conflicts) are per-row errors in a 200 response.
- 401 missing-or-invalid-key
- No API key was provided, or it doesn't match the rt_ key format.
- 401 unknown-key
- The key doesn't match any company — it may have been rotated or revoked.
- 403 not-the-assigned-timer
- The event exists, but your company isn't its currently assigned timer for the current edition.
- 404 event-not-found / sub-event-not-found
- No event with that slug, or no sub-event with that id.
- 409 results-busy
- Another writer (a dashboard import or re-rank) holds the results lock. Retry shortly.
- 422 too-many
- A write body exceeded its row cap (200 for bibs/check-in, 500 for results). Split the batch.
- 429 rate-limited
- Rate limit exceeded (reads 30/s burst 60; writes 10/s burst 20). Wait the
Retry-Afterseconds and retry.
Race-day integration checklist
The week before
Confirm your key works:GET /racereturns the event with the sub-events you expect. Do a full paginated pull and load the roster into your software.Assign numbers
Push bib + chip assignments viaPOST /bibs(or read the ones assigned in the dashboard). Resolve anybib-conflict/chip-conflictrows —wearerRegistrationIdtells you who holds the number.Start the delta loop
Pollmodified_afterevery ~30 seconds (serverTime − 10s recipe). Late signups, edits, and cancellations flow in automatically — watch forstatus: "canceled"and pull those from your start list.Race morning
Push check-ins as runners arrive (POST /check-in, batched). UsecheckedInOnly=1or the/racecounts to reconcile who’s on course.During and after the race
Post finishers withPOST /resultsinupsertmode as they cross; correct rows by re-posting them. Usereplacefor a final clean upload. Publishing stays a human decision in the dashboard.
Not listed on Run This yet? Start with Listing your timer company.

