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

Concepts

  • Overview
  • Booking flows
  • Passengers and ages
  • Pricing
  • Post-booking operations

Search and price

  • Airport autocomplete
  • Search flights
  • Basic routes
  • Passengers and cabins
  • Filters
  • Supplier selection
  • Open-jaw and multi-city
  • Flexible dates
  • Price an offer

Book, pay and cancel

  • Book
  • Quote a held booking
  • Pay a held booking
  • Cancel a held booking
  • Quote a cancellation
  • Confirm a cancellation

After booking

  • Get a booking
  • Get the ticket PDF
  • Get the ticket number
  • ACD passport uploads
  • Use these docs with AI

Flights · After booking

ACD passport uploads

Upload, add and replace the passport copies that an ACD booking needs: one for every traveler of the booking, sent after the booking with upload_acd_documents.

POST/api/v1/flights/upload_acd_documents

Some flight bookings are ACD bookings. FX-Port needs one passport copy for every traveler on the booking, and you send the copies after the booking exists. This page is the reference for the two endpoints that do it: get_acd_documents tells you what a booking needs and what it already has, upload_acd_documents adds or replaces the files.

MethodPathPermissionBodyDescription
POST/api/v1/flights/get_acd_documentsReadJSONThe ACD state of a booking: how many passports are needed, which travelers have one, the upload limits
POST/api/v1/flights/upload_acd_documentsRead+Writemultipart/form-dataAdd one, several or all passports, or replace passports that were already sent

Both are in the default rate group (6,000 requests per minute per key). They answer only for bookings of your agency and of the environment of your key: a sandbox key never sees a live booking, and a booking of another agency looks exactly like a booking that does not exist (404 BOOKING_NOT_FOUND, see Which bookings you can reach).

What ACD means for a booking

An ACD booking is a booking made with an ACD fare. Only the Amadeus Algeria supplier (amadeus_aqc_dz_1) sells them. The price of such a fare is adjusted by a rule (see Flight pricing), and the booking is completed with passports:

  • One passport per traveler on the booking. The travelers are the passengers stored on the booking, so the list exists as soon as the booking exists. No ticket is needed: ticket_number is null for a hold, and for an instant booking until the airline returns it.
  • Upload at any time after the booking was created, also while it is still a hold (Hold bookings). A booking that is cancelled, failed, voided, refunded or expired refuses uploads (BOOKING_NOT_ELIGIBLE).
  • The set is complete when every traveler has a passport. The booking then becomes acd_submitted: true, and for an issued booking in the live environment FX-Port's ACD team is told by e-mail (Notifications).
  • A wrong or unreadable scan is replaced with replace=true. FX-Port keeps the replaced file and tells the ACD team about the change.
  • Duffel bookings are never ACD bookings: their offers have no acd key, and get_acd_documents answers acd_required: false.

Recognise an ACD booking

The flag has several spellings, one for each step of the booking flow:

WhereFieldFor an ACD booking
Search offers (get_flights) and priced offers (price_flight)pricing.acd.appliedtrue. false on the other Amadeus Algeria offers; Duffel offers have no acd key.
The same offersrequiresACDtrue. Top level of the offer, present only when true.
book_flight and pay_order answerspricing.acd.applied, requiresACDThe same two fields, copied from the priced offer.
get_bookingbooking.require_acdtrue. Twins: booking.acd_required, booking.requires_acd.
Account history and booking detailrequireAcd and record.require_acdtrue
get_acd_documentsacd.acd_requiredtrue. The value to trust before you upload: it is false when FX-Port waived ACD for the booking (acd.acd_waived).

Decide with get_acd_documents: upload when acd.acd_required is true and acd.can_upload is true. Take the traveler ids from acd.travelers and acd.missing_traveler_ids: that list is the one the upload is checked against.

Test it in the sandbox

ACD fares exist only on amadeus_aqc_dz_1, and in the sandbox a search from CDG to ALG on that supplier returns only ACD fares: all 62 offers of a search on 2026-11-01 had pricing.acd.applied: true, and so did every offer of every earlier run (38 to 62 offers). Five calls give you a booking that needs passports.

