# API

Det åpne GraphQL-API-et til TRD Events. Les [oversikten](/utviklere) først hvis du ikke har gjort det;
den fullstendige typelisten finner du på [referansesiden](/utviklere/reference) og i
[schema.graphql](https://trdevents.no/utviklere/schema.graphql).

## Endepunkt

```
POST https://trdevents.no/graphQL
Content-Type: application/json

{"query": "...", "variables": {...}, "operationName": "..."}
```

- `variables` og `operationName` er valgfrie. Svar er JSON: `{"data": ..., "errors": [...], "extensions": {...}}`.
- **GET** godtas for spørringer (`?query=...&variables=...`, URL-kodet), men bare med headeren
  `Apollo-Require-Preflight: true`. Uten den svarer tjeneren 400 med en CSRF-melding. Bruk helst POST.
- CORS er åpent: en nettside på hvilket som helst opphav kan kalle endepunktet direkte. Egne headere
  utløser en preflight, som besvares.
- [Sandboxen](https://trdevents.no/graphQL) er det samme endepunktet åpnet i en nettleser (en forespørsel som godtar HTML): skjemaet,
  autofullføring og en kjørbar spørring. Introspeksjon er på.
- Det er ingen versjonering i URL-en. Endringer kunngjøres i [endringsloggen](/utviklere/changelog)
  og, for kallere med nøkkel, på e-post før de lander.

## Headere

| Header / parameter | Hvem sender den | Betydning |
|---|---|---|
| `X-Api-Key: hsk_…` | du | Applikasjonens nøkkel. Identitet, ikke sikkerhet: se [Nøkler](/utviklere/keys). |
| `?key=hsk_…` | du, når headere er utenfor rekkevidde | Samme nøkkel som parameter i spørrestrengen, for plattformer som ikke kan sette headere. |
| `X-Page-Url: https://…` | innbygginger i nettleser | Full URL til siden innbyggingen står på. Nettlesere sender bare opphavet som `Referer` på tvers av opphav, så uten denne er en widget på `example.no/kultur/program` umulig å skille fra enhver annen side på `example.no`. |
| `X-Client-Id: name/version` | våre egne klienter | Reservert for kalenderens egen front-end, tjenerrenderingen, widgeten og skjermappene. Ikke send den: trafikken din ville blitt ført under vår. |

En nøkkel som er ukjent eller trukket tilbake får ikke forespørselen til å feile: den behandles som anonym, og
svaret bærer en `extensions.notice` som sier det.

## events-spørringen

```graphql
query Upcoming($page: Int, $pageSize: Int, $filter: Filter) {
  events(page: $page, pageSize: $pageSize, filter: $filter) {
    totalCount
    hasMore
    pageInfo { currentPage pageSize totalPages }
    data {
      id
      event_slug
      eventLink
      title_nb
      title_en
      startDate
      endDate
      startTime
      duration
      categories
      mode
      venue { id name slug address location { latitude longitude } }
      organizers { id name slug website }
      images { urlSmall urlLarge alt }
      repetitions { startDate endDate startTime venue { name } }
    }
  }
}
```

### Paginering

`events(filter, page, pageSize)`. `page` starter på **0**; `pageSize` er **10** som standard. Les
totalene fra `EventConnection`, ikke fra lengden på `data`:

- `totalCount` — arrangementer som matcher filteret, på tvers av alle sider;
- `hasMore` — om `page + 1` har noe;
- `pageInfo` — `currentPage`, `pageSize`, `totalPages`.

`page < 0` eller `pageSize <= 0` gir feilen `BAD_USER_INPUT`. Forbindelsen bærer også fasetter over
hele det filtrerte settet (ikke bare siden): `venues`, `organizers` og `categories`, hver en liste
av `{ …, hits }`.

### Filter

Alle felt er valgfrie. Kombiner fritt; hver betingelse må holde.

| Felt | Betydning |
|---|---|
| `fromDate` | Arrangementer som **fortsatt pågår** på dette tidspunktet eller senere: ikke avsluttet ennå, eller (for arrangementer publisert uten sluttid, der `endDate == startDate`) startet for mindre enn tre timer siden. Standard er nå. |
| `untilDate` | Arrangementer som starter før dette tidspunktet. |
| `fromStartDate` | Arrangementer som **starter** på dette tidspunktet eller senere. Bruk denne heller enn `fromDate` når pågående arrangementer ikke skal vises. |
| `categories` | Kategori-**id-er** (se [Identifikatorer](/utviklere/api#identifikatorer)); hvilken som helst av dem. Tom betyr alle. |
| `notCategories` | Utelukk disse kategori-id-ene. |
| `venues`, `venueSlug` | Sted-**slugs** (`Venue.slug`), hvilken som helst av dem / én av dem. |
| `organizers`, `organizerSlug` | Arrangør-**slugs** (`Organizer.slug`). |
| `searchTerm` | Fritekst, matchet uavhengig av store/små bokstaver og aksenter mot titler, beskrivelser, stikkord, stedsnavn, arrangørnavn og kategorietiketter; når ingenting matcher bokstavelig, matches titler og beskrivelser omtrentlig (én eller to skrivefeil). |
| `tag` | Ett stikkord fra `Event.tags`. |
| `mode` | `online` eller `offline`. |
| `superEvent` | Id-en til et beholderarrangement (en festival, et marked): programmet dets. |
| `onlyFeatured` | Bare arrangementer arrangøren har fremhevet. |
| `onlyFeaturedSpecialEvent` | Bare arrangementer fremhevet i lisensens spesialarrangement, der det er satt opp. |
| `cancelledNotIncluded`, `soldOutNotIncluded` | Fjern avlyste / utsolgte arrangementer. |
| `hoursRange` | `HH:mm-HH:mm`, f.eks. `16:00-22:00`: arrangementer som starter innenfor det vinduet. |
| `groupRepetitionsByDay` | Utvid hver fremtidig dag i et arrangement med flere datoer til sin egen node (med den dagens `startDate`), så en liste kan vise én rad per dag. Datoer samme dag blir i den nodens `repetitions`. |
| `municipality`, `postalCodes` | Filtrer på stedets adresse. |
| `sortBy` | Utgått: resultatene er alltid kronologiske. |

Datoargumenter tar `YYYY-MM-DD HH:mm:ss` etterfulgt av en forskyvning (`+02:00`, `+0200`, `+02` eller `Z`),
for eksempel `"2026-09-01 00:00:00+02:00"`. En dato som ikke kan tolkes gir feilen `Query Arguments invalid`
med `extensions.invalidArgs` som navngir argumentet.

```graphql
{
  events(
    filter: {
      searchTerm: "konsert"
      fromDate: "2026-09-01 00:00:00+02:00"
      untilDate: "2026-12-31 23:59:59+01:00"
    }
    page: 0
    pageSize: 20
  ) {
    totalCount
    data { id title_nb startDate venue { name } }
  }
}
```

### Andre spørringer

| Spørring | Returnerer |
|---|---|
| `eventByID(eventID: String!)` | Ett arrangement etter `id`. |
| `eventBySlug(eventSlug: String!)` | Ett arrangement etter `event_slug` (siste segment i `eventLink`). |
| `eventsBySlugs(eventsSlugs: [String]!)` | Flere arrangementer etter slug. |
| `eventByTitle(title: String!, lan: String!)` | Ett arrangement etter eksakt tittel; `lan` er `nb` eller `en`. |
| `allUpcomingSuperEvents` | Beholderarrangementer (festivaler, markeder) som ikke er avsluttet. |
| `allUpcomingEventsInArea(minLatitude, maxLatitude, minLongitude, maxLongitude)` | Kommende arrangementer med sted innenfor boksen. |
| `categories` | Denne lisensens kategorier med id-er, etiketter, slugs og underkategorier. |
| `venues`, `organizers` | Katalogen over steder og arrangører, med id-er og slugs. |

Det finnes ingen mutasjoner. Arrangementer publiseres av mennesker gjennom kalenderens egne skjemaer og av
kalenderens egne importører.

## Datoer

To fakta. Hvert av dem har gitt feil program på noens nettsted.

**1. `startDate` og `endDate` bærer UTC-forskyvningen som gjelder på arrangementets dato.** Norge er
`+01:00` om vinteren og `+02:00` om sommeren, og verdien sier hvilken:

```
2026-02-14 19:00:00+01:00    en februarkonsert kl. 19:00 Oslo-tid
2026-07-14 19:00:00+02:00    en julikonsert kl. 19:00 Oslo-tid
```

Begge er kl. 19:00 på veggklokka. Begge er gyldige tidspunkt. Det de ikke er, er «ISO med +00»:
en parser satt opp med fast forskyvning, eller en formatering som skriver i tjenerens egen sone,
viser 18:00 eller 20:00 for én av dem, og feilen snur ved hver overgang til og fra sommertid. Et
reiselivsnettsted viste hvert klokkeslett feil i ukevis på denne måten.

- Formatet er `YYYY-MM-DD HH:mm:ss±HH:mm` med et **mellomrom** mellom dato og tid. En streng
  RFC 3339-parser vil ha en `T`: bytt ut mellomrommet, så tolkes den overalt.
- For visning: konverter tidspunktet til `Europe/Oslo` (aldri til leserens eller tjenerens sone).
- Eller dropp regnestykket: `startTime` (`HH:mm`) er den norske starten på veggklokka nøyaktig slik
  arrangøren skrev den, og `duration` er i minutter. Det finnes ikke noe `endTime`-felt; utled det fra
  `endDate` i `Europe/Oslo` eller fra `startTime + duration`.
- `publishingDate` og `ticketsFromDate` følger samme regel. `created_at` og `updated_at` er
  bokføring og gjør ikke nødvendigvis det.

**2. `startDate` er neste kommende forekomst; `repetitions` lister bare fremtidige.** Et
arrangement med flere datoer er ett arrangement med én `id`. Når den første datoen har passert, løfter
API-et den neste datoen som fortsatt gjelder inn i `startDate`, `endDate`, `startTime`, `duration`,
`venue`, `ticketsURL`, `eventCancelled` og `eventSoldOut`, og `repetitions` holder datoene etter
den. Passerte datoer returneres ikke, så samme `id` svarer med en annen `startDate` neste uke.
Ikke nøkle dine egne poster på `id + startDate` med mindre du vil ha én post per forekomst;
i så fall gir `groupRepetitionsByDay` deg dagsnodene direkte.

## Identifikatorer

**3. Filtrer og vis etter id. Navn og slugs er presentasjon.**

- `Event.id` er identiteten til et arrangement hele dets levetid. `event_slug` er URL-segmentet
  (`eventLink` er hele URL-en); `title_nb` / `title_en` redigeres av mennesker.
- `Event.categories` er en liste over kategori-**id-er**. Hent etikettene fra `categories` ved hver kjøring
  — ikke én gang ved installasjon, og aldri skrevet for hånd. Id-er er forskjellige mellom lisenser (samme etikett
  er `CONCERT` på én kalender og noe annet på en annen), og en lisens kan bytte ut hele
  taksonomien sin: en pensjonert id forsvinner fra `categories`, arrangementene som bar den migreres, og
  id-en gjenbrukes aldri til noe annet. Å filtrere på en id som ikke lenger finnes i `categories`
  returnerer ingenting, uten feilmelding. [Endringsloggen](/utviklere/changelog) registrerer hver slik endring.
- `Venue` og `Organizer` har en `id` og en `slug`. `events`-filteret tar **sluggen**
  (`venues`, `organizers`); les den fra `venues` / `organizers` heller enn å utlede den fra et navn.
- `categories` returnerer `visible` per kategori; skjulte er fortsatt gyldige id-er på arrangementer.

### Kategoriene til TRD Events

Slik de fulgte med dette bygget. `categories`-spørringen er sannheten ved kjøring.

| id | name_nb | name_en | slug_nb | slug_en |
|---|---|---|---|---|
| `FAMILY` | Barn / familie | Children / family | barn-familie | children-family |
| `DANCE` | Dans | Dance | dans | dance |
| `FESTIVAL` | Festival | Festival | festival | festival |
| `MOVIES` | Film | Film | film | movies |
| `CONCERT` | Konsert | Concert | konsert | concert |
| `COURSE` | Kurs / workshop | Courses / workshops | kurs-workshop | courses-workshops |
| `LITERATURE` | Litteratur | Literature | litteratur | literature |
| `MARKET` | Marked / messer | Markets / fairs | marked-messer | markets-fairs |
| `FOOD_DRINKS` | Mat og drikke | Food and drinks | mat-og-drikke | food-and-drinks |
| `MUSEUM` | Museum | Museum | museum | museum |
| `GUIDED_TOUR` | Omvisning | Guided tour | omvisning | guided-tour |
| `QUIZ` | Quiz | Quiz | quiz | quiz |
| `DEBATE` | Samtale / foredrag | Talks / lectures | samtale-foredrag | talks-lectures |
| `SOCIAL` | Sosialt | Social | sosialt | social |
| `GAMES` | Spill / gaming | Games / gaming | spill-gaming | games-gaming |
| `SPORT` | Sport / friluftsliv | Sports / outdoors | sport-friluftsliv | sports-outdoors |
| `THEATER` | Teater / show | Theater / show | teater-show | theater-show |
| `EXHIBITION` | Utstilling | Exhibition | utstilling | exhibition |
| `TECHNOLOGY` | Vitenskap / teknologi | Science / technology | vitenskap-teknologi | science-technology |

### Billettyper

`Price.type` er en billettype-id. De innebygde på denne kalenderen:

| id | name_nb | name_en |
|---|---|---|
| `ASSISTANT` | Ledsager | Assistant |
| `CHILD` | Barn | Child |
| `FAMILY` | Familie | Family |
| `MEMBERS` | Medlemmer | Members |
| `REGULAR` | Vanlig | Regular |
| `REDUCED` | Redusert | Reduced |
| `SENIOR` | Honnør | Senior |
| `STUDENT` | Student | Student |

En arrangør kan også definere egne billettyper for sine arrangementer; de id-ene står ikke
her, og `Price.name_nb` / `Price.name_en` bærer etiketten når arrangøren ga en.

## Pris og kapasitet

**4. Ingenting som kommer fra et skjema er garantert numerisk.**

- `Price.price` er typet `Int`, og tjeneren avrunder det arrangøren lagret — men den lagrede
  verdien kan være `1.595` (skrevet med norsk tusenskilletegn), `150,-` eller tom. Når
  den ikke kan leses som tall er feltet `null`. Del aldri på det, anta aldri øre.
- `ticketsInformation` sier hvilken av `free`, `noTicketsInfo` eller `ticketsInfo` som gjelder; `prices`
  er bare meningsfull for `ticketsInfo`. `ticketsURL` er der billetter selges når de selges
  et annet sted.
- `duration`, `minimumAge`, `maximumAge`, `cancellationPeriod`, `views` kan være `null`.
- Kapasitetsfeltene (`registrationEnabled`, `availableTickets`, `activeTickets`, `maxBookingDate`,
  `maxBookingTime`, `paymentMethod`) finnes for kalendere der besøkende melder seg på gjennom kalenderen
  selv. På en kalender uten påmelding er de `null` eller `false`; ikke les `availableTickets: null`
  som «utsolgt». `eventSoldOut` er arrangørens eksplisitte flagg.
- En `Repetition` kan bære sine egne `prices`; når den er `null`, gjelder arrangementets `prices`.

## Bilder

`Event.images` er en liste; det første bildet er forsidebildet. `urlSmall` og `urlLarge` er samme
bilde i to størrelser.

Et kort beskjærer som regel bildet til en boks med sin egen form, og en beskjæring rundt midten
kutter hodene av et gruppebilde. `focusX` og `focusY` sier hvor menneskene er, som andeler av
bildet: `0, 0` er øverste venstre hjørne, `1, 1` nederste høyre. Legg det punktet i bildet på samme
punkt i boksen din, så blir det i bildet uansett hvilken form boksen har. I CSS er det én deklarasjon:

```css
img { object-fit: cover; object-position: 51% 31%; }   /* focusX: 0.51, focusY: 0.31 */
```

```graphql
{
  events(pageSize: 3) {
    data { title_nb images { urlLarge alt focusX focusY width height } }
  }
}
```

- Begge er `null` når det ikke ble funnet noe ansikt i bildet, og den første halvtimen eller så av
  et nytt bildes liv, før det er analysert. Behold din egen standardbeskjæring da.
- `width` og `height` er pikselstørrelsen til bildet på `urlLarge`, og `null` til det er
  analysert.

## Feil

Svarkroppen er alltid JSON.

| Situasjon | HTTP | Kropp |
|---|---|---|
| Kroppen er ikke gyldig JSON | 400 | `{"errors":[{"message":"Malformed JSON body"}]}` |
| GET uten `Apollo-Require-Preflight` | 400 | `errors[0].extensions.code = "BAD_REQUEST"`, meldingen nevner CSRF |
| Spørringen validerer ikke (ukjent felt eller argument, feil type) | 400 | `errors[0].extensions.code = "GRAPHQL_VALIDATION_FAILED"`; meldingen navngir feltet |
| Ugyldig argumentverdi (negativ side, dato som ikke kan tolkes) | 200 | `data: null`, `errors[0].extensions.code = "BAD_USER_INPUT"` eller `extensions.invalidArgs` |
| Feil i en resolver | 200 | `data` med `null` for feltet som feilet og en oppføring i `errors` |

En `errors`-liste kan følge med delvise `data`; sjekk etter den i hvert svar, ikke bare ved
statuser som ikke er 200.

`extensions.notice` er **ikke en feil**. Det er en streng på vellykkede svar til forespørsler
uten gyldig nøkkel, som peker til denne portalen. En klient som ignorerer `extensions` påvirkes ikke;
en som leser den kan logge den én gang og gå videre.

## Rate limits og kvote

Ingen i dag, med eller uten nøkkel. Når en kvote kommer, beholder applikasjoner med nøkkel sitt eget budsjett, og
anonym trafikk som først ses etter den datoen kan få en lavere. Ikke ennå — dette avsnittet endres
først, og endringsloggen sier fra.

Cache det du kan: en liste som endrer seg noen ganger om dagen trenger ikke hentes hvert
sekund.

## Kontakt

Spørsmål, et felt du trenger, et endringsvarsel du ikke fikk: post@midtbyen.no. Si hvilken
kalender og, hvis du har en, hvilken nøkkel.
