The API supports three upload patterns. Pick the one that matches your file.
PatternWhen to useEndpoint family
Direct multipartSmall files (creative media up to ~50 MB).POST /media, POST /ingest/transaction_summary/from_excel, [cr_api] POST events/bulk_upload
Presigned URL multipartLarge files (>50 MB), CSVs, datasets.POST /storage/multipart-upload, S3 PUT, PUT /storage/multipart-upload (complete)
Single-part presignedMedium files where you want to skip multipart.POST /storage/presigned-url, S3 PUT

Direct multipart — creative media

POST /media
Authorization: Bearer <token>
X-Preferred-Partner-Id: <partner_id>
Content-Type: multipart/form-data; boundary=...

field: file (the binary)
POST /media hits the API host without the /v3 prefix. The reference webapp builds the URL via axios.create({ baseURL: apiURL }) (no version), separate from its main api instance.
Response:
{ "type": "image", "url": "https://media.future-demand.com/...", "filename": "creative.jpg" }

Reference implementation

export async function uploadMedia(file: File, onProgress?: (pct: number) => void) {
  const form = new FormData();
  form.append("file", file);

  const xhr = new XMLHttpRequest();
  xhr.open("POST", `${API_URL}/media`);                  // no /v3
  xhr.setRequestHeader("Authorization",       `Bearer ${token()}`);
  xhr.setRequestHeader("X-Preferred-Partner-Id", partnerId());

  if (onProgress) {
    xhr.upload.onprogress = e => {
      if (e.lengthComputable) onProgress((e.loaded / e.total) * 100);
    };
  }

  return new Promise<{ url: string }>((resolve, reject) => {
    xhr.onload = () => {
      try { resolve(JSON.parse(xhr.responseText)); }
      catch (e) { reject(e); }
    };
    xhr.onerror = () => reject(new Error("Network error"));
    xhr.send(form);
  });
}
XMLHttpRequest is the simplest path to onProgress in a browser; modern fetch finally supports request-side progress but adoption is uneven.

Presigned URL multipart — large files

For files > ~50 MB, do a multipart upload directly to S3 with presigned URLs.

1. Initiate

POST /storage/multipart-upload
{
  "filename":       "audience.csv",
  "expected_total_parts": 5,
  "content_type":   "text/csv"
}
Optional header: X-Upload-Type: <type> (e.g. audience_csv). Response (abbreviated):
{
  "upload_id":   "uplo_xyz",
  "object_key":  "tmp/partner-id-acme/audience.csv",
  "presigned_urls": [
    { "part_number": 1, "url": "https://...s3.../?..." },
    { "part_number": 2, "url": "https://...s3.../?..." }
  ]
}

2. Upload each part to S3

async function uploadPart(presignedUrl: string, blob: Blob, partNumber: number) {
  const resp = await fetch(presignedUrl, {
    method:  "PUT",
    headers: { "Content-Type": "application/octet-stream" },
    body:    blob,
  });
  if (!resp.ok) throw new Error(`Part ${partNumber} failed: ${resp.status}`);
  const etag = resp.headers.get("ETag")!.replace(/"/g, "");
  return { part_number: partNumber, etag };
}
Run in parallel (e.g. 4-6 at a time) for throughput.

3. Complete

PUT /storage/multipart-upload
{
  "upload_id":  "uplo_xyz",
  "object_key": "tmp/partner-id-acme/audience.csv",
  "parts":      [
    { "part_number": 1, "etag": "..." },
    { "part_number": 2, "etag": "..." }
  ]
}

4. Validate (optional)

GET /storage/multipart-upload?upload_id=uplo_xyz&object_key=...&expected_total_parts=5
Returns whether the upload can be completed.

5. Abort (on failure or cancel)

DELETE /storage/multipart-upload
{ "upload_id": "uplo_xyz", "object_key": "tmp/..." }

Single-part presigned

For medium files (a few MB) where multipart is overkill:
POST /storage/presigned-url
{ "filename": "report.pdf", "content_type": "application/pdf" }
Returns one { url, object_key }. PUT to url directly with the file as the body and Content-Type: application/octet-stream. The object is then addressable via object_key.

Bulk ingest endpoints

The /ingest/* family is for data-pipeline scope. Direct multipart for the Excel variants:
EndpointBody
POST /ingest/transaction_summaryJSON { eid, from, to, tickets: [...] }
POST /ingest/transaction_summary/from_excel?eid=...multipart, file field
POST /ingest/events_df / POST /ingest/sales_df / POST /ingest/campaigns_dfJSON DataFrame payload
POST /ingest/events / POST /ingest/sales / POST /ingest/campaignsJSON array
[cr_api] POST events/bulk_upload?event_type=...multipart, file field
See Lookout — Sales Uploads and Lookout — Bulk Event Upload.

CSV downloads

For per-package customer list downloads:
const blob = await fd.get<Blob>(
  `/package_builder/packages/${packageId}/${encodeURIComponent(tc)}/customer_list`,
  { responseType: "blob" },
);
const url = URL.createObjectURL(blob);
const a   = document.createElement("a");
a.href     = url;
a.download = `${tc}-customers.csv`;
a.click();
URL.revokeObjectURL(url);

Common mistakes

POST /media is on the API host without the version prefix. The reference webapp builds a separate axios instance. Replicate or you get a 404.
Presigned PUTs to S3 require Content-Type: application/octet-stream by default (unless the presigned URL was created with a specific content type). Mismatching the content type at PUT time produces a signature mismatch.
The S3 PUT URL is presigned. Do not add your Bearer token or the partner header — they cause a signature mismatch. Hit the URL with only Content-Type and the body.
S3 returns ETag: "abcdef..." — strip the quotes before sending to the complete endpoint. The reference uses resp.headers.get("ETag").replace(/"/g, "").
Multipart uploads can run for minutes. Wire the cancel button to DELETE /storage/multipart-upload to free the S3 space and stop the parts in flight.