Skip to content

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.

ComparedPOST /assets/uploadthree-step uploadPOST /assets/from-url
You holdone imagea large file, or manylinks
Per callone fileup to 500 filesup to 20 URLs
Per file25 MB100 MB25 MB
The bytesthe request body, streamed to storagePUT straight to the object store, never through the APIfetched by the service
The same bytes againthe asset you already have, existing: truestage-upload says exists and names the asset; skip that filethe asset you already have, existing: true
A retrysafe as it is: same bytes, same assetIdempotency-Key on both callssafe as it is: same bytes, same asset
Who uses itcurl, a no-code HTTP modulethe SDKs, the CLI and the n8n node, in one calla 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.

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

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.

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" ]

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.

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}`);

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.

whenansweron
past your plan's asset ceiling422 asset_count_exceeded, checked before anything is storedevery call that creates
more than 20 URLs400 invalid_param on urlsfrom-url
4 synchronous calls of yours already in flight429 rate_limited + Retry-Afterfrom-url
a file over 25 MB413 payload_too_large, details.limit in bytesupload, from-url
not one of the extensions or image types above400 unsupported_formatupload, from-url
a URL that is not http(s), resolves to a private address, redirects, answers another HTTP error or is empty400 invalid_param on urlfrom-url
a URL whose host answers 5xx, 429 or 408, or not in time503 provider_unavailable on url, retryablefrom-url
storing that URL's image failed on our side500 internal_error on url, retryablefrom-url
a raw body without a Content-Length, an empty one, or JSON400 invalid_paramupload
a form-encoded body415 unsupported_media_typeupload
the node is at capacity503 provider_unavailable, details.reason capacity, + Retry-Afterupload, from-url
more than 500 files400 invalid_paramstage-upload, finish-upload
a file over 100 MB, not an image, or with no sizean error on that file, no URL; the others are stagedstage-upload
an object not staged by this account, named twice, already an asset, or staged over a day ago; a name with no accepted extension400 invalid_param on objectId or name — the whole batchfinish-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

fieldtypewhat it is
idstringthe reference you pass to every op, job and preset run
name · collectionstringits name — for an upload, the file name without its extension — and the collection it is in (below)
sourceupload | process | ai-generate | ai-edit | renderwhere it came from: an upload, or the kind of job that made it
tagsstring[]your own labels (below), in the order you gave them; [] when there are none
statusPROCESSING | DONE | FAILEDPROCESSING while the preview, dimensions and metadata are written, then DONE — or FAILED when the bytes could not be read
imageAssetImagemime 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
metadataobjecteverything 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
takenAtintegerits capture time, from that metadata, in epoch milliseconds
lineageAssetLineagefor 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 · publicUrlboolean · stringwhether it is on the CDN, and its URL there once it is (below)
createdAt · updatedAt · expiresAtintegerepoch 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

callanswers
GET /assets/{id}the full record above
GET /assets/{id}/content?variant=readable|original|previewthe 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-urlsup to 100 ids → a signed thumbnail URL each, valid fifteen minutes, for painting a listing; not a deliverable
POST /assets/statusup 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.

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

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.

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

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.

const { items } = await client.assets.collections({ q: "shoot" });
[
  {
    "collection": "shoot-01",
    "count": 1,
    "lastCreatedAt": 1789659705376
  }
]
const { updated } = await client.assets.renameCollection("shoot-01", "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.

await client.assets.tag("<asset-id>", ["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.

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.

const { items, meta } = await client.assets.list({ collection: "shoot-01", minWidth: 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

filtertakesmatches
viewALL | PUBLISHEDwhich slice of the library: every asset, or only the published ones
collectionstringone collection, matched exactly, case included
hasCollectiontrue | falsefalse is everything you have not filed — the one thing collection cannot say, which is why the two are never sent together (400 invalid_param)
tagstringone of your tags, matched exactly, case included
mimestringan exact MIME type: image/jpeg
sourceupload | process | ai-generate | ai-edit | renderwhere it came from: an upload, or the kind of job that made it
jobId · opstringeverything one run, or one op, produced — an upload has neither and never matches
minWidth · maxWidth · minHeight · maxHeightintegerpixel bounds, inclusive
takenFrom · takenTostringthe capture time the file's EXIF records
createdFrom · createdTostringwhen ImageStep made or ingested it — the axis an automation's output lies along
statusPROCESSING | DONE | FAILEDthe ingest state; FAILED is an upload whose ingest never finished
qstringfree 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. PROCESSINGmade by an upload, a URL fetch or a job — expiresAt is stamped now, once
  2. DONEpreview, readable copy and metadata written; publish it whenever you like
  3. until one of:
    • expiresAtthe day comes — unpublished and removed
    • DELETEyou 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; 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.
await client.assets.delete(["<asset-id>", "<another-id>"]);

No MCP tab: no tool deletes. The body of a 422 asset_count_exceeded is on errors, retries & limits.