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

# Uploads API - Register Files and Get Presigned URLs

> Register files to receive presigned upload URLs, then push content directly to storage, file bytes never pass through the Examino API.

Examino uses a two-step upload flow: you first register a file with the API to receive a presigned storage URL, then you push the file bytes directly to that URL. The Examino API never sees your file content, which is what makes bulk imports of hundreds of copies fast and reliable. Once a file is uploaded, you attach it to one or more copies via the Copies API.

***

## Step 1 - Register a file

Call `POST /api/v1/uploads` to register the file metadata. The API returns a presigned `PUT` URL, method, headers, and an expiry. You use all of these in Step 2.

**Required scope:** `copies:write`

<ParamField body="fileType" type="string" required>
  MIME type of the file, e.g. `application/pdf` or `image/png`. Between 1 and 200 characters.
</ParamField>

<ParamField body="fileSize" type="integer" required>
  Exact byte size of the file. This value is signed into the presigned URL, a request body of any other size will be rejected by storage.
</ParamField>

<ParamField body="name" type="string">
  Original filename, e.g. `copie-dupont.pdf`. Maximum 500 characters. Used for display in the web app.
</ParamField>

<ParamField body="pages" type="integer">
  Page count, if known. Must be strictly positive. Providing this value can speed up processing.
</ParamField>

```bash title="Request" theme={null}
curl -X POST https://app.examino.ai/api/v1/uploads \
  -H "Authorization: Bearer $EXAMINO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "copie-dupont.pdf",
    "fileType": "application/pdf",
    "fileSize": 1048576,
    "pages": 8
  }'
```

```json title="Response 201" theme={null}
{
  "fileId": "5c6d7e8f-9a0b-41c2-83d4-e5f6a7b8c9d0",
  "name": "copie-dupont.pdf",
  "fileType": "application/pdf",
  "upload": {
    "method": "PUT",
    "url": "https://storage.examino.ai/uploads/…",
    "headers": {
      "content-type": "application/pdf",
      "content-length": "1048576"
    },
    "expiresInSeconds": 3600
  }
}
```

### Response fields

<ResponseField name="fileId" type="string">
  UUID that identifies this registered file. Save it, you'll pass it to `POST /exams/{examId}/copies` to attach the file to a student copy.
</ResponseField>

<ResponseField name="name" type="string | null">
  The filename you provided, echoed back.
</ResponseField>

<ResponseField name="fileType" type="string">
  The MIME type you provided, echoed back.
</ResponseField>

<ResponseField name="upload.method" type="string">
  HTTP method to use for the upload request. Always `PUT`.
</ResponseField>

<ResponseField name="upload.url" type="string">
  Presigned storage URL. Use this as the request URL in Step 2. The URL contains authentication and is single-use.
</ResponseField>

<ResponseField name="upload.headers" type="object">
  Headers you must include on the `PUT` request. Typically includes `content-type` and `content-length`. You must send all of them exactly as returned.
</ResponseField>

<ResponseField name="upload.expiresInSeconds" type="integer">
  Seconds until the presigned URL expires. Always `3600` (one hour).
</ResponseField>

***

## Step 2 - Push the file

Use the `method`, `url`, and `headers` from the Step 1 response to push the file bytes directly to storage. Do not add extra headers or modify the ones provided.

```bash title="Request" theme={null}
curl -X PUT "$UPLOAD_URL" \
  -H "content-type: application/pdf" \
  -H "content-length: 1048576" \
  --data-binary @copie-dupont.pdf
```

A successful upload returns an empty `200 OK` response from the storage provider. There is no Examino API call for this step.

<Warning>
  The presigned URL expires **one hour** after it is issued. If you don't complete the upload within that window, call `POST /api/v1/uploads` again to get a new URL. The old `fileId` is invalidated and cannot be used to attach a copy.
</Warning>

<Note>
  A `fileId` is unattached until you call `POST /exams/{examId}/copies`. An unattached file is not visible in the web app and is not processed until it is linked to a copy.
</Note>

<Tip>
  For bulk imports, register and upload all files in parallel before making a single batched `POST /copies` call. This is the fastest way to import a large class set.
</Tip>

***

## Accepted formats and limits

### Supported file types

| Category      | Extensions                                   |
| ------------- | -------------------------------------------- |
| Documents     | `pdf`, `docx`, `odt`                         |
| Images        | `png`, `jpg`, `jpeg`, `webp`, `heic`, `heif` |
| Spreadsheets  | `xlsx`, `ods`                                |
| Presentations | `pptx`, `odp`                                |

### Size and page limits

| Limit           | Value                          |
| --------------- | ------------------------------ |
| PDF size        | 150 MB                         |
| Other file size | 50 MB                          |
| Pages per copy  | 100                            |
| Subject pages   | 20 (40 for a shared workspace) |

***

## Error reference

<AccordionGroup>
  <Accordion title="Registration errors (POST /uploads)">
    | Case                  | Response              |
    | --------------------- | --------------------- |
    | Unsupported file type | `400 invalid_request` |
    | File too large        | `422 unprocessable`   |

    The `400 invalid_request` is returned at registration time, before any data is transferred, you don't need to attempt the upload to discover whether a format is supported.
  </Accordion>

  <Accordion title="Upload errors (PUT to presigned URL)">
    Errors from the presigned `PUT` come directly from the storage provider, not from the Examino API. Common causes:

    * **URL expired**, the one-hour window passed. Re-register the file.
    * **Wrong `content-length`**, the byte count does not match what was signed into the URL. Re-register with the correct `fileSize`.
    * **Wrong `content-type`**, the header does not match the registered `fileType`. Re-register with the correct `fileType`.
  </Accordion>
</AccordionGroup>
