Public API (Studio)
Drive Mutator from your own systems: start runs, watch them and read what they did.
Connecting an AI agent? Use the MCP server at
https://mutator.app/mcpinstead — same keys, and nothing it can call posts anything. See the MCP documentation.
Keys
Settings → API keys → New key (workspace owner, any paid plan). The secret is shown once and stored only as a SHA-256 hash — there is no reveal path. Lose it and you issue a new one.
Two scopes:
| Scope | Can do |
|---|---|
read | List automations and runs, read run detail and analytics |
write | Everything in read, plus start runs |
A key names exactly one workspace. There is no cross-workspace access, and a workspace that drops to a plan without keys loses API access immediately — keys are re-checked against the plan on every request, not at renewal.
Revoking takes effect at once.
What a key reaches depends on the plan
Keys come with any paid plan, and the plan still decides what there is to reach. On Pro that is the work Pro does: the library, and the pictures and clips Generate makes. Automations are a Growth feature, so on Pro there are none to list and none to start, and /api/v1/automations answers with an empty list rather than an error. The deeper parts of the analytics report are withheld from a plan that does not include them, the same way they are on the screen.
The trial is the one paid-feature tier with no keys.
Authentication
curl https://your-host/api/v1 \
-H "Authorization: Bearer loop_sk_..."
GET /api/v1 returns the workspace the key belongs to (its id and its name), the key's scope, and the endpoint list — use it as a connectivity check before wiring anything up, and as the label for a saved connection.
The machine-readable version
GET /api/v1/openapi.json is this API as an OpenAPI 3.1 document, and it needs no key: it describes the surface and says nothing about any workspace, so an importer can read it before anybody has an account.
It is generated from the same schemas the server answers through, not maintained beside them, so the shapes in it are the shapes you get. Point Zapier's importer at it, generate a client from it, or hand it to an agent.
Endpoints
| Method | Path | Scope |
|---|---|---|
| GET | /api/v1 | read |
| GET | /api/v1/automations | read |
| GET | /api/v1/automations/{id}/runs | read |
| POST | /api/v1/automations/{id}/runs | write |
| GET | /api/v1/runs | read |
| GET | /api/v1/runs/{id} | read |
| GET | /api/v1/media | read |
| GET | /api/v1/media/{id} | read |
| GET | /api/v1/posts | read |
| GET | /api/v1/generations | read |
| POST | /api/v1/assets | write |
| POST | /api/v1/generations | write |
| GET | /api/v1/generations/{id} | read |
| GET | /api/v1/hooks | read |
| POST | /api/v1/hooks | read |
| DELETE | /api/v1/hooks/{id} | read |
| GET | /api/v1/approvals | read (always empty, see below) |
| POST | /api/v1/approvals/{id}/decide | write (refused, see below) |
| GET | /api/v1/analytics?days=30 | read |
Starting a run
idempotencyKey is required, so retrying after a timeout is safe:
curl -X POST https://your-host/api/v1/automations/AUTOMATION_ID/runs \
-H "Authorization: Bearer loop_sk_..." \
-H "content-type: application/json" \
-d '{"idempotencyKey": "weekly-2026-08-12", "dryRun": false}'
Replaying the same key returns the original run rather than starting a second one. Keys are namespaced per API key, so two integrations cannot collide.
Runs started this way are recorded with trigger api, so run history shows they came from an integration rather than a person.
Paging
Every list takes limit (25 by default, 100 at most) and after, and returns nextCursor beside the rows. Pass that value back as after for the next page; null means you have reached the end.
curl "https://your-host/api/v1/media?limit=50" -H "Authorization: Bearer loop_sk_..."
# { "media": [ … ], "nextCursor": "MjAyNi0wOS0yM1QwODoxNTozMC4xMjNafGFydC0x" }
A cursor names a row rather than a position, which matters if you are polling. With ?page=2, anything created between your two requests shifts the list down and you skip exactly one item, silently. A cursor is "everything older than this row", so nothing is lost and nothing repeats. Cursors are opaque: do not build one, and a value we did not write is a 400 rather than a fresh first page.
What Mutator made
GET /api/v1/media is the list to poll if you want the files: everything this workspace has produced, in a run or in Generate, newest first.
{
"media": [
{
"id": "art_…",
"kind": "video",
"title": "Kitchen countertop, 9:16",
"mime": "video/mp4",
"sizeBytes": 4318221,
"createdAt": "2026-09-23T08:15:30.123Z",
"runId": "run_…",
"automationId": "aut_…",
"durationSeconds": 8,
"aspectRatio": "9:16",
"downloadUrl": "https://…",
"posterUrl": "https://…"
}
],
"nextCursor": null
}
Filters: kind (image, video, image_set), automationId.
GET /api/v1/media/{id} is the same thing for one file. It is what to call when a media.created webhook arrives: the event carries ids rather than a link, because a delivery can wait on a queue and a six hour old link would be useless by the time anything fetched it. Ask for the file and the link is signed then. A file that has since gone, or that the content check withheld, answers 404 rather than handing back a row with nothing to fetch.
Files somebody uploaded are not here. "What Mutator made" is the question this answers; a file you brought is already wherever you brought it from.
Links expire after six hours. Longer than the hour a preview in the app gets, because these are fetched by a queue somewhere else, and short enough that a URL in a spreadsheet stops working. Fetch the bytes, or ask again.
Anything the content check withheld is not listed at all, rather than listed with no link. Mutator's own screens draw a withheld file as a tile saying why, which is right for the person who made it and useless to a Zap that was going to copy it somewhere.
GET /api/v1/runs/{id} now carries outputs: the same media, plus any caption set with its words in content, because a post is a file and the words that go with it. Briefs and analytics summaries are the run thinking out loud and stay out of it.
GET /api/v1/generations is the other half: the pictures and clips somebody asked for directly, with no automation. status is queued, generating, done or failed; a failure carries error and, when paying would change the answer, errorCode. Credits are held when the work starts rather than when it is asked for, so running out appears here rather than as a 402.
GET /api/v1/runs lists runs across every automation, newest first, filtered by status or automationId. Use it rather than walking the automations one at a time.
What went out
GET /api/v1/posts lists what a platform actually accepted, newest first.
{
"posts": [
{
"id": "pub_…",
"platform": "tiktok",
"url": "https://www.tiktok.com/@example/video/…",
"publishedAt": "2026-09-22T09:00:00.000Z",
"mock": false,
"mediaArtifactId": "art_…",
"captionArtifactId": "art_…",
"runId": "run_…",
"automationId": "aut_…",
"variantLabel": null
}
],
"nextCursor": null
}
Filters: platform, automationId. Paged like the rest, on publishedAt, which is this record's clock.
Nothing in this API makes one of these. It reports what a person decided in Mutator. The rows are kept deliberately free of the automation and the run that produced them, so a post that went out last year is still listed after the automation behind it has been deleted.
A post made in test mode is listed with mock: true rather than left out, so that an integration wired up in test mode sees something rather than concluding it is broken.
captionArtifactId and mediaArtifactId name what went with it; fetch the file with /api/v1/media/{id}.
Asking for a picture or a clip
curl -X POST https://your-host/api/v1/generations \
-H "Authorization: Bearer loop_sk_..." \
-H "content-type: application/json" \
-d '{"idempotencyKey": "sheet-row-412", "kind": "video",
"prompt": "The mug on a windowsill, steam rising", "aspectRatio": "9:16"}'
# { "id": "gen_…", "deduplicated": false }
The same request the Generate panel makes: kind (image or video), prompt, and optionally aspectRatio, look, quality, variations (1 or 4, because the provider batches ones and fours), duration for a clip, caption, and the ids of a picture to animate, a character, a product or an environment.
Nothing waits for the provider. Poll /api/v1/generations/{id} or subscribe to generation.finished.
idempotencyKey is required, because every integration platform retries a request that timed out, and a timeout after the work started looks exactly like one before it. Asking again with the same value returns the first answer and deduplicated: true rather than making a second video. Keys are namespaced per API key, so two integrations cannot collide.
This is attributed to whoever issued the key: it is what the workspace's own record of the work will say, and it is where the credits are spent from.
Bringing a picture in
curl -X POST https://your-host/api/v1/assets \
-H "Authorization: Bearer loop_sk_..." \
-H "content-type: application/json" \
-d '{"idempotencyKey": "drive-file-88", "url": "https://.../mug.png",
"title": "Blue mug", "referenceRole": "product"}'
# { "id": "art_…", "mime": "image/png", "sizeBytes": 208411, "status": "checking", … }
A link, not an upload: it is what a Zap or a Make scenario holds, and the browser's own path is a signed PUT that only a browser can make. We fetch it.
PNG, JPEG, WebP and GIF, up to 32 MB, decided by the file's first bytes rather than by the content-type the same server sends. Bigger files and video go in through Mutator, which streams them. The address must be a public one, and a redirect is refused rather than followed: send the link to the file itself.
The picture is not usable yet. status is normally checking, because everything that arrives goes past the content check before anything may paint from it. Wait for media.created, or poll /api/v1/media for the id. Then pass the id to /api/v1/generations as imageArtifactId, productId, characterId or environmentArtifactId.
idempotencyKey is required here too, and a retry returns the file the first call stored rather than a second copy of it.
Webhooks
Rather than polling, ask to be told.
curl -X POST https://your-host/api/v1/hooks \
-H "Authorization: Bearer loop_sk_..." \
-H "content-type: application/json" \
-d '{"event": "media.created", "targetUrl": "https://hooks.example.com/abc"}'
# { "id": "…", "event": "media.created", "targetUrl": "…", "secret": "whsec_…", … }
DELETE /api/v1/hooks/{id} stops it, and GET /api/v1/hooks lists what this key is listening for, with disabledAt and lastError when we have given up on one.
A read key is enough. A subscription sends the workspace's own data to an address its holder chose, which is no more than that key can already read, and requiring a write key would mean every trigger-only integration held one that can also start runs.
Subscribing twice with the same event and address is one subscription, so a platform that re-subscribes when somebody edits an automation does not double every delivery.
| Event | When |
|---|---|
media.created | A picture or clip finished and passed the content check |
run.finished | A run reached a final state, including a failed one |
generation.finished | Something asked for in Generate finished or failed |
post.published | Something a person scheduled actually went out |
post.published says that something went out. It is not a way to send one: nothing in this API publishes, and a post exists only because a person put a file in an account's schedule with their name against the decision.
The delivery
POST /abc HTTP/1.1
mutator-event: media.created
mutator-delivery: 0b2f…
mutator-signature: t=1790000000,v1=6f3c…
content-type: application/json
{ "id": "0b2f…", "event": "media.created", "workspaceId": "…",
"createdAt": "2026-09-23T08:15:30.123Z", "data": { … } }
Answer with any 2xx. Anything else, or a timeout after ten seconds, is a failure and we try again with the queue's backoff. Deliveries are at-least-once: dedupe on id, which is also in the mutator-delivery header.
The payload carries ids and a few fields, never the bytes. Fetch the file from the link in /api/v1/media or the run's outputs.
Check the signature. It is HMAC-SHA256 of ${t}.${body} with the secret that subscribe returned, written t=…,v1=…. Compare in constant time, and reject a t that is not recent, which is what stops a delivery you received today being replayed at you tomorrow.
const [, t, sig] = /^t=(\d+),v1=([a-f0-9]+)$/.exec(header);
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
When we stop
Fifteen failed attempts in a row disables a subscription, and the workspace is told in Mutator. Nothing is lost on our side, and subscribing again with the same event and address switches the same subscription back on.
A subscription also stops when the key that made it is revoked or expires, and while the workspace has no plan that includes API access. Revoking the key is the way to turn an integration off.
Approvals were removed
On 20 September 2026 the approval step was taken out. A run no longer stops and waits for a person, because a run no longer posts anything at all: what it generates goes to the library, and a post exists only once somebody has put a particular file in a particular account's schedule, in Mutator, with their name recorded against the decision.
Both paths still answer rather than 404, so an integration written against them gets a reply it can read:
GET /api/v1/approvalsreturns an empty list. Nothing is waiting, because nothing can wait.POST /api/v1/approvals/{id}/decidereturns 410 with a message saying where the decision is made instead.
There is no endpoint in this API that schedules a post, which means no API key can cause anything to be published. That is stronger than the rule it replaces: a write-scoped key used to be able to approve named items. If your integration relied on that, the run and its output are still yours to read; the posting is done by a person in the app.
Errors
JSON with error and correlationId. Quote the correlation id in support requests; it ties the response to the server logs.
| Status | Meaning |
|---|---|
| 400 | Invalid body (an issues array names the fields) |
| 401 | Missing, unknown, revoked or expired key |
| 402 | Refused by the workspace's plan, credits, billing or a spending limit (see below) |
| 403 | Read-only key attempted a write |
| 404 | Not found in this key's workspace |
| 410 | Gone: approvals were removed (see above) |
| 422 | content_blocked: words or media Mutator does not make or publish (see below) |
| 429 | Rate limited |
| 503 | screening_unavailable: the content check could not answer, so nothing went ahead; retry later |
| 5xx | Our side; safe to retry idempotent calls |
A record belonging to another workspace returns 404, not 403 — existence is not disclosed across tenants.
Refusals a purchase could change
A 402 carries a code. The error sentence says what is true — "This run needs 40 credits and 12 are left." — and never what to buy, because the same sentence is shown in Mutator's phone apps. What would change the answer is the code, with details:
code | Means | details |
|---|---|---|
plan_required | The workspace has no plan it can run on right now | reason: no_plan, trial_ended, plan_ended, trial_runs_used (with limit), or card_required (the welcome credits are spent and the trial has not started) |
plan_feature | The plan does not include this, or not this many | feature (e.g. advanced_triggers, members); requiredPlan (growth or studio) when the plan lacks it entirely; limit and used for a counted allowance |
credits_required | Not enough credits for the work | needed, available |
billing_attention | A payment problem is holding things up | reason: payment_failed or credits_on_hold |
limit_exceeded | A limit no purchase lifts: a spending cap set in Mutator, a step's cost ceiling, the number of keys | none |
{
"error": "Scheduled runs are part of Growth.",
"code": "plan_feature",
"details": { "feature": "advanced_triggers", "requiredPlan": "growth" },
"correlationId": "…"
}
Plans and credits are changed by a workspace owner in Mutator; no endpoint here buys anything. An integration should stop and tell a person, not retry.
Over MCP, the same refusal comes back as a tool result with isError: true: the sentence in the first text block, and {"code", "details"} as JSON in a second.
A refusal that happens after the request has returned keeps its code, without details, beside its message: error.code on a failed step and on the run it failed, errorCode on a Studio generation, faceErrorCode on a character whose face sheet failed, and code on a notification about it (a paused schedule, a stopped repeat, a failed run, an ended trial or plan). Credits are held when a generation or a face sheet starts rather than when it is asked for, so running out shows up there and not as a 402. Each is optional and null for anything else.
Refusals by the content screen
Mutator does not make or publish nudity, sexual content, graphic violence or the other things its terms prohibit, and checks the words and media that pass through it. Two codes say what the check decided:
| Status | code | Means | What to do |
|---|---|---|---|
| 422 | content_blocked | Something in the request, or on a step of the automation being run, describes what Mutator does not make | Stop and tell a person; the same request will be refused again |
| 503 | screening_unavailable | The check could not be made, so nothing was saved, started or charged | Retry later with the same idempotency key |
When the refusal is about a field somebody wrote, an issues array names it in the same shape a 400 uses. For a run, the path is the step's key and the field, e.g. r2.extraGuidance:
{
"error": "“Strategy”: The guidance can't be used: it describes nudity, which Mutator doesn't make or publish.",
"code": "content_blocked",
"issues": [{ "path": "r2.extraGuidance", "message": "…" }],
"correlationId": "…"
}
A run is checked before anything is created or spent, and after every other refusal above: a workspace that is out of credits is told that first.
Over MCP, both come back as a tool result with isError: true: the sentence in the first text block, and {"code", "issues"} as JSON in a second (issues only when the refusal names a field).
A step that fails for either reason once the run has started keeps the code on its stored error — error.code on the step and on the run it failed — without issues. An output withheld after it was generated is not charged for.
Rate limits
Per key, so one integration cannot exhaust another's budget: 120 reads/minute, 30 writes/minute.
Versioning
The path carries the version. /api/v1 will not change incompatibly; breaking changes ship as /api/v2.