{"openapi":"3.1.0","info":{"title":"DragonBoat Hub Race Schedule API","version":"1.0.0","summary":"Read and write a regatta's running order, lane draw and results.","description":"Lets timing and regatta-management software drive a race schedule hosted on DragonBoat Hub.\n\n**Concurrency.** Writes are 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.\n\n**No deletes.** The API can create and update; it cannot remove. Deleting a race, a team or a schedule stays a human action in the app. A misconfigured client can corrupt data here, but it cannot destroy it.\n\n**Your ids, not ours.** Every race and team is addressed by an `externalId` you choose. 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.\n\n**Adoption.** When an `externalId` is unknown, the API looks for an existing row with the same natural key — `raceNumber` for a race, `name` for a team — and claims it, stamping your id onto it. This is what lets you push crews into an event whose teams were already seeded from club registrations, instead of colliding with them.\n\n**No personal data.** Race structure, teams, lanes, times, places and statuses only. Paddler names and seat plans are not exposed and will not be added to this version.","contact":{"name":"DragonBoat Hub","url":"https://dragonboathub.de/developers","email":"flechtner@robocitrus.com"}},"servers":[{"url":"https://data.dragonboathub.de/v1","description":"DragonBoat Hub public API"}],"security":[{"bearerAuth":[]}],"tags":[{"name":"Schedule","description":"The regatta's running order."},{"name":"Teams","description":"The crews competing at the event."},{"name":"Races","description":"Heats, finals and their timing."},{"name":"Results","description":"Lane draw and finish."}],"paths":{"/events/{eventId}/schedule":{"parameters":[{"$ref":"#/components/parameters/EventId"}],"get":{"tags":["Schedule"],"operationId":"getSchedule","summary":"Read the whole running order","description":"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.","responses":{"200":{"description":"The schedule.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Schedule"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"}}},"post":{"tags":["Schedule"],"operationId":"createSchedule","summary":"Create the schedule if the event has none","description":"Idempotent. Returns `201` with a fresh schedule, or `200` with the one that already exists — retrying after a timeout is safe.\n\nAll 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.","requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScheduleCreate"},"example":{"laneScheme":["1","2","3","4","5","6"],"defaultResultMode":"timed","defaultResultVisibility":"full"}}}},"responses":{"200":{"description":"The schedule already existed and is returned unchanged.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScheduleSummary"}}}},"201":{"description":"Created.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScheduleSummary"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/ValidationFailed"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/events/{eventId}/teams":{"parameters":[{"$ref":"#/components/parameters/EventId"}],"get":{"tags":["Teams"],"operationId":"listTeams","summary":"List the crews at this event","responses":{"200":{"description":"The crew list.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TeamList"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"}}},"put":{"tags":["Teams"],"operationId":"upsertTeams","summary":"Create or update crews","description":"Matches on `externalId`, then adopts an existing crew with the same `name`. Teams seeded from club registrations carry no `externalId`, so your first push claims them rather than failing on the unique name index.\n\n`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.\n\nEach returned crew carries an `outcome` telling you which of the three paths it took.","requestBody":{"required":true,"content":{"application/json":{"schema":{"oneOf":[{"type":"array","items":{"$ref":"#/components/schemas/TeamWrite"}},{"type":"object","properties":{"teams":{"type":"array","items":{"$ref":"#/components/schemas/TeamWrite"}}},"required":["teams"]}]},"example":{"teams":[{"externalId":"crew-101","name":"RC Hansa I"},{"externalId":"crew-102","name":"RC Hansa II","boatIndex":2}]}}}},"responses":{"200":{"description":"The crew list after the write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TeamListWithOutcome"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/Conflict"},"422":{"$ref":"#/components/responses/ValidationFailed"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/events/{eventId}/races":{"parameters":[{"$ref":"#/components/parameters/EventId"}],"put":{"tags":["Races"],"operationId":"upsertRaces","summary":"Create or update races","description":"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.\n\nAdoption matches on `raceNumber`, the one identifier a schedule built by hand and a schedule pushed by software agree on.\n\nOmitted 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.","requestBody":{"required":true,"content":{"application/json":{"schema":{"oneOf":[{"type":"array","items":{"$ref":"#/components/schemas/RaceWrite"}},{"type":"object","properties":{"races":{"type":"array","items":{"$ref":"#/components/schemas/RaceWrite"}}},"required":["races"]}]},"example":{"races":[{"externalId":"heat-12","raceNumber":12,"title":"Mixed 200m Heat","scheduledAt":"2026-05-16T12:20:00Z","distanceMeters":200,"boatClass":"Mixed","round":"Heat"}]}}}},"responses":{"200":{"description":"The running order after the write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RaceListWithOutcome"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/Conflict"},"422":{"$ref":"#/components/responses/ValidationFailed"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/events/{eventId}/races/{externalId}/entries":{"parameters":[{"$ref":"#/components/parameters/EventId"},{"$ref":"#/components/parameters/RaceExternalId"}],"put":{"tags":["Results"],"operationId":"setEntries","summary":"Set the lane draw for one race","description":"Send the complete draw. A lane with no `teamExternalId` is emptied but kept, so the race keeps its start-position structure.\n\nThe crew must already exist — push to `/teams` first. `positionLabel` defaults to the race's lane scheme, then the schedule's, then the position number.\n\nSeat 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.","requestBody":{"required":true,"content":{"application/json":{"schema":{"oneOf":[{"type":"array","items":{"$ref":"#/components/schemas/LaneWrite"}},{"type":"object","properties":{"lanes":{"type":"array","items":{"$ref":"#/components/schemas/LaneWrite"}}},"required":["lanes"]}]},"example":{"lanes":[{"position":1,"teamExternalId":"crew-101"},{"position":2,"teamExternalId":"crew-102"},{"position":3}]}}}},"responses":{"200":{"description":"The race with its lanes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Race"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/Conflict"},"422":{"$ref":"#/components/responses/ValidationFailed"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/events/{eventId}/races/{externalId}/results":{"parameters":[{"$ref":"#/components/parameters/EventId"},{"$ref":"#/components/parameters/RaceExternalId"}],"put":{"tags":["Results"],"operationId":"setResults","summary":"Post the finish for one race","description":"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.\n\nIn 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.\n\n`status` defaults to `finished`. Use `dns`, `dnf` or `dsq` where they apply.\n\nPosting 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.","requestBody":{"required":true,"content":{"application/json":{"schema":{"oneOf":[{"type":"array","items":{"$ref":"#/components/schemas/ResultWrite"}},{"type":"object","properties":{"results":{"type":"array","items":{"$ref":"#/components/schemas/ResultWrite"}}},"required":["results"]}]},"example":{"results":[{"position":1,"finishTimeMillis":51230},{"position":2,"finishTimeMillis":52880},{"position":3,"status":"dnf"}]}}}},"responses":{"200":{"description":"The race with its results and derived places.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Race"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/ValidationFailed"},"429":{"$ref":"#/components/responses/RateLimited"}}}}},"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","description":"An API key minted in the DragonBoat Hub app under Account settings → API keys. Send it as `Authorization: Bearer dbh_live_…`.\n\nKeys 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.\n\nKeys are shown once at creation and cannot be recovered. 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."}},"parameters":{"EventId":{"name":"eventId","in":"path","required":true,"description":"The DragonBoat Hub event id. Find it in the app's event URL.","schema":{"type":"integer","format":"int64"}},"RaceExternalId":{"name":"externalId","in":"path","required":true,"description":"Your own identifier for the race, as sent to `PUT /races`. Percent-encode it if it contains slashes or spaces.","schema":{"type":"string"}}},"responses":{"Unauthorized":{"description":"Missing, malformed, unknown or revoked key.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"NotFound":{"description":"The event, schedule or race does not exist — or is not visible to this key's owner. The two are deliberately indistinguishable: an id you may not see should not be confirmable as existing.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"Conflict":{"description":"The request contradicts stored state — a duplicate race number, a crew already drawn into another lane of the same race.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"ValidationFailed":{"description":"The payload is well-formed JSON but wrong. `field` names the offending property, including its index in a list.","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"RateLimited":{"description":"600 reads or 120 writes per minute per key, as a sliding window. `Retry-After` and the `X-RateLimit-*` headers say when to come back. Note that a 429 counts against your own window: a client ignoring `Retry-After` keeps itself locked out.","headers":{"Retry-After":{"schema":{"type":"integer"}},"X-RateLimit-Limit":{"schema":{"type":"integer"}},"X-RateLimit-Remaining":{"schema":{"type":"integer"}},"X-RateLimit-Reset":{"schema":{"type":"integer"}}},"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}},"schemas":{"Problem":{"type":"object","description":"RFC 9457 problem detail. Branch on `code`, which is stable; `title` and `detail` are prose and may be reworded.","properties":{"type":{"type":"string","format":"uri"},"title":{"type":"string"},"status":{"type":"integer"},"detail":{"type":"string"},"code":{"type":"string","enum":["missing_credentials","invalid_credentials","forbidden","event_not_found","schedule_not_found","race_not_found","route_not_found","method_not_allowed","payload_too_large","malformed_json","validation_failed","conflict","rate_limited","internal_error"]},"field":{"type":["string","null"],"description":"Set on `validation_failed` only."}},"required":["type","title","status","code"]},"ResultMode":{"type":"string","enum":["timed","placesOnly"],"description":"`timed` derives places from finish times. `placesOnly` means the organiser numbers the field by hand and no times are stored."},"ResultVisibility":{"type":"string","enum":["hidden","placesOnly","full"],"description":"What the club wants shown publicly. The API always returns full data regardless; honour this if you render a spectator-facing view."},"EntryStatus":{"type":"string","enum":["assigned","finished","dns","dnf","dsq"]},"ScheduleCreate":{"type":"object","properties":{"laneScheme":{"type":["array","null"],"items":{"type":"string"},"description":"Ordered start-position labels, e.g. `[\"1\",\"2\",\"3\"]` or `[\"Window\",\"Wall\"]`. Null or absent means the venue has no lane structure."},"defaultResultMode":{"$ref":"#/components/schemas/ResultMode"},"defaultResultVisibility":{"$ref":"#/components/schemas/ResultVisibility"}}},"ScheduleSummary":{"type":"object","properties":{"eventId":{"type":"integer"},"status":{"type":"string","enum":["draft","published"]},"laneScheme":{"type":["array","null"],"items":{"type":"string"}},"defaultResultMode":{"$ref":"#/components/schemas/ResultMode"},"defaultResultVisibility":{"$ref":"#/components/schemas/ResultVisibility"},"updatedAt":{"type":"string","format":"date-time"}}},"Schedule":{"allOf":[{"$ref":"#/components/schemas/ScheduleSummary"},{"type":"object","properties":{"eventName":{"type":"string"},"timezone":{"type":["string","null"],"description":"IANA zone of the venue. All timestamps are UTC; this is how the app renders local start times and groups races into days."},"races":{"type":"array","items":{"$ref":"#/components/schemas/Race"}}}}]},"Race":{"type":"object","properties":{"externalId":{"type":["string","null"]},"raceNumber":{"type":"integer"},"title":{"type":["string","null"]},"scheduledAt":{"type":"string","format":"date-time"},"distanceMeters":{"type":["integer","null"]},"boatClass":{"type":["string","null"]},"round":{"type":["string","null"]},"divisionLabel":{"type":["string","null"]},"resultMode":{"$ref":"#/components/schemas/ResultMode"},"resultVisibility":{"oneOf":[{"$ref":"#/components/schemas/ResultVisibility"},{"type":"null"}],"description":"Null inherits the schedule default."},"laneScheme":{"type":["array","null"],"items":{"type":"string"}},"notes":{"type":["string","null"]},"lanes":{"type":"array","items":{"$ref":"#/components/schemas/Lane"}}}},"Lane":{"type":"object","properties":{"position":{"type":"integer"},"positionLabel":{"type":"string"},"teamExternalId":{"type":["string","null"]},"teamName":{"type":["string","null"]},"status":{"$ref":"#/components/schemas/EntryStatus"},"finishTimeMillis":{"type":["integer","null"]},"finishPlace":{"type":["integer","null"]},"penaltyMillis":{"type":["integer","null"]}}},"RaceWrite":{"type":"object","required":["externalId","raceNumber"],"properties":{"externalId":{"type":"string","maxLength":128},"raceNumber":{"type":"integer","minimum":1},"title":{"type":"string","maxLength":200},"scheduledAt":{"type":"string","format":"date-time","description":"Required when creating. Must carry an explicit UTC offset."},"distanceMeters":{"type":"integer","minimum":1},"boatClass":{"type":"string","maxLength":60},"round":{"type":"string","maxLength":60},"divisionLabel":{"type":"string","maxLength":60},"resultMode":{"$ref":"#/components/schemas/ResultMode"},"resultVisibility":{"$ref":"#/components/schemas/ResultVisibility"},"laneScheme":{"type":"array","items":{"type":"string"}},"notes":{"type":"string","maxLength":500}}},"Team":{"type":"object","properties":{"externalId":{"type":["string","null"]},"name":{"type":"string"},"clubId":{"type":["string","null"]},"boatIndex":{"type":["integer","null"]},"sortOrder":{"type":"integer"},"adopted":{"type":"boolean","description":"True when this crew existed before your sync and was claimed by it."}}},"TeamWrite":{"type":"object","required":["externalId","name"],"properties":{"externalId":{"type":"string","maxLength":128},"name":{"type":"string","maxLength":120},"clubId":{"type":"string","maxLength":64},"boatIndex":{"type":"integer","minimum":1},"sortOrder":{"type":"integer"}}},"TeamList":{"type":"object","properties":{"teams":{"type":"array","items":{"$ref":"#/components/schemas/Team"}}}},"TeamListWithOutcome":{"type":"object","properties":{"teams":{"type":"array","items":{"allOf":[{"$ref":"#/components/schemas/Team"},{"type":"object","properties":{"outcome":{"$ref":"#/components/schemas/UpsertOutcome"}}}]}}}},"RaceListWithOutcome":{"type":"object","properties":{"races":{"type":"array","items":{"allOf":[{"$ref":"#/components/schemas/Race"},{"type":"object","properties":{"outcome":{"$ref":"#/components/schemas/UpsertOutcome"}}}]}}}},"UpsertOutcome":{"type":"string","enum":["matched","adopted","inserted"],"description":"`matched` — found by your externalId. `adopted` — an existing row with the same natural key was claimed and now carries your id. `inserted` — newly created."},"LaneWrite":{"type":"object","required":["position"],"properties":{"position":{"type":"integer","minimum":1},"positionLabel":{"type":"string","maxLength":40},"teamExternalId":{"type":["string","null"],"description":"Omit or null to empty the lane while keeping it."}}},"ResultWrite":{"type":"object","required":["position"],"properties":{"position":{"type":"integer","minimum":1},"status":{"$ref":"#/components/schemas/EntryStatus"},"finishTimeMillis":{"type":"integer","minimum":0,"description":"Timed races only."},"finishPlace":{"type":"integer","minimum":1,"description":"Places-only races. In a timed race, places are derived."},"penaltyMillis":{"type":"integer","minimum":0}}}}}}