# TavlingsConsult – Integrationsguide

## Introduktion

TavlingsConsults integrations-API låter externa tidtagningspartners och system utbyta data med plattformen i realtid. Du kan hämta liveresultat och startlistor, ta emot dem med webhooks när de ändras, och skicka in tidtagningshändelser eller färdiga resultat som uppdaterar livetabellen direkt.

API:et är avsett för system-till-system-integration. Kontakta oss på [Info@tavlingsconsult.com](mailto:Info@tavlingsconsult.com) för att begära en API-nyckel. En kortare introduktion finns på <https://tavlingsconsult.com/for-utvecklare>.

---

## Autentisering

Alla anrop till integrations-API:et måste innehålla din API-nyckel i headern `X-API-KEY`.

```http
X-API-KEY: tc_live_<din nyckel här>
```

Nycklar har två behörighetstyper:

| Typ | Beskrivning |
|-----|-------------|
| Läs (`CanRead`) | Tillåter GET-anrop för att hämta tävlingar, deltagare, resultat, startlistor och tidtagningshändelser |
| Skriv (`CanWrite`) | Tillåter POST-anrop för att publicera tävlingar, klasser, sträckor, deltagare, tidtagningshändelser och färdiga resultat |

En nyckel kan vara låst till en specifik tävling. Om den är låst returneras `404 Not Found` för alla anrop som riktar sig mot en annan tävling.

Om din nyckel saknas eller är ogiltig returnerar API:et `401 Unauthorized`. Om din nyckel saknar rätt behörighet för en operation returneras `403 Forbidden`.

---

## Testa API:et i webbläsaren (Scalar)

Det finns en interaktiv API-referens byggd med Scalar där du kan utforska hela integrations-API:et direkt i webbläsaren. Referensen visar varje endpoint med request- och svarsformat och har en inbyggd testklient ("Test Request") som skickar riktiga anrop – du behöver alltså inget separat verktyg.

- Öppna referensen på `https://api.tavlingsconsult.com/integration/docs`.
- Bläddra bland alla integrations-endpoints, se request- och svarsformat och skicka live-testanrop ("Test Request") direkt från webbläsaren.
- För autentiserade anrop: klistra in din API-nyckel (`X-API-KEY`, format `tc_live_…`) i Scalars autentiseringspanel, så skickas den automatiskt med varje testanrop.
- Endast integrations-API:ets endpoints exponeras här – inte adminpanelen eller övriga konto- och klubbanrop.
- Tänk på att live-testanrop går mot det riktiga API:et. Vill du inte påverka skarp data, använd en test-/sandbox-nyckel eller en nyckel låst till en testtävling. Skrivnycklar har ett eget namnområde per nyckel, så testdata du publicerar hålls isär från vår egen – men läsanrop visar riktiga, aktiva tävlingar.

---

## Testtävling / sandlåda

För att utforska API:et mot realistiska data utan att påverka skarpa tävlingar finns en permanent demotävling – **"DEMO – Integrationssandlåda"**. Den är förifylld med exempeldata (klasser, deltagare, resultat och tidtagningshändelser), så varje läs-endpoint returnerar riktiga svar att testa mot.

Demotävlingen är "unlisted": den visas inte på den publika resultatsidan eller i publika tävlingslistningar, men nås via integrations-API:et med en nyckel som är låst till den (ungefär som en olistad video).

- Be arrangören om en **sandlådenyckel** – en läsnyckel (`CanRead`) låst till demotävlingen (se avsnittet *Få en nyckel*). Eftersom den bara har läsbehörighet och är tävlingslåst kan den bara läsa demotävlingen, aldrig skarp data.
- Klistra in nyckeln i Scalar-referensen (se avsnittet *Testa API:et i webbläsaren (Scalar)*) och anropa läs-endpointarna direkt i webbläsaren, eller använd den mot bas-URL:en `https://api.tavlingsconsult.com/api/integration/v1`.
- Vill du testa publicering (skriv) i stället sker det med en olåst skrivnyckel i ett eget namnområde och är skilt från läs-sandlådan – se avsnittet *Publicera komplett tävlingsdata med egna ID:n*.

---

## Bas-URL

```
https://api.tavlingsconsult.com/api/integration/v1
```

Alla endpoints anges relativt denna bas-URL.

---

## Endpoint-referens

### Lista tävlingar

Returnerar tävlingar som din nyckel har åtkomst till (endast aktiva tävlingar; eller den enskilda låsta tävlingen).

```http
GET /competitions
```

**Svar**

```json
[
  {
    "id": 12,
    "name": "Midnattsloppet 2026",
    "startDate": "2026-07-15T00:00:00Z",
    "competitionType": 1,
    "isActive": true,
    "isLocked": false
  }
]
```

`competitionType`-värden: `0` = Sprint, `1` = Rally, `2` = Rallycross. Ur integrations-API:ets perspektiv beter sig Rallycross som Rally – samma endpoints, händelsetyper (`RallyStart`/`RallyFinish`/`RallyIntermediate`) och svarsformat gäller. Skillnaden är poängräkningen: rallycrosstävlingar rankas som standard med SBF:s kvalpoäng via värderingsregeln `RallycrossPoints` – se *Hämta resultat / resultattabell*. Folkrace och crosskart körs på samma tävlingstyp (`2`) men rankas med sina egna kvalpoäng (`FolkracePoints` respektive `CrosskartPoints`); grenen sätts av arrangören i adminpanelen och följer inte med i integrations-API:et.

---

### Lista sträckor (heats) för en tävling

```http
GET /competitions/{id}/heats
```

**Svar**

```json
[
  { "id": 45, "heatNumber": 1, "isClosed": false },
  { "id": 46, "heatNumber": 2, "isClosed": true }
]
```

`id` är sträckans interna ID (används i `?heatId`-filtret och i POST-endpointen). `heatNumber` är det visade SS-numret.

---

### Hämta resultat / resultattabell

```http
GET /competitions/{id}/results
```

Valfria query-parametrar:

| Parameter | Typ | Beskrivning |
|-----------|-----|-------------|
| `heatId` | int | Filtrera till deltagare som har ett resultat i denna sträcka (sträckans **ID**) |
| `className` | string | Filtrera till deltagare i denna klass (skiftlägesokänsligt) |

**Svar**

```json
{
  "competitionId": 12,
  "competitionName": "Midnattsloppet 2026",
  "discipline": "Rally",
  "ratingRule": "Default",
  "useCompetitorCodeAsStartNumber": false,
  "useCompetitorRaceNumberAsStartNumber": false,
  "heats": [1, 2],
  "entries": [
    {
      "position": 1,
      "classPosition": 1,
      "competitorId": 88,
      "startNumber": 5,
      "code": "",
      "raceNumber": "",
      "name": "Erik Svensson",
      "coDriver": "Anna Svensson",
      "notifier": "",
      "club": "Dalens MK",
      "car": "Volvo 240",
      "engineClass": "",
      "className": "Open",
      "classOrder": 1,
      "totalTime": 185420,
      "gapToLeader": 0,
      "classGapToLeader": 0,
      "heatResults": [
        { "heatNumber": 1, "timeMs": 92710, "penalty": 0, "status": 0 },
        { "heatNumber": 2, "timeMs": 92710, "penalty": 0, "status": 0 }
      ]
    }
  ]
}
```

Alla tider anges i millisekunder. `heats` är en lista med SS-nummer (`heatNumber`), inte interna ID:n. `status`-värden: `0` = OK, `1` = DNS, `2` = DNF, `3` = DSQ.

`discipline` anger GRENEN som enumnamn: `"Rallysprint"`, `"Rally"`, `"Rallycross"`, `"Crosskart"`, `"Folkrace"`, `"Backe"`, `"Langlopp"`, `"Racing"` eller `"Ovrigt"`. Den är inte samma sak som tävlingsformen och inte samma sak som `ratingRule` — grenen säger vad arrangören kallar sin tävling, och därmed vad ett heat heter i den: rally och rallysprint kör **sträckor** (`SS3`), rallycross och backe **omgångar** (`Omg 3`), folkrace, crosskart och racing **heat** (`Heat 3`), långlopp **lopp** (`Lopp 3`). Fältet saknas i svar som cachades innan det fanns; behandla tom sträng som "ingen gren angiven" och skriv sträcka, som förut.

`ratingRule` anger tävlingens värderingsregel: `"Default"` (flest genomförda sträckor, därefter lägst total tid), `"BestSingleHeat"` (bästa enskilda sträcktid, se nedan), `"BestTwoHeats"` (de två bästa sträckorna räknas, se nedan), `"RallycrossPoints"` (SBF:s kvalpoäng för rallycross, se nedan), `"FolkracePoints"` (SBF:s kvalpoäng för folkrace, se nedan), `"CrosskartPoints"` (SBF:s kvalpoäng för crosskart, se nedan) eller `"LapsThenTime"` (flest varv, därefter lägst tid – racing och långlopp, se nedan).

#### Visat startnummer (kod och tävlingsnummer)

Utöver `startNumber` bär varje deltagare två valfria alternativa nummer:

