# 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.

Endpoint: `POST /api/v1/flights/upload_acd_documents`

Source: https://fx-port.com/docs/flights/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` | `/api/v1/flights/get_acd_documents` | Read | JSON | The ACD state of a booking: how many passports are needed, which travelers have one, the upload limits |
| `POST` | `/api/v1/flights/upload_acd_documents` | **Read+Write** | `multipart/form-data` | 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](#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](https://fx-port.com/docs/flights/pricing.md#the-acd-block-amadeus-algeria)), 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](#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](#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:

| Where | Field | For an ACD booking |
|---|---|---|
| Search offers ([`get_flights`](https://fx-port.com/docs/flights/search.md)) and priced offers ([`price_flight`](https://fx-port.com/docs/flights/price.md)) | `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`](https://fx-port.com/docs/flights/book.md) and [`pay_order`](https://fx-port.com/docs/flights/pay-order.md) answers | `pricing.acd.applied`, `requiresACD` | The same two fields, copied from the priced offer. |
| [`get_booking`](https://fx-port.com/docs/flights/get-booking.md) | `booking.require_acd` | `true`. Twins: `booking.acd_required`, `booking.requires_acd`. |
| [Account history](https://fx-port.com/docs/account/history.md) and [booking detail](https://fx-port.com/docs/account/booking-detail.md) | `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`). |


> [!TIP]
> 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.


> [!WARNING]
> Sandbox uploads are really stored, and the file URL that comes back is **public** ([Security: file links are 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.

**Step 1: Search CDG → ALG on the ACD supplier**

~~~bash
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:

~~~json
{
  "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.

**Step 2: Price the offer**

Send the `searchId` of the supplier entry and the `id` of the offer to [`price_flight`](https://fx-port.com/docs/flights/price.md) (`search_id`, `offer_id`). The priced offer has `pricing.acd` and `requiresACD` again.

**Step 3: Book it**

[`book_flight`](https://fx-port.com/docs/flights/book.md) 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.

~~~json
{
  "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](https://fx-port.com/docs/flights/book.md#errors).

**Step 4: Read the state**

~~~bash
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`.

**Step 5: Create two fake files and upload them**

~~~bash
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 documents=@passport-1.png
~~~

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](#upload-examples) send both.


## How it works


**Step 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.

**Step 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`.

**Step 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](#notifications) for when the e-mail is sent). If you send the passports over several requests, you do not need to do anything else.

**Step 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

| 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.


**Request**

```bash
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:

~~~json
{
  "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`](#blocked_reason-values). `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`](https://fx-port.com/docs/flights/get-ticket-number.md) 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/pdf`, `image/jpeg`), 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](#security-file-links-are-public). |
| `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-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):

~~~json
{
  "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`](https://fx-port.com/docs/flights/cancel-order.md). |
| `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](#error-codes). The authentication errors are described in [Errors](https://fx-port.com/docs/errors.md).

## 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`)

| 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_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](#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](#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](#test-it-in-the-sandbox).


**Upload the passports of an ACD booking: one file per traveler, matched by order**

**cURL**

```bash
# 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 documents=@passport-1.png \
  --form documents=@passport-2.pdf \
  --form replace=false
```

**Node.js**

```javascript
// Node.js 18 or later. fetch, FormData and Blob are built in: no library is needed.
import { readFile } from "node:fs/promises";
import { basename } from "node:path";

const API_KEY = process.env.FXPORT_API_KEY ?? "fxp_test_YOUR_KEY";
const BASE_URL = "https://api.fx-port.com/api/v1";
const BOOKING_ID = "Y7IA34";
const REPLACE = "false"; // "true" overwrites a passport that was already uploaded

// One entry per traveler: the traveler_id from get_acd_documents and that traveler's passport file.
const PASSPORTS = [
  ["1", "passport-1.png"],
  ["2", "passport-2.pdf"],
];

const form = new FormData();
form.append("booking_id", BOOKING_ID);
for (const [travelerId] of PASSPORTS) form.append("traveler_ids", travelerId);
for (const [, path] of PASSPORTS) form.append("documents", new Blob([await readFile(path)]), basename(path));
form.append("replace", REPLACE);

const response = await fetch(BASE_URL + "/flights/upload_acd_documents", {
  method: "POST",
  // No Content-Type: fetch sends multipart/form-data with its own boundary.
  headers: { Authorization: "Bearer " + API_KEY },
  body: form,
});
const body = await response.json();
console.log("HTTP", response.status);

if (response.status === 200 || response.status === 207) {
  // 200: every passport was saved. 207: some were saved and some were not; retry only the missing travelers.
  console.log(body.message);
  for (const item of body.results) console.log(item.index, item.traveler_id, item.filename, item.status);
  console.log("still missing:", body.acd.missing_traveler_ids);
  console.log("notification:", body.notification);
} else {
  // Nothing was saved. The code says why; item problems are in results.
  console.log(body.code, "-", body.message);
  for (const item of body.results ?? []) console.log(item.index, item.traveler_id, item.status, item.error?.code);
}
```

**Python**

```python
import os

import requests

API_KEY = os.environ.get("FXPORT_API_KEY", "fxp_test_YOUR_KEY")
BASE_URL = "https://api.fx-port.com/api/v1"
BOOKING_ID = "Y7IA34"
REPLACE = "false"  # "true" overwrites a passport that was already uploaded

# One entry per traveler: the traveler_id from get_acd_documents and that traveler's passport file.
PASSPORTS = [("1", "passport-1.png"), ("2", "passport-2.pdf")]

response = requests.post(
    BASE_URL + "/flights/upload_acd_documents",
    # No Content-Type: requests sends multipart/form-data with its own boundary.
    headers={"Authorization": "Bearer " + API_KEY},
    # A list value becomes a repeated field: traveler_ids=1, traveler_ids=2.
    data={"booking_id": BOOKING_ID, "traveler_ids": [t for t, _ in PASSPORTS], "replace": REPLACE},
    # The same field name, once per file; the order matches traveler_ids.
    files=[("documents", (path, open(path, "rb"))) for _, path in PASSPORTS],
    timeout=120,
)
body = response.json()
print("HTTP", response.status_code)

if response.status_code in (200, 207):
    # 200: every passport was saved. 207: some were saved and some were not; retry only the missing travelers.
    print(body["message"])
    for item in body["results"]:
        print(item["index"], item["traveler_id"], item["filename"], item["status"])
    print("still missing:", body["acd"]["missing_traveler_ids"])
    print("notification:", body["notification"])
else:
    # Nothing was saved. The code says why; item problems are in results.
    print(body["code"], "-", body["message"])
    for item in body.get("results", []):
        print(item["index"], item["traveler_id"], item["status"], item.get("error", {}).get("code"))
```

**PHP**

```php
<?php
// PHP 8 with the curl extension. PHP's cURL cannot repeat a field name inside one request,
// so this program sends one request per traveler; each request adds that traveler's passport.
$apiKey = getenv("FXPORT_API_KEY") ?: "fxp_test_YOUR_KEY";
$baseUrl = "https://api.fx-port.com/api/v1";
$bookingId = "Y7IA34";
$replace = "false"; // "true" overwrites a passport that was already uploaded

// One entry per traveler: the traveler_id from get_acd_documents and that traveler's passport file.
$passports = [["1", "passport-1.png"], ["2", "passport-2.pdf"]];

foreach ($passports as [$travelerId, $path]) {
    $curl = curl_init($baseUrl . "/flights/upload_acd_documents");
    curl_setopt_array($curl, [
        CURLOPT_POST => true,
        CURLOPT_HTTPHEADER => ["Authorization: Bearer " . $apiKey],
        // An array body makes cURL send multipart/form-data with its own boundary.
        CURLOPT_POSTFIELDS => [
            "booking_id" => $bookingId,
            "traveler_ids" => $travelerId,
            "documents" => new CURLFile($path),
            "replace" => $replace,
        ],
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 120,
    ]);
    $raw = curl_exec($curl);
    if ($raw === false) {
        fwrite(STDERR, curl_error($curl) . "\n");
        exit(1);
    }
    $status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
    $body = json_decode($raw, true);
    echo "traveler $travelerId: HTTP $status\n";
    if ($status === 200 || $status === 207) {
        echo "  " . $body["message"] . "\n";
        echo "  still missing: " . json_encode($body["acd"]["missing_traveler_ids"]) . "\n";
    } else {
        // Nothing was saved for this traveler. The code says why.
        echo "  " . $body["code"] . " - " . $body["message"] . "\n";
    }
}
```

**Go**

```go
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"mime/multipart"
	"net/http"
	"os"
	"path/filepath"
	"time"
)

type passport struct{ travelerID, path string }

func check(err error) {
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}

func main() {
	apiKey := os.Getenv("FXPORT_API_KEY")
	if apiKey == "" {
		apiKey = "fxp_test_YOUR_KEY"
	}
	bookingID := "Y7IA34"
	replace := "false" // "true" overwrites a passport that was already uploaded

	// One entry per traveler: the traveler_id from get_acd_documents and that traveler's passport file.
	passports := []passport{{"1", "passport-1.png"}, {"2", "passport-2.pdf"}}

	var payload bytes.Buffer
	form := multipart.NewWriter(&payload)
	check(form.WriteField("booking_id", bookingID))
	for _, p := range passports {
		check(form.WriteField("traveler_ids", p.travelerID))
	}
	for _, p := range passports {
		content, err := os.ReadFile(p.path)
		check(err)
		part, err := form.CreateFormFile("documents", filepath.Base(p.path))
		check(err)
		_, err = part.Write(content)
		check(err)
	}
	check(form.WriteField("replace", replace))
	check(form.Close())

	request, err := http.NewRequest("POST", "https://api.fx-port.com/api/v1/flights/upload_acd_documents", &payload)
	check(err)
	request.Header.Set("Authorization", "Bearer "+apiKey)
	request.Header.Set("Content-Type", form.FormDataContentType()) // multipart/form-data; boundary=...

	client := &http.Client{Timeout: 120 * time.Second}
	response, err := client.Do(request)
	check(err)
	defer response.Body.Close()
	raw, err := io.ReadAll(response.Body)
	check(err)

	var body map[string]any
	check(json.Unmarshal(raw, &body))
	fmt.Println("HTTP", response.StatusCode)
	if response.StatusCode == 200 || response.StatusCode == 207 {
		// 200: every passport was saved. 207: some were saved and some were not; retry only the missing travelers.
		fmt.Println(body["message"])
		acd, _ := body["acd"].(map[string]any)
		fmt.Println("still missing:", acd["missing_traveler_ids"])
		fmt.Println("notification:", body["notification"])
	} else {
		// Nothing was saved. The code says why; item problems are in results.
		fmt.Println(body["code"], "-", body["message"])
	}
}
```

**Java**

```java
import java.io.ByteArrayOutputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import java.util.UUID;

// Java 11 or later: java FxPortExample.java. The JDK has no JSON parser, so the answer is printed as it is;
// use Jackson or Gson to read it.
public class FxPortExample {
    public static void main(String[] args) throws Exception {
        String apiKey = System.getenv().getOrDefault("FXPORT_API_KEY", "fxp_test_YOUR_KEY");
        String bookingId = "Y7IA34";
        String replace = "false"; // "true" overwrites a passport that was already uploaded

        // One entry per traveler: the traveler_id from get_acd_documents and that traveler's passport file.
        List<String[]> passports = List.of(new String[] {"1", "passport-1.png"}, new String[] {"2", "passport-2.pdf"});

        String boundary = "fxport" + UUID.randomUUID().toString().replace("-", "");
        ByteArrayOutputStream body = new ByteArrayOutputStream();
        field(body, boundary, "booking_id", bookingId);
        for (String[] passport : passports) field(body, boundary, "traveler_ids", passport[0]);
        for (String[] passport : passports) {
            Path file = Path.of(passport[1]);
            write(body, "--" + boundary + "\r\n"
                + "Content-Disposition: form-data; name=\"documents\"; filename=\"" + file.getFileName() + "\"\r\n"
                + "Content-Type: application/octet-stream\r\n\r\n");
            body.write(Files.readAllBytes(file));
            write(body, "\r\n");
        }
        field(body, boundary, "replace", replace);
        write(body, "--" + boundary + "--\r\n");

        HttpRequest request = HttpRequest.newBuilder(URI.create("https://api.fx-port.com/api/v1/flights/upload_acd_documents"))
            .timeout(Duration.ofSeconds(120))
            .header("Authorization", "Bearer " + apiKey)
            .header("Content-Type", "multipart/form-data; boundary=" + boundary)
            .POST(HttpRequest.BodyPublishers.ofByteArray(body.toByteArray()))
            .build();
        HttpResponse<String> response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());

        // 200: every passport was saved. 207: some were saved and some were not. 4xx and 5xx: nothing was saved.
        System.out.println("HTTP " + response.statusCode());
        System.out.println(response.body());
    }

    private static void field(ByteArrayOutputStream out, String boundary, String name, String value) throws Exception {
        write(out, "--" + boundary + "\r\n"
            + "Content-Disposition: form-data; name=\"" + name + "\"\r\n\r\n"
            + value + "\r\n");
    }

    private static void write(ByteArrayOutputStream out, String text) throws Exception {
        out.write(text.getBytes(StandardCharsets.UTF_8));
    }
}
```

**C#**

```csharp
// .NET 8 or later: dotnet run
using System.Net.Http.Headers;
using System.Text.Json;

var apiKey = Environment.GetEnvironmentVariable("FXPORT_API_KEY") ?? "fxp_test_YOUR_KEY";
const string BaseUrl = "https://api.fx-port.com/api/v1";
const string BookingId = "Y7IA34";
const string Replace = "false"; // "true" overwrites a passport that was already uploaded

// One entry per traveler: the traveler_id from get_acd_documents and that traveler's passport file.
var passports = new[] { (TravelerId: "1", FilePath: "passport-1.png"), (TravelerId: "2", FilePath: "passport-2.pdf") };

using var form = new MultipartFormDataContent();
form.Add(new StringContent(BookingId), "booking_id");
foreach (var passport in passports) form.Add(new StringContent(passport.TravelerId), "traveler_ids");
foreach (var passport in passports)
    form.Add(new ByteArrayContent(await File.ReadAllBytesAsync(passport.FilePath)), "documents", Path.GetFileName(passport.FilePath));
form.Add(new StringContent(Replace), "replace");

// No Content-Type header: MultipartFormDataContent sends multipart/form-data with its own boundary.
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(120) };
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
using var response = await http.PostAsync(BaseUrl + "/flights/upload_acd_documents", form);
var status = (int)response.StatusCode;
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var body = document.RootElement;
Console.WriteLine("HTTP " + status);

if (status == 200 || status == 207)
{
    // 200: every passport was saved. 207: some were saved and some were not; retry only the missing travelers.
    Console.WriteLine(body.GetProperty("message").GetString());
    foreach (var item in body.GetProperty("results").EnumerateArray())
        Console.WriteLine($"{item.GetProperty("index")} {item.GetProperty("traveler_id")} {item.GetProperty("filename")} {item.GetProperty("status")}");
    Console.WriteLine("still missing: " + body.GetProperty("acd").GetProperty("missing_traveler_ids"));
    Console.WriteLine("notification: " + body.GetProperty("notification"));
}
else
{
    // Nothing was saved. The code says why; item problems are in results.
    Console.WriteLine(body.GetProperty("code").GetString() + " - " + body.GetProperty("message").GetString());
}
```

**Swift**

```swift
// macOS 12 / iOS 15 or later: swift main.swift
import Foundation

let apiKey = ProcessInfo.processInfo.environment["FXPORT_API_KEY"] ?? "fxp_test_YOUR_KEY"
let baseURL = "https://api.fx-port.com/api/v1"
let bookingID = "Y7IA34"
let replace = "false" // "true" overwrites a passport that was already uploaded

// One entry per traveler: the traveler_id from get_acd_documents and that traveler's passport file.
let passports: [(travelerID: String, path: String)] = [("1", "passport-1.png"), ("2", "passport-2.pdf")]

let boundary = "fxport-" + UUID().uuidString
var payload = Data()
func append(_ text: String) { payload.append(Data(text.utf8)) }
func field(_ name: String, _ value: String) {
    append("--\(boundary)\r\nContent-Disposition: form-data; name=\"\(name)\"\r\n\r\n\(value)\r\n")
}

field("booking_id", bookingID)
for passport in passports { field("traveler_ids", passport.travelerID) }
for passport in passports {
    let file = URL(fileURLWithPath: passport.path)
    append("--\(boundary)\r\nContent-Disposition: form-data; name=\"documents\"; filename=\"\(file.lastPathComponent)\"\r\nContent-Type: application/octet-stream\r\n\r\n")
    payload.append(try Data(contentsOf: file))
    append("\r\n")
}
field("replace", replace)
append("--\(boundary)--\r\n")

var request = URLRequest(url: URL(string: baseURL + "/flights/upload_acd_documents")!)
request.httpMethod = "POST"
request.timeoutInterval = 120
request.setValue("Bearer " + apiKey, forHTTPHeaderField: "Authorization")
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
request.httpBody = payload

let (data, response) = try await URLSession.shared.data(for: request)
let status = (response as! HTTPURLResponse).statusCode
let body = try JSONSerialization.jsonObject(with: data) as! [String: Any]
print("HTTP", status)

if status == 200 || status == 207 {
    // 200: every passport was saved. 207: some were saved and some were not; retry only the missing travelers.
    print(body["message"] ?? "")
    for case let item as [String: Any] in body["results"] as? [Any] ?? [] {
        print(item["index"] ?? "", item["traveler_id"] ?? "", item["filename"] ?? "", item["status"] ?? "")
    }
    let acd = body["acd"] as? [String: Any]
    print("still missing:", acd?["missing_traveler_ids"] ?? [])
    print("notification:", body["notification"] ?? "")
} else {
    // Nothing was saved. The code says why; item problems are in results.
    print(body["code"] ?? "", "-", body["message"] ?? "")
}
```

**Kotlin**

```kotlin
import java.io.ByteArrayOutputStream
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.nio.file.Files
import java.nio.file.Path
import java.time.Duration
import java.util.UUID

// Kotlin on Java 11 or later. The JDK has no JSON parser, so the answer is printed as it is;
// use kotlinx.serialization or Jackson to read it.
fun main() {
    val apiKey = System.getenv("FXPORT_API_KEY") ?: "fxp_test_YOUR_KEY"
    val bookingId = "Y7IA34"
    val replace = "false" // "true" overwrites a passport that was already uploaded

    // One entry per traveler: the traveler_id from get_acd_documents and that traveler's passport file.
    val passports = listOf("1" to "passport-1.png", "2" to "passport-2.pdf")

    val boundary = "fxport" + UUID.randomUUID().toString().replace("-", "")
    val body = ByteArrayOutputStream()
    fun write(text: String) = body.write(text.toByteArray())
    fun field(name: String, value: String) =
        write("--$boundary\r\nContent-Disposition: form-data; name=\"$name\"\r\n\r\n$value\r\n")

    field("booking_id", bookingId)
    for ((travelerId, _) in passports) field("traveler_ids", travelerId)
    for ((_, path) in passports) {
        val file = Path.of(path)
        write("--$boundary\r\nContent-Disposition: form-data; name=\"documents\"; filename=\"${file.fileName}\"\r\nContent-Type: application/octet-stream\r\n\r\n")
        body.write(Files.readAllBytes(file))
        write("\r\n")
    }
    field("replace", replace)
    write("--$boundary--\r\n")

    val request = HttpRequest.newBuilder(URI.create("https://api.fx-port.com/api/v1/flights/upload_acd_documents"))
        .timeout(Duration.ofSeconds(120))
        .header("Authorization", "Bearer $apiKey")
        .header("Content-Type", "multipart/form-data; boundary=$boundary")
        .POST(HttpRequest.BodyPublishers.ofByteArray(body.toByteArray()))
        .build()
    val response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString())

    // 200: every passport was saved. 207: some were saved and some were not. 4xx and 5xx: nothing was saved.
    println("HTTP " + response.statusCode())
    println(response.body())
}
```


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

~~~bash
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 documents=@john-passport-rescan.jpg \
  --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):

~~~json
{
  "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](#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:

~~~json
{
  "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):

~~~json
{
  "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:

~~~json
{
  "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.

| Format | Extensions | How it is recognised |
|---|---|---|
| PDF | `.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_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`](https://fx-port.com/docs/flights/pay-order.md) 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`](https://fx-port.com/docs/flights/cancel-order.md) 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](https://fx-port.com/docs/errors.md) 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/json`. | 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](https://fx-port.com/docs/errors.md). | 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/form-data`. | 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_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`](https://fx-port.com/docs/flights/get-ticket.md), `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

| Topic | In these endpoints |
|---|---|
| Identifier | `booking_id` takes the PNR, the supplier order id or the FX-Port UUID, like [`get_booking`](https://fx-port.com/docs/flights/get-booking.md), [`get_ticket`](https://fx-port.com/docs/flights/get-ticket.md) and [`get_ticket_number`](https://fx-port.com/docs/flights/get-ticket-number.md). 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](#legacy-names)). The answers are `snake_case` except `requestId` (twin: `request_id`). `?naming=snake` returns every key in `snake_case`: see [Conventions](https://fx-port.com/docs/conventions.md). |
| 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](https://fx-port.com/docs/errors.md). 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](#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](https://fx-port.com/docs/rate-limits.md). |
| 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](https://fx-port.com/docs/legacy-names.md). |

## 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](https://fx-port.com/docs/flights/pricing.md#the-acd-block-amadeus-algeria): the `acd` block of the price.
- [Book a flight](https://fx-port.com/docs/flights/book.md), [Pay a held order](https://fx-port.com/docs/flights/pay-order.md) and [Cancel a held order](https://fx-port.com/docs/flights/cancel-order.md).
- [Get a flight booking](https://fx-port.com/docs/flights/get-booking.md): `require_acd` and `acd_submitted` of a booking.
- [Sandbox](https://fx-port.com/docs/sandbox.md) and [Errors](https://fx-port.com/docs/errors.md).
