Developers

Race Schedule
API

Drive a regatta's running order, lane draw and results from your own timing software. Results you post reach the crews following that race within a minute.

OpenAPI 3.1 spec ↓https://data.dragonboathub.de/v1

Start here

Two calls answer the questions everything else depends on: whose key you are holding, and which regattas it may write to. Between them they are also the only way to tell a bad key apart from a wrong event id — every other route answers 404 for both.

# Check the key and see who it belongs to
curl -H "Authorization: Bearer $DBH_KEY" https://data.dragonboathub.de/v1/me
# {"userId":"482","displayName":"Maximilian F.",
#  "keyLabel":"Photo-Finish Laptop","keyPrefix":"dbh_live_a1b2c3d4"}

# List the regattas this key may write to
curl -H "Authorization: Bearer $DBH_KEY" https://data.dragonboathub.de/v1/events
# {"events":[{"id":1234,"name":"Hansa-Cup 2026",
#             "startDate":"2026-07-12","endDate":"2026-07-12",
#             "timezone":"Europe/Berlin","city":"Hamburg","countryCode":"DE",
#             "hasSchedule":true,"scheduleStatus":"draft"}]}

The list carries the events your club role covers plus any you created yourself, newest first, past ones included. It is re-checked on every request, so an event can leave it without your key changing — and if you may manage nothing, you get an empty list rather than an error.

Quickstart

Create a key in the DragonBoat Hub app under Account settings → API keys. You need permission to manage a race schedule in at least one club. The key is shown once and cannot be recovered — copy it before you close the screen.

# Read a regatta's running order
curl -H "Authorization: Bearer $DBH_KEY" \
  https://data.dragonboathub.de/v1/events/1234/schedule

# Push a heat
curl -X PUT -H "Authorization: Bearer $DBH_KEY" \
  -H "Content-Type: application/json" \
  -d '{"races":[{
        "externalId":"heat-12",
        "raceNumber":12,
        "title":"Mixed 200m Heat",
        "scheduledAt":"2026-05-16T12:20:00Z"
      }]}' \
  https://data.dragonboathub.de/v1/events/1234/races

# Post the finish
curl -X PUT -H "Authorization: Bearer $DBH_KEY" \
  -H "Content-Type: application/json" \
  -d '{"results":[
        {"position":1,"finishTimeMillis":51230},
        {"position":2,"finishTimeMillis":52880}
      ]}' \
  https://data.dragonboathub.de/v1/events/1234/races/heat-12/results

# Let the crews see it — a new schedule is a draft until you do
curl -X POST -H "Authorization: Bearer $DBH_KEY" \
  https://data.dragonboathub.de/v1/events/1234/schedule/publish

The event id comes from GET /v1/events above; it is also the number in the app's event URL if you would rather read it off by hand. If the event has no schedule yet, POST to the same path first — and POST to /schedule/publish when you are done, or the running order stays a draft the crews cannot see.

Four things to know

Read these before you write anything. Each one will otherwise surprise you in production.

Last-write-wins

There is no version check, no If-Match and no 409 on a concurrent edit. If a manager edits the schedule in the app while your client pushes a stale copy, your copy wins silently. Push what changed, not what you last read.

No deletes

The API can create and update; it cannot remove. Deleting a race, a crew or a schedule stays a human action in the app. A misconfigured client can corrupt data here, but it cannot destroy it.

Your ids, not ours

Every race and crew is addressed by an externalId you choose. Our internal identifiers appear in no request body. A repeated push is a no-op rather than a duplicate, so retrying after a timeout is always safe.

Adoption

When an externalId is unknown, we look for an existing row with the same natural key — raceNumber for a race, name for a crew — and claim it, stamping your id onto it. That is what lets you push crews into an event whose teams were already seeded from club registrations.

Authentication

Send your key as a bearer token on every request.

Authorization: Bearer dbh_live_…
  • Keys belong to a person, not to a club or a machine, and carry exactly that person's permissions — re-checked on every request. If they lose the race-schedule role or leave the club, the key stops working for it with no revocation step.
  • Keys are shown once at creation and cannot be recovered. We store only a hash.
  • There is no CORS. This API is not callable from a browser, and a key placed in front-end JavaScript is a key you have published.
  • A key can be revoked at any time from the app, and revocation takes effect immediately.

Reference

Generated from the OpenAPI document, so it cannot drift from what the server actually does. Four reads and six writes — there is no DELETE anywhere.

GET/me

Who this key belongs to

Confirms the key works and names the person behind it, without touching an event. Use it for a connection test: every other route answers 404 for an event you may not see, so a bad key and a wrong event id are otherwise indistinguishable.

No e-mail address is returned, here or anywhere else. displayName is the name its owner set in the app, and is meant for showing which account a desktop client is connected as.

GET/events

The events this key may manage

Every event whose race schedule the key's owner may manage — the ones their club role covers, plus any they created themselves. Ordered by startDate, newest first, and capped at 200 with no pagination.

Past events are included on purpose: results get typed up days later and a running order gets corrected after the fact.

An owner who may manage nothing gets an empty list and a 200, never a 403. Permissions are re-checked on every request, so an event can leave this list without your key changing.

GET/events/{eventId}/schedule

Read the whole running order

Returns the schedule with every race and lane, including draft schedules and unmasked finish times. resultVisibility reports what the club configured for public display — respect it if you render a spectator-facing scoreboard.

POST/events/{eventId}/schedule

Create the schedule if the event has none