| Fält | Plats | Typ | Beskrivning |
|------|-------|-----|-------------|
| `code` | per post i `entries` (och per deltagare i deltagarlistan) | string | Deltagarkod. Kan vara tom |
| `raceNumber` | per post i `entries` (och per deltagare i deltagarlistan) | string | Tävlingsnummer – numret ekipaget bär själv, t.ex. en MC-förare vars nummer bara är unikt inom klassen (i MSport Pro kolumnen `C_I27`). Kan vara tom |
| `useCompetitorCodeAsStartNumber` | per tävling | bool | `true` när deltagarkoden ska visas i startnumrets ställe. Standard `false` |
| `useCompetitorRaceNumberAsStartNumber` | per tävling | bool | `true` när tävlingsnumret ska visas i startnumrets ställe. Standard `false` |

Båda flaggorna finns i svaret från `GET /competitions/{id}` och `GET /competitions/{id}/results`. De är tävlingsinställningar som arrangören sätter i adminpanelen – de kan inte sättas via integrations-API:et.

Visar ni ett startnummer själva – följ samma ordning som vi:

1. `raceNumber`, när `useCompetitorRaceNumberAsStartNumber` är `true` och värdet inte är tomt,
2. annars `code`, när tävlingen visar deltagarkod i startnumrets ställe (inställningen `useCompetitorCodeAsStartNumber`) och värdet inte är tomt,
3. annars `startNumber`.

Tävlingsnumret vinner alltså när båda inställningarna är på. Ordningen gäller bara **visningen**: deltagarens identitet i API:et är alltid `startNumber` – tidtagningshändelser, filter och startlistor använder det numret, aldrig det visade.

#### Sträckor som inte räknas in i resultatet (träningsheat)

Arrangören kan markera en sträcka som att den inte ska räknas in i resultatet – ett träningsheat är det typiska fallet. Sträckan körs och tidtas som vanligt, cellen ligger kvar i `heatResults` och visar sin tid, men den räknas inte in i `totalTime`, `totalLaps`, `gapToLeader`/`classGapToLeader`, `position`/`classPosition` eller kvalpoängen (`placement`, `points`, `totalPoints`).

| Fält | Plats | Typ | Beskrivning |
|------|-------|-----|-------------|
| `excludedFromTotal` | per sträckcell i `heatResults` | bool | `true` när sträckans tid visas men inte räknas in i resultatet |

Fältet utelämnas helt ur JSON när det är `false` – samma mönster som `placement`/`points` ovan. En cell utan fältet räknas alltså in som vanligt, och svaret för en tävling utan träningsheat är oförändrat. Sträckan ligger kvar i `heats` som förut, och sträcklistan (`GET /competitions/{id}/heats`) bär inte flaggan – den läses per cell i resultatsvaret.

**Summerar du sträcktider själv?** Hoppa över cellerna där `excludedFromTotal` är `true`, annars stämmer inte din summa med vårt `totalTime`.

```json
"heatResults": [
  { "heatNumber": 1, "timeMs": 92710, "penalty": 0, "status": 0, "excludedFromTotal": true },
  { "heatNumber": 2, "timeMs": 93100, "penalty": 0, "status": 0 }
]
```

#### Delad placering (`sharedPosition`, `sharedClassPosition`)

Två grenar kräver att tävlande som värderingsregeln inte kan skilja åt **delar placering**: backe (SBF MR 7.2, MR 9.4.1) och racing (RA 9.4.2). En tävling som delar placering numrerar därför inte varje rad för sig, och samma `position`/`classPosition` kan **återkomma på flera poster**. Det är avsiktligt, inte trasig data.

| Fält | Plats | Typ | Beskrivning |
|------|-------|-----|-------------|
| `sharedPosition` | per post i `entries` | bool | `true` när posten delar sin `position` med minst en annan post |
| `sharedClassPosition` | per post i `entries` | bool | `true` när posten delar sin `classPosition` med minst en annan post i samma klass |

**Båda fälten utelämnas helt ur JSON när de är `false`** – samma mönster som `excludedFromTotal` ovan och varvfälten längre ned. Payloaden är alltså oförändrad för varje tävling som inte delar placering, och en post utan fälten har sin placering för sig själv.

Tre saker att bygga mot:

1. **Numreringen blir `1, 2, 2, 4` – aldrig `1, 2, 2, 3`.** Den delade andraplatsen äter upp trean: raden efter har tre ekipage framför sig och är därmed fyra. Härled alltså aldrig placeringen ur radens position i listan.
2. **Flaggan sitter på hela den delade gruppen, också dess första post.** Två som delar andraplatsen bär båda `sharedPosition: true`, så ni kan rita den delade platsen utan att först gruppera eller sortera om listan.
3. **De två flaggorna följer inte varandra.** Klassplaceringen prövas mot närmast föregående post *i samma klass*, inte mot raden ovanför. En oskiljaktig grupp som spänner över flera klasser delar därför totalplacering utan att någon klassplacering delas – varje klass har bara en av dem.

Delningen sker först när värderingsregelns egna nycklar är uttömda. Startnumret, som annars är listans sista deterministiska särskiljning, räknas inte som en särskiljning här – det ordnar bara listan. Poster som regeln inte kan rangordna alls (varken tid, poäng eller varv) delar aldrig placering: de behåller var sitt nummer, i startnummerordning.

Delad placering är en tävlingsinställning som **inte kan sättas via integrations-API:et**, och flaggan för själva tävlingen ingår inte i svaret från `GET /competitions/{id}`. En tävling som publiceras hit får den aldrig automatiskt – grenen följer inte med i publiceringen. Läs `sharedPosition`/`sharedClassPosition` per post i stället.

```json
"entries": [
  { "position": 1, "classPosition": 1, "startNumber": 3, "totalTime": 50000 },
  { "position": 2, "classPosition": 2, "startNumber": 1, "totalTime": 60000, "sharedPosition": true, "sharedClassPosition": true },
  { "position": 2, "classPosition": 2, "startNumber": 9, "totalTime": 60000, "sharedPosition": true, "sharedClassPosition": true },
  { "position": 4, "classPosition": 4, "startNumber": 4, "totalTime": 70000 }
]
```

Exemplet visar **numreringen** (alla fyra i samma klass). *Vilka* poster som är oskiljaktiga avgörs av värderingsregeln – lika `totalTime` är inte i sig nog, se countbacken under `BestSingleHeat` nedan.

#### Bästa sträcka (`ratingRule: "BestSingleHeat"`)

Under `BestSingleHeat` räknas deltagarens **bästa enskilda sträcktid**, oavsett vilken sträcka den kördes på – backens modell, "bästa tid från någon av omgångarna". `totalTime` är den tiden, inte en summa.

`position`/`classPosition` rangordnas i den här ordningen:

1. **Lägst bästa sträcktid** (`totalTime`).
2. **Countback på de näst bästa tiderna.** Deltagarens sträcktider sorteras stigande och jämförs par för par: bästa mot bästa, näst bästa mot näst bästa och så vidare. Att ha en tid på en countbackposition slår att sakna den, så den som kört ytterligare en sträcka kan särskiljas på den. Det är SBF MR 7.2: "Om två eller flera deltagare har lika bästa tid, skiljs de åt med den nästa bästa tiden."
3. **Lägst startnummer** som sista, deterministiska särskiljning.

Sträckor som är markerade *räknas inte i resultatet* (`excludedFromTotal`, se ovan) deltar inte i countbacken.

**Observera – ändrat beteende:** countbacken tillkom 2026-09-15. Tidigare avgjordes lika bästa tider direkt på startnummer. En lista där två deltagare har lika bästa tid men olika näst bästa tid kan därför numreras om jämfört med vad ni hämtade tidigare, **också för en tävling som redan är körd**. Det är en rättelse mot regelboken, men den syns i era data.

#### Två bästa sträckor (`ratingRule: "BestTwoHeats"`)

Under värderingsregeln `BestTwoHeats` räknas en deltagares **två bästa sträckor**: summan av de två lägsta sträcktiderna avgör, oavsett vilka sträckor tiderna kördes på. Fler körda sträckor än två ger alltså ingen fördel utöver att de två bästa får väljas ut. `totalTime` är summan av de två räknande sträckorna.

`position`/`classPosition` rangordnas i den här ordningen:

1. **Flest räknande sträckor först.** Antalet räknande sträckor är `min(antal genomförda sträckor, 2)`. En deltagare med två sträcktider placeras alltså före en med bara en, och den i sin tur före en helt utan.
2. **Lägst summa** av de två lägsta sträcktiderna. Har deltagaren bara en sträcka jämförs den enda tiden.
3. **Countback** på deltagarens sträcktider sorterade i stigande ordning: bästa tiden jämförs först, därefter näst bästa och så vidare.
4. **Lägst startnummer** som sista, deterministiska särskiljning.

`gapToLeader`/`classGapToLeader` visas bara när posten har lika många räknande sträckor som ledaren (för `classGapToLeader`: ledaren i klassen). Skiljer sig antalet är fältet `null` – en tidsdifferens mot någon som kört fler räknande sträckor vore missvisande. Sträckor som är markerade *räknas inte i resultatet* (`excludedFromTotal`, se ovan) räknas inte heller här.

#### Kvalpoäng (rallycross, `ratingRule: "RallycrossPoints"`)

