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
hotelsdictionary and every offer points into it. See why. - There is a streaming form.
/v1/search/streamemits 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
pricePerPersonandpriceTotalare always populated, and neither is derivable from the other.offer.priceSourcesays which kind of price you are holding:"quote"is real and bookable, while"catalogue"is a weaker provider search price. See Prices.
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.
# 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:
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 session | Answers without one |
|---|---|
GET /v1/searchPOST /v1/searchGET /v1/search/streamGET /v1/search/planGET /v1/trendsGET /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 -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:
{
"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.
{
"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.
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 devTo 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.
| Call | Does | Answers |
|---|---|---|
GET /v1/console | Public. 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/session | Public. 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".
| Setting | Meaning |
|---|---|
GOOGLE_CLIENT_ID | The Google OAuth web client (…apps.googleusercontent.com). Unset: sign-in is off and the console routes follow the ordinary rule ("console": "open"). |
CONSOLE_EMAILS | Who may sign in, separated by commas or spaces. Default marcus@mzr.se. |
CONSOLE_SESSION_SECRET | Optional; signs the sessions. Unset, a key is derived from BOOKING_LINK_SECRET. Setting or changing it signs every session out. |
CLIENT_SECRETS_KEY | Optional, 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_READERS | Vercel 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
| Field | Type | Notes |
|---|---|---|
status | string | "ok" when the process is serving. |
version | string | The package version this build was cut from. |
uptimeMs | integer | Milliseconds since process start. A number that keeps resetting is a crash loop. |
node | string | Node 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 -sS http://localhost:3000/health{
"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
| Name | Type | Default | Description |
|---|---|---|---|
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 -sS 'http://localhost:3000/v1/reference/airports?market=se'{
"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
| Name | Type | Default | Description |
|---|---|---|---|
top | boolean | false |
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. |
country | ISO-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. |
limit | integer 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 -sS 'http://localhost:3000/v1/reference/destinations?top=true&country=ES'{
"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 -sS http://localhost:3000/v1/providers{
"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.
limit,
offset, budgetMs and totalBudgetMs are
clamped into range rather than rejected, because a page size should never fail a
search.
| Name | Type | Default | Description |
|---|---|---|---|
origin | IATA code, repeatable / comma | STO |
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. |
destination | destination 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. |
departFrom | date YYYY-MM-DD | — | Earliest acceptable departure, inclusive. Omit both dates for “whenever”. |
departTo | date YYYY-MM-DD | — | Latest acceptable departure, inclusive. |
nightsMin | integer 0–90 | — | Shortest acceptable trip, in nights, inclusive. nightsMin above nightsMax is a 400, as is departFrom after departTo. |
nightsMax | integer | — | Longest acceptable trip, inclusive. Setting both equal is the common case (“exactly a week”). |
adults | integer 1–9 | 2 |
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. |
childAges | comma-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. |
rooms | JSON 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. |
board | comma of RO BB HB FB AI | — | Canonical board codes: room only, bed & breakfast, half board, full board, all inclusive. Multiple values are OR-ed. |
starsMin | number 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”. |
scoreMin | number 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. |
priceMax | integer | — | 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. |
directOnly | boolean | false |
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. |
quote | boolean | 0-120 | true |
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. |
flights | boolean | true |
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. |
sort | enum | price_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. |
groupBy | none hotel trip | none |
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. |
groupMembers | integer 1–50 | 8 |
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. |
perProvider | integer 0–10 | 0 |
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. |
reuse | boolean | true |
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. |
weeks | cheapest | on-sale | all | cheapest |
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. |
limit | integer 1–1000 | 100 |
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. |
offset | integer ≥ 0 | 0 |
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. |
budgetMs | integer 500–60000 | 15000 |
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. |
totalBudgetMs | integer 500–120000 | 30000 |
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. |
front | front 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. |
providers | comma-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. |
currency | SEK NOK EUR DKK | SEK |
Currency of every price in the response. |
Example
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.
{
"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
}
}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
{
"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 -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
| Header | Value |
|---|---|
content-type | application/x-ndjson; charset=utf-8 |
cache-control | no-cache, no-transform |
x-accel-buffering | no — tells nginx and friends not to sit on the chunks and hand you the whole stream at once |
connection | keep-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.
| type | Payload | When |
|---|---|---|
query | query: the normalized SearchQueryplan: [{ 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. |
batch | provider, taskId, label, offers[], hotels[], ms | Once 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. |
task | trace: a TaskTrace | Once per planned task as it settles — including the ones that came back empty, failed, or were skipped when the budget ran out. |
priced | offers[], quoted, notes[] | Compatibility correction for clients that replace provisional batch rows by offer.id. New clients should adopt the following page event wholesale. |
page | The same query, meta, offers, hotels, facets and trace shape as GET /v1/search | Once, 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. |
done | facetstrace: { providers[], ms, plannedTasks, executedTasks }stats: { offers, hotels, duplicates, truncated }priceQuoted, priceQuoteNotesscope, 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. |
reused | searchId, ageSec, searchedAt | Only on a search answered from a kept one, right after query: no batches or tasks follow, then page and done. |
error | error: { 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. |
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.
page event replaces
that provisional model with the exact GET-equivalent page, including quote-window
price ranking and paging.
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 -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.
{"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
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.
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
| Field | Meaning |
|---|---|
query, scope | The 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. |
quoteReach | How 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. |
matrix | Whether steering was read, and the weeks it would let the search skip; the same shape as a search's trace.providers[].matrix. |
notes | Plain words on the estimate's limits for this query. |
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'{
"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.
| Call | Does | Answers |
|---|---|---|
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}/details | The 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". |
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.
id=$(curl -sS 'http://localhost:3000/v1/search?origin=MMX&destination=es.gran-canaria&adults=2&childAges=0,4,6"e=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}'{
"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/trends
What is worth asking, for many destinations at once, before any search. From these airports, for this party: which providers fly to each place, and which weeks of the window came out cheapest, which had nothing on sale and which nobody has asked about yet. It reads the measured route sweeps and the price matrix, asks no provider, and is scoped to the caller's front like a search: a front's trends never mention a provider it does not search.
Name the destinations, or leave them out to get every place a provider in scope flies
from these airports, most-flown first. At most 120 routes (airports times
destinations) are read at once; more is 400 too_many_routes.
index ranks it against
the other weeks of the window for that destination: the cheapest week is 100 and 120
costs about a fifth more. It averages each provider's own curve (per airport, trip
length and party), so companies on different price bases are never compared by
number. Every price a traveller sees must come from a search.
none only when every
provider that could fly the route was asked about that whole week, for this party and a
trip length in range, recently, and had nothing on sale. A week anybody has not been
asked about is unknown, and so is a route with no sweep (Sembo has none
at all). basis says whose weeks the ranking used: this party's
(party), other parties' when this one has none (other_parties),
or nobody's (none).
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
origin | IATA code(s), comma-joined | required | Departure airports. A group such as STO reads every airport in it. There is no default: trends for an airport nobody named answer a question nobody asked. |
destination | destination id(s), comma-joined | every flown place | Names and aliases resolve as on search. |
departFrom, departTo | YYYY-MM-DD | tomorrow, +26 weeks | The window, in whole Monday weeks; at most 53. |
adults, childAges | as on search | 2 adults | The party, because ages change prices. rooms works as on search. |
nights | integer 1–35, or short | week | fortnight | long | 6–10 | Trip length; nightsMin/nightsMax also work. |
front, providers | as on search | — | Scope, with the same refusals: an unknown front is a 400, never every provider. |
Response
| Field | Meaning |
|---|---|
matrix | { enabled, read, store }. read: false means the matrix could not be read in time: routes come from the sweeps alone, every week is unknown, and notes says why. |
destinations[].routes[] | One per provider in scope: live (the sweep found trips from from), empty (the sweep found none: a prior, since charter routes are seasonal), unknown (no sweep from here) or unmapped (the provider cannot search this place). sweptAt dates the sweep, seenAt the last time a search or fill saw trips. |
destinations[].weeks[] | Every Monday of the window: status trips | none | unknown, index (or null), curves (how many provider curves the index averages), trips and none (which providers, and none at which trip length), unknown (providers that could fly it and have no say on that week), checkedAt, fresh. |
destinations[].ranked | The weeks with an index, cheapest first. |
destinations[].counts | { ranked, trips, none, unknown }: how much of the window is known. |
unswept | Only without destination: places a provider in scope can search from these airports but has no sweep for, and nobody has searched yet. Not read. On a Sembo front this is nearly every place, because Sembo has no sweep: unknown, never "not flown". Name them as destination to read their weeks. |
curl -sS 'http://localhost:3000/v1/trends?origin=MMX&destination=es.gran-canaria&adults=2&childAges=0,4,6&departFrom=2026-11-05&departTo=2027-01-15&nights=7'{
"matrix": { "enabled": true, "read": true, "store": "redis" },
"query": { "origins": ["MMX"], "destinations": ["es.gran-canaria"], "departFrom": "2026-11-05",
"departTo": "2027-01-15", "party": "2+0,4,6", "nightsMin": 7, "nightsMax": 7 },
"destinations": [
{
"id": "es.gran-canaria", "name": "Gran Canaria",
"routes": [
{ "provider": "sembo", "status": "unknown", "from": [], "sweptAt": null, "seenAt": null },
{ "provider": "solresor", "status": "live", "from": ["MMX"], "sweptAt": "2026-08-29", "seenAt": "2026-10-29" },
{ "provider": "ving", "status": "live", "from": ["MMX"], "sweptAt": "2026-10-12", "seenAt": "2026-10-29" },
{ "provider": "nazar", "status": "unmapped", "from": [], "sweptAt": "2026-08-29", "seenAt": null }
],
"basis": "party",
"weeks": [
{ "week": "2026-11-02", "status": "unknown", "index": null, "curves": 0, "trips": [], "none": [],
"unknown": ["sembo", "solresor", "ving"], "checkedAt": null, "fresh": false },
{ "week": "2026-11-09", "status": "trips", "index": 100, "curves": 2, "trips": ["solresor", "ving"], "none": [],
"unknown": ["sembo"], "checkedAt": "2026-10-29T17:17:12.000Z", "fresh": true },
{ "week": "2026-11-16", "status": "trips", "index": 118, "curves": 2, "…": "…" }
],
"ranked": ["2026-11-09", "2026-11-16"],
"counts": { "ranked": 2, "trips": 2, "none": 0, "unknown": 9 }
}
],
"unswept": [],
"notes": ["index ranks the weeks of the window for each destination: …"]
}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.
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.
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
| Name | Type | Default | Description |
|---|---|---|---|
origin | IATA code | — | Departure airport. A group such as STO reads every airport in it, one row each. Omit together with destination to list routes. |
destination | destination id | — | One destination; names and aliases resolve as on search. |
adults | integer 1–9 | — | Show only this party. Without any party parameter, every party recorded is shown. |
childAges | comma-joined integers 0–17 | — | The party's children, every age. rooms works as on search. |
nights | short | 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 field | Meaning |
|---|---|
status | trips, or none as above. |
index, typicalIndex | The 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, departDays | What the week held: distinct hotels, offers, and the weekdays with a departure. |
wholeWeek, nightsAsked | Whether 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. |
fresh | Within its window: 2 days for the next 6 weeks, 7 days beyond, 7 days for none. Cells are kept 30 days. |
quoted, checkedAt, source | Whether the cheapest was a quote, when the cell was written, and by a search or a fill. |
curl -sS 'http://localhost:3000/v1/matrix?origin=ARN&destination=es.gran-canaria&adults=2&nights=week'{
"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
{
"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 -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.
| Field | Type | Meaning |
|---|---|---|
id | string | Permanent slug, 2–40 of a-z 0-9 -. Booking links and the click ledger carry it. resolve is reserved. |
name | string | The 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. |
hosts | string[] | 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. |
client | string | null | The 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. |
builtIn | boolean | True for house alone (Onyx, our own brand, every provider): it always exists and cannot be deleted. |
createdAt, updatedAt | ISO time | Set 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 -sS http://localhost:3000/v1/fronts # with sign-in on: -H "Authorization: Bearer $CONSOLE_SESSION"{
"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
| Name | Type | Default | Description |
|---|---|---|---|
host | hostname | — | Required. The Host the visitor used. |
/v1/fronts/{id}
| Call | Does | Answers |
|---|---|---|
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}/ai | For 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=30 | Booking 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 -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{
"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).
| Field | Type | Meaning |
|---|---|---|
id, name | string | Permanent slug (same rules as a front's), and the company's name. |
builtIn | boolean | True for travelfront alone: us, the client our own fronts belong to. |
providers | id[] | The providers that are this company. One provider belongs to at most one client (409 provider_taken). |
website | https URL | null | The 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 } | null | Read-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. |
| Call | Does | Answers |
|---|---|---|
GET /v1/clients | Every 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-key | Body { "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-key | Remove it: the client's fronts go back to our key. | The client. |
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.
| Call | Does | Answers |
|---|---|---|
GET /v1/clients/{id}/brand | The 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}/brand | Console. 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}/brand | Console. 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 -sS http://localhost:3000/openapi.json | jq '.paths | keys'[
"/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.
| Field | Type | Description |
|---|---|---|
id | string | Stable 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. |
hotelId | string | Key into the response's hotels dictionary. Shaped provider:rawId. |
destinationId | string | null | Our resolved destination, e.g. es.mallorca. null when the arrival airport could not be mapped — the offer is still valid, just unclassified. |
origin | IATA | The real departure airport, not the group code that was searched. A STO search yields ARN, NYO or BMA. |
destination | IATA | Arrival airport. |
pricePerPerson | number | Per-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. |
priceTotal | integer | The 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. |
currency | SEK NOK EUR DKK | Currency 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. |
departDate | EpochDay | Outbound date, as days since 1970-01-01 UTC. |
returnDate | EpochDay | Return date. |
nights | integer | Trip length. Not always returnDate - departDate once overnight flights are involved, so use this field. |
board | RO BB HB FB AI | Canonical board code. Providers spell these about forty different ways; normalization happens on ingest. |
boardLabel | string | null | The provider's own wording, kept for display. null when it gave none. |
roomId | string | null | Provider 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. |
roomName | string | null | Display name of the room, in the market's language. null for Sunweb, same reason. |
outbound | FlightLeg | null | null 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. |
inbound | FlightLeg | null | Same. |
direct | boolean | null | true 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. |
overnight | boolean | null | True 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. |
refundable | boolean | null | Whether 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. |
deeplink | string | null | First-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.
| Field | Type | Description |
|---|---|---|
id | string | provider:rawId, e.g. tripx:12508. This is the dictionary key. |
provider | "tripx" | "sembo" | "sunweb" | "nazar" | "solresor" | "ving" | "tui" | Source adapter. |
name | string | Hotel name as the provider spells it. |
stars | number | null | 0–5. Halves are allowed and do occur. |
score | number | null | Guest score normalized to 0–100 across providers, so a 0–10 source and a 0–5 source are comparable. |
reviewCount | integer | null | How much weight the score deserves. A 94 from 6 reviews is not a 94. |
lat / lng | number | null | WGS84 decimal degrees. |
city | string | null | City or resort. |
area | string | null | The provider's own area label, kept raw. Our classification is offer.destinationId. |
country | string | null | Country name in the market's language. |
images | string[] | Absolute URLs, best first. Can be empty — render a placeholder, do not break. |
description | string | null | Plain text, market language. |
facilities | string[] | Normalized facility labels. Can be empty. |
distanceToBeach | integer | null | Metres, where known. |
distanceToCentre | integer | null | Metres, where known. |
FlightLeg
| Field | Type | Description |
|---|---|---|
from | IATA | Departure airport of this leg. |
to | IATA | Arrival airport. |
departsAt | string | Local 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. |
arrivesAt | string | Local ISO date-time at the arrival airport. |
stops | integer | Stops on this leg. 0 is non-stop. |
carriers | string[] | Operating airlines, in order. More than one means the leg is a connection. |
flightNumbers | string[] | 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.
| Field | Type | Description |
|---|---|---|
ms | integer | Wall 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. |
searchMs | integer | The provider fan-out alone. This is what ms used to be, which is why both are published. |
quoteMs | integer | The 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. |
flightMs | integer | The 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. |
totalMs | integer | Fan-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. |
budgetMs | integer | The total budget that governed this request (totalBudgetMs), not the fan-out's budgetMs. Read totalMs against it. |
deadlineExceeded | boolean | true 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. |
total | integer | Rows that matched after filtering — not the size of offers. This is the number facets describe. |
returned | integer | Offers on this page. Less than limit at the end of the result set. |
offset | integer | Where this page starts. Clamped to total, so an over-large offset gives an empty page rather than an error. |
hotels | integer | Distinct hotels across the whole collected set — not the size of the hotels dictionary, which only covers this page. |
truncated | boolean | A provider hit its per-call ceiling, or the collector hit its own row cap. More inventory exists than you were shown. |
duplicates | integer | Offers dropped during the merge because an identical offer.id had already arrived. |
sort | SortKey | The sort actually applied. |
priceQuoted | integer | How many offers on this page carry priceSource: "quote". Less than returned means the rest are browse prices — priceQuoteNotes says why. |
priceQuoteNotes | string[] | 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. |
perProvider | integer | The 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, handle | string | 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 } | null | Set 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. |
quoteWindow | integer | Offers 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. |
flightEnriched | integer | How 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. |
flightEnrichmentNotes | string[] | 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. |
flightEnrichmentComplete | boolean | false 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.
| Field | Type | Description |
|---|---|---|
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 / priceMax | integer | null | Total 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 / departTo | EpochDay | null | Actual 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”.
| Field | Type | Description |
|---|---|---|
by | "hotel" | "trip" | Which grouping produced this block. |
total | integer | Distinct groups over the whole filtered result set. meta.total still counts offers. |
returned / offset | integer | Groups on this page, and where it starts. limit/offset page groups whenever grouping is on. |
membersLimit | integer | The groupMembers cap that produced each group's offerIds. |
truncated | integer | How 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. |
items | OfferGroup[] | The groups themselves. |
notes | string[] | 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.
| Field | Type | Description |
|---|---|---|
key | string | Stable across runs and pages, derived from the data rather than iteration order. Safe as a client-side identity. |
names | string[] | 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 / providers | string[] / ProviderId[] | Members' hotel ids and providers, cheapest first. Resolve hotelIds against the response's hotels dictionary. |
destinationId | string | null | Our destination, from the group's cheapest member. |
members | integer | Members across the whole filtered result set — not the number serialised. |
offerIds | string[] | Members actually returned in offers[], cheapest first. |
truncated | boolean | true when offerIds.length < members. |
cheapestOfferId | string | The 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 / pricePerPersonMin | number | Across 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. |
priceQuoted | integer | Members carrying priceSource: "quote", out of members. |
departFrom / departTo / nightsMin / nightsMax | EpochDay / integer | The span the group collapsed. Equal at both ends on a trip group, by construction. |
byProvider | object[] | 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. |
spread | object | null | The 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. |
spreadNote | string | Why 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”. |
match | object | basis, 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.
| Field | Type | Description |
|---|---|---|
provider | "tripx" | "sembo" | "sunweb" | "nazar" | "solresor" | "ving" | "tui" | Which adapter. |
planned | integer | Upstream calls the planner decided on. Planning is pure and synchronous, so this number exists before anything is spent. |
executed | integer | Calls actually made. executed < planned means the budget ran out — the difference is missing coverage. |
offers | integer | Offers this provider contributed, before merge-level de-duplication. |
ms | integer | Sum 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. |
wallMs | integer | absent | Elapsed 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 } | absent | Present 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 } | absent | Inputs 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 } | absent | Departure 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. |
tasks | TaskTrace[] | Per-call detail. |
TaskTrace
| Field | Type | Description |
|---|---|---|
taskId | string | Matches the taskId on batch events in the stream. |
provider | "tripx" | "sembo" | "sunweb" | "nazar" | "solresor" | "ving" | "tui" | Owner. |
label | string | Human-readable, e.g. "STO->PMI". This is what tells you which route is missing when a task fails. |
ms | integer | The upstream call itself, start to finish. Excludes time spent queued. |
waitMs | integer | absent | How 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. |
offers | integer | Rows this call produced. |
status | ok | empty | error | skipped | empty is a successful call with no inventory. skipped means the budget expired before it started. |
error | { kind, message } | absent | See 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.
| Field | Type | Description |
|---|---|---|
market | "se" | "no" | Resolved market. |
currency | SEK NOK EUR DKK | Resolved currency. |
origins | IATA[] | Always an array, even for one origin. |
destinations | DestinationId[] | Empty array = anywhere. |
rooms | { adults, childrenAges[] }[] | Party composition. The child count is always childrenAges.length. |
departFrom / departTo | EpochDay? | Inclusive window. Absent means “whenever”. |
nightsMin / nightsMax | number? | Inclusive range. |
boards | BoardCode[]? | Post-filter; a provider may additionally push it upstream. |
starsMin / scoreMin / priceMax | number? | 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. |
directOnly | boolean? | Post-filter. |
budgetMs | integer | Hard 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.
| Field | Type | Description |
|---|---|---|
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. |
maxResultsPerCall | integer | null | Ceiling 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. |
paginates | boolean | Whether 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. |
concurrency | integer | Max 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
| Type | Definition |
|---|---|
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. |
IataCode | Three uppercase letters. May be a city/group code (STO, LON, PAR) rather than an airport. |
DestinationId | Our own stable, provider-independent id: es.mallorca, tr.antalya. |
EpochDay | Integer days since 1970-01-01 UTC. |
SortKey | price_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.
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.
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.
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 / board | 1 adult | 2 adults | 3 adults | 4 adults | 2 ad + 2 ch |
|---|---|---|---|---|---|
| 8357 / 27643 / RO | 8415 | 5929 | 5771 | 5827 | 4736 |
| 11335 / 27702 / RO | 9669 | 6029 | 5775 | 5778 | 4842 |
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.
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();
}| Signal | Means |
|---|---|
offers: [], no error anywhere | Genuinely empty market for that query. Trustworthy. |
trace.providers[].error present | Provider 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 < plannedTasks | The budget expired before the fan-out finished. Raise budgetMs or narrow the query. |
meta.truncated === true | A provider hit its per-call ceiling. More inventory exists than you were shown. |
trace.providers[].matrix.skipped present | The 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 true | The 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.returned | Some 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
maxResultsPerCallceiling 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
totalResultsis authoritative and uncapped. Ving's 250 pages the same way, but itscountis 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.
quotesanswers “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.
| Field | Type | Description |
|---|---|---|
error | string | Machine-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. |
message | string | Human-readable, and specific — it names the term it could not resolve. |
statusCode | integer | Mirrors the HTTP status. |
requestId | string | Same value as the x-request-id header. |
field | string? | On a 400: which parameter was rejected. |
term | string? | 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. |
kind | string? | On a provider failure that sank the whole search: the error kind. |
curl -sS 'http://localhost:3000/v1/search?destination=mallorka'{
"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
| Status | When |
|---|---|
200 | The request was served. This includes searches where a provider failed — check trace. |
302 | /docs → /docs/. |
400 | The 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. |
401 | A guarded route without a valid API key, Vercel token or console session, on a server where the check is on. See Authentication. |
403 | A search for a paused front (front_paused). |
404 | No 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 412 | A 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). |
503 | The 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. |
429 | An app past its daily search ceiling (quota_exceeded, with Retry-After). |
429 502 504 | A 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. |
499 | The client disconnected and the aggregation was cancelled. |
500 | The 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.
| kind | Meaning | Usually |
|---|---|---|
timeout | The upstream call did not answer inside its budget. | Retryable |
rate_limited | The provider pushed back — HTTP 429 or equivalent. | Retryable, with backoff |
upstream_5xx | The provider's backend errored. | Retryable |
upstream_4xx | The provider rejected the request. Usually our bug, not theirs. | Not retryable |
degraded | HTTP 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_wall | A challenge or block page instead of data. | Not retryable |
malformed | The response did not parse, or parsed into something the adapter cannot map. | Not retryable |
cancelled | Aborted by the budget or by the caller disconnecting. Expected, not a fault. | — |
unknown | Anything 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.