Streaming search results
Receive flight search results supplier by supplier as server-sent events, instead of waiting for the slowest supplier.
POST / runs the same search as the JSON mode, but answers with server-sent events: the first offers arrive as soon as the first supplier has answered, and the rest follow supplier by supplier, instead of one response after the slowest supplier. The request body, the key permission (Read), the offers and the errors that are found before the search starts are the ones of Search flights.
/api/v1/get_flights?stream=trueWhen to use it
A search fans out to every compatible supplier and the suppliers answer at different speeds. Measured on the sandbox through api.fx-port.com on 2026-11-01 (ALG to CDG, one adult, two suppliers):
| JSON mode | Stream | |
|---|---|---|
| First byte | After the slowest supplier: 2.7 to 8 s | 0.3 to 0.7 s (the start event) |
| First offers | Same moment as the last offers | 1.5 to 2.6 s (the fastest supplier) |
| Last offers | 2.7 to 8 s | 2.9 to 8 s (the slowest supplier) |
| Bytes on the wire | About 26 KB with Accept-Encoding: gzip (700 KB without) | About 660 KB: streams are never compressed |
| Code | One json.loads | An event loop: see the complete client |
Use the stream for a search page that a person is looking at: the first fares appear in about two and a half seconds and the list grows while the slow suppliers finish. Use the JSON mode for everything else: batch jobs, price comparison, caching a search for later, and any client that cannot read a stream. On a slow link (a phone on mobile data) the 660 KB of an uncompressed stream can take longer to download than the 26 KB of a gzipped JSON response: measure before you choose.
Request
Same body as Search flights, same Read permission. Ask for a stream with either of these (each works alone):
- the query parameter
?stream=true(1andyesalso work); - the header
Accept: text/.event-stream
| Header | Value | |
|---|---|---|
Authorization | Bearer fxp_test_... | Required |
Content-Type | application/ | Required: the search is a JSON body |
Accept | text/ | Recommended |
User-Agent | Your own, for example my-app/ | Cloudflare blocks the default agents of Python's urllib and Perl's libwww-perl with a 403 and the text error code: 1010 |
curl 'https://api.fx-port.com/api/v1/get_flights?stream=true' \
-H "Authorization: Bearer fxp_test_YOUR_KEY" \
-H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
-d '{
"origin": "ALG",
"destination": "CDG",
"departure_date": "2026-12-15",
"cabin_class": "economy",
"passengers": {
"adults": 1
}
}'A problem with the request is answered before the stream starts, as an ordinary JSON error with its own status code, not 200: a missing or invalid key (401), a key that is not allowed (403), a malformed body, an invalid passenger mix or an unknown supplier (400), too many requests (429). Always check that the status is 200 and the Content-Type is text/ before you parse events. Once the stream has started the status is 200 and failures arrive as events.
Response headers
Verified through Cloudflare on 2026-11-01:
| Header | Value | Meaning |
|---|---|---|
Content-Type | text/ | Server-sent events in UTF-8 (the header has no charset: decode the bytes as UTF-8) |
Cache-Control | no-cache, no-transform | Never cached, and proxies are asked not to compress or rewrite it |
Transfer-Encoding | chunked | The length is unknown (on HTTP/2 the body is sent as frames and the header is absent) |
X-Environment | sandbox or live | The environment of the key |
X-Request-Id | The id of the request | Quote it to support |
cf-cache-status | DYNAMIC | Cloudflare does not cache or hold the stream |
There is no Content-Encoding: the stream is not compressed, even when you send Accept-Encoding: gzip.
The wire format
A stream is text. Each event is a few lines ended by an empty line:
id: 4
event: supplier_complete
data: {"type":"supplier_complete","supplierId":"duffel_1","totalOffers":92,"hasFlexPrices":false,"flexPricesCount":0,"complete":true,"timestamp":"2026-11-01T15:22:59.175148"}
| Field | Content |
|---|---|
id | The number of the event, from 0, increasing by one. For logging: a stream cannot be resumed (see Timeouts and reconnecting) |
event | The name of the event: start, supplier_start, offers_chunk, supplier_complete, supplier_result, flex_prices, error, summary, done |
data | One line of compact JSON. The same name is in its type key |
| an empty line | Ends the event: dispatch it now |
Rules every client must follow:
- Ignore lines that start with a colon. They are comments, such as the keep-alive line
: keep-alive(below). - Ignore a field or an event you do not know. More of them can appear in the future.
- A line can be huge. One
offers_chunkcarries up to 50 offers and itsdataline was up to 210 KB in our measurements. A reader with a small line buffer (Go'sbufio.Scannerhas 64 KB by default) fails with a cryptic error. - Timestamps (
timestamp) are ISO 8601 in UTC without aZ:2026-11-01T15:22:59.175148. - Only the
startevent carriesenvironment(as its first key); the headerX-Environmentsays it too.
Events
A search with two suppliers produced these 12 events (the offers are not shown):
0 start
1 supplier_start duffel_1 92 offers coming in 2 chunks
2 offers_chunk duffel_1 chunk 1 of 2, 50 offers
3 offers_chunk duffel_1 chunk 2 of 2, 42 offers
4 supplier_complete duffel_1 complete: true
5 supplier_start amadeus_aqc_dz_1 68 offers coming in 2 chunks
6 offers_chunk amadeus_aqc_dz_1 chunk 1 of 2, 50 offers
7 offers_chunk amadeus_aqc_dz_1 chunk 2 of 2, 18 offers
8 supplier_complete amadeus_aqc_dz_1 complete: false <- another batch of this supplier follows
9 supplier_result amadeus_aqc_dz_1 2 more offers, complete: true, another searchId
10 summary
11 done| Event | When | What it carries |
|---|---|---|
start | First, at once | The request id and the suppliers that will answer |
supplier_result | A supplier, or a later batch of it, answered with 50 offers or fewer | All the offers of one result set, in data.results.offers |
supplier_start | A supplier answered with more than 50 offers | The result set without its offers: the searchId, the currencies, results.totalOffers, chunkSize, totalChunks |
offers_chunk | After supplier_start | Up to 50 offers, numbered chunkNumber of totalChunks |
flex_prices | A chunked result set that carries a date matrix (flexible: true) | The matrix of the supplier. Not seen in the sandbox: see flexible dates |
supplier_complete | After the last chunk | The counters of the result set and complete |
error | A supplier failed | Which supplier, and the error text |
summary | After every supplier has answered or failed | The counters and the list of failures |
done | Last | The end of the stream |
start
{
"environment": "sandbox",
"type": "start",
"requestId": "d36b6918",
"searchType": "ONE WAY",
"search_type": "ONE_WAY",
"flexible": false,
"timestamp": "2026-11-01T15:22:57.762071",
"expectedSuppliers": ["amadeus_aqc_dz_1", "duffel_1"]
}expectedSuppliers are the suppliers called for this search: the compatible ones, a supplier skipped for the route or the passenger mix is not listed. searchType here is the raw classification of the route (ONE WAY, ROUNDTRIP, MULTIPLE CITY, OPEN), not the ONE_WAY, ROUND_TRIP, OPEN_JAW, MULTI_CITY of the JSON mode. Branch on search_type: it has the vocabulary of the JSON mode.
supplier_result and supplier_start
A supplier answered. data is the same object as an entry of suppliers[] in the JSON mode, arranged a little differently:
{
"type": "supplier_start",
"supplierId": "duffel_1",
"success": true,
"data": {
"success": true,
"requestId": "d36b6918",
"searchId": "fs_dfx_59ce98b7-ce5a-4911-abb8-b43d4786e6c9",
"searchType": "ONE_WAY",
"processingTime": 1.381,
"supplierCurrency": "USD",
"agencyCurrency": "DZD",
"results": {
"totalOffers": 92,
"supplierCurrency": "USD",
"agencyCurrency": "DZD",
"offers": [],
"chunked": true,
"chunkSize": 50,
"totalChunks": 2
},
"meta": {
"filtersApplied": {},
"warnings": [],
"supplierId": "duffel_1",
"technologyType": "duffel",
"supportHold": false,
"supportVoid": false,
"voidDeadline": null
},
"exchangeRate": 250.0
},
"processingTime": 1.381,
"timestamp": "2026-11-01T15:22:59.161692"
}supplier_result has the same shape, plus a top-level complete, and the offers in data.results.offers (no chunked, chunkSize or totalChunks).
Compared with the JSON mode: searchId and requestId are inside data; supportHold, supportVoid, voidDeadline, filtersApplied and warnings stay inside data.meta (the JSON mode moves them to the supplier entry); meta has no supplierName, processingTime or currencies (the last two are next to it, in data). The offers themselves are exactly those of the JSON mode: see the Flight offer object.
offers_chunk
{
"type": "offers_chunk",
"supplierId": "duffel_1",
"chunkNumber": 1,
"totalChunks": 2,
"offersInChunk": 50,
"offers": [{ "id": "fx_offer_9d08a93c", "type": "flight_offer", "...": "an offer, as in the JSON mode" }],
"timestamp": "2026-11-01T15:22:59.161771"
}A chunk carries only the supplierId: add its offers to the result set opened by the last supplier_start of that supplier.
supplier_complete
{
"type": "supplier_complete",
"supplierId": "amadeus_aqc_dz_1",
"totalOffers": 68,
"hasFlexPrices": false,
"flexPricesCount": 0,
"complete": false,
"timestamp": "2026-11-01T15:22:59.819079"
}error
{
"type": "error",
"supplierId": "duffel_1",
"success": false,
"error": "The supplier rejected the search request.",
"cause": "supplier_error",
"processingTime": 0,
"timestamp": "2026-11-01T15:23:32.838533"
}One supplier failed: it timed out, refused the search, was skipped for a short while or was busy. The other suppliers go on. cause is a stable code: supplier_timeout, supplier_api_error, supplier_auth_error, supplier_error, supplier_temporarily_unavailable, supplier_busy (FX-Port's capacity towards the supplier is full for a moment: the supplier was not called, search again in a few seconds) or service_overloaded (FX-Port was too busy to prepare that supplier's results: search again in a few seconds). error is a fixed sentence about the cause: it never carries the supplier's own text (addresses, stack text), and its wording can change, so branch on cause.
summary and done
{
"type": "summary",
"requestId": "d36b6918",
"success": true,
"processingTime": 2.581,
"summary": { "totalSuppliers": 2, "successfulSuppliers": 2, "failedSuppliers": 0, "totalOffers": 162 },
"successfulSuppliers": [
{ "supplierId": "duffel_1", "offersCount": 92, "processingTime": 1.381 },
{ "supplierId": "amadeus_aqc_dz_1", "offersCount": 70, "processingTime": 2.516 }
],
"failures": null,
"timestamp": "2026-11-01T15:23:00.286463"
}{ "type": "done", "requestId": "d36b6918", "timestamp": "2026-11-01T15:23:00.286605" }success is true when at least one supplier returned offers. failures is null, or a list of { "supplierId", "error", "cause" } (the same cause values as the error event). successfulSuppliers is a number inside summary and a list next to it. In the stream summary.totalSuppliers is the number of suppliers that were called (in the JSON mode it counts the suppliers that answered).
When the stream reaches its time limit (see Timeouts and reconnecting) the summary event has "truncated": true, and the suppliers that had not answered are in failures with the cause supplier_timeout.
Flexible dates
With "flexible": true the date matrix (flexPrices, see Flexible-date search) is the data.flexPrices of the result set that has it. For Amadeus Algeria that is the second, slower batch: the first batch has the offers and no matrix, and the matrix arrives with the supplier_result that has complete: true (7 seconds into the stream in our test). A chunked result set that carries a matrix sends it in a separate flex_prices event (flexPrices and a count) before supplier_complete; we have not seen that event in the sandbox, so treat it as optional.
Ordering guarantees
startis the first event (id0) anddonethe last.summaryis just beforedone.- The events of a result set are in order:
supplier_start, thenoffers_chunk1 tototalChunks, thenflex_pricesif there is one, thensupplier_complete. A small result set is a singlesupplier_result. - Suppliers are independent and arrive in the order they finish. There is no fixed order between suppliers, and it can change from one search to the next.
- The events of two result sets are not mixed in practice, but do not rely on it: file every
offers_chunkunder itssupplierId. - An
errorcan come at any point afterstart. The stream still ends withsummaryanddone:doneis always the last event, also when the search failed half-way. - The stream is over only at
done. A stream that ends without it (a dropped connection, a proxy that closed it) is incomplete.
Merging the offers
A supplier can answer more than once. Keep one list of offers per searchId:
Open a result set
On
supplier_startorsupplier_result, readdata.searchId, remember it as the currentsearchIdof thatsupplierId, and start a list withdata.results.offers(empty forsupplier_start).Fill it
On every
offers_chunk, appendoffersto the list of the currentsearchIdof itssupplierId.chunkNumberandtotalChunkslet you check that none is missing.Know when a supplier is finished
A supplier is finished at its first
supplier_completeorsupplier_resultwithcomplete: true, or at itserror.complete: falsemeans that another batch of this supplier follows.
supplier_complete.totalOffers and results.totalOffers are the number of offers of that result set. The total of the search is summary.summary.totalOffers.
Keep-alive lines
While a supplier is slow the server writes a comment line about every 15 seconds, so that proxies, load balancers and your own read timeout see traffic:
: keep-alive
A comment starts with a colon and carries no event. Skip every line that starts with a colon: the SSE standard says so too. Do not count a comment as an event and do not use it as an end condition.
Errors in the stream
| Situation | What you see | What to do |
|---|---|---|
| The request is wrong (invalid key, malformed body, unknown supplier, a passenger mix no supplier can serve) | An ordinary JSON error with a 4xx status, no stream | Fix the request. See Errors |
| One supplier fails | An error event for it, then the stream goes on | Show the offers of the others. Search again later if the missing supplier matters |
| Every supplier fails | error events, then summary with success: false, then done | Treat it like the 503 of the JSON mode (all_suppliers_failed, or supplier_busy or service_overloaded when every cause says so): wait, then retry once or twice with backoff (a few seconds are enough for supplier_busy and service_overloaded) |
The connection drops, or the stream ends without done | The stream is cut | The offers you have are valid but incomplete. Run the search again: see below |
429 | An ordinary JSON error before the stream: rate_limit_exceeded, or concurrency_limit_exceeded when too many streams of your key are open (Limits) | Wait for Retry-After (Rate limits) |
Timeouts and reconnecting
- A stream cannot be resumed. There is no
Last-Event-IDsupport and noretry:field. If a connection drops, start a new search: it is a new request and counts once in your rate limit. - Do not use the browser's
EventSource. It cannot send aPOSTbody or anAuthorizationheader, and it reconnects on its own and would repeat the search. Read the stream withfetchon your server, or relay it to the browser with an endpoint of yours. - Never put your key in a browser. Call the API from your backend and forward the events to the page.
- A stream lasts 300 seconds at most. At that point the server cancels the suppliers that are still running, sends an
errorevent withcause: "supplier_timeout"for each, thensummarywith"truncated": true, thendone. A normal search ends within about 10 seconds. - Set a read timeout of at least 60 seconds (the time you accept without receiving a byte) and a total deadline of about two minutes. A slow supplier can take more than 15 seconds, and the keep-alive lines keep the connection busy meanwhile.
- Reconnect with backoff. After a failure wait 1 s, then 2 s, then 4 s (with jitter) and give up after three tries. Do not reconnect in a tight loop.
- Offers expire. A search stays usable for 24 hours, but a supplier can withdraw an offer much earlier. Price an offer soon after you show it (Price a flight offer).
Limits
| Limit | Value |
|---|---|
| Streams open at the same time | Keep at most 4 streams open per API key. A stream holds its connection and its supplier calls for as long as it runs, so the open streams of one key are capped at 4. Every search with stream=true counts while it runs; a JSON search never does. Once the cap is enforced, a fifth stream is refused before it starts with 429 concurrency_limit_exceeded and Retry-After: 5. Until then the streams are only counted: do not rely on the extra ones. |
| Duration of one stream | 300 seconds: see Timeouts and reconnecting. |
| Requests | A stream is one request of the search group (Rate limits). |
| Request body | The body of the search: 64 KB at most (413 request_too_large). |
When many of your users search at the same moment, their streams all use your key. Open a stream only for a search that a person is waiting for, close the connection when that person leaves the page (the server stops the search and frees the slot), and use the JSON mode for batch jobs and for anything that is not shown while it arrives. After a 429, wait for Retry-After and open the stream again.
Proxies, CDNs and compression
- Cloudflare passes the stream through unbuffered: the first event arrived after 0.3 to 0.7 s and the others as the suppliers answered. The response has
cf-cache-status: DYNAMIC. - Streams are not compressed.
Content-Encodingis absent whatever you send inAccept-Encoding. The JSON mode is compressed (26 KB instead of 700 KB for the same search). - Do not buffer in your own proxy. In nginx set
proxy_buffering off;for the route that forwards the stream, turn off response compression fortext/and raise the proxy read timeout above 60 seconds.event-stream - Use your own
User-Agent. Cloudflare answers the default agents of Python'surlliband Perl'slibwww-perlwith403and the texterror code: 1010: see Errors that do not come from the API.curl, Go, Java, Node.js, PHP and Pythonrequestsare not blocked. - Decode as UTF-8. Some HTTP libraries assume ISO-8859-1 for
text/types without a charset (Python* requestsdoes): set the encoding to UTF-8 or read bytes, or the names of airports and cities come out garbled. - A line can be 200 KB or more. Do not use a reader with a small fixed line limit.
A complete client
One program per language. Each sends a search, ignores comment lines, keeps the offers of every result set under its searchId, prints every event with the time it arrived and ends at done. They read the key from FXPORT_API_KEY and search for a date 30 days from today. The Java and Kotlin versions count the offers with regular expressions because the JDK has no JSON parser: parse the data lines with Jackson or Gson to keep the offers.
# --no-buffer shows every event when it arrives instead of when the program exits.
DEPARTURE=$(date -d '+30 days' +%F 2>/dev/null || date -v+30d +%F)
curl --no-buffer --silent --show-error \
--request POST "https://api.fx-port.com/api/v1/get_flights?stream=true" \
--header "Authorization: Bearer fxp_test_YOUR_KEY" \
--header "Content-Type: application/json" \
--header "Accept: text/event-stream" \
--header "User-Agent: my-flight-app/1.0" \
--data "{\"origin\":\"ALG\",\"destination\":\"CDG\",\"departure_date\":\"$DEPARTURE\",\"cabin_class\":\"economy\",\"passengers\":{\"adults\":1}}"
# Only the event names, each with the time it arrived:
# ... | grep --line-buffered '^event:' | while read -r line; do echo "$(date +%T) $line"; doneA run of the Node.js program against the sandbox (two suppliers, 184 offers; the first offers arrived after 1.8 seconds and the stream took 7 seconds):
[0.40s] start, suppliers: amadeus_aqc_dz_1, duffel_1
[1.45s] supplier_start duffel_1 fs_dfx_3f7927a9-e248-40ec-96ce-1453f3831f22 totalOffers 108
[1.81s] chunk 1/3 duffel_1 +50
[1.94s] chunk 2/3 duffel_1 +50
[1.94s] chunk 3/3 duffel_1 +8
[1.94s] duffel_1 complete: true
[6.57s] supplier_start amadeus_aqc_dz_1 fs_4c684e2a-5de8-447e-9d5e-2a02ab8d8575 totalOffers 76
[6.93s] chunk 1/2 amadeus_aqc_dz_1 +50
[7.05s] chunk 2/2 amadeus_aqc_dz_1 +26
[7.05s] amadeus_aqc_dz_1 complete: true
[7.05s] summary {"totalSuppliers":2,"successfulSuppliers":2,"failedSuppliers":0,"totalOffers":184}
[7.05s] 184 offers in 2 result setsTest it in the sandbox
| You want to see | Send |
|---|---|
| Chunks | Any search that returns more than 50 offers from a supplier: ALG to CDG in 30 days returns 90 to 110 from duffel_1 |
A result set with complete: false, then a second searchId | A route that touches Algeria, so that amadeus_aqc_dz_1 answers (ALG to CDG); it happens on most searches, not on all |
| One supplier only | "supplier_id": "duffel_1" |
An error event | A well-formed code that does not exist, for example "origin": "XXX" with "supplier_id": "duffel_1": the supplier refuses the request, the stream carries one error event (cause: supplier_error), then summary (success: false) and done. A refusal of a request does not count as an outage; a supplier that keeps timing out or answering 5xx is skipped for a short while, for every agency of the environment (cause: supplier_temporarily_unavailable) |
| A cut stream | Close your connection while the search runs: nothing is kept on the server |
Related
- Search flights: the request, the filters and the offers.
- API reference: Search flights: every field, with the events listed.
- Price a flight offer: the next step, with the
searchIdand the offerid. - Rate limits and Errors.