Sandbox uploads are really stored, and the file URL that comes back is public (Security: file links are public). Use fake files in the sandbox, never the passport of a real person. The files below are a valid 1×1 PNG and a minimal PDF: FX-Port checks the content of a file, not who it belongs to.

  1. 1

    Search CDG → ALG on the ACD supplier

    curl --request POST 'https://api.fx-port.com/api/v1/get_flights' \
      --header 'Authorization: Bearer fxp_test_YOUR_KEY' \
      --header 'Content-Type: application/json' \
      --compressed \
      --data '{
        "origin": "CDG",
        "destination": "ALG",
        "departure_date": "2026-12-20",
        "cabin_class": "economy",
        "passengers": { "adults": 1 },
        "supplier_id": "amadeus_aqc_dz_1"
      }'

    Use a date 30 to 60 days ahead. Every offer carries the flag:

    {
      "id": "fx_offer_3cb9ee1c",
      "requiresACD": true,
      "pricing": {
        "agencyCurrency": "DZD",
        "finalPrice": 52078.03,
        "acd": { "applied": true, "scenario": "B", "...": "..." }
      }
    }

    Other routes also give ACD fares: a departure from Algeria to some countries does (ALG → JFK: 146 of 146 offers on 2026-11-01), most other Algerian routes do not (ALG → CDG: 1 offer of 90, a mixed-carrier fare). In production, read pricing.acd.applied of the offer you book. In a search that also covers Duffel (no supplier_id), only the Amadeus Algeria offers have it.

  2. 2

    Price the offer

    Send the searchId of the supplier entry and the id of the offer to price_flight (search_id, offer_id). The priced offer has pricing.acd and requiresACD again.

  3. 3

    Book it

    book_flight with "booking_type": "issue" (instant) or "hold", and "send_email": false. The answer says "requiresACD": true. Keep the PNR (booking.pnr): it is the booking_id of the ACD calls.

    {
      "success": true,
      "booking": { "status": "confirmed", "pnr": "Y7HWMD", "type": "instant" },
      "requiresACD": true,
      "pricing": { "acd": { "applied": true, "scenario": "B", "...": "..." }, "...": "..." }
    }

    A hold of the cheapest offers can fail at the supplier (we saw 2 holds of 3 fail on 2026-11-01); book another offer, or use issue. Each failed booking leaves a FAILED-… record: see the errors of the book call.

  4. 4

    Read the state

    curl --request POST 'https://api.fx-port.com/api/v1/flights/get_acd_documents' \
      --header 'Authorization: Bearer fxp_test_YOUR_KEY' \
      --header 'Content-Type: application/json' \
      --data '{ "booking_id": "Y7HWMD" }'

    Expect acd_required: true, required_documents equal to the number of passengers, missing_traveler_ids: ["1"] and can_upload: true.

  5. 5

    Create two fake files and upload them

    echo 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4//8/AAX+Av4N70a4AAAAAElFTkSuQmCC' | base64 -d > passport-1.png
    printf '%%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 10 10]>>endobj\ntrailer<</Root 1 0 R>>\n%%%%EOF\n' > passport-2.pdf
    
    curl --request POST 'https://api.fx-port.com/api/v1/flights/upload_acd_documents' \
      --header 'Authorization: Bearer fxp_test_YOUR_KEY' \
      --form booking_id=Y7HWMD \
      --form traveler_ids=1 \
      --form [email protected]

    Use your own PNR. The booking is now acd_submitted: true. The answer says notification.reason: "sandbox_environment" (an issued booking) or "hold_booking" (a hold): the sandbox never sends the e-mail. The second file is for a booking with two passengers: the program examples send both.

How it works

  1. 1

    Check what is needed

    Call get_acd_documents with the booking. The response lists every traveler of the booking with a traveler_id, a status (missing or uploaded) and the ticket number when there is one. Each traveler needs one passport, so required_documents is the number of travelers.

  2. 2

    Upload passports

    Send upload_acd_documents as multipart/form-data. You can send one passport, several passports or all of them at once. Each file is linked to its traveler through traveler_ids.

  3. 3

    Submission completes automatically

    When every traveler has a passport, the booking switches to acd_submitted: true and FX-Port's ACD team is notified (see Notifications for when the e-mail is sent). If you send the passports over several requests, you do not need to do anything else.

  4. 4

    Replace a passport if needed

    To correct a wrong or unreadable scan, upload the new file for that traveler with replace=true. The new file becomes the current passport, the old one is kept in the history, and the ACD team is told about the change.

POST /api/v1/flights/get_acd_documents

Permission: Read. The body is JSON.

Request body

FieldTypeRequiredDescription
booking_idstringYesThe PNR, the supplier order id or the FX-Port booking UUID. order_id and pnr are accepted as other names of the same field. Letters, digits and - _ . : = ~, up to 128 characters.

Legacy names: bookingId and orderId still work and are answered with a deprecated_field warning. If booking_id is sent together with order_id or pnr and the values differ, booking_id wins and an ignored_field warning names the dropped one.

curl https://api.fx-port.com/api/v1/flights/get_acd_documents \
  -H "Authorization: Bearer fxp_test_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "booking_id": "Y7IA34"
}'

Response

A booking with three travelers, one passport uploaded. Real response of 2026-11-01, traveler data replaced by a fake name, bucket address replaced:

{
  "environment": "sandbox",
  "success": true,
  "requestId": "8201f6f6",
  "booking_id": "Y7IA34",
  "acd": {
    "booking_id": "Y7IA34",
    "acd_required": true,
    "acd_waived": false,
    "acd_submitted": false,
    "acd_paid": false,
    "complete": false,
    "can_upload": true,
    "blocked_reason": null,
    "required_documents": 3,
    "uploaded_documents": 1,
    "missing_documents": 2,
    "missing_traveler_ids": ["1", "3"],
    "travelers": [
      {
        "traveler_id": "1",
        "traveler_name": "JANE DOE",
        "traveler_type": "ADULT",
        "ticket_number": null,
        "status": "missing",
        "document": null
      },
      {
        "traveler_id": "2",
        "traveler_name": "JOHN DOE",
        "traveler_type": "ADULT",
        "ticket_number": null,
        "status": "uploaded",
        "document": {
          "filename": "john-passport.pdf",
          "content_type": "application/pdf",
          "size_bytes": 482113,
          "download_url": "https://s3.example.com/fx-port/acd_passports/Y7IA34/2/20261101142618_ec9632ab.pdf",
          "uploaded_at": "2026-11-01T14:26:18.120000+00:00",
          "replaced_count": 0
        }
      },
      {
        "traveler_id": "3",
        "traveler_name": "JAMES DOE",
        "traveler_type": "ADULT",
        "ticket_number": null,
        "status": "missing",
        "document": null
      }
    ],
    "unassigned_documents": [],
    "revision": 1,
    "limits": {
      "accepted_formats": ["avif", "bmp", "gif", "heif", "jp2", "jpeg", "jxl", "pdf", "png", "tiff", "webp"],
      "accepted_extensions": ["avif", "bmp", "dib", "gif", "heic", "heif", "j2k", "jfif", "jp2", "jpeg", "jpg", "jpx", "jxl", "pdf", "pjp", "pjpeg", "png", "tif", "tiff", "webp"],
      "max_file_size_bytes": 10485760,
      "max_request_size_bytes": 26214400,
      "max_documents_per_request": 3
    }
  }
}

