Consuming streaming search results

Every frame a search or fares stream can send, how to tell a finished search from a failed one, and how to reconnect and plan for timeouts.

Flight, stay, car, and train search — POST /v1/flights/search, POST /v1/stays/search, POST /v1/cars/search, POST /v1/trains/search — and
their matching /fares endpoints all stream their results back as
Server-Sent Events instead of returning one JSON body. The wire mechanics
below are identical across all eight endpoints, so this page covers them
once instead of repeating them on every vertical guide.

This page is about the stream's behaviour: what you receive, in what order,
and how to tell success from failure. The JSON shape of each frame's data
payload — field names, types, and examples — lives in the API
Reference
; this page links to the relevant
operation instead of repeating field tables.

How it works

Every stream opens with 200 OK and Content-Type: text/event-stream, then
sends zero or more named frames before closing. Each frame is:

event: <name>
data: <json>

Open the connection the same way you make any other request — these are
POST endpoints, so the frames start arriving only after you send the full
request body; there is nothing to stream incrementally on the request side.

Frame vocabulary

event: nameCarriesWhen it arrives
solutionOne priced search result or fare.The default frame on every search and fares stream — one per result, as the provider returns it. Order is provider order, not sorted by price or any other field.
catalog-itemAn unpriced stay property, before it has been priced.POST /v1/stays/search only, and only when the search includes catalog browsing. Additive: a client that does not recognise this event ignores it. May arrive before or after solution frames for the same property.
outbound-groupOne physical flight outbound bundled with its priced return options.POST /v1/flights/search only, and only when the request sets presentation: "grouped" on a two-leg itinerary. Replaces solution framing for that request — a grouped request does not also emit flat solution frames.
provider-statusCounts only — how many pricing sources are still running, have answered, or have failed. Never names a source.POST /v1/stays/search only, on a search priced live across multiple sources. Arrives each time those counts change.
settledThe same counts-only shape as provider-status.Stays live pricing only. Arrives at most once, when every pricing source has stopped answering.
completeThe same counts-only shape as provider-status.Stays live pricing only. Arrives once, and is always immediately followed by done.
doneAn empty object, {}.Terminal. The stream is exhausted — every result the server has, has been sent. A search with zero results still ends in done; that is the "no availability" signal, not an error.
error{"code": ..., "message": ...}.Terminal. The stream stopped before finishing. See Completion and failure below.

A stream never sends both done and error: the server emits exactly one
of the two terminal frames, never both, and never neither while the
connection stays open.

Completion and failure

done and error are mutually exclusive and both terminal — once either
one arrives, the server closes the connection and sends nothing more on
that stream.

  • done means the server finished emitting every result it has, for
    whatever reason it stopped looking — including a well-formed search that
    found nothing. Treat everything received before done as the complete,
    final result set.
  • error means the search stopped before it could call itself
    finished. Whatever solution (or other) frames you already received
    before the error frame are not retracted or re-sent — they are
    real, already-returned results — but the server is telling you it did not
    get as far as it normally would. Do not expect a second attempt on the
    same connection; open a new stream (see
    Reconnecting after a drop).

The error frame's code is one of:

CodeMeaningWhat to do
PROVIDER_ERRORThe upstream provider failed or timed out.Transient — safe to retry with a new stream.
PROVIDER_UNAUTHORIZEDThe credentials behind the search were rejected upstream.Re-authenticate before retrying; retrying with the same token will fail the same way.
PROVIDER_FORBIDDENThe provider returned a resolved authorization denial for this search.Final — do not retry the same request.
MAX_NIGHTS_EXCEEDEDThe requested stay exceeds the configured maximum length of stay. Stays only (POST /v1/stays/search or POST /v1/stays/fares); the message states the actual limit.Fix the request (shorten the date range) before retrying.
INTERNAL_ERRORAn unexpected failure on travel.tech's side, not necessarily the provider's.Safe to retry with a new stream.

A 401 or 422 on the initial request — bad credentials or an invalid
search body — is returned as a normal HTTP error response, before the
stream opens at all; see Errors
for that envelope. The error frame above is a different channel: it can
only happen after the 200 response has already started.

Example trace

A flight search (POST /v1/flights/search) that finds two solutions, then
finishes normally:

event: solution
data: {"id":"018f2c3e-1234-7abc-8def-000000000101","solutionKey":"6f1c9e2a...","providerName":"example-airline","totalPrice":{"amount":"149.90","currency":"EUR"},"cabinClass":"economy","stops":0,"isPolicyCompliant":true}

event: solution
data: {"id":"018f2c3e-1234-7abc-8def-000000000102","solutionKey":"a3d8b410...","providerName":"another-airline","totalPrice":{"amount":"172.40","currency":"EUR"},"cabinClass":"economy","stops":1,"isPolicyCompliant":true}

event: done
data: {}

The same search failing instead, after the provider stopped responding:

event: solution
data: {"id":"018f2c3e-1234-7abc-8def-000000000101","solutionKey":"6f1c9e2a...","providerName":"example-airline","totalPrice":{"amount":"149.90","currency":"EUR"},"cabinClass":"economy","stops":0,"isPolicyCompliant":true}

event: error
data: {"code":"PROVIDER_ERROR","message":"Search temporarily unavailable."}

The one solution already received before error is still a valid result —
discard nothing you have already parsed.

What you need to handle

Reconnecting after a drop