När tävlingen använder värderingsregeln `RallycrossPoints` (standard för nya rallycrosstävlingar) utökas svaret med tre valfria fält. Fälten utelämnas helt ur JSON när de är `null` – befintliga Sprint/Rally-payloads och rallycrosstävlingar med andra värderingsregler är alltså oförändrade.

| Fält | Plats | Typ | Beskrivning |
|------|-------|-----|-------------|
| `placement` | per sträckcell i `heatResults` | int? | Placering i klassen i kvalomgången (1..k). `null` för statusresultat (DNS/DNF/DSQ) |
| `points` | per sträckcell i `heatResults` | int? | Kvalpoäng för kvalomgången enligt SBF RC 7.2.1 |
| `totalPoints` | per post i `entries` | int? | Summerade kvalpoäng över alla kvalomgångar (SBF RC 7.2) |

**Exempel på en post under kvalpoängregeln**

```json
{
  "position": 1,
  "classPosition": 1,
  "startNumber": 5,
  "totalTime": 185810,
  "totalPoints": 155,
  "gapToLeader": null,
  "classGapToLeader": null,
  "heatResults": [
    { "heatNumber": 1, "timeMs": 92710, "penalty": 0, "status": 0, "placement": 1, "points": 80 },
    { "heatNumber": 2, "timeMs": 93100, "penalty": 0, "status": 0, "placement": 2, "points": 75 }
  ]
}
```

Poängberäkningen sker per klass och kvalomgång: placering på sträcktid ger 80/75/72/70/69/68 poäng för plats 1–6, därefter −1 poäng per ytterligare placering. DNF ger 1 poäng under sista förare i kvalomgångens fastställda startlista; DNS och DSQ ger 5 poäng under. En deltagare som helt saknar resultat i en stängd kvalomgång räknas som DNS.

`position`/`classPosition` rangordnas på högst `totalPoints`. Vid lika poäng särskiljs förarna på bästa placering i någon kvalomgång (countback), därefter bästa enskilda sträcktid (countback), därefter lägst startnummer (deterministisk ersättning för regelbokens lottning). `gapToLeader` och `classGapToLeader` är alltid `null` under denna regel – rangordningen sker på poäng, så en tidsdifferens vore missvisande.

#### Kvalpoäng (folkrace, `ratingRule: "FolkracePoints"`)

Folkrace körs på tävlingstypen Rallycross (`competitionType: 2`) men poängsätts enligt sin egen regelbok (SBF FR 7.2.1/FR 7.3). Svaret utökas med samma tre valfria fält som under rallycrossregeln – `placement` och `points` per sträckcell samt `totalPoints` per post – och fälten utelämnas ur JSON när de är `null`.

Poängen kommer ur **tävlingens egen tabell**: regelboken lägger poängberäkningen i tävlingsinbjudan, så det finns ingen nationellt fastställd folkracetabell motsvarande rallycrossens 80/75/72/70/69/68. Plattformens standardtabell är `7,5,4,3,2,1` för plats 1–6, därefter −1 poäng per ytterligare placering, dock aldrig under 0. Statuspoängen är däremot regelbokens och fasta: brutet heat (DNF) ger 1 poäng, svartflagg (DSQ) 0 poäng, och **ej start (DNS) ger ingen poängcell alls – inte 0**. Detsamma gäller en deltagare som saknar resultatrad i ett heat; till skillnad från rallycrossregeln förankras ingenting i startlistan.

`position`/`classPosition` rangordnas på högst `totalPoints`. Vid lika poäng särskiljs förarna på högsta enskilda heatpoäng (countback), därefter lägst startnummer. `gapToLeader` och `classGapToLeader` är alltid `null` – rangordningen sker på poäng, precis som under rallycrossregeln.

Poängtabellen är en tävlingsinställning som arrangören sätter i adminpanelen och **kan inte sättas via integrations-API:et**. En tävling som publiceras hit poängsätts därför alltid med standardtabellen ovan tills arrangören anger inbjudans egen tabell.

#### Kvalpoäng (crosskart, `ratingRule: "CrosskartPoints"`)

Även crosskart körs på tävlingstypen Rallycross (`competitionType: 2`), men poängsätts enligt SBF CK 7.2. Svaret utökas med samma tre valfria fält som ovan – `placement` och `points` per sträckcell samt `totalPoints` per post – och fälten utelämnas ur JSON när de är `null`.

Till skillnad från folkracets tabell är crosskartens **nationellt fastställd** och därmed fast: `10,7,5,3,2,1` för plats 1–6, därefter −1 poäng per ytterligare placering, dock aldrig under 0. Den kan varken sättas via integrations-API:et eller i adminpanelen. Statuspoängen: avbrutet kvalheat (DNF) ger 0 poäng och utesluten ur heat (DSQ) ger också 0 poäng, medan ej start (DNS) **inte ger någon poängcell alls** – inte 0, eftersom 0 är det avbrutna heatets poäng. Detsamma gäller en deltagare som saknar resultatrad i ett heat; liksom under folkracereglen förankras ingenting i startlistan.

`position`/`classPosition` rangordnas på högst `totalPoints`. Vid lika poäng särskiljs förarna enligt CK 7.2: först på antal segrar, därefter antal andraplatser och så vidare nedåt; därefter på placeringen i den **senaste** kvalomgången och baklänges genom omgångarna; sist på lägst startnummer (deterministisk ersättning för regelbokens lottning). Det finns alltså ingen tidsskiljare, till skillnad från rallycrossregeln. `gapToLeader` och `classGapToLeader` är alltid `null`, precis som under de andra poängreglerna.

**Observera:** placeringen i kvalheatet härleds ur den tidtagna tiden (justerad heattid, därefter startnummer). En crosskarttävling som körs helt utan tidtagning kan därför inte poängsättas – det finns inga placeringar att räkna på.

#### Varv (`ratingRule: "LapsThenTime"`)

Racing och långlopp rangordnar på **varv**, inte på tid (RA 9.4, NE1 9.0). Under värderingsregeln `LapsThenTime` utökas svaret med fyra valfria fält:

| Fält | Plats | Typ | Beskrivning |
|------|-------|-----|-------------|
| `laps` | per sträckcell i `heatResults` | int? | Antal hela varv på sträckan. Sätts för status OK och DNF; **inte** för DNS eller DSQ |
| `totalLaps` | per post i `entries` | int? | Summan av varven över de räknande sträckorna (de utan `excludedFromTotal`) |
| `lapsDownToLeader` | per post i `entries` | int? | Antal varv efter totalledaren. `0` betyder samma varv som ledaren. Sätts bara under `LapsThenTime` |
| `classLapsDownToLeader` | per post i `entries` | int? | Samma sak mot ledaren i klassen |

**Alla fyra utelämnas helt ur JSON när de är `null`.** Payloaden är alltså byte-identisk för varje partner som inte kör varvräkning – ett svar utan registrerade varv ser exakt ut som förut. `laps` och `totalLaps` dyker upp så snart ett varvantal faktiskt registrerats, oavsett värderingsregel; `lapsDownToLeader` och `classLapsDownToLeader` sätts bara under `LapsThenTime`.

Grenen (racing, långlopp) sätts bara av arrangören i adminpanelen och följer inte med i integrations-API:et, så en tävling som publiceras härifrån får aldrig varvregeln automatiskt. Ange därför `ratingRule: "LapsThenTime"` uttryckligen när ni publicerar en varvrankad tävling – se *Publicera / uppdatera en tävling*.

`position`/`classPosition` rangordnas i den här ordningen:

1. **Flest varv först** (`totalLaps`) – varvantalet är den primära nyckeln, inte tiden.
2. **Lägst sammanlagd tid** (`totalTime`). Inom en grupp med lika varvantal har alla kört samma distans, så lägre förfluten tid betyder att man passerade mållinjen först.
3. **Lägst startnummer** som sista, deterministiska särskiljning.

Poster helt utan varvantal sorteras sist, inbördes på tid och därefter startnummer. En tävling där tidtagningen ännu inte skickar varv rangordnas alltså på tid som förut – den tomma varvkolumnen är signalen, inte en trasig ordning.

**`gapToLeader` har tre fall under den här regeln**, inte två:

- `lapsDownToLeader: 0` – posten ligger på samma varv som ledaren. `gapToLeader` är då en **verklig tidsskillnad** i millisekunder.
- `lapsDownToLeader` är `1` eller mer – posten är varv efter ledaren. `gapToLeader` är då `null`: en tidsskillnad mellan två som kört olika långt vore meningslös. Läs `lapsDownToLeader` i stället.
- `lapsDownToLeader` saknas helt (utelämnat) – posten har inget varvantal alls och är inte jämförbar. `gapToLeader` är `null`.

Noll och utelämnat är alltså **inte** samma sak: `0` betyder "samma varv som ledaren, gapet intill är en riktig tid", ett utelämnat fält betyder "inget varvantal registrerat". Saknar posten eller ledaren en totaltid är `gapToLeader` `null` även vid lika varvantal. `classGapToLeader` och `classLapsDownToLeader` följer exakt samma tre fall, mot ledaren i klassen.

