# Uploads API reference

> HTML version: https://storeshot.art/docs/api/uploads/

Hand over images once; reference them in generations. Contract 1.18.0; base URL `https://api.storeshot.art/v1`. Schemas: https://storeshot.art/docs/api/schemas.md

## GET /uploads

**List uploads** (operation `listUploads`). Auth: API key with scope `uploads:write`, `generations:write` or `generations:read`, or access token.

Requires `uploads:write`, `generations:write` or `generations:read`. Uploads of the account that are not deleted (every API key and environment), newest first.

**Credits.** Free: no credits.

**Parameters:**

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `limit` | query | `integer` | no | 1–100, default 20. Default: 20 |
| `cursor` | query | `string` | no | `nextCursor` of the previous page. |

**Responses:**

| Status | Description | Schema | Headers |
|---|---|---|---|
| `200` | A page of uploads. | `UploadList` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` |
| `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` |
| `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |

Example 200 response:

```json
{
  "data": [
    {
      "id": "6f1c1f0e-8a5e-4b8e-9a51-0f0e1d2c3b4a",
      "status": "ready",
      "source": "multipart",
      "environment": "live",
      "contentType": "image/png",
      "bytes": 482113,
      "width": 1290,
      "height": 2796,
      "orientation": "portrait",
      "sha256": "9b1e5e5a3c0f8d2b7a6c4e1f0d9b8a7c6e5d4c3b2a1f0e9d8c7b6a5f4e3d2c1b",
      "sourceUrl": null,
      "expiresAt": "2026-11-20T10:00:00.000Z",
      "createdAt": "2026-10-21T10:00:00.000Z"
    }
  ],
  "nextCursor": "string"
}
```

## POST /uploads

**Upload an image** (operation `createUpload`). Auth: API key with scope `uploads:write`, or access token.

Requires `uploads:write`. Send either `multipart/form-data` with one file part `file`, or JSON `{ "url": "https://…" }` to have the server fetch the image (https only, public addresses only, at most 3 redirects and 15 seconds). PNG, JPEG or WebP, at most 20 MB and 50 megapixels; the format is detected from the bytes. HEIC is not supported. The upload is `ready` in the response. It is deleted 30 days after creation; generated results are kept until you delete them.

**When to use.** Once per raw screenshot (or logo) before creating generations or sets, which take upload ids. Use multipart for local files and `{ "url" }` for public https images.

**Credits.** Free: no credits (counts towards the upload quota).

**Request body** (`multipart/form-data`, required):

| Field | Type | Required | Description |
|---|---|---|---|
| `file` | `string` | yes | Exactly one file part named `file`: PNG, JPEG or WebP, at most 20 MB and 50 megapixels. Format: binary |

**Request body** (`application/json`, required, schema `CreateUploadFromUrl`):

| Field | Type | Required | Description |
|---|---|---|---|
| `url` | `string` | yes | An `https://` URL (port 443, no credentials, at most 2048 characters) that resolves to a public address. Fetched once, server-side. |

Example request body:

```json
{
  "url": "https://example.com/screenshots/home.png"
}
```

**Responses:**

| Status | Description | Schema | Headers |
|---|---|---|---|
| `201` | Created and ready. | `Upload` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` |
| `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `413` | `PAYLOAD_TOO_LARGE` (the request body is over its limit) or `IMAGE_TOO_LARGE` (see `details.reason`). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `415` | `UNSUPPORTED_FORMAT`: not a PNG, JPEG or WebP image (see `details.reason`). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `422` | `URL_FETCH_FAILED`: the URL could not be fetched (see `details.reason`). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` |
| `503` | `SERVICE_UNAVAILABLE`: uploads are busy. Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` |
| `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |

Example 201 response:

```json
{
  "id": "6f1c1f0e-8a5e-4b8e-9a51-0f0e1d2c3b4a",
  "status": "ready",
  "source": "multipart",
  "environment": "live",
  "contentType": "image/png",
  "bytes": 482113,
  "width": 1290,
  "height": 2796,
  "orientation": "portrait",
  "sha256": "9b1e5e5a3c0f8d2b7a6c4e1f0d9b8a7c6e5d4c3b2a1f0e9d8c7b6a5f4e3d2c1b",
  "sourceUrl": null,
  "expiresAt": "2026-11-20T10:00:00.000Z",
  "createdAt": "2026-10-21T10:00:00.000Z"
}
```

## POST /uploads/presign

**Start a presigned upload** (operation `presignUpload`). Auth: API key with scope `uploads:write`, or access token.

Requires `uploads:write`. Returns a pending upload and a URL to `PUT` the file to (from your server; valid 15 minutes), with the exact headers to send. Then call `POST /uploads/{id}/complete`. A pending upload that is never completed is deleted after 24 hours.

**When to use.** Only when your server should send the bytes straight to storage; agents and scripts use `POST /uploads`.

**Credits.** Free: no credits.

**Request body** (`application/json`, required, schema `PresignUploadRequest`):

| Field | Type | Required | Description |
|---|---|---|---|
| `contentType` | `string` | yes | The type of the file you will PUT: `image/png`, `image/jpeg` or `image/webp`. |
| `bytes` | `integer` | yes | Exact size of the file you will PUT, at most 20 MB (20971520). |

Example request body:

```json
{
  "contentType": "image/png",
  "bytes": 482113
}
```

**Responses:**

| Status | Description | Schema | Headers |
|---|---|---|---|
| `201` | Pending upload and its presigned URL. | `PresignedUpload` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` |
| `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `413` | `PAYLOAD_TOO_LARGE` (the request body is over its limit) or `IMAGE_TOO_LARGE` (see `details.reason`). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `415` | `UNSUPPORTED_FORMAT`: not a PNG, JPEG or WebP image (see `details.reason`). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` |
| `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |

Example 201 response:

```json
{
  "upload": {
    "id": "6f1c1f0e-8a5e-4b8e-9a51-0f0e1d2c3b4a",
    "status": "ready",
    "source": "multipart",
    "environment": "live",
    "contentType": "image/png",
    "bytes": 482113,
    "width": 1290,
    "height": 2796,
    "orientation": "portrait",
    "sha256": "9b1e5e5a3c0f8d2b7a6c4e1f0d9b8a7c6e5d4c3b2a1f0e9d8c7b6a5f4e3d2c1b",
    "sourceUrl": null,
    "expiresAt": "2026-11-20T10:00:00.000Z",
    "createdAt": "2026-10-21T10:00:00.000Z"
  },
  "uploadUrl": "https://example.com",
  "method": "PUT",
  "headers": {
    "Content-Type": "image/png",
    "Content-Length": "482113"
  },
  "uploadUrlExpiresAt": "2026-10-21T12:00:00.000Z"
}
```

## POST /uploads/{id}/complete

**Complete a presigned upload** (operation `completeUpload`). Auth: API key with scope `uploads:write`, or access token.

Requires `uploads:write`. Checks the file you PUT (size, format, caps) and makes the upload `ready`. Idempotent for a ready upload. `409 UPLOAD_NOT_READY` with `object_missing` means the file has not arrived yet: PUT it, then retry. A rejected file makes the upload `failed` and is deleted.

**Credits.** Free: no credits.

**Parameters:**

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `id` | path | `string` | yes | Upload id. An unknown or malformed id is 404. |

**Responses:**

| Status | Description | Schema | Headers |
|---|---|---|---|
| `200` | Ready. | `Upload` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` |
| `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `404` | `NOT_FOUND`: no such resource for this caller. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `409` | `UPLOAD_NOT_READY`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `413` | `PAYLOAD_TOO_LARGE` (the request body is over its limit) or `IMAGE_TOO_LARGE` (see `details.reason`). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `415` | `UNSUPPORTED_FORMAT`: not a PNG, JPEG or WebP image (see `details.reason`). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` |
| `503` | `SERVICE_UNAVAILABLE`: uploads are busy. Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` |
| `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |

