Repository navigation
sailsys api reference
sail-jinx 2.0 is standalone. It has no SailSys client, no credentials, and makes no outbound network calls. Results reach SailSys because a human reads a printed report and types them in.
This document is kept as a record of the reverse-engineering work behind the pre-2.0 integration, which is preserved on the
backed-by-sailsysbranch. Nothing here describes current behaviour, and none of these endpoints are called by any code onmain.If you are looking for how sail-jinx works now, start with Home and the Jinx handicap.
Reverse-engineered from HAR capture of app.sailsys.com.au. Sufficient to implement auth and handicap update flows in Java.
| Surface | Base URL |
|---|---|
| API | https://api.sailsys.com.au/api/v1 |
| App (browser) | https://app.sailsys.com.au |
All API calls are HTTPS. The API is served separately from the Angular front end, with CORS configured to allow https://app.sailsys.com.au.
SailSys uses a custom session token, not JWT Bearer or cookies. The token is a plain opaque string returned in the login response body and passed on every subsequent request as a custom request header named sessiontoken.
There is no XSRF token, no cookie, no Authorization: Bearer header. The sessiontoken header is the sole credential on all authenticated calls.
POST https://api.sailsys.com.au/api/v1/auth
Request headers (required on every API call - see Section 3):
Content-Type: application/json
app: 0
apptimezone: Australia/Sydney
apptimezoneoffset: 10
sessiontoken: ← empty string on login
Request body:
{
"email": "user@example.com",
"password": "secret",
"twoFactorAuthCode": ""
}Response (HTTP 200):
{
"data": {
"user": {
"id": 956,
"firstname": "Greg",
"surname": "Wilkins",
"email": "gregw@wiltel.org",
"isSysAdmin": false,
"role": 2,
"clubAdminPositions": [
{
"club": {
"id": 23,
"shortName": "MYC",
"longName": "Manly Yacht Club"
},
"adminLevel": 0
}
]
},
"sessionToken": "DowEl7dKeAOpXffAbPROSWduz4HPb4pUcrm7OIGxN8MPxaxARX",
"twoFactorAuthRequired": false
},
"result": "success",
"httpCode": 200,
"errorMessage": null
}Extract data.sessionToken and include it in sessiontoken header on all subsequent requests.
These headers must be present on every API call, authenticated or not.
| Header | Value | Notes |
|---|---|---|
Content-Type |
application/json |
Always |
app |
0 |
Constant; identifies the web client |
apptimezone |
Australia/Sydney |
IANA timezone of the client |
apptimezoneoffset |
10 |
UTC offset in hours (adjust for DST: 11 in summer) |
sessiontoken |
<token> |
Empty string before login; token thereafter |
No Authorization header. No cookies required.
All endpoints return the same outer envelope:
{
"data": { ... },
"result": "success",
"httpCode": 200,
"errorMessage": null,
"errorObject": null,
"log": null
}Check result == "success" and httpCode == 200 for success. On failure, errorMessage is populated.
| Entity | ID | Notes |
|---|---|---|
| Club | 23 |
MYC club ID |
| Series (2026 Winter) | 4915 |
Changes each season |
| Division Div 1 | 13779 |
|
| Division Div 2 | 13780 |
|
| Division Yachts | 13778 |
|
| Handicap definition (PHS) | 5 |
shortName: "PHS", fullName: "SailSys PHS"
|
Boat IDs and race IDs are per-boat and per-race and must be fetched dynamically (see Section 7).
This is the primary write operation needed for the MYC twilight handicap system.
PUT https://api.sailsys.com.au/api/v1/series/{seriesId}/entries
Note: despite being a data fetch, the app uses PUT here (not GET), presumably because the filter payload is too complex for query params.
Request body:
{
"pageSize": 1000,
"pageNumber": 0,
"totalItemsCount": 0,
"hasNextPage": true,
"items": [],
"divisionId": null,
"subSeriesId": null,
"requiredStages": null,
"handicapId": null
}Response: Array of entry objects under data. Each entry includes:
-
boatId- integer, use as path parameter for handicap updates -
boatName,boatSailNumber -
divisionId,divisionName -
handicaps[].value- current handicap TCF value -
handicaps[].definition.id- handicap definition id (5 for PHS)
PUT https://api.sailsys.com.au/api/v1/series/{seriesId}/entries/{boatId}/handicaps
Request body (array, even for a single handicap):
[
{
"createdFromId": null,
"definitionId": 15,
"value": 1.0100,
"spinnakerType": 1,
"appliesFrom": null
}
]Field notes:
-
definitionId: the handicap definition id —5for PHS at MYC,15for MYC TCF. -
value: the new TCF as a decimal (e.g.1.0100). -
spinnakerType: must match the existing handicap row for the (boat, definition) pair. Read it fromhandicap.currentHandicaps[i].spinnakerTypein the entrants payload — don't guess. The MYC TCF row uses1; the PHS row may use a different value depending on division config.- SailSys returns a misleading 400 if
spinnakerTypedoesn't match:"Handicaps should be greater than 0, please try again."— actually meaning "no row matched (definitionId, spinnakerType)".
- SailSys returns a misleading 400 if
-
createdFromId: null (not copying from another handicap). -
appliesFrom: null (effective immediately).
Response: Full entry detail object under data, including boatHandicaps[].value confirming the written value. Use this for read-back verification.
PUT https://api.sailsys.com.au/api/v1/races/{raceId}/handicaps
Request body: the full race-entrants array as returned by GET /races/{raceId}/entrants, with each row's handicap.currentHandicaps[].value overwritten to the desired TCF. Every nested field (boat, division, skipper, currentHandicapForSort, spinnakerType, …) must be echoed back verbatim — SailSys validates the row shape, not just the value.
Why this exists alongside the per-boat endpoint:
- One round-trip for an entire race instead of N requests with a 200–500 ms cushion each.
-
spinnakerTypeis preserved automatically (it came back inside the entrants payload), so the misleading"Handicaps should be greater than 0"400 cannot happen. - The response is the standard race envelope (
data.raceType,data.handicapAndStartTimeProcessingStatus, …) — same shape asGET /races/{raceId}/status.
sail-jinx prefers this endpoint for both manual TCF edits in the entrants table and for pushing the next race's computed TCFs after Process Handicaps (see wiki/Jinx-Handicaps.md §6). The per-boat endpoint above is retained for backwards compatibility but is no longer the primary write path.
POST https://api.sailsys.com.au/api/v1/series/all
Note: a POST (not GET) because the filter payload is too rich for query
params. Backs the Angular /club/{clubId}/series/list page.
Request body:
{
"pageNumber": 0,
"totalItemsCount": 0,
"hasNextPage": true,
"name": "",
"clubId": "23",
"states": [0, 1, 2]
}Field notes:
-
clubIdis a string here (SailSys quirk), even though most other references to club IDs are integers. -
states: filters by series status —0= upcoming,1= active,2= completed. Pass[0,1,2]to get everything. -
name: substring filter; empty string returns all.
Response: standard envelope wrapping a paged container:
{
"data": {
"items": [
{
"id": 5699,
"name": "Test Jinx Series",
"status": 1,
"racesCount": 3,
"divisionsCount": 1,
"subSeriesCount": 0,
"club": { "id": 23, "shortName": "MYC", "longName": "Manly Yacht Club" },
"defaultHandicap": 15,
"isLegacyEvent": false,
"complianceEnabled": false,
...
}
],
"hasNextPage": false,
"pageSize": 20,
"pageNumber": 0,
"totalItemsCount": 12
},
"result": "success",
"httpCode": 200
}Each item carries id and name (sufficient to drive subsequent
/series/{seriesId}/entries calls), plus counts and the embedded club
summary.
GET https://api.sailsys.com.au/api/v1/users
Returns the authenticated user profile. Useful to confirm auth is working and to retrieve clubAdminPositions[].club.id.
GET https://api.sailsys.com.au/api/v1/boats/{boatId}/handicaps?divisionId={divisionId}&definitionId={definitionId}
Returns an array of historical handicap records across all clubs and series. Each item has:
-
id,value,raceDate,seriesName,clubShortName -
spinnakerType,assignedSpinnakerType
Useful for audit and for seeding the initial TCF from the boat's PF history.
GET https://api.sailsys.com.au/api/v1/races/{raceId}/startTimes
Returns per-boat start time data for a pursuit race. Each item includes:
-
id(boatId),offset(string like"+7.00001434833333 mins"),startTimeLocal(HH:MM string) - Full boat and skipper profile, current handicap
Note: the offset field is a human-readable string, not a clean numeric. Parse with care.
GET https://api.sailsys.com.au/api/v1/series/{seriesId}/races
Returns data as a bare JSON array of race summaries — no envelope pagination.
Each entry includes:
-
id— integer raceId (used for all per-race endpoints below) -
number,name(e.g."Race 1") -
date— ISO datetime (midnight at race date) -
seriesId,subSeriesIds[] -
divisionTiming[]—[{id, name, startTimeLocal, timeZoneId}]
GET https://api.sailsys.com.au/api/v1/races/{raceId}/status
Returns rich per-race metadata. Key fields and their observed state machines:
| Field | Values | Meaning |
|---|---|---|
raceEntrantVisibility |
0, 1
|
0 = draft, 1 = entrants list published |
handicapAndStartTimeProcessingStatus |
0, 1, 2
|
0 = pending, 1 = processing, 2 = processed |
handicapAndStartTimeVisibility |
0, 1
|
0 = draft, 1 = start sheet published |
handicapAndStartTimeStatus |
0, 1, 2
|
0 = initial, 1 = published, 2 = processed but unpublished |
entriesCount, pendingEntriesCount
|
int | total entries / those awaiting RO approval |
startersCount, finishersCount
|
int | filled in once results entered |
resultStatus |
int | 0 = no results |
handicappings[] |
array | list of handicap definitions used by this race |
divisionTiming[] |
array | per-division {startTimeLocal, startTimeUtc, courseLength, raceType, isAbandoned, isShortened}
|
raceType (top-level) |
0, 1
|
0 = Fleet, 1 = Pursuit in SailSys's own terminology. Our UI calls these "scratch start" and "pursuit" (or "pursuit TCF" when a division has shortenedCourse/isShortened=true). The authoritative race-type discriminator — verified across HAR captures of races whose SailSys-UI labels were known. |
offsetPursuitRace |
bool | True for the "offset pursuit" variant. Independent of raceType (was observed false on a Pursuit race that had raceType=1). |
GET https://api.sailsys.com.au/api/v1/races/{raceId}/entrants
Returns data as a bare JSON array. Each entrant has:
-
id— entry id (often equalsboat.idbut not guaranteed) -
boat—{id, name, sailNumber, make, model, club, country, ...} -
skipper.profile—{userId, firstname, surname, email, ...} -
division—{id, name, defaultHandicap, divisionType, spinnakerType} -
handicap.currentHandicaps[]— array of{definition, id, boatId, value, ...}— the live TCFs -
startTimeLocal,finishTimeLocal,elapsedTime— null until processed -
entryStatus— int (observed: 0 for normal entries) -
seriesCasualEntry,nonSpinnaker,protest,boatCrewInfoComplete— booleans -
penalties[],crew[],subSeries[]— arrays
GET https://api.sailsys.com.au/api/v1/races/current?pageNumber=0&clubId={clubId}
Returns the current/upcoming race list for a club.
PUT https://api.sailsys.com.au/api/v1/races/{raceId}/timing
Sets start times and course configuration per division. Request body is an array:
[
{
"divisionId": 13779,
"divisionName": "Div 1",
"startTimeLocal": "2026-05-17T12:17:00.000",
"raceType": 0,
"startTimeUtc": "2026-05-17T02:17:00.000",
"courseLength": 8,
"isAbandoned": false,
"isShortened": false
}
]Both local and UTC times are required. The raceType field in divisionTiming[]
has been observed to be always 0, regardless of whether the race is
Pursuit or Fleet — it is NOT a useful discriminator. Use the top-level
raceType from the race status response instead (0 = Fleet, 1 = Pursuit).
A successful PUT triggers SailSys-side start-time processing. The
handicapAndStartTimeProcessingStatus field on /races/{raceId}/status
transitions 0 → 1 → 2 (pending → processing → processed) — typically
under a second. Poll status until it leaves 1 before refetching entrants
(their startTimeLocal values will have changed).
Patch flow: GET status → mutate divisionTiming[i].courseLength → PUT the
whole array back. Other fields (startTimeLocal, startTimeUtc, raceType,
flags) must be preserved verbatim or SailSys will reject the request.
PUT https://api.sailsys.com.au/api/v1/races/{raceId}/entrants/visibility/{0|1}
Empty body. 1 publishes the entrants list, 0 reverts to draft. Toggles
the raceEntrantVisibility field on the status payload. Response is the
full race object envelope.
PUT https://api.sailsys.com.au/api/v1/races/{raceId}/handicaps/visibility/{0|1}
Empty body. 1 publishes the start sheet (TCFs + start times), 0 reverts
to draft. Toggles handicapAndStartTimeVisibility. Only meaningful after
processing has produced start times — publishing before processing is a
SailSys-side configuration mistake, not enforced by the API.
GET https://api.sailsys.com.au/api/v1/divisions/{divisionId}/penalties?type=undefined
Catalogue of penalty definitions valid for the division. Each entry has:
id, definitionId, definitionShortName (DNC/DNF/DNS/DSQ/OCS/
RET/ABD), definitionName, definitionDescription, value, base,
type, valueType, customPenalty, averagePoints, handicapId.
When attaching a penalty to a finisher entry (see below), include the whole object verbatim — SailSys rejects partially-populated stubs.
GET https://api.sailsys.com.au/api/v1/races/{raceId}/results/starters
GET https://api.sailsys.com.au/api/v1/races/{raceId}/results/finishers
Both return the same array shape: every entered boat with its skipper, boat,
division, startTimeOffset, startTimeLocal, startedRace (boolean),
finishTime, finishDate, penalties[], protest, boatCrewInfoComplete,
crew[]. The two endpoints are interchangeable for read-only consumers —
fetch either and you have the full per-boat state. Use /finishers if you
need a template for a results PUT below; it carries the same shape needed by
both PUTs.
PUT https://api.sailsys.com.au/api/v1/races/{raceId}/results/starters?token={N}
PUT https://api.sailsys.com.au/api/v1/races/{raceId}/results/finishers?token={N+1}
Body for both: the full results array (echoed back from the GET above) with the desired mutations:
-
Starters PUT sets
startedRaceper boat (true = boat crossed the line, false = DNC/DNS/duty). -
Finishers PUT sets
finishTime(ISO local, e.g."2026-05-26T18:31:00.000"),finishDate(race date at midnight, e.g."2026-05-26T00:00:00.000"), andpenalties[](array of full penalty objects taken from the division catalogue). Multiple penalties per boat are supported — observed in HAR a boat with bothOCSandDNF.
Save token chain. The starting token is resultSaveToken from
/races/{raceId}/status. Each successful PUT returns a fresh race object
whose resultSaveToken is the value to use for the next PUT in the chain.
A stale token returns an error. The expected pattern is:
- GET status →
saveToken = resultSaveToken(e.g.0). - PUT starters with
?token=0→ response carriesresultSaveToken: 1. - PUT finishers with
?token=1→ response carriesresultSaveToken: 2.
Apply the standard 200–500 ms gap between the two PUTs (see §8).
AVG (duty boat / give average points). SailSys has no AVG entry in
the division penalty catalogue — instead, the "give this boat average
points" case is represented by a special single-field penalty stub:
{"averagePoints": true}No definitionId, no definitionShortName, no value — just that one
key. SailSys accepts it on PUT and echoes it back on GET. The duty boat is
pushed as startedRace=false + penalties=[{"averagePoints": true}]. On
read, any finisher entry whose penalties[] contains an averagePoints: true element is the duty boat — clients should treat it as such (set
their dutyBoatId).
Multiple penalties per boat are supported (HAR-11 shows one boat carrying ABD/DNC/DNF/DNS/DSQ/OCS/RET simultaneously).
For unknown short-names with no matching definitionShortName in the
division catalogue, drop them silently.
Use /results/starters as the canonical full-fleet template — it returns
every entered boat with its current startedRace / finishTime /
penalties state. /results/finishers returns an empty array when no
boat has a finish time (e.g. after a complete wipe), so it's unreliable as
a template source.
GET https://api.sailsys.com.au/api/v1/races/{raceId}/results
The SailSys-computed view of the race — the results UI's data source. Top
level carries race metadata; competitors[] is the per-division grouping,
each with items[] (one per boat) including:
-
boat,skipper,division— identity (same shape as elsewhere) -
startTimeLocal,finishTimeLocal,elapsedTime— pretty-formattedHH:MM/HH:MM:SSstrings -
penalties[]— authoritative penalty list (same shape as in the finishers PUT) -
calculations[]— one entry per handicap definition.calculations[0]is the primary (MYC TCF for our club). Each entry has:-
handicapDefinitionId(e.g.15= MYC TCF) andhandicapCreatedFrom(the TCF value used for the calc) -
correctedTime(when the handicap produces one) -
placings[]withplace(int),placingText(display string),points(number or null when no pointscore is configured),averagePoints(bool),subSeriesId,isCasualEntry
-
Only trust these values when the race status reports
requiresResultCalculation=false (otherwise they reflect the previous
finishers PUT, not the current one).
GET https://api.sailsys.com.au/api/v1/races/{raceId}/results/check
Returns the race status object. Despite the name, this does NOT trigger
recalculation — verified against HAR-16: on a published Final race it
returned 200 with the status payload unchanged and no recalc happened.
The actual recalc trigger is PUT /results/penalties/complete (below).
GET https://api.sailsys.com.au/api/v1/races/{raceId}/results/penalties/complete
PUT https://api.sailsys.com.au/api/v1/races/{raceId}/results/penalties/complete
The GET returns the race-scoped penalty catalogue — an array of full penalty-definition objects (DNC, DNF, DNS, DSQ, ABD, AVG, …) with the shape:
{
"id": 359, "definitionId": 7, "definitionShortName": "DNF",
"definitionName": "Did Not Finish",
"definitionDescription": "This boat did not finish the course",
"customPenalty": false, "averagePoints": false,
"type": 0, "base": 3, "valueType": null, "value": 1.00,
"handicapId": null
}The PUT body picks two of those entries — typically DNF and DNC — and
nests them as nonFinisherPenalty / nonStartersPenalty:
{
"nonFinisherPenalty": {<full DNF entry>},
"nonStartersPenalty": {<full DNC entry>}
}This PUT is the actual "Process Results" trigger used by SailSys's own
UI. It works regardless of the race's publish state — on a Final
race (resultStatus=4) it transitions the race to resultStatus=1
(Hidden, pending recalc) immediately, runs the recalc, and SailSys
restores the original publish state (4 → 4) automatically without any
re-promote call from the client. Poll /races/{raceId}/status until
requiresResultCalculation is false before reading /results.
PUT https://api.sailsys.com.au/api/v1/races/{raceId}/results/status/{0|1|2}?notify=false
Empty body. The {N} in the path is a user-visible state, not the raw
resultStatus field on the race object:
URL N
|
Meaning | Resulting resultStatus
|
|---|---|---|
0 |
Hidden |
2 (Hidden, processed) |
1 |
Provisional | 3 |
2 |
Final | 4 |
notify=false suppresses the email-to-skippers side effect.
Workflow. A typical RO sequence is:
- SAVE captures times → starters/finishers PUT (see above) leaves the race
in
resultStatus=2,requiresResultCalculation=true. -
GET /results/checkkicks off the recalc; poll status untilrequiresResultCalculation=false. -
PUT /results/status/1to publish provisionally. - After review,
PUT /results/status/2to publish as final. - To amend after Final:
PUT /results/status/0(back to Hidden), edit, PUT finishers again (with bumpedresultSaveToken), recalc, re-promote.
Captured from HAR transitions:
| Value | Meaning |
|---|---|
0 |
Never had results (initial / fresh race) |
1 |
Pending recalc (transient between finishers PUT and |
| calc completion) | |
2 |
Hidden, calc complete |
3 |
Provisional, published |
4 |
Final, published |
Treat requiresResultCalculation as the source of truth for "are the
computed numbers up to date" — resultStatus alone can be 2 either
because the race was never published or because it was reverted from a
published state, and you can't tell from resultStatus alone whether the
finisher data has been recalculated since the last PUT.
Use Jetty HttpClient (matching the server-side Jetty dependency). Key requirements:
- Set
Content-Type: application/jsonon all requests - Set
app,apptimezone,apptimezoneoffsetheaders as constants - Set
sessiontokenheader: empty string before login, token value after - Jetty HttpClient handles gzip decompression automatically
public class SailSysClient {
private static final String BASE = "https://api.sailsys.com.au/api/v1";
private final HttpClient httpClient;
private String sessionToken = "";
private Request baseRequest(String path) {
return httpClient.newRequest(BASE + path)
.header("Content-Type", "application/json")
.header("app", "0")
.header("apptimezone", "Australia/Sydney")
.header("apptimezoneoffset", "10")
.header("sessiontoken", sessionToken);
}
public void login(String email, String password) throws Exception {
String body = """
{"email":"%s","password":"%s","twoFactorAuthCode":""}
""".formatted(email, password);
ContentResponse resp = baseRequest("/auth")
.method(HttpMethod.POST)
.body(new StringRequestContent("application/json", body))
.send();
// parse JSON, extract data.sessionToken
sessionToken = parseSessionToken(resp.getContentAsString());
}
}public void updateHandicap(int seriesId, int boatId, double newValue) throws Exception {
String body = """
[{"createdFromId":null,"definitionId":5,"value":%s,"spinnakerType":3,"appliesFrom":null}]
""".formatted(newValue);
ContentResponse resp = baseRequest("/series/%d/entries/%d/handicaps".formatted(seriesId, boatId))
.method(HttpMethod.PUT)
.body(new StringRequestContent("application/json", body))
.send();
// parse response, verify boatHandicaps[0].value == newValue
}Use Jackson (jackson-databind) for all JSON parsing and serialisation, consistent with Jetty's own dependencies. Credentials and series IDs are injected from the YAML config file at startup via SnakeYAML.
- Check HTTP status code first; non-200 indicates infra/auth error
- Check
resultfield in envelope:"success"or error string - Check
errorMessagefield for human-readable detail - Always read back after write and compare values before reporting success
- Re-login on 401 response (token expiry behaviour is unknown)
No rate limit headers observed, but SailSys serves 50+ clubs. Apply a 200-500ms delay between batch writes. Do not parallelise write operations.
The following were not captured in this session and will need further investigation or inference from the read endpoints:
-
Fetching all races in a series - likely
GET /api/v1/series/{seriesId}/racesby analogy - Recording finish times / results - a separate write endpoint, not yet captured
- Bulk handicap update - no evidence of a batch endpoint; assume per-boat PUT calls are required
- Token expiry - unknown. Implement re-login on 401 response.
-
SignalR / WebSocket - a
raceHubSignalR connection was negotiated (POST /raceHub/negotiate) for real-time race updates. Not required for the handicap update workflow.