Skip to content

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.

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

const small = await client.images.transform("resize", { file: "./product.jpg", parameters: { width: 1200 } });
await writeFile("out.jpg", small);

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:

ComparedPOST /jobsPOST /images/*
Who finishes itThis service: it retries, settles and reports each itemYou, by sending it again
You get backA job handle — or the finished job, with wait up to 60 s — then webhooksThe image bytes, or a five-minute link to them
What is keptA new asset per output, with an id and lineage; publish it for a CDN URLNothing
InputStored assets, up to 10,000 per jobOne image: up to 25 MB of bytes, a URL, or one of your assets
What can runEvery op, AI included, and every presetDeterministic ops and presets with no AI step (below)
How longAs long as it takes10 s (20 s for a render), then 503 deadline_exceeded
CostAI ops spend credits; a deterministic item counts one against the processing allowanceA success counts one against the processing allowance; never credits
Retrying safelyThe same Idempotency-Key replays the first answerNo key: send it again
At once2 running jobs per type; the rest wait their turn4 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 manyRuns. One call, one image back
has an AI step, alone or beside other steps400 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.

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

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:

{
  "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).

formhowfor
Raw bodythe bytes are the body, Content-Type says what they are — the call at the top of this pagethe SDKs and the CLI; anything that can post bytes
Multipartmultipart/form-data with a file part — the curl -F tab at the topa client that can only post a form: an HTML form, a workflow tool's HTTP node
A referencea JSON body with a url this service fetches, or the assetId of one of your stored assetsan image that is already somewhere
const small = await client.images.transform("resize", { url: "https://example.com/product.jpg", parameters: { width: 800 } }); // or assetId: "<asset-id>"

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.

const link = await client.images.transform("resize", { file: "./product.jpg", parameters: { width: 1200 }, response: "url" });
console.log(link.url, link.expiresInSeconds);
{
  "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.

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

const png = await client.images.render("builtin-template-og-image", { title: "Hello" });
await writeFile("og.png", 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 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.

const meta = await client.images.metadata("./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.

whenanswerretryable
the image is over 25 MB — the body, what a url answered, or the asset read413 payload_too_large, details.limit in bytesno
the image is over 50 megapixels, or is not one this lane reads400 unsupported_formatno
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 load400 invalid_param, param names whichno
a form-encoded body415 unsupported_media_typeno
an assetId, preset or templateId you do not have404 asset_not_found · preset_not_found · not_foundno
an assetId with no stored object to read400 invalid_stateno
4 calls of yours already in flight — transform, render, metadata and from-url share them429 rate_limited, details.reason account_concurrencyyes
the node is full503 provider_unavailable, details.reason capacityyes
a url whose host answers 5xx, 429 or 408, or not in time503 provider_unavailable, details.reason url_unavailableyes
reading an assetId from storage broke off503 provider_unavailable, details.reason storage_readyes
longer than 10 s (20 s for render)503 provider_unavailable, details.reason deadline_exceededyes
no processing worker reachable503 provider_unavailable, details.reason worker_unreachableyes
past your plan's processing allowance, and the balance cannot pay for the call402 insufficient_credit: top up — past the allowance a call is paid, not refusedno

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 transform or render counts one against your plan's processing allowance — the plan 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.