---
title: Assets
url: https://base_url.placeholder/docs/assets
group: Concepts
---

# Assets

An asset is an image stored in your account, with a stable id and everything ImageStep knows about it. Every op reads assets and every job writes them, so a pipeline is a chain of ids rather than a chain of files — and an agent never has to hold image bytes. Publish one and it gets a URL on the CDN that does not change.

## Getting images in

Three ways in, and which one is right is decided by what you are holding. All three end in the same place: an asset in `PROCESSING` that becomes `DONE` a second or two later, when the worker has written its preview, dimensions and metadata. Every example below runs as written — it starts from the key and ends with a finished asset.

|   | `POST /assets/upload` | three-step upload | `POST /assets/from-url` |
| --- | --- | --- | --- |
| You hold | one image | a large file, or many | links |
| Per call | one file | up to 500 files | up to 20 URLs |
| Per file | 25 MB | 100 MB | 25 MB |
| The bytes | the request body, streamed to storage | PUT straight to the object store, never through the API | fetched by the service |
| The same bytes again | the asset you already have, existing: true | stage-upload says exists and names the asset; skip that file | the asset you already have, existing: true |
| A retry | safe as it is: same bytes, same asset | Idempotency-Key on both calls | safe as it is: same bytes, same asset |
| Who uses it | curl, a no-code HTTP module | the SDKs, the CLI and the n8n node, in one call | a workflow or agent holding a link |

**What counts as an image** is a file with one of these extensions: `.jpg` `.jpeg` `.png` `.gif` `.webp` `.svg` `.bmp` `.tiff` `.tif` `.avif` `.jxl` `.jp2` `.j2k` `.psd` `.ico` `.heic` `.heif` `.heics` `.heifs`, or a camera RAW file: `.cr2` `.cr3` `.crw` `.nef` `.nrw` `.arw` `.sr2` `.srf` `.orf` `.raw` `.rw2` `.raf` `.pef` `.dng` `.mrw` `.srw` `.3fr` `.fff` `.iiq` `.mef` `.dcr` `.k25` `.kdc` `.rwl`. The three-step upload types a file by its name's extension and nothing else; `upload` and `from-url` take a declared image `Content-Type` first and fall back to the extension. Anything else is refused before it is stored — what each way refuses is below. The original is kept byte for byte whatever it is; how each format is read back and published comes after.

### Upload one image

`POST /assets/upload` takes the image as the request body — raw, with its own `Content-Type` and a `Content-Length`, or as `multipart/form-data` with a `file` part — and `name`, `collection` and `tags` in the query string. Up to 25 MB per file. The answer is short: `id`, `name`, `status` and `existing`. Bytes the account already holds as a finished asset (same SHA-1) come back as that asset — `DONE`, `existing: true`, its own tags kept and nothing new stored — so re-sending is safe and there is no `Idempotency-Key` to manage.

#### JavaScript

```js
import { ImageStep } from "imagestep";                                    // pnpm add imagestep
const client = new ImageStep({ apiKey: process.env.IMAGESTEP_API_KEY });

// one call: the SDK stages, PUTs, finishes and waits for ingest — the same call at any size
const asset = await client.assets.upload("./product.jpg", { collection: "shoot-01" });
console.log(asset.id, asset.status);                                      // …  DONE
```

#### Python

```python
from imagestep import ImageStep                                # pip install imagestep
client = ImageStep()                                           # reads IMAGESTEP_API_KEY

# one call: the SDK stages, PUTs, finishes and waits for ingest — the same call at any size
asset = client.assets.upload("./product.jpg", collection="shoot-01")
print(asset["id"], asset["status"])                            # …  DONE
```

#### CLI

```sh
pnpm add -g imagestep-cli                                      # or: npm install -g imagestep-cli
imagestep login                                                # or export IMAGESTEP_API_KEY=is_sk_…

# the CLI returns as soon as finish-upload has accepted the file; -o json prints one row per file with its assetId
ASSET_ID=$(imagestep asset upload ./product.jpg -c shoot-01 -o json | jq -r '.[0].assetId')

# wait for ingest: PROCESSING → DONE takes a second or two
until STATUS=$(imagestep asset get "$ASSET_ID" -o json | jq -r '.status'); [ "$STATUS" != "PROCESSING" ]; do sleep 1; done
echo "$ASSET_ID $STATUS"
```

#### curl