**Ett brutet lopp (DNF) är fortfarande klassificerat under den här regeln.** NE1 9.0 och RA 9.4 kräver det: den som bryter behåller sina varv och sin placering, och kan därför **slå en förare som kom i mål på färre varv**. Det är ett avsteg från hur alla andra värderingsregler behandlar en DNF, och en resultatvy som sorterar bort allt som inte är status `0` blir fel här. DNF-cellens *tid* räknas som vanligt inte in i `totalTime` – men dess `laps` räknas in i `totalLaps`. DNS och DSQ bär inget varvantal i resultatsvaret: cellens `laps` utelämnas.

**Exempel på en post under varvregeln** (en post som ligger ett varv efter ledaren):

```json
{
  "position": 2,
  "classPosition": 2,
  "startNumber": 7,
  "totalTime": 3612400,
  "totalLaps": 41,
  "lapsDownToLeader": 1,
  "classLapsDownToLeader": 1,
  "gapToLeader": null,
  "classGapToLeader": null,
  "heatResults": [
    { "heatNumber": 1, "timeMs": 3612400, "penalty": 0, "status": 0, "laps": 41 }
  ]
}
```

---

### Hämta startlista för en tävling (alla sträckor)

```http
GET /competitions/{id}/start-list
```

**Svar**

```json
[
  {
    "id": 201,
    "heatId": 45,
    "heatNumber": 1,
    "competitorId": 88,
    "startNumber": 5,
    "name": "Erik Svensson",
    "startOrder": 1,
    "scheduledStartTimeMs": null
  }
]
```

---

### Hämta startlista för en specifik sträcka

```http
GET /heats/{heatId}/start-list
```

Samma svarsformat som ovan, utan fältet `heatNumber`. `heatId` är sträckans interna ID (hämtas från `/competitions/{id}/heats`).

---

### Hämta tävlingsinformation (detalj)

```http
GET /competitions/{id}
```

**Svar**

```json
{
  "id": 12,
  "name": "Midnattsloppet 2026",
  "competitionType": 1,
  "startDate": "2026-07-15T00:00:00Z",
  "isActive": true,
  "isLocked": false,
  "ratingRule": "Default",
  "useCompetitorCodeAsStartNumber": false,
  "useCompetitorRaceNumberAsStartNumber": false,
  "activeHeat": 1,
  "totalHeats": 3,
  "classes": [
    { "order": 1, "title": "Klass 1", "className": "Open" }
  ]
}
```

---

### Hämta klasser

```http
GET /competitions/{id}/classes
```

**Svar**

```json
[
  { "id": 7, "order": 1, "title": "Klass 1", "className": "Open" }
]
```

---

### Hämta deltagare (fullständig lista)

```http
GET /competitions/{id}/competitors
```

**Svar**

```json
[
  {
    "id": 88,
    "startNumber": 5,
    "code": "",
    "raceNumber": "",
    "firstName": "Erik",
    "lastName": "Svensson",
    "className": "Open",
    "classOrder": 1,
    "engineClass": "",
    "club": "Dalens MK",
    "car": "Volvo 240",
    "coDriver": "Anna Svensson",
    "notifier": ""
  }
]
```

`code` och `raceNumber` är deltagarens alternativa nummer och kan vara tomma – se *Visat startnummer (kod och tävlingsnummer)* för i vilken ordning de visas.

---

### Hämta tidtagningshändelser (rålogg)

Returnerar de rådata tidtagningshändelser (start/mål/mellantid) som registrerats, nyaste först. Fältet `type` kan vara `RallyStart`, `RallyFinish` eller `RallyIntermediate`. Varje rad innehåller `channel` (tidtagningslinjen: `1` = Start, `2` = Mål, `>= 3` = mellantid) och `status` (`Unassigned`, `Assigned` eller `Rejected`).

```http
GET /competitions/{id}/timing-events
```

Valfria query-parametrar:

| Parameter | Typ | Beskrivning |
|-----------|-----|-------------|
| `heatNumber` | int | Filtrera till en specifik sträcka (SS-nummer) |
| `startNumber` | int | Filtrera till ett specifikt startnummer |
| `take` | int | Max antal rader (1–1000, standard 200) |

**Svar**

```json
[
  {
    "id": 4501,
    "type": "RallyFinish",
    "heatId": 45,
    "heatNumber": 1,
    "startNumber": 5,
    "channel": 2,
    "status": "Assigned",
    "timestampMs": 73417833,
    "receivedUtc": "2026-07-15T18:23:37.833Z",
    "laps": 41
  }
]
```

| Fält | Typ | Beskrivning |
|------|-----|-------------|
| `laps` | int? | Antal hela varv som **passeringen** bar med sig – samma värde som skickades in med `POST /timing-events` eller `POST /heats/{heatId}/timing-events`, inte resultatradens summerade varvantal |

`laps` ligger **sist** i objektet; inget befintligt fält har flyttats. `null` betyder att passeringen inte rapporterade något varvantal – noll varv är ett rapporterat värde och kommer tillbaka som `0`. Fältet är alltså ifyllt bara för händelser vars avsändare skickade med ett varvantal.

**Här skrivs `laps` alltid ut, även när det är `null`.** Det skiljer sig från de andra två varvytorna: varvfälten i resultatsvaret (se *Varv*) och `laps` i webhookens `data` **utelämnas** helt ur JSON när de saknas – webhookens undantag beror på att HMAC-signaturen räknas byte för byte över kroppen. Rådloggen är osignerad, och nyckeln finns på varje rad oavsett om passeringen rapporterade varv eller inte. Parsar ni strikt: räkna med `"laps": null` här, och med ett saknat fält i de andra två.

Rådloggen visar vad som **kom in**, inte resultatradens nuvarande tillstånd: rättar arrangören ett varvantal i adminpanelen ändras resultatraden, medan händelsens `laps` står kvar som det postades. Läs resultatradens varvantal via `GET /competitions/{id}/results` (`heatResults[].laps`).

`timestampMs` fungerar **inte** likadant: det är det enda inskickade fältet som inte kommer tillbaka som ni skickade det. Tidsstämpeln räknas om när händelsen tas emot, till plattformens enda konvention – **millisekunder sedan midnatt i svensk klocktid** (`Europe/Stockholm`) – och det är det omräknade värdet som ligger här. Skickar ni Unix-ms (se *Tidsstämpelformat*, som fortfarande är rätt format att skicka) får ni alltså **inte** Unix-ms tillbaka: `73417833` i exemplet ovan är klockan 20:23:37,833 svensk tid. Händelser från arrangörens egen tidtagning bär samma konvention, så alla rader i svaret är jämförbara med varandra.

**Behöver ni en absolut tidpunkt: använd `receivedUtc`.** Det är det enda fältet på raden som bär ett datum. `receivedUtc` är tidpunkten då **vi tog emot** händelsen (UTC), inte passeringens egen – för en tidtagning som skickar löpande skiljer de sig med nätverksfördröjningen, för en efterskickad batch med mer. Behöver ni kunna knyta en rad tillbaka till er egen ursprungsstämpel: **spara den hos er**. Vi behåller ert inskickade värde i vår händelselogg, men det exponeras inte i det här svaret.

Sorteringen – *nyaste först* – sker på `receivedUtc`, alltså på när vi tog emot raden, och `take` skär i den ordningen. **Inte på `timestampMs`:** ett klockslag bär inget datum, så på ett flerdagarsrally hade raderna med högst tid *på dygnet* kommit först, och eftersom `take` skär direkt efteråt hade gårdagens kvällssträcka slagit ut dagens senaste passeringar. `receivedUtc` är den enda kolumnen på raden som är en absolut tidpunkt och därmed den enda som kan bära löftet. Inom ett och samma tävlingsdygn ger de två ordningarna samma resultat – undantaget är rader som skickats in i efterhand, som har ett gammalt klockslag men en färsk mottagningstid. Filtrera på `heatNumber` när ni vill ha en avgränsad, entydig mängd.

---

### Skicka in en tidtagningshändelse (mot vårt sträck-ID)

Kräver skrivbehörighet (`CanWrite`). Använd denna variant när din nyckel är låst till en av **våra** tävlingar och du skickar tider mot ett sträck-ID du hämtat via `GET /competitions/{id}/heats`.

```http
POST /heats/{heatId}/timing-events
Content-Type: application/json
```

**Request-body**

```json
{
  "type": "RallyStart",
  "startNumber": 5,
  "timestampMs": 1750000000000,
  "penaltyMs": null,
  "channel": null,
  "laps": null
}
```

| Fält | Typ | Beskrivning |
|------|-----|-------------|
| `type` | string | `"RallyStart"`, `"RallyFinish"` eller `"RallyIntermediate"` |
| `startNumber` | int | Deltagarens startnummer |
| `timestampMs` | long | Unix-tidsstämpel i millisekunder (tidtagningsklocka, inte väggklocka). **Måste vara större än `0`** – se *Avvisade värden* nedan. Lagras omräknad till svenskt klockslag – se *Tidsstämpelformat* |
| `penaltyMs` | long? | Valfritt tillägg i millisekunder |
| `channel` | int? | Tidtagningslinje. Utelämnas eller `null` för start/mål (tvingas till 1 = Start, 2 = Mål). Sätt `>= 3` för `RallyIntermediate` (3 = Mellan 1, 4 = Mellan 2 …) |
| `laps` | int? | Valfritt antal hela varv vid passeringen. `0` och uppåt; **ett negativt antal avvisas** – se *Avvisade värden* nedan. Sparas och går att läsa tillbaka oavsett värderingsregel (se *Hämta tidtagningshändelser* och *Varv*); det är bara **rangordningen** som använder varvantalet, och bara under `LapsThenTime` |

