Skip to content

sailsys api reference

Greg Wilkins edited this page Aug 18, 2026 · 9 revisions

⚠️ Historical — this integration no longer exists

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-sailsys branch. Nothing here describes current behaviour, and none of these endpoints are called by any code on main.

If you are looking for how sail-jinx works now, start with Home and the Jinx handicap.


SailSys REST API Reference

Reverse-engineered from HAR capture of app.sailsys.com.au. Sufficient to implement auth and handicap update flows in Java.


1. Base URLs

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.


2. Authentication

2.1 Mechanism

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.

2.2 Login endpoint

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.


3. Common Request Headers

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.


4. Standard Response Envelope

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.


5. Key Identifiers (MYC-specific)

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).


6. Handicap Update Flow

This is the primary write operation needed for the MYC twilight handicap system.

Step 1 - Fetch current series entries (to get boat IDs and current handicaps)

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)

Step 2 - Update a boat's handicap

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 — 5 for PHS at MYC, 15 for 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 from handicap.currentHandicaps[i].spinnakerType in the entrants payload — don't guess. The MYC TCF row uses 1; the PHS row may use a different value depending on division config.
    • SailSys returns a misleading 400 if spinnakerType doesn't match: "Handicaps should be greater than 0, please try again." — actually meaning "no row matched (definitionId, spinnakerType)".
  • 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.

Step 2 (alternative) - Bulk update every entrant's handicap for a race

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.
  • spinnakerType is 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 as GET /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.


7. Other Endpoints Observed

POST list club series

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:

  • clubId is 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 current user

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 handicap history for a boat

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 race start times

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 races in a series

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 race status

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 race entrants

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 equals boat.id but 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 races list

GET https://api.sailsys.com.au/api/v1/races/current?pageNumber=0&clubId={clubId}

Returns the current/upcoming race list for a club.

PUT race division timing

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 race entrants visibility

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 race start sheet visibility

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 division penalties

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 race starters / finishers

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 race starters / finishers

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 startedRace per 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"), and penalties[] (array of full penalty objects taken from the division catalogue). Multiple penalties per boat are supported — observed in HAR a boat with both OCS and DNF.

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:

  1. GET status → saveToken = resultSaveToken (e.g. 0).
  2. PUT starters with ?token=0 → response carries resultSaveToken: 1.
  3. PUT finishers with ?token=1 → response carries resultSaveToken: 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.

GET race results (per-boat starters/finishers)

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 computed results

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-formatted HH:MM / HH:MM:SS strings
  • 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) and handicapCreatedFrom (the TCF value used for the calc)
    • correctedTime (when the handicap produces one)
    • placings[] with place (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 results check

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 / PUT results penalties complete — the recalc trigger

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 results visibility

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:

  1. SAVE captures times → starters/finishers PUT (see above) leaves the race in resultStatus=2, requiresResultCalculation=true.
  2. GET /results/check kicks off the recalc; poll status until requiresResultCalculation=false.
  3. PUT /results/status/1 to publish provisionally.
  4. After review, PUT /results/status/2 to publish as final.
  5. To amend after Final: PUT /results/status/0 (back to Hidden), edit, PUT finishers again (with bumped resultSaveToken), recalc, re-promote.

resultStatus field — observed values

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.


8. Java Implementation Notes

HTTP client

Use Jetty HttpClient (matching the server-side Jetty dependency). Key requirements:

  • Set Content-Type: application/json on all requests
  • Set app, apptimezone, apptimezoneoffset headers as constants
  • Set sessiontoken header: empty string before login, token value after
  • Jetty HttpClient handles gzip decompression automatically

Session management

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());
    }
}

Handicap update with read-back verification

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
}

JSON serialisation

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.

Error handling

  • Check HTTP status code first; non-200 indicates infra/auth error
  • Check result field in envelope: "success" or error string
  • Check errorMessage field 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)

Rate limiting

No rate limit headers observed, but SailSys serves 50+ clubs. Apply a 200-500ms delay between batch writes. Do not parallelise write operations.


9. What Was Not Observed

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}/races by 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 raceHub SignalR connection was negotiated (POST /raceHub/negotiate) for real-time race updates. Not required for the handicap update workflow.

Clone this wiki locally