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.
| Method | Path | Permission | Body | Description |
|---|---|---|---|---|
POST | / | Read | JSON | The ACD state of a booking: how many passports are needed, which travelers have one, the upload limits |
POST | / | Read+Write | multipart/ | Add 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_numberisnullfor 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
acdkey, andget_acd_documentsanswersacd_required: false.
Recognise an ACD booking
The flag has several spellings, one for each step of the booking flow:
| Where | Field | For an ACD booking |
|---|---|---|
Search offers (get_flights) and priced offers (price_flight) | pricing.acd.applied | true. false on the other Amadeus Algeria offers; Duffel offers have no acd key. |
| The same offers | requiresACD | true. Top level of the offer, present only when true. |
book_flight and pay_order answers | pricing.acd.applied, requiresACD | The same two fields, copied from the priced offer. |
get_booking | booking.require_acd | true. Twins: booking.acd_required, booking.requires_acd. |
| Account history and booking detail | requireAcd and record.require_acd | true |
get_acd_documents | acd.acd_required | true. The value to trust before you upload: it is false when FX-Port waived ACD for the booking (acd.acd_waived). |
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.
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.appliedof the offer you book. In a search that also covers Duffel (nosupplier_id), only the Amadeus Algeria offers have it.Price the offer
Send the
searchIdof the supplier entry and theidof the offer toprice_flight(search_id,offer_id). The priced offer haspricing.acdandrequiresACDagain.Book it
book_flightwith"booking_type": "issue"(instant) or"hold", and"send_email": false. The answer says"requiresACD": true. Keep the PNR (booking.pnr): it is thebooking_idof 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 aFAILED-…record: see the errors of the book call.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_documentsequal to the number of passengers,missing_traveler_ids: ["1"]andcan_upload: true.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 saysnotification.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
Check what is needed
Call
get_acd_documentswith the booking. The response lists every traveler of the booking with atraveler_id, astatus(missingoruploaded) and the ticket number when there is one. Each traveler needs one passport, sorequired_documentsis the number of travelers.Upload passports
Send
upload_acd_documentsasmultipart/. You can send one passport, several passports or all of them at once. Each file is linked to its traveler throughform-data traveler_ids.Submission completes automatically
When every traveler has a passport, the booking switches to
acd_submitted: trueand 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.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
| Field | Type | Required | Description |
|---|---|---|---|
booking_id | string | Yes | The 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
| Field | Type | Description |
|---|---|---|
booking_id | string | The PNR of the booking (twin: pnr). |
acd_required | boolean | true when the booking needs ACD passports and FX-Port did not waive them. |
acd_waived | boolean | true when FX-Port marked ACD as not required for this booking. No upload is accepted then. You cannot set it. |
acd_submitted | boolean | true once every traveler has a passport. Never null: false before. Also true for a hold that has a complete set. |
acd_paid | boolean | true once FX-Port's team has marked the ACD amount of the booking as paid. You cannot set it. |
complete | boolean | Every required passport is uploaded. Always false when ACD is not required. |
can_upload | boolean | Whether upload_acd_documents accepts files for this booking right now. |
blocked_reason | string or null | Why can_upload is false: see blocked_reason. null otherwise. |
required_documents | integer | The number of passports needed: one for each traveler of the booking. 0 when ACD is not required. |
uploaded_documents, missing_documents | integer | Counts toward required_documents. |
missing_traveler_ids | string[] | 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. |
revision | integer | Increases by one with each upload that stored a file. 0 for a booking without any. |
limits | object | The upload limits: see below. Read them instead of hardcoding them. |
travelers[] fields
| Field | Type | Description |
|---|---|---|
traveler_id | string | The 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_name | string | The name on the booking. |
traveler_type | string | The passenger type, for example ADULT. |
ticket_number | string or null | The 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. |
status | string | uploaded or missing. |
document | object or null | The current passport file; null while the traveler is missing. |
document.filename | string | The file name you sent. |
document.content_type | string | The type FX-Port detected from the content (for example application/, image/), not the one you sent. |
document.size_bytes | integer | The size of the stored file. |
document.download_url | string | A public address of the file. Treat it as sensitive: see Security. |
document.uploaded_at | string | ISO 8601, UTC, with +00:00. |
document.replaced_count | integer | How many times the passport was replaced. |
limits fields
| Field | Value |
|---|---|
accepted_formats | The 11 formats (see Accepted files). |
accepted_extensions | The 20 extensions of those formats. |
max_file_size_bytes | 10485760 (10 MiB). |
max_request_size_bytes | 26214400 (25 MiB): all files of a request together. |
max_documents_per_request | The 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
| Value | Meaning | Reproduce in the sandbox |
|---|---|---|
ACD_NOT_REQUIRED | The booking has no ACD (every Duffel booking, most Algerian routes). | Yes: any booking without ACD. |
ACD_WAIVED | FX-Port marked ACD as not required for this booking. | No: an FX-Port setting. |
BOOKING_NOT_ELIGIBLE | The status of the booking is cancelled, failed, voided, refunded or expired. | Not run: cancel an ACD hold with cancel_order. |
TRAVELERS_NOT_AVAILABLE | The 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/: a JSON body is refused (415 UNSUPPORTED_MEDIA_TYPE).
Request (multipart/ form-data)
| Field | Type | Required | Description |
|---|---|---|---|
booking_id | text | Yes | The PNR, the supplier order id or the FX-Port booking UUID. order_id and pnr are accepted as other names. |
traveler_ids | text | Yes | The 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. |
documents | file, repeated | Yes | One passport file for each traveler. The field name must be documents (the other names below are accepted as legacy). |
replace | text | No | true 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_idsvalue goes with the firstdocumentsfile, 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: falseuntil the last missing traveler is uploaded. More files than travelers on the booking isTOO_MANY_DOCUMENTS. replaceis request-wide. Withreplace=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-Typeheader yourself in an HTTP library: it must carry the multipart boundary that the library creates. - The content decides. The file name and the
Content-Typeof 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 name | Use instead |
|---|---|
bookingId, orderId | booking_id |
travelerIds, travelerId, traveler_id | traveler_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.
# 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=falseReplace 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 with409 DOCUMENT_ALREADY_UPLOADEDand 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,statusisreplacedanddocument.replaced_countgoes up by one. - If the booking was already fully submitted, the ACD team is told about the change (
notification.type: "updated"). The booking staysacd_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": []
}| Field | Description |
|---|---|
status | success (every file stored) or partial_success (some stored, some failed: HTTP 207). |
message | A sentence: 1 document saved., 2 documents saved.. |
summary | The counts of this request: received, uploaded (new passports), replaced, failed. |
results[] | One entry for each file, in the order you sent them: see below. |
acd | The state of the booking after this request, in the shape of get_acd_documents. |
notification | Whether 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
| Field | Description |
|---|---|
index | The position of the file in your request, from 0. |
traveler_id, traveler_name | The traveler of this file. traveler_name is null when the id is not on the booking. |
filename | The file name you sent (document-<n> when the part had none). |
status | uploaded (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_url | Only for uploaded and replaced: the detected type, the size and the public address of the stored file. |
error | Only 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:
codeand 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,400otherwise. Readresults[].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
acdstate, 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:
- Authentication, permission, rate limit and size of the whole body (
413 request_too_largeabove 26 MiB). - The body is multipart (
UNSUPPORTED_MEDIA_TYPE,INVALID_MULTIPART). booking_id(MISSING_BOOKING_ID,INVALID_BOOKING_ID), thenreplace(INVALID_REPLACE_FLAG).MISSING_TRAVELER_IDSorMISSING_DOCUMENTS, thenDOCUMENT_COUNT_MISMATCH, thenDUPLICATE_TRAVELER_ID.- 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. - 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 answers409even for an unreadable file. TOO_MANY_DOCUMENTS.- Each file, in order, and the first problem of a file wins:
UNKNOWN_TRAVELER_ID,DOCUMENT_ALREADY_UPLOADED(unlessreplace),EMPTY_FILE,FILE_TOO_LARGE,UNSUPPORTED_FILE_TYPE. REQUEST_TOO_LARGE(the files together are over 25 MiB). It wins over the problems of step 8.- If any file failed: nothing is stored;
VALIDATION_FAILEDor the common code. - 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.
| Format | Extensions | How it is recognised |
|---|---|---|
.pdf | %PDF- in the first 1 KB | |
| JPEG | .jpg, .jpeg, .jfif, .pjpeg, .pjp | The JPEG signature |
| PNG | .png | The PNG signature |
| WebP | .webp | RIFF with WEBP |
| HEIC / HEIF (iPhone photos) | .heic, .heif | An ISO media ftyp box with a HEIF brand |
| AVIF | .avif | An ISO media ftyp box with an AVIF brand |
| TIFF (scanners) | .tif, .tiff | The TIFF signature (classic and BigTIFF) |
| BMP | .bmp, .dib | BM and a known DIB header |
| GIF | .gif | GIF87a or GIF89a |
| JPEG 2000 | .jp2, .jpx, .j2k | The JP2 signature or a J2K codestream |
| JPEG XL | .jxl | The JPEG XL signature |
SVG, Office documents, archives (ZIP) and every other type are not accepted. An empty file is EMPTY_FILE.
Size limits
| Limit | Value | Error when exceeded |
|---|---|---|
| Per file | 10 MiB (10,485,760 bytes). A file of 10 MiB + 1 byte is refused. | 413 FILE_TOO_LARGE |
| All files of a request together | 25 MiB (26,214,400 bytes) | 413 REQUEST_TOO_LARGE |
| The whole request body | 26 MiB | 413 request_too_large, answered by the platform before the request is read |
| Files per request | Up to the number of travelers of the booking, one file per traveler, and at most 40 | 400 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.
| Situation | notification | Sandbox |
|---|---|---|
| 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 booking | Live: { "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 complete | Live: { "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_documentslists the travelers withticket_number: null, andcan_uploadistrue.- When the set is complete,
acd_submittedbecomestruebefore payment, and the answer saysnotification.reason: "hold_booking": no e-mail is sent for an unpaid hold. - When you pay the hold with
pay_orderthe files stay and the booking staysacd_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_orderis cancelled at once: the booking is thencancelledand 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).
| HTTP | code | When | What to do | Sandbox |
|---|---|---|---|---|
400 | MISSING_BOOKING_ID | No booking_id (a JSON field, or a form field for the upload). | Send it. | Yes: leave it out. |
400 | INVALID_BOOKING_ID | booking_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. |
400 | INVALID_JSON | get_acd_documents: the body is not valid JSON. | Send one JSON object with Content-Type: application/. | Not run: send {. |
400 | INVALID_BODY | get_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 []. |
400 | INVALID_MULTIPART | The 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. |
400 | MISSING_TRAVELER_IDS | No traveler_ids. | Send one for each file. | Yes: leave it out. |
400 | MISSING_DOCUMENTS | No file in documents. | Send at least one file. | Yes: send no file. |
400 | DOCUMENT_COUNT_MISMATCH | traveler_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. |
400 | DUPLICATE_TRAVELER_ID | A traveler appears twice. The body has duplicate_traveler_ids. | One file for each traveler and request. | Yes: traveler_ids=1,1. |
400 | TOO_MANY_DOCUMENTS | More 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. |
400 | UNKNOWN_TRAVELER_ID | A 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. |
400 | EMPTY_FILE | A file has no content. | Send the real file. | Yes: an empty file. |
400 | INVALID_REPLACE_FLAG | replace is not true/false (or 1, yes, on, 0, no, off). | Send true or false. | Yes: replace=maybe. |
400 | VALIDATION_FAILED | Several files have different problems. | Read results[].error, fix, send again. | Yes: two bad files. |
401 | authentication_required, invalid_api_key | The key is missing or invalid. These errors use the {"detail": {...}} body of Errors. | Send Authorization: Bearer <key>. | Yes: no key. |
403 | read_write_permission_required | upload_acd_documents with a Read key. Body: {"detail": {"error": "read_write_permission_required", ...}}. | Use a Read+Write key. | Yes: a Read key. |
404 | BOOKING_NOT_FOUND | None 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. |
409 | DOCUMENT_ALREADY_UPLOADED | The traveler already has a passport. Nothing was changed. | Send replace=true to replace it. | Yes: upload twice. |
409 | ACD_NOT_REQUIRED | The booking does not need ACD documents. The body carries acd. | Do not upload. | Yes: a booking without ACD. |
409 | ACD_WAIVED | FX-Port marked ACD as not required for this booking. | Do not upload. | No. |
409 | BOOKING_NOT_ELIGIBLE | The booking is cancelled, failed, voided, refunded or expired. The body has booking_status. | Do not upload. | Not run: cancel an ACD hold. |
409 | TRAVELERS_NOT_AVAILABLE | The booking has no traveler data. | Contact support. | No. |
409 | CONCURRENT_UPDATE | Another request changed the documents of this booking at the same moment. | Call get_acd_documents, then send what is still missing. | No. |
413 | FILE_TOO_LARGE | One file is over 10 MiB (max_file_size_bytes). | Compress or rescan it. | Yes: 10 MiB + 1 byte. |
413 | REQUEST_TOO_LARGE | The 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. |
413 | request_too_large | The whole body is over 26 MiB; refused by the platform, with max_bytes. | Split the upload. | Not run. |
415 | UNSUPPORTED_FILE_TYPE | The 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. |
415 | UNSUPPORTED_MEDIA_TYPE | upload_acd_documents was not sent as multipart/. | Send a multipart form. | Not run: send JSON. |
429 | rate_limit_exceeded | Too many requests. | Wait for the Retry-After seconds. | No. |
502 | STORAGE_UPLOAD_FAILED | No 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. |
503 | STORAGE_UNAVAILABLE | Document storage is not available. | Retry later. | No. |
500 | INTERNAL_ERROR | Unexpected 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_documentsfirst and use itstraveler_idvalues andmissing_traveler_ids. Do not build traveler ids yourself. - Without
replace, a retry is safe. A traveler who was already saved is rejected withDOCUMENT_ALREADY_UPLOADEDand nothing changes, so a passport is never overwritten without you asking for it. Retry only the travelers inmissing_traveler_ids. - With
replace=true, a retry is not harmless. It replaces the file again:replaced_countgoes up,revisiongoes up, and in the live environment the ACD team gets anotherupdatede-mail. Usereplace=trueonly for a correction. - After a timeout or a dropped connection, do not resend blindly: call
get_acd_documentsand 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.
200means every file was saved,207means only some were (readresults),4xxmeans nothing changed. A5xxafter the files were stored cannot be told from the outside: callget_acd_documents. 502 STORAGE_UPLOAD_FAILEDand503 STORAGE_UNAVAILABLEchanged 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_urlas 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
| Topic | In these endpoints |
|---|---|
| Identifier | booking_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). |
| Names | Requests 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 id | requestId in the body (twin request_id) and the X-Request-Id header. Quote it when you contact support. |
| Errors | The flat body above, plus the unified errors[] of Errors. Authentication and permission errors use the {"detail": {...}} body. |
| Permission | get_acd_documents is Read. upload_acd_documents is Read+Write: a Read key gets 403 read_write_permission_required. |
| Environment | Every 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 limit | The default group: 6,000 requests per minute per key. The body of an upload is limited to 26 MiB. See Rate limits. |
| Warnings | A 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
travelersand no longer counts towardrequired_documents. - Send a
User-Agentof your own. Cloudflare blocks the default agents of Python'surlliband Perl'slibwwwwith an error 1010 page before the request reaches the API.
Related
- Flight pricing: the
acdblock of the price. - Book a flight, Pay a held order and Cancel a held order.
- Get a flight booking:
require_acdandacd_submittedof a booking. - Sandbox and Errors.