Skip to content

GraphQL API

Query anonymised bookings, post-holiday satisfaction surveys and members, with filters and joins generated from the data.

Updated View as Markdown

Everything in the local database is queryable at /graphql. Open $API/graphql in a browser for GraphiQL, which lists every field with its type and autocompletes as you type — the fastest way to find out what exists.

The page loads without a key and arrives with the key already filled into its header editor, so you can run a query immediately. Everything else, including queries sent from your own code, needs x-api-key:

curl -H "x-api-key: $KEY" -H 'content-type: application/json' \
  -d '{"query":"{ bookingsCount csatCount membersCount }"}' \
  $API/graphql

The schema is generated from the database tables when the server starts, so when the organiser adds a dataset it appears here without a new release.

The datasets

Query What Notable fields
bookings Holiday bookings, one row per reservation reservation_id, member_id, hotel_code, selling_price, margin, start_date, duration, pax, departure_airport_code, board_code, agent_method
csat Post-holiday survey responses osat, nps, value_for_money, hotel_rating, comment, hotel_code, hotel_name, link_type
members One row per customer, aggregated member_id, booking_count, survey_count, total_selling_price, first_booking_date, last_booking_date, post_area
destinations 189 countries, regions and resorts code, name, type, parent_code, country_code
destination_airports 170 links between a destination and an airport destination_code, airport_code
hotels Content for the 93 hotels that appear in bookings hotel_code, name, star_rating, rating, tripadvisor_id, latitude, longitude, resort_name, description
hotel_images 2,685 hotel photos hotel_code, position, small, medium, large
hotel_facility_items 2,013 facilities, one row per facility hotel_code, group_name, name, facility_code, distance

Bookings carry around 180 fields covering flights, accommodation, bags, transfers, margins and promotions. Browse them in GraphiQL rather than guessing.

Every query takes the same four arguments

{
  bookings(
    where: { departure_airport_code: "MAN", selling_price_gte: 1000 }
    orderBy: "selling_price desc"
    limit: 20
    offset: 0
  ) {
    reservation_id
    hotel_code
    selling_price
  }
}
  • limit defaults to 50 and caps at 500.
  • orderBy is a field name, optionally followed by asc or desc.
  • Each query has a matching Count field — bookingsCount, csatCount, membersCount — taking the same where and ignoring pagination.

Filter operators

Every field supports these suffixes:

Suffix Meaning Example
none equals board_code: "AI"
_in one of departure_airport_code_in: ["MAN", "LGW"]
_gte at least selling_price_gte: 1000
_lte at most osat_lte: 3
_contains substring, text fields only comment_contains: "transfer"

Combine them freely; conditions are ANDed. Dates are stored as YYYY-MM-DD text and compare correctly, so start_date_gte and start_date_lte give you a window:

{
  bookingsCount(where: { start_date_gte: "2026-11-01", start_date_lte: "2026-11-30" })
}

Passing null matches rows where the field is empty — where: { member_id: null } finds bookings made through an agent or by a customer who wasn’t logged in.

Joins

Bookings and surveys both link to a member, and a member exposes both back:

{
  members(orderBy: "total_selling_price desc", limit: 5) {
    member_id
    booking_count
    total_selling_price
    bookings { reservation_id hotel_code selling_price start_date }
    surveys { osat nps comment link_type }
  }
}

Or from a survey outward:

{
  csat(where: { osat_lte: 3 }, limit: 10) {
    osat
    comment
    hotel_name
    member { member_id total_selling_price booking_count }
  }
}

A booking also reaches its hotel, and a hotel reaches its images, facilities and bookings. This is how you ask what the low scorers actually stayed in:

{
  bookings(where: { selling_price_gte: 2000 }, limit: 10) {
    reservation_id
    selling_price
    hotel { name star_rating rating resort_name tripadvisor_id }
  }
}

hotels.tripadvisor_id is on every one of the 93 hotels, so it is the way across to /api/tripadvisor/<id> for reviews and ratings.

Destinations

The same geography the search API uses, flattened into rows. 30 countries, 133 regions, 16 resorts, plus 10 virtual groupings like “South of France”.

type is one of Country, Region, Resort, VirtualCountry, VirtualRegion. parent_code is the destination one level up; country_code is the country it ultimately sits in, so you can filter a whole tree in one hop:

{
  spanishRegions: destinations(where: { country_code: "ES", type: "Region" }) {
    code
    name
  }
  resortCount: destinationsCount(where: { type: "Resort" })
}

Walk up or down the hierarchy through parent, children and country:

{
  destinations(where: { code: "GBCIJE" }) {
    name                                  # Jersey
    airports { airport_code }             # JER
    parent { name type }                  # Channel Islands, Region
    country { code name }                 # GB, United Kingdom
  }
}

Go the other way to find where an airport flies:

{
  destination_airports(where: { airport_code: "MAN" }) {
    destination { code name type country { name } }
  }
}

The code values are exactly what /api/search/packages wants for its geography parameter, so this is how you populate a destination picker and then search with it.

Read this before you trust a join

The practical consequence: on a synthetic row, the survey’s hotel and the joined booking’s hotel are different hotels.

  • For hotel-level satisfaction, use the survey’s own hotel_code and hotel_name.
  • Do not use member { bookings { hotel_code } } to decide which hotel a survey is about.
# Correct: satisfaction by hotel, using the survey's own hotel
{
  csat(where: { hotel_code: "ESTF0008" }) { osat hotel_name comment }
}

Anything that joins satisfaction to booking value, margin or lead time is measuring noise until the extracts are aligned. It will start being true without any change to your query once they are.

Anonymisation, and what it means for you

Identifiers are replaced with sequential surrogates: member_id reads M0001, reservation_id reads R0001. Consistency is preserved, so a member’s bookings and surveys still group correctly. Free-text comments have emails, phone numbers, URLs and booking references stripped. Postcodes are reduced to their area letters (JE2 becomes JE).

Asking for more data

The schema comes from the database, so a new dataset needs no code — the organiser imports it and it appears in GraphiQL. If you need a field that isn’t there, or a join that isn’t wired up, ask: adding a relation is a two-line change.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close