API
The public GraphQL API of Tysnes Kalender. Read the overview first if you have not; the full type list is on the reference page and in schema.graphql.
Endpoint
POST https://hvaskjer-staging.web.app/graphQL
Content-Type: application/json
{"query": "...", "variables": {...}, "operationName": "..."}
variablesandoperationNameare optional. Responses are JSON:{"data": ..., "errors": [...], "extensions": {...}}.- GET is accepted for queries (
?query=...&variables=..., URL-encoded) but only with the headerApollo-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 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. |
?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
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— whetherpage + 1has 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); 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.
{
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:mmwith a space between date and time. A strict RFC 3339 parser wants aT: 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, anddurationis in minutes. There is noendTimefield; derive it fromendDateinEurope/Osloor fromstartTime + duration. publishingDateandticketsFromDatefollow the same rule.created_atandupdated_atare 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.idis the identity of an event for its whole life.event_slugis the URL segment (eventLinkis the full URL);title_nb/title_enare edited by people.Event.categoriesis a list of category ids. Fetch the labels fromcategorieson every run — not once at install time, and never typed by hand. Ids differ between licences (the same label isCONCERTon one calendar and something else on another), and a licence may replace its whole taxonomy: a retired id disappears fromcategories, the events that carried it are migrated, and the id is never reused for anything else. Filtering on an id that is no longer incategoriesreturns nothing, silently. The changelog records every such change.VenueandOrganizerhave anidand aslug. Theeventsfilter takes the slug (venues,organizers); read it fromvenues/organizersrather than deriving it from a name.categoriesreturnsvisibleper 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.priceis typedIntand the server rounds whatever the organiser stored — but the stored value may be1.595(typed with a Norwegian thousands separator),150,-, or empty. When it cannot be read as a number the field isnull. Never divide by it, never assume øre.ticketsInformationsays which offree,noTicketsInfoorticketsInfoapplies;pricesis only meaningful forticketsInfo.ticketsURLis where tickets are sold when they are sold elsewhere.duration,minimumAge,maximumAge,cancellationPeriod,viewsmay benull.- Capacity fields (
registrationEnabled,availableTickets,activeTickets,maxBookingDate,maxBookingTime,paymentMethod) exist for calendars where visitors book through the calendar itself. On a calendar without bookings they arenullorfalse; do not readavailableTickets: nullas "sold out".eventSoldOutis the organiser's explicit flag. - A
Repetitionmay carry its ownprices; when it isnull, the event'spricesapply.
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.