> ## Documentation Index
> Fetch the complete documentation index at: https://docs.adclear.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# File upload

> Upload files to the File API with the resumable chunked protocol, then reference them when creating a promotion.

<Note>
  **Text promotions skip this step entirely.** If your content is plain text or HTML, provide it inline via the `content` field when [creating a promotion](/developer/promotions).
</Note>

Uploads use a resumable chunked protocol. You initiate the upload, then send the file in one or more chunks using `Content-Range` headers.

<Steps>
  <Step title="Initiate the upload">
    **Request:**

    ```http theme={null}
    POST https://files.adclear.ai/v1/uploads

    {
      "fileName": "summer-campaign-v1.pdf",
      "fileSize": 2048576,
      "contentType": "application/pdf"
    }
    ```

    **Response:**

    ```json theme={null}
    {
      "uploadId": "a1b2c3d4-...",
      "uploadUrl": "/v1/uploads/a1b2c3d4-...",
      "maxChunkSize": 52428800
    }
    ```

    * `uploadUrl` is the relative path for chunk uploads.
    * `maxChunkSize` is the maximum bytes per chunk (default 50 MB). The final chunk may be smaller.
  </Step>

  <Step title="Upload the file in chunks">
    Send the file bytes as one or more `PUT` requests with a `Content-Range` header.

    ```http theme={null}
    PUT https://files.adclear.ai/v1/uploads/{uploadId}
    Content-Type: application/pdf
    Content-Range: bytes 0-2048575/2048576

    <binary file data>
    ```

    **Content-Range format:** `bytes <start>-<end>/<total>`, where `start` and `end` are zero-based inclusive byte offsets and `total` is the full file size. For a multi-chunk upload, repeat with the next byte range until the response `status` is `completed`.
  </Step>

  <Step title="Check upload status (optional)">
    You can poll upload status at any time:

    ```http theme={null}
    GET https://files.adclear.ai/v1/uploads/{uploadId}
    ```

    ```json theme={null}
    {
      "uploadId": "a1b2c3d4-...",
      "status": "completed",
      "fileName": "summer-campaign-v1.pdf",
      "fileSize": 2048576,
      "createdAt": "2026-02-16T12:00:00.000Z"
    }
    ```

    Status transitions: `pending` → `uploading` → `completed` | `failed`.
  </Step>
</Steps>

## Full upload example

The curl tab covers the single-chunk happy path. The TypeScript and Python tabs handle chunking for large files.