```sh
export IMAGESTEP_API_KEY=is_sk_…                       # /keys

# the bytes are the body; name and collection ride in the query string
ASSET_ID=$(curl -s "https://api.imagestep.dev/api/v1/assets/upload?name=product.jpg&collection=shoot-01" \
  -H "Authorization: ApiKey $IMAGESTEP_API_KEY" -H "Content-Type: image/jpeg" \
  --data-binary @product.jpg | jq -r '.data.id')

# wait for ingest: PROCESSING → DONE takes a second or two (FAILED when the bytes could not be read)
until STATUS=$(curl -s "https://api.imagestep.dev/api/v1/assets/status" -H "Authorization: ApiKey $IMAGESTEP_API_KEY" \
    -H "Content-Type: application/json" -d "$(jq -n --arg id "$ASSET_ID" '{ids: [$id]}')" | jq -r '.data.items[0].status'); \
  [ "$STATUS" != "PROCESSING" ]; do sleep 1; done
echo "$ASSET_ID $STATUS"
```

#### curl -F

```sh
export IMAGESTEP_API_KEY=is_sk_…                       # /keys

# multipart: the file part brings its own name and type, so only the collection is left for the query string
ASSET_ID=$(curl -s "https://api.imagestep.dev/api/v1/assets/upload?collection=shoot-01" \
  -H "Authorization: ApiKey $IMAGESTEP_API_KEY" \
  -F file=@product.jpg | jq -r '.data.id')

# wait for ingest: PROCESSING → DONE takes a second or two (FAILED when the bytes could not be read)
until STATUS=$(curl -s "https://api.imagestep.dev/api/v1/assets/status" -H "Authorization: ApiKey $IMAGESTEP_API_KEY" \
    -H "Content-Type: application/json" -d "$(jq -n --arg id "$ASSET_ID" '{ids: [$id]}')" | jq -r '.data.items[0].status'); \
  [ "$STATUS" != "PROCESSING" ]; do sleep 1; done
echo "$ASSET_ID $STATUS"
```

### Large files and batches: the three-step upload

Above 25 MB, or for many files at once, the bytes go to the object store directly. `POST /assets/stage-upload` declares each file (name, byte size, optional SHA-1 — up to 500 files of 100 MB) and returns, per file, a presigned PUT URL valid for an hour, the object id and the Content-Type that was signed into the URL; a file it will not take — too large, not an image, no size — gets an `error` in its place and no URL. You PUT the bytes there, then `POST /assets/finish-upload` turns the objects into assets and answers `id`, `name` and `status` for each.

Two rules the object store enforces, not this API: the PUT must carry exactly the `contentType` stage-upload returned and a `Content-Length` equal to the size you declared — both are in the signature, and anything else is rejected. A file whose SHA-1 the account already has comes back from stage-upload as `exists` with the existing id; the presigned slot is still empty, so skip the PUT _and_ the finish for that one. A staged object becomes one asset, once, and within a day: finish-upload checks every item before it writes any, and refuses the whole batch (`400 invalid_param` on `objectId`) for an object named twice, one already an asset, or one staged more than a day ago — an object staged and never finished is deleted after a day. Finish-upload is also the call whose asset-limit check counts (`422 asset_count_exceeded`); stage-upload's is advisory. The SDKs, the CLI and the [n8n node](https://base_url.placeholder/docs/n8n#node) always take this route, whatever the size, in one call.

#### JavaScript

```js
import { ImageStep } from "imagestep";                                    // pnpm add imagestep
const client = new ImageStep({ apiKey: process.env.IMAGESTEP_API_KEY });

// the same call as for a small file: the SDK always takes the three-step route (stage → PUT → finish)
// and waits for ingest. Batches are a loop; the bytes never pass through the API.
const files = ["./shoot/raw-01.cr2", "./shoot/raw-02.cr2", "./shoot/raw-03.cr2"];
const assets = await Promise.all(files.map((file) => client.assets.upload(file, { collection: "shoot-01" })));
console.log(assets.map((a) => `${a.id} ${a.status}`));                  // [ "… DONE", "… DONE", "… DONE" ]
```

#### Python

```python
from imagestep import ImageStep                                # pip install imagestep
client = ImageStep()                                           # reads IMAGESTEP_API_KEY

# the same call as for a small file: the SDK always takes the three-step route (stage → PUT → finish)
# and waits for ingest. Batches are a loop; the bytes never pass through the API.
files = ["./shoot/raw-01.cr2", "./shoot/raw-02.cr2", "./shoot/raw-03.cr2"]
assets = [client.assets.upload(f, collection="shoot-01") for f in files]
print([(a["id"], a["status"]) for a in assets])                # [('…', 'DONE'), …]
```

#### CLI

