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

# File upload guide

> Upload files efficiently with presigned URLs

## How uploads work

Files are uploaded with a presigned URL: request a time-limited URL from the API, then upload the file straight to Azure Blob Storage. This keeps upload bandwidth off your API server, handles files up to the per-category maximums, and works well for parallel uploads.

## Presigned URL uploads

### Basic flow

<Steps>
  <Step title="Request presigned URL">
    Call `/api/uploads/url` to get a time-limited upload URL.
  </Step>

  <Step title="Upload directly to Azure">
    Use the returned URL to upload your file to Azure Blob Storage with a single `PUT`.
  </Step>

  <Step title="Create submission">
    Submit the returned `blobPath` in your submission request.
  </Step>
</Steps>

The request body uses `filename` and `contentType`. The response returns `uploadUrl`, `blobPath`, and `expiresAt`. The presigned URL expires after 1 hour.

<Note>
  This same upload flow is used for **supporting context files**, not just primary assets. Upload the file here, then reference its `blobPath` in a submission's `contextItems` instead of `assets`. Document types (PDF, DOCX, TXT) are accepted in addition to media. See [Tags and supporting context](/concepts/submissions#tags-and-supporting-context).
</Note>

### Implementation example

<CodeGroup>
  ```python theme={null}
  import requests
  from pathlib import Path

  API_KEY = "your-api-key-here"
  API_BASE = "https://api.northell.io/nhl/prod/mediamagic-verify-integrations"

  def upload_file_with_presigned_url(file_path: str) -> str:
      """Upload a file using a presigned URL and return the blobPath."""

      # Step 1: Request a presigned URL
      file = Path(file_path)
      response = requests.post(
          f"{API_BASE}/api/uploads/url",
          headers={"X-API-Key": API_KEY},
          json={
              "filename": file.name,
              "contentType": get_content_type(file.suffix),
          },
      )
      response.raise_for_status()
      data = response.json()
      upload_url = data["uploadUrl"]
      blob_path = data["blobPath"]

      # Step 2: Upload to Azure with a single PUT
      with open(file_path, "rb") as f:
          upload_response = requests.put(
              upload_url,
              headers={"x-ms-blob-type": "BlockBlob"},
              data=f,
          )
          upload_response.raise_for_status()

      print(f"Uploaded {file.name} -> {blob_path}")
      return blob_path

  def get_content_type(suffix: str) -> str:
      """Map a file extension to a supported content type."""
      types = {
          ".mp4": "video/mp4",
          ".mov": "video/quicktime",
          ".mp3": "audio/mpeg",
          ".wav": "audio/wav",
          ".jpg": "image/jpeg",
          ".png": "image/png",
          ".pdf": "application/pdf",
          ".txt": "text/plain",
      }
      return types.get(suffix.lower(), "application/octet-stream")

  # Usage
  blob_path = upload_file_with_presigned_url("meeting-recording.mp4")
  ```

  ```javascript theme={null}
  const fs = require("fs");
  const path = require("path");
  const fetch = require("node-fetch");

  const API_KEY = "your-api-key-here";
  const API_BASE = "https://api.northell.io/nhl/prod/mediamagic-verify-integrations";

  async function uploadFileWithPresignedUrl(filePath) {
    const file = fs.readFileSync(filePath);
    const fileName = path.basename(filePath);
    const contentType = getContentType(path.extname(filePath));

    // Step 1: Request a presigned URL
    const urlResponse = await fetch(
      `${API_BASE}/api/uploads/url`,
      {
        method: "POST",
        headers: {
          "X-API-Key": API_KEY,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          filename: fileName,
          contentType: contentType,
        }),
      }
    );

    if (!urlResponse.ok) throw new Error("Failed to get presigned URL");

    const { uploadUrl, blobPath } = await urlResponse.json();

    // Step 2: Upload to Azure with a single PUT
    const uploadResponse = await fetch(uploadUrl, {
      method: "PUT",
      headers: {
        "x-ms-blob-type": "BlockBlob",
        "Content-Length": file.length,
      },
      body: file,
    });

    if (!uploadResponse.ok) throw new Error("Upload failed");

    console.log(`Uploaded ${fileName} -> ${blobPath}`);
    return blobPath;
  }

  function getContentType(extension) {
    const types = {
      ".mp4": "video/mp4",
      ".mov": "video/quicktime",
      ".mp3": "audio/mpeg",
      ".wav": "audio/wav",
      ".jpg": "image/jpeg",
      ".png": "image/png",
      ".pdf": "application/pdf",
      ".txt": "text/plain",
    };
    return types[extension.toLowerCase()] || "application/octet-stream";
  }

  // Usage
  uploadFileWithPresignedUrl("meeting-recording.mp4")
    .then((blobPath) => console.log("Ready for submission:", blobPath))
    .catch((err) => console.error(err));
  ```
</CodeGroup>

### Parallel uploads