These streams are POST-initiated, so the browser's native EventSource
— which only ever issues GET and reconnects automatically — cannot open
them directly; you need a client library that lets you send a POST body
and read Server-Sent Events from the response (see
Consuming the stream in code below).

There is no Last-Event-ID or cursor contract on these streams, unlike the
real-time notification stream: reconnecting
means sending the exact same request again from the start, not resuming a
paused one. If the connection drops before you receive done or error,
the search's outcome is unknown — treat it the same as an error:

  • Discard whatever you buffered from that attempt. A dropped connection
    gives you no guarantee the partial results you saw are the cheapest or
    most complete available; re-running the search is the only way to know.
  • Open a new stream with the same request body. Each request is a fresh,
    independent search — prices and availability can differ between the
    original attempt and the retry, because real inventory moves between
    calls. Do not expect the retry to reproduce the first attempt's results.

Timeouts

Every stream — all four verticals, search and fares — sends a : keepalive
comment frame (a line that starts with :, which every compliant SSE
parser already ignores) every 15 seconds that it has nothing else to send.
This exists so the connection survives a load balancer's default ~60-second
idle timeout while a search is still legitimately running; it is not a
signal of anything going wrong, and does not count as a frame.

Stays — both POST /v1/stays/search and POST /v1/stays/fares — also
enforce an overall wall-clock budget on the underlying search: 90 seconds by
default. If that budget runs out:

  • Everything found so far is still sent, and the stream ends in done, not
    error, as long as at least one result was found.
  • If nothing was found at all within the budget, the stream ends in error
    with PROVIDER_ERROR instead.

Flights, cars, and trains — search and fares, all three verticals — have no
equivalent coded ceiling beyond the keepalive above: the stream ends when
the provider itself is exhausted or fails, which can in practice take longer
or shorter than 90 seconds depending on the provider. Plan your own
client-side timeout rather than assuming any of these streams close within a
fixed window.

Consuming the stream in code

Both examples below search flights; the same pattern — open with the search
body, handle every named frame, treat a request error the same as the
error frame, and retry with a fresh request on either — applies to every
vertical's search and fares stream.

Python (httpx-sse)

import httpx
from httpx_sse import connect_sse

search_body = {
    "legs": [{"origin": "MXP", "destination": "LHR", "date": "2026-02-10"}],
    "passengers": {"adults": 1, "children": 0, "infants": 0},
    "cabinClass": "economy",
    "directOnly": False,
}


def run_search(client: httpx.Client) -> list[dict]:
    solutions: list[dict] = []
    with connect_sse(
        client, "POST", "/v1/flights/search", json=search_body
    ) as event_source:
        for sse in event_source.iter_sse():
            if sse.event == "solution":
                solutions.append(sse.json())
            elif sse.event == "done":
                return solutions
            elif sse.event == "error":
                error = sse.json()
                raise RuntimeError(f"{error['code']}: {error['message']}")
    # The loop exited without `done` or `error`: the connection dropped.
    # The outcome is unknown — treat it exactly like an error.
    raise RuntimeError("stream ended without done or error")


with httpx.Client(
    base_url="https://api.travel.tech",
    headers={
        "Authorization": f"Bearer {traveler_jwt}",
        "TravelTech-Consumer": consumer,
        "TravelTech-Timestamp": timestamp,
        "TravelTech-Signature": signature,
    },
) as client:
    try:
        solutions = run_search(client)
    except (RuntimeError, httpx.TransportError):
        # Reconnect by opening a brand-new stream with the same search_body —
        # there is no cursor to resume from.
        solutions = run_search(client)

httpx-sse raises on a non-2xx response before any event is read, so a
401 or 422 on the initial request surfaces as an httpx.HTTPStatusError
rather than silently yielding zero frames.

TypeScript / JavaScript (@microsoft/fetch-event-source)

Native browser EventSource cannot send a POST body or the consumer HMAC
headers, so use a library that supports both, such as
@microsoft/fetch-event-source:

import { fetchEventSource } from "@microsoft/fetch-event-source";

const searchBody = {
  legs: [{ origin: "MXP", destination: "LHR", date: "2026-02-10" }],
  passengers: { adults: 1, children: 0, infants: 0 },
  cabinClass: "economy",
  directOnly: false,
};

let solutions: unknown[] = [];

await fetchEventSource("https://api.travel.tech/v1/flights/search", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Authorization: `Bearer ${travelerJwt}`,
    "TravelTech-Consumer": consumer,
    "TravelTech-Timestamp": timestamp,
    "TravelTech-Signature": signature,
  },
  body: JSON.stringify(searchBody),
  onmessage(msg) {
    if (msg.event === "solution") {
      solutions.push(JSON.parse(msg.data));
    } else if (msg.event === "done") {
      // `solutions` is now the complete, final result set.
    } else if (msg.event === "error") {
      const error = JSON.parse(msg.data);
      throw new Error(`${error.code}: ${error.message}`);
    }
  },
  onerror(err) {
    // No Last-Event-ID contract on this stream: a retry starts the search
    // over, not where it left off, so the partial results from this attempt
    // are not final. Clear them before the library's own retry fires.
    solutions = [];
    // Returning (not throwing) lets the library back off and retry with the
    // same request; rethrow instead if you would rather surface the failure
    // to the caller than retry silently.
  },
});

Related guides


Did this page help you?