`heatId` i URL:en är sträckans interna ID (hämtas från `/competitions/{id}/heats`).

**Utelämnat `laps` betyder "vet inte", inte "noll varv".** Skickar ni inget `laps` (eller `null`) lämnas resultatradens varvantal **orört** – exakt som `penaltyMs` fungerar. Ett system som inte räknar varv kan alltså fortsätta skicka precis vad det skickar i dag och kommer aldrig att nolla ett varvantal som arrangören lagt in för hand. Vill ni sätta varvantalet till noll skickar ni `"laps": 0`; noll och utelämnat är skilda fakta hela vägen genom API:et. Utelämnat (`null`), `0` och ett positivt tal är alltså **tre** skilda fakta – "inte rapporterat", "körde noll varv" och ett faktiskt antal – medan ett **negativt** varvantal är omöjligt och avvisas med `400` (se *Avvisade värden* nedan).

**Svar** – `202 Accepted` vid framgång. Resultattabellen uppdateras i realtid.

**curl-exempel**

```bash
curl -X POST "https://api.tavlingsconsult.com/api/integration/v1/heats/45/timing-events" \
  -H "X-API-KEY: tc_live_din_nyckel_har" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "RallyStart",
    "startNumber": 5,
    "timestampMs": 1750000000000,
    "penaltyMs": null
  }'
```

Samma anrop från en tidtagning som räknar varv – `laps` läggs bara till:

```bash
curl -X POST "https://api.tavlingsconsult.com/api/integration/v1/heats/45/timing-events" \
  -H "X-API-KEY: tc_live_din_nyckel_har" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "RallyFinish",
    "startNumber": 5,
    "timestampMs": 1750000092710,
    "penaltyMs": null,
    "laps": 41
  }'
```

#### Avvisade värden (gäller båda tidtagnings-endpointarna)

Två fält prövas innan någonting skrivs – här och på `POST /timing-events` (se *Publicera en tidtagningshändelse*):

| Villkor | Svar |
|---------|------|
| `timestampMs` är `0` eller negativt | `400` `{ "error": "Tidsstämpeln måste vara större än 0." }` |
| `laps` är negativt | `400` `{ "error": "Varvantalet kan inte vara negativt." }` |

Tidsstämpeln prövas först: bär kroppen båda felen får ni tidsstämpelns meddelande.

**Ingenting skrivs vid ett avslag.** Ingen tidtagningsrad, ingen rad i rådloggen (`GET /competitions/{id}/timing-events`) och ingen post i vår händelselogg. På `POST /timing-events` gäller det även sträckan och tävlingen: det anropet skapar annars båda som en sidoeffekt, men kontrollerna ligger före den upserten. Ett avvisat anrop lämnar alltså aldrig en halv tävling efter sig.

**`laps: 0` är fortfarande giltigt** och besvaras med `202` som vilket varvantal som helst – noll körda varv är ett rapporterat faktum. Bara det negativa avvisas, och det *avvisas* i stället för att klippas till `0`: en klippning hade skrivit in ett varvantal ni aldrig skickade, och eftersom `0` är ett giltigt värde hade det efteråt inte gått att skilja från ett äkta nollvarv.

**Noll avvisas däremot för `timestampMs`**, och det är ingen motsägelse mot raden ovan. Fältet är obligatoriskt och inte nullbart, så en kropp som **utelämnar** det kommer fram som exakt `0` – "midnatt precis" och "jag glömde fältet" är samma tal på tråden, och de går inte att skilja åt. `laps` är nullbart och har inte det problemet. Den glömda varianten är dessutom farlig på just det sätt spärren finns för: en `RallyFinish` med tidsstämpel `0` mot en verklig starttid ger en negativ sträcktid.

**Kontrollen gäller värdet ni skickar, inte värdet vi lagrar.** Den ligger före omräkningen till svenskt klockslag (se *Tidsstämpelformat*), så en Unix-stämpel passerar alltid – ett sådant tal ligger i storleksordningen 10¹² – även när tidpunkten råkar vara svensk midnatt och lagras som `0`. Ett lagrat `0` är alltså fortsatt giltigt; det är bara nollan på tråden som inte går att lita på. Menar ni verkligen midnatt precis: skicka Unix-ms som vanligt.

**Det här är en ändring av beteendet.** Båda fallen svarade tidigare `202`. Skickar ert system negativa varvantal, eller en tidsstämpel som är `0` eller mindre, får ni nu `400` där ni förut fick `202`. Det som gick igenom skrev dock redan trasiga data: en negativ tidsstämpel gav en negativ sträcktid, och en negativ totaltid sorterar **före varenda verklig tävlande** i resultatlistan, medan ett negativt varvantal rangordnades på under `LapsThenTime`. Felet syns nu vid ingången i stället för i resultatlistan.

---

## Publicera komplett tävlingsdata med egna ID:n

Om ni vill publicera en hel tävling till oss – tävlingsinfo, klasser, sträckor, deltagare och tider – använder ni publicerings-endpointarna nedan. De kräver en **olåst skrivnyckel** (`CanWrite`, utan tävlingslås). En låst nyckel får `403`.

Ni anger era **egna ID:n** (`externalId`, `competitionExternalId`). Vi håller isär varje partners data i ett eget namnområde, så era ID:n krockar aldrig med våra egna eller andra partners. Samma anrop igen med samma ID:n uppdaterar (upsert) befintlig post i stället för att skapa dubletter.

Allt som publiceras landar i vårt system precis som data från vår egen tidtagning: det visas på vår resultatsida i realtid **och** skickas vidare till era ev. utgående webhooks.

### Publicera / uppdatera en tävling

```http
POST /competitions
```

```json
{
  "externalId": 1001,
  "name": "Midnattsloppet 2026",
  "competitionType": 1,
  "isActive": true,
  "startDate": "2026-07-15T00:00:00Z",
  "ratingRule": "Default"
}
```

| Fält | Typ | Beskrivning |
|------|-----|-------------|
| `externalId` | int | **Ert** ID för tävlingen (används i övriga publicerings-anrop) |
| `name` | string | Tävlingens namn |
| `competitionType` | int? | `0` = Sprint, `1` = Rally, `2` = Rallycross (beter sig som Rally i integrations-API:et; rankas som standard med kvalpoäng, se `ratingRule`) |
| `isActive` | bool | Markerar tävlingen som aktiv |
| `startDate` | datetime? | Startdatum (UTC) |
| `ratingRule` | string? | `Default`, `BestSingleHeat`, `BestTwoHeats`, `RallycrossPoints`, `FolkracePoints`, `CrosskartPoints` eller `LapsThenTime` |

Om `ratingRule` utelämnas (eller är ogiltigt) vid en uppdatering behålls tävlingens befintliga värderingsregel – en re-upsert skriver alltså inte över den. Anges ingen regel när tävlingen **skapas** väljs standardregeln av grenen i första hand och tävlingstypen i andra: gren Folkrace ger `FolkracePoints`, gren Crosskart ger `CrosskartPoints`; i övrigt ger `competitionType: 2` (Rallycross) `RallycrossPoints` och andra tävlingstyper `Default`. Grenen följer aldrig med i publiceringsanropet, så i praktiken är det bara de två sista fallen som kan inträffa för en tävling som skapas härifrån.

Grenen sätts bara av arrangören i adminpanelen och följer inte med i publiceringsanropet – en tävling som skapas härifrån har alltså ingen folkrace- eller crosskartgren att gå på. Publicerar ni folkrace eller crosskart anger ni därför `ratingRule: "FolkracePoints"` respektive `"CrosskartPoints"` uttryckligen, och för en varvrankad tävling (racing, långlopp) `"LapsThenTime"`. Folkracets poängtabell kan inte publiceras hit (den är en adminpanelinställning, se *Kvalpoäng (folkrace)*), så tävlingen poängsätts med plattformens standardtabell tills arrangören anger inbjudans egen. Crosskartens tabell är nationellt fastställd och går aldrig att ändra.

**Svar:** `{ "id": 12, "externalId": 1001 }` – `id` är vårt interna ID (för läsanrop).

### Publicera / uppdatera en klass

```http
POST /classes
```

```json
{ "competitionExternalId": 1001, "order": 1, "title": "Klass 1", "className": "Open" }
```

`order` identifierar klassen inom tävlingen och kopplas till deltagarnas `classOrder`.

### Publicera / uppdatera en sträcka (heat)

```http
POST /heats
```

```json
{ "competitionExternalId": 1001, "heatNumber": 1 }
```

**Svar:** `{ "competitionId": 12, "heatId": 45, "heatNumber": 1 }`.

### Publicera / uppdatera deltagare (en eller flera)

```http
POST /competitors
```

```json
{
  "competitionExternalId": 1001,
  "competitors": [
    {
      "startNumber": 5,
      "firstName": "Erik",
      "lastName": "Svensson",
      "classOrder": 1,
      "club": "Dalens MK",
      "car": "Volvo 240",
      "coDriver": "Anna Svensson",
      "code": "",
      "raceNumber": "",
      "engineClass": "",
      "notifier": ""
    }
  ]
}
```