For batch operations, request a presigned URL per file and upload them in parallel:

```python theme={null}
import asyncio
import aiohttp

API_KEY = "your-api-key-here"
API_BASE = "https://api.northell.io/nhl/prod/mediamagic-verify-integrations"

async def request_presigned_url(session, file_name, content_type):
    async with session.post(
        f"{API_BASE}/api/uploads/url",
        headers={"X-API-Key": API_KEY},
        json={"filename": file_name, "contentType": content_type},
    ) as resp:
        resp.raise_for_status()
        return await resp.json()

async def upload_to_azure(session, upload_url, file_path):
    with open(file_path, "rb") as f:
        async with session.put(
            upload_url,
            headers={"x-ms-blob-type": "BlockBlob"},
            data=f,
        ) as resp:
            resp.raise_for_status()

async def upload_files_parallel(files: list[tuple[str, str]]) -> list[str]:
    """Upload multiple files in parallel. files = [(path, contentType), ...]."""
    blob_paths = []
    async with aiohttp.ClientSession() as session:
        # Request all presigned URLs first
        url_data = await asyncio.gather(
            *[
                request_presigned_url(session, path.split("/")[-1], ctype)
                for path, ctype in files
            ]
        )
        # Upload all files concurrently
        await asyncio.gather(
            *[
                upload_to_azure(session, data["uploadUrl"], path)
                for data, (path, _) in zip(url_data, files)
            ]
        )
        blob_paths = [data["blobPath"] for data in url_data]
    return blob_paths
```

## Supported file types and sizes

The API accepts these MIME types:

| Category | Allowed content types                                                                                                            | Max size |
| -------- | -------------------------------------------------------------------------------------------------------------------------------- | -------- |
| Video    | `video/mp4`, `video/quicktime`, `video/x-msvideo`, `video/x-matroska`                                                            | 5 GB     |
| Audio    | `audio/mpeg`, `audio/wav`, `audio/x-wav`, `audio/aac`, `audio/ogg`                                                               | 500 MB   |
| Image    | `image/jpeg`, `image/png`, `image/gif`, `image/webp`, `image/svg+xml`                                                            | 50 MB    |
| Document | `application/pdf`, `application/msword`, `application/vnd.openxmlformats-officedocument.wordprocessingml.document`, `text/plain` | 100 MB   |

A `415` is returned when the `contentType` is not in this allowlist or does not match the file.

## Handling large files

Large files use the same presigned URL flow. A single `PUT` works for the SAS URL. For very large files you can use the Azure Blob Storage SDK against the same SAS URL, which handles the transfer for you:

```python theme={null}
from azure.storage.blob import BlobClient

def upload_large_file(upload_url: str, file_path: str):
    """Upload a large file to the presigned SAS URL using the Azure SDK."""

    blob_client = BlobClient.from_blob_url(upload_url)

    with open(file_path, "rb") as f:
        blob_client.upload_blob(f, overwrite=True)

    print("Upload complete")
```

The presigned URL is valid for 1 hour. If the upload does not finish in that window, request a new URL and retry. There is no application-level resumable, multipart, or verify step — the upload completes when the `PUT` to Azure succeeds, and the `blobPath` is ready to submit.

## Error handling

### Network retries

Implement exponential backoff for transient failures:

```python theme={null}
import time
from requests.exceptions import RequestException

def upload_with_retries(file_path: str, max_retries: int = 3) -> str:
    """Upload with automatic retry on transient failure."""

    for attempt in range(max_retries):
        try:
            return upload_file_with_presigned_url(file_path)
        except RequestException:
            if attempt < max_retries - 1:
                wait_time = 2 ** attempt  # Exponential backoff
                print(f"Upload failed, retrying in {wait_time}s...")
                time.sleep(wait_time)
            else:
                raise
```

### Validate before uploading

Check the file exists, is readable, and is within the category limit before you upload:

```python theme={null}
import os

def validate_before_upload(file_path: str, max_mb: int) -> bool:
    """Check file exists, is readable, and within the category size limit."""

    if not os.path.exists(file_path):
        raise FileNotFoundError(f"File not found: {file_path}")

    size_mb = os.path.getsize(file_path) / (1024 * 1024)
    if size_mb > max_mb:
        raise ValueError(f"File too large: {size_mb:.1f} MB (max {max_mb} MB)")

    if not os.access(file_path, os.R_OK):
        raise PermissionError(f"File not readable: {file_path}")

    return True
```

## Troubleshooting

### Upload URL expired

Presigned URLs expire after 1 hour. If the upload takes longer, request a new URL and retry.

### Content type mismatch

Ensure the `contentType` matches the actual file format and is one of the allowed MIME types above. A mismatched or unsupported type returns `415`.

### 403 invalid filename

The filename failed validation. Use a plain filename without path traversal or unusual characters.

### 413 payload too large

The uploaded blob exceeds the per-category size limit for its type. Check the file against the maximum sizes above before submitting.
