Skip to content
FX-Port/Docs
GuidesAPI referenceChangelog
Get API keys
  • Introduction
  • Quickstart
  • Security
  • Authentication
  • Environments and Postman
  • Sandbox
  • Conventions and naming
  • Legacy names and twins
  • Errors
  • Rate limits and fair use
  • Streaming search results
  • Use these docs with AI

API fundamentals

Streaming search results

Receive flight search results supplier by supplier as server-sent events, instead of waiting for the slowest supplier.

POST /api/v1/get_flights?stream=true 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.

POST/api/v1/get_flights?stream=true

When 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 modeStream
First byteAfter the slowest supplier: 2.7 to 8 s0.3 to 0.7 s (the start event)
First offersSame moment as the last offers1.5 to 2.6 s (the fastest supplier)
Last offers2.7 to 8 s2.9 to 8 s (the slowest supplier)
Bytes on the wireAbout 26 KB with Accept-Encoding: gzip (700 KB without)About 660 KB: streams are never compressed
CodeOne json.loadsAn 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 (1 and yes also work);
  • the header Accept: text/event-stream.
HeaderValue
AuthorizationBearer fxp_test_...Required
Content-Typeapplication/jsonRequired: the search is a JSON body
Accepttext/event-streamRecommended
User-AgentYour own, for example my-app/1.0Cloudflare 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/event-stream 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:

HeaderValueMeaning
Content-Typetext/event-streamServer-sent events in UTF-8 (the header has no charset: decode the bytes as UTF-8)
Cache-Controlno-cache, no-transformNever cached, and proxies are asked not to compress or rewrite it
Transfer-EncodingchunkedThe length is unknown (on HTTP/2 the body is sent as frames and the header is absent)
X-Environmentsandbox or liveThe environment of the key
X-Request-IdThe id of the requestQuote it to support
cf-cache-statusDYNAMICCloudflare 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"}
FieldContent
idThe number of the event, from 0, increasing by one. For logging: a stream cannot be resumed (see Timeouts and reconnecting)
eventThe name of the event: start, supplier_start, offers_chunk, supplier_complete, supplier_result, flex_prices, error, summary, done
dataOne line of compact JSON. The same name is in its type key
an empty lineEnds the event: dispatch it now

Rules every client must follow:

  1. Ignore lines that start with a colon. They are comments, such as the keep-alive line : keep-alive (below).
  2. Ignore a field or an event you do not know. More of them can appear in the future.
  3. A line can be huge. One offers_chunk carries up to 50 offers and its data line was up to 210 KB in our measurements. A reader with a small line buffer (Go's bufio.Scanner has 64 KB by default) fails with a cryptic error.
  4. Timestamps (timestamp) are ISO 8601 in UTC without a Z: 2026-11-01T15:22:59.175148.
  5. Only the start event carries environment (as its first key); the header X-Environment says 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
EventWhenWhat it carries
startFirst, at onceThe request id and the suppliers that will answer
supplier_resultA supplier, or a later batch of it, answered with 50 offers or fewerAll the offers of one result set, in data.results.offers
supplier_startA supplier answered with more than 50 offersThe result set without its offers: the searchId, the currencies, results.totalOffers, chunkSize, totalChunks
offers_chunkAfter supplier_startUp to 50 offers, numbered chunkNumber of totalChunks
flex_pricesA chunked result set that carries a date matrix (flexible: true)The matrix of the supplier. Not seen in the sandbox: see flexible dates
supplier_completeAfter the last chunkThe counters of the result set and complete
errorA supplier failedWhich supplier, and the error text
summaryAfter every supplier has answered or failedThe counters and the list of failures
doneLastThe 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

  • start is the first event (id 0) and done the last. summary is just before done.
  • The events of a result set are in order: supplier_start, then offers_chunk 1 to totalChunks, then flex_prices if there is one, then supplier_complete. A small result set is a single supplier_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_chunk under its supplierId.
  • An error can come at any point after start. The stream still ends with summary and done: done is 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:

  1. 1

    Open a result set

    On supplier_start or supplier_result, read data.searchId, remember it as the current searchId of that supplierId, and start a list with data.results.offers (empty for supplier_start).

  2. 2

    Fill it

    On every offers_chunk, append offers to the list of the current searchId of its supplierId. chunkNumber and totalChunks let you check that none is missing.

  3. 3

    Know when a supplier is finished

    A supplier is finished at its first supplier_complete or supplier_result with complete: true, or at its error. complete: false means that another batch of this supplier follows.

One supplier, two searchIds

Amadeus Algeria answers in two batches, a fast one and a slow one. The first batch has complete: false. The second arrives later as a supplier_result with complete: true, with only the extra offers and a different searchId. In the example above the first batch had 68 offers under fs_509846e6-... and the second 2 offers under fs_35418d61-..., and summary counted 70 for the supplier.

Price and book an offer with the searchId of the result set it arrived in. Do not merge two result sets into one list that has a single searchId. duffel_1 answered in a single result set.

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

SituationWhat you seeWhat 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 streamFix the request. See Errors
One supplier failsAn error event for it, then the stream goes onShow the offers of the others. Search again later if the missing supplier matters
Every supplier failserror events, then summary with success: false, then doneTreat 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 doneThe stream is cutThe offers you have are valid but incomplete. Run the search again: see below
429An 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-ID support and no retry: 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 a POST body or an Authorization header, and it reconnects on its own and would repeat the search. Read the stream with fetch on 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 error event with cause: "supplier_timeout" for each, then summary with "truncated": true, then done. 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

LimitValue
Streams open at the same timeKeep 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 stream300 seconds: see Timeouts and reconnecting.
RequestsA stream is one request of the search group (Rate limits).
Request bodyThe 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-Encoding is absent whatever you send in Accept-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 for text/event-stream and raise the proxy read timeout above 60 seconds.
  • Use your own User-Agent. Cloudflare answers the default agents of Python's urllib and Perl's libwww-perl with 403 and the text error code: 1010: see Errors that do not come from the API. curl, Go, Java, Node.js, PHP and Python requests are not blocked.
  • Decode as UTF-8. Some HTTP libraries assume ISO-8859-1 for text/* types without a charset (Python requests does): 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.

Terminal
# --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"; done

A 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 sets

Test it in the sandbox

You want to seeSend
ChunksAny 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 searchIdA 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 eventA 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 streamClose 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 searchId and the offer id.
  • Rate limits and Errors.
PreviousRate limits and fair useNext Flights API overview

On this page

  • When to use it
  • Request
  • Response headers
  • The wire format
  • Events
  • start
  • supplier_result and supplier_start
  • offers_chunk
  • supplier_complete
  • error
  • summary and done
  • Flexible dates
  • Ordering guarantees
  • Merging the offers
  • Keep-alive lines
  • Errors in the stream
  • Timeouts and reconnecting
  • Limits
  • Proxies, CDNs and compression
  • A complete client
  • Test it in the sandbox
  • Related

© 2026 FX-Port

TermsPrivacy[email protected]