---
title: Synchronous images
url: https://base_url.placeholder/docs/api/images
group: Surfaces
---

# Synchronous images

Run a deterministic operation while you wait. Nothing is stored.

3 endpoints under `/api/v1/images`. What every call shares is on [the REST overview](https://base_url.placeholder/docs/api#conventions), and what they are for — with the calls for every SDK, the CLI and MCP — on [Synchronous ops](https://base_url.placeholder/docs/sync). `*` marks a required field.

- POST /api/v1/images/metadata — Read an image's metadata synchronously
- POST /api/v1/images/render — Render one template row synchronously
- POST /api/v1/images/transform — Transform an image synchronously

## Read an image's metadata synchronously

POST /api/v1/images/metadata

EXIF, GPS, dimensions, format and SHA-1 for the image in the request body. Nothing is stored, no credits are spent, and it does NOT count against the plan's processing allowance — it reads a header.

The image is sent the ways `/images/transform` takes it: raw bytes with their own `Content-Type`, a multipart `file` part, or JSON `{assetId}` / `{url}`.

Returns the same `image` / `metadata` shape `GET /api/v1/assets/{id}` carries, so measuring an image before storing it and reading one after storing it hand back the same fields.

LIMITS: the transform lane's — 25 MB, 50 megapixels, 10 s — and its 4 calls in flight per account (`429 rate_limited`, `Retry-After: 1`).

USE THIS WHEN:

- You want to know what you are holding before deciding whether to store it

DO NOT USE WHEN:

- The image is already an asset → GET /api/v1/assets/{id} already has this

### Request body — `image/*`, `multipart/form-data` or `application/json` (`SyncReferenceInput`)

The image as raw bytes with their own Content-Type, a multipart form with a `file` part, or a JSON reference (`assetId` or `url`) instead of bytes

### Returns `200` — `data` is `object`

`data` is `{image, metadata, durationMs}`: `image` has the fields of an asset's `image` (mime type, dimensions, size, SHA-1 and what is derived from the pixels), `metadata` the map exiftool read under its own tag names, `durationMs` the worker's time.

### Errors

| Status | Code and when |
| --- | --- |
| 400 | - `unsupported_format` — not an image, a format this service does not read, or an input over 50 megapixels<br>- `invalid_param` on `file` / `url` — a multipart body with no `file` part; a `url` this service will not fetch, or one that answers an HTTP error other than 5xx / 429 / 408<br>- `invalid_state` on `assetId` — the asset has no stored object yet |
| 404 | `asset_not_found` — the JSON form's `assetId` is not one of your assets |
| 413 | `payload_too_large` on `file` — the image is over 25 MB; `details.limit` is the ceiling in bytes |
| 415 | `unsupported_media_type` — a form-encoded body; `details.supported` lists what is read |
| 503 | `provider_unavailable`, retryable, with `Retry-After` — `details.reason` is `capacity`, `deadline_exceeded` or `worker_unreachable`, `storage_read` for the JSON form's stored asset, or `url_unavailable` when the `url`'s host answered 5xx, 429 or 408, or not in time. |

## Render one template row synchronously

POST /api/v1/images/render

A template plus ONE row of data becomes one PNG, returned directly. Nothing is stored: no job, no asset, no public URL.

`templateId` accepts `id@version` to pin a version, exactly as the job form does.

LIMITS: 20 s of wall clock (`503`, `deadline_exceeded`). It shares the synchronous lane's 4 calls in flight per account (`429 rate_limited`, `Retry-After: 1`), and a success counts one against the processing allowance — past it, paid from the balance (`402 insufficient_credit` when it cannot be). No `Idempotency-Key`: send it again.

USE THIS WHEN:

- You want one image from one row of data, right now

DO NOT USE WHEN:

- You have many rows → POST /api/v1/jobs {"op":"render_template", "items":[…]}
- You want an asset id or a permanent URL → the job form

### Request body — `RenderRequest`

### Returns `200` — `image/png`

The PNG, at the template's `width` × `height`, with `X-ImageStep-Duration-Ms`

### Errors

| Status | Code and when |
| --- | --- |
| 400 | `invalid_param` on `templateId` — missing, an `@version` that is not a positive number, or a template Chromium could not load (not retryable, as in a batch) |
| 404 | `not_found` on `templateId` — no such template or version, or it is not yours |
| 503 | `provider_unavailable`, retryable, with `Retry-After` — `details.reason` says which:<br>- `capacity` — this node, or the render worker, is full<br>- `deadline_exceeded` — the render took longer than the deadline<br>- `worker_unreachable` — no render worker answered |

## Transform an image synchronously

POST /api/v1/images/transform

Runs ONE deterministic op — or ONE saved preset with no AI step — on ONE image and answers with the result bytes. Nothing is stored: no job, no asset, no public URL.

The image is the request body; the op and its parameters are query parameters: `?op=resize&width=1200`. An op takes only what its catalogue entry declares, so resizing AND re-encoding is two steps, and two steps are a preset: `?preset=<slug>` (or `<slug>@<version>`) runs all of its steps in this one call, and takes no parameters beside it. Which ops may be used here is `GET /api/v1/ops` → `syncEndpoint`; do not hard-code a list.

THREE WAYS TO SEND THE IMAGE: the raw bytes with their own `Content-Type` (the format is read from that header, so `application/octet-stream` on a camera RAW is `unsupported_format`); `multipart/form-data` with a `file` part; or `application/json` with a reference instead of bytes — `{assetId}` (one of your assets) or `{url}` (fetched by this service: `http(s)` only, no private addresses, no redirects) — and then `op` / `preset` / `parameters` may ride in the body too; the query string wins where both say something.

Which presets may be used here follows from their steps: only deterministic steps, however many, run here. A preset with an AI step, or with 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`) — is `400 invalid_param` on `preset`: it is still a valid preset, and the same reference runs as a job.

LIMITS: a body of at most 25 MB, an input of at most 50 megapixels (a bigger one is `400 unsupported_format`: it is refused on the size its header declares, before it is decoded), 10 s of wall clock (`503`, `deadline_exceeded`). At most 4 calls in flight per account: one more is `429 rate_limited` with `details.reason = "account_concurrency"` and `Retry-After: 1`. A success counts one against the plan's processing allowance, however many steps a preset holds; past it, a success is paid from your balance at the plan's `overageCredits` (`GET /api/v1/ops`), and a call the balance cannot cover is `402 insufficient_credit` before anything runs. A failure counts and costs nothing. No `Idempotency-Key`: nothing is created, so send it again.

USE THIS WHEN:

- You are holding an image and only want the result back
- You do not want the input or the output stored

DO NOT USE WHEN:

- The op or a preset step is an AI op, or you have more than one image → POST /api/v1/jobs
- You want an asset id or a permanent URL → POST /api/v1/jobs

### Parameters

| Name | In | Type | Description |
| --- | --- | --- | --- |
| op | query | string | Op name; must be one whose GET /api/v1/ops entry has a syncEndpoint. Mutually exclusive with `preset`. |
| preset | query | string | A deterministic preset to run as one call: its slug or id, or slug@version to pin one version. Mutually exclusive with `op`. |
| response | query | string | `url` to get JSON with a signed link to the result (valid 5 minutes) instead of the bytes; anything else, or nothing, answers the bytes. The one part of this endpoint that writes storage: a temporary object, gone within a day. |

### Request body — `image/*`, `multipart/form-data` or `application/json` (`SyncReferenceInput`)

The image as raw bytes with their own Content-Type, a multipart form with a `file` part, or a JSON reference (`assetId` or `url`) instead of bytes

### Returns `200` — `application/json` or `image/*`

The transformed image, `Content-Type` the produced format, with `X-ImageStep-Width` / `X-ImageStep-Height` and `X-ImageStep-Duration-Ms`. With `?response=url`, JSON instead: `data` is `{url, contentType, bytes, expiresInSeconds, width, height}` — a signed link to the result, valid for 5 minutes.

### Errors

| Status | Code and when |
| --- | --- |
| 400 | - `invalid_param` on `op` / `preset` — neither or both given; an unknown op; an op that has no synchronous form (an AI op, `overlay`) or is answered by another endpoint (`read_metadata`)<br>- `invalid_param` on `preset` — a preset with an AI step, several segments, or a step that reads a stored layer<br>- `invalid_param` naming the key — a parameter the op does not declare or cannot take, or any parameter beside `preset`<br>- `invalid_param` on `file` — a multipart body with no `file` part<br>- `invalid_param` on `url` — not `http(s)`, does not resolve, resolves to an address this service will not fetch, redirects, answers an HTTP error other than 5xx / 429 / 408, or returns nothing<br>- `invalid_state` on `assetId` — the asset has no stored object yet<br>- `unsupported_format` — not an image, a format this service does not read, or an input over 50 megapixels |
| 404 | - `asset_not_found` — the JSON form's `assetId` is not one of your assets<br>- `preset_not_found` — no such preset or version, or it is not yours |
| 413 | `payload_too_large` on `file` — the image is over 25 MB; `details.limit` is the ceiling in bytes |
| 415 | `unsupported_media_type` — a form-encoded body; `details.supported` lists what is read |
| 503 | `provider_unavailable`, retryable — `details.reason` says which:<br>- `capacity` — this node, or the worker behind it, is full (`Retry-After`)<br>- `deadline_exceeded` — the work took longer than the deadline (`Retry-After`)<br>- `worker_unreachable` — no processing worker answered (`Retry-After`)<br>- `storage_read` — the JSON form's stored asset could not be read (`Retry-After`)<br>- `url_unavailable` — the `url`'s host answered 5xx, 429 or 408, or not in time (`Retry-After`) |

## Objects

Each described once; a linked type is another object on this page.

### RenderRequest

One template and one row of data

| Field | Type | Description |
| --- | --- | --- |
| data | object | The row: variable name → value. `{{ name }}` is filled HTML-escaped, `{{{ name }}}` raw, `a.b` reads nested data; a name the row does not carry renders as nothing. |
| templateId | string | Template id, your own slug, or either with `@version` to pin one version (required)<br>e.g. "builtin-template-og-image" |

### SyncReferenceInput

The JSON form of a synchronous call: a reference to the image instead of its bytes. Give `assetId` or `url`. On `/images/transform` the op, preset and parameters may ride here too; a query parameter of the same name wins.

| Field | Type | Description |
| --- | --- | --- |
| assetId | string | One of your assets, read by this service (its browser-readable rendition when it has one) |
| op | string | Transform only: the op, as `?op=` |
| parameters | object | Transform with `op` only: the op's parameters. A key also in the query string takes the query's value. |
| preset | string | Transform only: the preset, as `?preset=` |
| url | string | An image on the internet, fetched by this service: `http(s)` only, never a private or loopback address, redirects not followed. Its format is the type it was served as, else the one its extension names |
