Presets
An op does one thing. A preset is a saved list of ops — resize, then sharpen, then convert — that runs as one job and is versioned, so a batch you run in March and a batch you run in September go through exactly the same steps. It is also where consistency lives: a preset can carry the reference images and the description of a product or a character, so every generated image in a batch starts from the same subject.
What a preset is
A document: a name, a slug to run it by (optional — one is made from the name), and the steps, in order. This one cuts a product out, squares it on white and hands back a WebP.
{
"name": "Product cut-out, 1200 WebP",
"slug": "product-cutout",
"description": "Cut the product out, square it on white, WebP.",
"steps": [
{
"op": "remove_bg"
},
{
"op": "resize",
"parameters": {
"width": 1200,
"height": 1200,
"fit": "contain",
"background": "#ffffff"
}
},
{
"operation": "sharpen",
"params": {
"sigma": 0.5
}
},
{
"op": "convert",
"parameters": {
"format": "webp",
"quality": 85
}
}
]
}Save it with POST /api/v1/presets:
JavaScript
import { readFile } from "node:fs/promises";
const preset = await client.presets.create(JSON.parse(await readFile("product-cutout.json", "utf8")));
console.log(preset.slug, preset.version); // "product-cutout" 1Python
import json
preset = client.presets.create(json.load(open("product-cutout.json")))
print(preset["slug"], preset["version"]) # "product-cutout" 1CLI
imagestep preset create -f product-cutout.json -o jsoncurl
curl -s https://api.imagestep.dev/api/v1/presets -H "Authorization: ApiKey $IMAGESTEP_API_KEY" -H "Content-Type: application/json" -d @product-cutout.jsonThere is no MCP tab because this preset has an engine step (sharpen): the save_preset tool takes ops from the catalogue and nothing else, so a preset like this one is saved by a person. One made of ops only is saved over MCP too — subjects below has one. A person can also build the chain in the playground, running each step until it does what they want, and save it from there — the same call; saved presets are listed at presets, with how often each has run.
The answer is the preset as stored — 201, version 1. An account keeps up to 1,000 presets of its own; one more is 422 resource_limit_exceeded, and built-ins do not count.
{
"id": "pre_4369d6530a66449e864fec8c2f5a2b57",
"name": "Product cut-out, 1200 WebP",
"slug": "product-cutout",
"description": "Cut the product out, square it on white, WebP.",
"steps": [
{
"op": "remove_bg"
},
{
"op": "resize",
"parameters": {
"width": 1200,
"height": 1200,
"fit": "contain",
"background": "#ffffff"
}
},
{
"operation": "sharpen",
"params": {
"sigma": 0.5
}
},
{
"op": "convert",
"parameters": {
"format": "webp",
"quality": 85
}
}
],
"subjects": [],
"version": 1,
"versionCount": 1,
"createdAt": "2026-09-17T15:41:46.969331054Z",
"updatedAt": "2026-09-17T15:41:46.969331054Z"
}| field | type | meaning |
|---|---|---|
| name · description | string | for people. Editing them is not a new version |
| slug | string | how you run it, beside the id. Optional: one is made from the name, with -2, -3… when that one is taken. Lowercase letters, digits and hyphens; one you send that is already taken is 400, and builtin- is reserved. Changing it is not a new version — but a reference written with the old slug stops resolving, so pin by id what you might rename |
| id | string | the id the service gave it — a reference takes it wherever it takes the slug |
| steps | PresetStep[] | the list, in order; each step is one of the two shapes below |
| subjects | Subject[] | reference images and descriptors for a consistent subject (below); empty for most presets |
| version · versions | integer · PresetVersion[] | the current version number and every earlier snapshot of steps and subjects |
| versionCount | integer | how many versions are on record — the current one plus every superseded one. A list row carries this and not the snapshots, so you can tell a preset with history from one without reading any |
| builtIn | boolean | true for the presets ImageStep ships; those are read-only |
| createdAt · updatedAt | string (date-time) | when it was first saved and last changed |
| warnings | StepWarning[] | on a write only: what these steps say that is legal and probably not meant (below). Advisory — the preset was saved; a dry run recomputes them for any preset |
| usage | PresetUsage | on a read only: how many of your jobs ran this preset and when the last one did. Counted over the jobs still on record — a job record expires with the assets it produced — so it is a window, not a lifetime total |
Writing steps
A step is one of two shapes:
| shape | what it names | checked against |
|---|---|---|
| {"op", "parameters"?, "model"?, "prompt"?} | an op from the catalogue — an AI op or a deterministic one; model and prompt belong on an AI step only | that op's parameter contract, exactly as a job would be: a value outside it is 400 invalid_param on steps[i].parameters.<name> |
| {"operation", "params"} | one of the processing engine's own registry keys, for what no op covers — a sharpen, a colour matrix, a progressive JPEG | the closed key set; params go to Sharp verbatim. The keys, the rules and the links are on the step registry page |
Prefer an op wherever one exists: an op has a stable, validated contract, a registry key follows Sharp's signature at the pinned version. Two ops cannot be steps: read_metadata (a read, not a transform) and render_template (its input is rows, not images). The registry, with the five rules the engine applies (decoder directives, finishers, orientation), is preset steps; ready-made examples for social sizes, product shots, print and looks are recipes.
Steps run in order, and any order runs unless it cannot run as written: an analyze that is not last, a generate inside a chain, two values for a pass-wide parameter. The save refuses those and every other mistake it can see with 400 invalid_param naming the step — the full list is at the end of the page — and answers with warnings for what is legal but probably not meant.
Running one
As a job — any preset, any batch, one item per asset. The reference is the slug, the id, or slug@version to pin one version (below); one that names nothing is 404 preset_not_found. Price it first with the dry run.
JavaScript
const job = await client.presets.run("product-cutout@1", ["ast_1a2b","ast_3c4d"], { wait: true });
const outputs = await client.jobs.outputs(job);Python
job = client.presets.run("product-cutout@1", ["ast_1a2b","ast_3c4d"], wait=True)
outputs = client.jobs.outputs(job)CLI
imagestep jobs submit --preset-id product-cutout@1 --asset-ids ast_1a2b,ast_3c4d --wait -o jsoncurl
# price it first with ?dryRun=true; "wait" holds the response until a one-item job is done (60 s at most)
curl -s https://api.imagestep.dev/api/v1/jobs -H "Authorization: ApiKey $IMAGESTEP_API_KEY" -H "Content-Type: application/json" \
-d '{"presetId":"product-cutout@1","assetIds":["ast_1a2b","ast_3c4d"],"wait":30}'MCP
# tool call
run_preset {"preset":"product-cutout@1","asset_ids":["ast_1a2b","ast_3c4d"]}The preset decides the job type. When its only step is one AI step, the request's own model, prompt and parameters override that step, so each row of a batch can carry its own scene while the steps stay fixed. Any other preset refuses them — 400 invalid_param naming the field, never silently dropped — because there is no one step for them to land on: a preset with no AI step has none, a chain has several. The value belongs on the step, and changing a step saves a new version. In MCP it is the run_preset tool; in n8n, Preset → Run.
Synchronously — one image you are holding, the bytes straight back, nothing stored — for a preset with no AI step: its deterministic steps compile to one pass however many there are, and a call counts one against the processing allowance. Two kinds are jobs only and answer 400 invalid_param on preset: one with an AI step (a model call needs an owner for its retry and refund), and one with a step that reads a stored layer, composite or overlay (that route holds no storage credential) — which is also the answer for any other step that names one of your assets as its second image, boolean or joinChannel. The same reference runs unchanged as a job; the synchronous endpoints have the rule in full. builtin-util-web-optimize runs this way:
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.webpVersions
A PUT carries only what changes: the body is merged over the current preset, so a rename is {"name": "…"} and nothing else, and the slug stays unless you send one. A PUT that changes steps or subjects — one reworded descriptor included — saves version + 1; a new name, description or slug does not. A version is never edited: a PUT to slug@N is 400.
Every place that takes a preset takes slug@version (or id@version) to pin one: the job body, the synchronous ?preset=, the SDKs, the CLI, MCP's run_preset, the n8n node. Without @ you get the version that is current at the moment of submit. A job records the version it ran as presetVersion, and so does each output's lineage; a later edit never changes a queued job, and a resume runs the original's version. Reading an earlier version — to see what a job from March actually ran — is the same spelling (?version=N reads it too):
JavaScript
const first = await client.presets.get("product-cutout@1"); // the steps version 1 ran, whatever it holds nowPython
first = client.presets.get("product-cutout@1") # the steps version 1 ran, whatever it holds nowCLI
imagestep preset get product-cutout@1 -o jsoncurl
curl -s https://api.imagestep.dev/api/v1/presets/product-cutout@1 -H "Authorization: ApiKey $IMAGESTEP_API_KEY"- A preset keeps 50 versions on record — the current one plus every superseded one. At the ceiling a
PUTthat would save another is422 resource_limit_exceeded; the oldest version is never rolled off, because aslug@Nsomething is holding must not stop resolving over a write it knows nothing about. Make room withDELETE /presets/{slug}/versions/{version}when you know what pins what — that reference then answers404 preset_not_foundfor good, and the number is never reissued, soslug@Ncan never come back meaning different steps. The current version cannot be deleted (400): save the next one first, or delete the preset. GET /presets/{slug}is the export shape, versions included;POST /presets/importtakes it back and replays the history — an entry that fails a check, or carries more than 50 versions, is reported inerrorswhile the rest land, and a slug already in use gets a numeric suffix.GET /presetsleavesversionsoff and answersversionCountinstead;?includeVersions=truebrings it back for every row, which is whatimagestep preset list -o jsonsends so its output stays an import document.
Built-ins
| slug | what it does |
|---|---|
| builtin-util-web-optimize | orient, fit inside 1920 without enlarging, sharpen, WebP at quality 85 — the general web delivery preset |
| builtin-util-thumbnail | 300×300, cover-cropped to the busiest region, sharpened, JPEG at quality 80 |
| builtin-util-to-webp | convert to WebP at quality 85, nothing else |
| builtin-consistent-character | a generate step shaped for a recurring character — copy it and add the subject its prompt names |
| builtin-consistent-product | the same, for a product |
Built-ins are version 1 and read-only: 403 on PUT, DELETE and deleting a version. GET /presets?filter=builtin lists them. To start from one, read it and save its steps as your own under a slug of your own — builtin- is reserved, so its body posted as it is answers 400. The two consistency built-ins carry a {{subject.<name>}} in their prompt and no subject, so they run only once copied with the subject added. An op's default model is not a preset — it is the catalogue entry's defaultModel.
Consistency: subjects
Drift has two halves, and a subject pins both. The reference images pin the geometry — no amount of text fixes the shape of a face or the silhouette of a bottle; they ride along on every generate or edit step of the preset, ahead of the item's own image. The descriptor pins the words — colour under other lighting, material, the text on a label — and {{subject.<name>}} in the prompt expands to it server-side, at submit and dry-run time, so the prompt on the dry run is the prompt the provider gets.
{
"name": "Casa launch",
"slug": "casa-launch",
"subjects": [
{
"name": "hero",
"referenceAssetIds": [
"ast_1a2b",
"ast_3c4d"
],
"descriptor": "a matte black insulated bottle, brushed steel lid, CASA in small serif on the front"
},
{
"name": "model",
"referenceAssetIds": [
"ast_5e6f"
],
"descriptor": "a woman in her thirties with short silver hair"
}
],
"steps": [
{
"op": "generate",
"model": "google/gemini-3.1-flash-image-preview",
"prompt": "{{subject.model}} holding {{subject.hero}} on a marble counter, morning light"
}
]
}JavaScript
import { readFile } from "node:fs/promises";
const preset = await client.presets.create(JSON.parse(await readFile("casa-launch.json", "utf8")));
console.log(preset.slug, preset.version); // "casa-launch" 1Python
import json
preset = client.presets.create(json.load(open("casa-launch.json")))
print(preset["slug"], preset["version"]) # "casa-launch" 1CLI
imagestep preset create -f casa-launch.json -o jsoncurl
curl -s https://api.imagestep.dev/api/v1/presets -H "Authorization: ApiKey $IMAGESTEP_API_KEY" -H "Content-Type: application/json" -d @casa-launch.jsonMCP
# tool call — an agent has no disk to read, so the document travels in the call
save_preset {"name":"Casa launch","slug":"casa-launch","steps":[{"op":"generate","model":"google/gemini-3.1-flash-image-preview","prompt":"{{subject.model}} holding {{subject.hero}} on a marble counter, morning light"}],"subjects":[{"name":"hero","referenceAssetIds":["ast_1a2b","ast_3c4d"],"descriptor":"a matte black insulated bottle, brushed steel lid, CASA in small serif on the front"},{"name":"model","referenceAssetIds":["ast_5e6f"],"descriptor":"a woman in her thirties with short silver hair"}]}Its dry run shows the prompt with every placeholder already expanded — check it there, not in the pictures:
{
"type": "ai-generate",
"model": "google/gemini-3.1-flash-image-preview",
"presetId": "pre_bf0d6fbf2f03427bb60d104fe9cec95a",
"presetName": "Casa launch",
"presetVersion": 1,
"totalItems": 1,
"costPerItem": 845,
"estimatedCredits": 845,
"creditBalance": 50000,
"sufficientCredit": true,
"assetCountLeft": 199,
"maxInputEdge": 1536,
"prompt": "a woman in her thirties with short silver hair holding a matte black insulated bottle, brushed steel lid, CASA in small serif on the front on a marble counter, morning light"
}- Up to 4 subjects and 4 reference images across them; every image must be your own and fully processed (
DONE). A name is a lowercase handle — letters, digits,-and_— that no other subject of the preset uses; a descriptor is at most 300 characters. - A subject with no images is refused — a descriptor alone is a prompt fragment — and so are subjects on a preset with no
generateoreditstep to send them with. The other AI steps take one image and ignore them. - Two checks wait for submit and dry-run time, before any credit is touched, because saving cannot answer them: every placeholder must name a subject that has a descriptor (
400 invalid_paramonprompt), and the model must take text plus several images — the Gemini image models, GPT-Image — with room for the references plus the item's own image, when it has one (400 invalid_paramonsubjects). A reference image deleted since the preset was saved is refused there too. - Any change to the subjects is a new version. Start from the two consistency built-ins above.
Chains with a model in them
Adjacent deterministic steps run as one processing pass and each AI step runs as its own — those are the preset's segments. A preset of one segment submits as that segment's job type. A preset of more than one — an AI step beside other steps, or two AI steps — submits as a chain: still one POST /api/v1/jobs, one item per asset, each item walking the segments in order with the product of one becoming the input of the next.
ast_1a2b— the image you sentsegment 0 · remove_bg— the model call — the only step that costs creditssegment 1 · resize → sharpen → convert— one processing pass, one of your allowancethe output— a new asset; its lineage.sourceAssetId is ast_1a2b
- The images in between are not yours to clean up. They are out of
GET /api/v1/assets(?includeIntermediate=trueif you want to see one), do not count against your asset ceiling, and are deleted when their item completes. What you get back is the last segment's output, and itslineage.sourceAssetIdis the image you sent — one hop, however long the preset. - Priced per segment. The dry run returns
steps[]—index,op,model,costPerItem— summing to the job'scostPerItem, plusprocessPerItem, the passes each item takes out of the processing allowance. A step markedboundhas a model billed on the size of the image it is sent, so its figure is priced at the largest image that model is ever sent — and it is the charge, whatever the size of yours. - You pay for the AI steps that finished. An item that fails on its third segment pays for the AI steps before it, reports
failedStep, and keeps the last finished segment's image;POST /jobs/{id}/resumerestarts each failed item from there, on the version the original ran (failure and resume). A cancel lets the segment in flight finish and starts no more. model,promptandparameterson the request are refused for a chain — there are several steps and no one of them to land on. Put the override on the step.
The dry run of the cut-out preset above — two segments, the model call and everything after it — with the price broken down so the total can be added up rather than trusted:
{
"type": "chain",
"presetId": "pre_4369d6530a66449e864fec8c2f5a2b57",
"presetName": "Product cut-out, 1200 WebP",
"presetVersion": 1,
"totalItems": 1,
"costPerItem": 227,
"estimatedCredits": 227,
"creditBalance": 50000,
"sufficientCredit": true,
"assetCountLeft": 199,
"processCountLeft": 200,
"overageRuns": 0,
"overageCredits": 0,
"processPerItem": 1,
"steps": [
{ "index": 0, "op": "remove_bg", "model": "fal-ai/bria/background/remove", "costPerItem": 227, "maxInputEdge": 2048 },
{ "index": 1, "op": "process", "costPerItem": 0 }
]
}And an item that got through the model call and failed in the pass after it: it has paid for segment 0, says where it stopped, and a resume starts it from there.
{
"status": "FAILED",
"sourceAssetId": "ast_1a2b",
"step": 1,
"failedStep": 1,
"error": "Pipeline processing failed: the image could not be encoded as webp",
"errorCode": "internal_error",
"retryable": true,
"credits": 227
}Running the steps without saving them
A preset is not what lets a chain run; it is what gives one a name and a version. The same steps array goes on the job itself — checked exactly as POST /api/v1/presets would check it, compiled into the same segments, priced per segment by the same dry run — and runs once, saved nowhere but on the job that ran it (GET /api/v1/jobs/{id} carries the steps; a list row carries stepCount). The one thing that rides beside it is subjects — the same array a consistency preset stores, checked the same way and recorded on the job — so a chain tuned from one runs with its reference images before it is saved. Beside steps, an op, a presetId, variants or a request-level model, prompt or parameters is refused by name, and subjects without steps is refused too. Save the steps as a preset when you will run them again — that is what a program pins, and what a person can run without you.
JavaScript
const job = await client.jobs.submit({ steps: [{ op: "remove_bg" }, { op: "resize", parameters: { width: 1200, height: 1200, fit: "contain" } }], assetIds: ["ast_1a2b"] });
const done = await client.jobs.wait(job.id);
const outputs = await client.jobs.outputs(done);Python
job = client.jobs.submit({"steps": [ {"op": "remove_bg"}, {"op": "resize", "parameters": {"width": 1200, "height": 1200, "fit": "contain"}}], "assetIds": [ "ast_1a2b"]})
done = client.jobs.wait(job["id"])
outputs = client.jobs.outputs(done)CLI
imagestep jobs submit --steps '[{"op":"remove_bg"},{"op":"resize","parameters":{"width":1200,"height":1200,"fit":"contain"}}]' --asset-ids ast_1a2b --wait -o jsoncurl
# price it first with ?dryRun=true; "wait" holds the response until a one-item job is done (60 s at most)
curl -s https://api.imagestep.dev/api/v1/jobs -H "Authorization: ApiKey $IMAGESTEP_API_KEY" -H "Content-Type: application/json" \
-d '{"steps":[{"op":"remove_bg"},{"op":"resize","parameters":{"width":1200,"height":1200,"fit":"contain"}}],"assetIds":["ast_1a2b"],"wait":30}'No MCP tab: run_preset takes a saved preset, and save_preset then run_preset is how an agent keeps and runs a chain — its tool schema does not carry a step array, on purpose. The n8n node has no field for one either.
What a save refuses
Every write checks the steps it is given — POST, PUT, each entry of an import, and inline steps on a job — and refuses what cannot run as written with 400 invalid_param, param naming the place, so a program can point at the step:
| refused | param | why |
|---|---|---|
| no steps | steps | a preset is its steps |
| a step with both op and operation, or neither | steps[i] | a step is one shape or the other |
| an op the catalogue does not have, or read_metadata or render_template | steps[i].op | read_metadata is a read and render_template renders rows of data; neither transforms the image a preset runs on |
| a parameter outside the op's contract | steps[i].parameters.<name> | the contract a job carrying that op is held to, so a preset cannot store what the op would refuse |
| a field of the other shape: model or prompt on a deterministic op, params on an op step, model / prompt / parameters on a registry step | steps[i].<field> | a deterministic op runs no model; an op step takes parameters, a registry step params |
| a model no provider runs, or one that does not run that step's op | steps[i].model | refused when the preset is written, not on item 400 of a batch; GET /api/v1/ai-models lists them |
| a registry key the engine does not have, or a registry parameter that would name a file on the worker — a layer written as a string, an ICC profile other than srgb, p3 or cmyk | steps[i].operation · steps[i].params… | the keys are a closed set, and a layer is one of your assets; the rest of a registry step's params are Sharp's, checked when the step runs (preset steps) |
| an op that answers with JSON (analyze) anywhere but last | steps[i].op | it produces no image, so the steps after it would have nothing to run on: the item would settle where the analyze finished, every later step skipped and already paid for. GET /api/v1/ops says which ops those are: produces |
| an op that takes no input image (generate) in a preset of more than one segment | steps[i].op | a chain's items are your assets and every step of it runs on the image in hand, so generate would be handed an image its contract does not take. edit is the op that means “this image, with this prompt” |
| frame · metadata · density given two values by one pass, or frame=all where the pass writes a still format | steps[i].parameters.<name> | those are decided once per pass, whichever step asked: the worker reads frame and density before it decodes a byte, and metadata applies at encode time. One of the two would be silently ignored — and it can be the strip that was keeping a photograph's GPS coordinates out of a published file |
| subjects that break a subject rule, or on a preset with no generate or edit step | subjects | the rules are under subjects; with no step to send them with, nothing would read them |
Everything else about an order is legal. An AI step beside other steps, several AI steps, a deterministic step between two model calls: that is a chain, and it is the point of a preset. What needs the moment of the run — a placeholder, a model's room for reference images — is checked at submit (subjects).
Warnings
A write answers with warnings when the steps say something legal that is probably not what you meant, and the dry run returns the same list for any preset. Each one carries a code to branch on, a sentence, and the step indexes it is about. A warning never stops anything — it is a field to read, like retryable, not a wall.
| code | what it is telling you |
|---|---|
| format_shadowed | two steps of one pass name an output format. A pass writes one image, in the format of its last format step, so the earlier encoder never runs — though an earlier jpeg's flattening does, and transparency is gone by the time the real format is written |
| step_repeated | a step and the one before it are the same, and running it again on its own output cannot change it — a second remove_bg is a second charge for the first one's result |
| steps_cancel_out | two adjacent flip steps, or two adjacent flop steps. Either you meant one mirror or none, and the steps say neither: inside one pass a mirror is a switch, not an action, so the image comes out mirrored once. Keep the step you mean |
There are few of them on purpose. A warning has to follow from the steps alone, with no image in hand, and it must not fire on a recipe that is right: cutting a product out, flattening it onto white and writing a JPEG throws the alpha channel you paid for away, and it is the most common thing this product does. What needs the image to decide — whether a resize after an upscale really throws pixels away — is left to the run, where a failed item tells you the step and whether it is worth retrying.
Managing presets
| REST | SDK — JavaScript · Python | CLI | what |
|---|---|---|---|
| GET /presets | client.presets.list(filter, { includeVersions })client.presets.list(filter=, include_versions=False) | preset list | list, built-ins first |
| GET /presets/{slug} | client.presets.get(slug)client.presets.get(slug) | preset get <preset-slug> | one preset, or one earlier version (slug@version, or ?version=N) |
| POST /presets | client.presets.create(preset, { idempotencyKey, signal })client.presets.create(preset, idempotency_key=) | preset create | create version 1 — MCP: save_preset |
| PUT /presets/{slug} | client.presets.update(slug, preset)client.presets.update(slug, preset) | preset update <preset-slug> | send only what changes; new steps → new version |
| DELETE /presets/{slug} | client.presets.delete(slug)client.presets.delete(slug) | preset delete <preset-slug> | delete it and every version; built-ins answer 403 |
| DELETE /presets/{slug}/versions/{version} | client.presets.deleteVersion(slug, version)client.presets.delete_version(slug, version) | preset delete-version <preset-slug> <version> | drop one superseded version — that slug@version stops resolving |
| POST /presets/import | client.presets.import(presets)client.presets.import_(presets) | preset import <json> | import the export shape |
| POST /jobs | 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=) | jobs submit--preset-id <ref> | run it (above), with presetId — MCP: run_preset |
The method names are the same in JavaScript and Python. Every write takes an Idempotency-Key; the SDKs and the CLI send one for you. A job that already ran keeps its own copy of what it ran, so deleting a preset or a version rewrites no history.