> ## 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.

export const Since = ({version, from}) => {
  const knownPrevious = {
    '5.0': '4.10',
    '6.0': '5.4'
  };
  const previous = (from ?? knownPrevious[version]) ?? (() => {
    const [major, minor] = version.split('.').map(Number);
    if (Number.isNaN(major) || Number.isNaN(minor) || minor < 1) {
      throw new Error(`<Since version="${version}" />: cannot derive previous version automatically. ` + `Pass an explicit "from" prop, e.g. <Since version="${version}" from="X.Y" />.`);
    }
    return `${major}.${minor - 1}`;
  })();
  return <Tooltip tip={`Available since Spree ${version}+.`} cta="Upgrade instructions" href={`/developer/upgrades/${previous}-to-${version}`}>
      <Badge icon="lock">Spree {version}+</Badge>
    </Tooltip>;
};

<Since version="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](/docs/api-reference/seller-api/introduction)).
2. Send the returned `signed_id` to the endpoint that uses it — product [media](/docs/developer/core-concepts/media), an import, a logo, a document.

## Uploading with the SDK

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

<Tabs>
  <Tab title="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 })
    ```
  </Tab>

  <Tab title="Spree CLI">
    ```bash theme={"theme":"night-owl"}
    spree api upload ./products.csv --private
    ```
  </Tab>
</Tabs>

`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.

<Tabs>
  <Tab title="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 })
    ```
  </Tab>

  <Tab title="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" }'
    ```
  </Tab>
</Tabs>

**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.

<Tabs>
  <Tab title="SDK">
    ```typescript theme={"theme":"night-owl"}
    const { signed_id } = await client.files.upload(file, { method: 'multipart' })
    ```
  </Tab>

  <Tab title="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'
    ```
  </Tab>
</Tabs>

## 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](/docs/developer/upgrades/5.6-to-6.0#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.