Example 200 response:

```json
{
  "id": "6f1c1f0e-8a5e-4b8e-9a51-0f0e1d2c3b4a",
  "status": "ready",
  "source": "multipart",
  "environment": "live",
  "contentType": "image/png",
  "bytes": 482113,
  "width": 1290,
  "height": 2796,
  "orientation": "portrait",
  "sha256": "9b1e5e5a3c0f8d2b7a6c4e1f0d9b8a7c6e5d4c3b2a1f0e9d8c7b6a5f4e3d2c1b",
  "sourceUrl": null,
  "expiresAt": "2026-11-20T10:00:00.000Z",
  "createdAt": "2026-10-21T10:00:00.000Z"
}
```

## GET /uploads/{id}

**Get an upload** (operation `getUpload`). Auth: API key with scope `uploads:write`, `generations:write` or `generations:read`, or access token.

Requires `uploads:write`, `generations:write` or `generations:read`. Metadata only, in any status (deleted uploads included).

**Credits.** Free: no credits.

**Parameters:**

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `id` | path | `string` | yes | Upload id. An unknown or malformed id is 404. |

**Responses:**

| Status | Description | Schema | Headers |
|---|---|---|---|
| `200` | The upload. | `Upload` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` |
| `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `404` | `NOT_FOUND`: no such resource for this caller. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` |
| `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |

Example 200 response:

```json
{
  "id": "6f1c1f0e-8a5e-4b8e-9a51-0f0e1d2c3b4a",
  "status": "ready",
  "source": "multipart",
  "environment": "live",
  "contentType": "image/png",
  "bytes": 482113,
  "width": 1290,
  "height": 2796,
  "orientation": "portrait",
  "sha256": "9b1e5e5a3c0f8d2b7a6c4e1f0d9b8a7c6e5d4c3b2a1f0e9d8c7b6a5f4e3d2c1b",
  "sourceUrl": null,
  "expiresAt": "2026-11-20T10:00:00.000Z",
  "createdAt": "2026-10-21T10:00:00.000Z"
}
```

## DELETE /uploads/{id}

**Delete an upload** (operation `deleteUpload`). Auth: API key with scope `uploads:write`, or access token.

Requires `uploads:write`. Deletes the file now; the upload keeps its metadata with status `deleted`. Idempotent.

**Credits.** Free: no credits.

**Parameters:**

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `id` | path | `string` | yes | Upload id. An unknown or malformed id is 404. |

**Responses:**

| Status | Description | Schema | Headers |
|---|---|---|---|
| `204` | Deleted. | — | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `400` | `INVALID_INPUT`. A credential sent in the query string is refused before authentication, without RateLimit headers. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `401` | `UNAUTHORIZED`: missing, malformed, unknown, revoked or expired credential. | `ErrorEnvelope` | `X-Request-Id` |
| `403` | `FORBIDDEN`: see `details.reason`. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `404` | `NOT_FOUND`: no such resource for this caller. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
| `429` | `RATE_LIMITED`: the request rate of the API key (`details.scope` `key`) or of the account (`user`) is exhausted for this window, or too many failed authentication attempts came from this IP (no `details.scope` and no RateLimit headers). Retry after `Retry-After` seconds. | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy`, `Retry-After` |
| `default` | Error (see ErrorEnvelope and /v1/docs/errors). | `ErrorEnvelope` | `X-Request-Id`, `RateLimit`, `RateLimit-Policy` |