The top-level booking_id is the PNR, whatever identifier you sent (twin: pnr). requestId has the twin request_id. Every other key is already snake_case.

acd fields

FieldTypeDescription
booking_idstringThe PNR of the booking (twin: pnr).
acd_requiredbooleantrue when the booking needs ACD passports and FX-Port did not waive them.
acd_waivedbooleantrue when FX-Port marked ACD as not required for this booking. No upload is accepted then. You cannot set it.
acd_submittedbooleantrue once every traveler has a passport. Never null: false before. Also true for a hold that has a complete set.
acd_paidbooleantrue once FX-Port's team has marked the ACD amount of the booking as paid. You cannot set it.
completebooleanEvery required passport is uploaded. Always false when ACD is not required.
can_uploadbooleanWhether upload_acd_documents accepts files for this booking right now.
blocked_reasonstring or nullWhy can_upload is false: see blocked_reason. null otherwise.
required_documentsintegerThe number of passports needed: one for each traveler of the booking. 0 when ACD is not required.
uploaded_documents, missing_documentsintegerCounts toward required_documents.
missing_traveler_idsstring[]The travelers who still need a passport. Use these values in traveler_ids.
travelers[]object[]Every traveler of the booking, listed even when ACD is not required.
unassigned_documents[]object[]Files that FX-Port holds for the booking but could not match to a traveler: { "download_url" }. They are kept, but they do not count as a traveler's passport.
revisionintegerIncreases by one with each upload that stored a file. 0 for a booking without any.
limitsobjectThe upload limits: see below. Read them instead of hardcoding them.

travelers[] fields

FieldTypeDescription
traveler_idstringThe id to send in traveler_ids. It is the id of the passenger on the booking: "1", "2", "3" (position) for Amadeus Algeria, pas_... for Duffel.
traveler_namestringThe name on the booking.
traveler_typestringThe passenger type, for example ADULT.
ticket_numberstring or nullThe e-ticket number. null until the airline returns one: always for a hold, and for an instant booking whose number has not arrived (the sandbox instant ACD booking in these examples never got one). Ask get_ticket_number to fetch it.
statusstringuploaded or missing.
documentobject or nullThe current passport file; null while the traveler is missing.
document.filenamestringThe file name you sent.
document.content_typestringThe type FX-Port detected from the content (for example application/pdf, image/jpeg), not the one you sent.
document.size_bytesintegerThe size of the stored file.
document.download_urlstringA public address of the file. Treat it as sensitive: see Security.
document.uploaded_atstringISO 8601, UTC, with +00:00.
document.replaced_countintegerHow many times the passport was replaced.

limits fields

FieldValue
accepted_formatsThe 11 formats (see Accepted files).
accepted_extensionsThe 20 extensions of those formats.
max_file_size_bytes10485760 (10 MiB).
max_request_size_bytes26214400 (25 MiB): all files of a request together.
max_documents_per_requestThe number of travelers of the booking.

A booking that does not need ACD

get_acd_documents does not fail for a booking without ACD: it answers 200, lists the travelers with status: "missing" and says why you cannot upload. Only upload_acd_documents answers 409 ACD_NOT_REQUIRED. Real response of 2026-11-01 for an Amadeus Algeria booking without ACD (limits shortened):

{
  "environment": "sandbox",
  "success": true,
  "requestId": "8b5b5c52",
  "booking_id": "Y7HKJM",
  "acd": {
    "booking_id": "Y7HKJM",
    "acd_required": false,
    "acd_waived": false,
    "acd_submitted": false,
    "acd_paid": false,
    "complete": false,
    "can_upload": false,
    "blocked_reason": "ACD_NOT_REQUIRED",
    "required_documents": 0,
    "uploaded_documents": 0,
    "missing_documents": 0,
    "missing_traveler_ids": [],
    "travelers": [
      {
        "traveler_id": "1",
        "traveler_name": "JOHN SMITH",
        "traveler_type": "ADULT",
        "ticket_number": "124-2446977395",
        "status": "missing",
        "document": null
      }
    ],
    "unassigned_documents": [],
    "revision": 0,
    "limits": { "max_file_size_bytes": 10485760, "max_request_size_bytes": 26214400, "max_documents_per_request": 1, "...": "..." }
  }
}

blocked_reason values

ValueMeaningReproduce in the sandbox
ACD_NOT_REQUIREDThe booking has no ACD (every Duffel booking, most Algerian routes).Yes: any booking without ACD.
ACD_WAIVEDFX-Port marked ACD as not required for this booking.No: an FX-Port setting.
BOOKING_NOT_ELIGIBLEThe status of the booking is cancelled, failed, voided, refunded or expired.Not run: cancel an ACD hold with cancel_order.
TRAVELERS_NOT_AVAILABLEThe booking has no traveler data, so files cannot be matched. Contact support.No.

The order of the checks is the order of the table: a booking without ACD is ACD_NOT_REQUIRED even if it is also cancelled.

Errors of get_acd_documents

MISSING_BOOKING_ID, INVALID_BOOKING_ID, INVALID_JSON, INVALID_BODY (400), BOOKING_NOT_FOUND (404), INTERNAL_ERROR (500): see Error codes. The authentication errors are described in Errors.

POST /api/v1/flights/upload_acd_documents

Permission: Read+Write. A Read key is refused with 403 read_write_permission_required before anything is read. The body is multipart/form-data: a JSON body is refused (415 UNSUPPORTED_MEDIA_TYPE).

