Synchronous ops
Send an image, get the result back in the same response. No job, no asset, no public URL — nothing is kept, and nothing of yours is made readable to anyone else.
JavaScript
import { writeFile } from "node:fs/promises";
const small = await client.images.transform("resize", { file: "./product.jpg", parameters: { width: 1200 } });
await writeFile("out.jpg", small);Python
small = client.images.transform("resize", file="./product.jpg", parameters={"width": 1200})
open("out.jpg", "wb").write(small)CLI
imagestep image resize ./product.jpg --width 1200 --out out.jpgcurl
curl --data-binary @product.jpg -H "Content-Type: image/jpeg" \
-H "Authorization: ApiKey $IMAGESTEP_API_KEY" \
"https://api.imagestep.dev/api/v1/images/transform?op=resize&width=1200" -o out.jpgcurl -F
curl -F file=@product.jpg -H "Authorization: ApiKey $IMAGESTEP_API_KEY" \
"https://api.imagestep.dev/api/v1/images/transform?op=resize&width=1200" -o out.jpgMCP
# tool call — one file, a deterministic op: the server takes this road by itself and answers with a path, not an asset
transform {"op": "resize", "file_paths": ["./product.jpg"], "parameters": {"width": 1200}}
# the hosted server cannot read your disk: give it "urls" (next section) and the answer carries a five-minute linkJob or synchronous call?
The line is not speed. It is who carries the retry. A job means this service has promised to finish the work — and that promise is what a row, an object, a settlement and a webhook are paying for. Synchronously, you are still holding the input, so a failure costs you one re-send and costs us nothing to remember. Everything else follows from that:
| Compared | POST /jobs | POST /images/* |
|---|---|---|
| Who finishes it | This service: it retries, settles and reports each item | You, by sending it again |
| You get back | A job handle — or the finished job, with wait up to 60 s — then webhooks | The image bytes, or a five-minute link to them |
| What is kept | A new asset per output, with an id and lineage; publish it for a CDN URL | Nothing |
| Input | Stored assets, up to 10,000 per job | One image: up to 25 MB of bytes, a URL, or one of your assets |
| What can run | Every op, AI included, and every preset | Deterministic ops and presets with no AI step (below) |
| How long | As long as it takes | 10 s (20 s for a render), then 503 deadline_exceeded |
| Cost | AI ops spend credits; a deterministic item counts one against the processing allowance | A success counts one against the processing allowance; never credits |
| Retrying safely | The same Idempotency-Key replays the first answer | No key: send it again |
| At once | 2 running jobs per type; the rest wait their turn | 4 calls in flight per account, then 429 |
So an AI op is always a job — a provider call needs an owner for its retry and its refund — and a batch is a job. A fast AI op still gets its answer in one call: a job submitted with wait.
Which ops can go this way
Ask the catalogue, do not keep a list. An entry of GET /api/v1/ops that can run here carries syncEndpoint, the endpoint that runs it; an entry without one cannot. The rule behind the field: every deterministic op has one, except the one that reads a stored layer (overlay), and render_template and read_metadata have endpoints of their own; an AI op never does. imagestep ops list prints the current list. The SDKs, the CLI, the MCP server and the n8n node all read that field, which is why a new deterministic op works everywhere the day it ships.
One op does one thing. resize resizes: it does not re-encode, and passing it a format is a 400 invalid_param naming the parameter — an op only accepts what its catalogue entry says it takes. Resizing and re-encoding is two steps, and two steps are a preset: save the chain once with POST /api/v1/presets, then run the whole thing in one call with ?preset=. That is the same split the job API has — the synchronous endpoints did not invent a second way to combine things.
Which presets can go this way
A preset is what runs; this lane is who carries the retry. They are separate choices, so a preset is not a job-only thing: a preset with no AI step runs here, with ?preset=<slug> or <slug>@<version>, on one image per call. However many deterministic steps it holds, they compile to a single pass — the same compilation a job gets, so a parameter means the same thing on both.
| The preset… | Here |
|---|---|
| has only deterministic steps, op steps or registry steps, however many | Runs. One call, one image back |
| has an AI step, alone or beside other steps | 400 invalid_param on preset — a model call needs an owner for its retry and refund. Submit it as a job |
has a step that reads a stored layer (composite, overlay) — or any other step that names one of your assets as its second image (boolean, joinChannel) | 400 invalid_param on preset, naming the step — this route holds no credential for stored objects, which is why it is allowed to exist. Submit it as a job |
A refused preset is still a valid preset: the same reference runs unchanged in POST /api/v1/jobs. The refusal is retryable: false, comes before any work is admitted and counts nothing. Nothing rides beside preset: a preset's parameters are written on its steps, so ?preset=…&width=800 — or parameters in a JSON body — is 400 invalid_param naming the key; change the step and save a new version. A call that succeeds counts one against the processing allowance whatever the number of steps. The built-in builtin-util-web-optimize, builtin-util-thumbnail and builtin-util-to-webp are deterministic, so they run here as they are.
JavaScript
const bytes = await client.images.transform(null, { file: "./in.jpg", preset: "builtin-util-web-optimize" });Python
data = client.images.transform(None, file="./in.jpg", preset="builtin-util-web-optimize")CLI
imagestep image run --preset builtin-util-web-optimize ./in.jpg --out out.webpcurl
curl --data-binary @in.jpg -H "Content-Type: image/jpeg" \
-H "Authorization: ApiKey $IMAGESTEP_API_KEY" \
"https://api.imagestep.dev/api/v1/images/transform?preset=builtin-util-web-optimize" -o out.webpNo MCP tab: run_preset always makes a job. And what a preset with a model in it is answered with — nothing ran, nothing was counted:
{
"success": false,
"error": {
"code": "invalid_param",
"message": "Preset 'marketplace-cutout' runs as 2 segments, and this endpoint answers in one pass — submit it as a job: POST /api/v1/jobs",
"retryable": false,
"param": "preset",
"requestId": "12ad37b7-dc84-46a8-80d7-bfe3a87163d0"
},
"timestamp": "2026-09-17T15:41:47.054Z"
}Sending the image
Parameters live in the query string and the image is the body — except the JSON form, where the input is a reference rather than bytes and everything may travel in the body (the query string still wins where both name the same thing).
| form | how | for |
|---|---|---|
| Raw body | the bytes are the body, Content-Type says what they are — the call at the top of this page | the SDKs and the CLI; anything that can post bytes |
| Multipart | multipart/form-data with a file part — the curl -F tab at the top | a client that can only post a form: an HTML form, a workflow tool's HTTP node |
| A reference | a JSON body with a url this service fetches, or the assetId of one of your stored assets | an image that is already somewhere |
JavaScript
const small = await client.images.transform("resize", { url: "https://example.com/product.jpg", parameters: { width: 800 } }); // or assetId: "<asset-id>"Python
small = client.images.transform("resize", url="https://example.com/product.jpg", parameters={"width": 800}) # or asset_id="<asset-id>"curl
curl -s -H "Authorization: ApiKey $IMAGESTEP_API_KEY" -H "Content-Type: application/json" \
-d '{"url":"https://example.com/product.jpg"}' \
"https://api.imagestep.dev/api/v1/images/transform?op=resize&width=800" -o out.jpgMCP
# tool call — one URL and a deterministic op run synchronously; "asset_ids" always makes a job
transform {"op":"resize","urls":["https://example.com/product.jpg"],"parameters":{"width":800}}No CLI tab: the CLI's image subcommands take local files. A url is fetched by this service under the same rules a webhook target gets: https or http only, refused if it resolves to a private, loopback or link-local address, redirects not followed, 10 seconds and 25 MB at most. An assetId sends the asset's readable form — the one a browser shows — so any format you stored works, once the asset is DONE.
What you can send is any format an upload accepts. The ones a browser shows are read from their bytes; the ones the worker converts first — HEIC, camera RAW, PSD, JPEG XL and the rest — are recognised by their Content-Type alone, so send the right one: application/octet-stream on a CR2 is 400 unsupported_format. curl -F guesses a part's type from the file name and falls back to octet-stream; add ;type=image/heic when it does. A url goes on with the type it was served with — or, when that is not an image type, the one its extension names, the rule from-url reads — and an assetId with the type it was stored under. A RAW file here is the camera's embedded preview, or a half-resolution decode when there is none — the full demosaic is a job's, where nothing waits on a connection.
A link instead of the bytes
?response=url answers with a five-minute signed link instead of streaming the image — for an output you would rather hand on than hold. It is the one shape of these endpoints that answers in JSON, and it is what the hosted MCP server asks for on its own, because it has no disk to write to.
JavaScript
const link = await client.images.transform("resize", { file: "./product.jpg", parameters: { width: 1200 }, response: "url" });
console.log(link.url, link.expiresInSeconds);Python
link = client.images.transform("resize", file="./product.jpg", parameters={"width": 1200}, response="url")
print(link["url"], link["expiresInSeconds"])curl
curl -s --data-binary @product.jpg -H "Content-Type: image/jpeg" -H "Authorization: ApiKey $IMAGESTEP_API_KEY" \
"https://api.imagestep.dev/api/v1/images/transform?op=resize&width=1200&response=url"{
"url": "https://…/tmp/sync/…?X-Amz-Expires=300&X-Amz-Signature=…",
"contentType": "image/jpeg",
"bytes": 48213,
"width": 1200,
"height": 900,
"expiresInSeconds": 300
}That is data of the usual envelope. It is the one time this lane writes anything: the result sits in a temporary object behind the link, which the bucket deletes within a day. When the bytes are streamed instead, the same width and height travel as the X-ImageStep-Width and X-ImageStep-Height headers; client.images.transformResult (transform_result in Python) hands them over beside the bytes and the content type, so nobody has to decode the image to name the file. The CLI has no flag for this: it writes files.
Rendering a template
POST /api/v1/images/render is the synchronous form of render_template: a template reference (id@version pins one) and one row of variables in, a PNG out, 20 seconds at most. Writing the template, its versions and the batch form are on templates.
JavaScript
import { writeFile } from "node:fs/promises";
const png = await client.images.render("builtin-template-og-image", { title: "Hello" });
await writeFile("og.png", png);Python
png = client.images.render("builtin-template-og-image", {"title": "Hello"})
open("og.png", "wb").write(png)CLI
imagestep image render --template builtin-template-og-image --data '{"title":"Hello"}' --out og.pngcurl
curl -s -H "Authorization: ApiKey $IMAGESTEP_API_KEY" -H "Content-Type: application/json" \
-d '{"templateId":"builtin-template-og-image","data":{"title":"Hello"}}' \
https://api.imagestep.dev/api/v1/images/render -o og.pngReading metadata
POST /api/v1/images/metadata reads EXIF, GPS, dimensions, format and SHA-1 from the image you send — as bytes or as a reference, like a transform — and answers with the same image and metadata an asset carries, so measuring before you store and reading after agree. It is free, it counts nothing and it stores nothing; it does take one of your 4 places in flight.
JavaScript
const meta = await client.images.metadata("./photo.jpg");Python
meta = client.images.metadata("./photo.jpg")CLI
imagestep image metadata ./photo.jpg -o jsoncurl
curl -s --data-binary @photo.jpg -H "Content-Type: image/jpeg" -H "Authorization: ApiKey $IMAGESTEP_API_KEY" \
https://api.imagestep.dev/api/v1/images/metadataMCP
# tool call — one local file is read without being uploaded
transform {"op": "read_metadata", "file_paths": ["./photo.jpg"]}The CLI's image subcommands are built from the catalogue when it starts, so an op that gains a syncEndpoint is a subcommand the same day.
Limits, and what they answer
Every refusal is the usual error envelope; branch on retryable. Each retryable answer in the table but the allowance carries Retry-After, in seconds.
| when | answer | retryable |
|---|---|---|
| the image is over 25 MB — the body, what a url answered, or the asset read | 413 payload_too_large, details.limit in bytes | no |
| the image is over 50 megapixels, or is not one this lane reads | 400 unsupported_format | no |
| an AI op or one with no syncEndpoint; neither or both of op and preset; a parameter the op does not declare, or anything beside preset; a refused preset; a url that is not http(s), resolves to a private address, redirects, answers another HTTP error or is empty; a template that does not load | 400 invalid_param, param names which | no |
| a form-encoded body | 415 unsupported_media_type | no |
| an assetId, preset or templateId you do not have | 404 asset_not_found · preset_not_found · not_found | no |
| an assetId with no stored object to read | 400 invalid_state | no |
| 4 calls of yours already in flight — transform, render, metadata and from-url share them | 429 rate_limited, details.reason account_concurrency | yes |
| the node is full | 503 provider_unavailable, details.reason capacity | yes |
| a url whose host answers 5xx, 429 or 408, or not in time | 503 provider_unavailable, details.reason url_unavailable | yes |
| reading an assetId from storage broke off | 503 provider_unavailable, details.reason storage_read | yes |
| longer than 10 s (20 s for render) | 503 provider_unavailable, details.reason deadline_exceeded | yes |
| no processing worker reachable | 503 provider_unavailable, details.reason worker_unreachable | yes |
| past your plan's processing allowance, and the balance cannot pay for the call | 402 insufficient_credit: top up — past the allowance a call is paid, not refused | no |
The per-account limit and the full node are two different answers on purpose: the first is something you can fix by slowing down, the second is not your fault at all. The two bodies:
{
"success": false,
"error": {
"code": "rate_limited",
"message": "You already have 4 synchronous requests in flight",
"retryable": true,
"details": { "reason": "account_concurrency", "limit": 4 },
"requestId": "5b1f0c9e-2a47-4d0e-9a63-7f1c2e8d4b90"
},
"timestamp": "2026-09-17T15:41:47.300Z"
}{
"success": false,
"error": {
"code": "provider_unavailable",
"message": "This node is at capacity for synchronous requests; retry shortly",
"retryable": true,
"details": { "reason": "capacity" },
"requestId": "c0a4e6d2-91b3-4f58-8d27-3e9a1b7c5f04"
},
"timestamp": "2026-09-17T15:41:47.300Z"
}- What it costs. A successful
transformorrendercounts one against your plan's processing allowance — the plan says how many — and usage lists it undersync; a failure or a timeout counts nothing. On a paid plan the allowance is unlimited, so nothing is charged; on Free, past its 200 a month, each success is paid from your balance at $0.002, and a call the balance cannot cover is402 insufficient_creditbefore anything runs.metadatais free on every plan. - No
Idempotency-Key. These endpoints create nothing that survives the response, so there is no outcome a replay could protect — and you still hold the input, which is the premise. Sending one is ignored, not an error.