---
title: Synchronous ops
url: https://base_url.placeholder/docs/sync
group: Concepts
---

# 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

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

```python
small = client.images.transform("resize", file="./product.jpg", parameters={"width": 1200})
open("out.jpg", "wb").write(small)
```

#### CLI

```sh
imagestep image resize ./product.jpg --width 1200 --out out.jpg
```

#### curl

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

#### curl -F

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

#### MCP

```text
# 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 link
```

## Job 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:

|   | `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](https://base_url.placeholder/docs/jobs#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](https://base_url.placeholder/docs/presets) 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

```js
const bytes = await client.images.transform(null, { file: "./in.jpg", preset: "builtin-util-web-optimize" });
```

#### Python

```python
data = client.images.transform(None, file="./in.jpg", preset="builtin-util-web-optimize")
```

#### CLI

```sh
imagestep image run --preset builtin-util-web-optimize ./in.jpg --out out.webp
```

#### curl

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

No 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:

```json
{
  "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

```js
const small = await client.images.transform("resize", { url: "https://example.com/product.jpg", parameters: { width: 800 } }); // or assetId: "<asset-id>"
```

#### Python

```python
small = client.images.transform("resize", url="https://example.com/product.jpg", parameters={"width": 800})  # or asset_id="<asset-id>"
```

#### curl

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

#### MCP

```text
# 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](https://base_url.placeholder/docs/assets#in) 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](https://base_url.placeholder/docs/assets#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

```js
const link = await client.images.transform("resize", { file: "./product.jpg", parameters: { width: 1200 }, response: "url" });
console.log(link.url, link.expiresInSeconds);
```

#### Python

```python
link = client.images.transform("resize", file="./product.jpg", parameters={"width": 1200}, response="url")
print(link["url"], link["expiresInSeconds"])
```

#### curl

```sh
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"
```

```json
{
  "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](https://base_url.placeholder/docs/templates).

#### JavaScript

```js
import { writeFile } from "node:fs/promises";

const png = await client.images.render("builtin-template-og-image", { title: "Hello" });
await writeFile("og.png", png);
```

#### Python

```python
png = client.images.render("builtin-template-og-image", {"title": "Hello"})
open("og.png", "wb").write(png)
```

#### CLI

```sh
imagestep image render --template builtin-template-og-image --data '{"title":"Hello"}' --out og.png
```

#### curl

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

## Reading 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](https://base_url.placeholder/docs/assets#record) 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

```js
const meta = await client.images.metadata("./photo.jpg");
```

#### Python

```python
meta = client.images.metadata("./photo.jpg")
```

#### CLI

```sh
imagestep image metadata ./photo.jpg -o json
```

#### curl

```sh
curl -s --data-binary @photo.jpg -H "Content-Type: image/jpeg" -H "Authorization: ApiKey $IMAGESTEP_API_KEY" \
  https://api.imagestep.dev/api/v1/images/metadata
```

#### MCP

```text
# 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](https://base_url.placeholder/docs/errors); 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:

```json
{
  "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"
}
```

```json
{
  "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 `transform` or `render` counts one against your plan's processing allowance — the [plan](https://base_url.placeholder/pricing) says how many — and usage lists it under `sync`; 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 is `402 insufficient_credit` before anything runs. `metadata` is 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.