Request (multipart/form-data)

FieldTypeRequiredDescription
booking_idtextYesThe PNR, the supplier order id or the FX-Port booking UUID. order_id and pnr are accepted as other names.
traveler_idstextYesThe travelers the files belong to, in the same order as documents. Send the field once for each file, or one comma-separated value (1,2), or a JSON array as text (["1","2"]). Spaces and empty entries are dropped. A traveler may appear once per request.
documentsfile, repeatedYesOne passport file for each traveler. The field name must be documents (the other names below are accepted as legacy).
replacetextNotrue to overwrite travelers that already have a passport. true, 1, yes, on and false, 0, no, off in any letter case; empty means false; anything else is 400 INVALID_REPLACE_FLAG. Default false.

Rules of the multipart request

  • Matched by order. The first traveler_ids value goes with the first documents file, the second with the second, and so on. The two lists must have the same length (DOCUMENT_COUNT_MISMATCH) and a traveler can appear once per request (DUPLICATE_TRAVELER_ID).
  • Fewer files than travelers is fine. Send what you have; the booking stays acd_submitted: false until the last missing traveler is uploaded. More files than travelers on the booking is TOO_MANY_DOCUMENTS.
  • replace is request-wide. With replace=true, travelers who already have a passport get it replaced (status: "replaced") and travelers who have none are added (status: "uploaded"), so you can do both in one request.
  • Do not set the Content-Type header yourself in an HTTP library: it must carry the multipart boundary that the library creates.
  • The content decides. The file name and the Content-Type of a part are not used to accept a file (Accepted files).
  • Limits of the request: at most 40 files, 64 form fields and 64 KiB for one text field, and 10 MiB per file and 25 MiB for all files together (Size limits).
  • Nothing is stored unless everything is valid. If any file is rejected, no file of the request is stored and the booking does not change.

Legacy names

These spellings still work and are answered with a deprecated_field warning. Send the name in the second column.

Legacy nameUse instead
bookingId, orderIdbooking_id
travelerIds, travelerId, traveler_idtraveler_ids
files, file, document, documents[]documents

order_id and pnr are other names of booking_id that are not deprecated. When the form holds several spellings of the booking with different values, the first one of this list wins and an ignored_field warning names each dropped one: booking_id, bookingId, order_id, orderId, pnr. The same booking twice, or an empty value, gives no ignored_field warning.

Upload examples

The same request in every language of the site. The program sends two passports for one booking (traveler 1 and traveler 2, matched by order) and reads the answer the way the sections below describe it. Put your PNR in BOOKING_ID, the traveler_id values of get_acd_documents in PASSPORTS and the paths of your files. For the sandbox, create passport-1.png and passport-2.pdf as shown in Test it in the sandbox.

Terminal
# traveler_ids and documents are matched by order: the first id goes with the first file.
# Do not set Content-Type yourself: curl sends multipart/form-data with its own boundary.
curl --silent --show-error --write-out '\nHTTP %{http_code}\n' \
  --request POST "https://api.fx-port.com/api/v1/flights/upload_acd_documents" \
  --header "Authorization: Bearer fxp_test_YOUR_KEY" \
  --form booking_id=Y7IA34 \
  --form traveler_ids=1 \
  --form traveler_ids=2 \
  --form [email protected] \
  --form [email protected] \
  --form replace=false

Replace a passport that was already uploaded: the same request with replace=true (--form replace=true, or REPLACE = "true" in the programs).

curl --request POST 'https://api.fx-port.com/api/v1/flights/upload_acd_documents' \
  --header 'Authorization: Bearer fxp_test_YOUR_KEY' \
  --form booking_id=Y7IA34 \
  --form traveler_ids=2 \
  --form [email protected] \
  --form replace=true
  • Without replace=true, a file for a traveler who already has a passport is rejected with 409 DOCUMENT_ALREADY_UPLOADED and nothing is changed. This prevents accidental overwrites.
  • With replace=true, the new file becomes the current passport, the old one is kept in the history, status is replaced and document.replaced_count goes up by one.
  • If the booking was already fully submitted, the ACD team is told about the change (notification.type: "updated"). The booking stays acd_submitted: true.

Response: success (200)

Every file was stored. Real response of 2026-11-01 for the last two travelers of a three-traveler booking (fake names, bucket address replaced):

{
  "environment": "sandbox",
  "success": true,
  "status": "success",
  "requestId": "58bfcf4a",
  "booking_id": "Y7IA34",
  "message": "2 documents saved.",
  "summary": { "received": 2, "uploaded": 2, "replaced": 0, "failed": 0 },
  "results": [
    {
      "index": 0,
      "traveler_id": "1",
      "traveler_name": "JANE DOE",
      "filename": "passport-1.png",
      "status": "uploaded",
      "content_type": "image/png",
      "size_bytes": 69,
      "download_url": "https://s3.example.com/fx-port/acd_passports/Y7IA34/1/20261101142620_28e115c8.png"
    },
    {
      "index": 1,
      "traveler_id": "3",
      "traveler_name": "JAMES DOE",
      "filename": "passport-3.pdf",
      "status": "uploaded",
      "content_type": "application/pdf",
      "size_bytes": 191,
      "download_url": "https://s3.example.com/fx-port/acd_passports/Y7IA34/3/20261101142620_4194315e.pdf"
    }
  ],
  "acd": {
    "booking_id": "Y7IA34",
    "acd_required": true,
    "acd_submitted": true,
    "complete": true,
    "can_upload": true,
    "required_documents": 3,
    "uploaded_documents": 3,
    "missing_documents": 0,
    "missing_traveler_ids": [],
    "revision": 2,
    "...": "same shape as get_acd_documents"
  },
  "notification": { "sent": false, "type": "submitted", "skipped": true, "reason": "sandbox_environment" },
  "warnings": []
}
FieldDescription
statussuccess (every file stored) or partial_success (some stored, some failed: HTTP 207).
messageA sentence: 1 document saved., 2 documents saved..
summaryThe counts of this request: received, uploaded (new passports), replaced, failed.
results[]One entry for each file, in the order you sent them: see below.
acdThe state of the booking after this request, in the shape of get_acd_documents.
notificationWhether FX-Port's ACD team was notified: see Notifications.
warnings[]Problems that did not stop the upload, such as EMAIL_SEND_FAILED, and notices about your request (deprecated_field). Your files are saved.

