---
title: SDKs
url: https://base_url.placeholder/docs/sdk
group: Surfaces
---

# SDKs

`imagestep` on npm and `imagestep` on PyPI are the [REST API](https://base_url.placeholder/docs/api) with the plumbing written once: the response envelope unwrapped, an idempotency key on every write, retryable failures sent again, waiting and paging done for you. Both have the same resources with the same methods — camelCase in JavaScript, snake_case in Python. The top of this page is how a client behaves; below it, every method of every resource. The same API is also a [CLI](https://base_url.placeholder/docs/cli) for the terminal and an [MCP server](https://base_url.placeholder/docs/mcp) for agents.

## Install and make a first call

JavaScript runs on Node 20 or newer, Deno, Bun and edge runtimes, ships ESM and CommonJS with its types, and has no runtime dependencies: it uses the runtime's own `fetch` and the global `crypto`. Python needs 3.10 or newer and one runtime dependency, `httpx`.

```sh
pnpm add imagestep     # JavaScript — or: npm install imagestep
pip install imagestep  # Python
```

Upload a photo, cut out its background as a job, and publish the result — four calls, each of which waits for what the next one needs:

#### JavaScript

```js
import { ImageStep } from "imagestep";

const client = new ImageStep({ apiKey: process.env.IMAGESTEP_API_KEY });

const asset = await client.assets.upload("./product.jpg");           // stage, PUT, finish, wait for ingest
const job = await client.ops.removeBg(asset.id, { wait: true });      // a job; wait holds on until it settles
const [cutout] = await client.jobs.outputs(job);                      // what it made: new assets, in item order
const [published] = await client.assets.publish(cutout.id);
console.log(published.publicUrl);
```

#### Python

```python
from imagestep import ImageStep

client = ImageStep()  # api_key from IMAGESTEP_API_KEY

asset = client.assets.upload("./product.jpg")
job = client.ops.remove_bg(asset["id"], wait=True)
[cutout] = client.jobs.outputs(job)
[published] = client.assets.publish(cutout["id"])
print(published["publicUrl"])
```

## The client

| JavaScript | Python | Default and meaning |
| --- | --- | --- |
| `apiKey` | `api_key` | A key from [/keys](https://base_url.placeholder/keys). JavaScript reads no environment variable, and without a key sends no `Authorization`: the public reads (`ops.list`, `ops.get`, `agent.guidelines`) work and every other call is a `401`. Python falls back to `IMAGESTEP_API_KEY` and does not construct without a key (`TypeError`). |
| `baseUrl` | `base_url` | default `https://api.imagestep.dev`; Python falls back to `IMAGESTEP_BASE_URL` before the default. |
| `timeoutMs` | `timeout` | default 60 s, per attempt — milliseconds in JavaScript, seconds in Python. |
| `maxRetries` | `max_retries` | default 2 — how many times a retryable failure is sent again (below). |
| `userAgent` | `user_agent` | replaces `imagestep-js/<version>` · `imagestep-python/<version>`. |
| `fetch` | `transport · http_client` | Your own `fetch`; in Python an httpx transport, or a whole `httpx.Client`. For proxies and tests. |

Methods return what the API returns, unwrapped from its envelope: plain objects in JavaScript, typed by the package's `index.d.ts`; plain dicts in Python, typed as TypedDicts generated from the OpenAPI document, with the keys as they are on the wire (`asset["publicUrl"]`). Python's keyword arguments are snake_case and go out under their wire names; a dict you pass as a body (a preset, a template, a webhook patch) is sent as written, in the wire's camelCase. In JavaScript the methods whose signature lists a `signal` take an `AbortSignal`; Python has no equivalent.

Everything a program imports: `ImageStep` (also the default export), `ImageStepError`, `JobFailedError`, `verifyWebhookSignature` and `constructWebhookEvent` from `imagestep` on npm; `ImageStep`, `AsyncImageStep`, `ImageStepError`, `JobFailedError`, `WebhookSignatureError`, `verify_webhook_signature`, `construct_webhook_event`, `Page`, `RequestResult` and `BinaryResult` from `imagestep` on PyPI.

### Python: a sync client and an async one

`ImageStep` and `AsyncImageStep` take the same arguments and have the same resources with the same method names. On the async client every resource method is awaited, `images` included, and the `iterate…` methods are async iterators (`async for`). Close them with `close()` or `with`, and `aclose()` or `async with`. The differences inside a resource: the async `jobs.wait` also accepts a coroutine as `on_progress`, `webhooks.verify` and `webhooks.construct_event` are plain functions on both clients — no network, nothing to await — and the async `assets.upload` reads a local file synchronously before it sends it.

```python
from imagestep import AsyncImageStep

async with AsyncImageStep() as client:
    job = await client.ops.remove_bg(asset_ids, wait=True)
    outputs = await client.jobs.outputs(job)
```

## Retries, timeouts and idempotency

Every call sends the key as `Authorization: ApiKey …` and unwraps the envelope, so a method returns the `data`. What happens when a call fails:

|   | What the SDK does |
| --- | --- |
| Retries | A failure whose `retryable` is true, and a network failure (a timed-out attempt is one), is sent again up to `maxRetries` / `max_retries` times. A failure that is not retryable is raised on the first attempt, with the service's `details` on the error — for `insufficient_credit`, what was needed and `topUpUrl`, the page that clears it. |
| Backoff | The `Retry-After` header, in seconds, when the response carries one; otherwise 0.5 s, 1 s, 2 s … after an API error and 0.25 s, 0.5 s, 1 s … after a network failure. |
| Timeouts | `timeoutMs` / `timeout` bounds each attempt, not the whole call; a request that asks the service to hold it open for a job gets that window plus 15 s. In JavaScript, aborting the `signal` stops the request and any backoff wait, and is never retried. |
| Idempotency-Key | On every write: a fresh UUID per call, or the `idempotencyKey` / `idempotency_key` you pass. The SDK's own retries resend the same key, so a retried submission cannot create a second job. Pass your own when _your_ code retries: the same key with the same body replays the first response instead of running again. The methods that take one are `ops.run` and every per-op helper, `ops.estimate`, `presets.run`, `presets.create`, `jobs.submit`, `jobs.estimate`, `assets.renameCollection`, `agent.feedback` and the raw requests. |
| The exception: `images` | The synchronous endpoints get no key, because they create nothing and there is nothing to replay. They still retry a retryable failure, since you are still holding the input — except a JavaScript body that can be read only once (a stream), which gets a single attempt. |

The contract behind these — the envelope, the error codes, what a replay answers, rate limits — is [Errors, idempotency and limits](https://base_url.placeholder/docs/errors).

## Waiting and paging

Two things the SDKs do in a loop so that your code does not: hold on until work is finished, and walk a listing to its end.

|   | What the SDK does |
| --- | --- |
| `wait` on a run | `ops.run`, every per-op helper, `presets.run` and, in JavaScript, `jobs.submit` take `wait`: `true`, or the options of `jobs.wait`. The submit itself asks the service to hold the answer (up to 60 s), so a one-item job that settles in that window comes back finished in one round trip; whatever is left is `jobs.wait`. |
| `jobs.wait` | Each read is `GET /api/v1/jobs/{id}?wait=`, which the service answers the moment the job settles (or after 60 s), so a five-second job costs one request. The interval is only a floor between reads. It gives up after 10 minutes unless you pass a timeout, raising `JobFailedError` with the job as it last stood — nothing is cancelled. A job that ends `FAILED` or `CANCELLED` raises too, unless `throwOnFailure` / `throw_on_failure` is false. A read turned away for now — `429 rate_limited` when the account already holds its share of open waits, or a 503 — is asked again after its `Retry-After` until the time runs out. For anything long-running, a [webhook](https://base_url.placeholder/docs/webhooks) beats waiting. |
| Uploads | `assets.upload`, `uploadMany` and `fromUrl` wait for ingest by default: one status call per tick for every asset still `PROCESSING`, every 1.5 s for up to 120 s, then `JobFailedError`. With `wait: false` they return at once, and `assets.waitReady` waits later with your own interval and timeout. |
| Listings | A list method (`list`, `collections`, `items`, `deliveries`, `reports`) reads one page — at most 100 rows — and returns it with its `meta`; `presets.list` and `webhooks.list` answer everything at once. Its `iterate…` twin yields every row, one at a time, reading each page after the first by the `meta.nextCursor` the last one carried, and throws rather than stop halfway when a page says `hasMore` without a cursor. Pass `cursor` to resume a walk. The paging rules themselves are [Pagination](https://base_url.placeholder/docs/errors#pagination). |

## Errors

Every failure the API answers is raised as `ImageStepError`, after the retries the SDK is allowed have run out. Branch on `retryable`, never on the status: the status cannot tell a rate limit from a quota. The closed set of codes and what each one means are [Errors, idempotency and limits](https://base_url.placeholder/docs/errors).

| JavaScript | Python | Meaning |
| --- | --- | --- |
| `status` | `status` | the HTTP status; 0 for an error the SDK raises itself (an op with no synchronous form, a parameter named op, preset or response) |
| `code` | `code` | from the closed set. When the response carried no error envelope (a proxy's 502), `null` in JavaScript; Python fills in `internal_error` for a 5xx and `unknown_error` otherwise |
| `message` | `message` | for a person to read, not to parse |
| `retryable` | `retryable` | whether sending the same request again can succeed — the field to branch on. Without an error envelope to say so, a 429 or a 5xx is retryable and anything else is not |
| `param` | `param` | the parameter to fix, when the error is about one; else null |
| `details` | `details` | machine-readable context, when there is some; else null |
| `retryAfter` | `retry_after` | seconds the service asked you to wait (the `Retry-After` header); else null |
| `requestUrl` | `request_url` | the URL that failed |
| `requestId` | `request_id` | the service's id for the request (`X-Request-Id`) — quote it when you report a failure; null before a response |

| JavaScript | Python | Raised when |
| --- | --- | --- |
| `JobFailedError` | `JobFailedError` | A waited-for job ended other than `COMPLETED`, or a wait ran out of time (`jobs.wait`, a `wait` option, an upload's wait, `assets.waitReady`). `job` is the last state polled; `retryable` is always false. |
| `Error` | `WebhookSignatureError` | A webhook signature did not verify in `constructWebhookEvent` / `construct_webhook_event`. The Python class is a `ValueError`. |
| the runtime's fetch error | `httpx.TransportError` | The network failed on every attempt, or a timeout outlived the retries. |

#### JavaScript

```js
import { ImageStepError, JobFailedError } from "imagestep";

try {
  await client.ops.upscale(ids, { wait: true });
} catch (err) {
  // retryable: the SDK already retried, so come back later — after what the service asked for
  if (err instanceof ImageStepError && err.retryable) queueForLater(ids, err.retryAfter);
  else if (err instanceof JobFailedError) console.error(err.job.status, err.job.id);
  else throw err; // not retryable: fix what err.param names
}
```

#### Python

```python
from imagestep import ImageStepError, JobFailedError

try:
    client.ops.upscale(ids, wait=True)
except JobFailedError as err:
    print(err.job["status"], err.job["id"])
except ImageStepError as err:
    if not err.retryable:
        raise  # fix what err.param names
    # retryable: the SDK already retried, so come back later — after what the service asked for
    queue_for_later(ids, err.retry_after)
```

## `ops` — the catalogue, and running an op as a job

An op is one atomic capability, and `ops.list()` is the catalogue: read it instead of hard-coding names, since a new op appears there without an SDK release. Running an op submits a job, which is what owns progress, cancellation, settlement and an asset id for the result. What each op does and takes is [Ops](https://base_url.placeholder/docs/ops).

| JavaScript | Python | Returns |
| --- | --- | --- |
| `client.ops.list()` | `client.ops.list()` | every op definition, with its parameter contract and `syncEndpoint` |
| `client.ops.get(op)` | `client.ops.get(op)` | one op definition |
| `client.ops.run(op, { assetIds, prompt, count, model, parameters, variants, templateId, items, collection, retentionDays, dryRun, imageCount, wait, idempotencyKey, signal })` | `client.ops.run(op, asset_ids=, prompt=, count=, model=, parameters=, variants=, template_id=, items=, collection=, retention_days=, dry_run=False, image_count=, wait=False, idempotency_key=)` | The created job. With `wait` (true, or an object of `jobs.wait` options) the finished job, or a `JobFailedError` (waiting); with `dryRun` the estimate. `assetIds` is one id or many; `collection` is where the outputs go, each of which is a new asset — a job never overwrites its input. |
| `client.ops.estimate(op, { assetIds, prompt, count, model, parameters, variants, templateId, items, collection, retentionDays, dryRun, imageCount, wait, idempotencyKey, signal })` | `client.ops.estimate(op, **opts)` | The cost dry run — `estimatedCredits`, `creditBalance`, `sufficientCredit` — with nothing created |

### One helper per op

Each helper is `ops.run` with the op name filled in, and returns what `run` returns; the options are the same. A deterministic op's helper takes the op's own parameters — the keys shown — before them. Every name below is on `client`.

| JavaScript | Python | Takes and returns |
| --- | --- | --- |
| `ops.removeBg · ops.upscale · ops.restoreFace · ops.colorize · ops.analyze · ops.grayscale · ops.flip · ops.flop` `(assetIds, opts)` | `ops.remove_bg · ops.upscale · ops.restore_face · ops.colorize · ops.analyze · ops.grayscale · ops.flip · ops.flop` `(asset_ids, **opts)` | what `run` returns. `analyze` answers with structured JSON per image, as each job item's `output`; the default answer's tags and objects also join the asset's `tags`. |
| `ops.generate` `(prompt, opts)` | `ops.generate` `(prompt, **opts)` | what `run` returns |
| `ops.edit` `(assetIds, prompt, opts)` | `ops.edit` `(asset_ids, prompt, **opts)` | what `run` returns |
| `ops.resize` `(assetIds, { width, height, fit, gravity, background, withoutEnlargement, matchOrientation }, opts)` | `ops.resize` `(asset_ids, parameters, **opts)` | what `run` returns |
| `ops.convert` `(assetIds, { format, quality }, opts)` | `ops.convert` `(asset_ids, parameters, **opts)` | what `run` returns |
| `ops.compress` `(assetIds, { quality, format }, opts)` | `ops.compress` `(asset_ids, parameters, **opts)` | what `run` returns |
| `ops.crop` `(assetIds, { left, top, width, height }, opts)` | `ops.crop` `(asset_ids, parameters, **opts)` | what `run` returns |
| `ops.pad` `(assetIds, { top, bottom, left, right, background }, opts)` | `ops.pad` `(asset_ids, parameters, **opts)` | what `run` returns |
| `ops.rotate` `(assetIds, { angle, background }, opts)` | `ops.rotate` `(asset_ids, parameters, **opts)` | what `run` returns |
| `ops.trim` `(assetIds, { threshold, background }, opts)` | `ops.trim` `(asset_ids, parameters=, **opts)` | what `run` returns |
| `ops.flatten` `(assetIds, { background }, opts)` | `ops.flatten` `(asset_ids, parameters=, **opts)` | what `run` returns |
| `ops.adjust` `(assetIds, { brightness, saturation, hue, lightness, contrast }, opts)` | `ops.adjust` `(asset_ids, parameters, **opts)` | what `run` returns |
| `ops.mask` `(assetIds, { shape, radius }, opts)` | `ops.mask` `(asset_ids, parameters=, **opts)` | what `run` returns |
| `ops.blurRegion` `(assetIds, { width, height, left, top, sigma, pixelate }, opts)` | `ops.blur_region` `(asset_ids, parameters, **opts)` | what `run` returns |
| `ops.overlay` `(assetIds, { layerAssetId, gravity, scale, opacity, margin, tile }, opts)` | `ops.overlay` `(asset_ids, parameters, **opts)` | what `run` returns |
| `ops.caption` `(assetIds, { text, font, size, color, background, gravity, margin }, opts)` | `ops.caption` `(asset_ids, parameters, **opts)` | what `run` returns |
| `ops.readMetadata` `(assetId)` | `ops.read_metadata` `(asset_id)` | not a job: `{ id, name, image, metadata, expiresAt }` read from the asset |

#### JavaScript

```js
const { items } = await client.assets.list({ collection: "shoot-01" });
const ids = items.map((a) => a.id);
const estimate = await client.ops.estimate("convert", { assetIds: ids, parameters: { format: "webp", quality: 80 } });
if (estimate.sufficientCredit) {
  const job = await client.ops.convert(ids, { format: "webp", quality: 80 }, { wait: { onProgress: (j) => console.log(j.status) } });
}
```

#### Python

```python
page = client.assets.list(collection="shoot-01")
ids = [a["id"] for a in page.items]
estimate = client.ops.estimate("convert", asset_ids=ids, parameters={"format": "webp", "quality": 80})
if estimate["sufficientCredit"]:
    job = client.ops.convert(ids, {"format": "webp", "quality": 80}, wait={"on_progress": print})
```

## `images` — bytes in, bytes out, nothing stored

The synchronous face: for when you are holding an image and only want the result back. Deterministic ops and deterministic presets only — AI ops, batches and anything you want an asset id for are jobs. Which ops qualify is read from `ops.list()` once per client, never from a list in the SDK. The endpoints and their limits are [Synchronous ops](https://base_url.placeholder/docs/sync).

| JavaScript | Python | Returns |
| --- | --- | --- |
| `client.images.syncEndpoints()` | `client.images.sync_endpoints()` | `{ op: syncEndpoint or null }` for every op, fetched once and cached on the client |
| `client.images.supports(op)` | `client.images.supports(op)` | whether the op has a synchronous form |
| `client.images.transform(op, { file, url, assetId, preset, response, signal, parameters })` | `client.images.transform(op=, file=, url=, asset_id=, preset=, response=, parameters=)` | The result bytes (`Uint8Array` / `bytes`); with `response: "url"` a JSON object with a signed URL instead. Pass `null` / `None` as the op to run a `preset`. An op with no synchronous form fails with `invalid_param` (`status` 0) before the image is sent — the SDK reads that from the catalogue. The input is one of `file` — a path or bytes, and in JavaScript a `Blob` or a stream, in Python a binary file object — or `url`, or `assetId` / `asset_id`, which send a reference instead of bytes. The op's own parameters go in `parameters`, never beside `file`: a parameter can name nothing but a parameter, so a bag of them that came from somewhere else cannot point this process at a local file. |
| `client.images.transformResult(op, { file, url, assetId, preset, response, signal, parameters })` | `client.images.transform_result(op=, file=, url=, asset_id=, preset=, response=, parameters=)` | the same, plus what the service measured: `{ bytes, json, contentType, width, height }` / `BinaryResult(content, json, content_type, width, height)`; a size that was not measured is null |
| `client.images.render(templateId, data, { signal })` | `client.images.render(template_id, data=)` | one PNG from one row of data; many rows are a `render_template` job |
| `client.images.metadata(file, { signal })` | `client.images.metadata(file)` | EXIF, GPS, dimensions, format and SHA-1 as JSON — free, and it stores nothing |

#### JavaScript

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

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

const { bytes, contentType, width } = await client.images.transformResult(null, { file: "./photo.jpg", preset: "builtin-util-web-optimize" });
```

#### Python

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

result = client.images.transform_result(None, file="./photo.jpg", preset="builtin-util-web-optimize")
print(result.content_type, result.width, result.height)
```

## `assets` — upload, find, publish

An asset is an image stored in the account with a stable id. Upload bytes or hand the service URLs to fetch, put them in a collection, and publish the ones that need a permanent URL. The asset document and its filters are [Assets](https://base_url.placeholder/docs/assets).

| JavaScript | Python | Returns |
| --- | --- | --- |
| `client.assets.upload(input, { name, mimeType, collection, tags, retentionDays, wait, reuseExisting, signal })` | `client.assets.upload(source, name=, mime_type=, collection=, tags=, retention_days=, wait=True, reuse_existing=True, timeout=)` | The asset once ingest has written its dimensions and metadata — at most 120 s, then `JobFailedError` (waiting); with `wait: false` the new asset straight away, still `PROCESSING`. Bytes ingested before come back as that asset unless `reuseExisting` is false. The input is a path (Node), a `Blob` or bytes; in Python a path, bytes or a binary file object. Python's `timeout` bounds the PUT to storage, not the wait. |
| `client.assets.fromUrl(urls, { collection, tags, retentionDays, wait, signal })` | `client.assets.from_url(urls, collection=, tags=, retention_days=, wait=True)` | `{ url, asset }` or `{ url, error }` per URL, in order, so one bad link costs only itself. The service fetches them; the SDK sends any number, 20 to a request. `wait` waits for all of them, one status call per tick. |
| `client.assets.uploadMany(inputs, { concurrency, collection, tags, retentionDays, wait, reuseExisting, signal })` | `client.assets.upload_many(sources, concurrency=4, collection=, tags=, retention_days=, wait=True, reuse_existing=True, timeout=)` | `{ name, asset }` or `{ name, error }` per input, in order: one stage and one finish call per batch, `concurrency` uploads at a time (default 4) and one status call per tick while they ingest — for many files, not `upload` in a loop. Same inputs as `upload`. |
| `client.assets.waitReady(id, { intervalMs = 1500, timeoutMs = 120_000, signal })` | `client.assets.wait_ready(asset_id, interval=1.5, timeout=120.0)` | the asset once it leaves `PROCESSING`, polled at the interval above; `JobFailedError` when the time runs out |
| `client.assets.status(ids, { signal })` | `client.assets.status(ids)` | `[{ id, status, width?, height? }]` for up to 100 ids, in request order; ids that are not yours are absent |
| `client.assets.download(id, { variant = "readable", signal })` | `client.assets.download(asset_id, variant="readable")` | `{ bytes, contentType }` / `bytes`. `variant` is `readable` (full size, in a type a browser shows), `original` or `preview` (a 400 px wide WebP). Follows the signed redirect without sending the API key; both requests are timed and retried like any other. In JavaScript it needs a runtime whose fetch exposes a manual redirect (Node does, a browser does not). |
| `client.assets.get(id, { signal })` | `client.assets.get(asset_id)` | the asset |
| `client.assets.list({ page, perPage, cursor, collection, tag, mime, q, view, minWidth, maxWidth, minHeight, maxHeight, takenFrom, takenTo, createdFrom, createdTo, source, status, includeIntermediate, hasCollection, jobId, op })` | `client.assets.list(**params)` | `{ items, meta }` / `Page`, filtered by collection, tag, type, origin, size, capture date, free text and publish state |
| `client.assets.iterate({ page, perPage, cursor, collection, tag, mime, q, view, minWidth, maxWidth, minHeight, maxHeight, takenFrom, takenTo, createdFrom, createdTo, source, status, includeIntermediate, hasCollection, jobId, op })` | `client.assets.iterate(**params)` | every matching asset, a row at a time, paging as it goes — `for await (const a of …)` in JavaScript, `for a in …` (or `async for`) in Python. Same filters as `list`; each page after the first is the `meta.nextCursor` the last one carried, and it stops when the answer says there is no more. |
| `client.assets.collections({ q, page, perPage, cursor })` | `client.assets.collections(**params)` | `{ items, meta }` / `Page` of your collections — `collection`, `count`, `lastCreatedAt` — most recently added to first; `q` narrows to names containing it |
| `client.assets.iterateCollections({ q, page, perPage, cursor })` | `client.assets.iterate_collections(**params)` | every collection, one at a time, paging as it goes |
| `client.assets.renameCollection(from, to, { idempotencyKey, signal })` | `client.assets.rename_collection(from_, to, idempotency_key=)` | `{ from, to, updated }` — every asset in `from` moved to `to`; `null` / `None` takes them out of any collection. Python writes `from_` because `from` is a keyword. |
| `client.assets.publish(ids, published = true)` | `client.assets.publish(ids, published=True)` | the updated assets, each with a stable `publicUrl` on the CDN — the image at full size, not a thumbnail |
| `client.assets.unpublish(ids)` | `client.assets.unpublish(ids)` | the updated assets |
| `client.assets.setCollection(ids, collection)` | `client.assets.set_collection(ids, collection)` | the updated assets, each now in that collection |
| `client.assets.tag(ids, tags)` | `client.assets.tag(ids, tags)` | the updated assets, their tags replaced by `tags` (`[]` clears them); `list` with `tag` matches one exactly |
| `client.assets.delete(ids)` | `client.assets.delete(ids)` | `{ deleted: 1 }` for one id, the batch result for several. Permanent. |

## `jobs` — follow, collect, cancel

A job is the handle for work the service promises to finish. `ops.run` and `presets.run` create them; this is where you follow one. The job document and its states are [Jobs](https://base_url.placeholder/docs/jobs).

| JavaScript | Python | Returns |
| --- | --- | --- |
| `client.jobs.submit(request, { body, headers, idempotencyKey, signal, retries, timeoutMs, wait })` | `client.jobs.submit(request, **opts)` | the created job, from a raw job request — `ops.run` is the usual entry point. In JavaScript `wait` holds on for the finished job the way `ops.run`'s does |
| `client.jobs.estimate(request, { body, headers, idempotencyKey, signal, retries, timeoutMs })` | `client.jobs.estimate(request, **opts)` | the dry-run estimate of a raw job request |
| `client.jobs.get(id, { signal, wait })` | `client.jobs.get(job_id, wait=)` | the job, with its first 100 items; `wait` (seconds, at most 60) holds the call until the job settles or the window closes, and answers with the job as it stands either way |
| `client.jobs.list({ page, perPage, cursor, status, type, op, preset, rootJobId, createdFrom, createdTo })` | `client.jobs.list(**params)` | `{ items, meta }` / `Page` of job summaries — the counts, without items |
| `client.jobs.iterate({ page, perPage, cursor, status, type, op, preset, rootJobId, createdFrom, createdTo })` | `client.jobs.iterate(**params)` | every matching job, a row at a time, paging as it goes — the same filters as `list` |
| `client.jobs.items(id, { page, perPage, cursor, status })` | `client.jobs.items(job_id, **params)` | `{ items, meta }` / `Page` of the job's items past the first 100 — each with the `index` the API names it by; `status` narrows to one state |
| `client.jobs.iterateItems(id, { page, perPage, cursor, status })` | `client.jobs.iterate_items(job_id, **params)` | every item of a job, one at a time, paging as it goes |
| `client.jobs.cancel(id)` | `client.jobs.cancel(job_id)` | the job |
| `client.jobs.resume(id)` | `client.jobs.resume(job_id)` | the new attempt: a job of its own, linked by rootJobId and attemptNumber |
| `client.jobs.wait(id, { intervalMs = 1000, timeoutMs = 10 * 60_000, onProgress, signal, throwOnFailure = true })` | `client.jobs.wait(job_id, interval=1.0, timeout=600.0, on_progress=, throw_on_failure=True)` | The job once it is `COMPLETED`, `FAILED` or `CANCELLED`. The service holds each read open until the job settles, so the interval is a floor between reads, not a polling rate. Raises `JobFailedError` when it ends other than completed (unless `throwOnFailure` is false) or the time runs out — waiting. |
| `client.jobs.outputs(job)` | `client.jobs.outputs(job)` | the run's products as list rows, in item order — one paged listing, not one read per item. JavaScript also takes the job's id; Python takes the job |

One read has no method: the totals by status and type, `GET /api/v1/jobs/counts` — the numbers beside the console's job filters, a table for a person to look at more than a step a program takes. Send it as a raw request, `client.get("/api/v1/jobs/counts")` in either SDK. A program that watches one status already has its total: `meta.total` of `jobs.list` with that `status`.

## `presets` — saved steps, run by slug

A preset is a versioned list of steps saved once and run over assets as one job — including a chain with a model in it, where the image each step makes for the next is handed on and cleaned up for you, and the dry run prices every step before the first one runs. The same steps run once without a preset as `jobs.submit({steps, assetIds})`. Built-ins are read-only; start from one by reading it and creating your own. The step format is [Presets](https://base_url.placeholder/docs/presets).

| JavaScript | Python | Returns |
| --- | --- | --- |
| `client.presets.list(filter, { includeVersions })` | `client.presets.list(filter=, include_versions=False)` | the presets; `filter` is `builtin` or `user` |
| `client.presets.get(slug)` | `client.presets.get(slug)` | one preset — `steps`, `subjects`, `version`, `versions` |
| `client.presets.create(preset, { idempotencyKey, signal })` | `client.presets.create(preset, idempotency_key=)` | the saved preset, version 1. An `idempotencyKey` / `idempotency_key` makes a retried call return the preset the first one saved, instead of a second preset. |
| `client.presets.update(slug, preset)` | `client.presets.update(slug, preset)` | the preset as saved — send only what changes; what you leave out is carried over, and new steps are a new version |
| `client.presets.delete(slug)` | `client.presets.delete(slug)` | nothing |
| `client.presets.deleteVersion(slug, version)` | `client.presets.delete_version(slug, version)` | nothing. `slug@version` answers `404 preset_not_found` from then on and the number is never reissued, so this is for a version nothing pins — and it is what makes room when a preset is at its version ceiling and `update` is refused with `422 resource_limit_exceeded`. The current version cannot be deleted. |
| `client.presets.import(presets)` | `client.presets.import_(presets)` | the import result, entry by entry. Python spells it `import_` because `import` is a keyword. |
| `client.presets.run(presetId, assetIds, { wait, collection, retentionDays, prompt, count, dryRun, imageCount, signal, idempotencyKey })` | `client.presets.run(preset_id, asset_ids, collection=, retention_days=, prompt=, count=, dry_run=False, image_count=, wait=False, idempotency_key=)` | The job, the finished job with `wait`, or the estimate with `dryRun`. `presetId` is a slug, an id, or `slug@version` to pin one version; the preset decides the job type. |

## `templates` — HTML and CSS to PNG

A render template is HTML and CSS with `{{ var }}` placeholders; `images.render` and the `render_template` op turn rows of data into PNGs. Templates are versioned: an update saves a new version and the previous one stays readable and renderable as `id@version`. What a template may contain is [Templates](https://base_url.placeholder/docs/templates).

| JavaScript | Python | Returns |
| --- | --- | --- |
| `client.templates.list(filter, { page, perPage, cursor })` | `client.templates.list(filter=, **params)` | one page, built-ins first, then yours; `filter` is `builtin` or `user`. Rows carry no html or css — `get` returns the whole document |
| `client.templates.iterate(filter)` | `client.templates.iterate(filter=)` | every template, a row at a time, paging as it goes |
| `client.templates.get(id)` | `client.templates.get(template_id)` | one template — by id, by your slug, or `id@version` for one frozen version |
| `client.templates.versions(id)` | `client.templates.versions(template_id)` | every version ever saved, newest first |
| `client.templates.create(template)` | `client.templates.create(template)` | version 1, from `{ name, html, css?, width, height, variables? }` |
| `client.templates.update(id, template)` | `client.templates.update(template_id, template)` | the new version, from only what changes; the previous one is kept |
| `client.templates.delete(id)` | `client.templates.delete(template_id)` | nothing — the template and every version are gone |
| `client.templates.import(templates)` | `client.templates.import_(templates)` | each entry created as version 1 from what `list("user")` returns; an entry that fails is reported, not raised |

## `webhooks` — endpoints and signatures

Register an `https://` endpoint and the service POSTs signed events to it when a job finishes. The events, the retry schedule and auto-disable are [Webhooks](https://base_url.placeholder/docs/webhooks).

| JavaScript | Python | Returns |
| --- | --- | --- |
| `client.webhooks.list()` | `client.webhooks.list()` | your endpoints, with the secret masked |
| `client.webhooks.get(id)` | `client.webhooks.get(endpoint_id)` | one endpoint |
| `client.webhooks.create({ url, events, description, enabled })` | `client.webhooks.create(url, events=, description=, enabled=)` | the endpoint with its `secret` — shown this once |
| `client.webhooks.update(id, patch)` | `client.webhooks.update(endpoint_id, patch)` | the endpoint |
| `client.webhooks.delete(id)` | `client.webhooks.delete(endpoint_id)` | nothing |
| `client.webhooks.rotateSecret(id)` | `client.webhooks.rotate_secret(endpoint_id)` | the endpoint with a new `secret`; the old one stops verifying |
| `client.webhooks.test(id)` | `client.webhooks.test(endpoint_id)` | the delivery of a test event, sent now |
| `client.webhooks.deliveries(id, { page, perPage, cursor })` | `client.webhooks.deliveries(endpoint_id, **params)` | `{ items, meta }` / `Page` of delivery attempts |
| `client.webhooks.iterateDeliveries(id, { page, perPage, cursor })` | `client.webhooks.iterate_deliveries(endpoint_id, **params)` | every delivery to one endpoint, newest first, one at a time, paging as it goes |
| `client.webhooks.verify(rawBody, header, secret, { toleranceSeconds, now })` | `client.webhooks.verify(raw_body, header, secret, tolerance_seconds=300, now=)` | Whether the `ImageStep-Signature` header signs this body and its timestamp is within the tolerance (300 s unless you pass one). A promise in JavaScript (Web Crypto) — await it; a plain `bool` in Python. No network call. An empty or missing secret throws (`TypeError` / `ValueError`) rather than answering: a key nobody set is a signature anybody can make. |
| `client.webhooks.constructEvent(rawBody, header, secret, { toleranceSeconds, now })` | `client.webhooks.construct_event(raw_body, header, secret, tolerance_seconds=300, now=)` | Verify, then parse: `{ id, type, createdAt, data }`. Throws an `Error` in JavaScript and raises `WebhookSignatureError` in Python when the signature does not check out. |

The same two functions are exported at the top level, so a webhook handler needs no client: `verifyWebhookSignature` and `constructWebhookEvent` in JavaScript, `verify_webhook_signature` and `construct_webhook_event` in Python. Verify the body exactly as it arrived; parsed and re-serialised JSON has different bytes and never verifies.

#### JavaScript

```js
import { constructWebhookEvent } from "imagestep";

export async function POST(request) {
  const raw = await request.text();                                   // the raw body, not request.json()
  let event;
  try {
    event = await constructWebhookEvent(raw, request.headers.get("ImageStep-Signature"), secret);
  } catch {
    return new Response("bad signature", { status: 400 });
  }
  if (event.type === "job.completed") await collect(event.data);      // an error here is a 500, and a retry
  return new Response(null, { status: 204 });
}
```

#### Python

```python
from imagestep import WebhookSignatureError, construct_webhook_event

def handle(raw_body: bytes, signature: str | None, secret: str) -> int:
    try:
        event = construct_webhook_event(raw_body, signature, secret)
    except WebhookSignatureError:
        return 400
    if event["type"] == "job.completed":
        collect(event["data"])
    return 204
```

## `agent` — the operating contract, and reporting a gap

The rules an automation is expected to follow, and somewhere to say what ImageStep could not do instead of routing around it. More on [For agents](https://base_url.placeholder/docs/agents).

| JavaScript | Python | Returns |
| --- | --- | --- |
| `client.agent.guidelines()` | `client.agent.guidelines()` | `{ version, updated, markdown }` — how to discover ops, price a batch, decide on a retry; a public endpoint |
| `client.agent.feedback({ kind, message, op, context, idempotencyKey, signal })` | `client.agent.feedback(kind, message, op=, context=, idempotency_key=)` | the stored report; `kind` is `capability_gap`, `bug` or `other`. Free: no credit, no job. |
| `client.agent.reports({ page, perPage, cursor })` | `client.agent.reports(**params)` | what this account has reported, newest first: `{ items, meta }` in JavaScript, a `Page` in Python — the same shape every other list method answers |
| `client.agent.iterateReports({ page, perPage, cursor })` | `client.agent.iterate_reports(**params)` | every report this account has filed, one at a time, paging as it goes |

## `models` — the model catalogue

| JavaScript | Python | Returns |
| --- | --- | --- |
| `client.models.list(mode = "ai_image")` | `client.models.list(mode="ai_image")` | the models with their prices; `mode` is `ai_image` or `analyze` |

## `usage` — what a key has spent

| JavaScript | Python | Returns |
| --- | --- | --- |
| `client.usage.get({ from, to, groupBy })` | `client.usage.get(from_=, to=, group_by="op")` | Credits charged, jobs created and items settled over a window, grouped by `op` (the default), `key` or `day`. `from` and `to` are `YYYY-MM-DD` or ISO-8601 instants; the window defaults to the last 30 days. Python writes `from_` because `from` is a keyword. |

## Raw requests

For an endpoint you want to call directly, with the same headers, envelope handling, idempotency key and retries as every method above.

| JavaScript | Python | Returns |
| --- | --- | --- |
| `client.request(method, path, { body, headers = {}, idempotencyKey, signal, retries, timeoutMs = this.timeoutMs })` | `client.request(method, path, body=, headers=, idempotency_key=, retries=, timeout=)` | `{ data, meta, replayed, headers }` / `RequestResult` — `replayed` is true when the service answered with a stored response for that key. |
| `client.requestBinary(path, { body, contentType, accept = "*/*", signal, retries, timeoutMs = this.timeoutMs })` | `client.request_binary(path, content=, json_body=, content_type=, accept="*/*", timeout=, retries=)` | A bytes call, with no key: `{ bytes \| json, contentType, headers }` in JavaScript; the bytes or the parsed JSON in Python. |
| `client.get(path, { body, headers, idempotencyKey, signal, retries, timeoutMs })` | `client.get(path, **opts)` | request() with GET |
| `client.post(path, body, { body, headers, idempotencyKey, signal, retries, timeoutMs })` | `client.post(path, body=, **opts)` | request() with POST |
| `client.put(path, body, { body, headers, idempotencyKey, signal, retries, timeoutMs })` | `client.put(path, body=, **opts)` | request() with PUT |
| `client.del(path, { body, headers, idempotencyKey, signal, retries, timeoutMs })` | `client.delete(path, **opts)` | request() with DELETE |
| — | `client.request_binary_result(path, content=, json_body=, content_type=, accept="*/*", timeout=, retries=)` | `request_binary` as a `BinaryResult` — the bytes, the parsed JSON, the content type and the measured size |
| — | `client.put_object(url, data, content_type, timeout=)` | PUT bytes to a presigned storage URL, with none of the API's headers — what assets.upload does between its two calls |