```sh
pnpm add -g imagestep-cli                                      # or: npm install -g imagestep-cli
imagestep login                                                # or export IMAGESTEP_API_KEY=is_sk_…

# wildcards and directories; three uploads in flight (--concurrency), staged, uploaded and finished 500 at a time
imagestep asset upload ./shoot/*.cr2 -c shoot-01 --concurrency 3 -o json
imagestep asset list -c shoot-01 -o json | jq -r '.[] | "\(.id) \(.status)"'
```

#### curl

```sh
export IMAGESTEP_API_KEY=is_sk_…                       # /keys
FILE=raw-shot.cr2

# 1. declare the file: name, byte size and SHA-1. The answer carries a presigned PUT URL (valid an hour),
#    the object id, and the Content-Type that was signed into the URL. `error` is set per item instead
#    when the file is over the cap or not an image.
STAGE=$(curl -s "https://api.imagestep.dev/api/v1/assets/stage-upload" \
  -H "Authorization: ApiKey $IMAGESTEP_API_KEY" -H "Content-Type: application/json" \
  -d "$(jq -n --arg name "$FILE" --argjson size "$(wc -c < "$FILE")" \
           --arg sha1 "$(shasum -a 1 "$FILE" | cut -d' ' -f1)" \
           '[{fileName: $name, fileSize: $size, sha1Hash: $sha1}]')" | jq -c '.data[0]')

# already stored? `exists` says so, and existingAssetId is the asset — the presigned slot is still empty,
# so do not finish an upload you skipped
if [ "$(echo "$STAGE" | jq -r '.exists')" = "true" ]; then echo "$(echo "$STAGE" | jq -r '.existingAssetId') DONE"; fi

# 2. PUT the bytes to the object store, with exactly the Content-Type and length that were signed
curl -s -X PUT "$(echo "$STAGE" | jq -r '.url')" \
  -H "Content-Type: $(echo "$STAGE" | jq -r '.contentType')" --data-binary @"$FILE"

# 3. make it an asset
ASSET_ID=$(curl -s "https://api.imagestep.dev/api/v1/assets/finish-upload" \
  -H "Authorization: ApiKey $IMAGESTEP_API_KEY" -H "Content-Type: application/json" \
  -d "$(jq -n --arg obj "$(echo "$STAGE" | jq -r '.objectId')" --arg name "$FILE" \
           '[{objectId: $obj, name: $name, collection: "shoot-01"}]')" | jq -r '.data[0].id')

# wait for ingest: PROCESSING → DONE takes a second or two (FAILED when the bytes could not be read)
until STATUS=$(curl -s "https://api.imagestep.dev/api/v1/assets/status" -H "Authorization: ApiKey $IMAGESTEP_API_KEY" \
    -H "Content-Type: application/json" -d "$(jq -n --arg id "$ASSET_ID" '{ids: [$id]}')" | jq -r '.data.items[0].status'); \
  [ "$STATUS" != "PROCESSING" ]; do sleep 1; done
echo "$ASSET_ID $STATUS"
```

### From a URL

`POST /assets/from-url` with up to 20 public http(s) URLs, and a `collection` and `tags` for all of them: the service fetches each one, stores it and creates an asset as an upload would. It refuses private, loopback and link-local addresses, does not follow redirects — name the final URL — and gives each fetch 10 seconds and 25 MB. The type is the response's image Content-Type, else the URL's extension; a response that declares something else, or a length over the cap, is turned away before its body is read.