results[] fields

FieldDescription
indexThe position of the file in your request, from 0.
traveler_id, traveler_nameThe traveler of this file. traveler_name is null when the id is not on the booking.
filenameThe file name you sent (document-<n> when the part had none).
statusuploaded (a new passport), replaced (a passport that existed was replaced), failed (this file has a problem, see error), skipped (this file is valid, but another file of the request failed, so it was not processed).
content_type, size_bytes, download_urlOnly for uploaded and replaced: the detected type, the size and the public address of the stored file.
errorOnly for failed: { "code", "message" } and, depending on the code, expected_traveler_ids, accepted_extensions, max_file_size_bytes or reason (storage_error, for STORAGE_UPLOAD_FAILED).

Response: partial success (207)

The files are validated together first. If they are valid, they are stored one by one. If the storage fails for some of them, the files that were stored are kept and the answer is HTTP 207 with success: true and status: "partial_success". This cannot be provoked in the sandbox; the shape comes from the service code and its tests:

{
  "success": true,
  "status": "partial_success",
  "message": "1 of 2 documents saved; 1 failed — retry the failed travelers.",
  "summary": { "received": 2, "uploaded": 1, "replaced": 0, "failed": 1 },
  "results": [
    { "index": 0, "traveler_id": "1", "traveler_name": "JANE DOE", "filename": "passport-1.png", "status": "uploaded", "content_type": "image/png", "size_bytes": 69, "download_url": "https://s3.example.com/fx-port/acd_passports/Y7IA34/1/20261101142620_28e115c8.png" },
    {
      "index": 1,
      "traveler_id": "3",
      "traveler_name": "JAMES DOE",
      "filename": "passport-3.pdf",
      "status": "failed",
      "error": { "code": "STORAGE_UPLOAD_FAILED", "message": "The document could not be stored. Retry this traveler.", "reason": "storage_error" }
    }
  ],
  "acd": { "missing_traveler_ids": ["3"], "...": "..." },
  "notification": { "sent": false, "skipped": true, "reason": "awaiting_remaining_documents" },
  "warnings": []
}

Retry only the travelers listed in acd.missing_traveler_ids: their files were not saved, so replace=true is not needed. The reason of a failed storage is always the fixed text storage_error: the message of the storage system is never returned. A 207 has a second cause: another request stored a passport for the same traveler a moment before yours. That item is failed with DOCUMENT_ALREADY_UPLOADED.

Response: failure (4xx / 5xx)

The whole request is checked first, so a failure lists every problem at once, no file is stored and the booking does not change. Real response of 2026-11-01 (two files, two different problems; accepted_extensions shortened):

{
  "environment": "sandbox",
  "success": false,
  "status": "failed",
  "error": "validation_failed",
  "code": "VALIDATION_FAILED",
  "message": "2 of 2 documents were rejected. Nothing was uploaded.",
  "results": [
    {
      "index": 0,
      "traveler_id": "1",
      "traveler_name": "JANE DOE",
      "filename": "bad.pdf",
      "status": "failed",
      "error": {
        "code": "UNSUPPORTED_FILE_TYPE",
        "message": "'bad.pdf' is not a supported passport document. Upload a PDF or a JPEG, PNG, WebP, HEIC/HEIF, AVIF, TIFF, BMP, GIF, JPEG 2000 or JPEG XL image.",
        "accepted_extensions": ["avif", "bmp", "dib", "..."]
      }
    },
    {
      "index": 1,
      "traveler_id": "2",
      "traveler_name": "JOHN DOE",
      "filename": "empty.pdf",
      "status": "failed",
      "error": { "code": "EMPTY_FILE", "message": "Uploaded document 'empty.pdf' is empty." }
    }
  ],
  "acd": { "booking_id": "Y7IA34", "acd_required": true, "complete": false, "...": "the state, unchanged" },
  "requestId": "34d61957"
}
  • One problem for every rejected file, or only one file: code and the HTTP status are that problem. With several files the message starts with the count: 2 documents were rejected: A passport is already uploaded for traveler '1'. Send replace=true to replace it.
  • Different problems: VALIDATION_FAILED, with the status of the problems when they share one, 400 otherwise. Read results[].error.
  • Valid files of a failed request have status: "skipped". Send them again with the corrected request.
  • Request-shape errors (missing field, count mismatch ...) carry only counters: received_traveler_ids, received_documents, expected_travelers, duplicate_traveler_ids.
  • A failure that depends on the booking (its state, too many files, the total size) also carries the current acd state, so you do not need another call.

Real response of a booking that does not need ACD:

{
  "environment": "sandbox",
  "success": false,
  "status": "failed",
  "error": "acd_not_required",
  "code": "ACD_NOT_REQUIRED",
  "message": "This booking does not require ACD documents.",
  "acd": { "booking_id": "Y7HKJM", "acd_required": false, "can_upload": false, "blocked_reason": "ACD_NOT_REQUIRED", "...": "..." },
  "requestId": "5b73272a"
}

