# Upload images (key or sign-in)

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

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

The same upload as `POST /v1/file/upload` — identical body, limits,
per-file checks and response — but it also accepts a signed-in
session, so code running in the Stylor dashboard can use it. It
accepts, in this order:

1. a **private** API key as a Bearer token, or
2. a signed-in Stylor session cookie when no valid Bearer token is present.

A **public** key is refused with `403`: public keys ship in storefront
code, and uploads are not a storefront operation. A private key that is
malformed, unknown or revoked is not an error by itself: the request
falls through to the session check, and is refused only if there is no
session either.

With a private key the files belong to the key's organization. With a
session, they belong to one organization the signed-in account is an
active member of (the first one found — it cannot be chosen); an
account with no organization is refused.

Checks run in this order: authentication, the per-IP rate limit, the
session's organization, then the `Content-Type` and body.

This endpoint is **not metered against your plan**: it draws no quota,
returns no `X-RateLimit-*` headers, and the shared key/quota errors
listed for other endpoints do not apply — only the errors below can
occur. Instead it is limited to 30 requests per minute per client IP
(bucket `api.v1.file.upload.session`), counted after authentication
succeeds. The `429` carries no `Retry-After` header; read `retryAfter`
from the body.

## Example request

```bash
curl -X POST "https://stylor.ai/api/v1/file/upload/session" \
  -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. Any file part is accepted regardless of field name. 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": "huge.png",
      "error": "File exceeds maximum size of 10 MB"
    }
  ],
  "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`. |
| 401 | Authentication required. Provide a Bearer token or sign in. | No valid API key was sent and there is no signed-in session. A malformed or unknown key falls through to the session check before this is returned. |
| 403 | Public keys cannot be used here. Use a private key or sign in. | The Bearer token is a public key (`sgpt-pk-…`). Checked before the session. |
| 403 | No organization is associated with this account. | Session authentication succeeded but the account is not a member of any organization. |
| 403 | Only superusers can choose an upload folder. | The request carried a `folder` query parameter. It is reserved for Stylor staff; leave it out and files are stored under `uploads/`. |
| 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`. |
| 429 | Rate limit exceeded. | More than 30 requests in the current minute from this IP. The body also carries `bucket`, `resource`, `used`, `limit`, `retryAfter` (seconds) and `windowSeconds`.Body: `{"error":"Rate limit exceeded.","bucket":"ip","resource":"api.v1.file.upload.session","used":31,"limit":30,"retryAfter":42,"windowSeconds":60}` |
| 500 | Internal server error. | An unexpected failure on the server, including every file failing to store. The message is always this string; when files were involved the body also carries `files`, `failures` and `summary`. |