`startNumber` identifierar deltagaren inom tävlingen. `classOrder` måste matcha en klass `order`. `code` och `raceNumber` är valfria strängar (får vara tomma) – `raceNumber` är ekipagets eget tävlingsnummer, se *Visat startnummer (kod och tävlingsnummer)*. Om numren ska visas i startnumrets ställe styrs av tävlingens inställningar i adminpanelen, inte av publiceringsanropet.

### Publicera en tidtagningshändelse (mot era ID:n)

```http
POST /timing-events
```

```json
{
  "competitionExternalId": 1001,
  "heatNumber": 1,
  "type": "RallyStart",
  "startNumber": 5,
  "timestampMs": 1750000000000,
  "penaltyMs": null,
  "channel": null,
  "laps": null
}
```

Sträckan skapas automatiskt om den inte redan finns. Start- och måltider kombineras till en sträcktid precis som för vår egen tidtagning. Samma `type`- och `channel`-regler gäller som för endpointen mot vårt sträck-ID: sätt `type: "RallyIntermediate"` med `channel >= 3` för mellantider. `timestampMs` skickas som Unix-ms här också, och räknas om till svenskt klockslag vid mottagandet – se *Tidsstämpelformat*. **Svar:** `202 Accepted`.

`laps` (int?) är valfritt och har exakt samma innebörd här: antal hela varv vid passeringen, och **utelämnat betyder "vet inte" – radens varvantal lämnas orört**, aldrig nollat. `0` är ett giltigt varvantal och betyder noll körda varv. Varvantalet sparas och går att läsa tillbaka oavsett värderingsregel (`GET /competitions/{id}/timing-events` ger tillbaka passeringens `laps`); bara rangordningen använder det, och bara under `LapsThenTime` – se *Varv*.

**Samma två kontroller som på endpointen mot vårt sträck-ID gäller här** – ett `timestampMs` som är `0` eller mindre och ett negativt `laps` avvisas med `400`, se *Avvisade värden (gäller båda tidtagnings-endpointarna)* för meddelandena och skälen. Kontrollerna ligger **före** sträck-upserten, så ett avvisat anrop skapar varken tävling eller sträcka i ert namnområde: det lämnar ingenting efter sig att städa.

**Typiskt arbetsflöde för att publicera en hel tävling:**

1. `POST /competitions` – skapa tävlingen
2. `POST /classes` – en gång per klass
3. `POST /competitors` – deltagarna (kan skickas i grupp)
4. `POST /timing-events` – tider löpande under tävlingen, **eller** `POST /results` – färdiga sträckresultat (se nästa avsnitt)

---

## Skicka in färdiga resultat

Har ni en resultatlista men inga start- och målstämplar – en annan tidtagning, ett eget resultatsystem – skickar ni sträckresultaten som de är: tid, tillägg, status och varv per deltagare. Ni behöver alltså inte bryta ner en lista i passeringar. Två varianter, samma regler:

| Endpoint | Nyckel | Används när |
|----------|--------|-------------|
| `POST /results` | Olåst skrivnyckel | Ni publicerar tävlingen med era egna ID:n (se ovan) |
| `POST /heats/{heatId}/results` | Skrivnyckel, får vara låst till tävlingen | Ni levererar resultat till en av **våra** tävlingar, mot ett sträck-ID från `GET /competitions/{id}/heats` |

```http
POST /results
Content-Type: application/json
```

```json
{
  "competitionExternalId": 1001,
  "heatNumber": 2,
  "results": [
    { "startNumber": 5, "status": 0, "timeMs": 92710, "penaltyMs": 0 },
    { "startNumber": 7, "status": 0, "timeMs": 93100, "penaltyMs": 10000, "laps": 41 },
    { "startNumber": 9, "status": 2, "laps": 12 },
    { "startNumber": 11, "status": 1 }
  ]
}
```

`POST /heats/{heatId}/results` tar samma kropp utan `competitionExternalId` och `heatNumber`: `{ "results": [ … ] }`.

| Fält | Typ | Beskrivning |
|------|-----|-------------|
| `startNumber` | int | Deltagarens startnummer. **Deltagaren måste redan finnas** i tävlingen |
| `status` | int | `0` = OK (standard om fältet utelämnas), `1` = DNS, `2` = DNF, `3` = DSQ |
| `timeMs` | long? | Sträcktiden i millisekunder. **Krävs och måste vara större än `0` för status `0`.** Valfri för övriga statusar (`0` om den utelämnas) |
| `penaltyMs` | long? | Tillägg i millisekunder. Negativt drar av, men aldrig under noll (`timeMs + penaltyMs` måste vara `0` eller mer). **Utelämnat = `0`** |
| `laps` | int? | Antal hela varv. `0` och uppåt. **Utelämnat lämnar radens varvantal orört** |

**Varje rad är hela sträckresultatet.** Tid, tillägg och status skrivs alltid – ett utelämnat `penaltyMs` betyder "inget tillägg", inte "vet inte". Det skiljer sig medvetet från tidtagningshändelserna, där en passering bara bär en del av raden. Varvet är undantaget, av samma skäl som där: ett system som inte räknar varv ska inte nolla ett varvantal som arrangören lagt in för hand.

**Samma anrop igen ger samma resultat.** Raden skrivs över, så ni kan skicka hela sträckan på nytt efter varje ändring utan att få dubbletter. Ändras ett resultat på en sträcka som arrangören stängt öppnas sträckan igen, och listan blir preliminär tills den stängs på nytt – precis som när vår egen tidtagning rättar en tid.

**Allt eller inget.** Hela anropet prövas innan något skrivs. En enda felaktig rad avvisar alla med `400`, och svaret listar varje fel på en gång, så att ni kan rätta allt i ett svep:

```json
{
  "error": "Resultaten avvisades. Ingenting sparades.",
  "errors": [
    { "index": 1, "startNumber": 99, "error": "Okänt startnummer. Publicera deltagaren först." },
    { "index": 2, "startNumber": 12, "error": "Ett resultat med status 0 (OK) måste ha timeMs större än 0." }
  ]
}
```

`index` är radens plats i `results`, från `0`. Övriga fel per rad: samma startnummer två gånger i anropet, en status utanför `0`–`3`, en negativ tid, ett negativt varvantal och ett tillägg som gör tiden negativ. Högst **1000 rader** per anrop.

För `POST /results` måste tävlingen redan vara publicerad (annars `404`); sträckan skapas däremot vid behov, men först när hela anropet godkänts.

**Svar:** `202 Accepted` med `{ "competitionId": 12, "heatId": 46, "count": 4 }`. Resultatlistan, livesidan och de utgående `results`-webhooks som bevakar tävlingen uppdateras direkt.

---

## Tidsstämpelformat

### Vad ni skickar

`timestampMs` skickas in som Unix-tid i millisekunder – antalet millisekunder som förflutit sedan 1970-01-01T00:00:00Z. **Det formatet är oförändrat**: skickar ert system `Date.now()` i dag behöver ni inte ändra någonting. Använd en exakt tidtagningsklocka, inte telefonens systemklocka, för korrekta resultat.

Värdet måste vara **större än `0`** – ett `0` eller ett negativt tal avvisas med `400` (se *Avvisade värden (gäller båda tidtagnings-endpointarna)*). En Unix-stämpel klarar det alltid; kontrollen finns för kroppar som tappat fältet.

**Exempel (JavaScript):**
```js
const timestampMs = Date.now();
```