The order of the checks

A request is checked in this order; the first problem found is the answer. This is what makes the same mistake answer the same code every time:

  1. Authentication, permission, rate limit and size of the whole body (413 request_too_large above 26 MiB).
  2. The body is multipart (UNSUPPORTED_MEDIA_TYPE, INVALID_MULTIPART).
  3. booking_id (MISSING_BOOKING_ID, INVALID_BOOKING_ID), then replace (INVALID_REPLACE_FLAG).
  4. MISSING_TRAVELER_IDS or MISSING_DOCUMENTS, then DOCUMENT_COUNT_MISMATCH, then DUPLICATE_TRAVELER_ID.
  5. The booking is looked up among the bookings of your agency and of your environment (BOOKING_NOT_FOUND). A booking of another agency is not found: it answers exactly like an id that does not exist.
  6. The state of the booking (409: ACD_NOT_REQUIRED, ACD_WAIVED, BOOKING_NOT_ELIGIBLE, TRAVELERS_NOT_AVAILABLE). This comes before the files are looked at: a booking without ACD answers 409 even for an unreadable file.
  7. TOO_MANY_DOCUMENTS.
  8. Each file, in order, and the first problem of a file wins: UNKNOWN_TRAVELER_ID, DOCUMENT_ALREADY_UPLOADED (unless replace), EMPTY_FILE, FILE_TOO_LARGE, UNSUPPORTED_FILE_TYPE.
  9. REQUEST_TOO_LARGE (the files together are over 25 MiB). It wins over the problems of step 8.
  10. If any file failed: nothing is stored; VALIDATION_FAILED or the common code.
  11. Storage (STORAGE_UNAVAILABLE, STORAGE_UPLOAD_FAILED) and the write (CONCURRENT_UPDATE).

Accepted files

FX-Port checks the actual content of a file. The file name and the Content-Type you send are not used to decide whether a file is accepted. A text file renamed to .pdf is rejected with UNSUPPORTED_FILE_TYPE; a PDF named passport.jpg is accepted. The stored extension follows the detected format.

FormatExtensionsHow it is recognised
PDF.pdf%PDF- in the first 1 KB
JPEG.jpg, .jpeg, .jfif, .pjpeg, .pjpThe JPEG signature
PNG.pngThe PNG signature
WebP.webpRIFF with WEBP
HEIC / HEIF (iPhone photos).heic, .heifAn ISO media ftyp box with a HEIF brand
AVIF.avifAn ISO media ftyp box with an AVIF brand
TIFF (scanners).tif, .tiffThe TIFF signature (classic and BigTIFF)
BMP.bmp, .dibBM and a known DIB header
GIF.gifGIF87a or GIF89a
JPEG 2000.jp2, .jpx, .j2kThe JP2 signature or a J2K codestream
JPEG XL.jxlThe JPEG XL signature

SVG, Office documents, archives (ZIP) and every other type are not accepted. An empty file is EMPTY_FILE.

Size limits

LimitValueError when exceeded
Per file10 MiB (10,485,760 bytes). A file of 10 MiB + 1 byte is refused.413 FILE_TOO_LARGE
All files of a request together25 MiB (26,214,400 bytes)413 REQUEST_TOO_LARGE
The whole request body26 MiB413 request_too_large, answered by the platform before the request is read
Files per requestUp to the number of travelers of the booking, one file per traveler, and at most 40400 TOO_MANY_DOCUMENTS, 400 DUPLICATE_TRAVELER_ID, 400 INVALID_MULTIPART

The messages of the API say "MB"; the numbers are MiB (1,048,576 bytes). If a large group would go over 25 MiB, split the passports over several requests: each request adds to what was already uploaded. get_acd_documents returns the limits in acd.limits.

Notifications

When a passport set becomes complete, FX-Port's ACD team is told by e-mail. The e-mail goes to FX-Port, not to you or to the traveler; notification only reports what happened, and nothing has to be done with it. The API sends it from an upload only: paying a hold does not send it.

SituationnotificationSandbox
The set is still incomplete after this request{ "sent": false, "skipped": true, "reason": "awaiting_remaining_documents" }Yes
The set is complete on a hold (unpaid) booking{ "sent": false, "skipped": true, "reason": "hold_booking" }Yes
The set became complete on an issued bookingLive: { "sent": true, "type": "submitted", "skipped": false }. Sandbox: { "sent": false, "type": "submitted", "skipped": true, "reason": "sandbox_environment" }Yes (sandbox form)
A passport was replaced on a booking that was already completeLive: { "sent": true, "type": "updated", "skipped": false }. Sandbox: { "sent": false, "type": "updated", "skipped": true, "reason": "sandbox_environment" }Yes (sandbox form)
The e-mail could not be sent{ "sent": false, "type": "...", "error": "EMAIL_SEND_FAILED", "reason": "email_error" } and a warnings[] entry EMAIL_SEND_FAILED. reason is a fixed text: the mail system's own message is never returned (quote the requestId to support). Your files are saved and the team still sees them in its queue.No

The sandbox never sends the e-mail. Only the type key is missing from the first two forms.

Hold bookings

A hold has a traveler list and no tickets, and it accepts uploads:

  • get_acd_documents lists the travelers with ticket_number: null, and can_upload is true.
  • When the set is complete, acd_submitted becomes true before payment, and the answer says notification.reason: "hold_booking": no e-mail is sent for an unpaid hold.
  • When you pay the hold with pay_order the files stay and the booking stays acd_submitted: true. The e-mail to the ACD team is sent from an upload on an issued booking: the first complete set (submitted) or the replacement of a passport of a complete set (updated).
  • A hold that is not paid in time is cancelled by FX-Port, and a hold that you cancel with cancel_order is cancelled at once: the booking is then cancelled and refuses uploads (BOOKING_NOT_ELIGIBLE).

