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. A key query 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

Anyone with your key can read the rosters — and write bibs, check-ins, and results — for every event your company is assigned to. Don’t commit it to source control or paste it in shared docs. If it leaks, rotate it immediately from the API tab — the old key stops working the moment the new one is minted. Revoking deletes the key entirely.

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.

  1. Mint your key

    Open your Timer Dashboard → API tab and click Create API key. Copy the key immediately — it’s shown only once.
  2. Discover the race

    Call /api/v1/timer/race?race={slug} to get the sub-event ids, registration counts, and the race’s start time.
  3. Full pull, page by page

    Call /api/v1/timer/registrations?race={slug}. If the response has "hasMore": true, repeat with after=<nextCursor> until hasMore is false. Remember the serverTime of the FIRST page.
  4. Poll for changes every ~30 seconds

    Call the same endpoint with modified_after=<lastSync>, where lastSync = the previous poll’s serverTimeMs minus about 10 seconds (clock-skew + in-flight-write cushion). Apply the returned rows — including "status": "canceled" rows — in order, page until hasMore is false, then store the new serverTimeMs for the next poll.
  5. 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

Set each poll’s 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.total is the sum of the sub-events’ current-edition registration counters; counts.checkedIn is a live checked-in count (it can be null if the count is momentarily unavailable — never treat that as zero).
  • registeredCount per 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 the X-RT-Next-Cursor header for CSV). A cursor only works with the same modified_after mode it was minted in.
includeCanceled
Optional. Canceled registrations are included by default (each row carries a status field so you can filter). Pass 0 to drop them. Don’t drop them in a delta poll — the cancellation IS the update your software needs.
checkedInOnly
Optional. Pass 1 (or true) to return only athletes who’ve been checked in.
format
Optional. Pass csv to 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, or null if not collected.
city / state
The registrant's city and US state.
subEventId / subEventName
Which distance or heat they're in.
bibNumber
Assigned bib, or null if none yet. Assign via POST /api/v1/timer/bibs or 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; checkedInAt is an ISO timestamp or null.
teamName
Team name, or null for individual entries.
status
active or canceled. A canceled registration was refunded/released — pull it from your start list.
lastModified / lastModifiedMs
When the registration last changed (ISO + epoch ms), or null on old rows that predate change stamping. This is the field modified_after compares 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 hasMore is false — a filtered page (sub-event filter, checkedInOnly, canceled dropped) can return fewer rows than limit, even zero, while more pages still exist. hasMore is the only end-of-data signal; an empty registrations array is not.
  • A cursor is mode-specific: one minted on a full pull can’t be used with modified_after (and vice versa) — you get 400 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:

  1. Full pull (no modified_after), paging until hasMore is false. Save the first page’s serverTimeMs as lastSync.
  2. Every ~30s: GET …&modified_after={lastSync − 10000}, paging until hasMore is false.
  3. Apply returned rows in order (upsert by registrationId).
  4. Set lastSync to the response’s serverTimeMs and 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 registrationId and 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). null or "" clears the bib. Omit the field to leave it untouched.
chip
A string, 1–16 chars (numbers accepted and stringified). null or "" clears. Omit to leave untouched. Each row must include at least one of bib / 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" }
  ]
}
  • updated counts 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, wearerRegistrationId tells 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: false with the bib error — and checkedIn still set. The check-in itself stuck; only the number was refused.
  • updated counts 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 (by registrationId first, then by bib within 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. event is 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. published tells you whether results are currently public.
  • runnerNotifications is the race’s current Notify runners switch (email + push; on by default) — when true and results are published, rows you post alert the matched runners.
  • errors appears only when rows were skipped (e.g. a registrationId that 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

Webhook bodies never carry names, contact info, or any other registrant data — just the 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, and last_modified at the end.
  • Pagination still applies to CSV — check the X-RT-Has-More response header and pass X-RT-Next-Cursor back as after. The X-RT-Server-Time header carries the poll timestamp.

Ready-made URLs in your dashboard

Your Timer Dashboard → API tab lists copy-ready URLs for every race you’re assigned to — per sub-event, JSON or CSV, full pull or delta — so you rarely need to build these by hand.

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_after every ~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-limited with a Retry-After header (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-Cache response header says hit or miss. serverTime is always fresh, and the 5-second window sits inside the recommended “serverTime − 10s” delta cushion, so the sync recipe is unaffected.
  • On any 5xx or 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 race parameter is missing or isn’t a valid slug.
400 bad-modified-after
modified_after isn’t Unix milliseconds or ISO-8601.
400 bad-limit / bad-cursor / cursor-mode-mismatch
limit out of range, an unreadable after cursor, 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-After seconds and retry.

Race-day integration checklist

  1. The week before

    Confirm your key works: GET /race returns the event with the sub-events you expect. Do a full paginated pull and load the roster into your software.
  2. Assign numbers

    Push bib + chip assignments via POST /bibs (or read the ones assigned in the dashboard). Resolve any bib-conflict / chip-conflict rows — wearerRegistrationId tells you who holds the number.
  3. Start the delta loop

    Poll modified_after every ~30 seconds (serverTime − 10s recipe). Late signups, edits, and cancellations flow in automatically — watch for status: "canceled" and pull those from your start list.
  4. Race morning

    Push check-ins as runners arrive (POST /check-in, batched). Use checkedInOnly=1 or the /race counts to reconcile who’s on course.
  5. During and after the race

    Post finishers with POST /results in upsert mode as they cross; correct rows by re-posting them. Use replace for a final clean upload. Publishing stays a human decision in the dashboard.

Not listed on Run This yet? Start with Listing your timer company.