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.
| Compared | 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
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); // … DONEPython
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"]) # … DONECLI
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
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
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 always take this route, whatever the size, in one call.
JavaScript
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
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
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
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.
JavaScript
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
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
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
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 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:
{
"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):
{
"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
const { bytes, contentType } = await client.assets.download("<asset-id>", { variant: "original" });Python
data = client.assets.download("<asset-id>", variant="original")CLI
imagestep asset download <asset-id> --variant original -f ./original.pngcurl
curl -sL "https://api.imagestep.dev/api/v1/assets/<asset-id>/content?variant=original" -H "Authorization: ApiKey $IMAGESTEP_API_KEY" -o original.pngThe 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
const [published] = await client.assets.publish("<asset-id>"); // client.assets.unpublish(id) takes it down
console.log(published.publicUrl);Python
published = client.assets.publish("<asset-id>")[0] # client.assets.unpublish(id) takes it down
print(published["publicUrl"])CLI
imagestep asset publish <asset-id> -o json # --off takes it downcurl
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:
{
"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
const { items } = await client.assets.collections({ q: "shoot" });Python
page = client.assets.collections(q="shoot") # page.itemsCLI
imagestep asset collections -q shoot -o jsoncurl
curl -s "https://api.imagestep.dev/api/v1/assets/collections?q=shoot" -H "Authorization: ApiKey $IMAGESTEP_API_KEY"MCP
# tool call
search_assets {"group_by": "collection", "q": "shoot"}[
{
"collection": "shoot-01",
"count": 1,
"lastCreatedAt": 1789659705376
}
]JavaScript
const { updated } = await client.assets.renameCollection("shoot-01", "shoot-2026-09");Python
renamed = client.assets.rename_collection("shoot-01", "shoot-2026-09") # renamed["updated"]CLI
imagestep asset rename-collection shoot-01 shoot-2026-09curl
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"}'{
"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
await client.assets.tag("<asset-id>", ["hero","spring-sale"]);Python
client.assets.tag("<asset-id>", ["hero","spring-sale"])CLI
imagestep asset tag <asset-id> --tags hero,spring-salecurl
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
const { items, meta } = await client.assets.list({ collection: "shoot-01", minWidth: 600, view: "PUBLISHED" });Python
page = client.assets.list(collection="shoot-01", min_width=600, view="PUBLISHED") # page.items, page.metaCLI
imagestep asset list -c shoot-01 --min-width 600 -v published -o jsoncurl
curl -s "https://api.imagestep.dev/api/v1/assets?collection=shoot-01&minWidth=600&view=PUBLISHED" -H "Authorization: ApiKey $IMAGESTEP_API_KEY"MCP
# tool call
search_assets {"collection": "shoot-01", "min_width": 600, "view": "PUBLISHED"}One row of data, and the meta beside it:
{
"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
}{
"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 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
PROCESSING— made by an upload, a URL fetch or a job — expiresAt is stamped now, onceDONE— preview, readable copy and metadata written; publish it whenever you like- until one of:
expiresAt— the day comes — unpublished and removedDELETE— you delete it — no trash, no restore
- Retention is stamped once, at creation.
expiresAtcomes 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; a job record expires on the same clock as the assets it produced. An upload that could not be read endsFAILED, 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 Pythonretention_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 ownexpiresAt. 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 reportsassetCountLeftso 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 shorterretentionDaysis the way under it that does not cost a plan. - Deletion is final.
DELETE /assets/{id}(204;404 asset_not_foundwhen it is not yours) orPOST /assets/deletewith up to 10,000 ids, which answersdeletedCountanddeletedand 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 withasset_not_found. - Nothing is ever downscaled or re-encoded behind your back. The
originalvariant is the object you stored, byte for byte, for as long as the asset lives.
JavaScript
await client.assets.delete(["<asset-id>", "<another-id>"]);Python
client.assets.delete(["<asset-id>", "<another-id>"])CLI
imagestep asset delete <asset-id> <another-id> -ycurl
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.