Error codes

Every error of these endpoints is a flat body: success: false, status: "failed", error (the code in lower case), code (the code in upper case), message and the keys listed with the code, plus requestId. The unified error body of Errors is added next to them: errors[0].code is the lower-case code (acd_not_required) and errors[0].type follows the status (validation_error, not_found, conflict, internal_error). The codes below are written in upper case, as in code.

The last column says whether you can provoke the error in the sandbox: Yes (reproduced on 2026-11-01), Not run (should be reproducible, not run), No (cannot be provoked from outside; the shape is taken from the service code and its tests).

HTTPcodeWhenWhat to doSandbox
400MISSING_BOOKING_IDNo booking_id (a JSON field, or a form field for the upload).Send it.Yes: leave it out.
400INVALID_BOOKING_IDbooking_id is not an identifier: letters, digits and - _ . : = ~, up to 128 characters.Send the PNR, order id or UUID as returned by book_flight.Not run: send A B.
400INVALID_JSONget_acd_documents: the body is not valid JSON.Send one JSON object with Content-Type: application/json.Not run: send {.
400INVALID_BODYget_acd_documents: the body is JSON but not an object, or is nested too deeply, or holds a text value that is too long.Send one JSON object.Not run: send [].
400INVALID_MULTIPARTThe multipart body cannot be read, or has more than 40 files, 64 fields or a text field over 64 KiB.Check the form; split a huge upload.Not run.
400MISSING_TRAVELER_IDSNo traveler_ids.Send one for each file.Yes: leave it out.
400MISSING_DOCUMENTSNo file in documents.Send at least one file.Yes: send no file.
400DOCUMENT_COUNT_MISMATCHtraveler_ids and documents have different lengths. The body has received_traveler_ids and received_documents.They are matched by order: one id for each file.Yes: traveler_ids=1,2 with one file.
400DUPLICATE_TRAVELER_IDA traveler appears twice. The body has duplicate_traveler_ids.One file for each traveler and request.Yes: traveler_ids=1,1.
400TOO_MANY_DOCUMENTSMore files than travelers on the booking. The body has expected_travelers and received_documents.Send at most one file for each traveler.Yes: two files for a one-traveler booking.
400UNKNOWN_TRAVELER_IDA traveler is not on the booking (results[].error.expected_traveler_ids lists the valid ones).Use the traveler_id values of get_acd_documents.Yes: traveler_ids=9.
400EMPTY_FILEA file has no content.Send the real file.Yes: an empty file.
400INVALID_REPLACE_FLAGreplace is not true/false (or 1, yes, on, 0, no, off).Send true or false.Yes: replace=maybe.
400VALIDATION_FAILEDSeveral files have different problems.Read results[].error, fix, send again.Yes: two bad files.
401authentication_required, invalid_api_keyThe key is missing or invalid. These errors use the {"detail": {...}} body of Errors.Send Authorization: Bearer <key>.Yes: no key.
403read_write_permission_requiredupload_acd_documents with a Read key. Body: {"detail": {"error": "read_write_permission_required", ...}}.Use a Read+Write key.Yes: a Read key.
404BOOKING_NOT_FOUNDNone of your bookings in the environment of your key matches booking_id: the id is unknown, or the booking belongs to another agency or to the other environment (all three answer the same way).Check the id and the environment of the key.Yes: an unknown id.
409DOCUMENT_ALREADY_UPLOADEDThe traveler already has a passport. Nothing was changed.Send replace=true to replace it.Yes: upload twice.
409ACD_NOT_REQUIREDThe booking does not need ACD documents. The body carries acd.Do not upload.Yes: a booking without ACD.
409ACD_WAIVEDFX-Port marked ACD as not required for this booking.Do not upload.No.
409BOOKING_NOT_ELIGIBLEThe booking is cancelled, failed, voided, refunded or expired. The body has booking_status.Do not upload.Not run: cancel an ACD hold.
409TRAVELERS_NOT_AVAILABLEThe booking has no traveler data.Contact support.No.
409CONCURRENT_UPDATEAnother request changed the documents of this booking at the same moment.Call get_acd_documents, then send what is still missing.No.
413FILE_TOO_LARGEOne file is over 10 MiB (max_file_size_bytes).Compress or rescan it.Yes: 10 MiB + 1 byte.
413REQUEST_TOO_LARGEThe files together are over 25 MiB. The body has max_request_size_bytes.Split them over several requests.Yes: three files of 9.5 MiB.
413request_too_largeThe whole body is over 26 MiB; refused by the platform, with max_bytes.Split the upload.Not run.
415UNSUPPORTED_FILE_TYPEThe content is not a PDF or a supported image. results[].error.accepted_extensions lists the extensions.Send a PDF, JPEG, PNG, WebP, HEIC/HEIF, AVIF, TIFF, BMP, GIF, JPEG 2000 or JPEG XL file.Yes: text renamed .pdf.
415UNSUPPORTED_MEDIA_TYPEupload_acd_documents was not sent as multipart/form-data.Send a multipart form.Not run: send JSON.
429rate_limit_exceededToo many requests.Wait for the Retry-After seconds.No.
502STORAGE_UPLOAD_FAILEDNo file could be stored. Nothing changed. Every results[].error has reason: "storage_error", a fixed text: the storage's own message is never returned. Cloudflare can replace the body of a 502 by its own HTML page: treat any 502 of this endpoint the same way.Retry the request.No.
503STORAGE_UNAVAILABLEDocument storage is not available.Retry later.No.
500INTERNAL_ERRORUnexpected error.Call get_acd_documents to see what was stored before you retry.No.

Which bookings you can reach

A key finds only the bookings of its own agency and of its own environment. These three cases answer the same 404 BOOKING_NOT_FOUND, with the same message (No flight booking found for '<booking_id>'.) and the same shape, so an answer never tells you whether a booking exists:

  • an id that does not exist;
  • a booking of another agency;
  • a booking of the other environment (a sandbox key does not read a live booking, and the other way round).

A booking that has no agency attached is hidden from API keys as well. The lookup runs among your own bookings, so when two agencies hold the same PNR, each one finds its own. 403 BOOKING_FORBIDDEN exists only for FX-Port's own tools (the dashboard and internal callers): a key never receives it.

Retries and idempotency

  • Read before you write. Call get_acd_documents first and use its traveler_id values and missing_traveler_ids. Do not build traveler ids yourself.
  • Without replace, a retry is safe. A traveler who was already saved is rejected with DOCUMENT_ALREADY_UPLOADED and nothing changes, so a passport is never overwritten without you asking for it. Retry only the travelers in missing_traveler_ids.
  • With replace=true, a retry is not harmless. It replaces the file again: replaced_count goes up, revision goes up, and in the live environment the ACD team gets another updated e-mail. Use replace=true only for a correction.
  • After a timeout or a dropped connection, do not resend blindly: call get_acd_documents and send what is missing.
  • Send one request at a time for a booking. Two requests that write the same booking at the same moment can end with CONCURRENT_UPDATE; read the state and send again.
  • Read the status. 200 means every file was saved, 207 means only some were (read results), 4xx means nothing changed. A 5xx after the files were stored cannot be told from the outside: call get_acd_documents.
  • 502 STORAGE_UPLOAD_FAILED and 503 STORAGE_UNAVAILABLE changed nothing: retry the same request after a short wait.

Security: file links are public

download_url (here and in get_ticket, bucket_link in other answers) is an address anyone can open without a key. It is the only protection a file has: whoever holds the address can download the passport.

  • Treat every download_url as a secret: do not log it, do not put it in a URL you show to other users, do not send it by e-mail or to analytics, and do not store it longer than you need.
  • Download the file on your server when you need it, and give your users your own, access-controlled copy.
  • A passport copy is personal data. Collect it with the traveler's consent and keep it only as long as the booking needs it.
  • The sandbox stores files the same way: never upload a real passport in the sandbox.

Same conventions as the rest of the flights API

TopicIn these endpoints
Identifierbooking_id takes the PNR, the supplier order id or the FX-Port UUID, like get_booking, get_ticket and get_ticket_number. The lookup tries the PNR, then the order id, then the UUID (only a value shaped like a UUID), among the bookings of your agency. PNRs are not unique across suppliers: prefer the UUID or the order id. The answer's booking_id is always the PNR (twin: pnr).
NamesRequests use snake_case (booking_id, traveler_ids, documents, replace); the older spellings still work (Legacy names). The answers are snake_case except requestId (twin: request_id). ?naming=snake returns every key in snake_case: see Conventions.
Request idrequestId in the body (twin request_id) and the X-Request-Id header. Quote it when you contact support.
ErrorsThe flat body above, plus the unified errors[] of Errors. Authentication and permission errors use the {"detail": {...}} body.
Permissionget_acd_documents is Read. upload_acd_documents is Read+Write: a Read key gets 403 read_write_permission_required.
EnvironmentEvery answer starts with "environment". A key sees only the bookings of its own environment and agency; the others answer 404 BOOKING_NOT_FOUND, like an unknown id (Which bookings you can reach).
Rate limitThe default group: 6,000 requests per minute per key. The body of an upload is limited to 26 MiB. See Rate limits.
WarningsA legacy spelling is answered with a top-level warnings entry deprecated_field; an unknown field is noticed as ignored_field. See Legacy names.

Integration tips

  • Sandbox keys work with sandbox bookings only. Files are stored and the state is tracked like in production, but no e-mail is sent.
  • Dashboard uploads count too. Passports uploaded from the FX-Port dashboard appear in get_acd_documents, and you can replace them through the API.
  • Voided travelers drop out. The number of required passports follows the travelers that are still on the booking: a passenger removed by a void is no longer in travelers and no longer counts toward required_documents.
  • Send a User-Agent of your own. Cloudflare blocks the default agents of Python's urllib and Perl's libwww with an error 1010 page before the request reaches the API.

Related

  • Flight pricing: the acd block of the price.
  • Book a flight, Pay a held order and Cancel a held order.
  • Get a flight booking: require_acd and acd_submitted of a booking.
  • Sandbox and Errors.
PreviousGet the ticket numberNext Hotels API overview

On this page

  • What ACD means for a booking
  • Recognise an ACD booking
  • Test it in the sandbox
  • How it works
  • POST /api/v1/flights/get_acd_documents
  • Request body
  • Response
  • acd fields
  • travelers[] fields
  • limits fields
  • A booking that does not need ACD
  • blocked_reason values
  • Errors of get_acd_documents
  • POST /api/v1/flights/upload_acd_documents
  • Request (multipart/form-data)
  • Rules of the multipart request
  • Legacy names
  • Upload examples
  • Response: success (200)
  • results[] fields
  • Response: partial success (207)
  • Response: failure (4xx / 5xx)
  • The order of the checks
  • Accepted files
  • Size limits
  • Notifications
  • Hold bookings
  • Error codes
  • Which bookings you can reach
  • Retries and idempotency
  • Security: file links are public
  • Same conventions as the rest of the flights API
  • Integration tips
  • Related

© 2026 FX-Port

TermsPrivacy[email protected]