# Upload images

`POST https://stylor.ai/api/v1/file/upload`

- Authentication: private API key, sent as `Authorization: Bearer sgpt-sk-…::…`
- Rate-limit resource: `api.v1.image.upload`
- Demo key: not accepted
- Web page: https://stylor.ai/guides/rest/v1/upload-file

Uploads up to 10 images in one `multipart/form-data` request and
returns a storage `key` for each. Use it to store reference images —
for example a background for outfit compositions — and then pass the
returned key wherever an image key is accepted, or display it through
the image delivery endpoint at `https://stylor.ai/api/v1/image/{key}`.

## Accepted files

Files are read from every file part in the body regardless of the
form field name; non-file fields are ignored. Only the first 10 file
parts are read — any further files are silently dropped, not
rejected. Each file must:

- declare one of `image/jpeg`, `image/png`, `image/webp`, `image/avif` in the part's `Content-Type` (a part without one is treated as `application/octet-stream` and rejected)
- actually be that format (the first bytes are checked against the declared type's signature)
- be between 1 KB (1,024 bytes) and 10 MB (10,485,760 bytes)

## Per-file results

Files are validated and stored independently, so one bad file does not
fail the request. The response always carries three keys: `files` for
the stored ones, `failures` for the rejected ones (each with the
original `filename` and an `error`), and a `summary`. The status tells
you at a glance how it went: `200` all stored, `207` some stored. When
none was stored the response is an error that still carries `files`,
`failures` and `summary`, with `error: No files were stored.` and a
status chosen from the per-file reasons: `415` when every file had an
unaccepted type or content, `413` when every file was too large, `422`
for any other client-side mix, and `500` (`Internal server error.`)
when storage failed. Per-file `error` values are:

- `Unsupported file type: <type>. Allowed: image/jpeg, image/png, image/webp, image/avif`
- `File content does not match declared type <type>` — the bytes are not the declared format
- `File too small to validate as <type>` — too few bytes arrived to check the type's signature (under 12 for WebP, 8 for PNG and AVIF, 3 for JPEG)
- `File exceeds maximum size of 10 MB` — the upload was cut off at 10 MB and discarded
- `File too small. Minimum size is 1024 bytes`
- `Failed to process file` — storage or record creation failed
- `File stream error` — the connection dropped mid-file

Each stored file gets a UUID `fileId` and a `key` of the form
`uploads/<fileId>.<ext>`. Pixel dimensions are extracted where
possible and are `null` when they cannot be read.

This endpoint counts one request against your quota however many
files it carries.

## Example request

```bash
curl -X POST "https://stylor.ai/api/v1/file/upload" \
  -H "Authorization: Bearer $STYLOR_API_KEY" \
  -F "file=@./catalogue.csv"
```

## Request body

Content type `multipart/form-data`, required.

- `file` (array<string (binary)>, required): One or more image files. The field name is not significant — any file part is accepted — but `file` is conventional. Only the first 10 file parts are read. [max items 10]

### Examples

#### One image

Upload a single image file.

```json
{
  "file": "@background.jpg"
}
```

## Responses

### 200

Every file was stored.

Content type `application/json`:

- `files` (array<UploadedFile>, required): Files that were stored.
  - `fileId` (string (uuid), required): Unique id of the stored file. It is the UUID portion of `key`. [example `"6d1e5c0a-2b3f-4a8e-9c7d-0f1e2a3b4c5d"`]
  - `key` (string, required): Object key of the form `uploads/<fileId>.<ext>`. Pass it to `GET /v1/image/{key}` or to any field that accepts an image key. [example `"uploads/6d1e5c0a-2b3f-4a8e-9c7d-0f1e2a3b4c5d.jpg"`]
  - `filename` (string, required): The original filename after sanitising (path stripped, unsafe characters removed, at most 255 characters). `unnamed` when none was sent. [max length 255; example `"background.jpg"`]
  - `contentType` (string, required): The declared and verified MIME type. [one of `"image/jpeg"`, `"image/png"`, `"image/webp"`, `"image/avif"`; example `"image/jpeg"`]
  - `size` (integer, required): Size in bytes. [minimum 1024; maximum 10485760; example `482113`]
  - `sha256` (string, required): Hex SHA-256 of the stored bytes, computed as the file streamed in. Compare it with a local hash to confirm the upload arrived intact. [pattern `^[0-9a-f]{64}$`; example `"9f2b6c1d4e8a7b3c0d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c"`]
  - `width` (integer, nullable, required): Pixel width, or `null` if it could not be read. [example `1600`]
  - `height` (integer, nullable, required): Pixel height, or `null` if it could not be read. [example `1067`]
- `failures` (array<UploadFailure>, required): Files that were rejected.
  - `filename` (string, required): The sanitised original filename. [example `"logo.svg"`]
  - `error` (string, required): Why the file was rejected. One of the per-file messages listed in the endpoint description. [example `"Unsupported file type: image/svg+xml. Allowed: image/jpeg, image/png, image/webp, image/avif"`]
- `summary` (object, required): Counts for the batch.
  - `total` (integer, required): Files processed (`succeeded + failed`). At most 10. [example `2`]
  - `succeeded` (integer, required): Number of entries in `files`. [example `1`]
  - `failed` (integer, required): Number of entries in `failures`. [example `1`]

Example:

```json
{
  "files": [
    {
      "fileId": "6d1e5c0a-2b3f-4a8e-9c7d-0f1e2a3b4c5d",
      "key": "uploads/6d1e5c0a-2b3f-4a8e-9c7d-0f1e2a3b4c5d.jpg",
      "filename": "background.jpg",
      "contentType": "image/jpeg",
      "size": 482113,
      "sha256": "9f2b6c1d4e8a7b3c0d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c",
      "width": 1600,
      "height": 1067
    }
  ],
  "failures": [],
  "summary": {
    "total": 1,
    "succeeded": 1,
    "failed": 0
  }
}
```

### 207

Some files were stored and some were rejected. Inspect `failures`.

Content type `application/json`:

- `files` (array<UploadedFile>, required): Files that were stored.
  - `fileId` (string (uuid), required): Unique id of the stored file. It is the UUID portion of `key`. [example `"6d1e5c0a-2b3f-4a8e-9c7d-0f1e2a3b4c5d"`]
  - `key` (string, required): Object key of the form `uploads/<fileId>.<ext>`. Pass it to `GET /v1/image/{key}` or to any field that accepts an image key. [example `"uploads/6d1e5c0a-2b3f-4a8e-9c7d-0f1e2a3b4c5d.jpg"`]
  - `filename` (string, required): The original filename after sanitising (path stripped, unsafe characters removed, at most 255 characters). `unnamed` when none was sent. [max length 255; example `"background.jpg"`]
  - `contentType` (string, required): The declared and verified MIME type. [one of `"image/jpeg"`, `"image/png"`, `"image/webp"`, `"image/avif"`; example `"image/jpeg"`]
  - `size` (integer, required): Size in bytes. [minimum 1024; maximum 10485760; example `482113`]
  - `sha256` (string, required): Hex SHA-256 of the stored bytes, computed as the file streamed in. Compare it with a local hash to confirm the upload arrived intact. [pattern `^[0-9a-f]{64}$`; example `"9f2b6c1d4e8a7b3c0d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c"`]
  - `width` (integer, nullable, required): Pixel width, or `null` if it could not be read. [example `1600`]
  - `height` (integer, nullable, required): Pixel height, or `null` if it could not be read. [example `1067`]
- `failures` (array<UploadFailure>, required): Files that were rejected.
  - `filename` (string, required): The sanitised original filename. [example `"logo.svg"`]
  - `error` (string, required): Why the file was rejected. One of the per-file messages listed in the endpoint description. [example `"Unsupported file type: image/svg+xml. Allowed: image/jpeg, image/png, image/webp, image/avif"`]
- `summary` (object, required): Counts for the batch.
  - `total` (integer, required): Files processed (`succeeded + failed`). At most 10. [example `2`]
  - `succeeded` (integer, required): Number of entries in `files`. [example `1`]
  - `failed` (integer, required): Number of entries in `failures`. [example `1`]

Example:

```json
{
  "files": [
    {
      "fileId": "6d1e5c0a-2b3f-4a8e-9c7d-0f1e2a3b4c5d",
      "key": "uploads/6d1e5c0a-2b3f-4a8e-9c7d-0f1e2a3b4c5d.jpg",
      "filename": "background.jpg",
      "contentType": "image/jpeg",
      "size": 482113,
      "sha256": "9f2b6c1d4e8a7b3c0d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c",
      "width": 1600,
      "height": 1067
    }
  ],
  "failures": [
    {
      "filename": "logo.svg",
      "error": "Unsupported file type: image/svg+xml. Allowed: image/jpeg, image/png, image/webp, image/avif"
    }
  ],
  "summary": {
    "total": 2,
    "succeeded": 1,
    "failed": 1
  }
}
```

## Errors

Every error is a JSON object with an `error` string. Match on the status code; the message is written for people.

| Status | Message | When |
| --- | --- | --- |
| 400 | Failed to parse upload | The multipart body could not be parsed — it is malformed or truncated, or the `multipart/form-data` header has no `boundary` parameter. |
| 400 | No files were stored. | Every file's stream broke off mid-upload (`File stream error`). The body also carries `files`, `failures` and `summary`. |
| 413 | No files were stored. | Every file exceeded 10 MB. The body also carries `files` (empty), `failures` and `summary`; read `failures[].error`.Body: `{"error":"No files were stored.","files":[],"failures":[{"filename":"huge.png","error":"File exceeds maximum size of 10 MB"}],"summary":{"total":1,"succeeded":0,"failed":1}}` |
| 415 | Content-Type must be multipart/form-data | The request's `Content-Type` header is not `multipart/form-data`. |
| 415 | No files were stored. | Every file declared an unaccepted type, or its bytes did not match the declared type. The body also carries `files` (empty), `failures` and `summary`; read `failures[].error`.Body: `{"error":"No files were stored.","files":[],"failures":[{"filename":"notes.txt","error":"Unsupported file type: text/plain. Allowed: image/jpeg, image/png, image/webp, image/avif"}],"summary":{"total":1,"succeeded":0,"failed":1}}` |
| 422 | No files provided | The request has no body, or the body parsed but contained no file parts. |
| 422 | No files were stored. | Every file was rejected, for differing client-side reasons or because a file was under 1 KB. The body also carries `files` (empty), `failures` and `summary`; read `failures[].error`. |

### Shared authentication, quota and rate-limit errors

| Status | Message | When |
| --- | --- | --- |
| 401 | API key missing. | The `Authorization` header is absent, is not `Bearer <key>`, or the key does not have three dash-separated segments. |
| 401 | Authentication failed: public key is invalid or not recognized. | A public-key endpoint was called with a key that does not exist. |
| 401 | Authentication failed: private key provided instead of a public key. | A public-key endpoint was called with an `sgpt-sk-…` key. |
| 401 | Authentication failed: token is not recognized. | A private-key endpoint was called with a key whose lookup segment does not exist. |
| 401 | Authentication failed: private key is invalid. | A private-key endpoint was called with a key whose secret does not match. |
| 401 | Authentication failed: public key provided instead of a private key. | A private-key endpoint was called with an `sgpt-pk-…` key. |
| 401 | Authentication failed: bearer token format is invalid. | A private key was sent without its `::` lookup segment. |
| 401 | Authentication failed: API key could not be read. Regenerate the key. | The key's embedded organization data could not be decrypted or parsed. |
| 403 | Demo mode is not available for private-key routes. Demos are only supported with public API keys. | The demo key was sent to a private-key endpoint. |
| 403 | Demo mode is not supported on this API route. Check the documentation for available demo endpoints. | The demo key was sent to a public-key endpoint that opts out of demo mode. |
| 403 | No active subscription for this organization. | The organization that owns the key has no active subscription. |
| 403 | No quota found for organization "<organizationId>". | The organization has no quota record for the current billing period. |
| 403 | No entitlement for "<resource>". | The plan's quota template has no entry for this endpoint. |
| 403 | Access denied to "<resource>". | The plan's quota template turns this endpoint off. |
| 429 | Monthly quota exceeded for "<resource>" (<quota>/<quota> used). | The billing-period quota for this endpoint is exhausted. `Retry-After` gives the seconds until the period resets. |
| 429 | RPM limit exceeded for "<resource>" (<used>/<limit> used). | More than the allowed requests per minute were sent. `Retry-After` is 60. |
| 500 | Internal server error. | An unexpected failure on the server. The message is always this string; details are logged, never returned. Quota consumed by the request is refunded, as it is for every 5xx response. |