Idempotent. Returns 201 with a fresh schedule, or 200 with the one that already exists — retrying after a timeout is safe.

All three settings default, because the app asks about them deliberately and a machine caller answers none of them: no lane scheme, timed results, full visibility. Send them explicitly if the venue is not a lane course.

POST/events/{eventId}/schedule/publish

Make the running order visible to the crews

POST /schedule creates a draft, which only the event's managers can see. Until you publish, a complete regatta — every race, every lane, every result — reaches nobody following the event.

Idempotent: publishing an already-published schedule is a 200, not an error. 404 if the event has no schedule yet; create one first.

There is no un-publish. Taking a running order back off the crews' phones stays a human action in the app.

PUT/events/{eventId}/schedule/delay

Report that a race day is running late

Sets the offset for one race day, so every race of that day which has not been scored is re-estimated at scheduledAt + delayMinutes.

Per day and not per schedule: a regatta spans several days and each slips on its own. venueDay is YYYY-MM-DD in the venue's zone — the same key the app groups races by, and deliberately not derived from a UTC date, which lands on the wrong day across a DST boundary.

Signed, because a day can also run early. Zero clears the offset rather than storing a zero.

This is the one write here that reaches people within the minute: the schedule-delayed push goes to the crews following that day. Setting it on a regatta that has already finished is silent all the same — the notification task only looks at a 30-hour window.

GET/events/{eventId}/teams

List the crews at this event

PUT/events/{eventId}/teams

Create or update crews

Matches on externalId, then adopts an existing crew with the same name — but only one that carries no externalId of its own. Teams seeded from club registrations carry none, so your first push claims them rather than failing on the unique name index. A crew already held under a different externalId is never adopted: your row is treated as new, hits UNIQUE (eventId, name), and comes back as a 409. Renaming means pushing the new name under the externalId that already owns it.

clubId, boatIndex and sortOrder are preserved when omitted: they tie a crew to a club on this platform and to the registration it came from, and a caller that has never heard of either should not erase both by pushing a name.

Each returned crew carries an outcome telling you which of the three paths it took.

Two failures to tell apart: the same externalId twice in one payload is a 422 with field set to teams[i].externalId, and a name already held under a different externalId is a 409 with no field.

PUT/events/{eventId}/races

Create or update races

One request, one transaction. Race numbers must be unique across the whole schedule once your payload is applied — not just within the payload — and a violation rejects the entire request rather than leaving a half-written running order.

Adoption matches on raceNumber, the one identifier a schedule built by hand and a schedule pushed by software agree on — but only for a race that carries no externalId yet. A race already held under a different externalId is never adopted; your row is treated as new and then collides on the race number, which is a 409. To renumber, push the new number under the externalId that already owns the race.

Omitted fields are preserved on an existing race. scheduledAt is required when creating one and must carry an explicit UTC offset — a naive timestamp is rejected rather than guessed at.

A race number repeated *within* your payload is a 422 naming races[i].raceNumber. One that collides with a race already in the schedule but absent from your payload is a 409 with no fielddetail names the number, and you will have to find it yourself.

PUT/events/{eventId}/races/{externalId}/entries

Set the lane draw for one race

Send the complete draw. A lane with no teamExternalId is emptied but kept, so the race keeps its start-position structure.

The crew must already exist — push to /teams first. positionLabel defaults to the race's lane scheme, then the schedule's, then the position number.

Seat plans and progression rules cannot be set here. Both belong to the app, and a caller that forgot to echo them back would otherwise erase a lineup by re-pushing a draw.

PUT/events/{eventId}/races/{externalId}/results

Post the finish for one race

Send every lane you have a result for in a single call. A place is a statement about the whole field — a time in lane 5 can demote lanes 1 through 4 — so posting lanes one at a time makes every intermediate state a ranking nobody entered, visible to everyone reading the live schedule.

In a timed race, places are derived from the times: send finishTimeMillis and leave finishPlace alone. In a placesOnly race, send finishPlace and no time. Mixing them is rejected rather than silently dropped.

status defaults to finished. Use dns, dnf or dsq where they apply.

A result contradicting the race's mode is a 422 with field set to results — the whole list, not the offending lane. The rule belongs to the race, so there is no one element to blame.

Posting a result for a race scheduled near the current time notifies the crews following it, within about a minute. Backfilling a finished regatta does not.

Errors

Every non-2xx is an RFC 9457 application/problem+json body. Branch on code, which is stable; title and detail are prose and may be reworded. Validation failures also carry field, naming the offending property including its index in a list.

{
  "type":   "https://dragonboathub.de/developers/errors/validation-failed",
  "title":  "Request failed validation",
  "status": 422,
  "detail": "Must carry an explicit UTC offset.",
  "code":   "validation_failed",
  "field":  "races[0].scheduledAt"
}
missing_credentialsinvalid_credentialsforbiddenevent_not_foundschedule_not_foundrace_not_foundroute_not_foundmethod_not_allowedpayload_too_largemalformed_jsonvalidation_failedconflictrate_limitedinternal_error

Rate limits

Per key, as a sliding window: 600 reads and 120 writes per minute. Responses carry X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset; a 429 adds Retry-After.

A 429 counts against your own window, so a client that ignores Retry-After keeps itself locked out. Back off rather than retrying tighter.

Building something?

The API covers the race schedule today. If you need something it does not do yet — or you hit a case the docs do not answer — write, and it will get looked at properly.

flechtner@robocitrus.com