<CodeGroup>
  ```bash curl theme={null}
  # Initiate
  UPLOAD=$(curl -s -X POST "$FILES_BASE/v1/uploads" \
    -H "Authorization: Bearer $ADCLEAR_API_KEY" \
    -H "X-External-User-ID: $USER_ID" \
    -H "Content-Type: application/json" \
    -d '{ "fileName": "ad.pdf", "fileSize": 2048576, "contentType": "application/pdf" }')
  UPLOAD_ID=$(echo "$UPLOAD" | jq -r .uploadId)

  # Send the bytes (single chunk)
  curl -s -X PUT "$FILES_BASE/v1/uploads/$UPLOAD_ID" \
    -H "Authorization: Bearer $ADCLEAR_API_KEY" \
    -H "Content-Type: application/pdf" \
    -H "Content-Range: bytes 0-2048575/2048576" \
    --data-binary @ad.pdf
  ```

  ```typescript TypeScript theme={null}
  import { readFile } from "node:fs/promises";
  import { basename } from "node:path";

  const FILES_BASE = "https://files.adclear.ai";
  const API_KEY = process.env.ADCLEAR_API_KEY!;
  const auth = { Authorization: `Bearer ${API_KEY}`, "X-External-User-ID": process.env.USER_ID! };

  async function upload(filePath: string, contentType = "application/pdf") {
    const file = await readFile(filePath);

    // 1 — initiate
    const init = await fetch(`${FILES_BASE}/v1/uploads`, {
      method: "POST",
      headers: { ...auth, "Content-Type": "application/json" },
      body: JSON.stringify({ fileName: basename(filePath), fileSize: file.byteLength, contentType }),
    });
    if (!init.ok) throw new Error(`Initiate failed (${init.status})`);
    const { uploadId, maxChunkSize } = await init.json();

    // 2 — send chunks
    for (let offset = 0; offset < file.byteLength; ) {
      const end = Math.min(offset + maxChunkSize, file.byteLength) - 1;
      const res = await fetch(`${FILES_BASE}/v1/uploads/${uploadId}`, {
        method: "PUT",
        headers: { ...auth, "Content-Type": contentType,
                   "Content-Range": `bytes ${offset}-${end}/${file.byteLength}` },
        body: file.subarray(offset, end + 1),
      });
      if (!res.ok) throw new Error(`Chunk failed (${res.status})`);
      offset = end + 1;
    }
    return uploadId;
  }
  ```

  ```python Python theme={null}
  import os, requests
  from pathlib import Path

  FILES_BASE = "https://files.adclear.ai"
  auth = {"Authorization": f"Bearer {os.environ['ADCLEAR_API_KEY']}",
          "X-External-User-ID": os.environ["USER_ID"]}

  def upload(file_path: str, content_type: str = "application/pdf") -> str:
      data = Path(file_path).read_bytes()

      # 1 — initiate
      init = requests.post(f"{FILES_BASE}/v1/uploads", headers={**auth, "Content-Type": "application/json"},
          json={"fileName": Path(file_path).name, "fileSize": len(data), "contentType": content_type})
      init.raise_for_status()
      upload_id, max_chunk = init.json()["uploadId"], init.json()["maxChunkSize"]

      # 2 — send chunks
      offset = 0
      while offset < len(data):
          end = min(offset + max_chunk, len(data)) - 1
          res = requests.put(f"{FILES_BASE}/v1/uploads/{upload_id}",
              headers={**auth, "Content-Type": content_type,
                       "Content-Range": f"bytes {offset}-{end}/{len(data)}"},
              data=data[offset:end + 1])
          res.raise_for_status()
          offset = end + 1
      return upload_id
  ```
</CodeGroup>

## Implementation notes

* A promotion references exactly one upload. To review several files, create a separate promotion for each.
* If a chunk fails due to a network error, check the upload status to see how many bytes were received, then resume from that offset.
* Uploading to an already-completed upload returns `409 Conflict`.

## Common error scenarios

| Scenario                                                | HTTP status | Error code          | What to do                                                                              |
| ------------------------------------------------------- | ----------- | ------------------- | --------------------------------------------------------------------------------------- |
| File too large for its format                           | 413         | `PAYLOAD_TOO_LARGE` | Check the size limits in [Introduction](/developer/introduction#supported-file-formats) |
| Missing `Content-Range` header                          | 400         | `VALIDATION_ERROR`  | Add `Content-Range: bytes <start>-<end>/<total>`                                        |
| Chunk body size doesn't match the range span            | 400         | `VALIDATION_ERROR`  | Ensure the body's byte length equals `end - start + 1`                                  |
| `Content-Range` total doesn't match declared `fileSize` | 400         | `VALIDATION_ERROR`  | Use the same total in every chunk as the `fileSize` from initiation                     |
| Upload already completed                                | 409         | `INVALID_REQUEST`   | The file is already uploaded; proceed to create the promotion                           |
| Upload not found                                        | 404         | `NOT_FOUND`         | The upload ID is invalid or the session expired; initiate a new upload                  |
| Network failure mid-upload                              | N/A         | N/A                 | `GET` the upload status to check `bytesReceived`, then resume from that offset          |

For cross-cutting errors (401, 403, 429, 502), see [Errors & rate limits](/developer/errors).

## Frequently asked questions

<AccordionGroup>
  <Accordion title="Can I upload multiple files for one promotion?" icon="circle-question">
    No. A promotion references exactly one upload. To review several files, create a separate promotion for each.
  </Accordion>

  <Accordion title="Can I reuse an upload ID across promotions or versions?" icon="circle-question">
    Yes. Upload IDs aren't consumed. The same upload can be referenced by multiple promotions or versions.
  </Accordion>

  <Accordion title="Do uploads expire?" icon="circle-question">
    Completed uploads are stored indefinitely. If you initiate an upload and never send any chunks, the upload session may eventually become unusable, so complete uploads promptly after initiation.
  </Accordion>
</AccordionGroup>
