# Building a client for the Travelfront API

Read this before writing any code that calls this API from another app: a website, a
mobile app's backend, a script, an internal tool. It says how to get access, what a
correct client does with each response, and the mistakes this API was designed to make
visible. It is written to be handed to a coding agent as-is.

The full reference lives next to this file and is served by every deployment:

| | |
|---|---|
| `/docs` | Human API reference with a live "Try it" panel per endpoint |
| `/openapi.json` | OpenAPI 3.1. Generate types from it rather than writing them by hand |
| `/docs/CLIENTS.md` | This guide |

Production is `https://api.travelfront.io` (since 2026-10-29). Its Vercel addresses,
`https://travelfront-api.vercel.app` and, from when the API was called trip-aggregator,
`https://trip-aggregator.vercel.app`, still serve the same deployment, so booking links minted
on either keep working. A local server is
`http://127.0.0.1:3000` (`npm run dev` in this repo).

---

## 1. Access: our own apps only

The API answers only our own apps, and each app proves who it is one of two ways:

- **An app of ours on Vercel** (Onyx) sends the token Vercel gives every function request
  (the `x-vercel-oidc-token` header on its own incoming request) as `Authorization: Bearer
  <token>`. Never under Vercel's header name: Vercel overwrites that header on every request
  into the API with the API's own token, so it never arrives. The API checks Vercel's signature, our team, and that the
  project is on its `API_APPS` list (default: Onyx's) and the deployment is production, or
  development (the token `vercel env pull` writes for a team member's laptop). A preview is
  never let in. There is no secret to mint, copy or rotate.
- **Any other app** gets its own API key, below. The key names the app in the server's
  logs, and it can be revoked without touching any other client.

A person signed in to the console (the Travelfront Portal, or this API's own test console)
gets in by their console session (section 2b), which works wherever a key would.

**Getting a key.** Keys are minted by whoever runs the server (Marcus), never by the
client: `npm run api-key -- <client-name>` in this repo prints the key once plus a
`<client-name>:<sha256>` entry, and the entry is appended to the `API_KEY_HASHES`
environment variable in Vercel, followed by a redeploy. Ask for a key with a lowercase
name such as `ios-app` or `partner-web`. The server keeps only the hash, so a lost key
cannot be recovered; mint a new one.

**Sending it.** On every search request:

```
Authorization: Bearer tak_…
```

Never in a query string or a URL (they end up in logs and browser history; the API
ignores a key there anyway), and never committed to a repo.

**Which routes need it.** The search, matrix and fronts routes. Everything a client needs
before its first search answers without a key.

| Needs an app (key or Vercel token) or a console session | Answers without one |
|---|---|
| `GET /v1/search`, `POST /v1/search`, `GET /v1/search/stream` | `/health`, `/openapi.json`, `/docs` |
| `/v1/matrix`, `/v1/fronts`, `/v1/clients` and everything under them | `/v1/reference/airports`, `/v1/reference/destinations`, `/v1/providers` |
| | `/go/…` booking links (opened by your users' browsers), `/v1/console` and `POST /v1/console/session` |

The fronts console's own routes (listing fronts, writing them, their clicks, and every
`/v1/clients` route) are the exception once console sign-in is on: they need a console
session, not a key (see the end of section 2b). `GET /v1/fronts/<id>/ai` also needs the
caller's Vercel OIDC token (section 2c).

**Whether the check is on.** `GET /health` returns `"auth": "required"` or
`"auth": "off"`. `API_AUTH=required` switches it on, and so does a non-empty
`API_KEY_HASHES`; production runs with `required` since 2026-10-29. With `off` the server
enforces nothing, so a client that sends its credential anyway works against both, and
that is what a client should do.

**Where the key lives.** On a server you control, in an environment variable (name it
`TRAVELFRONT_API_KEY`). Anything shipped to a browser or inside a mobile app binary
can be read by its users, so a key there is a public key. A public website or an app
calls **its own backend**, and that backend calls this API with the key. A browser may
call the API directly only for internal tools whose users may hold the key; CORS allows
any origin and the `Authorization` header, so that works technically.

**401 handling.** The body says which case it is; none is worth retrying.

| `error` | Meaning | What the client does |
|---|---|---|
| `missing_api_key` | Neither a key, a console session nor a Vercel token was sent | Configuration bug: the credential is not loaded |
| `invalid_api_key` | A header was sent, but it is not `Bearer <key>` or the key is unknown (mistyped or revoked) | Alert an operator; stop calling until the key is replaced |
| `invalid_app_token` | A Vercel token was sent and did not hold: forged, expired, another team, a project not on `API_APPS`, or a preview. The message says which | Configuration bug: check the project and environment |

A Vercel token that cannot be checked because Vercel's signing keys could not be fetched
is a `503 oidc_unavailable`; that one is worth a retry after a few seconds.

**The daily search ceiling.** Each app may start a set number of searches per Stockholm
day (`API_DAILY_SEARCHES`, default 1000 for every app), counted when a search starts.
Past it, the three search routes answer `429 quota_exceeded` with `retry-after` in
seconds until midnight in Stockholm, and run nothing. It is a brake on a leaked credential
or a scripted client, set far above a real day: tell the user searching is paused for
today, do not retry before `retry-after`, and alert an operator. Console sessions are not
counted, and every other route is free.

---

## 2. The calls a client makes

A typical client needs four.

1. **Reference tables, once at startup** (no key; cache them for the session):
   `GET /v1/reference/airports?market=se` and `GET /v1/reference/destinations?market=se`.
   Use the ids they return: an airport is an IATA code or a city group (`STO` is every
   Stockholm airport), a destination is **our** id such as `es.mallorca`, never a bare
   airport code or a provider's id.
2. **A search**: `GET /v1/search?...`, the same as a JSON body to `POST /v1/search`, or
   `GET /v1/search/stream?...` for results as they arrive (section 4).
3. **Rendering**: offers plus the `hotels` map (section 3).
4. **Booking**: send the user to the offer's `deeplink` (section 3).

Common search parameters (all optional; `/docs` and `/openapi.json` list every one):

| Parameter | Example | Notes |
|---|---|---|
| `origin` | `STO` or `STO,GOT` | Defaults to `STO` |
| `destination` | `es.mallorca,tr.antalya` | Defaults to the top 10 destinations, **not** everywhere |
| `departFrom`, `departTo` | `2026-11-01` | Inclusive departure window |
| `nightsMin`, `nightsMax` | `7` | Trip length |
| `adults`, `childAges` | `2`, `5,9` | Child ages at time of travel. `rooms` (JSON) for several rooms |
| `board` | `AI,HB` | `RO`, `BB`, `HB`, `FB`, `AI` |
| `starsMin`, `scoreMin`, `priceMax`, `directOnly` | | `priceMax` is for the **whole party** |
| `sort` | `price_asc` | Also `price_desc`, `depart_asc`, `score_desc`, `stars_desc`, `nights_asc` |
| `limit`, `offset` | `20`, `0` | Paging; clamped, never rejected |
| `quote` | `true` or `30` | Re-price the page at the providers' booking endpoints (default `true`: the 120 cheapest on a price sort). Slower, but bookable prices. A number N prices only the N cheapest by browse price and costs the companies a quarter as much at N=30 (§2d) |
| `reuse` | `true` | Answer from a search kept in the last 30 minutes that asked exactly this (default). `false` always asks the companies again (§2d) |
| `perProvider` | `3` | Every provider that found anything shows its N cheapest on the first page, still in price order. Without it one cheap provider can fill the page and hide the rest. `meta.perProvider` says what was applied |
| `front` | `sembo` | Search for one front: only its providers are asked, and its booking links are counted for it (section 2b) |
| `providers` | `sembo,tripx` | Narrow the search to these providers, within the front if one is named. Never widens |
| `weeks` | `cheapest` | Which departure weeks the price matrix may leave out (default `cheapest`). `on-sale` skips only weeks known to have nothing on sale; `all` searches every week. See §3 |

**Which providers run.** Seven are built; a server runs the ones its `ENABLED_PROVIDERS`
setting names, by default all seven. `GET /v1/providers` lists the running ones
under `providers` and the switched-off ones under `disabled`. A switched-off provider is
never searched, so it is simply absent from every trace: read that list rather than
hard-coding seven, and never tell a user a company "found nothing" when it is in
`disabled`.

**Timeouts.** A search fans out to every running provider and then re-prices the page. It takes
seconds, bounded by `totalBudgetMs` (default 30 000). Give the HTTP call at least 45 s,
show progress in the UI, and use the stream if users are waiting on it.

**The price matrix (optional).** `GET /v1/matrix?origin=ARN&destination=es.gran-canaria&adults=2`
returns what earlier searches learned about a route: per company, departure week, trip length
and party, whether it sells and how dear each week is against its other weeks. It asks no
provider, so it answers at once, and it is the cheap way to pick which weeks to search. Read it
this way:

- `index` ranks weeks within one row (its cheapest week is 100). It is **not a price** and
  rows of different companies are not comparable. Every price you show comes from a search.
- A week with no cell was never asked. Only `status: "none"` means nothing on sale, and only
  for that party and the one trip length in `nightsAsked`.
- `POST /v1/matrix/fill` with `origin`, `destination`, `week`, `nights` and the party runs one
  ordinary search of one week and records it. It costs what a search costs; call it once per
  week, never in a burst. A fill always asks its week, whatever the matrix already knows.

**Searches read the matrix too.** With `departFrom`, `departTo`, `nightsMin`, `nightsMax` and
a destination, a search leaves out departure weeks the matrix has recent evidence about, per
company: 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 company's 8 cheapest
known ones. A week the matrix does not know is always searched. Send `weeks=all` when you need
every week of a long window, for example to prove a price is the cheapest of the season.

---

## 2a. Search less: trends, the dry run and call counts

Every search fans out to the travel companies: one search of a few weeks and places plans
tens of requests to them, a season across several airports a few hundred, and re-pricing
the page comes on top. They are our partners, and a client that searches on a traveller's
behalf should ask them only what is worth asking. Three things help, and none of them asks
any company.

**`GET /v1/trends` before a search.** Takes `origin` (required), `destination` (a list, or
none for every place flown from these airports), `departFrom`/`departTo`, the party, the
trip length (`nights` as a number or `short`/`week`/`fortnight`/`long`, or `nightsMin`/
`nightsMax`; default 6-10) and `front`. Per destination it says:

- `routes`: per provider in scope, `live` (its route sweep found trips from one of these
  airports, or a search since saw it sell there), `empty` (the sweep found nothing and no
  search has seen it since: a prior, because charter is seasonal), `unknown` (never swept
  from these airports; Sembo has no sweep at all), or `unmapped` (cannot be asked at all).
  Leave out a destination no provider flies, never one that is only `unknown`.
- `weeks`: each week of the window with `status` `trips`, `none` or `unknown`, and an
  `index` where the matrix can rank it (cheapest week of the window = 100, averaged over each
  provider's own curve). `ranked` lists the ranked weeks cheapest first; `basis` says whether
  this party's prices rank them or other parties' (then trust the order more than the gaps).
- `none` means every provider that could fly the route answered that whole week for this
  party with nothing on sale in the last 7 days. `unknown` is never empty: nobody asked.

Without `destination`, `destinations` holds the places a sweep or a search says are flown,
most-flown first, and `unswept` names the places a provider in scope can search from these
airports but has no sweep for and nobody has searched yet. On a Sembo front that is nearly
every place, because Sembo has no sweep: read `unswept` as unknown, never as "not flown", and
name the ones that matter as `destination` to read their weeks. At most 120 routes (airports x
destinations) are read at once; more is `400 too_many_routes`.

The index is **not a price**. It is for choosing which weeks to search: in the 2026-10-19
test the 8 cheapest-ranked of 26 weeks held a hotel's cheapest week 83% of the time. Every
price you show still comes from a search. Trends need the same access as a search (they say
what has been searched) and are scoped by `front` the same way: an unknown front is a 400.

**`GET /v1/search/plan` before an expensive search.** Takes exactly the search parameters and
returns the plan that search would run, after the front's scope and the matrix's steering,
without sending anything: per provider its `tasks`, the weeks the matrix would skip, what the
task cap would drop, and the first 60 task labels. `calls.search` is a floor (Sunweb, Ving and
TUI page, so one task can be several requests, and a retry is another); `calls.quoteMax` is a
ceiling on what re-pricing the page could cost. It spends none of an app's daily searches.
Use it to keep a budget: narrow the weeks, places, airports or trip lengths of a search that
plans too many.

**`meta.calls` after a search.** Every search response, and the stream's `done` line, says
what the search really sent the companies, counted on the wire:
`{ search, quote, flights, total, byProvider: { <id>: { search, quote, flights } } }`. One HTTP
request or WebSocket opened to a provider is one call; a retry or a page is another; a request
that two searches shared while it was in flight counts once, for the search that sent it. The
trace's `plannedTasks` and `executedTasks` count tasks, not calls. Log `meta.calls` per search
and per conversation: it is the number a provider sees.

---

## 2b. Fronts: searching for one branded site

The chat product runs as several **fronts**: branded sites that each search only the
providers chosen for them (an agency's own front searches only that agency; our own fronts
compare several or all). The API owns the list, so a client never decides a front's scope
itself: it names the front and the API decides which providers that means.

- **Which front.** `GET /v1/fronts/resolve?host=<hostname>` answers the front a hostname
  opens (port and case ignored), or 404 `front_not_found` when no front lists it.
  `GET /v1/fronts/<id>` answers one front by id. Both return the front itself:
  `{id, name, kind, providers ("all" or a list), hosts, status, client, branding {accent,
  logoUrl, tagline, language}, attribution, builtIn, createdAt, updatedAt, effective}`.
  **Render from `effective`**: `{status, closedBy, branding, brand, attribution, ai}` is what
  the front runs with once its client's settings are filled in (section 2c); `brand` is its
  client's whole brand profile, or `null` (see *A client's brand* below). `status` and
  `branding` beside it are the front's own settings, which the console edits. In
  `effective.branding`, `accent`, `logoUrl` and `tagline` can be `null`, meaning "use your
  default". The built-in front `house` (today's brand, every provider) always exists. A
  front with `effective.status: "paused"` is closed: show that, and do not search.
  `closedBy` says why: `front_paused`, or its client has no access to Onyx
  (`no_product_access`) or no longer exists (`client_missing`).
- **Search for it.** Send `front=<id>` on every search (GET, POST or stream). Only its
  providers are asked, and every booking link in the answer carries the front, so the
  traveller's click is counted for it.
- **Check what was searched.** Every search response has `meta.scope`:
  `{front, providers, excluded: {notInFront, disabled, notRequested, notInMarket}}`.
  `providers` were asked; each other provider is in exactly one `excluded` list saying why
  not. A client that sent `front` must refuse to show results unless `meta.scope.front`
  equals the id it sent: an API that predates fronts ignores the parameter and answers for
  every provider, which would show one agency's customers another agency's prices.
- **Refusals, never a wider search.** An unknown front is 400 `unknown_front`
  (`field: "front"`, with `suggestions`); a paused one 403 `front_paused`; a front none of
  whose providers this server runs 503 `front_unavailable`; a fronts store that cannot be
  read 503 `fronts_unavailable`. `providers` that names an unknown id, nothing, or nobody
  left to ask is a 400 (`unknown_provider`, `empty_scope`). A JSON `null` for either is a 400
  too, never "none", and so is the misspelling `provider`. None of these is ever answered by
  searching every provider, and a client must not fall back to that either.
- **"Nothing found" on a front is about its providers only.** On a one-provider front an
  empty answer means that company has nothing; say so in those words, not "nothing on sale".
- `GET /v1/providers?front=<id>` marks each provider `inScope` and returns the same `scope`
  a search would.

The console (the Travelfront Portal) manages fronts with `GET /v1/fronts`, `PUT /v1/fronts/<id>`
(create or replace; 400s name the field as a dotted path such as `hosts.1`, 409
`host_taken` when another front lists the host, `If-None-Match: *` to create only),
`DELETE /v1/fronts/<id>` (not `house`), `PUT` with `client` naming a client (400
`unknown_client`, 409 `no_product_access` when it has no access to Onyx; `effective` may be
sent back as read and is ignored) and `GET /v1/fronts/<id>/clicks?days=30` (booking
pages opened, travellers who pressed Continue (`clicks`) and travellers the page sent on by
itself after two seconds (`autos`, since 2026-10-29), per Stockholm day and provider; a deleted
front's counts stay readable with `deleted: true`). `GET /v1/fronts` and
`GET /v1/fronts/<id>` read the store on every call, so a front just saved shows at once;
`resolve` and searches use a copy up to 30 seconds old. Every answer says `store` and
`durable`: until Redis is connected the store is not durable, and a lost front fails
loudly as an unknown one.

**Console sign-in.** Once the server has `GOOGLE_CLIENT_ID` (`GET /health` says
`"console": "google"`), `GET /v1/fronts`, `PUT` and `DELETE /v1/fronts/<id>` and
`GET /v1/fronts/<id>/clicks`, and `POST /v1/matrix/fill` (which spends provider calls), need
`Authorization: Bearer tcs_…`, a console session; an API key does not stand in for one. `GET /v1/console` (public) answers `signIn`, the
`googleClientId` to draw Google's sign-in button with, and the session the request
carried. Send the button's `credential` to `POST /v1/console/session` (public) and keep
the `token` it answers, which lasts 7 days; only addresses on the server's
`CONSOLE_EMAILS` get one (`403 not_allowed` otherwise). On a console route, `401
sign_in_required`, `session_expired` or `invalid_session` means sign in again; `403
not_allowed` means the address was taken off the list. A session also works wherever an
API key would. `resolve` and `GET /v1/fronts/<id>`, which a chat front needs, are not
console routes.

---

## 2c. Clients: the companies behind the fronts, and their AI keys

A **client** is a travel company we partner with (`/v1/clients`, console routes only):
`{id, name, providers, website, products, branding {accent, logoUrl}, attribution, ai,
brand, builtIn, createdAt, updatedAt}`. `brand` is a read-only summary `{name, updatedAt,
scanId}` of its brand profile, or `null` (below).

The hierarchy is **client > product > the product's items**. A client has access to
`products` (today only `onyx`); Onyx's items are fronts, and every front says so in its
read-only `product: "onyx"`. `GET /v1/clients` lists each product with `items: {name,
plural, path}` (Onyx: `front`, `fronts`, `/v1/fronts`), so a product added later brings
its own collection, each item naming its `client` and `product`. Every front has a client:
a front's `client` names it, and `null` means ours, which belong to the built-in client
`travelfront` (us). It is listed first, has every product, owns no provider, runs on our
own AI key, and refuses every write (409 `client_builtin`); a front written with `client:
"travelfront"` is stored as `null`.

Its settings reach the fronts two ways:

- **By provider, everywhere.** A client owns providers (one provider belongs to at most one
  client), and its `attribution` (an affiliate id, say) goes on every booking link to them,
  from its own fronts, from our house front comparing everyone, and from a search that
  named no front. A front's own `attribution` for the same provider is added on top and wins
  a clash. Nothing to do on the client side: `/go/…/continue` adds them.
- **By front, on its own.** A front with `client` set is the client's own: its brand fills
  the front's empty `accent` and `logoUrl` and arrives whole as `effective.brand`, its AI key
  runs the front's chat, and the front opens only while the client's `products` include
  `onyx`. All of it is already applied in the front's `effective` (section 2b).

Console calls: `GET /v1/clients` (us first, then by name, with the `products` a client can be given),
`GET`/`PUT`/`DELETE /v1/clients/<id>` (409 `provider_taken`; `If-None-Match: *` to create
only; a client with fronts cannot be deleted, 409 `client_has_fronts`), and the AI key's
own write-only routes, `PUT /v1/clients/<id>/ai-key` with `{"key": "sk-ant-…"}` and
`DELETE /v1/clients/<id>/ai-key`. The key is checked with Anthropic (`400
ai_key_rejected`), sealed with AES-256-GCM under a key only the API holds, and never
answered: a client shows `ai.key: {hint, setAt, verified, readable}` or `null`. Setting or
removing one is refused (409 `sign_in_required_for_keys`) while console sign-in is off.

### A client's brand, for any product

A client can have a **brand profile**: its brand in product-neutral terms, read from its
website by a scan and applied by a person in the console. It describes the company, not any
product's skin, so every product draws from the same record its own way. Read it at
`GET /v1/clients/<id>/brand` (an ordinary `/v1` route: your app's credentials, not a console
session), which answers `{client, brand, store, durable}` with `brand: null` when the client
has none. A product item carries it too: a front's `effective.brand` is the client's profile
on the client's own fronts while the client has the product, and `null` on our own fronts,
on `house` and on a client without access. A product added later gets its items' brand the
same way.

The profile (`BrandProfile` in `/openapi.json`):

| Part | What it says |
|---|---|
| `name` | How the brand writes its own name. |
| `colors` | By role, `#rrggbb`: `action` (its call-to-action fill) and `onAction`, `accent` or `null`, `background` as the site paints it, `surface`, `text`, `textMuted`, `line`, `header` (`{background, text}` or `null`), `more`. |
| `type` | `heading` and `body` fonts, weights (100-900), heading tracking (em) and case, body size (px) and line height. A font is `{family, use: {family, licence, files}, note, fallback}`. |
| `shape` | Corner radii in px (`control`, `card`, `photo`; 999 is a pill), `button` (`shape`, `fill`, `case`, `weight`), `border` width, `elevation`, `density`. |
| `logo` | `{url, onDark, mark, format, aspect}` or `null`. |
| `imagery` | A `style` sentence, `photos` from the brand's own site (best first), and the `treatment` it lays text over photos with. |
| `icons` | `{style, weight}` or `null`. |
| `voice` | `address.sv` (`du`/`ni`/`null`), `traits`, `summary`, `do`, `dont`, `words`, `emoji`, `exclamations`, `samples`, `claims`, `tagline`, `examples`. |
| `sources` | Where each value came from, by path. |
| `scan` | The scan it started as, or `null`. |

How to read it without the mistakes it is built to expose:

- **Unset is yours.** `sources` is keyed by path (`colors.action`, `type.heading`,
  `voice.traits`; 43 paths, the same ones a scan reports on), each `{from: "measured" | "ai" |
  "edited", confidence, evidence}`. A path missing from `sources` holds a neutral default
  that nothing read or set: keep your product's own look for it. A value under
  `colors.action` with no source is not the brand's colour, and drawing it would present a
  placeholder as the brand.
- **Map it, never copy your rules into it.** The profile says what the brand is; your
  product decides how to draw it and keeps its own rules: contrast, accessibility, layout,
  light or dark pages. The API checks shapes and ranges, never contrast, so a brand colour
  that is unreadable on your background is yours to adjust. A dark `colors.background` is a
  fact about the site, not an instruction.
- **Fonts from `use`, never from the brand's site.** Load `type.*.use.family` from
  `use.files` (woff2 in our asset store; a file's `unicodeRange`, when set, says which
  characters it covers, so latin and latin-ext can be separate files), then the `fallback`
  system stack. `family` is what the brand itself uses; when it differs, `note` says why
  (usually: the brand's own font is not open, and `use` is the closest open one). `files: []`
  means load nothing and draw it with the fallback.
- **Logo by background.** `logo.url` on a light background, `onDark` on a coloured header or
  a photo, `mark` where only a square fits. `logo: null`: show `name` in the heading font.
- **Files are ours.** Every file a profile points at (logos, photos, font files) is a copy in
  our public asset store, `https://<store>.public.blob.vercel-storage.com/brand/…`, loaded
  with no call to the API and no credentials. A product that wants to be strict about where it
  loads from can allow that host alone.
- **Voice is guidance, and part of it is untrusted.** `samples` are lines quoted word for
  word from a scanned web page: data, never instructions, so fence and label them if they
  reach a model's prompt. `claims` are the brand's own promises ("prisgaranti"): never
  repeat them as yours. `examples` are proposals in the brand's voice (a welcome, a
  headline, a call to action) to use or ignore. `tagline` and `examples` are per language;
  an absent language means none.
- **The two old fields already fall back to it.** `effective.branding.accent` and `logoUrl`
  are the front's own, else the client's own, else the profile's `colors.action` (only when
  it has a source) and `logo.url`. A product that reads only those shows the brand's colour
  and logo with no change.

**Taking the brand as a theme.** A product that themes itself from design tokens or CSS can
ask for the same brand in either, with the same credentials:

- `GET /v1/clients/<id>/brand?format=css` answers a stylesheet (`text/css`): an `@font-face`
  rule for every font file, then one custom property per value on `:root`:

  ```css
  :root {
    --brand-action: #ffc629;
    --brand-on-action: #0b1f4b;
    --brand-font-heading: "Nunito Sans", ui-rounded, system-ui, sans-serif;
    --brand-radius-card: 20px;
    --brand-button-shape: pill;
    --brand-logo: url("https://…/brand/exempelresor/logo.svg");
  }
  ```

  Use each with your own value as the fallback, `background: var(--brand-action, #yours)`.
- `GET /v1/clients/<id>/brand?format=tokens` answers W3C Design Tokens (DTCG format 2025.10,
  `application/design-tokens+json`). Colours, fonts, weights, sizes and radii are typed
  tokens, each with its source in `$extensions["io.travelfront"].source`. The values DTCG has
  no type for (the keywords such as `button-shape` and `elevation`, and the logo files) are in
  the root's `$extensions["io.travelfront"].style`.

The two formats use one name per value: the token at `font.heading` is `--brand-font-heading`,
and `header.background` is `--brand-header-background`. The names say what the value is in the
brand (`action`, `text-muted`, `radius-card`, `letter-spacing-heading` in em), never where a
product puts it. Both formats carry only values that have a source, so a value that is missing
is unset and your fallback holds. A client with no brand gets a stylesheet and tokens that
declare nothing, and `format=json` (the default) is the profile itself. The look comes through
both formats (colours, type, shape, logo, photo treatment, icons), but the voice and the photos
only come through the profile. Fetch the file on your server and serve it yourself, or inline
it: the call needs your credentials, so a browser cannot `<link>` it directly. The stylesheet
escapes every string from the profile, so it is safe inside a `<style>` element.

The console uploads a file for a profile with `POST /v1/clients/<id>/brand/assets`: JSON
`{kind, contentType, data}` with the file in base64 (the request may be 4.5 MB), where `kind`
is `logo`, `logoOnDark` or `mark` (SVG, PNG or WebP), `photo` (JPEG, PNG, WebP or AVIF) or `font`
(woff2, with `weight` and `style`, and `licence: "client"` confirming the client licenses it
for its fronts, else 400 `licence_required`). The bytes must be the type claimed
(`asset_mismatch`), an SVG is cleaned of anything that runs or reaches outside the file
(`svg_unsafe` if something survives), and the answer `201 {url, kind, contentType, size}` is
the URL to put in the profile. No asset store on the server is 503 `assets_unavailable`.

Console calls: `PUT /v1/clients/<id>/brand` with the whole profile (`sources` and `scan` may
be left out; `updatedAt` is set by the API; every other field is required and an unknown
one is refused, 400 `invalid_brand` or `unknown_field` with `field` as a dotted path such as
`colors.action`, `voice.do.2` or `sources.colors.action`; a `sources` key must be one of
the 43 paths, and an `edited` source has confidence 1), answering `201` the first time and
`200` after with the same shape as the GET; `DELETE /v1/clients/<id>/brand` (`204`, `404
brand_not_found`). `travelfront`, us, never has a brand (409 `client_builtin`), so our own
fronts and `house` always look like the product.

**Scanning a client's site** (console). `POST /v1/clients/<id>/brand/scans` with `{url?,
pages?}` (the client's `website` when `url` is left out; at most three extra `pages` on the
same site) starts a scan and answers `202` with it, `status: "running"`. A real browser reads
the site and proposes a profile; read it back at `GET /v1/clients/<id>/brand/scans/<scan>`
(about one to two minutes; `step` says where it is) until `status` is `done`, with
`proposal` (a whole `BrandProfile`, `scan` filled in), `missing` (every path the scan could
not read, with why: leave those to the person reviewing) and `notes`, or `failed`, with
`error.kind` `unreachable`, `refused`, `robots`, `timeout`, `not_html` or `internal`.
`ai` says whether the model's reading ran (`off` and `failed` still give every measured
value). `GET /v1/clients/<id>/brand/scans` lists the last 20, newest first; each is kept 30
days. A scan changes nothing: show the proposal beside its `pages[].screenshots`, let a
person edit it, and apply it with `PUT /v1/clients/<id>/brand` (mark what they changed as an
`edited` source). One scan runs at a time per client (409 `scan_running`), a day allows
`BRAND_SCANS_PER_DAY` across clients (429 `brand_scan_quota`); 400 `website_required`,
`invalid_url` or `page_off_site` (with `field`) for an address it will not scan, and 503
`scanner_unavailable` or `assets_unavailable` when this server cannot run one. How the
scanner reads a site, and what it does not do: `docs/BRAND-SCAN.md`.

**Using a client's key (Onyx only).** `GET /v1/fronts/<id>/ai` answers `{front, source:
"client" | "ours", client, provider: "anthropic", key}`. It answers only the production
deployment of a Vercel project listed in `AI_KEY_READERS` (Onyx's), which proves it by
sending its own Vercel OIDC token as `Authorization: Bearer <token>` (the token Vercel puts
in `x-vercel-oidc-token` on the caller's own request; never sent under that name, which
Vercel overwrites on the way in); it is also what passes the app check. With `source: "ours"` run the
chat on your own key. A key that is set but cannot be read is a 503 `ai_key_unreadable`:
show the traveller the chat is unavailable rather than quietly using your own key, which
would bill a client's traffic to us. Keep the key in memory only: never log it, store it or
send it to a browser.

---

## 2d. Search once: the handle, reuse, details

A search is the expensive part, so ask it once and read it many times. Three things make that
work, and none of them asks a company again unless it is told to.

**The handle.** Every search answer carries `meta.searchId` (for example
`s_3kQ9xV0bT1mZ8pLwYc2aDe`), and `meta.handle` says what it is worth:
`{ searchId, expiresAt, reusable, note, store, durable, bytes }`. The API keeps every offer the
search matched, not only the page, for **30 minutes**. `searchId` is `null` when it could not be
kept, and `note` says why; `durable` is true only when the store is Redis (elsewhere a restart
can lose it early). Then:

- `GET /v1/search/<searchId>?offset=20&limit=20&sort=…` is another page, another order, a
  filter or a grouping (`groupBy=hotel|trip`) of the same offers, with facets over the filtered
  set. It asks no company: `meta.calls.total` is 0. Filters may only **narrow** the search's own
  question: an origin, destination, date or trip length outside it, or a lower `starsMin`, is a
  `400 outside_search`; what the companies were asked (`adults`, `childAges`, `rooms`,
  `market`, `currency`, `weeks`) is a `400 fixed_by_search`. Search again for those.
  `directOnly=true` on a search that did not ask for it is `400 needs_search`.
- `quote` and `flights` are off on a page of a kept search unless you send them; with them
  only that page's offers are priced or given flight times, an offer already priced is never
  asked again, and what is learned is kept with the search for the next page and for whoever
  reads it next.
- Past 30 minutes, or for an id it never issued, the answer is `404 search_expired`. Search
  again from the question you kept: never show the old page as if it were current.
- Send the same `front` as the search. A handle opens only for a front whose providers cover
  every provider the search asked (`403 search_out_of_scope` otherwise), and its booking links
  are built for the front that opens it.

**Reuse.** A search asking exactly what one kept in the last half hour asked the companies
(same airports, places, dates, trip lengths, party, providers, `weeks`, and every filter one of
those providers takes upstream) is answered from it. Its answer says so: `meta.reused` is
`{ searchId, ageSec, searchedAt }`, `trace.reused.note` says no provider was asked, and the
provider traces are the kept search's. Show the age when it matters ("prices from 12 minutes
ago"). A search where a company failed, timed out, was cut by the deadline, or that hit the
offer ceiling is kept for its own pages and never reused, because reusing it would hide that
company. Send `reuse=false` to ask again, for instance when the traveller asks to refresh. The
stream answers a reused search as `query`, a `reused` line with the same three fields, then
`page` and `done`.

**Details.** `POST /v1/search/<searchId>/details` with `{"offerIds": ["…"], "front": "sembo"}`
(1-12 ids, `quote` and `flights` default true) prices those offers for the exact party and
fetches their flight times, both at once, from what the search kept about each offer (never
anything the caller sends). Call it for the trips a traveller opens or you recommend. An offer
that cannot be priced keeps its browse price and `meta.priceQuoteNotes` says why; that is a
200, not a failure. An id the search did not find is `400 unknown_offer` with
`field: "offerIds.N"`.

**`quote=N` on a search.** `quote=true` prices the 120 cheapest offers of a price sort, which
for a Ving family search is up to 240 requests to Ving. `quote=30` prices the 30 cheapest by
browse price, ranks them again on the bookable price, and leaves the rest on their browse
price (`priceSource: "catalogue"`, counted in `meta.priceQuoteNotes`). `meta.quoteWindow`
says how many were priced. Measured on 2026-10-29 on three family searches (2 adults and
children 0, 4 and 6, from Malmö and Copenhagen, winter, spring and summer): the cheapest 10
after pricing the first 10 were the cheapest 10 after pricing 120, in the same order, in all
three. Further down it moves more: a Ving hotel priced 40% too high by its browse price (an
infant priced as a five-year-old) climbed from 108th to 15th, so a list of 20 or more can
change. Price what you show: N for the list, then details for what is opened. Method and
numbers: `docs/measurements/quote-window-2026-10-29.md`.

---

## 3. Reading a search response correctly

The rules below are where clients go wrong. Each one exists because a number that
renders fine was wrong in a way nothing else would have shown.

- **An empty list is not "no holidays".** A provider that failed still returns HTTP 200;
  its failure is in `trace.providers[].error` (with a `kind` such as `timeout` or
  `bot_wall`). `trace.providers[].unsupported` lists inputs a provider could not search
  at all. Before telling a user "nothing found", check both: "nothing found" is only true
  when no provider has an error. Show partial results with a note when some failed.
  On production TUI always fails with `bot_wall` when it is switched on: TUI refuses
  Vercel's network. That is expected, not a client bug.
- **A skipped week is not an empty week.** `trace.providers[].matrix.skipped` lists the
  departure weeks the price matrix left out of that provider's search, each with a `reason`:
  `nothing_on_sale` (a recent whole-week search found nothing) or `dearer` (ranked outside
  the window's 8 cheapest weeks for that provider). A provider can then show `planned: 0`
  with no error, because every week was known to be empty. Never say a week was searched
  when it is listed there, and never call the result "the cheapest of the window" while any
  week was skipped as `dearer`; `trace.matrix.note` says whether the matrix steered at all.
- **Offers reference hotels.** Each offer has a `hotelId`; the hotel (name, stars, score,
  images, location) is in the response's `hotels` map under that id. Only hotels used by
  this page are included.
- **Prices.** Show `priceTotal` for the party, and `pricePerPerson` where a per-person
  figure is wanted. **Never compute one from the other**: infants are billed at zero and
  child fares differ, so `pricePerPerson × travellers` is wrong for many parties.
  `priceSource: "quote"` is a bookable price for this exact party; `"catalogue"` is a
  provider's browse price, so label it "from" or "indicative". `meta.priceQuoted` counts
  the quoted offers on the page and `meta.priceQuoteNotes` says why any were not.
  `refundable: false` or `null` explains why a provider's own page may show a higher,
  flexible rate.
- **Unknown is not false.** `direct`, `overnight`, `refundable`, `stars`, `score` and the
  flight legs may be `null`, meaning the provider did not say. Render "unknown", never
  "no". The API's own filters (`directOnly`, `starsMin`, `scoreMin`) already exclude
  unknown rows, so filter server-side rather than in the client.
- **Dates are integers.** `departDate` and `returnDate` are EpochDays: days since
  1970-01-01 UTC. `new Date(epochDay * 86_400_000)` read in UTC is the date.
- **Counts.** `meta.total` is how many offers matched; `offers.length` is this page.
  Page with `offset`. `meta.truncated: true` means a provider or the server hit a cap,
  so more inventory exists than this search collected.
- **Booking links.** `deeplink` is a path on **this API** (`/go/…`), not a provider URL.
  Prefix it with the API's base URL and open it in a new tab: the user sees a short
  "Continue to <provider>" page, then lands on that exact offer. A search for a front gets
  that front's page: its name, colour and language, and the hotel, day and nights of the
  trip it opens (the ticket carries them, signed). On a client's own front with a brand
  profile, the page also wears the brand: its fonts, its button and its heading and text
  colours, only where the profile has a source and the colour stays readable on the light page.
  A search without a front gets a plain page. Never rebuild a
  provider link from offer fields; it would open a different holiday (wrong party or
  room). A `null` deeplink means there is no link to offer.
- **What was searched.** `meta.scope.providers` is who was asked; the `excluded` lists say
  why each other provider was not (not in the front, switched off on this server, left out
  by your `providers`, or not in the market). Read results against that list, not against
  "every provider" (section 2b).
- **400s name the problem.** An input the server cannot resolve is a 400 with `field`,
  `term` and usually `suggestions` (`[{ value, label }]` where `value` can be resent
  as-is). Show them; never retry a 400 unchanged. The API never drops an input silently,
  so a client must not either.

---

## 4. Streaming

`GET /v1/search/stream` takes the same parameters as `GET /v1/search` and the same
`Authorization` header, and answers NDJSON: one JSON object per line.

- The first line is `{"type":"query", ...}` with the planned fan-out. The last line is
  `{"type":"done", ...}`, or `{"type":"error", ...}` if the search broke. A stream that
  ends without either was cut off and is **not** a complete result. Both carry `scope`,
  the same object as `meta.scope`, so a front's client can check it before rendering a
  batch.
- Between them, `batch` lines carry offers and hotels as providers answer (show them as
  they come), and `task` lines report each provider call.
- `page`, just before `done`, is the authoritative page: the same shape as the
  `GET /v1/search` response, after de-duplication, re-pricing and paging. Replace what
  the batches built with it.
- `done` carries `calls`, every request the whole stream sent the companies (§2a).
- A search answered from a kept one (§2d) has no batches: `query`, a `reused` line
  `{searchId, ageSec, searchedAt}`, then `page` and `done`. Its `page.meta.searchId` is the
  handle, as on every streamed search.
- A 400, 401, 403 or 503 arrives as an ordinary JSON response before any streaming
  starts, so check the HTTP status first.
- Closing the connection cancels the search on the server.

Read the body line by line and keep the trailing partial line between chunks: a chunk
can end in the middle of a JSON object.

---

## 5. Minimal server-side example (TypeScript, Node 18+)

```ts
const BASE = process.env.TRAVELFRONT_API_URL ?? 'https://api.travelfront.io';
const KEY = process.env.TRAVELFRONT_API_KEY; // never shipped to a browser

export async function search(params: Record<string, string>) {
  const res = await fetch(`${BASE}/v1/search?${new URLSearchParams(params)}`, {
    headers: { accept: 'application/json', ...(KEY ? { authorization: `Bearer ${KEY}` } : {}) },
    signal: AbortSignal.timeout(60_000),
  });
  const body = await res.json();
  if (res.status === 401) throw new Error(`Travelfront API rejected the API key: ${body.error}`);
  if (res.status === 429) throw new Error(`Travelfront API: today's searches are used up (${body.message})`);
  if (res.status === 400) return { rejected: body }; // body.field, body.term, body.suggestions
  if (!res.ok) throw new Error(`Travelfront API ${res.status}: ${body.message} (request ${body.requestId})`);

  const failed = body.trace.providers.filter((p: any) => p.error);
  return {
    offers: body.offers,           // each offer.hotelId -> body.hotels[hotelId]
    hotels: body.hotels,
    total: body.meta.total,
    failedProviders: failed.map((p: any) => `${p.provider}: ${p.error.kind}`),
    bookingUrl: (o: { deeplink: string | null }) => (o.deeplink ? BASE + o.deeplink : null),
  };
}
```

For types, generate from the live document instead of hand-writing them, for example
`npx openapi-typescript https://api.travelfront.io/openapi.json -o travelfront-api.d.ts`.

---

## 6. Before calling the client done

- [ ] The key comes from server-side configuration, is sent as a Bearer header on every
      search, and appears in no URL, bundle, log line or committed file.
- [ ] A 401 is reported as a configuration problem and not retried.
- [ ] A 429 `quota_exceeded` tells the user searching is paused for today, and is not
      retried before its `retry-after`.
- [ ] "Nothing found" is shown only when no provider in `trace` has an `error`; partial
      failures are visible.
- [ ] Prices show `priceTotal` / `pricePerPerson` as given, with catalogue prices marked
      as indicative.
- [ ] `null` fields render as unknown.
- [ ] Booking goes through the API's `/go/…` link, prefixed with the base URL.
- [ ] A 400 shows its `field`, `term` and `suggestions`.
- [ ] A client serving a front sends `front` on every search, shows nothing unless
      `meta.scope.front` is that id, and treats 400/403/503 front refusals as closed, never
      as a reason to search without the front.
- [ ] Searches have a timeout above 45 s and a loading state; streams are treated as
      incomplete unless they end with `done`.
- [ ] A client that searches on a traveller's behalf reads `/v1/trends` before it picks
      weeks and places, keeps a call budget with `/v1/search/plan`, and logs `meta.calls`.
- [ ] Later pages, sorts and filters read `GET /v1/search/<searchId>` instead of searching
      again; a `404 search_expired` searches again from the kept question; an answer with
      `meta.reused` shows its age where prices matter; opened trips are priced with
      `POST /v1/search/<searchId>/details`.
- [ ] Every response carries an `x-request-id` header (also `requestId` in error
      bodies); the client logs it so a problem can be traced on the server.