**Exempel (C#):**
```csharp
var timestampMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
```

### Vad som händer med värdet hos oss

Vid mottagandet räknas tidsstämpeln om till plattformens enda konvention: **millisekunder sedan midnatt i svensk klocktid**, tidszon `Europe/Stockholm`. Omräkningen använder den tidszonsavvikelse som gällde **vid just den tidpunkten**, så en sommartävling (UTC+02) och en vintertävling (UTC+01) blir båda rätt.

Ett exempel: passeringen 2026-07-15 kl. 20:22:05,123 svensk tid skickas som `1784139725123` och läses tillbaka som `73325123` – 20 h 22 min 5,123 s sedan midnatt.

Gränsen mellan de två formaten är exakt och ingen gissning: ett värde **under ett dygn** (86 400 000) är redan ett klockslag och lämnas orört, ett värde **från ett dygn och uppåt** tolkas som Unix-tid och räknas om. Ett verkligt klockslag kan aldrig förväxlas med en Unix-stämpel – de skiljer sig med fem tiopotenser.

Det här påverkar er på fyra sätt:

- **`GET /competitions/{id}/timing-events` ger inte tillbaka det ni skickade.** `timestampMs` bär klockslaget, inte er Unix-stämpel.
- **Detsamma gäller den utgående webhooken `timing-event`**: `data.timestampMs` är klockslaget.
- **Behöver ni ett datum: använd `receivedUtc`** (i rådloggen) eller `sentAtUtc` (i webhookens envelope). Ett klockslag bär inget datum alls.
- **Skillnader och sorteringar inom samma dygn påverkas inte.** Differensen mellan två klockslag är densamma som differensen mellan de två Unix-stämplarna, så sträcktider, mellantider och ordningsföljd blir oförändrade. Över en midnattspassage gäller det inte – en klocktidsskillnad kan bli negativ där.

**Natten då sommartiden slutar** inträffar klockan 02:30 två gånger, och båda passeringarna får samma `timestampMs`. Det är en egenskap hos konventionen i sig, inte hos omräkningen – en tid som arrangörens egen tidtagning matat in är exakt lika tvetydig. Kör ni ett lopp över den timmen: knyt era egna rader till `receivedUtc` i stället.

---

## Händelsetyper

| Typ | Beskrivning |
|-----|-------------|
| `RallyStart` | Deltagaren passerar startlinjen (kanal 1) |
| `RallyFinish` | Deltagaren passerar mållinjen (kanal 2) |
| `RallyIntermediate` | Deltagaren passerar en mellantidslinje (kanal ≥ 3) |

Start- och måltider kombineras av plattformen för att beräkna sträcktiden. Om ett startnummer redan har en tilldelad start-/måltid i systemet skriver en ny händelse av samma typ över den tidigare tilldelningen (synligt i tidtagningsöversikten).

### Kanaler och mellantider

Varje tidtagningshändelse hör till en **kanal**: `1` = Start, `2` = Mål, `>= 3` = mellantid (`channel 3` = Mellan 1, `channel 4` = Mellan 2 osv.). För `RallyStart`/`RallyFinish` sätts kanalen automatiskt (1/2) och ett medskickat `channel` ignoreras. För `RallyIntermediate` anger `channel` vilken mellantidslinje passeringen gäller; utelämnas det (eller `< 3`) tolkas passeringen som Mellan 1 (kanal 3).

Mellantider lagras som delsträckstider (splits) på deltagarens resultat – mellantiden räknas ut som passeringens tidsstämpel minus starttiden – och påverkar inte sluttiden. En ny mellantidspassering på samma kanal för samma startnummer ersätter den tidigare. Kolumnetiketterna för mellantiderna styrs av tävlingens inställning `timingIntermediateLabels` (t.ex. `["Mellan 1","Mellan 2"]`), som arrangören sätter i adminpanelen.

---

## Tävlingslås

När en nyckel är låst till en specifik tävling är alla anrop implicit begränsade till den tävlingen. Anrop mot andra tävlingar returnerar `404`. Detta är rekommenderat läge för samarbeten kring enskilda evenemang.

---

## Utgående webhooks (push från oss till ert system)

Utöver att ni kan hämta data via GET kan TavlingsConsult **skicka data till ert system** automatiskt. Arrangören registrerar en mål-URL i adminpanelen under **Integrationer → Utgående webhooks**. När något händer POSTar vi då en JSON-payload till er endpoint.

Mål-URL:en måste använda **https**. Adresser som pekar in i privata nät (t.ex. `localhost`, RFC 1918-adresser som `10.x`/`192.168.x`, länklokala adresser eller namn som slutar på `.local`/`.internal`) avvisas när webhooken registreras. Vill ni testa mot en lokal mottagare, exponera den via en https-tunnel (t.ex. ngrok) eller använd en publik testmottagare som <https://webhook.site>.

Detta är motsatt riktning mot GET-anropen ovan: här behöver ni alltså inte fråga efter data – ni tar emot den i realtid.

### Vad ni tar emot

| Händelse | När den skickas |
|----------|-----------------|
| `timing-event` | Varje gång en start-, mål- eller mellantid registreras |
| `results` | Varje gång resultattabellen uppdateras |

Arrangören kan välja att skicka bara den ena typen, båda, och kan låsa en webhook till en specifik tävling.

### Payload-format

Alla webhooks skickas som `POST` med `Content-Type: application/json` och en gemensam yttre struktur (envelope):

```json
{
  "id": "3f1c2a9e-8b7d-4c51-9a0e-2d6f4b8c1e07",
  "event": "timing-event",
  "competitionId": 12,
  "sentAtUtc": "2026-07-15T18:22:05.123Z",
  "data": { }
}
```

| Fält | Beskrivning |
|------|-------------|
| `id` | Leveransens id (GUID). **Samma i varje omförsök** – deduplicera på det. Står även i headern `X-TavlingsConsult-Delivery` |
| `event` | `timing-event`, `results` eller `test` |
| `competitionId` | Vårt tävlings-ID |
| `sentAtUtc` | När händelsen köades hos oss (UTC). Ändras inte i ett omförsök – det är händelsens tidpunkt, inte försökets |
| `data` | Händelsens innehåll, se nedan |

Headers på varje leverans:

| Header | Innehåll |
|--------|----------|
| `X-TavlingsConsult-Event` | Samma värde som `event`, så ni kan dirigera utan att tolka kroppen |
| `X-TavlingsConsult-Delivery` | Samma värde som `id` |
| `X-TavlingsConsult-Attempt` | Vilket försök det här är, från `1` |
| `X-TavlingsConsult-Timestamp`, `X-TavlingsConsult-Signature` | Signaturen – se *Signaturverifiering* nedan. Saknas på osignerade webhooks |

**Ändrat 2026-10-22:** `id` och headrarna `X-TavlingsConsult-Delivery`/`X-TavlingsConsult-Attempt` tillkom när leveranserna fick omförsök. Fältet ligger först i kuvertet; övriga fält, deras ordning och `data` är oförändrade. Verifierar ni signaturen över råkroppen, som nedan, påverkar det er inte.

**`event: "timing-event"`** – `data` innehåller en enskild tidtagningshändelse:

```json
{
  "event": "timing-event",
  "competitionId": 12,
  "sentAtUtc": "2026-07-15T18:22:05.123Z",
  "data": {
    "type": "RallyStart",
    "heatId": 45,
    "heatNumber": 1,
    "channel": 1,
    "startNumber": 5,
    "timestampMs": 73325123,
    "penaltyMs": null
  }
}
```

`channel` anger tidtagningslinjen (`1` = Start, `2` = Mål, `>= 3` = mellantid) och `type` kan vara `RallyStart`, `RallyFinish` eller `RallyIntermediate`.

`data.timestampMs` är **millisekunder sedan midnatt i svensk klocktid** – `73325123` ovan är 20:22:05,123 – även när passeringen skickades in till oss som Unix-tid. Se *Tidsstämpelformat*. Behöver ni ett datum finns det i envelopens `sentAtUtc`.

Räknar tidtagningen varv bär `data` ett fält till, `laps`, sist i objektet:

```json
{
  "event": "timing-event",
  "competitionId": 12,
  "sentAtUtc": "2026-07-15T18:23:37.833Z",
  "data": {
    "type": "RallyFinish",
    "heatId": 45,
    "heatNumber": 1,
    "channel": 2,
    "startNumber": 5,
    "timestampMs": 73417833,
    "penaltyMs": null,
    "laps": 41
  }
}
```

| Fält | Typ | Beskrivning |
|------|-----|-------------|
| `laps` | int? | Antal hela varv som **passeringen** bar med sig – inte resultatradens summerade varvantal. Samma hållning som `penaltyMs` har här: händelsen berättar vad som just kom in, inte hela resultatets tillstånd |

**`laps` utelämnas helt ur `data` när passeringen inte rapporterade något varvantal.** Det är en avvikelse från de andra valfria fälten – `penaltyMs` skrivs fortfarande ut som `null` – och skälet är signaturen: HMAC-en beräknas över kroppen **byte för byte** (se *Signaturverifiering*), så ett ovillkorligt `"laps": null` hade ändrat varje leverans och därmed varje signatur för varje partner som redan tar emot webhooken. Fältet läggs sist och rör inte de befintliga fälten, deras ordning eller deras typer. Tar ni inte emot varv från er tidtagning är kroppen alltså precis densamma som förut.

**Utelämnat `laps` betyder "inte rapporterat", inte "noll varv".** Lagrar ni varvantal per deltagare ska en passering utan `laps` lämna det ni redan har **orört** – inte nolla det. Det är exakt vad vår egen resultatrad gör med samma passering. Noll varv är däremot ett rapporterat värde och levereras som `"laps": 0` – synligt i kroppen som vilket tal som helst.

**`event: "results"`** – `data` innehåller hela resultattabellen i samma format som svaret från `GET /competitions/{id}/results` (fälten `competitionId`, `competitionName`, `heats`, `entries` osv. – inklusive `raceNumber` per deltagare och `useCompetitorRaceNumberAsStartNumber` på tävlingen, kvalpoängfälten `placement`/`points`/`totalPoints` för tävlingar med en poängregel (`RallycrossPoints`, `FolkracePoints` eller `CrosskartPoints`), varvfälten `laps`/`totalLaps`/`lapsDownToLeader`/`classLapsDownToLeader` för tävlingar som räknar varv, och `excludedFromTotal` per sträckcell för sträckor som inte räknas in i resultatet). Alla dessa fält utelämnas ur JSON när de är `null`, så payloaden är oförändrad för tävlingar som inte använder dem.

**`event: "test"`** – skickas när arrangören trycker på *Testa* i adminpanelen. `data` innehåller `{ "message": "..." }` och kan ignoreras i produktion.

### Autentisering mot er endpoint

Om ni anger en bearer-token till arrangören skickar vi den på varje anrop:

```http
Authorization: Bearer <er token>
```

Token är valfri. Säkrar ni er endpoint på annat sätt (IP-tillåtelselista, hemlig URL) kan ni hoppa över den.

### Signaturverifiering

Varje webhook har en **signeringshemlighet** (64 hex-tecken, 32 slumpbytes) som skapas när arrangören registrerar webhooken. Hemligheten visas **en gång** – i svaret från `POST /api/admin/integration-webhooks` (fältet `signingSecret`) – och kan inte hämtas igen. Arrangören kan när som helst rotera den (`POST /api/admin/integration-webhooks/{id}/rotate-secret`, svaret är `{ "signingSecret": "…" }`); den gamla hemligheten slutar då gälla omedelbart, så byt på er sida samtidigt.

Med hemligheten kan ni verifiera att en leverans faktiskt kommer från oss och inte har ändrats på vägen. Alla leveranser – `timing-event`, `results` och `test` – bär då två headers utöver `X-TavlingsConsult-Event`:

```http
X-TavlingsConsult-Timestamp: 1700000000
X-TavlingsConsult-Signature: sha256=e6a22eb66e93669c75e7a035a110d9a2ccfa7cdef62d0ecb361671b92718ee9f
```

- `X-TavlingsConsult-Timestamp` är Unix-tid i **sekunder** (UTC) när leveransen skapades.
- `X-TavlingsConsult-Signature` är `sha256=` följt av HMAC-SHA256 i **gemena hex-tecken**, beräknad som:

```text
hex = HMAC-SHA256(
  key = hemligheten som UTF-8-bytes,
  msg = UTF-8("{timestamp}.{body}")
)
```

där `timestamp` är headervärdet och `body` är HTTP-kroppen **byte för byte som den skickades** (JSON, `Content-Type: application/json; charset=utf-8`). Tidsstämpel, en punkt, kropp – inget mer.

**Referensvektor** (finns som test i vår kodbas – använd den för att kontrollera er implementation):

| Fält | Värde |
|------|-------|
| Hemlighet | `secret` |
| Timestamp | `1700000000` |
| Body | `{"event":"test"}` |
| Signatur | `sha256=e6a22eb66e93669c75e7a035a110d9a2ccfa7cdef62d0ecb361671b92718ee9f` |

**Så bör mottagaren verifiera:**

1. Beräkna HMAC över **råkroppen innan JSON-parsning**. Parsar ni först och serialiserar om kan bytes ändras (fältordning, blanksteg, unicode-escaping) och signaturen stämmer inte längre.
2. Jämför signaturen med en **konstanttidsjämförelse** (`crypto.timingSafeEqual`, `CryptographicOperations.FixedTimeEquals` eller motsvarande), inte vanlig stränglikhet.
3. Avvisa tidsstämplar som är **äldre än några minuter** (eller ligger i framtiden) som skydd mot återuppspelning. Räkna med att klockorna kan skilja sig något.
4. Saknas headers helt är leveransen osignerad – se nedan.

Exempel med Node/Express (observera `express.raw` så att kroppen inte parsas innan signaturen kontrollerats):

```javascript
const crypto = require("node:crypto");
const express = require("express");

const SECRET = process.env.TC_WEBHOOK_SECRET; // 64 hex-tecken från arrangören
const MAX_AGE_SECONDS = 300;

const app = express();

app.post("/tavlingsconsult", express.raw({ type: "application/json" }), (req, res) => {
  const timestamp = req.get("X-TavlingsConsult-Timestamp");
  const signature = req.get("X-TavlingsConsult-Signature");
  if (!timestamp || !signature) return res.status(401).end();

  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!Number.isFinite(age) || age > MAX_AGE_SECONDS) return res.status(401).end();

  // req.body är en Buffer med råkroppen – parsa INTE innan verifieringen.
  const expected = "sha256=" + crypto
    .createHmac("sha256", SECRET)
    .update(`${timestamp}.`)
    .update(req.body)
    .digest("hex");

  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.status(401).end();

  const payload = JSON.parse(req.body.toString("utf8"));
  // Samma id kan komma igen vid ett omförsök – hoppa över det ni redan har.
  // ... hantera payload.event / payload.data ...
  res.status(204).end();
});
```

**Webhooks utan hemlighet.** Webhooks som registrerades innan signeringen infördes saknar hemlighet och levereras **utan** `X-TavlingsConsult-Timestamp`/`X-TavlingsConsult-Signature` tills arrangören roterar hemligheten. Listan i admin-API:t (`GET /api/admin/integration-webhooks`) har fältet `hasSigningSecret` som visar vilka webhooks som är signerade. Be arrangören rotera så snart er mottagare är redo att verifiera – och kräv sedan signatur på varje leverans.

### Svarskrav, omförsök och dubbletter

Svara med en `2xx`-statuskod så snart ni tagit emot payloaden – helst innan ni bearbetar den. Vi väntar högst **10 sekunder** på svaret. **Omdirigeringar följs inte** – svarar er endpoint med `3xx` räknas leveransen som misslyckad, så registrera den slutliga adressen direkt (utan t.ex. `http`→`https`- eller `www`-omdirigering).

**Allt som inte är `2xx` görs om** – ett `4xx`/`5xx`-svar, en timeout eller en anslutning som inte går fram. Varje leverans får **8 försök** över drygt 20 timmar:

| Försök | Väntan före försöket |
|--------|----------------------|
| 1 | – (direkt när händelsen inträffar) |
| 2 | 10 sekunder |
| 3 | 1 minut |
| 4 | 5 minuter |
| 5 | 30 minuter |
| 6 | 2 timmar |
| 7 | 6 timmar |
| 8 | 12 timmar |

Ett omförsök bär **samma `id` och exakt samma kropp**; bara `X-TavlingsConsult-Attempt` och signaturens `X-TavlingsConsult-Timestamp` (och därmed `X-TavlingsConsult-Signature`) är nya, så att ert skydd mot återuppspelning fortsätter att fungera. Ligger er mottagare nere en stund missar ni alltså ingenting – det kommer när ni är uppe igen.

Det betyder också att **samma leverans kan komma mer än en gång** – till exempel om ni hann behandla den men svaret aldrig nådde oss. Spara `id` på det ni tagit emot och hoppa över ett `id` ni redan sett.

Tre saker till att bygga mot:

1. **Ordningen garanteras inte.** Ett omförsök kan komma efter en nyare händelse. Sortera på `sentAtUtc` om ordningen spelar roll för er.
2. **En nyare resultatlista ersätter en äldre som väntar.** Varje `results`-leverans bär hela tabellen. Köas en ny medan en äldre för samma tävling väntar på omförsök skickas den äldre aldrig – annars hade ett sent omförsök kunnat skriva över en nyare lista hos er. Får ni ändå en lista med äldre `sentAtUtc` än en ni redan har: ignorera den.
3. **Efter sista försöket ger vi upp.** Leveransen syns då som misslyckad i adminpanelen, där arrangören kan skicka den igen. Hela läget finns alltid kvar i `GET /competitions/{id}/results`, och nästa ändring i resultatet ger en ny `results`-leverans.

Arrangören ser varje leverans – försök, svarskod och nästa försök – i adminpanelen under **Integrationer → Utgående webhooks → Leveranser**, och kan skicka en enskild leverans igen därifrån. Testleveranser (`event: "test"`) görs en gång och köas inte.

---

## Begränsningar (rate limits)

Det finns inga hårda begränsningar vid rimlig användning (upp till ca 60 tidtagningshändelser per minut och nyckel). Har ni många tider på en gång – till exempel en hel sträcka i efterhand – skicka dem som färdiga resultat i ett anrop (*Skicka in färdiga resultat*). Behöver ni en högre takt än så, kontakta oss.

---

## Felkoder

| Status | Betydelse |
|--------|-----------|
| `200 OK` | Anropet lyckades |
| `202 Accepted` | Händelsen togs emot |
| `400 Bad Request` | Ogiltig request-body, saknat obligatoriskt fält eller ett värde utanför det tillåtna (för tidtagningshändelser: se *Avvisade värden (gäller båda tidtagnings-endpointarna)*) |
| `401 Unauthorized` | Saknad eller ogiltig API-nyckel |
| `403 Forbidden` | Nyckeln saknar rätt behörighet, eller tävlingslås stämmer inte |
| `404 Not Found` | Resursen hittades inte (tävling, sträcka osv.) |

Felsvar innehåller en JSON-body med ett `error`-fält:

```json
{ "error": "Typen måste vara 'RallyStart', 'RallyFinish' eller 'RallyIntermediate'." }
```

---

## Få en nyckel

API-nycklar hanteras av arrangören i TavlingsConsults adminpanel under **Integrationer**. Kontakta din arrangör för att begära en nyckel med rätt behörigheter.

Vill du testa läsanrop säkert kan du be om en **sandlådenyckel** – en läsnyckel låst till demotävlingen "DEMO – Integrationssandlåda" (se avsnittet *Testtävling / sandlåda*). Arrangören skapar den med knappen **Skapa sandlådenyckel** under **Integrationer**.

Nycklar visas **en gång** vid skapandet och kan inte hämtas igen. Förvara dem säkert (t.ex. i miljövariabler eller en hemlighetshanterare).

---

## Kontakt och support

För frågor om integration, för att få en nyckel eller för att rapportera problem: skriv till [Info@tavlingsconsult.com](mailto:Info@tavlingsconsult.com). Berätta vilken tävling det gäller och vilket system ni integrerar från.