Each URL succeeds or fails on its own — the answer is one row per URL, in order, a reference or an error with `code`, `retryable` and `param` — so one dead link does not fail the batch. As with an upload, the same bytes are the same asset: a URL whose image the account already holds as a finished asset comes back as that asset — `DONE`, `existing: true`, its own name, collection and tags — and so does a second URL in the same request that serves the same image. So a request whose answer you lost can simply be sent again; the same `Idempotency-Key` replays the whole answer instead of fetching again, and the URLs that failed go in a new request. While it fetches, a call holds one of the 4 places per account it shares with the [synchronous endpoints](https://base_url.placeholder/docs/sync).

#### JavaScript

```js
import { ImageStep } from "imagestep";                                    // pnpm add imagestep
const client = new ImageStep({ apiKey: process.env.IMAGESTEP_API_KEY });

// the service fetches; one result per URL, and the SDK waits for ingest of each asset it created
const results = await client.assets.fromUrl(["https://example.com/a.jpg", "https://example.com/b.jpg"], { collection: "inbox" });
for (const r of results) console.log(r.url, r.asset ? `${r.asset.id} ${r.asset.status}` : `failed: ${r.error.code}`);
```

#### Python

```python
from imagestep import ImageStep                                # pip install imagestep
client = ImageStep()                                           # reads IMAGESTEP_API_KEY

# the service fetches; one result per URL, and the SDK waits for ingest of each asset it created
results = client.assets.from_url(["https://example.com/a.jpg", "https://example.com/b.jpg"], collection="inbox")
for r in results:
    print(r["url"], r["asset"]["status"] if "asset" in r else "failed: " + r["error"]["code"])
```

#### CLI

```sh
pnpm add -g imagestep-cli                                      # or: npm install -g imagestep-cli
imagestep login                                                # or export IMAGESTEP_API_KEY=is_sk_…

imagestep asset from-url https://example.com/a.jpg https://example.com/b.jpg -c inbox -o json   # exits 1 if any URL failed
imagestep asset list -c inbox -o json | jq -r '.[] | "\(.id) \(.status)"'
```

#### curl

```sh
export IMAGESTEP_API_KEY=is_sk_…                       # /keys

# one row per URL, in order: {url, id, status, name, existing} — or {url, error: {code, message, retryable}}
ROWS=$(curl -s "https://api.imagestep.dev/api/v1/assets/from-url" \
  -H "Authorization: ApiKey $IMAGESTEP_API_KEY" -H "Content-Type: application/json" \
  -d '{"urls": ["https://example.com/a.jpg", "https://example.com/b.jpg"], "collection": "inbox"}' | jq -c '.data')
echo "$ROWS" | jq -r '.[] | select(.error) | "\(.url): \(.error.code)"'      # the ones that did not land

# wait for ingest of the ones that did
IDS=$(echo "$ROWS" | jq -c '[.[] | select(.id) | .id]')
until curl -s "https://api.imagestep.dev/api/v1/assets/status" -H "Authorization: ApiKey $IMAGESTEP_API_KEY" \
    -H "Content-Type: application/json" -d "$(jq -n --argjson ids "$IDS" '{ids: $ids}')" \
    | jq -e '[.data.items[].status] | all(. != "PROCESSING")' > /dev/null; do sleep 1; done
echo "$IDS"
```

### What each way refuses

A refusal of the whole request stores nothing. On `from-url`, what is wrong with one URL — its size, its type, the URL itself — is an error in that URL's row while the others go ahead, and so is a host that fails for now or a failure on our side while storing it (retryable: send that URL again); the rest refuse the request before anything is fetched.

| when | answer | on |
| --- | --- | --- |
| past your plan's asset ceiling | `422 asset_count_exceeded`, checked before anything is stored | every call that creates |
| more than 20 URLs | `400 invalid_param` on urls | from-url |
| 4 synchronous calls of yours already in flight | `429 rate_limited` \+ Retry-After | from-url |
| a file over 25 MB | `413 payload_too_large`, details.limit in bytes | upload, from-url |
| not one of the extensions or image types above | `400 unsupported_format` | upload, from-url |
| a URL that is not http(s), resolves to a private address, redirects, answers another HTTP error or is empty | `400 invalid_param` on url | from-url |
| a URL whose host answers 5xx, 429 or 408, or not in time | `503 provider_unavailable` on url, retryable | from-url |
| storing that URL's image failed on our side | `500 internal_error` on url, retryable | from-url |
| a raw body without a Content-Length, an empty one, or JSON | `400 invalid_param` | upload |
| a form-encoded body | `415 unsupported_media_type` | upload |
| the node is at capacity | `503 provider_unavailable`, details.reason capacity, + Retry-After | upload, from-url |
| more than 500 files | `400 invalid_param` | stage-upload, finish-upload |
| a file over 100 MB, not an image, or with no size | an error on that file, no URL; the others are staged | stage-upload |
| an object not staged by this account, named twice, already an asset, or staged over a day ago; a name with no accepted extension | `400 invalid_param` on objectId or name — the whole batch | finish-upload |

### Waiting until it is usable

A new asset is `PROCESSING` until the worker has written its preview, dimensions and metadata; an op run on it before then fails that item with `invalid_state`. `POST /assets/status` takes up to 100 ids and answers each one's status in one call — ids that are not yours are left out rather than reported, and `DONE` and `FAILED` are final. It is the poll at the end of every curl example above, and what the SDKs' `upload` / `waitReady`, the n8n node's _Wait Until Ready_ and the console all do. Usually a second or two.

### Or store nothing

If you are holding an image and only want the result back, a deterministic op runs in one call on the [synchronous endpoints](https://base_url.placeholder/docs/sync) and creates no asset at all.

## What an asset carries

| field | type | what it is |
| --- | --- | --- |
| id | `string` | the reference you pass to every op, job and preset run |
| name · collection | `string` | its name — for an upload, the file name without its extension — and the collection it is in (below) |
| source | `upload \| process \| ai-generate \| ai-edit \| render` | where it came from: an upload, or the kind of job that made it |
| tags | `string[]` | your own labels (below), in the order you gave them; [] when there are none |
| status | `PROCESSING \| DONE \| FAILED` | PROCESSING while the preview, dimensions and metadata are written, then DONE — or FAILED when the bytes could not be read |
| image | `AssetImage` | mime type, width, height, byte size, SHA-1 — and what a caller would otherwise download the bytes to learn: hasAlpha and opaque, colorSpace, animated and pages, density, dominantColor, blurhash and thumbhash placeholders, and a perceptual phash that says whether two images are the same picture rather than the same bytes |
| metadata | `object` | everything the file says about itself — EXIF, XMP, IPTC, GPS — under exiftool's own tag names, the same map POST /api/v1/images/metadata answers with |
| takenAt | `integer` | its capture time, from that metadata, in epoch milliseconds |
| lineage | `AssetLineage` | for anything a job produced: the jobId and op, the sourceAssetId it came from, the presetId and presetVersion it ran (a render's templateId and templateVersion), and for a model's work the model, prompt and parameters — absent on an upload |
| published · publicUrl | `boolean · string` | whether it is on the CDN, and its URL there once it is (below) |
| createdAt · updatedAt · expiresAt | `integer` | epoch milliseconds: when it was made; when you last changed its collection, tags or publishing (ingest never moves it); and the day the record and its objects expire (below) |

`GET /api/v1/assets/{id}` returns all of it. A field that could not be derived is absent, never null. An uploaded PNG, once it is `DONE`:

```json
{
  "id": "ast_968460652fda43429d7f0e3b8a3a13f3",
  "name": "product",
  "status": "DONE",
  "published": false,
  "updatedAt": 1789659705377,
  "createdAt": 1789659705376,
  "expiresAt": 1792251705377,
  "image": {
    "mimeType": "image/png",
    "height": 480,
    "width": 640,
    "size": 5747,
    "sha1Hash": "8c1e8524358415ac0f11d40e9eabd34bc8aa64ac",
    "dominantColor": "#3868a8",
    "blurhash": "L25?}$p2fQp2pMfkfQfkfQfQfQfQ",
    "thumbhash": "G5UBBYAIW3Z5h3h3h4d4d4d3jwh3",
    "phash": "d75de6e8f8b000c3",
    "hasAlpha": false,
    "opaque": true,
    "animated": false,
    "pages": 1,
    "colorSpace": "srgb",
    "hasIccProfile": false
  },
  "metadata": {
    "Filter": "Adaptive",
    "BitDepth": "8",
    "FileType": "PNG",
    "MIMEType": "image/png",
    "ColorType": "RGB",
    "ImageSize": "640x480",
    "Interlace": "Noninterlaced",
    "ImageWidth": "640",
    "Megapixels": "0.307",
    "PixelUnits": "meters",
    "Compression": "Deflate/Inflate",
    "ImageHeight": "480",
    "PixelsPerUnitX": "1000",
    "PixelsPerUnitY": "1000",
    "FileTypeExtension": "png"
  },
  "source": "upload",
  "collection": "shoot-01",
  "tags": [
    "hero"
  ]
}
```

An asset a job made has the same shape, and says where it came from — here the `grayscale` of the one above (the rest of the record left out):

```json
{
  "id": "ast_2f6252285c31321fb18b8196150c1397",
  "source": "process",
  "lineage": {
    "jobId": "job_d59ca98c1d6d4b5fbc8eef2477dc5681",
    "op": "grayscale",
    "sourceAssetId": "ast_968460652fda43429d7f0e3b8a3a13f3"
  }
}
```

## Reading it back

| call | answers |
| --- | --- |
| GET /assets/{id} | the full record above |
| GET /assets/{id}/content?variant=readable\|original\|preview | the private bytes: a 302 to a signed URL valid five minutes. The SDK's assets.download and the CLI's asset download follow it, so the API key never reaches storage |
| POST /assets/preview-urls | up to 100 ids → a signed thumbnail URL each, valid fifteen minutes, for painting a listing; not a deliverable |
| POST /assets/status | up to 100 ids → status, width, height; the poll after an upload |

Three forms stand behind an asset, and `variant` names them: `original` is the bytes as uploaded or produced; `readable`, the default, is the image at full size in a type a browser shows — a copy made beside the original when that is HEIF, camera RAW, JPEG XL, JPEG 2000, PSD or ICO, the original itself otherwise — and is what the CDN serves when you publish it; `preview` is a 400px wide WebP for a grid. Asking for one that is not written yet — a `preview` while the asset is `PROCESSING` — is `400 invalid_state`. Two uploaded formats have no browser form of their own: a TIFF is served as `image/tiff`, which most browsers download rather than show, and an SVG is stored and served as a plain download, never rendered — `convert` either in a job if it is going on a page. A model is never handed any of them as stored: each call gets a copy made for it, turned upright, stripped of metadata and no larger than that model reads.

#### JavaScript

```js
const { bytes, contentType } = await client.assets.download("<asset-id>", { variant: "original" });
```

#### Python

```python
data = client.assets.download("<asset-id>", variant="original")
```

#### CLI

```sh
imagestep asset download <asset-id> --variant original -f ./original.png
```

#### curl

```sh
curl -sL "https://api.imagestep.dev/api/v1/assets/<asset-id>/content?variant=original" -H "Authorization: ApiKey $IMAGESTEP_API_KEY" -o original.png
```

The answer is a redirect, and the signed URL is on the object store's host, not this one. `curl -L` follows it and — because the host changed — does **not** send your `Authorization` header along; only `--location-trusted` would, so do not use it. The SDKs and the CLI do the same by hand: they read the `Location` and fetch it with no credentials. No MCP tab: bytes never enter an agent's context; it is handed `publicUrl` instead.

## Publishing

Everything is private until you publish it. `POST /assets/update` with `published: true` puts the asset on the CDN and fills `publicUrl` — the asset itself at full size, never the 400px preview, at a URL that does not expire or change: it is the asset's id on the CDN host, so publishing again after a takedown brings back the same URL. A published output is the deliverable, so its URL is too: it is what a job's outputs hand an agent, what n8n writes into a sheet, what the MCP tools return. `published: false` — or deleting the asset, or its retention running out — takes it down again: the URL answers 404 within about two minutes.

#### JavaScript

```js
const [published] = await client.assets.publish("<asset-id>"); // client.assets.unpublish(id) takes it down
console.log(published.publicUrl);
```

#### Python

```python
published = client.assets.publish("<asset-id>")[0]  # client.assets.unpublish(id) takes it down
print(published["publicUrl"])
```

#### CLI

```sh
imagestep asset publish <asset-id> -o json   # --off takes it down
```

#### curl

```sh
curl -s https://api.imagestep.dev/api/v1/assets/update -H "Authorization: ApiKey $IMAGESTEP_API_KEY" -H "Content-Type: application/json" \
  -d '{"ids":["<asset-id>"],"published":true}'
```

The answer is the updated records; what publishing changed on this one:

```json
{
  "id": "ast_968460652fda43429d7f0e3b8a3a13f3",
  "published": true,
  "publicUrl": "https://cdn.imagestep.dev/ast_968460652fda43429d7f0e3b8a3a13f3"
}
```

No MCP tab, because over MCP publishing is not a second call: `transform`, `generate` and `run_preset` take `publish` and answer with the `publicUrl` of what they made.

Only the deliverable goes public. Publishing an asset does not publish the job that made it, the job's inputs, or the thumbnails, and an asset an analyze job wrote onto stays private — checking a result never makes an original public. An asset published while it is still `PROCESSING` is served as it is and switches to its readable copy when ingest finishes.

## Collections and tags

`collection` is an opaque label you attach to an asset — `shoot-01`, `catalog/2026/spring`, anything. There is no tree, no root and no move-into: an asset is in one collection or in none. Name it at upload or fetch time, change it with `POST /assets/update`, filter on it with `collection` in search. A job puts its outputs in the `collection` you give it, else in the collection of the asset each output was made from. It is the cheapest way to find a batch again, which is why every surface exposes it.

A name is matched exactly, case included, and `root` or `/` is a name like any other. It is at most 200 characters with no control characters, and it cannot start with `job:` — that is where a chain job keeps the images one step makes for the next. Anything else is `400 invalid_param` on `collection`. To take assets out of their collection, send `"collection": ""` to `POST /assets/update`; leaving the field out leaves it alone.

`GET /assets/collections` lists the collections you have — each with `count` (the total a search for it reports) and `lastCreatedAt`, most recently added to first, `q` narrowing to names that contain it, case aside. Check a name there before you filter or submit into it: a misspelt name is not an error, it is a new, empty collection. `POST /assets/collections/rename` with `{ from, to }` moves every asset in one to the other in a single statement — a rename, a merge when `to` already exists, or with `to: ""` the end of the collection. A collection exists while an asset is in it; there is nothing to create first.

#### JavaScript

```js
const { items } = await client.assets.collections({ q: "shoot" });
```

#### Python

```python
page = client.assets.collections(q="shoot")  # page.items
```

#### CLI

```sh
imagestep asset collections -q shoot -o json
```

#### curl

```sh
curl -s "https://api.imagestep.dev/api/v1/assets/collections?q=shoot" -H "Authorization: ApiKey $IMAGESTEP_API_KEY"
```

#### MCP

```text
# tool call
search_assets  {"group_by": "collection", "q": "shoot"}
```

```json
[
  {
    "collection": "shoot-01",
    "count": 1,
    "lastCreatedAt": 1789659705376
  }
]
```

#### JavaScript

```js
const { updated } = await client.assets.renameCollection("shoot-01", "shoot-2026-09");
```

#### Python

```python
renamed = client.assets.rename_collection("shoot-01", "shoot-2026-09")  # renamed["updated"]
```

#### CLI

```sh
imagestep asset rename-collection shoot-01 shoot-2026-09
```

#### curl

```sh
curl -s https://api.imagestep.dev/api/v1/assets/collections/rename -H "Authorization: ApiKey $IMAGESTEP_API_KEY" -H "Content-Type: application/json" \
  -d '{"from":"shoot-01","to":"shoot-2026-09"}'
```

```json
{
  "from": "shoot-01",
  "to": "shoot-2026-09",
  "updated": 1
}
```

No MCP tab on the rename: an agent reads collections and submits into them; reorganising a library is a person's call.

`tags` are the same kind of thing, several at a time: labels you choose. Attach them with `tags` at upload, finish-upload or fetch time, replace them with `POST /assets/update` (`[]` clears them), and find an asset again with `tag` in search, which matches one of them exactly, case included. An asset carries at most 50, each at most 100 characters; blanks and repeats are dropped, and more or longer is `400 invalid_param` on `tags`. A file you upload twice keeps the tags it already had. The one other writer is the `analyze` op asking its default question: the tags and objects it found are appended after yours. Its whole answer, and any answer to a schema of your own, is the job item's `output`, not part of the asset.

#### JavaScript

```js
await client.assets.tag("<asset-id>", ["hero","spring-sale"]);
```

#### Python

```python
client.assets.tag("<asset-id>", ["hero","spring-sale"])
```

#### CLI

```sh
imagestep asset tag <asset-id> --tags hero,spring-sale
```

#### curl

```sh
curl -s https://api.imagestep.dev/api/v1/assets/update -H "Authorization: ApiKey $IMAGESTEP_API_KEY" -H "Content-Type: application/json" \
  -d '{"ids":["<asset-id>"],"tags":["hero","spring-sale"]}'
```

`POST /assets/update` takes up to 10,000 ids, applies each field you send to all of them, and answers with the updated records — an id it cannot find among yours is skipped, not an error, so compare the answer with what you sent. Finding them again by tag is a search with `tag`. No MCP tab: the tools label what they make (`collection` on the call), and do not relabel what is already stored.

## Search

`GET /api/v1/assets` is one query, newest first, with every filter optional and composable. The same contract is `search_assets` in MCP, `asset list` in the CLI and _Asset → List_ in n8n.

#### JavaScript

```js
const { items, meta } = await client.assets.list({ collection: "shoot-01", minWidth: 600, view: "PUBLISHED" });
```

#### Python

```python
page = client.assets.list(collection="shoot-01", min_width=600, view="PUBLISHED")  # page.items, page.meta
```

#### CLI

```sh
imagestep asset list -c shoot-01 --min-width 600 -v published -o json
```

#### curl

```sh
curl -s "https://api.imagestep.dev/api/v1/assets?collection=shoot-01&minWidth=600&view=PUBLISHED" -H "Authorization: ApiKey $IMAGESTEP_API_KEY"
```

#### MCP

```text
# tool call
search_assets  {"collection": "shoot-01", "min_width": 600, "view": "PUBLISHED"}
```

One row of `data`, and the `meta` beside it:

```json
{
  "id": "ast_968460652fda43429d7f0e3b8a3a13f3",
  "name": "product",
  "status": "DONE",
  "mimeType": "image/png",
  "width": 640,
  "height": 480,
  "size": 5747,
  "collection": "shoot-01",
  "tags": [
    "hero"
  ],
  "published": true,
  "publicUrl": "https://cdn.imagestep.dev/ast_968460652fda43429d7f0e3b8a3a13f3",
  "source": "upload",
  "createdAt": 1789659705376,
  "expiresAt": 1792251705377
}
```

```json
{
  "total": 1,
  "page": 0,
  "perPage": 100,
  "hasMore": false,
  "nextCursor": null
}
```

Each row is a summary — `id`, `name`, `status`, `mimeType`, `width`, `height`, `size`, `collection`, `tags`, `published`, `publicUrl`, `source`, `createdAt`, `expiresAt` — and `GET /assets/{id}` has the rest. Pages are `page` (from 0) and `perPage` (100 by default and at most), or `cursor` from `meta.nextCursor`, like every list — [API basics](https://base_url.placeholder/docs/errors#pagination) has the rule and a loop that walks them all.

### Filters

| filter | takes | matches |
| --- | --- | --- |
| `view` | `ALL \| PUBLISHED` | which slice of the library: every asset, or only the published ones |
| `collection` | `string` | one collection, matched exactly, case included |
| `hasCollection` | `true \| false` | false is everything you have not filed — the one thing collection cannot say, which is why the two are never sent together (400 invalid_param) |
| `tag` | `string` | one of your tags, matched exactly, case included |
| `mime` | `string` | an exact MIME type: image/jpeg |
| `source` | `upload \| process \| ai-generate \| ai-edit \| render` | where it came from: an upload, or the kind of job that made it |
| `jobId` · `op` | `string` | everything one run, or one op, produced — an upload has neither and never matches |
| `minWidth` · `maxWidth` · `minHeight` · `maxHeight` | `integer` | pixel bounds, inclusive |
| `takenFrom` · `takenTo` | `string` | the capture time the file's EXIF records |
| `createdFrom` · `createdTo` | `string` | when ImageStep made or ingested it — the axis an automation's output lies along |
| `status` | `PROCESSING \| DONE \| FAILED` | the ingest state; FAILED is an upload whose ingest never finished |
| `q` | `string` | free text over the name and the camera make and model, case-insensitive |

The four time bounds take epoch milliseconds or an ISO-8601 date or instant, read in UTC; a bare date as an upper bound covers the whole of that day. A value the filter cannot read — an unknown `status`, a date that is not one — is `400 invalid_param` naming it.

## Retention, quota and deletion

1. `PROCESSING` — made by an upload, a URL fetch or a job — expiresAt is stamped now, once
2. `DONE` — preview, readable copy and metadata written; publish it whenever you like
3. until one of:

   - `expiresAt` — the day comes — unpublished and removed
   - `DELETE` — you delete it — no trash, no restore

*An upload that cannot be read goes PROCESSING → FAILED instead, and is removed a day later.*

- **Retention is stamped once, at creation.** `expiresAt` comes from your plan's retention window on the day the asset is made and is never recomputed — a downgrade does not shorten what you already paid to store. When the day comes the asset is unpublished and removed. The windows per plan are on [/pricing](https://base_url.placeholder/pricing); a job record expires on the same clock as the assets it produced. An upload that could not be read ends `FAILED`, expires a day after it was made and does not count against your ceiling.
- **A call can keep what it stores for less.** The uploads, a URL fetch and a job take `retentionDays` (CLI `--retention-days`, MCP and Python `retention_days`, n8n Retention Days): the asset is kept that many days or your plan's window, whichever is sooner — more is kept for the plan's time, not refused. A job's outputs keep the job's own `expiresAt`. An automation that posts its images at once need not keep them half a year.
- **Every plan has an asset ceiling**, on the same page. An upload, a URL fetch or a job submit that would pass it is `422 asset_count_exceeded`, refused whole, and the dry run reports `assetCountLeft` so you can see it coming. A chain's intermediate images and failed uploads do not count. Synchronous ops create nothing and count nothing. What you store is what a month makes times how long it is kept, so a shorter `retentionDays` is the way under it that does not cost a plan.
- **Deletion is final.** `DELETE /assets/{id}` (`204`; `404 asset_not_found` when it is not yours) or `POST /assets/delete` with up to 10,000 ids, which answers `deletedCount` and `deleted` and skips an id it cannot find: there is no trash and no restore. A published asset comes off the CDN with it. A job whose source asset was deleted fails that item with `asset_not_found`.
- **Nothing is ever downscaled or re-encoded behind your back.** The `original` variant is the object you stored, byte for byte, for as long as the asset lives.

#### JavaScript

```js
await client.assets.delete(["<asset-id>", "<another-id>"]);
```

#### Python

```python
client.assets.delete(["<asset-id>", "<another-id>"])
```

#### CLI

```sh
imagestep asset delete <asset-id> <another-id> -y
```

#### curl

```sh
curl -s https://api.imagestep.dev/api/v1/assets/delete -H "Authorization: ApiKey $IMAGESTEP_API_KEY" -H "Content-Type: application/json" \
  -d '{"ids":["<asset-id>","<another-id>"]}'
```

No MCP tab: no tool deletes. The body of a `422 asset_count_exceeded` is on [errors, retries & limits](https://base_url.placeholder/docs/errors).
