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

# Uploading files to the Admin and Seller APIs

> How to upload images, documents and CSV files to Spree, get a signed_id, and attach the file to products, imports, logos and other records.

Spree 6.0+ (Available since Spree 6.0+.)

Endpoints that use a file never take its bytes. You upload the file to the files endpoint first, get back a `signed_id`, and pass that `signed_id` to the endpoint that uses the file:

1. Upload the file with `POST /api/v3/admin/files` (or `POST /api/v3/seller/files` in the [Seller API](https://spreecommerce.org/docs/api-reference/seller-api/introduction.md)).
2. Send the returned `signed_id` to the endpoint that uses it — product [media](https://spreecommerce.org/docs/developer/core-concepts/media.md), an import, a logo, a document.

## Uploading with the SDK

`files.upload()` does the whole upload and resolves to the response:

**SDK**

```typescript theme={"theme":"night-owl"}
const { signed_id } = await client.files.upload(file, { visibility: 'private' })

await client.imports.create({ type: 'products', attachment_signed_id: signed_id })
```

**Spree CLI**

```bash theme={"theme":"night-owl"}
spree api upload ./products.csv --private
```

`file` is any `Blob` or `File` — from a file input in the browser, or `fs.openAsBlob(path)` in Node.

## Two ways to send the bytes

Both return the same response.

**Presigned (default).** Send the file's metadata as JSON, then send the bytes with `upload.method` (always `PUT`) to `upload.url`, carrying exactly `upload.headers`. The bytes go straight to storage, so use this for anything large. `checksum` is the file's MD5 digest, base64-encoded; storage checks the bytes against it.

**SDK**

```typescript theme={"theme":"night-owl"}
// files.upload() runs both steps; this is what it does underneath.
const upload = await client.files.create({
  filename: 'products.csv',
  content_type: 'text/csv',
  byte_size: 48213,
  checksum: 'rL0Y20zC+Fzt72VPzMSk2A==',
  visibility: 'private',
})

await fetch(upload.upload.url, { method: 'PUT', headers: upload.upload.headers, body: file })
```

**cURL**

```bash theme={"theme":"night-owl"}
curl -X POST 'https://api.mystore.com/api/v3/admin/files' \
  -H 'X-Spree-API-Key: sk_xxx' \
  -H 'Content-Type: application/json' \
  -d '{ "filename": "products.csv", "content_type": "text/csv", "byte_size": 48213,
        "checksum": "rL0Y20zC+Fzt72VPzMSk2A==", "visibility": "private" }'
```

**Multipart.** Send the bytes in a `file` part of the request itself. The file is stored before the response, so `upload` is `null`. No checksum is needed, but the size limit is lower. This suits scripts and tools that cannot make a second request.

**SDK**

```typescript theme={"theme":"night-owl"}
const { signed_id } = await client.files.upload(file, { method: 'multipart' })
```

**cURL**

```bash theme={"theme":"night-owl"}
curl -X POST 'https://api.mystore.com/api/v3/admin/files' \
  -H 'X-Spree-API-Key: sk_xxx' \
  -F 'file=@logo.png'
```

## The response

```json theme={"theme":"night-owl"}
{
  "signed_id": "eyJfcmFpbHMiOnsi…",
  "filename": "products.csv",
  "content_type": "text/csv",
  "byte_size": 48213,
  "visibility": "private",
  "expires_at": "2026-10-11T12:00:00.000Z",
  "upload": { "method": "PUT", "url": "https://…", "headers": { "Content-MD5": "…" } }
}
```

* **`signed_id` is opaque.** Pass it back unchanged and never parse it.
* **It expires** at `expires_at`, one day after the upload. An expired or altered `signed_id` is refused with a `422`; upload the file again.
* **The file belongs to your store.** A file uploaded in one store is meant to be used in that store only.

## Visibility

| `visibility` | Use for | Accepted types |
| - | - | - |
| `public` (default) | Images and videos customers see: product media, logos, category images | Web images, videos, CSV |
| `private` | Documents, imports, digital products, purchase orders | Any type |

Attaching a file never moves it between public and private storage, so choose when you upload. Each endpoint still checks the file it receives, so a digital product can be any type while a logo must be an image.

## Size limits

| Upload | Default | Setting |
| - | - | - |
| Presigned | 5 GB | `Spree::Config.max_upload_size` or `SPREE_MAX_UPLOAD_SIZE` (bytes) |
| Multipart | 10 MB | `Spree::Config.max_multipart_upload_size` or `SPREE_MAX_MULTIPART_UPLOAD_SIZE` (bytes) |

## Parameter names

Every parameter that takes an upload is named `signed_id` or `<name>_signed_id`, after the file it sets: `avatar_signed_id`, `logo_signed_id`, `image_signed_id`, `attachment_signed_id`, `document_signed_id`, `file_signed_id`, `po_document_signed_id`. Send `null` to remove a file, or leave the key out to keep it.

## Files that are never used

A file uploaded through the files endpoint that is never attached to anything is deleted after two days by `Spree::Uploads::PurgeUnattachedJob`. Files that belong to no store, such as files from before the 6.0 upgrade that were never attached, are left alone. Schedule it daily (see the [upgrade guide](https://spreecommerce.org/docs/developer/upgrades/5.6-to-6.0.md#file-uploads)). Change the period with `Spree::Config.unattached_upload_retention_days` or `SPREE_UNATTACHED_UPLOAD_RETENTION_DAYS`.

## Image URLs are opaque too

Image URLs in responses (`original_url`, `small_url`, …) can change host or format, for example when a store moves to a CDN. Store and render them as given; never build or parse them.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.