A strongly-typed, async .NET client library for the RaceResult Web API. It wraps the entire HTTP surface — authentication, event management, participants, timing, results, and more — behind a clean, fluent C# API.
- .NET 10.0 or later
- A running RaceResult Web server (on-premise or cloud)
RaceResultClient.NET/
├── Apiclient.cs # Core HTTP client (auth, request building, error handling)
├── EventApiClient.cs # Per-event facade; entry point for all event-scoped endpoints
├── EventEndpoints.cs # Core event-scoped endpoint groups (participants, timing, etc.)
├── EventEndpointsDesign.cs # Additional groups (archives, certificates, lists, labels, …)
├── PublicAndGeneral.cs # Server-level endpoints (login, event lifecycle, user rights)
├── Models.cs # Core request/response record types, enums and RaceResult's codes
├── DesignModels.cs # Design-object models (certificate, label, list, kiosk, …)
├── Supporting.cs # QueryParams builder, ApiException, Identifier, JSON config
└── RaceResultClient.csproj
This client mirrors the official Go libraries go-webapi
and go-model: endpoint paths, query parameters and
model fields track those repositories. Enums serialize numerically (as in Go's encoding/json),
except the certificate page-size/format enums which serialize as strings.
Property names follow C# conventions (Id, Url, Rssi), but every property whose Go JSON name
differs carries a [JsonPropertyName] attribute (ID, URL, RSSI, …), so the bytes on the wire
are byte-for-byte identical to what go-webapi sends and expects.
using RaceResultClient;
var client = new ApiClient(server: "my-raceresult-server.com", useHttps: true);
// Or with a handler of your own (one that checks where it may connect, a proxy, a test double);
// it is disposed with the client
var checkedClient = new ApiClient("my-raceresult-server.com", true, "webapi/1.0", new SocketsHttpHandler());Three authentication methods are supported:
// Username + password
await client.Public.LoginAsync(new LoginOptions { User = "admin", Password = "secret" });
// API key
await client.Public.LoginAsync(new LoginOptions { ApiKey = "your-api-key" });
// RaceResult user token
await client.Public.LoginAsync(new LoginOptions { RrUserToken = "token" });After login, the session token is stored internally and sent with every subsequent request as a Bearer token.
A session obtained elsewhere can be used instead of logging in, and the request timeout — 30 seconds,
as in go-webapi — can be changed before the first request:
client.SessionId = sessionIdFromElsewhere; // go-webapi's SetSessionID
client.Timeout = TimeSpan.FromMinutes(2); // go-webapi's SetTimeout// By known event ID
var ev = client.ForEvent("my-event-id");
// Or create a new event and get a client for it
var ev = await client.Public.CreateEventAsync("My Race 2025", new DateTime(2025, 6, 1));
// Or pick from the event list
var events = await client.Public.GetEventListAsync(year: 2025);
var nextEvents = await client.Public.GetNextEventListAsync();
var pastEvents = await client.Public.GetPastEventListAsync();
var ev = client.ForEvent(events[0].Id);
// A RACE RESULT server at the venue: no login, its own event list (public/eventlist
// would ask it for a token it cannot make); event endpoints then work as usual
using var local = new ApiClient("192.168.1.20", useHttps: false);
var localEvents = await local.Local.GetEventListAsync();
var localEv = local.ForEvent(localEvents[0].Id);All endpoint groups are accessed as properties on EventApiClient:
// Count registered participants
int count = await ev.Data.CountAsync();
// Get all contests
Contest[] contests = await ev.Contests.GetAsync();
// Create a new participant
var result = await ev.Participants.NewAsync(bib: 42, contest: 1);
// Submit a timing passing
await ev.Times.AddAsync(new[] {
new Passing {
Transponder = "ABC123",
UtcTime = DateTime.UtcNow
}
});ApiClient owns an HttpClient and implements IDisposable:
using var client = new ApiClient("my-server.com");
// ...| Endpoint | Description |
|---|---|
Public.LoginAsync |
Authenticate and store session |
Public.LogoutAsync |
Terminate the current session |
Public.GetEventListAsync |
List events for a given year |
Public.CreateEventAsync |
Create a new event |
Public.DeleteEventAsync |
Permanently delete an event |
Public.GetUserInfoAsync |
Get info about the authenticated user |
Public.TokenFromSessionAsync |
Exchange the session for an OAuth2 token |
Public.GetUserRightsAsync |
List user access rights for an event |
Public.SaveUserRightsAsync |
Grant/update user rights |
Public.DeleteUserRightsAsync |
Revoke user rights |
General.GetFontsAsync |
List supported font names |
General.GetAppVersionAsync |
Get server application version |
General.TranslateAsync |
Translate field names or expressions |
General.GetLangItemAsync |
Get a single translation string |
Local.GetEventListAsync |
All events on a RACE RESULT server at the venue (no login) |
All groups below are properties on EventApiClient.
| Group | Description |
|---|---|
AgeGroups |
CRUD, generate and reassign age group definitions |
Archives |
Archive lookup, create/write/import the event archive |
Backup |
Start/stop and monitor the backup process |
BibRanges |
CRUD for bib number ranges |
Certificates |
CRUD for certificate designs; render PDF/JPG/thumbnail |
CertificateSets |
CRUD for certificate sets; render and count |
Chat |
Read messages, register users, post messages |
ChipFile |
Read, save and clear the transponder chip file |
Contests |
CRUD for contest/category definitions |
CustomFields |
CRUD for custom participant fields |
Data |
Query and transform participant data with filtering, sorting, pagination |
Dependencies |
Inspect the field dependency tree and circular references |
EmailTemplates |
CRUD for email/SMS templates; preview and send |
EntryFees |
CRUD for entry fee tiers |
Exporters |
CRUD and start/stop for data exporters |
File |
File access, activation, version, modjobid, ownership/rights |
Forwarding |
Manage and monitor live data forwarding |
GroupTimes |
Read and save group start times / finish time limits |
History |
Read, export and delete participant field history |
Information |
Frequent first names and gender lookup |
Kiosks |
CRUD for kiosk configurations |
Labels |
CRUD for label designs; render PDF |
Lists |
CRUD for list designs; render PDF/HTML/XML/JSON/CSV/XLSX/… |
OverwriteValues |
Save/delete/count result overwrite values |
Participants |
Full participant lifecycle: create, read, update, delete, import (incl. SES), bib management |
Pictures |
Manage participant/event pictures and thumbnails |
Rankings |
CRUD for ranking definitions |
RawData |
Read, filter, export, copy/swap and delete raw timing data |
RawDataRules |
CRUD for raw data routing rules |
Registrations |
Process submissions; CRUD for registration forms |
Results |
CRUD for result column definitions |
Settings |
Read, write and delete event settings by name |
SimpleApi |
CRUD for Simple API endpoint configurations |
Splits |
CRUD for split/checkpoint definitions |
Statistics |
CRUD for statistic definitions; render and compute pivots |
Synchronization |
Check-out/check-in status (online server) |
TeamScores |
CRUD for team scoring definitions |
TimingPoints |
CRUD for timing point definitions |
TimingPointRules |
CRUD for timing point decoder routing rules |
Times |
Submit passings, read/delete/copy/swap times, single-start and random times |
UserDefinedFields |
Read and overwrite user-defined formula fields |
Vouchers |
CRUD for discount vouchers and validation |
WebHooks |
CRUD for webhook configurations |
Many endpoints accept either a bib number, an internal participant ID (PID), or a generic internal ID. The Identifier struct encodes this choice:
// Address by bib number
await ev.Times.GetAsync(Identifier.Bib(101));
// Address by internal participant ID (PID)
await ev.Participants.GetFieldsAsync(Identifier.Pid(5432), new[] { "Firstname", "Lastname" });
// Address by internal ID
await ev.History.GetAsync(Identifier.Id(99));Where a model field or a parameter is a choice among RaceResult's own numbers, the library takes an
enum, not an int, so a caller keeps no RaceResult numbers of its own. Identifiers stay int — a
PID, a bib, a contest, a result or a split Id is a number, not a choice. The values are
RaceResult's go-model constants or what RaceResult's own splits page writes (checked against a splits
definition set up by hand and exported):
| Enum | Field | Values |
|---|---|---|
SplitType |
Split.SplitType |
Split 0, Internal 2, Leg 9 |
SplitTimeMode |
Split.TimeMode (what TimeMin/TimeMax are) |
RaceTime 0, TimeOfDay -1, FromPreviousSplit -2, MinPerKm -3, MinPerMile -4, MinPer100m -5, Kmh -6, Mph -7, MetresPerSecond -8 |
SplitDistanceFrom |
Split.DistanceFrom |
Start 0, PreviousSplit -1, or SplitDistanceFromExtensions.FromSplit(id) |
TypeOfSport |
Split.TypeOfSport |
None 0, Swimming 4, Skiing 5, Other 10, Cycling 11, Running 100, TransitionFirstRead 250, TransitionLastRead 251 |
ContestTimingMode |
Contest.TimingMode |
GunTime 0, ChipTime 1 |
ParticipantStatus |
Participant.Status |
Regular 0, OutOfCompetition 1, Disqualified 2, DidNotFinish 3, DidNotStart 4, NotPresent 5 |
CustomFieldType |
CustomField.FieldType |
Text 0 … Transponder 10 |
VoucherType |
Voucher.Type |
Amount 0, Percent 1, FirstReg 2, PrevReg 3 |
WebHookType |
WebHook.Type |
ParticipantNew 0 … ModJobIdSettings 4 |
TriggerType |
TriggerSettings.TriggerType |
NewTime 0 … NewRawDataV2 10 |
StatisticAggregation |
Statistics.Aggregation, Statistics.ComputeAsync, Data.TransformationAsync |
Count 1, Minimum 2, Maximum 3, Mean 4, Sum 5 |
CertificateAlignment / CertificateVAlignment |
CertificateElement.Alignment / .VAlignment |
Left 1, Center 2, Right 3 / Top 0, Middle 1, Bottom 2 |
The design enums (PageSize, PageFormat, CertificateElementType, LabelAlignment,
ListShowAt, ListPageBreak, EmailTemplateType, CertificateSetType, …) cover the same ground
for the design objects.
Two fields RaceResult stores as text keep named string constants instead, since a value outside the set is still valid text:
| Class | Field | Values |
|---|---|---|
SplitDistanceUnits |
Split.DistanceUnit |
m, km, miles |
SplitSpeed |
Split.SpeedOrPace (the number as text) |
None 0 … MetresPerSecond 6 |
// Lap 2: 5 km from the previous split, between 5 s and 1 min after it, cycling
var lap = new Split
{
Contest = 1, Name = "Lap2", TimingPoint = "FINISH",
TypeOfSport = TypeOfSport.Cycling,
Distance = 5, DistanceUnit = SplitDistanceUnits.Kilometers,
DistanceFrom = SplitDistanceFrom.PreviousSplit,
TimeMin = 5, TimeMax = 60, TimeMode = SplitTimeMode.FromPreviousSplit,
};
await ev.Splits.SaveAsync([lap]);
// Measured from another split instead
var fromSplit = lap with { DistanceFrom = SplitDistanceFromExtensions.FromSplit(13) };
int? origin = fromSplit.DistanceFrom.SplitId(); // 13Every enum is backed by its RaceResult number and serializes as that number, so a value RaceResult
adds later still round-trips: Split.TimeMode also carries RaceResult's own encoding of
"From Split …" (a value of its own, outside SplitTimeMode), and reading and saving a split keeps
it unchanged.
go-model carries a few types that no go-webapi endpoint returns; this library ports them too, so a caller that meets them elsewhere (a web hook's body, a trigger subscription, a participant's payment JSON) does not have to write them itself:
| Type | Where it comes from |
|---|---|
WebHookMessage |
the body a web hook posts to its WebHook.Url |
TriggerSettings, TriggerType |
a push-trigger subscription |
OpData, OpDataPayment |
the parsed form of Participant.Opjson |
RegistrationRequestResponse, RegistrationRequestResponseRecord, InvoicePaymentTerms |
the answer to a RegistrationRequest (Registrations.ProcessAsync returns the raw JsonElement; deserialize it into these) |
RawDataReduced |
the short form of a raw-data entry, without the Passing |
TableValue |
a row of the event file's generic lookup table |
ParticipantAndDummyCount |
a participant count split into real and dummy records |
ArchiveEvent, ArchiveContest |
an archive's events and their contests |
UserRight.Has("participants.edit") reads a rights map the way go-model's UserRights.Has does,
"*" as a group or a detail included.
QueryParams is a fluent, type-safe builder used internally and exposed for raw calls:
var q = new QueryParams()
.Add("filter", "contest=1")
.Add("limitFrom", 0)
.Add("limitTo", 50)
.AddArray("fields", new[] { "Firstname", "Lastname", "Bib" });Any non-200 HTTP response throws an ApiException carrying the status code and server error message:
try
{
await ev.Participants.DeleteAsync(filter: "bib=999");
}
catch (ApiException ex)
{
Console.WriteLine($"API error {ex.StatusCode}: {ex.Message}");
}You can also inject a custom exception factory if your application uses its own exception hierarchy:
client.ErrorFactory = (message, statusCode) => new MyAppException(message, statusCode);If you need to call an endpoint not yet surfaced by the library:
byte[] raw = await ev.GetRawAsync("some/undocumented/endpoint");byte[] fileBytes = File.ReadAllBytes("participants.xlsx");
ImportResult result = await ev.Participants.ImportAsync(
file: fileBytes,
addParticipants: true,
updateParticipants: true
);
Console.WriteLine($"Added: {result.Added}, Updated: {result.Updated}");var rows = await ev.Data.ListAsync(
fields: new[] { "Bib", "Firstname", "Lastname", "Contest" },
filter: "status=0",
sort: new[] { "Lastname" },
limitFrom: 0,
limitTo: 100
);
foreach (var row in rows)
Console.WriteLine($"{row[0]} — {row[1]} {row[2]}");var responses = await ev.Times.AddAsync(new[]
{
new Passing
{
Transponder = "A1B2C3",
UtcTime = DateTime.UtcNow,
DeviceName = "Finish Line Reader"
}
});
foreach (var r in responses)
Console.WriteLine($"Bib {r.ResultId}: {r.Time} at {r.TimingPoint}");await ev.Participants.SaveFieldsAsync(
id: Identifier.Bib(42),
values: new Dictionary<string, object?>
{
["Status"] = 1,
["Comment"] = "DNS"
}
);var settings = await ev.Settings.GetAsync(ct, "EventName", "EventDate");
await ev.Settings.SaveAsync("EventName", "My Updated Race Name");ApiClientowns theHttpClientand session state. It is the root object and should be disposed when done.EventApiClientis a lightweight façade created viaclient.ForEvent(id). It holds only an event ID string and a reference toApiClient— no state of its own. Endpoint group objects (e.g.ev.Participants) are instantiated on each property access (no caching overhead, no shared state).- All models use C#
recordtypes withinit-only properties, making them immutable and copy-friendly. - Serialization uses
System.Text.Jsonthroughout, with case-insensitive deserialization. Enums serialize as their numbers, as Go'sencoding/jsondoes; the certificate page-size/format enums are the exception and serialize as strings, matching go-model's own marshallers. CancellationTokenis a last optional parameter on every async method, defaulting todefault.
This project is licensed under the MIT License.