---
title: "GraphQL API"
description: "Query anonymised bookings, post-holiday satisfaction surveys and members, with filters and joins generated from the data."
---

> Documentation Index
> Fetch the complete documentation index at: https://docs.easyjet-hackathon.uk/llms.txt
> Use this file to discover all available pages before exploring further.

# GraphQL API

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`:

```sh
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.

> **bookings and csat have a raw_json field**
>
> D1 refuses any table wider than 100 columns, and these two are 179 and 147. The
> 99 most useful columns of each are real, queryable fields; every remaining field
> is still there in `raw_json`, which holds the complete original row as JSON.
> 
> So nothing is missing — but a field only reachable through `raw_json` cannot be
> filtered or ordered on, because the database sees it as one blob of text. If you
> need to filter by one of those, ask the organiser to promote it to a real column.
> 
> ```graphql
{
  bookings(limit: 1) { reservation_id raw_json }
}
```

## Every query takes the same four arguments

```graphql
{
  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:

```graphql
{
  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:

```graphql
{
  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:

```graphql
{
  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:

```graphql
{
  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.

> **Only hotels that appear in bookings**
>
> `hotels` holds the 93 distinct `hotel_code` values found in `bookings`, nothing
> else. A `csat` row whose survey names a different hotel resolves `hotel` to null —
> only 4 of the 100 surveys name a hotel that was booked. Content for any other
> hotel is one REST call away at `/api/hotel/<code>`.

## 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:

```graphql
{
  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`:

```graphql
{
  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:

```graphql
{
  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.

> **Airports sit on countries and resorts, not regions**
>
> Only 49 of the 189 destinations have airports attached — countries, resorts and
> virtual countries. A region such as `ESBA` has none, so reach for its country or
> its resorts when you need one.

> **Nested list fields take no arguments**
>
> `children`, `airports`, `bookings` and `surveys` return everything they match, up
> to 500. To filter or page, use the top-level query with a filter instead —
> `destinations(where: { parent_code: "ES" }, limit: 4)` rather than asking Spain
> for four children.

## Read this before you trust a join

> **Most survey-to-booking links are fabricated**
>
> Every `csat` row carries a **`link_type`**:
> 
> - **`real`** — the survey's reservation matched a booking. Trust it.
> - **`synthetic`** — it did not, and the row was attached to a booking so the data
> is joinable for a demo. **The connection is invented.**
> 
> The two source extracts came from different time windows and share no
> reservations, so today every row is `synthetic`. Filter on
> `where: { link_type: "real" }` to see only genuine links.

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.

```graphql
# 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`).

> **One join that looks like it should work, but doesn't**
>
> `csat` has its own `e_ejh_cc_reservation_id_txt`, and `bookings` has
> `reservation_id`. Both are surrogates from the same numbering, so comparing them
> is meaningful — there is simply no overlap yet, and matching on them returns
> nothing. Use `member` or `linked_reservation_id` to traverse, and treat a
> non-empty result from `e_ejh_cc_reservation_id_txt` as good news.

## 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.

Source: https://docs.easyjet-hackathon.uk/graphql-api/index.mdx
