Travelfront API v1 · package search across providers

Travelfront API

A travel-package aggregator. It takes one search — origin, destinations, a date window, a party — fans it out across every configured provider, normalizes what comes back into a single offer shape, and streams the merged result as it arrives. TripX Sweden, Sembo, Sunweb, Nazar, Solresor, Ving and TUI are live (a server's ENABLED_PROVIDERS setting can switch some off; /v1/providers says which). The contract is provider-shaped rather than TripX-shaped, so each one after the first cost an adapter, not a rewrite. The seven are deliberately unalike — one REST call returning a season, a GraphQL subscription pricing one departure date, a plain unauthenticated GET that pages 50 rows at a time, a TYPO3 page that carries its result set as a JSON literal, an unauthenticated JSON POST that prices one departure date and answers a dropped filter by widening rather than failing, a GraphQL POST whose whole query is one slash-delimited key that prices a seven-day window at one row per hotel, and a GraphQL POST to a search web component's server that takes one airport and one trip length per call with every destination at once — and all seven arrive here as the same Offer.

Base URL

http://localhost:3000

Everything below is relative to this. Change it with the chip in the header — “Try it” uses the same value.

Version

—

Read live from /health.

Status

checking…

—

What makes this API unusual

  • Offers reference hotels by id. The response carries one hotels dictionary and every offer points into it. See why.
  • There is a streaming form. /v1/search/stream emits NDJSON as each upstream call returns, so a UI can paint the first cards a second or two before the fan-out finishes.
  • Failures are visible in a 200. A provider that died shows up in trace.providers[].error. An empty market does not. Reading the trace is not optional — see Errors are never silent.
  • Dates are integers. Every date on an offer is an EpochDay: days since 1970-01-01 UTC.
  • Prices carry both the per-person average and the party total. Both pricePerPerson and priceTotal are always populated, and neither is derivable from the other. offer.priceSource says which kind of price you are holding: "quote" is real and bookable, while "catalogue" is a weaker provider search price. See Prices.
Authentication: the API answers only our own apps. Onyx proves itself with the token Vercel signs for it, any other app with its own API key sent as Authorization: Bearer <key>, and a person with their console sign-in. Production requires this since 2026-10-29; a server without API_AUTH=required or keys answers anyone. Authentication says which routes need it and how keys are issued.

Quick start

Three calls get you from nothing to offers. First check the service is up, then pull the reference data you need to build a valid query, then search.

bash
# 1. is it alive?
curl -sS http://localhost:3000/health

# 2. what can I fly from, and where to?
curl -sS 'http://localhost:3000/v1/reference/airports?market=se'
curl -sS 'http://localhost:3000/v1/reference/destinations?top=true'

# 3. search — two adults, Mallorca, a week in November
#    (the -H line is only needed on a server with API keys; see Authentication)
curl -sS -H "Authorization: Bearer $TA_API_KEY" \
  'http://localhost:3000/v1/search?origin=STO&destination=es.mallorca&departFrom=2026-11-01&departTo=2026-12-01&nightsMin=7&nightsMax=7&adults=2&sort=price_asc&limit=20'

For anything interactive, use the streaming form instead — same parameters, one JSON object per line, results as they land:

bash
curl -sN -H "Authorization: Bearer $TA_API_KEY" \
  'http://localhost:3000/v1/search/stream?origin=STO&destination=es.mallorca&adults=2' \
  | while read -r line; do echo "$line" | head -c 160; echo; done

-N disables curl's output buffering. Without it you will see the whole stream arrive at once and conclude, wrongly, that streaming does not work.

Authentication

Building an app that calls this API? Start with CLIENTS.md: keys, the calls to make, and how to read a response correctly, written to be handed to a coding agent.

Each client gets its own API key and sends it on every search as Authorization: Bearer <key>. The server binds the client's name, never its key, to every log line a request writes, so a slow or failing caller can be named from the logs, and it logs a refused key as a warning. Production logs at warn, so a routine request writes no line there; set LOG_LEVEL=info to log every request.

Our own apps on Vercel need no key. Every Vercel function is handed a token Vercel signs for its own team, project and environment (the x-vercel-oidc-token header on its own request), and Onyx sends it as Authorization: Bearer, never under Vercel's header name, which Vercel overwrites on every request into this API. The API checks the signature against Vercel's published keys, the team, and that the project is on API_APPS (<client>:<project id>, default Onyx's) and the environment is production, or development, the token vercel env pull writes for a team member's laptop. A preview deployment is never let in, and nothing has to be minted or copied. A person signed in to the console gets in by their session, wherever a key would.

The check is on when API_AUTH=required, or when the server has keys configured; production runs with required. A server with neither answers every route without a credential, exactly as before keys existed. API_AUTH=off beside a key list stops the server from starting rather than guessing which was meant. /health says which: "auth": "required" or "auth": "off".

Needs an app (key or Vercel token) or a console sessionAnswers without one
GET /v1/search
POST /v1/search
GET /v1/search/stream
GET /v1/search/plan
GET /v1/trends
GET /v1/matrix
/v1/fronts/resolve, GET /v1/fronts/{id} and GET /v1/fronts/{id}/ai
(the console's own routes, POST /v1/matrix/fill among them, need a console session)
/health, /openapi.json, /docs
/v1/reference/* and /v1/providers: static tables a client needs before it searches
/go/… booking links: opened by a customer's browser, and already signed by the search that made them
curl
curl -sS -H "Authorization: Bearer $TA_API_KEY" \
  'http://localhost:3000/v1/search?destination=es.mallorca&adults=2'

A search without a key, or with one the server does not recognise, is refused before anything runs:

401 application/json
{
  "error": "missing_api_key",
  "message": "This API answers only our own apps: send `Authorization: Bearer <key>`, or sign in to the console.",
  "statusCode": 401,
  "requestId": "e0f0b972-03f9-4837-8121-e110a25b725b"
}

missing_api_key means no header was sent; invalid_api_key means a header was sent and either is not Bearer <key> or names a key the server does not hold, for instance one that has been revoked. The response also carries WWW-Authenticate: Bearer, with error="invalid_request" for a header that is not a bearer token and error="invalid_token" for a key the server does not hold. Never put a key in a URL: query strings end up in logs and browser history, and the API does not read one there. A Vercel token that does not hold is a 401 invalid_app_token whose message says why (forged, expired, another team, a project not on API_APPS, a preview); one that cannot be checked because Vercel's keys could not be fetched is a 503 oidc_unavailable, never a pass.

Daily search ceiling

Each app may start a set number of searches per Stockholm day: API_DAILY_SEARCHES, <client>:<n> entries with * for every app not named, default *:1000. It is a brake on a leaked credential or a scripted client, set far above a real day, because every search fans out to the travel companies from one address. Past it the three search routes answer before anything runs, with Retry-After in seconds until midnight in Stockholm. Only apps are counted, never a console session, and only the search routes. When the counter cannot be read in time the search goes ahead and the miss is logged: a brake, not a gate.

429 application/json
{
  "error": "quota_exceeded",
  "message": "\"onyx\" has used all 1000 of its searches for today. The count starts again at midnight, Stockholm time.",
  "statusCode": 429,
  "requestId": "e0f0b972-03f9-4837-8121-e110a25b725b"
}

Browser apps

CORS allows any origin and the Authorization header, so an app on another domain can call the API directly. Anything shipped to a browser can be read by whoever uses it, though, so a key inside a public web page is a public key. A public site should call the API from its own server and keep the key there.

Issuing keys

For whoever runs the server. npm run api-key -- <client> mints a key and prints it once, together with the entry that admits it. The server holds only that entry: the client's name and the SHA-256 of its key, in the API_KEY_HASHES environment variable, comma-separated.

bash
npm run api-key -- partner-app
# client  partner-app
# key     tak_…            give this to the client
# entry   partner-app:3f…  add this to API_KEY_HASHES

API_KEY_HASHES='console:9b1c…,partner-app:3f…' npm run dev

To revoke a key, delete its entry. To rotate one, list the new entry beside the old for the same client, move the client over, then delete the old. The list is read at startup, so a change takes effect on the next restart or deploy. An entry the server cannot read stops it from starting rather than leaving that client, or every client, silently locked out or let in.

Console sign-in

The fronts console, the Travelfront Portal, is a static site, so its sign-in screen alone would guard nothing: the API is what checks. Once GOOGLE_CLIENT_ID is set, the consoles' own routes need a console session: listing fronts (GET /v1/fronts), writing them (PUT and DELETE /v1/fronts/{id}), their clicks (GET /v1/fronts/{id}/clicks), every /v1/clients route, and filling the price matrix (POST /v1/matrix/fill, which spends provider calls). An API key does not stand in for one. The reads Onyx makes to pick and brand a front, GET /v1/fronts/resolve and GET /v1/fronts/{id}, stay on the ordinary rule, and so do searches, the matrix index and booking links.

This server's own web console (the search console at / and /matrix.html) signs in the same way, with the same session, and keeps it per API base. Its sign-in screen spares a visitor a console that would not work; what guards the fill is the API. Searches stay public because Onyx and other clients make them: closing those is what API keys are for.

CallDoesAnswers
GET /v1/consolePublic. Whether the console needs a sign-in, the Google client to draw the button with, and the session the request carried.{ signIn: "open" | "google" | "misconfigured", googleClientId, session: { email, expiresAt } | null }
POST /v1/console/sessionPublic. Body { "credential": "<Google ID token>" }, the token Google's sign-in button hands the browser. Checks Google's signature, that it was issued for googleClientId and has not expired, and that its email is verified and listed in CONSOLE_EMAILS.{ token: "tcs_…", email, expiresAt }, a session for 7 days: send it as Authorization: Bearer tcs_…. 401 invalid_credential (the message says why), 403 not_allowed or email_unverified, 409 sign_in_off, 503 google_unavailable.

A console route without a session answers 401 sign_in_required; an ended one 401 session_expired, a forged one 401 invalid_session, both with WWW-Authenticate: Bearer realm="travelfront-console", error="invalid_token"; an address taken off the list 403 not_allowed at once, because the list is read on every request. A session is accepted wherever an API key is, so the console keeps working when keys are switched on. /health reports "console".

SettingMeaning
GOOGLE_CLIENT_IDThe Google OAuth web client (…apps.googleusercontent.com). Unset: sign-in is off and the console routes follow the ordinary rule ("console": "open").
CONSOLE_EMAILSWho may sign in, separated by commas or spaces. Default marcus@mzr.se.
CONSOLE_SESSION_SECRETOptional; signs the sessions. Unset, a key is derived from BOOKING_LINK_SECRET. Setting or changing it signs every session out.
CLIENT_SECRETS_KEYOptional, 32+ characters; seals clients' AI keys. Unset, a key is derived from BOOKING_LINK_SECRET. Changing whichever is in use makes every stored AI key unreadable (the console shows readable: false) until it is entered again.
AI_KEY_READERSVercel project ids whose production deployments may read a client's AI key. Default: Onyx's. VERCEL_OIDC_TEAM_SLUG and VERCEL_OIDC_TEAM_ID name the team whose OIDC tokens are trusted.

A setting that cannot be read closes the console routes (503 sign_in_misconfigured, "console": "misconfigured") and leaves everything else running. It never opens them.

GET/health

Liveness and build identity. Cheap enough to poll; it touches no provider and no network. A green /health says the process is up — it says nothing about whether TripX is answering, which is what the search trace is for.

Parameters

None.

Response

FieldTypeNotes
statusstring"ok" when the process is serving.
versionstringThe package version this build was cut from.
uptimeMsintegerMilliseconds since process start. A number that keeps resetting is a crash loop.
nodestringNode runtime version, e.g. "v22.14.0".
auth"off" | "required"Whether the guarded routes need an app (key or Vercel token) or a console session on this server. See Authentication.
console"open" | "google" | "misconfigured"Whether the fronts console routes need a Google sign-in. See Console sign-in.
curl
curl -sS http://localhost:3000/health
200 application/json
{
  "status": "ok",
  "version": "0.1.0",
  "uptimeMs": 84213,
  "node": "v22.14.0",
  "auth": "off",
  "console": "google"
}

GET/v1/reference/airports

Every origin the service can search from, for one market. Build your origin picker from this — do not hardcode a list. Some entries are group codes: STO is not an airport, it is Stockholm, and it expands upstream to ARN + NYO + BMA. Group codes are the ones you usually want; passing a bare airport code where the provider expects a group code returns zero rows, which is indistinguishable from no inventory.

Which vocabulary a provider speaks is the adapter's problem, not yours. TripX takes STO as a group and answers a bare ARN with zero rows; Sunweb has no group code at all — STO returns zero there, so the adapter expands it to ARN + NYO, its two Stockholm airports, and never to Bromma, which Sunweb does not sell. Nazar has no group code either and sells only from Arlanda in Stockholm, so STO expands to ARN alone and the expansion is reported rather than assumed. TUI does not know STO at all and answers it with its empty-market error, so it expands to ARN + NYO, the two Stockholm airports TUI flies from.

Offers report the real departure airport in offer.origin and offer.outbound.from, so a search on STO can legitimately come back with offers whose origin is ARN.

Parameters

NameTypeDefaultDescription
market"se" | "no"se Which market's origin list to return. Markets differ: the Swedish list is 28 entries, including the STO group code alongside its three members and the two foreign departure points sold into Sweden — CPH, and OSL, which only Sunweb, Nazar and Solresor sell. An unknown market is a 400, not an empty list.

Response

{ airports: Airport[] }. Each entry is identified by iata and carries a display name plus a nameSv in the market's language. isGroup marks a group code, and members lists the airports it expands to.

curl
curl -sS 'http://localhost:3000/v1/reference/airports?market=se'
200 application/json
{
  "airports": [
    {
      "iata": "STO",
      "name": "Stockholm (all airports)",
      "nameSv": "Stockholm (alla flygplatser)",
      "isGroup": true,
      "members": [
        "ARN",
        "NYO",
        "BMA"
      ],
      "markets": [
        "se"
      ],
      "popularity": 100
    },
    {
      "iata": "GOT",
      "name": "Gothenburg Landvetter",
      "nameSv": "Göteborg Landvetter",
      "isGroup": false,
      "members": null,
      "markets": [
        "se"
      ],
      "popularity": 55
    }
  ]
}

Field names mirror the checked-in upstream capture at src/reference/data/_raw/tripx-origins-se.json. Hit “Try it” to see exactly what your build returns.

GET/v1/reference/destinations

The destination tree, plus the countries that group it. Destination ids are ours, not the provider's — stable, provider-independent, lowercase, and shaped <country>.<place>, e.g. es.mallorca. Pass those ids to /v1/search?destination=. Never pass a provider's own code; the whole point of the id is that it survives a provider swap.

hotelCount is the size of the inventory behind a destination, which is the honest way to order a picker: Mallorca (644 hotels) belongs above Vlorë (23). The full Swedish tree is 46 countries and 165 destinations, so top=true exists to keep a first-load picker small.

Parameters

NameTypeDefaultDescription
topbooleanfalse Return only the headline destinations instead of the whole tree. Use it for a default picker; fall back to the full list behind an “all destinations” toggle.
countryISO-3166 alpha-2 or name— Restrict to one country. Matched loosely on purpose: ES, es and Spanien all work, as does the country prefix of an id. Combines with top. Does not narrow countries.
market"se" | "no"se Which market's tree to return. An unknown market is a 400.
limitinteger 1–1000— Cap on how many destinations come back, applied after top and country. countries is never capped.

Response

{ destinations: Destination[], countries: Country[] }. The two lists are siblings rather than nested so a client can render either a flat filterable list or a grouped one without reshaping anything. When country is set, destinations narrows to that country.

curl
curl -sS 'http://localhost:3000/v1/reference/destinations?top=true&country=ES'
200 application/json
{
  "destinations": [
    {
      "id": "es.mallorca",
      "name": "Mallorca",
      "nameSv": "Mallorca",
      "countryCode": "ES",
      "countryName": "Spain",
      "iata": [
        "PMI"
      ],
      "primaryIata": "PMI",
      "region": "Balearics",
      "hotelCount": 644
    }
  ],
  "countries": [
    {
      "code": "AE",
      "name": "United Arab Emirates",
      "nameSv": "Förenade Arabemiraten",
      "hotelCount": 394,
      "destinationCount": 2
    }
  ]
}

Abbreviated: each record also carries countryNameSv, lat/lng, an aliases list, the provider slug and thumbnail, and providers.tripx.code, providers.sembo.polygonIds, providers.sunweb — { "id": 83, "level": "region" } for Mallorca — providers.nazar.code, the arrival airport Nazar takes in its search path, and providers.solresor.ids, a list of Solresor package-destination ids. Sunweb's tree has three levels (country, region, city) and the level is stored rather than resolved at call time, because the alias that would resolve it answers an unrecognized id by dropping the filter and returning the whole 1056-row catalogue, while the typed per-level parameter returns zero. 21 of our destinations bind to Sunweb, reaching about 90% of its 1172 accommodations. Nazar is the opposite extreme: it flies to three arrival airports in total, so exactly three of our destinations carry a providers.nazar block and the other 162 are honestly null. Solresor needs a list for the opposite reason Sembo does: Sembo splits one of our destinations across several polygons, while Solresor sells several marketing areas that all land at one arrival airport we model as a single place — tr.antalya is five of its ids (Alanya, Side, Belek, Kemer, Antalya), and storing only the first would sell a fifth of Turkey's south coast and report it as the whole coast. 52 of its ids bind to 41 of our destinations; twelve more places it sells (Azores, Cancún, Playa del Carmen, Mauritius, Seychelles, Calabria, La Palma, Budva, Krabi, Cape Verde, Samos, Bodrum) have no id in our taxonomy at all and are listed in its provider doc rather than quietly dropped. providers.ving.geoIds is a list too, of nodes in Ving's geography tree — not the CMS ids its result rows carry, which are a different number space that collides with the tree's (row area 135 is Parga, tree node 135 is Morocco). Two of our destinations can share one node, because Ving's areas are political and ours are arrival airports: Crete is one Ving node for gr.chania and gr.heraklion, and each row is attributed by the arrival airport in its offer token. 14 destinations bind to Ving; Skiathos, Lesbos, Lefkas, Thassos and Cape Verde have priced Ving charter and no entry in our taxonomy. providers.tui.units is a list of TUI's own geography keys with the level inside the string ("G-000075:DESTINATION"), because the level is part of the key: the same id sent as COUNTRY is answered with TUI's empty-market error. 85 destinations bind, by the arrival airports observed on TUI's rows; like Ving's, eight TUI nodes serve two of ours (Crete again), and rows are attributed by their own arrival airport. A provider block is null where that provider cannot search the place at all — not the same statement as absent. Both languages are always present — name is English-ish, nameSv is the Swedish label the traveller recognizes — so a UI picks rather than re-translates. Hit “Try it” for the full records.

top-destinations is config, not truth. The top=true list is a curated first cut — ranked from TripX hotel counts and weighted toward the Swedish charter market — and it is meant to be replaced wholesale by the business's own list. It is also what a search with no destination fans out to, so it decides real spend. Treat it as a tunable.

GET/v1/providers

Every registered provider and its declared capability matrix, verbatim from the ProviderCapabilities the adapter returns. This is not documentation that can drift — it is the same object the orchestrator reads when it decides which filters to push upstream and which to apply locally.

Read it when a result set surprises you. If upstream.boards is false, a board filter did not narrow the upstream call, it only trimmed the rows we already paid for — and against a provider with a maxResultsPerCall ceiling that materially changes what you got back.

providers holds the ones this server runs. disabled holds the ones it has but has switched off with its ENABLED_PROVIDERS setting (comma-separated ids, or all; unset means all). A switched-off provider is never searched, quoted or asked for flights, so it never appears in an offer or a trace: its absence from a result is a setting, not an empty market. An unknown id in the setting stops the server from starting rather than quietly running without it.

Parameters

None.

Response

curl
curl -sS http://localhost:3000/v1/providers
200 application/json
{
  "providers": [
    {
      "id": "tripx",
      "capabilities": {
        "markets": ["se"],
        "upstream": {
          "dates": true,
          "nights": true,
          "boards": true,
          "stars": true,
          "score": true,
          "priceRange": true,
          "directOnly": false
        },
        "maxResultsPerCall": 500,
        "paginates": false,
        "concurrency": 16,
        "pricing": {
          "quotes": true,
          "note": "TripX's /search sells a rounded browse price which, on an undated query, is quoted for two adults whatever party was asked for; quote() replaces it with the bookable price from the endpoint their own booking page calls."
        }
      }
    },
    {
      "id": "sembo",
      "capabilities": {
        "markets": ["se"],
        "upstream": {
          "dates": true,
          "nights": true,
          "boards": true,
          "stars": false,
          "score": false,
          "priceRange": false,
          "directOnly": false
        },
        "maxResultsPerCall": 2118,
        "paginates": false,
        "concurrency": 8,
        "pricing": {
          "quotes": false,
          "note": "Sembo search prices come from its booking endpoint and planner for the exact party and departure date. Offers with complete room codes link to that exact hotel, board, complete room selection and cancellation policy and are already labelled quote; a response missing any room code or its cancellation policy remains catalogue."
        }
      }
    },
    {
      "id": "sunweb",
      "capabilities": {
        "markets": ["se"],
        "upstream": {
          "dates": true,
          "nights": true,
          "boards": true,
          "stars": true,
          "score": true,
          "priceRange": true,
          "directOnly": false
        },
        "maxResultsPerCall": 50,
        "paginates": true,
        "concurrency": 8,
        "pricing": {
          "quotes": false,
          "note": "Sunweb prices each row for the exact party, date, duration and board asked for — it is not a rounded browse price. It stays \"catalogue\" because it is a cheapest-room \"från\" price: the room is chosen in booking step 1, mandatory local taxes and checked baggage are excluded, and Sunweb warns the cheapest fare may use different outbound and return airports. Re-quoting cannot improve it — the only re-pricing endpoint ignores the departure airport and returns the globally cheapest one (measured: 8315 returned for an 11133 row)."
        }
      }
    },
    {
      "id": "nazar",
      "capabilities": {
        "markets": ["se"],
        "upstream": {
          "dates": true,
          "nights": true,
          "boards": false,
          "stars": false,
          "score": false,
          "priceRange": false,
          "directOnly": false
        },
        "maxResultsPerCall": null,
        "paginates": false,
        "concurrency": 4,
        "pricing": {
          "quotes": true,
          "note": "Nazar's search price is exact for an adults-only party — it matched the bookable price to the krona on 41 of 41 hotels measured. Add a child and it stops being reliable in BOTH directions: of 42 family comparisons, 9 named a total that is not purchasable at all, ranging from 25% below the cheapest bookable room to 29% above it. quote() therefore re-prices the page against the booking page Nazar's own \"Välj resa\" button opens, and that page is also the only place the ROOM and the BOARD are stated."
        }
      }
    },
    {
      "id": "solresor",
      "capabilities": {
        "markets": ["se"],
        "upstream": {
          "dates": true,
          "nights": false,
          "boards": false,
          "stars": true,
          "score": true,
          "priceRange": false,
          "directOnly": true
        },
        "maxResultsPerCall": 1000,
        "paginates": false,
        "concurrency": 8,
        "pricing": {
          "quotes": false,
          "note": "Solresor prices each row for the exact party asked for — verified against their own booking page: 2 adults quoted 7207 SEK/person on both sides, and 2 adults + 2 children 5193 SEK/person on both sides, same hotel and date. It is not a rounded browse price and re-quoting cannot improve it. It stays \"catalogue\" because it is a cheapest-package \"från\" price: the row names no room and no rate, and the board is undisclosed unless the package is All Inclusive."
        }
      }
    },
    {
      "id": "ving",
      "capabilities": {
        "markets": ["se"],
        "upstream": {
          "dates": true,
          "nights": false,
          "boards": false,
          "stars": false,
          "score": false,
          "priceRange": false,
          "directOnly": false
        },
        "maxResultsPerCall": 250,
        "paginates": true,
        "concurrency": 4,
        "pricing": {
          "quotes": true,
          "note": "Ving's search total is for the whole party in SEK. For adults only it matched the price on Ving's own room picker on 151 of 156 offers (the rest had sold out between calls). With children it is NOT the bookable price: the search prices every child as a five-year-old whatever age is asked, so infants come out 11-43% too high and teenagers up to 15.5% too low, and some rooms cannot be booked for that party at all. quote() re-prices against the room picker the hotel page uses, for the real ages. No refundability is published anywhere."
        }
      }
    },
    {
      "id": "tui",
      "capabilities": {
        "markets": ["se"],
        "upstream": {
          "dates": true,
          "nights": true,
          "boards": false,
          "stars": false,
          "score": false,
          "priceRange": false,
          "directOnly": false
        },
        "maxResultsPerCall": 200,
        "paginates": true,
        "concurrency": 4,
        "pricing": {
          "quotes": false,
          "note": "TUI's search total is the bookable price for the whole party in SEK, for the exact ages asked: it matched TUI's own booking page on 3 of 3 opened and TUI's re-price call on 40 of 40, across adults-only, infant, child and teenager parties. Per person divides by adults and children, never infants. It reads \"catalogue\" because we did not re-quote it; there is nothing better to fetch. Free cancellation is stated only where it applies."
        }
      }
    }
  ],
  "disabled": []
}

Those are the measured values for the seven live providers; a server started with ENABLED_PROVIDERS=tripx,sembo would return those two under providers and the other five under disabled. The matrix above renders live from /v1/providers, so each provider always reports its own. For TripX, every upstream filter except direct-only works — no direct-flight filter exists upstream at all, so stop counts come back per leg and are applied locally. boards: true carries an asterisk: the upstream field is a bare string, so exactly one board can be pushed per call and a multi-board query is narrowed here instead. One call returns at most 500 rows and there is no page, offset or cursor parameter of any kind, so the only route to more inventory is a narrower query and more calls.

Sembo declares far fewer upstream filters, because only its board filter has been measured to narrow — a sweep filtered to all-inclusive returned 1176/1176 rows all AI — and an unverified flag would stop the orchestrator filtering locally. Sunweb declares the same six as TripX and skips direct-only for a different reason: it publishes no flight information anywhere in its API, so there is nothing to push upstream and nothing to filter locally either, which is also why every Sunweb offer carries direct: null. Its boards: true is safe only in combination with a closed six-value board table in the adapter — nine of Sunweb's filters answer an unrecognized value by dropping the filter and returning the unfiltered set, so a typo would hand you the boards you excluded rather than an error.

Nazar declares the fewest of any provider, and every false is a measurement rather than caution. Its board and star filters are client-side JavaScript over the result set, not request parameters, so there is nothing to push; its board tag is a property of the hotel rather than of the priced package, so a boards: true here would stop the orchestrator filtering locally on the one board value we do publish. What it does narrow exactly is dates and trip length: one call is one departure date and one of the two lengths Nazar sells, and 288 of 288 measured rows came back on the date and length asked for. Its concurrency: 4 is the smallest in the system and the only one set from an error ceiling — Nazar sheds with HTTP 503 above roughly 4–5 requests a second while its latency stays flat, so there is no knee to find.

Solresor is the one provider whose false flags guard against generosity rather than absence. Its own filter_options endpoint publishes ["RO","BB","HB","FB","AI"] — byte-identical to our board union — and the search endpoint ignores the field completely: ["AI"], ["RO"] and even ["ZZ"] all returned the same 502 rows, so boards: false is a measurement against the most inviting thing in the API to declare true by inspection. nights: false is the same shape of care: its trip-length filter is expressed in days, and the day-to-night offset is 1 or 2 at every length, so the request is deliberately widened by a day and the adapter narrows it back — declaring the filter would serve the widening. What it does narrow exactly is dates, stars, TripAdvisor score and direct-only. Its concurrency: 8 is the only one in the system set from neither a knee nor a ceiling: across ~600 requests to level 24 there were no 429s, no 5xx and no socket errors, and p50 stayed inside 300–500 ms throughout, so the measurement can only fail to forbid a number rather than recommend one. 8 is a politeness bound.

Ving pushes only geography and dates, and the reason is that everything else it accepts fails silently. Its trip-length code is a group, not a length — a "two weeks" search returned 10- and 11-night trips on 18 of 214 rows — so nights: false and the range is narrowed here. Its meal and "vågor" facets were not measured, so they stay local. What the flags cannot show is the input it does take: an airport it does not fly from, a past date and a party it does not sell all return a well-formed page of hotels with no price, byte-identical to a sold-out week, and an unknown destination key returns the whole catalogue. So nothing reaches the wire that has not been checked against Ving's own published vocabulary, and the adapter re-checks every row against the offer token. Its concurrency: 4 is, like Solresor's, a politeness bound: nothing shed and nothing slowed to 8.

TUI pushes geography, dates and an exact trip length. Its nights: true is narrower than it looks: the parameter takes a list and answers for the first length in it that has inventory and only that one, so the adapter sends exactly one length per call and plans one call per length in the range. Its date window is applied to the day the flight lands, so an overnight long-haul leg leaving the day before the window is TUI's answer, and the adapter re-checks every row against the caller's range on the departure date. Board, stars and price are facets on its response but were not measured as inputs, so they stay local. Like Ving and Solresor, it fails silently both ways — an unknown airport or destination is answered with the same NO_CONTENT error a sold-out week gets, while an empty destination list is the whole world — so nothing reaches the wire unvalidated, and its concurrency: 4 is a politeness bound: nothing shed and nothing slowed to 8.

Sunweb is also the first provider with paginates: true. Its maxResultsPerCall: 50 is a request cap, not a result cap — limit=51 is an HTTP 400 — and execute() pages past it, so a full page is not the truncation signal it is for TripX's 500: a 1192-row set paged in 50s collected 1192 distinct rows, no duplicates. Sembo's 2118 is the largest single response measured (ARN→AYT, 2118 board options), and it cannot page at all. Solresor's 1000 is a result cap and a hard wall: it has no offset, page or cursor parameter anywhere, and because a search returns one row per hotel, a response at exactly 1000 is a truncated prefix with no total field to prove it — the row count is the only evidence there is, so execute() says so in the trace. Ving pages too, 250 at a time, but its total is the trap: count is every hotel in the geographic scope, priced or not, with the priced rows first. So execute() stops at the first unpriced row rather than at count, which would have spent three more calls reading hotels with nothing for sale. TUI pages 200 hotels at a time and its total is authoritative, but it must never be overrun: a page number past the last returns the whole result set again, so execute() stops at the count.

pricing is published rather than omitted, and it is the field that says whether a second call could improve a price. TripX declares quotes: true because its search price is a rounded browse price, and so does Nazar — for a narrower reason: its search price is exact for adults and drifts for parties with children, on 9 of 42 measured family comparisons, in both directions. Ving declares it for the same reason with a sharper cause: its search prices every child as a five-year-old whatever age was asked, so an infant comes out 11–43% too high and a teenager up to 15.5% too low, and its quote() re-prices against the room picker the hotel page itself uses, for the real ages. Sembo, Sunweb, Solresor and TUI declare quotes: false with a note explaining that their search price already comes from the endpoint their own booking page uses. TUI's is measured against its own re-price call, which returned the identical total on 40 of 40 offers across five parties, infants and teenagers included. Solresor's case is the cleanest of the four: opening its own booking page for the same hotel and date rendered 7207 SEK/person for two adults and 5193 SEK/person for two adults and two children — the two numbers its search returned, to the krona. It stays "catalogue" because the row names no room and no rate, and its board is undisclosed unless the package is All Inclusive, so we know the price without knowing which product it buys. Without it, an offer nobody re-quoted reads as a degraded one instead of a deliberate one — see Prices.

GET/v1/search

The buffered search. Fans out to every provider, waits for the aggregation to finish or for budgetMs to expire, then re-prices the page and proves its itineraries before returning one SearchResponse: the normalized query, result metadata, the offer page, the hotel dictionary, facets over the whole matched set, and the execution trace.

Pricing starts during the search where it is cheap to: a provider whose quote is priced per trip (pricing.quoteCost, TripX) is quoted as soon as its own searches are done, so it is priced while a slow provider is still answering. Per-offer quotes (Ving, Nazar) wait for the page. Flight detail runs alongside pricing, since a quote touches only prices and flight detail only itineraries. All three share one deadline, totalBudgetMs. Each takes the lesser of its own ceiling and what is left, so a slow fan-out costs pricing its time rather than adding to the total — and when the remainder runs out, meta.priceQuoteNotes and meta.flightEnrichmentNotes name the deadline as the reason instead of leaving a partly-priced page to look like a broken provider. meta.searchMs, meta.quoteMs and meta.flightMs say where the time went.

Prefer /v1/search/stream for anything a human is watching. Use this form for scripts, tests and anything that wants one JSON document.

Parameters

origin, destination, board and childAges accept either a repeated parameter (?origin=STO&origin=GOT) or one comma-joined value (?origin=STO,GOT). Every parameter is optional, and each plural alias is accepted too — origins, destinations, boards, childrenAges, plus from/ to for the date window, budget for budgetMs and totalBudget for totalBudgetMs.

Nothing is ever silently ignored. A term this service cannot resolve — a mistyped destination, an unknown board code, a bad sort key — is a 400 that names the field, echoes the term and offers suggestions. A dropped filter would be indistinguishable from an empty market at the other end of the wire, which is the failure mode this codebase exists to eliminate. Paging knobs are the exception: limit, offset, budgetMs and totalBudgetMs are clamped into range rather than rejected, because a page size should never fail a search.
NameTypeDefaultDescription
originIATA code, repeatable / commaSTO Departure airport or city-group code, from /v1/reference/airports. Group codes cover every airport in the city. Also resolves names and aliases (Växjö, vaxjo, VXO). Asking for a member airport works: the group is fetched and the result filtered back down to that airport.
destinationdestination id, repeatable / comma— Our destination ids, e.g. es.mallorca. Also accepts a name, an alias or an arrival IATA — Teneriffa, tenerife and TFS all resolve. Omitting it does not mean “anywhere”: it fans out to the curated top 10 destinations, because “anywhere” is a 166-call sweep that no budget survives. Anything unresolvable is a 400 with suggestions, never a silent drop.
departFromdate YYYY-MM-DD— Earliest acceptable departure, inclusive. Omit both dates for “whenever”.
departTodate YYYY-MM-DD— Latest acceptable departure, inclusive.
nightsMininteger 0–90— Shortest acceptable trip, in nights, inclusive. nightsMin above nightsMax is a 400, as is departFrom after departTo.
nightsMaxinteger— Longest acceptable trip, inclusive. Setting both equal is the common case (“exactly a week”).
adultsinteger 1–92 Adults in the party. Occupancy changes the per-person price materially — it is not a display detail. For more than one room, use rooms below instead.
childAgescomma-joined integers 0–17— Ages at time of travel, e.g. 4,9. The child count is the length of this list; there is no separate children parameter. At most 9.
roomsJSON array— Multi-room parties, e.g. [{"adults":2,"childrenAges":[5]},{"adults":2,"childrenAges":[]}]. Accepted as a JSON string on the query string too. When present it wins over adults/childAges. At most 9 rooms.
boardcomma of RO BB HB FB AI— Canonical board codes: room only, bed & breakfast, half board, full board, all inclusive. Multiple values are OR-ed.
starsMinnumber 0–5— Minimum hotel star rating, 0–5. Halves are allowed in the data, so 3.5 is a legal filter. Setting it at all also excludes hotels with no star rating — including starsMin=0, which is an active filter, not a no-op. Omit the parameter for “no constraint”.
scoreMinnumber 0–100— Minimum normalized guest score, 0–100. Hotels with no score are excluded whenever this is set — an unknown value never satisfies a filter that asks about it.
priceMaxinteger— Maximum total price for the whole party, in currency — not the per-person figure. Compare it against offer.priceTotal and against facets.priceMin/priceMax, which are also totals. Applied to the catalogue price, like the sort and the price facet — see meta.orderedBy — so a quoted offer on the page can land a little either side of it.
directOnlybooleanfalse Keep only offers where both legs are known non-stop. Offers whose stop counts are unknown (direct: null) are dropped by this filter, not kept.
quoteboolean | 0-120true Re-price candidates against provider booking endpoints before returning the page. TripX and Nazar have a quote to make: capabilities().pricing.quotes is true for those two. Set this false to skip that extra phase — TripX and Nazar rows then keep their catalogue prices (for Nazar that also means no room name and a board inferred from the hotel's meal tags), while a Sembo row whose exact room selection is already pinned remains priceSource: "quote" because no second request can improve it, and a Sunweb row is unaffected either way: it is already priced for the exact party, date, duration and board, and stays "catalogue" because it is a cheapest-room price rather than because it is stale. meta.priceQuoteNotes says what was and was not improved. A number N prices only the N cheapest by browse price on a price sort, re-ranks them on the price served and leaves the rest of the page on its browse price (meta.quoteWindow says how many were priced; on another sort, the page rows among the first N). On three family searches the cheapest 10 after pricing 10 were the cheapest 10 after pricing 120 (measured), at a quarter of the requests with N=30.
flightsbooleantrue Attach the itinerary a provider keeps behind a read-only follow-up call, filling in outbound, inbound, direct and overnight. Sunweb, Solresor and Ving need it; TripX, Sembo, Nazar and TUI return legs in the search itself. The cost differs sharply: Sunweb spends three GETs per offer on the page, while Solresor's endpoint is keyed on route and dates and is memoised per pass, so a page of many hotels over a few routes costs a handful of calls in total (measured: 19 offers over 3 routes = 3 requests, 524 ms). Set this false if you never render a carrier or a stop count. Those fields then stay null and meta.flightEnrichmentNotes says nobody looked, which is not the same claim as an itinerary with stops. It never moves a price. A directOnly=true search enriches regardless, but asks a narrower question — it only needs directness resolved, not full itineraries — because that filter excludes unknown-direct rows and would otherwise answer “no non-stop packages” having never looked.
sortenumprice_asc price_asc, price_desc, depart_asc, score_desc, stars_desc, nights_asc. Price sorts use priceTotal, never the per-person average. A front window is re-ranked on quotes when possible; meta.orderedBy says which prices decided membership.
groupBynone hotel tripnone Collapse the result set into groups, returning a groups block alongside the flat offers[]. hotel puts every departure date of one provider's hotel on a single row — what does this hotel cost across the season. trip puts the same hotel and the same departure from different providers on a single row — who sells it cheapest. Grouping runs over the whole filtered result set before paging, so limit/offset then page groups and a group's price range describes the group rather than the page. Every trip group publishes a match saying what proved its rows are the same hotel; read it before reading a price difference.
groupMembersinteger 1–508 Per-group cap on how many members are serialised into offers[]. Each provider's cheapest member is always included on top of the cap, so byProvider can never point at an offer the response does not carry. A group the cap cut says so in its own truncated flag and still reports a price range over all its members. Ignored without groupBy.
perProviderinteger 0–100 Hold at least this many of each provider's cheapest offers on the page. A plain cheapest-first page can be one provider from top to bottom while six others answered, and a reader concludes nobody else sells the trip. With perProvider=3 every provider that found anything shows its three cheapest, swapped in for the most expensive offers of providers holding more, still in price order. Those rows are re-priced like the rest of the page, so they can cost quote time. Applies to the first page (offset=0) of a flat price_asc search; deeper pages stay in plain order, so an offer can repeat or be skipped across that boundary. meta.perProvider says what was applied.
reusebooleantrue Answer from a search kept in the last 30 minutes that asked the providers exactly this: the same origins, destinations, dates, trip lengths, party, providers, weeks and every filter one of those providers takes upstream (a filter none of them takes upstream may be narrower). The answer says so in meta.reused (searchId, ageSec, searchedAt) and trace.reused, and its provider traces are that search's. A search where a provider failed, timed out, was cut by the deadline or hit the offer ceiling is never reused. false asks the providers again. See kept searches.
weekscheapest | on-sale | allcheapest Which departure weeks the price matrix may leave out of each provider's plan, from what earlier searches learned. cheapest: weeks a whole-week search of this exact trip length and party found nothing on sale in the last 7 days, and, in a window over 8 weeks, weeks ranked outside that provider's 8 cheapest known ones. on-sale: only the first. all: every week. A week the matrix does not know is always searched. Applies with departFrom, departTo, nightsMin, nightsMax and a destination; trace.matrix says whether it steered and trace.providers[].matrix names the weeks each provider was not asked about.
limitinteger 1–1000100 Page size — offers, or groups when groupBy is set. Clamped into range rather than rejected. meta.total always counts offers; groups.total counts groups. The stream's canonical page event applies the same limit.
offsetinteger ≥ 00 Rows to skip, clamped — offers, or groups when groupBy is set. Paging happens after filtering, sorting and de-duplication, including in the stream's canonical page event.
budgetMsinteger 500–6000015000 Hard wall-clock budget for the provider fan-out, clamped into range. When it expires, in-flight work is cancelled and whatever arrived is returned — with the shortfall visible as executedTasks < plannedTasks in the trace. It does not bound pricing or flight detail; totalBudgetMs does.
totalBudgetMsinteger 500–12000030000 Wall-clock budget for the whole request — fan-out, pricing and flight detail together — and the number that actually bounds the response. Each stage after the fan-out takes the lesser of its own ceiling and what is left of this, and says in its notes when the remainder ran out. Stating it below budgetMs cuts the fan-out to fit and the echoed query.budgetMs shows the value that really applied; leaving it unstated never shrinks a budgetMs you did state. meta.budgetMs reports what governed the response and meta.deadlineExceeded says whether it was overrun.
frontfront id— Search for one front: only its providers are asked, and its id rides inside every booking link so the click is counted for it. An unknown front is 400 unknown_front with suggestions, a paused one 403 front_paused, a fronts store that cannot be read 503 fronts_unavailable: never a search of every provider. meta.scope.front echoes it, and a client that sent a front must not show results whose scope names another or none.
providerscomma-joined provider ids— Narrow the search to these providers: within the front when front is given, within every running provider otherwise. It never widens: a provider outside the front is not asked and is named in meta.scope.excluded.notInFront. An unknown id, an empty list, or a list that leaves nobody to ask is a 400.
market"se" | "no"se Which storefront to search. Decides the origin list, the language of names, and which providers are eligible. All four registered providers declare markets: ["se"], so market=no is a valid request that no provider answers — an empty result with an empty trace, not an error.
currencySEK NOK EUR DKKSEK Currency of every price in the response.

Example

curl
curl -sS 'http://localhost:3000/v1/search\
?origin=STO\
&destination=es.mallorca&destination=es.gran-canaria\
&departFrom=2026-11-01&departTo=2026-12-01\
&nightsMin=7&nightsMax=7\
&adults=2&board=AI&starsMin=4\
&sort=price_asc&limit=2'

Response

A real capture of the call above (limit=2), with hotel images, facilities and descriptions shortened to fit the page. Note the relationship between the counts: meta.total is 7 matched offers, meta.returned is the 2 on this page, and meta.hotels is 17 — distinct hotels across the whole collected set, while the hotels dictionary carries only the hotels this page references.

meta.priceQuoted is 2 of 2, so both offers on this page were re-priced against TripX's booking endpoint and carry priceSource: "quote"; priceQuoteNotes is empty because nothing was left behind. meta.orderedBy says which prices decided page membership: on a price sort we quote a window off the front of the result set and re-rank it before cutting the page, so "quote" means these really are the cheapest offers we found. It reads "catalogue" on a non-price sort, with quote=false, or past that window — and the priceMax filter and facets.priceMin/priceMax are computed over catalogue prices either way, since those are the only prices that exist for rows nobody asked us to quote.

meta.flightEnriched is 0 while flightEnrichmentComplete is true, and that pair is not a contradiction: this is a TripX page, and TripX returns flight legs in the search response itself, so there was nothing left to look up. The counter reports work done, the flag reports whether anything is still unknown. A Sunweb page is where they come apart — every row arrives with direct: null and has to earn its itinerary.

Solresor is the case that shows why the flag tracks legs rather than directness. Every Solresor row states whether the package is non-stop and none of them carries a single flight time, carrier or flight number, so a page of them has fully known direct and no itinerary at all. Enrichment fills that in from a read-only route-and-date lookup, and each result is checked against the flight product the row was actually sold on — measured, that endpoint answers with a different flight for the same route about 4% of the time, and an offer that cannot be proven keeps its null legs with the reason in meta.flightEnrichmentNotes.

Ving rows carry no flight at all, not even directness. The legs come from the room picker's flight list, the same call its quote() makes, selected by the charter flight series in the offer token and checked against the token's route, departure date and return date before anything is attached. One lookup per hotel, date and trip length, memoised per pass.

TUI rows carry both journeys segment by segment, so they need no enrichment. A connection is a second segment, and directness comes from that and from TUI's own DIRECT / INDIRECT flag. The one gap is the flight number: TUI has no field for it, only a package id it is buried in ambiguously, so flightNumbers stays empty rather than holding a guess.

200 application/json
{
  "query": {
    "market": "se",
    "currency": "SEK",
    "origins": [
      "STO"
    ],
    "destinations": [
      "es.mallorca",
      "es.gran-canaria"
    ],
    "rooms": [
      {
        "adults": 2,
        "childrenAges": []
      }
    ],
    "budgetMs": 25000,
    "departFrom": 20758,
    "departTo": 20788,
    "nightsMin": 7,
    "nightsMax": 7,
    "boards": [
      "AI"
    ],
    "starsMin": 4
  },
  "meta": {
    "ms": 6104,
    "searchMs": 1259,
    "quoteMs": 4703,
    "flightMs": 0,
    "totalMs": 6104,
    "budgetMs": 30000,
    "deadlineExceeded": false,
    "total": 7,
    "returned": 2,
    "offset": 0,
    "hotels": 17,
    "truncated": false,
    "duplicates": 0,
    "sort": "price_asc",
    "priceQuoted": 2,
    "priceQuoteNotes": [],
    "flightEnriched": 0,
    "flightEnrichmentNotes": [],
    "flightEnrichmentComplete": true,
    "orderedBy": "catalogue",
    "perProvider": 0
  },
  "offers": [
    {
      "id": "tripx:NYO:LPA:4191:27763:AI:20759:7",
      "provider": "tripx",
      "hotelId": "tripx:4191",
      "destinationId": "es.gran-canaria",
      "origin": "NYO",
      "destination": "LPA",
      "pricePerPerson": 10859,
      "priceTotal": 21718,
      "currency": "SEK",
      "priceSource": "quote",
      "departDate": 20759,
      "returnDate": 20766,
      "nights": 7,
      "board": "AI",
      "boardLabel": "All Inclusive",
      "roomId": "27763",
      "roomName": "Dubbelrum Twin (två sängar) med trädgårdsutsikt",
      "outbound": {
        "from": "NYO",
        "to": "LPA",
        "departsAt": "2026-11-02T15:50:00",
        "arrivesAt": "2026-11-02T20:40:00",
        "stops": 0,
        "carriers": [
          "D8"
        ],
        "flightNumbers": [
          "D85723"
        ]
      },
      "inbound": {
        "from": "LPA",
        "to": "NYO",
        "departsAt": "2026-11-09T08:10:00",
        "arrivesAt": "2026-11-09T15:00:00",
        "stops": 0,
        "carriers": [
          "D8"
        ],
        "flightNumbers": [
          "D85722"
        ]
      },
      "direct": true,
      "overnight": false,
      "deeplink": "https://www.tripx.se/c/resa-till/spanien/gran-canaria/hotel-livvo-costa-taurito?origin=STO&from=2026-11-02&to=2026-11-09&rooms=2&packageRoomId=27763&packageBoardCode=AI&sourceTab=d0c9e11a760f"
    },
    {
      "id": "tripx:NYO:LPA:12648:27765:AI:20759:7",
      "provider": "tripx",
      "hotelId": "tripx:12648",
      "destinationId": "es.gran-canaria",
      "origin": "NYO",
      "destination": "LPA",
      "pricePerPerson": 12089,
      "priceTotal": 24178,
      "currency": "SEK",
      "priceSource": "quote",
      "departDate": 20759,
      "returnDate": 20766,
      "nights": 7,
      "board": "AI",
      "boardLabel": "All Inclusive",
      "roomId": "27765",
      "roomName": "Dubbelrum Twin (två sängar) med delvis havsutsikt",
      "outbound": {
        "from": "NYO",
        "to": "LPA",
        "departsAt": "2026-11-02T15:50:00",
        "arrivesAt": "2026-11-02T20:40:00",
        "stops": 0,
        "carriers": [
          "D8"
        ],
        "flightNumbers": [
          "D85723"
        ]
      },
      "inbound": {
        "from": "LPA",
        "to": "NYO",
        "departsAt": "2026-11-09T08:10:00",
        "arrivesAt": "2026-11-09T15:00:00",
        "stops": 0,
        "carriers": [
          "D8"
        ],
        "flightNumbers": [
          "D85722"
        ]
      },
      "direct": true,
      "overnight": false,
      "deeplink": "https://www.tripx.se/c/resa-till/spanien/gran-canaria/hotel-livvo-valle-taurito-aquapark?origin=STO&from=2026-11-02&to=2026-11-09&rooms=2&packageRoomId=27765&packageBoardCode=AI&sourceTab=a581604c17dd"
    }
  ],
  "hotels": {
    "tripx:4191": {
      "id": "tripx:4191",
      "provider": "tripx",
      "name": "Hotel LIVVO Costa Taurito",
      "stars": 4,
      "score": 76,
      "reviewCount": 206,
      "lat": 27.81655,
      "lng": -15.75113,
      "city": "Taurito",
      "area": "Gran Canaria",
      "country": "Spain",
      "images": [
        "https://tripx-images.imgix.net/TSPRO/hotelimages/11279538.jpg",
        "https://tripx-images.imgix.net/TSPRO/hotelimages/2710289_33_z.jpg"
      ],
      "description": "Hotel LIVVO Costa Taurito är en välkomnande tillflyktsort som ligger bara några steg frå…",
      "facilities": [
        "Gym/fitness center",
        "Inomhuspool",
        "Hiss",
        "Utomhuspool"
      ],
      "distanceToBeach": null,
      "distanceToCentre": null
    },
    "tripx:12648": {
      "id": "tripx:12648",
      "provider": "tripx",
      "name": "Hotel LIVVO Valle Taurito ",
      "stars": 4,
      "score": 80,
      "reviewCount": 266,
      "lat": 27.816978,
      "lng": -15.752879,
      "city": "Taurito",
      "area": "Gran Canaria",
      "country": "Spain",
      "images": [
        "https://tripx-images.imgix.net/TSPRO/hotelimages/92310050cc3c42f58df120d7350b915c.jpg",
        "https://tripx-images.imgix.net/TSPRO/hotelimages/f237c197b3774ca8b2be91f133249383.jpg"
      ],
      "description": "Hotel LIVVO Valle Taurito är en välkomnande tillflyktsort som ligger i den lugna Taurito…",
      "facilities": [
        "Gym/fitness center",
        "Inomhuspool",
        "Hiss",
        "Utomhuspool"
      ],
      "distanceToBeach": null,
      "distanceToCentre": null
    }
  },
  "facets": {
    "boards": [
      {
        "value": "AI",
        "count": 7
      }
    ],
    "stars": [
      {
        "value": 4,
        "count": 6
      },
      {
        "value": 5,
        "count": 1
      }
    ],
    "nights": [
      {
        "value": 7,
        "count": 7
      }
    ],
    "destinations": [
      {
        "value": "es.gran-canaria",
        "count": 7
      }
    ],
    "origins": [
      {
        "value": "NYO",
        "count": 5
      },
      {
        "value": "ARN",
        "count": 2
      }
    ],
    "priceMin": 21718,
    "priceMax": 49118,
    "departFrom": 20759,
    "departTo": 20760
  },
  "trace": {
    "providers": [
      {
        "provider": "tripx",
        "planned": 2,
        "executed": 2,
        "offers": 17,
        "ms": 2369,
        "tasks": [
          {
            "taskId": "tripx:tripx-se:STO->PMI",
            "provider": "tripx",
            "label": "STO->PMI",
            "ms": 1111,
            "offers": 4,
            "status": "ok"
          },
          {
            "taskId": "tripx:tripx-se:STO->LPA",
            "provider": "tripx",
            "label": "STO->LPA",
            "ms": 1258,
            "offers": 13,
            "status": "ok"
          }
        ]
      }
    ],
    "plannedTasks": 2,
    "executedTasks": 2
  }
}
Read meta.total against facets, not against offers.length. Facets are computed over every matched offer, not over the returned page. A page of 100 with total: 4821 means the facet counts describe 4821 offers.

POST/v1/search

Identical semantics to GET /v1/search — same parameters, same SearchResponse. It exists because a wide search outgrows a query string: forty destinations and six child ages is a long, ugly, and in some proxies a truncated URL.

The body is the same parameter set as JSON, with Content-Type: application/json. Parameters that are repeatable in the query string may be arrays here, and both the singular and plural spellings are accepted (origin or origins). Dates may be either "2026-11-01" or a bare EpochDay integer — a POST body speaks the contract's own units.

A query string on a POST is merged with the body, and the body wins on conflict — so POST /v1/search?limit=20 with a body that does not mention limit pages at 20. Body limit is 1 MB.

Request body

application/json
{
  "origin": ["STO"],
  "destination": ["es.mallorca", "es.gran-canaria"],
  "departFrom": "2026-11-01",
  "departTo": "2026-12-01",
  "nightsMin": 7,
  "nightsMax": 7,
  "adults": 2,
  "childAges": [],
  "board": ["AI"],
  "starsMin": 4,
  "sort": "price_asc",
  "limit": 20,
  "market": "se",
  "currency": "SEK"
}
curl
curl -sS -X POST http://localhost:3000/v1/search \
  -H 'content-type: application/json' \
  -d '{"origin":["STO"],"destination":["es.mallorca"],"adults":2,"nightsMin":7,"nightsMax":7,"limit":20}'

Response

Byte-for-byte the same shape as the GET form — see the example above. query echoes the normalized SearchQuery, so a POST is the fastest way to find out how the service interpreted something ambiguous.

GET/v1/search/stream

The same search, exposed as NDJSON: one JSON object per line, flushed as it happens, terminated by a single done event. Takes exactly the parameters of GET /v1/search.

Providers are async iterables internally — they yield a batch per upstream call and never buffer a whole provider before emitting. This endpoint is that internal stream, merged across providers and handed to the wire. A search that takes two seconds end to end typically paints its first offers in a few hundred milliseconds.

Response headers

HeaderValue
content-typeapplication/x-ndjson; charset=utf-8
cache-controlno-cache, no-transform
x-accel-bufferingno — tells nginx and friends not to sit on the chunks and hand you the whole stream at once
connectionkeep-alive

The response is chunked and the headers are flushed before the first event. A 400 is still an ordinary JSON error response: the query is parsed before the reply is hijacked, so a bad parameter never reaches you as a broken stream.

Events

Every line has a type. Ignore types you do not recognize — the set can grow.

typePayloadWhen
queryquery: the normalized SearchQuery
plan: [{ provider, tasks }]
Once, first. Planning is pure and synchronous — no I/O — so this line arrives immediately, and plan is the fan-out the orchestrator committed to before spending anything. Sum tasks for a progress denominator.
batchprovider, taskId, label, offers[], hotels[], msOnce per upstream call that produced rows. hotels is an array here, not the dictionary the buffered response uses — merge it into your own map by id.
tasktrace: a TaskTraceOnce per planned task as it settles — including the ones that came back empty, failed, or were skipped when the budget ran out.
pricedoffers[], quoted, notes[]Compatibility correction for clients that replace provisional batch rows by offer.id. New clients should adopt the following page event wholesale.
pageThe same query, meta, offers, hotels, facets and trace shape as GET /v1/searchOnce, immediately before done. This is the authoritative page after collector deduplication, local filters, quote-window re-ranking, offset and limit. It is built from the stream's already-collected outcome; no second provider search is made.
donefacets
trace: { providers[], ms, plannedTasks, executedTasks }
stats: { offers, hotels, duplicates, truncated }
priceQuoted, priceQuoteNotes
scope, and calls: the requests the whole stream sent (meta.calls)
Once, last. It is the unambiguous statement that no more stream data follows. The preceding page is the canonical result; these fields remain for progress and compatibility consumers.
reusedsearchId, ageSec, searchedAtOnly on a search answered from a kept one, right after query: no batches or tasks follow, then page and done.
errorerror: { kind, message }Only when the aggregation throws mid-flight. It is written instead of truncating the stream, because a truncated stream is indistinguishable from a finished one. Terminal — nothing follows it.
Batches are raw provider output — the post-filters have not run yet. board, starsMin, scoreMin, priceMax, directOnly and the date and night windows are applied once, at the end, over the collected table. Render batches only as a provisional first paint, then replace them with page.offers; that canonical page is filtered identically to GET /v1/search.
batch prices are browse prices; the priced event replaces them. Batches are flushed the instant an upstream call lands, which is before anything is known about which offers matter — so every offer in a batch arrives priceSource: "catalogue". After the fan-out completes, priced provides a compatibility correction and page provides the canonical filtered, deduplicated and re-ranked result. A complete Sembo batch can already contain priceSource:"quote" because its exact room-pinned price needs no second call. Sunweb rows stay "catalogue" for the whole run and no later event promotes them — read pricing.note on /v1/providers for why that is a design and not a failed quote. See Prices.
The terminal page has the same membership as the buffered endpoint. Progressive batches preserve first paint; the page event replaces that provisional model with the exact GET-equivalent page, including quote-window price ranking and paging.
Offers can repeat across batches in a way the buffered endpoint de-duplicates for you (meta.duplicates counts them). Key your own accumulator on offer.id — it is stable across runs and already includes the origin, so two origins reaching the same hotel stay two offers.

Example

curl
curl -sN 'http://localhost:3000/v1/search/stream\
?origin=STO\
&destination=es.mallorca&destination=es.gran-canaria\
&adults=2&board=AI&budgetMs=25000'

Response (one object per line)

A real capture of the call above, trimmed for the page: the first batch carried 32 offers and the second 80, and only one of each event type is shown. Nothing else is edited — field names, ordering and the taskId format are exactly what comes off the wire.

200 application/x-ndjson
{"type":"query","query":{"market":"se","currency":"SEK","origins":["STO"],"destinations":["es.mallorca","es.gran-canaria"],"rooms":[{"adults":2,"childrenAges":[]}],"budgetMs":25000,"boards":["AI"]},"plan":[{"provider":"tripx","tasks":2}]}
{"type":"batch","provider":"tripx","taskId":"tripx:tripx-se:STO->LPA","label":"STO->LPA","offers":[{"id":"tripx:ARN:LPA:8760:27702:AI:20717:3","provider":"tripx","hotelId":"tripx:8760","destinationId":"es.gran-canaria","origin":"ARN","destination":"LPA","pricePerPerson":5729,"priceTotal":11458,"currency":"SEK","priceSource":"catalogue","departDate":20717,"returnDate":20720,"nights":3,"board":"AI","boardLabel":"All Inclusive","roomId":"27702","roomName":"Tvårumslägenhet","outbound":{"from":"ARN","to":"LPA","departsAt":"2026-09-21T09:50:00","arrivesAt":"2026-09-21T19:25:00","stops":1,"carriers":["VY"],"flightNumbers":[]},"inbound":{"from":"LPA","to":"ARN","departsAt":"2026-09-24T08:10:00","arrivesAt":"2026-09-24T14:55:00","stops":0,"carriers":["DY"],"flightNumbers":["D85700"]},"direct":false,"overnight":false,"deeplink":"https://www.tripx.se/c/resa-till/spanien/gran-canaria/marina-elite?origin=STO&from=2026-09-21&to=2026-09-24&rooms=2&packageRoomId=27702&packageBoardCode=AI&sourceTab=939c26ca5cd4"}],"hotels":[{"id":"tripx:8760","provider":"tripx","name":"Marina Elite ","stars":3,"score":74,"reviewCount":620,"lat":27.7756,"lng":-15.70128,"city":"Balito","area":"Gran Canaria","country":"Spain","images":["https://tripx-images.imgix.net/TSPRO/hotelimages/b5cfdf262c4f4f19b86585696afd7dd6.jpg","https://tripx-images.imgix.net/TSPRO/hotelimages/591591fffdf54d0aa4f36732782ad9b1.jpg"],"description":"Marina Elite är ett välkomnande strandhotell som ligger ovanför det glittrande Atlanten i Patalavaca, Gran Canaria.","facilities":["Gym/fitness center","Hiss","Utomhuspool","Restaurang"],"distanceToBeach":null,"distanceToCentre":null}],"ms":1370}
{"type":"task","trace":{"taskId":"tripx:tripx-se:STO->LPA","provider":"tripx","label":"STO->LPA","ms":1374,"offers":32,"status":"ok"}}
{"type":"priced","offers":[{"id":"tripx:ARN:LPA:8760:27702:AI:20717:3","pricePerPerson":5680,"priceTotal":11360,"priceSource":"quote"}],"quoted":1,"notes":[]}
{"type":"page","query":{"market":"se","currency":"SEK","origins":["STO"],"destinations":["es.mallorca","es.gran-canaria"],"rooms":[{"adults":2,"childrenAges":[]}],"budgetMs":25000,"boards":["AI"]},"meta":{"ms":7431,"searchMs":2088,"quoteMs":5330,"flightMs":0,"totalMs":7431,"budgetMs":30000,"deadlineExceeded":false,"total":112,"returned":100,"offset":0,"hotels":112,"truncated":false,"duplicates":0,"sort":"price_asc","priceQuoted":100,"priceQuoteNotes":[],"orderedBy":"quote","perProvider":0},"offers":["… canonical page …"],"hotels":{"…":"…"},"facets":{"…":"…"},"trace":{"providers":["…"],"plannedTasks":2,"executedTasks":2}}
{"type":"done","facets":{"boards":[{"value":"AI","count":112}],"stars":[{"value":3,"count":41},{"value":3.5,"count":2},{"value":4,"count":67}],"nights":[{"value":3,"count":65},{"value":4,"count":20},{"value":5,"count":18}],"destinations":[{"value":"es.mallorca","count":80},{"value":"es.gran-canaria","count":32}],"origins":[{"value":"ARN","count":110},{"value":"NYO","count":2}],"priceMin":6538,"priceMax":80278,"departFrom":20694,"departTo":20968},"trace":{"providers":[{"provider":"tripx","planned":2,"executed":2,"offers":112,"ms":3462,"wallMs":2090,"tasks":[{"taskId":"tripx:tripx-se:STO->PMI","provider":"tripx","label":"STO->PMI","ms":2088,"waitMs":1,"offers":80,"status":"ok"},{"taskId":"tripx:tripx-se:STO->LPA","provider":"tripx","label":"STO->LPA","ms":1374,"waitMs":2,"offers":32,"status":"ok"}]}],"ms":2088,"plannedTasks":2,"executedTasks":2},"stats":{"offers":112,"hotels":112,"duplicates":0,"truncated":false},"priceQuoted":100,"priceQuoteNotes":[]}

Consuming it

javascript
const res = await fetch(base + '/v1/search/stream?' + qs, {
  headers: { authorization: 'Bearer ' + apiKey },   // when the server has keys
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = '';

for (;;) {
  const { value, done } = await reader.read();
  if (done) break;
  buf += dec.decode(value, { stream: true });
  let nl;
  while ((nl = buf.indexOf('\n')) >= 0) {
    const line = buf.slice(0, nl);
    buf = buf.slice(nl + 1);
    if (line.trim()) handle(JSON.parse(line));
  }
}

The trailing partial line matters: a chunk boundary can land in the middle of a JSON object. Buffer until you see \n, and flush whatever is left after the reader reports done.

GET/v1/search/plan

A dry run: what a search with these parameters would send to the travel companies, without asking any of them. Takes exactly the parameters of GET /v1/search, front included, and plans with the same code the search runs, after the same price-matrix steering, so a week the search would skip is skipped here too and named under matrix. It answers in milliseconds and does not count against an app's daily searches.

Use it before a search a client is not sure it can afford: a chat that is about to search eight places over half a year can see the 296 calls that costs, and ask a narrower question first.

It is an estimate, and it says which way it errs. calls.search counts planned tasks, so it is a floor: Sunweb, Ving and TUI page their answers and can send more than one request a task, and a retry sends another. calls.quoteMax is a ceiling: every offer pricing could reach (quoteReach), all priced by the dearest company to price. A real search reports what it sent in meta.calls; compare the two rather than treating either as the other.

Response

FieldMeaning
query, scopeThe normalized query and the providers it would ask, exactly as a search would echo them in query and meta.scope.
calls{ search, quoteMax, estimate: true }, as above. Flight lookups are not estimated: they depend on which offers reach the page.
quoteReachHow many offers of the result set re-pricing could touch: the page, or the 120-offer window a price sort re-ranks, plus the perProvider reserve. 0 with quote=false.
providers[]Per provider: tasks, paginates, labels (what each task asks, up to 60), dropped (what the plan left out and why, the task cap included), unsupported (origins or destinations it cannot search at all), matrix (weeks steering skipped), pricing and quoteMax.
matrixWhether steering was read, and the weeks it would let the search skip; the same shape as a search's trace.providers[].matrix.
notesPlain words on the estimate's limits for this query.
curl
curl -sS 'http://localhost:3000/v1/search/plan?origin=ARN&destination=es.gran-canaria&adults=2&departFrom=2026-11-23&departTo=2026-11-29&nights=7'
200 application/json (trimmed)
{
  "calls": { "search": 18, "quoteMax": 240, "estimate": true },
  "quoteReach": 120,
  "providers": [
    { "provider": "tripx", "tasks": 1, "paginates": false, "labels": ["STO->LPA"],
      "dropped": [], "pricing": { "quotes": true, "quoteCost": "per-trip", "quoteCalls": 1 }, "quoteMax": 120 },
    { "provider": "sembo", "tasks": 7, "paginates": false,
      "labels": ["ARN->LPA@2026-11-23/7n", "…"], "dropped": [], "pricing": { "quotes": false }, "quoteMax": 0 },
    { "provider": "nazar", "tasks": 0, "unsupported": { "unsupportedOrigins": [], "unsupportedDestinations": ["es.gran-canaria"] },
      "dropped": ["(none of the requested destination(s) are ones Nazar flies to, …)"], "quoteMax": 0 },
    { "provider": "ving", "tasks": 1, "paginates": true, "labels": ["ARN@2026-11-26±3/1"],
      "pricing": { "quotes": true, "quoteCost": "per-offer", "quoteCalls": 2 }, "quoteMax": 240 }
  ],
  "notes": [
    "sunweb, ving, tui page their answers, so they can send more than one request a task",
    "pricing reaches at most 120 offer(s) of the result set; tripx takes 1 request(s) per trip, ving takes 2 request(s) per offer. …"
  ]
}

/v1/search/{searchId}

Every search is kept for 30 minutes under its meta.searchId: every offer it matched, not only the page, with its trace and scope. Another page, another sort, a filter inside its own question or a grouping is read from it, and asks no travel company (meta.calls.total is 0). The same question from anybody else within the half hour is answered from it too (reuse above). It is the third thing this API keeps beside the price matrix and the fronts registry, in the same store, and it has the same rule: losing it costs a search, never a wrong answer, and nothing is ever served from it older than 30 minutes.

CallDoesAnswers
GET /v1/search/{searchId}A page of the kept search: offset, limit, sort, groupBy, groupMembers, perProvider, the filters (origin, destination, departFrom, departTo, nightsMin, nightsMax, board, starsMin, scoreMin, priceMax, directOnly, providers), front. Filters may only narrow the search's question. quote and flights default to false; with them, only this page's offers are priced or proven, never one priced already, and what is learned is kept with the search.A SearchResponse with meta.reused naming the search and its age. 400 outside_search (a filter outside the search's question, or a provider it did not ask), 400 fixed_by_search (adults, childAges, rooms, market, currency, weeks, budgetMs: search again), 400 needs_search (directOnly on a search that did not collect flight detail), 403 search_out_of_scope, 404 search_expired, 503 search_store_unavailable.
POST /v1/search/{searchId}/detailsThe bookable price and the flight times of up to 12 offers: { "offerIds": [...], "quote": true, "flights": true, "front": "sembo" }. Both run at once, from what the search kept about each offer (never anything the caller sends). Use it for the trips a traveller opens.{ searchId, offers, hotels, meta }, offers in the order asked. An offer that could not be priced keeps its browse price and meta.priceQuoteNotes says why: partial success is a 200. meta.alreadyQuoted counts the ones priced before, which were not asked again. 400 unknown_offer with field: "offerIds.N".
A handle is scoped like the search. It opens only for a front whose providers cover every provider the search asked, and its booking links carry the front that opens it. Past 30 minutes it is a 404 search_expired, never an empty page: search again from the question.

How many to quote. On 2026-10-29 three family searches (2 adults, children 0, 4 and 6, from Malmö and Copenhagen) were run with quote=false and quote=true. In all three, the cheapest 10 after pricing only the first 10 by browse price were the cheapest 10 after pricing 120, in the same order. Below the 10th the order moves more: one Ving hotel priced 40% too high by its browse price climbed from 108th to 15th. So a client that shows a list prices it with quote=N and prices what is opened with details.

curl
id=$(curl -sS 'http://localhost:3000/v1/search?origin=MMX&destination=es.gran-canaria&adults=2&childAges=0,4,6&quote=30' | jq -r .meta.searchId)
curl -sS "http://localhost:3000/v1/search/$id?offset=20&limit=20&board=AI" | jq '.meta | {total, reused, calls}'
curl -sS -X POST "http://localhost:3000/v1/search/$id/details" -H 'content-type: application/json' \
  -d '{"offerIds":["ving:MMX:es.gran-canaria:195108:A34:RO:20798:7"]}' | jq '.offers[0] | {priceSource, priceTotal}'
meta.handle, meta.reused
{
  "searchId": "s_3kQ9xV0bT1mZ8pLwYc2aDe",
  "handle": { "searchId": "s_3kQ9xV0bT1mZ8pLwYc2aDe", "expiresAt": "2026-10-29T22:51:07.000Z",
              "reusable": true, "note": "", "store": "redis", "durable": true, "bytes": 412306 },
  "reused": { "searchId": "s_3kQ9xV0bT1mZ8pLwYc2aDe", "ageSec": 74, "searchedAt": "2026-10-29T22:21:07.000Z" }
}

GET/v1/matrix

The price matrix: what earlier searches learned about a route, week by week. Every search through this API records what it saw, and POST /v1/matrix/fill records weeks somebody chose. This call asks no provider. With no parameters it lists the routes that have data; with origin and destination it returns one route as a grid of companies by departure weeks. The console's Price matrix page draws it.

One cell is one company, real departure airport, destination, departure week (Monday), trip-length group (short 1-5 nights, week 6-10, fortnight 11-17, long 18+) and party. The party is part of the key because ages change the price: 2, 2+0,4,6.

The matrix ranks weeks; it never prices them. A cell's index compares a week with the other weeks of the same row: the row's cheapest week is 100, and 120 costs a fifth more. Rows of different companies are not on one price basis (TripX's undated price is for two adults whatever the party, Ving prices every child as a five-year-old), so compare shapes, never numbers across rows, and get every price you show from a search.
A week with no cell is unknown, not empty. status: "none" is written only when the company was asked about that whole Monday-to-Sunday week at one trip length (nightsAsked), ran every call it planned, failed none and dropped none, and had nothing on sale for that party. Anything less leaves the cell unwritten. A company that refused this server (TUI's bot wall on Vercel) is listed under refused, apart from the cells.

Parameters

NameTypeDefaultDescription
originIATA code— Departure airport. A group such as STO reads every airport in it, one row each. Omit together with destination to list routes.
destinationdestination id— One destination; names and aliases resolve as on search.
adultsinteger 1–9— Show only this party. Without any party parameter, every party recorded is shown.
childAgescomma-joined integers 0–17— The party's children, every age. rooms works as on search.
nightsshort | week | fortnight | long— Show only one trip-length group.

Response

Without a route: { enabled, store, asOf, routes: [{ origin, destination, cells, updatedAt }] }. With one: weeks (the columns), rows (one per company, airport, party and trip-length group, each with its cells), ranking (per airport, party and group: the mean of the companies' indices per week, cheapest first, from rows with at least two weeks of trips), parties (every party recorded for the route, filtered or not), refused and notes. store is redis, vercel (Vercel's Runtime Cache) or memory (this process only).

Cell fieldMeaning
statustrips, or none as above.
index, typicalIndexThe cheapest price per person, and the median of the week's 10 cheapest hotels, against the row's cheapest week (100). null on none.
hotels, offers, departDaysWhat the week held: distinct hotels, offers, and the weekdays with a departure.
wholeWeek, nightsAskedWhether one search asked about the whole week at one trip length. When false, only part of the week was seen and the true cheapest may be lower.
freshWithin its window: 2 days for the next 6 weeks, 7 days beyond, 7 days for none. Cells are kept 30 days.
quoted, checkedAt, sourceWhether the cheapest was a quote, when the cell was written, and by a search or a fill.
curl
curl -sS 'http://localhost:3000/v1/matrix?origin=ARN&destination=es.gran-canaria&adults=2&nights=week'
200 application/json (trimmed)
{
  "enabled": true,
  "store": "vercel",
  "origins": ["ARN"],
  "destination": "es.gran-canaria",
  "party": "2",
  "nights": "week",
  "weeks": ["2026-11-16", "2026-11-23"],
  "rows": [
    {
      "provider": "tripx", "origin": "ARN", "party": "2", "nights": "week",
      "cells": [
        { "week": "2026-11-16", "status": "trips", "index": 182, "typicalIndex": 131,
          "hotels": 14, "offers": 14, "departDays": ["Tue", "Wed"], "wholeWeek": true,
          "nightsAsked": 7, "quoted": false, "fresh": true,
          "checkedAt": "2026-10-29T17:17:12.000Z", "source": "fill" },
        { "week": "2026-11-23", "status": "trips", "index": 100, "...": "..." }
      ]
    }
  ],
  "ranking": [],
  "refused": [{ "provider": "tui", "origin": "ARN", "kind": "bot_wall", "at": "2026-10-29T17:17:12.000Z" }]
}

POST/v1/matrix/fill

Runs one ordinary search of one Monday-to-Sunday week and records it. It takes the search's origin, destination and party, plus week (any date in the week) and nights (one trip length, 7 when omitted). It asks for search prices only, with no quote or flight lookup, so a fill costs what a search costs and nothing more. Once the server has GOOGLE_CLIENT_ID it needs a console session, which the matrix page holds after its Google sign-in.

One week per call, on purpose: no request runs longer than one search and nothing runs in the background. To fill a season, call it once per week; the console page does exactly that and can be stopped between weeks. A week already under way is searched from tomorrow, and records trips but not none.

Request body

application/json
{
  "origin": "ARN",
  "destination": "es.gran-canaria",
  "week": "2026-11-23",
  "nights": 7,
  "adults": 2,
  "childAges": [0, 4, 6]
}

Response

week, the departFrom/departTo actually searched, party, offers, ms, recorded: { routes, trips, none }, notes (why anything was not recorded) and providers[]: each company's planned and run calls, its offers, and its error or unsupported inputs. A 400 names the input (past_week, far_week, invalid_nights, …); a 409 says the matrix is switched off (PRICE_MATRIX=off).

curl
curl -sS -X POST http://localhost:3000/v1/matrix/fill \
  -H 'content-type: application/json' \
  -d '{"origin":"ARN","destination":"es.gran-canaria","week":"2026-11-23","nights":7,"adults":2}'

GET/v1/fronts

Every front of the chat product, the built-in house first. A front is a branded site that searches only the providers chosen for it: a travel agency's own front searches that agency, and our own fronts compare several or all. The API owns the list so that a front's scope is enforced where the search runs; a client names the front with front=<id> and never decides its providers itself. The console (the Travelfront Portal) writes fronts here; Onyx, the chat, reads them. Listing, writing and clicks need a console sign-in once the server has GOOGLE_CLIENT_ID.

FieldTypeMeaning
idstringPermanent slug, 2–40 of a-z 0-9 -. Booking links and the click ledger carry it. resolve is reserved.
namestringThe brand the traveller sees.
kind"partner" | "own"One agency's front, or our own comparison. Wording only, never what is searched.
providers"all" | id[]What it searches. "all" takes in providers added later.
hostsstring[]Bare lowercase hostnames that open it. One host opens one front.
status"active" | "paused"The front's own switch. A paused front's searches answer 403 front_paused; its existing booking links keep working.
clientstring | nullThe client whose own front this is, or null for one of ours, which belong to the built-in client travelfront (a write may say "travelfront"; it is stored as null). A client's brand and AI key then apply, and the front opens only while the client has access to Onyx.
product"onyx"Read-only: the product the front belongs to. Every front is Onyx's.
branding{ accent, logoUrl, tagline, language }#rrggbb, an https image, one line, sv or en. The first three may be null: use the client's default.
attribution{ provider: { param: value } }Added to that provider's booking URL at /go/…/continue, never overwriting a parameter the deeplink already has, so it cannot change which offer opens. Empty until an agency names its tracking scheme and a probe has shown the link still opens the same offer at the same price.
builtInbooleanTrue for house alone (Onyx, our own brand, every provider): it always exists and cannot be deleted.
createdAt, updatedAtISO timeSet by the API.
effective{ status, closedBy, branding, brand, attribution, ai }Read-only: what the front runs with once its client's settings are filled in. A chat front renders and gates on this, not on status and branding, which are the front's own. closedBy is front_paused, no_product_access (its client has no Onyx) or client_missing; branding.accent and logoUrl are the front's own, else its client's, else its client's brand profile's action colour (when one was read or set) and logo; brand is that profile whole on the client's own front while the client has Onyx, null on ours and on house; attribution is what each provider's booking links carry (the provider's client's parameters, then the front's own); ai is "client" when its client brought an AI key, else "ours". A PUT may send it back; it is ignored.
durable: false means a front can be lost. Fronts live in Upstash Redis when the deployment has it (store: "redis"), otherwise in Vercel's Runtime Cache, which may drop an entry when full, or in this process's memory. A lost front fails loudly, as an unknown front, and is never replaced by a search of every provider; but connect Redis before a partner goes live. Each instance re-reads the list every 30 seconds, so an edit reaches every instance within that.
curl
curl -sS http://localhost:3000/v1/fronts    # with sign-in on: -H "Authorization: Bearer $CONSOLE_SESSION"
200 application/json
{
  "fronts": [
    { "id": "house", "name": "Onyx", "kind": "own", "providers": "all", "hosts": [],
      "status": "active", "branding": { "accent": null, "logoUrl": null, "tagline": null, "language": "en" },
      "attribution": {}, "product": "onyx", "builtIn": true, "createdAt": "2026-10-29T00:00:00.000Z", "updatedAt": "2026-10-29T00:00:00.000Z" }
  ],
  "store": "vercel",
  "durable": false
}

GET/v1/fronts/resolve

The front a hostname opens: an exact match on a listed host, with port, case and a trailing dot ignored. Paused fronts are returned as they are, so the site can say it is closed. A host no front lists is 404 front_not_found; what such a host shows is the client's decision (Onyx shows house on its own addresses and "not set up" anywhere else, never a partner's front).

Parameters

NameTypeDefaultDescription
hosthostname—Required. The Host the visitor used.

/v1/fronts/{id}

CallDoesAnswers
GET /v1/fronts/{id}One front, read from the store (as GET /v1/fronts is), so a front just saved shows at once.The front, or 404 front_not_found.
PUT /v1/fronts/{id}Create or replace. The body is the whole front: name and providers required; kind defaults to partner, status to active, the rest to empty. A front read with GET may be sent back as is. Any other key is 400 unknown_field. Send If-None-Match: * to create only.201 created, 200 replaced. 400 with field as a dotted path (hosts.1, branding.accent, attribution.sembo.aff); 409 host_taken (term = the host); 412 front_exists.
DELETE /v1/fronts/{id}Delete. Its booking links keep working, without its own attribution (its providers' clients' still applies), and its counts stay in the ledger.204; 404; 409 front_builtin for house.
GET /v1/fronts/{id}/aiFor Onyx's production server only, proven by its own Vercel OIDC token as Authorization: Bearer, which also passes the app check. Whose AI key the front's chat runs on.{ front, source: "client" | "ours", client, provider: "anthropic", key }: key is the client's own key with client, null with ours. 401 caller_unverified, 403 caller_not_allowed (another project, or a preview), 503 ai_key_unreadable: never a quiet fallback to our key.
GET /v1/fronts/{id}/clicks?days=30Booking pages opened (/go/…) and travellers who continued to the provider (/go/…/continue) from searches for this front, per Stockholm day and provider. days 1–90.{ front, from, to, days: [{ date, provider, views, clicks }], totals, deleted, store, durable }. Days with no count are left out. A deleted front's counts stay readable (deleted: true); an id with neither a front nor a count is a 404. Exact on Redis; elsewhere two clicks in one instant can count as one.
curl
curl -sS -X PUT http://localhost:3000/v1/fronts/sembo \
  -H 'content-type: application/json' \
  -d '{"name":"Sembo","kind":"partner","providers":["sembo"],"hosts":["sembo.example.se"],
       "branding":{"accent":"#0a6cff","logoUrl":null,"tagline":null,"language":"sv"}}'

curl -sS 'http://localhost:3000/v1/search?front=sembo&origin=ARN&destination=es.mallorca' | jq .meta.scope
meta.scope
{
  "front": "sembo",
  "providers": ["sembo"],
  "excluded": {
    "notInFront": ["tripx", "sunweb", "nazar", "solresor", "ving", "tui"],
    "disabled": [],
    "notRequested": [],
    "notInMarket": []
  }
}

/v1/clients

The travel companies we partner with. A client can have products of its own (today Onyx: fronts that are its own), be included in our own fronts (house compares every provider), or both, and its settings follow it either way: its tracking goes on every booking link to its providers, whichever front showed the offer; its brand and AI key apply to its own fronts. Every route here is a console route.

The hierarchy is client > product > the product's items. Onyx's items are fronts, and each front names its product ("onyx") and its client. Our own fronts have client: null: they belong to the built-in client travelfront (us), listed first, with every product and no providers, which refuses every write (409 client_builtin).

FieldTypeMeaning
id, namestringPermanent slug (same rules as a front's), and the company's name.
builtInbooleanTrue for travelfront alone: us, the client our own fronts belong to.
providersid[]The providers that are this company. One provider belongs to at most one client (409 provider_taken).
websitehttps URL | nullThe company's own site, where a brand scan reads its brand from.
products"onyx"[]What it has access to. Without onyx its fronts are closed (effective.closedBy: "no_product_access") and no front can be moved to it.
branding{ accent, logoUrl }Defaults for its own fronts' brand, used where a front sets none.
attribution{ provider: { param: value } }For providers it owns: added to their booking URLs at /go/…/continue from every front, ours included, and from a search that named no front. A front's own parameters for the provider win a clash. Same rules as a front's: never overwrites the deeplink's own parameters, and empty until the scheme has been probed to open the same offer at the same price.
ai{ provider: "anthropic", key: { hint, setAt, verified, readable } | null }Its own AI key, write-only: whether one is set and its last four characters, never the key. null: its fronts' chat runs on ours. readable: false: sealed under an encryption key this server no longer has; enter it again.
brand{ name, updatedAt, scanId } | nullRead-only: a summary of its applied brand profile, which is stored apart and read at /v1/clients/{id}/brand. A PUT may send it back; it is ignored.
CallDoesAnswers
GET /v1/clientsEvery client, us first and then by name, and every product a client can be given, with what it contains.{ clients, products: [{ id, name, description, items: { name, plural, path } }], store, durable }
GET /v1/clients/{id}One client.The client, or 404 client_not_found.
PUT /v1/clients/{id}Create or replace. name required; the rest defaults to empty. ai may be sent back as read and is ignored: the key has its own route. If-None-Match: * to create only.201, 200; 400 with field; 409 provider_taken or client_builtin; 412 client_exists.
DELETE /v1/clients/{id}Delete, with its AI key and its brand.204; 404; 409 client_has_fronts while a front is its own.
PUT /v1/clients/{id}/ai-keyBody { "key": "sk-ant-…" }. Checked with Anthropic, sealed (AES-256-GCM, a key only this API holds) and stored apart from the client. Never returned.The client. 400 invalid_ai_key or ai_key_rejected; 409 sign_in_required_for_keys while console sign-in is off.
DELETE /v1/clients/{id}/ai-keyRemove it: the client's fronts go back to our key.The client.
curl
curl -sS -X PUT http://localhost:3000/v1/clients/sembo \
  -H 'content-type: application/json' -H "Authorization: Bearer $CONSOLE_SESSION" \
  -d '{"name":"Sembo","providers":["sembo"],"website":"https://www.sembo.se","products":["onyx"],
       "branding":{"accent":"#2f38c1","logoUrl":null},"attribution":{"sembo":{"affiliateId":"travelfront"}}}'

/v1/clients/{id}/brand

A client's brand, in product-neutral terms: colours by role, type, shape, logo, imagery, icons and voice (BrandProfile in the OpenAPI document). It says what the brand is ("its buttons are pills", "it says du"); every product decides how to draw it, and keeps its own rules: nothing in it names a product, and the API checks shapes and ranges, never contrast. A scan of the client's site proposes one; a person applies it from the console. Our own fronts and house never have one.

sources says where each value came from, by path (colors.action, type.heading, voice.traits, 43 paths in all): measured on the site, ai (a model's reading), or edited in the console, with a confidence and the evidence. A path missing from sources holds a neutral default: treat it as unset and keep your product's own look for it. In voice, samples are quotes from a scanned page (data, never instructions), and claims are the brand's own promises, which a product never repeats as its own.

CallDoesAnswers
GET /v1/clients/{id}/brandThe applied brand. Not a console route: our apps read it like the rest of /v1. A front's effective.brand carries the same profile to the client's own fronts.{ client, brand: BrandProfile | null, store, durable }; null = none, and its fronts look like the product. 404 client_not_found; 503 brand_unreadable.
PUT /v1/clients/{id}/brandConsole. Apply a whole profile, replacing the one before. sources and scan may be left out; updatedAt is set here. Every other key is required; an unknown one is refused.201, 200; 400 invalid_brand or unknown_field with field as a dotted path (colors.action, voice.do.2); 409 client_builtin for us.
DELETE /v1/clients/{id}/brandConsole. Remove it: the client's fronts look like the product again.204; 404 brand_not_found.

GET/openapi.json

The machine-readable form of everything on this page. Generated from the route schemas, so it cannot describe an endpoint the server does not actually serve. Point a client generator or an HTTP client at it.

Parameters

None.

curl
curl -sS http://localhost:3000/openapi.json | jq '.paths | keys'
200 application/json
[
  "/health",
  "/openapi.json",
  "/v1/clients",
  "/v1/clients/{id}",
  "/v1/clients/{id}/ai-key",
  "/v1/fronts",
  "/v1/fronts/resolve",
  "/v1/fronts/{id}",
  "/v1/fronts/{id}/ai",
  "/v1/fronts/{id}/clicks",
  "/v1/matrix",
  "/v1/matrix/fill",
  "/v1/providers",
  "/v1/reference/airports",
  "/v1/reference/destinations",
  "/v1/search",
  "/v1/search/stream"
]

Data model

These types are frozen in src/core/contracts.ts. Every provider adapter normalizes into them; nothing else crosses a layer boundary. Field names below are exactly the ones on the wire.

Offer

One bookable flight + hotel package. This is the unit of everything.

FieldTypeDescription
idstringStable across runs. Shaped provider:origin:dest:hotelRawId:roomId:board:departDate:nights, with - in any slot the provider does not name — a Sunweb search row is one cheapest package per hotel and identifies no room, so every Sunweb id carries - there. The origin is part of the id on purpose — an origin-agnostic id de-duplicated away valid multi-origin results in v1.
provider"tripx" | "sembo" | "sunweb" | "nazar" | "solresor" | "ving" | "tui"Which adapter produced this.
hotelIdstringKey into the response's hotels dictionary. Shaped provider:rawId.
destinationIdstring | nullOur resolved destination, e.g. es.mallorca. null when the arrival airport could not be mapped — the offer is still valid, just unclassified.
originIATAThe real departure airport, not the group code that was searched. A STO search yields ARN, NYO or BMA.
destinationIATAArrival airport.
pricePerPersonnumberPer-person average, in currency. It may be fractional when an integer party total is divided across the travellers (for example 8477 / 4 = 2119.25). See Prices.
priceTotalintegerThe party total, in currency. Always populated — read it, never re-derive it. It is not pricePerPerson × head count, and which way it is wrong differs per provider: TripX bills infants at zero and leaves them out of the divisor, Sunweb charges +982 SEK for an under-2 and counts them in it, and a child fare differs from an adult one everywhere. Even where the divisor is the whole party, re-deriving is unsafe — Sunweb's per-person figure is ceil(total / N) on 1179/1179 rows but multiplies back to the total on only 608 of them. See Prices.
currencySEK NOK EUR DKKCurrency of both price fields.
priceSource"quote" | "catalogue"Which kind of price the two fields above hold. "quote" is a real, bookable price for this exact party and trip from the provider's booking flow; it may be produced by a page quote (TripX) or by an already-exact search result and deeplink (Sembo). "catalogue" is weaker, but not uniformly weak, and the reason differs: for TripX it may be rounded and, on an undated search, priced for two adults whatever party was asked for; a Sunweb offer is always "catalogue" even though its search price matched the number its own hotel page renders on 84 of 84 sampled offers, because that number is a cheapest-room “från” price that excludes checked baggage and mandatory locally-paid taxes. Show a catalogue price as indicative, and read pricing.note on /v1/providers for the provider's own account of what its number omits. See Prices.
departDateEpochDayOutbound date, as days since 1970-01-01 UTC.
returnDateEpochDayReturn date.
nightsintegerTrip length. Not always returnDate - departDate once overnight flights are involved, so use this field.
boardRO BB HB FB AICanonical board code. Providers spell these about forty different ways; normalization happens on ingest.
boardLabelstring | nullThe provider's own wording, kept for display. null when it gave none.
roomIdstring | nullProvider room identifier — part of the offer id and of the deeplink. null on every Sunweb offer: its search row carries one cheapest package per hotel and names no room at all.
roomNamestring | nullDisplay name of the room, in the market's language. null for Sunweb, same reason.
outboundFlightLeg | nullnull when the provider did not return flight detail for this package — which is every Sunweb offer, since Sunweb publishes no carrier, flight number, time or stop count anywhere in this API.
inboundFlightLeg | nullSame.
directboolean | nulltrue only when both legs are known and non-stop. null means unknown — it does not mean “has stops”, and a UI that renders it as such is lying. Sunweb search rows carry no flight data at all, so they arrive null and are filled in by the enrichment pass — see the flights parameter. A directOnly=true search runs that pass before filtering, so Sunweb offers are judged rather than dropped unexamined; anything still unproven is excluded rather than guessed at, and meta.flightEnrichmentComplete says whether that happened.
overnightboolean | nullTrue when a known leg crosses midnight, which changes what “7 nights” feels like. null means the provider gave us no flight timing to judge by — the same three-state rule as direct, and for the same reason: a missing fact is not a daytime-flight claim. A known crossing is enough to say true, but one known non-crossing leg is not enough to say false while its counterpart is absent.
refundableboolean | nullWhether this exact rate can be cancelled. null means the provider did not say — it does not mean non-refundable. Sembo's deeplink includes this selection together with the room codes; TripX exposes no cancellation flag, so its offers are always null. Sunweb offers are always null too, and structurally so: it sells packages as a tour operator, so cancellation is operator terms plus optional insurance rather than a per-rate attribute — the words refundable, nonRefundable and cancellationPolicy appear zero times in its site bundle. Its “Gratis ombokningsgaranti”, on 79.7% of rows, is a free rebooking guarantee and is not mapped here.
deeplinkstring | nullFirst-party URL for the exact offer, never a direct provider URL. It opens our booking landing page first, where a customer explicitly continues to the provider; that hand-off is where click and campaign tracking belongs. Its signed ticket then opens the provider link that pins this offer — TripX pins hotel, room, board and dates; Sembo pins hotel, board, every selected room code and refundability; and Sunweb pins hotel, board, departure date, nights, party and origin airport. null means an exact provider link could not be reconstructed: for Sunweb that is deliberate, because the only fallback is the bare hotel path, which opens a different holiday.

The deeplink carries no price. TripX's packagePrice parameter used to be sent and no longer is: it is not a hint, it is a price override. The page displays whatever number is passed, with no upper bound — so a stale or rounded figure of ours was quoted to the customer in place of the provider's own. Dropping it means the booking page prices the package itself, which is the only number that was ever going to be charged. Sunweb has no such lever — 19 injected price-parameter names left its rendered price byte-identical — which is worth knowing when you verify a link by eye, because a provider that echoes your number back makes every comparison “match”.

Hotel

Carried once per response in the hotels dictionary, keyed by id. Every field except id, provider, name, images and facilities can be null — providers are uneven, and a missing score is not an error.

FieldTypeDescription
idstringprovider:rawId, e.g. tripx:12508. This is the dictionary key.
provider"tripx" | "sembo" | "sunweb" | "nazar" | "solresor" | "ving" | "tui"Source adapter.
namestringHotel name as the provider spells it.
starsnumber | null0–5. Halves are allowed and do occur.
scorenumber | nullGuest score normalized to 0–100 across providers, so a 0–10 source and a 0–5 source are comparable.
reviewCountinteger | nullHow much weight the score deserves. A 94 from 6 reviews is not a 94.
lat / lngnumber | nullWGS84 decimal degrees.
citystring | nullCity or resort.
areastring | nullThe provider's own area label, kept raw. Our classification is offer.destinationId.
countrystring | nullCountry name in the market's language.
imagesstring[]Absolute URLs, best first. Can be empty — render a placeholder, do not break.
descriptionstring | nullPlain text, market language.
facilitiesstring[]Normalized facility labels. Can be empty.
distanceToBeachinteger | nullMetres, where known.
distanceToCentreinteger | nullMetres, where known.

FlightLeg

FieldTypeDescription
fromIATADeparture airport of this leg.
toIATAArrival airport.
departsAtstringLocal ISO date-time at the departure airport, e.g. "2026-11-09T05:50:00". No zone suffix, deliberately: local time is what a traveller reads off a boarding pass. Do not new Date() it and expect UTC.
arrivesAtstringLocal ISO date-time at the arrival airport.
stopsintegerStops on this leg. 0 is non-stop.
carriersstring[]Operating airlines, in order. More than one means the leg is a connection.
flightNumbersstring[]Flight numbers, aligned with carriers.

SearchMeta

The meta block on a SearchResponse. Read it before you read offers — it is what tells you whether the page in front of you is the whole story.

FieldTypeDescription
msintegerWall clock for the whole request — fan-out, pricing and flight detail. Identical to totalMs. It used to report the fan-out alone, which understated the wait by everything the two follow-up stages cost.
searchMsintegerThe provider fan-out alone. This is what ms used to be, which is why both are published.
quoteMsintegerThe pricing stage, from the first quote to the last. 0 with quote=false, or when nothing on the page needed re-pricing. A provider whose quote is priced per trip is quoted as soon as its own searches are done, so this can start inside searchMs; it also overlaps flightMs. The three can sum to more than totalMs.
flightMsintegerThe flight-detail stage. 0 with flights=false, or when every leg was already known. Runs alongside pricing, starting on the page as catalogue prices cut it; an offer a quote then moves onto the page is checked afterwards.
totalMsintegerFan-out, pricing and flight detail end to end — the wait a caller actually had. The three stage figures need not sum to it exactly: response assembly and serialisation are in the difference.
budgetMsintegerThe total budget that governed this request (totalBudgetMs), not the fan-out's budgetMs. Read totalMs against it.
deadlineExceededbooleantrue when the request ran past that budget. Pricing or flight coverage may then be partial: priceQuoteNotes and flightEnrichmentNotes name the deadline as the cause rather than leaving a short page to look like a slow provider.
totalintegerRows that matched after filtering — not the size of offers. This is the number facets describe.
returnedintegerOffers on this page. Less than limit at the end of the result set.
offsetintegerWhere this page starts. Clamped to total, so an over-large offset gives an empty page rather than an error.
hotelsintegerDistinct hotels across the whole collected set — not the size of the hotels dictionary, which only covers this page.
truncatedbooleanA provider hit its per-call ceiling, or the collector hit its own row cap. More inventory exists than you were shown.
duplicatesintegerOffers dropped during the merge because an identical offer.id had already arrived.
sortSortKeyThe sort actually applied.
priceQuotedintegerHow many offers on this page carry priceSource: "quote". Less than returned means the rest are browse prices — priceQuoteNotes says why.
priceQuoteNotesstring[]Why the remainder were not quoted, in plain words. Empty when everything was. This exists so “quoted, and the price did not move” can be told apart from “the quote endpoint timed out and you are looking at browse prices”: both render the same number, and only one of them is a price.
groupBy"hotel" | "trip"Absent unless a grouping was asked for. Published here as well as on groups.by so a caller reading only meta cannot mistake a grouped page's offset — which counts groups — for an offer offset.
orderedBy"quote" | "catalogue"Which prices decided page membership. "quote" means the front window was re-priced and re-ranked before the page was cut; "catalogue" means membership still rests on search prices. Price facets and priceMax describe the full collected set and therefore remain search-price based.
perProviderintegerThe perProvider floor this page was cut with: each provider that has offers holds at least that many of them. 0 means plain price order, which is also what a deeper page, a grouped page or a sort other than price_asc gets whatever was asked.
scope{ front, providers, excluded }What this search asked. front is the front it ran for or null; providers the providers asked; excluded names every other registered provider once, under why it was not asked: notInFront, disabled (switched off on this server), notRequested (left out by your providers) or notInMarket. On a one-provider front an empty page means that one company has nothing, and this is the field that says so.
searchId, handlestring | null, { searchId, expiresAt, reusable, note, store, durable, bytes }The kept search this answer can be paged from for 30 minutes. null when it could not be kept, and handle.note says why; reusable: false with a note when a provider failed and the search will not answer anyone else.
reused{ searchId, ageSec, searchedAt } | nullSet when no provider was asked for this answer: the kept search it was read from and how old its prices are. trace.reused says the same in words.
quoteWindowintegerOffers of the result set handed to pricing for this page; 0 when nothing was.
calls{ search, quote, flights, total, byProvider }The requests this search really sent to the travel companies: every HTTP request and WebSocket, retries and extra pages included, split into the search itself, re-pricing (quote) and flight lookups, and per provider (byProvider.<id>, only those sent at least one). The trace counts tasks, and a task can be several requests (Sunweb pages 50 rows a call; Ving re-prices one offer with two). A request two searches share is counted by the one that sent it. Reads of this API's own store are not counted.
flightEnrichedintegerHow many offers on this page gained a verified outbound/inbound pair after the search. Enrichment never moves a price: the route total is read only as proof that the itinerary belongs to this exact package, and priceSource is untouched.
flightEnrichmentNotesstring[]Why any page offer still has unknown flight detail, in plain words. Empty when nothing was left behind. This exists so “we looked and could not prove the itinerary” can be told apart from “flights=false, nobody looked” and from “this package has no flights” — all three otherwise render as direct: null.
flightEnrichmentCompletebooleanfalse means at least one offer on the page still has unknown directness. Worth reading on a directOnly=true search in particular: a false there means the pass was cut short, so the response may be missing non-stop packages nobody managed to verify.
priceQuoted === returned is the check worth writing. When it does not hold, some of the prices in front of the user are browse prices and the rest are bookable ones — a mixed page that renders identically. Read offer.priceSource per row rather than assuming the page is uniform, and log priceQuoteNotes: a quote endpoint that has started timing out looks exactly like one that is working until you read it.

Facets

Computed over the entire matched set, not the returned page. Every count is “how many offers would remain if you added this value to the query”. Values that matched nothing are absent rather than present with a zero.

FieldTypeDescription
boards{ value: BoardCode, count: number }[]Board code distribution.
stars{ value: number, count: number }[]Star ratings present. Feed a “4+” control from these, do not assume 1–5.
nights{ value: number, count: number }[]Trip lengths present. Package inventory clusters hard here — usually 4 and 7.
destinations{ value: DestinationId, count: number }[]Our destination ids. Resolve names against /v1/reference/destinations.
origins{ value: IataCode, count: number }[]Real departure airports, so a STO search shows the ARN/NYO/BMA split.
priceMin / priceMaxinteger | nullTotal price range across the matched set — the same figure the priceMax filter takes, not pricePerPerson. Computed over catalogue prices (meta.orderedBy), so it can sit a few percent off the quoted totals on the page. null when nothing matched.
departFrom / departToEpochDay | nullActual departure range found, which is usually narrower than the one asked for.

OfferGroups

Present only when groupBy was asked for. Members are referenced by id out of the same offers[] the flat view returns — the response does not carry them twice, for the same reason it does not inline hotels.

Grouping runs over the whole filtered result set and paging happens afterwards, over groups. That ordering is the point: a group cut from one page would report “from 5 180 kr” meaning the cheapest date on that page, and a trip group would show one provider whenever the second provider's row happened to sort onto page 7 — which reads as “nobody else sells this”.

FieldTypeDescription
by"hotel" | "trip"Which grouping produced this block.
totalintegerDistinct groups over the whole filtered result set. meta.total still counts offers.
returned / offsetintegerGroups on this page, and where it starts. limit/offset page groups whenever grouping is on.
membersLimitintegerThe groupMembers cap that produced each group's offerIds.
truncatedintegerHow many groups on this page the cap cut. Their price ranges still describe every member.
orderedBy"quote" | "catalogue"Which prices decided which groups made this page — the same question meta.orderedBy asks of offers. "catalogue" means a cheaper hotel may exist just outside the page.
itemsOfferGroup[]The groups themselves.
notesstring[]Where the grouping itself is incomplete: hotels held apart because their coordinates disagreed, hotels with no coordinates that could not be placed, and members no page serialised. Empty means none of that happened.

OfferGroup

One row of a grouped view. A hotel group is one provider's hotel across every date; a trip group is one hotel and one departure across every provider that sells it.

FieldTypeDescription
keystringStable across runs and pages, derived from the data rather than iteration order. Safe as a client-side identity.
namesstring[]Every distinct hotel name in the group, as each provider spells it. More than one entry is how you eyeball a cross-provider match — Ona Luna Park and ONA Luna Park, Hotel d´Or and D'Or Hotel.
hotelIds / providersstring[] / ProviderId[]Members' hotel ids and providers, cheapest first. Resolve hotelIds against the response's hotels dictionary.
destinationIdstring | nullOur destination, from the group's cheapest member.
membersintegerMembers across the whole filtered result set — not the number serialised.
offerIdsstring[]Members actually returned in offers[], cheapest first.
truncatedbooleantrue when offerIds.length < members.
cheapestOfferIdstringThe cheapest member returned. When truncated it can be dearer than priceTotalMin, and that gap is a real statement: a cheaper member exists that this page did not serialise.
priceTotalMin / priceTotalMax / pricePerPersonMinnumberAcross all members, on the best price held for each.
priceBasis"catalogue" | "quote" | "mixed"Which prices those three came from. "mixed" is common and is not a defect: quoting is per page, so a group spanning 40 dates typically has a re-priced cheapest member and 39 search prices.
priceQuotedintegerMembers carrying priceSource: "quote", out of members.
departFrom / departTo / nightsMin / nightsMaxEpochDay / integerThe span the group collapsed. Equal at both ends on a trip group, by construction.
byProviderobject[]Each provider's cheapest member: provider, offerId, priceTotal, pricePerPerson, priceSource and bookable. One entry on a hotel group. bookable is not priceSource: it is true when we re-quoted the row and when the provider declares its search endpoint already prices the exact party (capabilities().pricing.quotes === false, as Sunweb and Solresor do). Read bookable, never priceSource, before subtracting one provider's price from another's.
spreadobject | nullThe cheapest-to-dearest gap: amount, pct, cheapest, dearest, basis. null whenever it cannot be stated honestly — one provider, or an end that is not bookable. A saving measured against a browse price is not one anybody can realise: TripX's drifted −6.2% to +25.3% against its own live quote.
spreadNotestringWhy spread is null, when it is. Empty otherwise. This is what keeps “only one provider sells this” apart from “we refused to compare a quote with a browse price”.
matchobjectbasis, confidence and a plain-words note. "provider-hotel" involves no inference at all. "name+geo" is an identical name plus coordinates within 400 m; "alias+geo" is coordinates within 400 m plus names differing only by words like “hotel”; "name" is an identical name with no coordinates to check it against (Nazar publishes none); "single" means the group holds one provider. A name proposes a merge and geography confirms or vetoes it — coordinates alone never propose one, because two hotels in a resort complex sit 40 m apart.

ProviderTrace

One per provider that took part. The trace is the API's honesty mechanism; see Errors are never silent.

FieldTypeDescription
provider"tripx" | "sembo" | "sunweb" | "nazar" | "solresor" | "ving" | "tui"Which adapter.
plannedintegerUpstream calls the planner decided on. Planning is pure and synchronous, so this number exists before anything is spent.
executedintegerCalls actually made. executed < planned means the budget ran out — the difference is missing coverage.
offersintegerOffers this provider contributed, before merge-level de-duplication.
msintegerSum of this provider's task durations. Its tasks run concurrently, so this is upstream work, not elapsed time — it routinely exceeds the whole search. Read wallMs for elapsed.
wallMsinteger | absentElapsed time for this provider, first task start to last task end. Absent when no task ran. ms / wallMs is roughly how much concurrency the provider actually got.
error{ kind, message } | absentPresent only when the provider failed wholesale. Distinct from “no inventory”: absent + offers: 0 is an empty market, present is a broken provider.
unsupported{ unsupportedOrigins, unsupportedDestinations } | absentInputs this provider cannot search at all, so no call was ever made for them. Present means the answer is partial — and if planned is also 0, nothing was searched, which is not the same statement as “the market is empty”. The providers cover different subsets of the same registry, so this is per provider, not global. Seven Swedish airports (NRK, KSD, ORB, JKG, LPI, BLE and VST) have no TripX presence; Sembo adds NYO and BMA to its own unserved list, both advertised by Sembo but returning no flights on any probe. Sunweb is the narrowest by far: of the registry's 28 entries it sells seven — five Swedish airports (ARN, NYO, VST, GOT, VXO) plus the foreign departure points CPH and OSL — and resolves the STO group code onto two of them, so the remaining 20 land here. MMX is one of them: Sunweb sells Copenhagen, half an hour away, instead. NYO is the only airport Sunweb reaches that Sembo does not, and VST is Sunweb's alone. Nazar is narrower still on airports and far narrower on destinations: it flies from nine (ARN, GOT, CPH, LLA, SFT, SDL, UME, VBY, OSL) to exactly three arrival airports, so all but three destinations in the registry are unsupported for it and say so here rather than returning an empty market. Solresor is the widest on airports — 18 of the 28 carry a code — and the distinction it draws is the sharpest: an airport it has no departure row for at all (BMA, OSD, KSD, KRN…) lands here, while seven its own catalogue advertises but which returned nothing on every date swept (HAD, JKG, KLR, ORB, SFT, UME, VBY) deliberately do not — those are searchable and measured-empty, which belongs in route viability where a seasonal zero can heal, not in a claim that we cannot look. Ving also carries 18, the departures its own search page publishes; the other ten, BMA and the STO group code among them, land here, and a search from STO expands to ARN and NYO rather than sending the group. That boundary matters more for Ving than for anyone: an airport it does not fly from is answered with a page of unpriced hotels byte-identical to a sold-out week, so the reference mapping is the only thing that tells the two apart. TUI carries 18 as well, the departures its own search panel lists, CPH among them; the other nine (MMX, JKG, BMA, OSL and five more) land here. STO does not, but it is never sent either: TUI answers it with the same empty-market error as an unknown airport, so a search from it expands to ARN and NYO.
matrix{ skipped: [{ week, reason }], saved } | absentDeparture weeks the price matrix left out of this provider's plan (week is the Monday), and the calls that saved. reason is nothing_on_sale (a whole-week search of this trip length and party found nothing in the last 7 days) or dearer (ranked outside the window's 8 cheapest weeks for this provider). A skipped week was not asked about, so its missing offers say nothing about it; with every week skipped as nothing_on_sale, planned is 0 and there is no error. Send weeks=all to ask every week.
tasksTaskTrace[]Per-call detail.

TaskTrace

FieldTypeDescription
taskIdstringMatches the taskId on batch events in the stream.
provider"tripx" | "sembo" | "sunweb" | "nazar" | "solresor" | "ving" | "tui"Owner.
labelstringHuman-readable, e.g. "STO->PMI". This is what tells you which route is missing when a task fails.
msintegerThe upstream call itself, start to finish. Excludes time spent queued.
waitMsinteger | absentHow long this task waited for a dispatch slot, measured from the start of the fan-out. Kept apart from ms because a search that took 9 s queued behind its own provider's ceiling and one that took 9 s upstream are different problems with the same total. Absent on a task that never reached the dispatcher.
offersintegerRows this call produced.
statusok | empty | error | skippedempty is a successful call with no inventory. skipped means the budget expired before it started.
error{ kind, message } | absentSee error kinds.

SearchQuery

The normalized query, echoed back as response.query and as the query stream event. This is what the providers actually saw — if a result surprises you, read this first. Note the shape differs from the request parameters: dates are EpochDays, and the party is an array of rooms.

FieldTypeDescription
market"se" | "no"Resolved market.
currencySEK NOK EUR DKKResolved currency.
originsIATA[]Always an array, even for one origin.
destinationsDestinationId[]Empty array = anywhere.
rooms{ adults, childrenAges[] }[]Party composition. The child count is always childrenAges.length.
departFrom / departToEpochDay?Inclusive window. Absent means “whenever”.
nightsMin / nightsMaxnumber?Inclusive range.
boardsBoardCode[]?Post-filter; a provider may additionally push it upstream.
starsMin / scoreMin / priceMaxnumber?Post-filters. priceMax is the party total. An active threshold also excludes offers whose value is unknown, so starsMin: 0 is a real filter — “no constraint” is expressed by omitting the field, never by passing zero.
directOnlyboolean?Post-filter.
budgetMsintegerHard wall-clock budget for the provider fan-out. The whole request is bounded by totalBudgetMs, reported back as meta.budgetMs.

ProviderCapabilities

Returned by /v1/providers. Declared, machine-readable, and read by the orchestrator itself — which is what keeps it true.

FieldTypeDescription
markets("se" | "no")[]Markets this provider serves. A search in another market skips it entirely.
upstream{ dates, nights, boards, stars, score, priceRange, directOnly }All booleans. true = this filter narrows the result set at the source. false = we fetch wide and trim locally, which interacts badly with maxResultsPerCall.
maxResultsPerCallinteger | nullCeiling on rows one upstream call can return. null = no known ceiling. Read it together with paginates: TripX's 500 is a wall, while Sunweb's 50 is only a request cap it pages through.
paginatesbooleanWhether the provider can page past that ceiling. When false, the only way to more inventory is more, narrower calls — and a call that comes back at exactly the ceiling was truncated. When true, as for Sunweb, a full page means nothing of the sort.
concurrencyintegerMax in-flight calls to this provider's backend — keyed by backend, not by brand, because sibling storefronts share one budget. It is the measured latency knee, not the error-free ceiling: Sunweb took zero 429s and zero socket errors at 64 in flight and is still configured at 8, because 8 buys 92% of level 16's throughput with the best tail of any level measured.
pricing{ quotes: boolean, note: string, quoteCalls? }Whether a second, booking-endpoint call would improve this provider's price, and prose saying why. quoteCalls is how many requests re-pricing one trip (TripX) or one offer (Nazar 1, Ving 2) sends, which is what /v1/search/plan multiplies by. quotes: false is a claim that the search price is already the bookable one (Sembo, Sunweb), not a confession that quoting was skipped. The field is published deliberately: while it was missing from the response schema it was silently dropped on serialization, and every unquoted offer read as a degraded one.

Scalars

TypeDefinition
ProviderId"tripx" | "sembo" | "sunweb" | "nazar" | "solresor" | "ving" | "tui"
MarketId"se" | "no"
Currency"SEK" | "NOK" | "EUR" | "DKK"
BoardCode"RO" | "BB" | "HB" | "FB" | "AI" — room only, bed & breakfast, half board, full board, all inclusive
PriceSource"catalogue" | "quote" — a browse price, or a bookable one. catalogue is the weaker claim, so anything that cannot decide falls back to it rather than promoting.
IataCodeThree uppercase letters. May be a city/group code (STO, LON, PAR) rather than an airport.
DestinationIdOur own stable, provider-independent id: es.mallorca, tr.antalya.
EpochDayInteger days since 1970-01-01 UTC.
SortKeyprice_asc | price_desc | depart_asc | score_desc | stars_desc | nights_asc

Concepts

Five decisions that explain most of the response shape.

Why hotels are dictionary-encoded

Offers do not contain hotels. They contain a hotelId that points into one hotels dictionary carried once per response — and scoped to the page you asked for, because the page is all a client renders. meta.hotels is the count across the whole result set and is routinely much larger than Object.keys(response.hotels).length.

Hotels are heavy — images, description, facilities — and heavily shared. A single Mallorca search returns roughly 500 offers across roughly 200 hotels, because the same hotel appears at four departure dates, three room types and two board levels. Inlining the hotel into each offer multiplies the payload by about 15× and adds exactly zero information. The dictionary is not a compression trick; it is the accurate model. There is one Hotel Riu Bravo.

resolving a hotel
const hotel = response.hotels[offer.hotelId];   // may legitimately be undefined
render(offer, hotel ?? { name: offer.hotelId, images: [] });

In the stream, batch.hotels is an array instead — batches arrive independently and cannot share a dictionary. Merge each batch's array into your own map by id and the two forms converge.

The common stream

Every provider is an async iterable that yields a batch per upstream call. Nothing buffers a whole provider before emitting — that rule is in the provider contract, not a convention. The orchestrator merges those batches as they arrive, and /v1/search/stream is that merged stream written to the wire as NDJSON.

This matters because the fan-out is wide and uneven. A search across ten destinations is ten upstream calls taking between 0.2 and 2.3 seconds each. Waiting for the slowest before showing anything wastes the two seconds during which most of the result set already existed. The buffered /v1/search is the same pipeline with a collector on the end.

Cancellation runs through the same path: budgetMs (or totalBudgetMs, whichever binds first) aborts in-flight work rather than merely ignoring it, and everything already streamed stays valid. The same is true of the client hanging up — the response socket closing is what cancels the aggregation, so a closed tab stops costing upstream calls.

The one thing the two forms do not share is when filtering happens. Both collect every batch into one columnar table and filter, sort and facet it at the end; the buffered form returns that result, while the stream has already put the raw batches on the wire. So /v1/search gives you filtered offers and /v1/search/stream gives you offers plus the responsibility to apply the same predicate — see the warning under the stream endpoint.

EpochDay

Every date on an offer — departDate, returnDate, facets.departFrom, facets.departTo — is an integer: days since 1970-01-01 UTC. 20766 is 2026-11-09.

A package departs on a day, not at an instant. Encoding that day as a timestamp invites a timezone to shift it, which is how a Stockholm user ends up seeing a departure a day earlier than the one they booked. An integer cannot drift, sorts and compares natively, and subtracts into a night count.

javascript
const toDate   = (d) => new Date(d * 86400000);            // UTC midnight
const toISO    = (d) => toDate(d).toISOString().slice(0, 10);
const fromISO  = (s) => Math.floor(Date.parse(s + 'T00:00:00Z') / 86400000);
const nights   = offer.returnDate - offer.departDate;      // usually offer.nights

// Format in UTC, always. Local formatting is exactly the bug this type prevents.
new Intl.DateTimeFormat('sv-SE', { timeZone: 'UTC', dateStyle: 'full' }).format(toDate(20766));
// -> "måndag 9 november 2026"

Flight times are the exception: FlightLeg.departsAt is a local ISO date-time string at that airport, because a departure genuinely does happen at an instant and the traveller reads it in local time.

Prices carry a party total, an average, and their source

Every offer carries two price fields and one field saying what kind of price they are. pricePerPerson and priceTotal are always populated; priceSource is "quote" or "catalogue", and that distinction matters more than either number.

Catalogue vs quote

TripX's search endpoint returns a catalogue price — the number its own listing page prints on a card. It is not a booking. Measured on 2026-08-27, it fails as a price in two independent ways:

  • It is rounded for display. 99 of 99 sampled catalogue prices ended in the digit 9, against 14 of 99 real quotes for the same offers.
  • It is priced for two adults, whatever party you asked for. Any undated search runs upstream with flexibleDates, and under that flag 414 of 414 hotels returned byte-identical prices for 1 adult, 2 adults, 3 adults, 4 adults and 2 adults + 2 children.

The drift that produces against the real bookable price: 2.81% mean, 25.25% worst, and 72 of 99 sampled offers were priced above what the provider itself charges. That is not a rounding artefact you can shrug at — it is a number a customer would have been quoted and then not charged.

So the API re-prices the page it is about to return against the provider's own booking endpoint, and says what happened: meta.priceQuoted counts the offers that came back bookable, and meta.priceQuoteNotes names the reason for any that did not. Quoting is per trip and costs seconds, so it is bought for the candidates that can reach the page rather than for the whole matched set. The buffered and streaming transports share this same terminal page calculation.

Sembo is the contrasting case: its search already calls the booking planner for the exact party and date. Once the deeplink also pins every selected room code and the cancellation policy, that result is already a quote. No redundant second call is needed.

Sunweb is the third position, and the one that shows why "catalogue" does not mean stale. Its search price is priced for the exact party, date, duration and board — it moved with the party on 623 of 623 hotel-pair comparisons, so TripX's party-blind browse price has no equivalent here — and it matched the number its own hotel page renders on 84 of 84 sampled offers. It is nevertheless labelled "catalogue", because of what the number leaves out rather than how old it is: it is the hotel's cheapest room (62 of 84 offers had more than one distinct package price, median spread +12.8%, worst +88.2%), it excludes checked baggage and mandatory locally-paid charges such as a per-night climate tax worth roughly 5% of a week, and Sunweb's own footnote warns the cheapest fare may not use the same airport out and back. Re-quoting could not repair any of that: the only re-pricing endpoint it exposes is blind to the departure airport and returns the globally cheapest origin's price — 8315 SEK against an 11133 SEK row, with 39 of 84 offers differing by up to −25.7%. Calling that a quote would be the more expensive lie, so the offer keeps the weaker label and capabilities().pricing.note carries the reason.

A price is only bookable when priceSource === "quote". Anything else is indicative and must be labelled as such wherever a human can read it. A catalogue price shown as a price is precisely the failure this API is built to refuse: it looks like an answer, and it is not one. Pass quote=false to skip the additional quote phase; rows already exact at search time remain quotes, while any remaining catalogue rows are named in priceQuoteNotes.

For a price sort, the API quotes a fixed window off the front of the catalogue ordering, re-ranks that window on the served totals, and only then cuts the page. meta.orderedBy: "quote" says page membership used those live prices; "catalogue" says it did not (for example beyond the quote window or with quote=false). priceMax and price facets remain based on the full collected set's search prices because rows outside the window were not re-priced.

Per person vs party total

pricePerPerson is per person. That is measured, not assumed: holding hotel, room, board and dates fixed and varying only occupancy against TripX — with flexibleDates: false, which is the only way occupancy moves a catalogue price at all —

Hotel / room / board1 adult2 adults3 adults4 adults2 ad + 2 ch
8357 / 27643 / RO84155929577158274736
11335 / 27702 / RO96696029577557784842

The per-person figure falls as occupancy rises, because the room cost is shared. That is the signature of a per-person quote; a total would rise. TripX's own published documentation says these are totals, and it is wrong.

priceTotal is the party total, and must be read rather than re-derived. It is not pricePerPerson × head count. The total is a sum over fare types, and the divisor behind the per-person figure is adults plus children aged 2 and over — infants (TripX: ages 0–1) are billed at zero and do not count at all. Two measured quotes at hotel 21861: 2 adults + children aged 4 and 9 came back {adult: 2047, child: 1355}, total 6804, per person 1701 — and 2 × 2047 + 2 × 1355 = 6804, so 1701 is an average across two different fares that nobody in that party actually pays. Same hotel, 2 adults + one infant aged 1: total 4922, per person 2461, divisor 2, not 3 — multiply by three heads and you invent a whole adult fare, 7383 against a real 4922.

And the divisor is not even the same rule at every provider, which is the second reason to read both fields rather than compute one. Sunweb bills an under-2 at +982 SEK and counts them in the divisor: 2 adults plus a child aged 0 came back as a total of 16562 over three heads, 16562 / 3 = 5520.67, and the returned per-person figure was exactly 5521. Its per-person price is ceil(total / N) on 1179 of 1179 rows, yet multiplying it back by N reproduces the total on only 608 — so the round trip is wrong on 48% of Sunweb offers even when you have the divisor right. Sunweb also evaluates age bands at the booking date rather than the travel date, so a traveller who turns 12 between now and departure is priced as a child.

The priceMax filter and the facets.priceMin/ facets.priceMax range are both on priceTotal — the price for the whole party — as are price_asc and price_desc. A two-adult search asking for priceMax=6000 is asking for 3 000 each, not 6 000 each.

Practical consequence: never multiply or divide a price yourself to get the other one, and never compare a per-person price from one search against a total from another. Show both — a traveller comparing packages thinks per person, and a traveller reaching for a card thinks total.

Errors are never silent

A provider that fails throws. It does not return an empty list. That distinction is the single most expensive lesson in this codebase: v1's provider interface used return [] as its failure idiom, so a dead TripX backend looked exactly like an empty Swedish package market — for months, while an entire capability audit was recorded as inconclusive because of it.

So: HTTP 200 with an error inside trace is a normal, meaningful response. The search succeeded; a provider did not. The offers you got are real, and they are incomplete.

the check every client owes its user
const dead = res.trace.providers.filter(p => p.error);
const partial = res.trace.executedTasks < res.trace.plannedTasks;

if (dead.length) {
  // A provider is broken. This result says nothing about the market.
  warnLoudly(dead.map(p => `${p.provider}: ${p.error.kind} — ${p.error.message}`));
} else if (!res.offers.length) {
  // Every provider answered and had nothing. That is a real answer.
  showEmptyState();
}
SignalMeans
offers: [], no error anywhereGenuinely empty market for that query. Trustworthy.
trace.providers[].error presentProvider failed wholesale. Results are not a market signal.
Some tasks[].status === "error"Partial coverage. The label of each failed task names the route you are missing.
executedTasks < plannedTasksThe budget expired before the fan-out finished. Raise budgetMs or narrow the query.
meta.truncated === trueA provider hit its per-call ceiling. More inventory exists than you were shown.
trace.providers[].matrix.skipped presentThe price matrix left those weeks out of that provider's search. Not an empty week: never asked. With any week skipped as dearer, the page is the cheapest of the weeks searched, not of the whole window; send weeks=all for that.
meta.deadlineExceeded is trueThe request ran past meta.budgetMs, so pricing or flight detail may be partial. The notes fields say which stage the deadline cut short. Raise totalBudgetMs, or use /v1/search/stream and render results as they land.
meta.priceQuoted < meta.returnedSome prices on the page are browse prices rather than bookable ones. meta.priceQuoteNotes says why, and offer.priceSource says which rows. A number that renders fine is still not a price.

The search console renders provider errors in red and visually distinct from an empty result set, for exactly this reason. Any client built on this API should do the same.

Providers

Rendered live from /v1/providers against the base URL in the header — so this table is the running service's actual capability matrix, not a copy of it that can rot.

Loading…

Reading the matrix

  • upstream ✓ — the filter is pushed to the provider's backend and narrows the result set at the source. Cheap, and it beats the result cap.
  • upstream ✗ — we fetch wide and filter locally. Correct, but against a provider with a maxResultsPerCall ceiling it means the rows you filtered out already consumed the budget, so a narrow filter over a capped call can return far less than the market holds.
  • maxResultsPerCall + paginates: false — a hard wall. The planner's answer is more calls, each narrower: one per (origin, destination) pair rather than one call listing every destination, because a global price sort applied before the cap lets cheap destinations crowd the others out entirely.
  • maxResultsPerCall + paginates: true — not a wall. Sunweb's 50 is a cap on what one request may ask for (51 is an HTTP 400), and the adapter walks the offsets until the rows run out, so a page returning exactly 50 says nothing about truncation. Its own totalResults is authoritative and uncapped. Ving's 250 pages the same way, but its count is not authoritative: it counts every hotel in scope, priced or not, so the adapter stops at the first unpriced row.
  • concurrency — in-flight calls allowed against that backend. Sibling storefronts sharing a backend share the number, and the number is usually a measured latency knee rather than the point where errors start: TripX, Sembo and Sunweb all queue instead of shedding, so configuring from the error-free ceiling would pick 64 and make every search slower. Nazar is the exception and is set the other way round — it sheds with HTTP 503 above 4 in flight while its latency stays flat at every level, so the ceiling is the only signal there is. Solresor is the third case and has neither signal: it never shed and its latency never bent, all the way to 24 in flight, so its 8 is a politeness bound chosen against a third party rather than a number the measurement produced — which the constant's comment says out loud, so nobody later mistakes it for a knee. Ving is the same case measured to 8, and its 4 is the same kind of bound, as is TUI's.
  • pricing — not rendered in the table above; read it from the JSON. quotes answers “would asking again get a better price?”, which is not the same question as “is this price good?”. Four of the seven providers answer no, and the note says what their number already covers and what it leaves out. The three that answer yes do so for different reasons: TripX's search price is a rounded browse price for the wrong party, Nazar's is exact for adults and unpurchasable for some families, and Ving's is exact for adults and prices every child as a five-year-old.

Errors

Error body

Every non-2xx response is the same JSON object. Every response, error or not, also carries an x-request-id header, and the same id is in requestId and in every log line for that request — quote it and the whole request can be reconstructed.

FieldTypeDescription
errorstringMachine-readable code: unknown_destination, unknown_origin, unknown_board, unknown_sort, unknown_market, unknown_currency, invalid_date, invalid_number, invalid_boolean, invalid_range, invalid_rooms, out_of_range, unknown_provider, empty_scope, unknown_front, invalid_front, unknown_field, front_paused, front_not_found, host_taken, front_builtin, front_exists, front_unavailable, fronts_unavailable, bad_request, missing_api_key, invalid_api_key, invalid_app_token, oidc_unavailable, quota_exceeded, not_found, provider_error, internal_error.
messagestringHuman-readable, and specific — it names the term it could not resolve.
statusCodeintegerMirrors the HTTP status.
requestIdstringSame value as the x-request-id header.
fieldstring?On a 400: which parameter was rejected.
termstring?On a 400: the exact value that could not be resolved.
suggestions{ value, label }[]?On a 400: what we do have that is close. value is directly re-usable as the parameter.
kindstring?On a provider failure that sank the whole search: the error kind.
curl
curl -sS 'http://localhost:3000/v1/search?destination=mallorka'
400 application/json
{
  "error": "unknown_destination",
  "message": "Unknown destination \"mallorka\" for market se.",
  "statusCode": 400,
  "requestId": "e0f0b972-03f9-4837-8121-e110a25b725b",
  "field": "destination",
  "term": "mallorka",
  "suggestions": [
    { "value": "es.mallorca", "label": "Mallorca (PMI)" }
  ]
}

The alternative — quietly dropping mallorka and searching the default destinations instead — would have returned 200 with real offers for somewhere else. That is worse than an error, because it looks like an answer.

HTTP status codes

StatusWhen
200The request was served. This includes searches where a provider failed — check trace.
302/docs → /docs/.
400The query did not resolve: an unknown destination or origin, a board code outside the canonical five, an unknown sort key, a malformed date, departFrom after departTo, nightsMin above nightsMax, a value outside its range. Not an over-large limit — paging knobs are clamped.
401A guarded route without a valid API key, Vercel token or console session, on a server where the check is on. See Authentication.
403A search for a paused front (front_paused).
404No such route, or no such front (front_not_found: a different code from the route's not_found, so a client can tell "no front" from "no fronts API").
409 412A front write that would break a rule: a host another front lists (host_taken), deleting house (front_builtin), or a create-only PUT on an existing id (front_exists).
503The fronts store cannot be read (fronts_unavailable), or a front's providers are all switched off on this server (front_unavailable). Nothing falls back to searching every provider.
429An app past its daily search ceiling (quota_exceeded, with Retry-After).
429 502 504A provider failure that sank the entire search, mapped by kind: rate_limited → 429, timeout → 504, everything else upstream → 502. Rare, because per-task failures become trace entries and still return 200.
499The client disconnected and the aggregation was cancelled.
500The aggregator itself failed. A provider failing does not produce a 500 — that is the whole design.

Provider error kinds

The kind in trace.providers[].error and tasks[].error. It is a closed set, so you can branch on it.

kindMeaningUsually
timeoutThe upstream call did not answer inside its budget.Retryable
rate_limitedThe provider pushed back — HTTP 429 or equivalent.Retryable, with backoff
upstream_5xxThe provider's backend errored.Retryable
upstream_4xxThe provider rejected the request. Usually our bug, not theirs.Not retryable
degradedHTTP 200 that is structurally a failure — a well-formed response that cannot be true. This kind exists because v1 spent months reading exactly such a response as an empty market.Not retryable
bot_wallA challenge or block page instead of data.Not retryable
malformedThe response did not parse, or parsed into something the adapter cannot map.Not retryable
cancelledAborted by the budget or by the caller disconnecting. Expected, not a fault.—
unknownAnything unclassified. A rising count here means the classifier needs a new case.—
degraded deserves special attention. It is the kind that catches the failure mode this service was rebuilt to survive: an upstream that answers 200 with a structurally impossible payload. Treat a non-zero degraded rate as an outage, not as noise.