# API

The public GraphQL API of Tysnes Kalender. Read [the overview](/utviklere) first if you have not;
the full type list is on the [reference page](/utviklere/reference) and in
[schema.graphql](https://hvaskjer-staging.web.app/utviklere/schema.graphql).

## Endpoint

```
POST https://hvaskjer-staging.web.app/graphQL
Content-Type: application/json

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

- `variables` and `operationName` are optional. Responses are JSON: `{"data": ..., "errors": [...], "extensions": {...}}`.
- **GET** is accepted for queries (`?query=...&variables=...`, URL-encoded) but only with the header
  `Apollo-Require-Preflight: true`. Without it the server answers 400 with a CSRF message. Prefer POST.
- CORS is open: a browser page on any origin may call the endpoint directly. Custom headers cause
  a preflight, which is answered.
- Opening the endpoint in a browser tab (a request that accepts HTML) shows a sandbox with the schema,
  autocompletion and a runnable query. Introspection is enabled.
- There is no versioning in the URL. Changes are announced on the [changelog](/utviklere/changelog)
  and, for keyed callers, by e-mail before they land.

## Headers

| Header / parameter | Who sends it | Meaning |
|---|---|---|
| `X-Api-Key: hsk_…` | you | Your application's key. Identity, not security: see [Keys](/utviklere/keys). |
| `?key=hsk_…` | you, when headers are out of reach | The same key as a query-string parameter, for platforms that cannot set headers. |
| `X-Page-Url: https://…` | browser embeds | The full URL of the page the embed is mounted on. Browsers only send the origin as `Referer` cross-origin, so without this a widget on `example.no/kultur/program` is indistinguishable from any other page on `example.no`. |
| `X-Client-Id: name/version` | our own clients | Reserved for the calendar's own front end, its server render, the widget and the screen apps. Do not send it: it would file your traffic under ours. |

A key that is unknown or revoked does not fail the request: it is served as anonymous and the
response carries an `extensions.notice` saying so.

## The events query

```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 } }
    }
  }
}
```

### Pagination

`events(filter, page, pageSize)`. `page` starts at **0**; `pageSize` defaults to **10**. Read the
totals from the `EventConnection`, not from the length of `data`:

- `totalCount` — events matching the filter, across all pages;
- `hasMore` — whether `page + 1` has anything;
- `pageInfo` — `currentPage`, `pageSize`, `totalPages`.

`page < 0` or `pageSize <= 0` is a `BAD_USER_INPUT` error. The connection also carries facets over
the whole filtered set (not just the page): `venues`, `organizers` and `categories`, each an array
of `{ …, hits }`.

### Filter

All fields are optional. Combine freely; every condition must hold.

| Field | Meaning |
|---|---|
| `fromDate` | Events **still current** at this instant or later: not yet ended, or (for events published without an end time, where `endDate == startDate`) started less than three hours before. Defaults to now. |
| `untilDate` | Events that start before this instant. |
| `fromStartDate` | Events that **start** at this instant or later. Use this rather than `fromDate` when ongoing events must not appear. |
| `categories` | Category **ids** (see [Identifiers](#identifiers)); any of them. Empty means all. |
| `notCategories` | Exclude these category ids. |
| `venues`, `venueSlug` | Venue **slugs** (`Venue.slug`), any of them / one of them. |
| `organizers`, `organizerSlug` | Organizer **slugs** (`Organizer.slug`). |
| `searchTerm` | Free text, matched case- and accent-insensitively against titles, descriptions, tags, venue name, organizer names and category labels; when nothing matches literally, titles and descriptions are matched fuzzily (one or two typos). |
| `tag` | One keyword of `Event.tags`. |
| `mode` | `online` or `offline`. |
| `superEvent` | Id of a container event (a festival, a market): its programme. |
| `onlyFeatured` | Only events the organiser marked as featured. |
| `onlyFeaturedSpecialEvent` | Only events featured in the licence's special event, where one is configured. |
| `cancelledNotIncluded`, `soldOutNotIncluded` | Drop cancelled / sold-out events. |
| `hoursRange` | `HH:mm-HH:mm`, e.g. `16:00-22:00`: events starting within that window. |
| `groupRepetitionsByDay` | Expand every future day of a multi-date event into its own node (with that day's `startDate`), so a listing can show one row per day. Dates on the same day stay in that node's `repetitions`. |
| `municipality`, `postalCodes` | Filter on the venue's address. |
| `sortBy` | Deprecated: results are always chronological. |

Date arguments take `YYYY-MM-DD HH:mm:ss` followed by an offset (`+02:00`, `+0200`, `+02` or `Z`),
for example `"2026-09-01 00:00:00+02:00"`. An unparsable date is a `Query Arguments invalid` error
with `extensions.invalidArgs` naming the argument.

```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 } }
  }
}
```

### Other queries

| Query | Returns |
|---|---|
| `eventByID(eventID: String!)` | One event by `id`. |
| `eventBySlug(eventSlug: String!)` | One event by `event_slug` (the last segment of `eventLink`). |
| `eventsBySlugs(eventsSlugs: [String]!)` | Several events by slug. |
| `eventByTitle(title: String!, lan: String!)` | One event by exact title; `lan` is `nb` or `en`. |
| `allUpcomingSuperEvents` | Container events (festivals, markets) that have not ended. |
| `allUpcomingEventsInArea(minLatitude, maxLatitude, minLongitude, maxLongitude)` | Upcoming events whose venue lies in the box. |
| `categories` | This licence's categories with ids, labels, slugs and subcategories. |
| `venues`, `organizers` | The catalogue of venues and organizers, with ids and slugs. |

There are no mutations. Events are published by people through the calendar's own forms and by
the calendar's own importers.

## Dates

Two facts. Each one has produced a wrong programme on somebody's site.

**1. `startDate` and `endDate` carry the UTC offset in force on the event's date.** Norway is
`+01:00` in winter and `+02:00` in summer, and the value says which:

```
2026-02-14 19:00:00+01:00    a February concert at 19:00 Oslo time
2026-07-14 19:00:00+02:00    a July concert at 19:00 Oslo time
```

Both are 19:00 on the wall clock. Both are valid instants. What they are not is "ISO with +00":
a parser configured for a fixed offset, or a formatter that prints in the server's own zone,
shows 18:00 or 20:00 for one of them and the error flips at every daylight-saving switch. A
tourism site painted every hour wrong for weeks this way.

- The format is `YYYY-MM-DD HH:mm:ss±HH:mm` with a **space** between date and time. A strict
  RFC 3339 parser wants a `T`: replace the space and it parses everywhere.
- To display, convert the instant to `Europe/Oslo` (never to the reader's or the server's zone).
- Or skip the arithmetic: `startTime` (`HH:mm`) is the Norwegian wall-clock start exactly as the
  organiser typed it, and `duration` is in minutes. There is no `endTime` field; derive it from
  `endDate` in `Europe/Oslo` or from `startTime + duration`.
- `publishingDate` and `ticketsFromDate` follow the same rule. `created_at` and `updated_at` are
  bookkeeping and may not.

**2. `startDate` is the next upcoming occurrence; `repetitions` lists only future ones.** An
event with several dates is one event with one `id`. When its first date has passed, the API
promotes the next date that is still current into `startDate`, `endDate`, `startTime`, `duration`,
`venue`, `ticketsURL`, `eventCancelled` and `eventSoldOut`, and `repetitions` holds the dates after
that one. Past dates are not returned, so the same `id` answers with a different `startDate` next
week. Do not key your own records on `id + startDate` unless you want one record per occurrence;
if you do, `groupRepetitionsByDay` gives you the day-nodes directly.

## Identifiers

**3. Filter and display by id. Names and slugs are presentation.**

- `Event.id` is the identity of an event for its whole life. `event_slug` is the URL segment
  (`eventLink` is the full URL); `title_nb` / `title_en` are edited by people.
- `Event.categories` is a list of category **ids**. Fetch the labels from `categories` on every run
  — not once at install time, and never typed by hand. Ids differ between licences (the same label
  is `CONCERT` on one calendar and something else on another), and a licence may replace its whole
  taxonomy: a retired id disappears from `categories`, the events that carried it are migrated, and
  the id is never reused for anything else. Filtering on an id that is no longer in `categories`
  returns nothing, silently. The [changelog](/utviklere/changelog) records every such change.
- `Venue` and `Organizer` have an `id` and a `slug`. The `events` filter takes the **slug**
  (`venues`, `organizers`); read it from `venues` / `organizers` rather than deriving it from a name.
- `categories` returns `visible` per category; hidden ones are still valid ids on events.

### Categories of Tysnes Kalender

As shipped with this build. The `categories` query is the source of truth at run time.

| id | name_nb | name_en | slug_nb | slug_en |
|---|---|---|---|---|
| `CONCERT` | Konsert | Concert | konsert | concert |
| `FESTIVAL` | Festival | Festival | festival | festival |
| `MUSEUM` | Galleri / Museum | Gallery / Museum | galleri-museum | gallery-museum |
| ↳ `GALLERY` | Galleri (hidden) | Gallery | galleri | gallery |
| ↳ `EXHIBITION` | Utstilling (hidden) | Exhibition | utstilling | exhibition |
| ↳ `MUSEUM` | Museum (hidden) | Museum | museum | museum |
| `FAMILY` | Familie | Family | familie | family |
| `THEATER` | Teater / Show | Theater / Show | teater-show | theater-show |
| `DEBATE` | Debatt / Samtale | Debate / Discussion | debatt | debate |
| ↳ `DEBATE` | Debatt (hidden) | Debate | debatt | debate |
| ↳ `LECTURE` | Foredrag (hidden) | Lecture | foredrag | lecture |
| ↳ `DISCUSSION` | Samtale (hidden) | Discussion | samtale | discussion |
| `COURSE` | Kurs | Course | kurs | course |
| `OTHER` | Annet | Other | annet | other |
| ↳ `CONFERENCE` | Konferanse | Conference | konferanse | conference |
| ↳ `DANCE` | Dans | Dance | dans | dance |
| ↳ `FOOD_DRINKS` | Mat og drikke | Food and drinks | mat-og-drikke | food-and-drinks |
| ↳ `GUIDED_TOUR` | Omvisning | Guided Tour | omvisning | guided-tour |
| ↳ `HANDWORK` | Håndarbeid | Handword | handarbeid | handword |
| ↳ `MARKET` | Marked | Market | marked | market |
| ↳ `MOVIES` | Film | Movies | film | movies |
| ↳ `OUTDOORS` | Friluftsliv | Outdoors | friluftsliv | outdoors |
| ↳ `QUIZ` | Quiz | Quiz | quiz | quiz |
| ↳ `SENIOR` | Senior | Senior | senior | senior |
| ↳ `SPORT` | Idrettsarrangement / E-sport | Sports / E-sport | idrettsarrangement-e-sport | sports-e-sport |
| ↳ `TECHNOLOGY` | Teknologi | Technology | teknologi | technology |

### Ticket types

`Price.type` is a ticket-type id. The built-in ones on this calendar:

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

An organiser may also define custom ticket types for their own events; those ids are not listed
here, and `Price.name_nb` / `Price.name_en` carry the label when the organiser gave one.

## Price and capacity

**4. Nothing that comes from a form is guaranteed numeric.**

- `Price.price` is typed `Int` and the server rounds whatever the organiser stored — but the
  stored value may be `1.595` (typed with a Norwegian thousands separator), `150,-`, or empty. When
  it cannot be read as a number the field is `null`. Never divide by it, never assume øre.
- `ticketsInformation` says which of `free`, `noTicketsInfo` or `ticketsInfo` applies; `prices`
  is only meaningful for `ticketsInfo`. `ticketsURL` is where tickets are sold when they are sold
  elsewhere.
- `duration`, `minimumAge`, `maximumAge`, `cancellationPeriod`, `views` may be `null`.
- Capacity fields (`registrationEnabled`, `availableTickets`, `activeTickets`, `maxBookingDate`,
  `maxBookingTime`, `paymentMethod`) exist for calendars where visitors book through the calendar
  itself. On a calendar without bookings they are `null` or `false`; do not read `availableTickets: null`
  as "sold out". `eventSoldOut` is the organiser's explicit flag.
- A `Repetition` may carry its own `prices`; when it is `null`, the event's `prices` apply.

## Errors

The response body is always JSON.

| Situation | HTTP | Body |
|---|---|---|
| Body is not valid JSON | 400 | `{"errors":[{"message":"Malformed JSON body"}]}` |
| GET without `Apollo-Require-Preflight` | 400 | `errors[0].extensions.code = "BAD_REQUEST"`, message mentions CSRF |
| Query does not validate (unknown field or argument, wrong type) | 400 | `errors[0].extensions.code = "GRAPHQL_VALIDATION_FAILED"`; the message names the field |
| Bad argument value (negative page, unparsable date) | 200 | `data: null`, `errors[0].extensions.code = "BAD_USER_INPUT"` or `extensions.invalidArgs` |
| Resolver failure | 200 | `data` with `null` for the failed field and an entry in `errors` |

An `errors` array can accompany partial `data`; check for it on every response, not only on
non-200 statuses.

`extensions.notice` is **not an error**. It is a string on successful responses to requests
without a valid key, pointing to this portal. A client that ignores `extensions` is unaffected;
one that reads it can log it once and move on.

## Rate limits and quota

None today, keyed or not. When a quota arrives, keyed applications keep their own budget and
anonymous traffic first seen after that date may get a lower one. Not yet — this paragraph will
change first, and the changelog will say so.

Cache what you can: a listing that changes a few times a day does not need to be fetched every
second.

## Contact

Questions, a field you need, a change notice you did not get: post@hvaskjerkalender.no. Say which
calendar and, if you have one, which key.
