Public API
The Pluto Ads API is the same API the app uses. Everything a person does in the app, a script or agent can do here with its own credential, and every write goes through the same commands, permissions and audit log. This page covers what you need to connect and to write safely: credentials, commands, conflicts, batches, jobs, webhooks, limits and errors, then each area of the API. The Route index at the end lists every route; the OpenAPI document has their schemas. For how the same things look in the app, see the guides: Launches, Publishing, Developers.
API messages use typographic apostrophes; the examples on this page show them as '.
Base URL
Every route lives under one base URL:
https://api.plutoads.ai/api/v1The OpenAPI 3.1 document lists every route, parameter and permission, and names the same base URL in
servers:
curl -s https://api.plutoads.ai/api/v1/openapi.json | jq -c '.servers'
# [{"url":"https://api.plutoads.ai/api/v1"}]The examples below use two shell variables:
export PLUTO_API=https://api.plutoads.ai/api/v1
export PLUTO_API_KEY=sk_live_... # a secret key; examples for admin routes need one with adminRequests and responses are JSON with camelCase fields. IDs are UUID v7, except Meta's own IDs (act_...
for ad accounts, digits for campaigns, ad sets and ads). Times are RFC 3339 in UTC.
Authentication
Four credentials reach the API. Each one belongs to exactly one workspace, acts for one person and
carries a set of permissions: read, review (comments, and approving or requesting changes: an ad's
review status), write (drafts, media, workflow), publish (provider publishing and live budgets),
admin (this workspace's members and guests, integrations, keys, webhooks and settings) and
organization (with admin: the organization's people, workspaces, plan, billing and usage limits).
| Credential | Header | For | Permissions |
|---|---|---|---|
Session cookie plutoads_session | Cookie | The app in a browser | The person's role |
Secret key sk_live_... | Authorization: Bearer | Scripts, servers, MCP clients | Chosen at creation: read, review, write, publish by default; admin and organization only when asked for |
Publishable key pk_live_... | Authorization: Bearer | Read-only integrations | read only |
| OAuth access token | Authorization: Bearer | Agents people connect by signing in from an MCP client (MCP); agent clients (see below) | The access the person chose, or the agent client's scopes; never more than its person |
Owners, admins and members have read, review, write and publish; owners and admins also have
admin, and organization owners and admins manage the organization.
Who can do what
Every credential acts for a person, and it can only ever do what that person can do right now.
- Whose person. A session and a connected agent act for the person who signed in. An
mcpagent client acts for the admin who registered it. An API key (secret or publishable) and anm2magent client act for their owner: the person who created them, or the admin who took an API key over. - Checked on every request. What a credential may do is its scopes, narrowed by its person's current role in the workspace, their current access to it and whether the credential is for the workspace or the organization. Nothing is decided only when it's created: demoting the person narrows the credential on its next request, and suspending or removing them, or deleting their account, stops it on the next request (open realtime sockets within 5 seconds). A credential nobody is accountable for acts for no one.
- Two levels of admin.
adminadministers the credential's own workspace: its members and guests, webhooks, keys and agent clients, the Meta connection and its settings. It never reaches the organization.organization(always together withadmin) administers the organization: its people and invitations, its workspaces, the plan, billing details, usage limits and extra storage. It works only while its person is an organization owner or admin. - Never by a credential. Granting or removing ownership, deleting the organization and deleting an account need the person, signed in. No credential removes the person it acts for either; a person leaves a workspace or the organization themselves.
Who creates what.
| Who | Connect an agent for themselves | API keys and agent clients |
|---|---|---|
| Organization owners and admins | Yes, up to their role | Yes, in any workspace, up to their role, including organization |
| Workspace admins | Yes, up to their role | Yes, in their workspace, up to admin |
| Members | Yes, up to their role (read, review, write, publish), while the organization allows it | No |
| Guests | No | No |
Guests reach the API only through the app. Members can connect agents (Organization, General, on by default) decides whether members may sign in from Claude, Cursor or another agent; turning it off stops members' connected agents on their next request, and owners' and admins' keep working.
Seeing and ending credentials. Organization owners and admins see every API key, agent client and
connected agent in the organization, with who it acts for, its workspace, scopes, when and from where it
was last used, when it was created and when it expires (GET /organizations/{id}/credentials), and revoke
any of them (DELETE /organizations/{id}/credentials/{credential_id}). Everyone sees and disconnects
their own connected agents in their account settings (GET /me/agents, DELETE /me/agents/{id}).
Before someone leaves, an organization owner or admin can take over their API keys
(POST /organizations/{id}/members/{user_id}/credentials/transfer, with toUserId the caller): each key is
re-issued to them with the same name, kind, scopes and expiry and a new secret shown once, and the old key
stops working at once. Agent clients, connected agents and keys the caller can't give the scopes of aren't
re-issued (skipped); they're revoked when the person leaves.
Hygiene. Secrets are shown once and stored only as a hash. A key can expire by itself (expiresAt,
up to two years). Rotate a key with POST /api-keys/{id}/rotate: the new key replaces the old one in
one step, so two never work at once. Each credential records when and from which address it was last
used. Creating, revoking, moving and rotating a credential, and every request a credential makes with its
admin or organization access, are in the audit log. Requests are rate limited per credential.
Approvals. The in-app Agent asks the person before it publishes, changes billing, the plan or
limits, adds a workspace, changes people or credentials, unless the person chose Run everything (see
Agent): then it acts as them, within their permissions, and still asks above the workspace's budget
guardrail and before it changes people, credentials or usage limits. Agents you connect act within the access you
gave them; what needs a person in the app (payment, ownership) returns a link or a refusal instead. Guests have review only. They see what they're asked to review: the launches shared with
guests (their ad sets, ads and creatives), the comments on them that aren't internal, their Inbox,
notifications and preferences. They comment, react, resolve and approve or request changes there, and
nothing else: no other launches, loops, the Agent or its chats, analytics, the media library, integrations
or settings, and no one's email address. They can't edit, publish, upload media, use the Agent or connect
agents, and they never take a seat. The routes guests reach are marked x-guests in the OpenAPI
document; anything else answers them 403, and launches or comments they may not see answer 404. A key
scoped to ["review"] sees exactly what a guest sees, for a review bot; with read as well it reads the
whole workspace.
Sharing with guests. A member shares a launch with the workspace's guests with
PATCH /launches/{id} { "expectedVersion": 3, "sharedWithGuests": true } (and stops with false,
which also removes guests' notifications about it). Assigning one of its ads to a guest, or mentioning a
guest in a comment that isn't internal, asks them to review and shares the launch too. A launch's
sharedWithGuests says whether guests see it.
A workspace whose guests review everything (an agency whose client is a guest) can share every new launch
from the start: an admin sends PATCH /workspace { "shareNewLaunchesWithGuests": true }. From then on,
every launch created in the workspace starts with sharedWithGuests: true, whoever creates it (the app,
the API, MCP, loops, the Agent, a Meta import or a duplicate). Launches that already exist keep their
sharing, and a member can still stop sharing any one of them. GET /workspace returns the setting; it's
off by default.
curl -s -X PATCH "$PLUTO_API/workspace" -H "authorization: Bearer $PLUTO_API_KEY" \
-H 'content-type: application/json' -d '{"shareNewLaunchesWithGuests":true}'Internal comments. A member starts an internal thread with "internal": true on
POST /ads/{ad_id}/comments, or marks any thread internal (or visible to guests again) with
PUT /comments/{id}/internal { "internal": true }, which needs write. Guests never receive an internal
thread, its replies or its reactions, and their notifications about it are removed. Replies follow their
thread. A guest can't be mentioned in an internal comment (422 on body).
Creating keys. A credential never gets a permission its creator lacks. Admins create keys on the API page of the app, or through the API:
curl -s "$PLUTO_API/api-keys" -H "authorization: Bearer $PLUTO_API_KEY" \
-H 'content-type: application/json' -H 'idempotency-key: key-reporting-agent' \
-d '{"name":"Reporting agent","kind":"secret"}' | jq -c '{prefix,kind,scopes,secret}'
# {"prefix":"sk_live_4f7Q","kind":"secret","scopes":["read","review","write","publish"],"secret":"sk_live_4f7Q..."}The full key is in the first response only. Replaying the same Idempotency-Key returns the key record
with "secret":null; we store only a hash. GET /api-keys lists keys and DELETE /api-keys/{id} revokes
one. A credential can't revoke itself (409); revoke it with another one. Each key, agent client and
webhook carries createdByActor (kind, id, name): the person, key or agent that created it.
A publishable key can read and nothing else:
curl -s "$PLUTO_API/launches" -H "authorization: Bearer $PLUTO_PK" \
-H 'content-type: application/json' -d '{"name":"x"}'
# {"type":"https://docs.plutoads.ai/api/errors#forbidden","title":"You can view this workspace but not change it.","status":403,"code":"forbidden"}Agent clients are for your own agents and services that should not hold a long-lived key. An admin
registers one (API page, Agent credentials, or POST /agent-clients). Kind mcp acts for the person who
registered it and is bounded by that person's current role; kind m2m runs as a service with the scopes
you choose, bounded by its owner's current role. The response carries a clientId and a clientSecret, shown once. Exchange them at
the authorization server's token endpoint for a short-lived access token (expires_in says for how
many seconds; request a new one when it runs out). The token endpoint is
discovered from the protected resource metadata, so nothing about the authorization server is
hard-coded:
AUTH_SERVER=$(curl -s "$PLUTO_API/.well-known/oauth-protected-resource" | jq -r '.authorization_servers[0]')
TOKEN_URL=$(curl -s "$AUTH_SERVER/.well-known/oauth-authorization-server" | jq -r '.token_endpoint')
curl -s "$TOKEN_URL" \
-d grant_type=client_credentials -d client_id="$CLIENT_ID" -d client_secret="$CLIENT_SECRET" \
-d resource="$PLUTO_API/mcp" | jq -c '{token_type,expires_in}'
# {"token_type":"Bearer","expires_in":300}The token works for REST and MCP. Its audience must be the MCP URL (resource above). MCP clients
discover all of this on their own from the metadata described in MCP.
Agent connections are agents a person connected by signing in from Claude, Cursor or another MCP
client. GET /agent-connections lists them (your own; admins see all) and
DELETE /agent-connections/{id} disconnects one. POST /agent-connections is the consent step of that
sign-in; the app's /oauth/authorize page calls it, not your code. Agents that registered themselves
with auth.md and were claimed by their person are listed too, with method auth_md
(MCP); POST /agent-connections/claims is that claim, which the app's
/agents/claim page calls.
Revoking a key, disconnecting an agent client or connection, or suspending or removing its person stops it on its next request, and on open realtime sockets within 5 seconds.
Actions that belong to a person. Reactions, following an ad or thread, the inbox, view preferences
and device sync act on a person's own data. An API key or an m2m agent client has no person, so these
answer 403 This action needs a person, not an API key. Agents connected by a person, and mcp agent
clients, act for that person and can use them.
Workspace header
A key or agent client belongs to one workspace, so it needs no workspace selector. A session can reach
every workspace its person belongs to; send Pluto-Workspace: <workspace id> to choose one, or it uses
the workspace last selected in the app (PUT /me/workspace). An agent token without an organization
claim also needs it.
When a credential sends a workspace it doesn't belong to, the request fails instead of silently using another workspace:
curl -s "$PLUTO_API/launches?limit=1" -H "authorization: Bearer $PLUTO_API_KEY" \
-H 'pluto-workspace: 01a0d7e5-0000-7000-8000-000000000000'
# {"type":"https://docs.plutoads.ai/api/errors#forbidden","title":"This credential belongs to another workspace.","status":403,"code":"forbidden"}A value that isn't a UUID is a 400 (Pluto-Workspace must be a workspace ID.).
Idempotency keys
Every write is a command. Send Idempotency-Key (1 to 200 characters) and reuse it when you retry the
same request. The result is stored in the same transaction as the change, so a retry after a timeout or a
lost response returns the stored result and changes nothing:
curl -s "$PLUTO_API/launches" -H "authorization: Bearer $PLUTO_API_KEY" \
-H 'content-type: application/json' -H 'idempotency-key: launch-spring-1' \
-d '{"name":"Spring sale"}' | jq -c '{id,name,version}'
# {"id":"01a0d8da-b21b-741f-9bdf-e0d0a8846c39","name":"Spring sale","version":1}Sending the same key and body again returns 201 with that same launch, even if the launch has changed
since. The replay is the original response, not a new read. Sending the same key with a different body
is refused, with reason set so code can tell it apart from other conflicts:
curl -s "$PLUTO_API/launches" -H "authorization: Bearer $PLUTO_API_KEY" \
-H 'content-type: application/json' -H 'idempotency-key: launch-spring-1' \
-d '{"name":"Something else"}'
# {"type":"https://docs.plutoads.ai/api/errors#conflict","title":"This command ID was already used for a different request. Use a new ID (Idempotency-Key, or the item's commandId) for new work; reuse an ID only to retry exactly the same request.","status":409,"code":"conflict","reason":"command_id_reused"}Keys are scoped to the credential (the person, API key or agent) within its workspace. Two agents that
happen to pick the same key each get their own command: the same request with the same key from a second
API key created a second launch. A request without the header still runs once, but a retry can't be
recognized and may act twice. Use a key derived from your own work item (launch-spring-1), not a
random value per attempt.
Command IDs are remembered for 30 days. Retry within that window: a key sent again after 30 days is a new
command, which runs again and gets a new result. That covers offline queues and agent retries; a job that
may resend work later than that should check the current state before it retries. The same window applies
to the commandId of each item in a batch.
Versions and conflicts
Every launch, ad set, ad, media item, folder and setting has a version. Updates take expectedVersion:
the version you last read. Versions are tracked per field. An edit based on an older version still
applies when none of the fields it touches changed since; it conflicts only when someone changed the
same field.
Two edits made from version 1, to different fields, both apply:
curl -s -X PATCH "$PLUTO_API/launches/$LAUNCH_ID" -H "authorization: Bearer $PLUTO_API_KEY" \
-H 'content-type: application/json' -H 'idempotency-key: rename-1' \
-d '{"expectedVersion":1,"name":"Spring sale US"}' | jq -c '{name,version,cta:.settings.cta}'
# {"name":"Spring sale US","version":2,"cta":"Shop now"}
curl -s -X PATCH "$PLUTO_API/launches/$LAUNCH_ID" -H "authorization: Bearer $PLUTO_API_KEY" \
-H 'content-type: application/json' -H 'idempotency-key: cta-1' \
-d '{"expectedVersion":1,"settings":{"cta":"Learn more"}}' | jq -c '{name,version,cta:.settings.cta}'
# {"name":"Spring sale US","version":3,"cta":"Learn more"}A third edit from version 1 that changes name again conflicts, because name changed after version 1.
The 409 carries the current entity in current, so you can show the difference or merge and retry with
expectedVersion set to current.version:
curl -s -X PATCH "$PLUTO_API/launches/$LAUNCH_ID" -H "authorization: Bearer $PLUTO_API_KEY" \
-H 'content-type: application/json' -H 'idempotency-key: rename-2' \
-d '{"expectedVersion":1,"name":"Spring sale EU"}' | jq -c '{status,code,title,current:{name:.current.name,version:.current.version}}'
# {"status":409,"code":"conflict","title":"This was changed by someone else. Review the current version and try again.","current":{"name":"Spring sale US","version":3}}Versions reach inside JSON: settings.cta and settings.headlines are separate fields, and so are an
ad's overridden headline and its overridden description. Changing the ad account, or moving an ad to
another launch, touches every field.
Batches
Batch endpoints take items[] and return an outcome per item, so one bad item never fails the rest. Each
item carries its own commandId, which works like an idempotency key for that item: resend the whole
batch after a failure and only the items that never applied run again.
curl -s "$PLUTO_API/launches/$LAUNCH_ID/ads/batch" -H "authorization: Bearer $PLUTO_API_KEY" \
-H 'content-type: application/json' -H 'idempotency-key: ads-batch-1' \
-d '{"items":[
{"commandId":"spring-us-hero","mediaId":"'"$MEDIA_ID"'","name":"Hero square"},
{"commandId":"spring-us-missing","mediaId":"01a0d7e7-0000-7000-8000-000000000000"}]}' \
| jq -c '.items[] | {commandId,status,adId:.ad.id,errors}'
# {"commandId":"spring-us-hero","status":"accepted","adId":"01a0d8db-1839-7554-82c7-7f0778585c06","errors":null}
# {"commandId":"spring-us-missing","status":"rejected","adId":null,"errors":[{"field":"mediaId","message":"Choose media from this workspace's library."}]}The outcomes are accepted (with the result), rejected (with every field problem, each with its field
path inside the item) and conflict (with a message, for example an externalKey already in use). Fix
and resend only the rejected items. A commandId is scoped like an idempotency key: reusing one for a
different item rejects that item on commandId. Derive command IDs from your own records
(spring-us-hero), never from a position in the batch. The response itself is 200 even when every item
was rejected; check each item.
Launches and ad sets have the same batch, with the same per-item shape (launch or adSet in place of
ad):
curl -s "$PLUTO_API/launches/batch" -H "authorization: Bearer $PLUTO_API_KEY" \
-H 'content-type: application/json' -H 'idempotency-key: launches-batch-1' \
-d '{"items":[
{"commandId":"spring-us","name":"Spring US","externalKey":"spring-us"},
{"commandId":"spring-eu","name":"","adAccountId":"act_unknown"}]}' \
| jq -c '.items[] | {commandId,status,launchId:.launch.id,errors}'
# {"commandId":"spring-us","status":"accepted","launchId":"01a0d8db-1858-765b-9b72-da52fc7085f9","errors":null}
# {"commandId":"spring-eu","status":"rejected","launchId":null,"errors":[{"field":"name","message":"Use a name of 1 to 120 characters."},{"field":"adAccountId","message":"Choose an ad account from your Meta connection."}]}
curl -s "$PLUTO_API/launches/$LAUNCH_ID/ad-sets/batch" -H "authorization: Bearer $PLUTO_API_KEY" \
-H 'content-type: application/json' -H 'idempotency-key: ad-sets-batch-1' \
-d '{"items":[
{"commandId":"spring-us-broad","name":"Broad","targeting":{"countries":["US"]}},
{"commandId":"spring-us-broad-2","name":"Broad"}]}' \
| jq -c '.items[] | {commandId,status,adSetId:.adSet.id,errors}'
# {"commandId":"spring-us-broad","status":"accepted","adSetId":"01a0d8db-1887-737c-aaae-8e13e82c99ce","errors":null}
# {"commandId":"spring-us-broad-2","status":"rejected","adSetId":null,"errors":[{"field":"name","message":"An ad set with this name already exists in this launch."}]}What holds for every create batch (/launches/batch, /launches/{id}/ad-sets/batch,
/launches/{id}/ads/batch):
- 1 to 1,000 items per request; more (or none) is a
422onitems(Send 1 to 1000 items per request.). Split larger sets, or use a launch spec. - Each item runs on its own: an item that is rejected or conflicts leaves no trace, and its
commandIdstays free, so you can fix the item and resend it under the same ID. - An item's
commandIdis the same key as the single create'sIdempotency-Key(POST /launches,POST /launches/{id}/ad-sets): a launch created with keyspring-uscomes back unchanged from either route, and never twice. Two identical batches sent at the same time create each item once. - The request's own
Idempotency-Keyreplays the whole stored answer, as for any command. - A batch needs
write, like the single create. An unknown launch in the path is a404for the batch. - Items are audited, announced to webhooks and shown live in the app exactly like single creates; agents and keys appear in the presence of the launches they create.
POST /media/uploads/batch (1 to 100 files) answers with the same status, errors and message per
item, plus the item's index and its upload in result. Its items may carry a commandId; without
one, an item's command ID is <Idempotency-Key>:<index>, which the response echoes.
POST /launches/batch creates launches only. To create a launch together with its ad sets and ads, send a
launch spec (below): it matches every child by externalKey, so it can also update what you created
before, which a batch of creates can't.
There is no batch of updates. Change many ad sets or ads with bulk edits, which check each item's version and record a changeset you can revert.
Launches, ad sets and ads
A launch is one campaign's worth of work: its ad account, campaign (a new one, or an existing Meta
campaign by campaignId), settings, ad sets and ads. GET /launches/{id} returns the whole document:
launch, adSets, ads, account, campaign and the readiness review.
Inheritance is override-only. The launch's settings hold the values every ad set and ad starts from.
An ad set or ad stores only what it changes, in overrides, and each ad carries its resolved values in
effective, with effective.inherited saying which groups (copy, destination, identity, creative
settings) still follow the launch. A value equal to the launch's is stored as inherited, so later launch
changes keep reaching it. PATCH /ads/{id} takes overrides, which replaces what the ad stores, or
overridesPatch, a JSON merge patch (RFC 7396) that changes only the keys it names; null removes an
override. An unknown override key is a 422 naming it (overrides.<key> or overridesPatch.<key>).
curl -s -X PATCH "$PLUTO_API/ads/$AD_ID" -H "authorization: Bearer $PLUTO_API_KEY" \
-H 'content-type: application/json' -H 'idempotency-key: hero-headline' \
-d '{"expectedVersion":1,"overrides":{"copyVariations":{"headlines":["Autumn is here"]}}}' \
| jq -c '{version,overrides,headline:.effective.headline,copyInherited:.effective.inherited.copy}'
# {"version":2,"overrides":{"copyVariations":{"headlines":["Autumn is here"]},"identity":null},"headline":"Autumn is here","copyInherited":false}The rest of the model:
- Ad sets.
POST /launches/{id}/ad-setscreates one (name,targeting,overrides,beforeIdfor its place),PATCH /ad-sets/{id}changes it andPOST /ad-sets/{id}/movereorders it.DELETE /ad-sets/{id}deletes a draft ad set; its ads become ungrouped.POST /ad-sets/{id}/duplicatecopies it with its ads, in its launch or into another (launchId). That is how an ad set moves between launches, because Meta can't move an ad set to another campaign. In another ad account, the pixel, custom audiences and identity follow the destination, and copied ads start a new review. - Ads. Create them in bulk (
/launches/{id}/ads/batch, one per media item). AmediaIdcan be an image or a video: the ad'sformat(imageorvideo) follows the media, and so do the creative enhancements it publishes with (creative.imageorcreative.videoin the settings).PATCH /ads/{id}changes name, ad set, creative (mediaId), review status (statusId), assignee, labels and overrides.POST /ads/{id}/duplicatecopies an ad right after the original.GET /adssearches ads across every unarchived launch by label, status, assignee, media (mediaId: the ads using a file) or name. - Moving ads.
POST /ads/movetakesadIds(oradswith versions) and anadSetId(nullungroups them), optionallybeforeId. WithlaunchId, draft ads move to that launch with their comments, activity, labels and followers, and keep what they show: a value that came from the old launch becomes an override when the new launch differs. Published ads can't move (409); duplicate them instead.
curl -s "$PLUTO_API/ads/move" -H "authorization: Bearer $PLUTO_API_KEY" \
-H 'content-type: application/json' -H 'idempotency-key: move-hero-to-2027' \
-d '{"adIds":["'"$AD_ID"'"],"launchId":"'"$OTHER_LAUNCH_ID"'","adSetId":null}' \
| jq -c '.ads[0] | {launchId,adSetId,overrides}'
# {"launchId":"01a0d8db-935d-757a-ac63-b1c196a2e02c","adSetId":null,"overrides":{"cta":"Learn more","identity":null}}- Deleting and archiving.
DELETE /ads/{id}deletes a draft ad; an ad with comments or activity is archived instead and the response saysarchived: true.DELETE /launches/{id}deletes a draft launch that never created anything at a provider. Anything live in Meta refuses both with a409(see Publishing).POST /launches/{id}/archivewith{"archived":true}archives a launch andfalserestores it;GET /launches?archived=truelists archived launches. - Copies and checks.
POST /launches/{id}/duplicatemakes a new draft with the same destination, settings, ad sets and ads, back at the default review status.GET /launches/{id}/reviewis the readiness checklist (ready,items,excluded).GET /launches/statscounts open and published launches, ads in review and ads with changes requested, and the caller's assigned review work.
Bulk edits
A bulk edit changes many ads or ad sets with one rule: a selector (a launchId or explicit adIds or
adSetIds, required, narrowed by filters such as labelIds, statusIds, assigneeId, ungrouped, q) and operations (set,
adjust such as +10% or +2.50, add and remove for lists, replace with find, and inherit,
which clears an override). Preview it first; nothing changes until you apply the preview:
curl -s "$PLUTO_API/ads/bulk/preview" -H "authorization: Bearer $PLUTO_API_KEY" \
-H 'content-type: application/json' \
-d '{"selector":{"launchId":"'"$LAUNCH_ID"'"},"operations":[{"field":"headlines","op":"set","value":["Big news"]}]}' \
| jq -c '{affected,changed,rejected,approvalResets,published,expiresAt}'
# {"affected":205,"changed":205,"rejected":0,"approvalResets":0,"published":0,"expiresAt":"2026-09-25T15:00:33.965161Z"}
curl -s "$PLUTO_API/ads/bulk/apply" -H "authorization: Bearer $PLUTO_API_KEY" \
-H 'content-type: application/json' -H 'idempotency-key: bulk-headlines-1' \
-d '{"snapshotToken":"'"$SNAPSHOT_TOKEN"'","operations":[{"field":"headlines","op":"set","value":["Big news"]}]}'
# {"changesetId":"01a0d8dd-fd66-7117-8a43-990437ecdaa1","applied":205,"unchanged":0,"conflicts":0,"rejected":0,"missing":0,"items":[],"jobId":"01a0d8dd-fd6b-7751-b94e-f6dae5eee6f3"}- The preview's
snapshotTokenpins the exact items it resolved, for 60 minutes. Apply sends it back with the same operations. The preview also returns before and aftersamples, andproblemsfor items that would be rejected. - A selector matches at most 10,000 items; more is a
422asking you to narrow it. - Items changed since the preview come back as conflicts in
items, never overwritten. An approved ad whose reviewed content changes goes back to review (approvalResets). A change to something already live in Meta becomes a pending revision (published), not a live change. - Apply commits in chunks of 100 items, each its own short transaction, as job
bulk_apply(jobId). The request waits up to 60 seconds; if chunks are still running then,pendingcounts them and the job reports the rest. - Every apply records a changeset.
GET /changesets/{id}shows who changed what, with before and after per field and item (entries).POST /changesets/{id}/revertrestores the items that haven't changed since, in chunks like an apply (jobbulk_revert); the others come back as conflicts. A revert never undoes what already happened in Meta.
Ad sets have the same pair at /ad-sets/bulk/preview and /ad-sets/bulk/apply. The MCP
preview_bulk_edit input schema lists every field an operation can change.
Launch specs
A launch spec is one document: the launch with its ad sets and ads, each with your own externalKey.
POST /launches/specs/validate is a dry run. It returns every error at once, the plan (what would be
created or updated) and the publish checklist, and changes nothing. POST /launches/specs/apply applies
the same document as one command: it creates what's new and updates what changed, matched by
externalKey. Applying the same spec twice changes nothing the second time.
Large specs can be sent as NDJSON (Content-Type: application/x-ndjson): a launch line first, then
adSet and ad lines in any order.
cat > spec.ndjson <<EOF
{"type":"launch","externalKey":"spring-2027","name":"Spring 2027"}
{"type":"adSet","externalKey":"us","name":"United States","targeting":{"countries":["US"]}}
{"type":"ad","externalKey":"hero-us","mediaId":"$MEDIA_ID","adSet":"us"}
EOF
curl -s "$PLUTO_API/launches/specs/validate" -H "authorization: Bearer $PLUTO_API_KEY" \
-H 'content-type: application/x-ndjson' --data-binary @spec.ndjson | jq -c '{valid,errors,plan}'
# {"valid":true,"errors":[],"plan":[{"kind":"launch","externalKey":"spring-2027","id":null,"action":"created"},{"kind":"adSet","externalKey":"us","id":null,"action":"created"},{"kind":"ad","externalKey":"hero-us","id":null,"action":"created"}]}
curl -s "$PLUTO_API/launches/specs/apply" -H "authorization: Bearer $PLUTO_API_KEY" \
-H 'content-type: application/x-ndjson' -H 'idempotency-key: spec-spring-2027-v1' \
--data-binary @spec.ndjson | jq -c '[.items[] | {kind,action}]'
# [{"kind":"launch","action":"created"},{"kind":"adSet","action":"created"},{"kind":"ad","action":"created"}]With a new idempotency key, the same spec reports every item as unchanged. Apply never publishes
anything. A spec is a draft, so it may still be incomplete for publishing: validate lists what
publishing would need in review (ad account, campaign, Facebook Page, budget) without failing the
spec.
A spec with up to 200 ad sets and ads applies inside the request: 200 with the launch ID and each
item's outcome. A larger spec (up to 10,000) is checked in full first, so an invalid one is still a
422 with every error and changes nothing. Then the launch is applied, the ad sets and ads are queued
as a job, and the answer is 202 at once:
curl -s "$PLUTO_API/launches/specs/apply" -H "authorization: Bearer $PLUTO_API_KEY" \
-H 'content-type: application/x-ndjson' -H 'idempotency-key: spec-big-v1' \
--data-binary @big.ndjson | jq -c '{jobId,launchId,items}'
# {"jobId":"01a0d8db-a993-7669-b72e-0794bb4ea345","launchId":"01a0d8db-a98c-7346-9615-a92e4b888035","items":[{"kind":"launch","externalKey":"big-2026","id":"01a0d8db-a98c-7346-9615-a92e4b888035","action":"created"}]}The job (kind apply_launch_spec) has one item per ad set and ad: ad sets first, then ads. Each
item's output is its outcome (kind, externalKey, id, action); a failed item says why in
error. New ad sets and ads keep the spec's order, whatever order the items finish in. Follow the job
with GET /jobs/{id}, the job.completed and job.failed webhooks, or MCP's wait_for_job. Retrying
the job applies its failed items again, matched by externalKey, so nothing is created twice. If the
key or person that applied the spec loses access, the items still queued fail instead of running.
The JSON form is the same document with adSets[] and ads[] arrays. The OpenAPI document types the
spec only as an object; the full field list is in the MCP validate_launch_spec input schema.
Money
Amounts are decimal strings in the ad account's currency: "49.99", never 49.99. A JSON number where
an amount belongs is a 400:
curl -s "$PLUTO_API/launches/specs/validate" -H "authorization: Bearer $PLUTO_API_KEY" \
-H 'content-type: application/json' \
-d '{"externalKey":"money","name":"Money","newCampaign":{"name":"Spring","budget":49.99}}' | jq -c '{status,title}'
# {"status":400,"title":"The spec isn't valid: invalid type: floating point `49.99`, expected a string at line 1 column 83"}null means unknown. It is never zero, and we never assume a currency. A draft may hold an invalid
amount such as "1,5"; publishing refuses it and the checklist says Enter a campaign budget, like 50 or 49.99. Amounts come back normalized: a budget sent as "40.00" reads back as "40". Keep amounts as
strings or decimals in your code too. Floats lose cents.
Publishing
Publishing creates campaigns, ad sets and ads in Meta that can spend money, so it has three guards:
- The credential needs the
publishpermission. A read-only key gets403You don't have permission to publish ads. - The request carries an explicit approval.
GET /launches/{id}/publishreturns what publishing would do (creates: campaigns, ad sets, ads, media), what still blocks it (items), and thelaunchVersion,adAccountIdandbudgetSummaryan approval must echo unchanged. Show those to the person who approves the spend before you send them back. Each item'spathnames the value to fix (adAccountId,newCampaign.budget,targeting.countries,overrides.beneficiaryon the ad set inadSetId,overrides.urlon the ad inadId), or isnullwhen the fix isn't a field. Only ad sets with ads, and those ads, are published:excludedcounts the ads without an ad set (ungroupedAds,ungroupedAdIds) and lists ad sets without ads (emptyAdSets). They aren't increatesorbudgetSummary, their gaps aren't initems, and moving an ad into an ad set includes both. Creating and editing drafts never waits for the checklist; only malformed input is refused. POST /launches/{id}/publishwithexpectedVersionand thatapprovalre-checks everything. A launch that isn't ready is a409with the checklist initems. Any change since the preview, including a budget change, is a409whosecurrentis the approval to review:
curl -s "$PLUTO_API/launches/$LAUNCH_ID/publish" -H "authorization: Bearer $PLUTO_API_KEY" \
| jq -c '{ready,launchVersion,adAccountId,budgetSummary,creates,excluded}'
# {"ready":true,"launchVersion":1,"adAccountId":"act_1234567890","budgetSummary":{"currency":"USD","campaign":{"period":"daily","amount":"40"},"adSets":[],"dailyTotal":"40","lifetimeTotal":null},"creates":{"campaigns":1,"adSets":1,"ads":1,"media":1},"excluded":{"ungroupedAds":0,"ungroupedAdIds":[],"emptyAdSets":[]}}
curl -s -X POST "$PLUTO_API/launches/$LAUNCH_ID/publish" -H "authorization: Bearer $PLUTO_API_KEY" \
-H 'content-type: application/json' -H 'idempotency-key: publish-autumn-stale' \
-d '{"expectedVersion":1,"approval":{"launchVersion":1,"adAccountId":"act_1234567890","budgetSummary":{"currency":"USD","campaign":{"period":"daily","amount":"30"},"adSets":[],"dailyTotal":"30","lifetimeTotal":null}}}' \
| jq -c '{status,title,current:(.current|keys)}'
# {"status":409,"title":"The launch changed since it was approved. Review it and approve it again.","current":["adAccountId","budgetSummary","launchVersion"]}
curl -s -X POST "$PLUTO_API/launches/$LAUNCH_ID/publish" -H "authorization: Bearer $PLUTO_API_KEY" \
-H 'content-type: application/json' -H 'idempotency-key: publish-autumn-1' \
-d '{"expectedVersion":1,"approval":{"launchVersion":1,"adAccountId":"act_1234567890","budgetSummary":{"currency":"USD","campaign":{"period":"daily","amount":"40"},"adSets":[],"dailyTotal":"40","lifetimeTotal":null}}}'
# {"jobId":"01a0d8dc-dda5-7014-9fe0-3f9ece67fd88"}An accepted publish returns 202 with a job (publish_launch). Nothing is live until the job reads the
objects back from Meta. GET /launches/{id}/publication shows the phase (draft, scheduled,
publishing, published, partially_published, failed) and every object with its Meta ID, configured
and effective status, review feedback and errors:
curl -s "$PLUTO_API/launches/$LAUNCH_ID/publication" -H "authorization: Bearer $PLUTO_API_KEY" \
| jq -c '{phase,counts,campaign:(.objects[0]|{entity,providerId,configuredStatus,state,budget})}'
# {"phase":"published","counts":{"total":3,"live":3,"failed":0,"skipped":0,"queued":0,"ads":1,"adsLive":1},"campaign":{"entity":"campaign","providerId":"120212345678900001","configuredStatus":"PAUSED","state":"live","budget":{"amount":"40","period":"daily","currency":"USD"}}}Every object is created paused. What settings.delivery (or an ad set's override) sets to Active is
switched on only after everything exists: each complete ad set (its ads first, then the ad set), then the new
campaign last, so a half-published launch never spends. An ad set whose ads weren't all created stays paused
and is listed in progress.held with why. Before any ad set exists, Meta checks every ad set without creating
it (validate_only); if Meta refuses one, nothing more is created: that ad set carries Meta's reason and
everything else error.code not_validated. Meta's limits are in the checklist: 200 ad sets per campaign, 50
ads per ad set, 5,000 ad sets and 5,000 ads per ad account. Retrying with the same idempotency key never
publishes twice, and a create whose answer was lost is looked up in Meta before it is sent again.
progress in the publication says how far the latest publish is: stage (uploading, checking,
creating, reconciling, activating, reading_back, done), adSets and ads as
{created, failed, total}, media, waiting ({reason, until, kind} when nothing can run until until:
throttled when Meta asked to slow down or usage is near Meta's limit, processing for a video, retry),
failures ([{entity, id, state, code, message, field}]), held and reconciled (what the comparison with
Meta found: {checked, adopted, strays, missing}; objects nobody planned are paused, never deleted). Publishing
paces itself to each ad account's usage as Meta reports it, shared by every publish on the account.
curl -s "$PLUTO_API/launches/$LAUNCH_ID/publication" -H "authorization: Bearer $PLUTO_API_KEY" \
| jq -c '.progress | {stage,adSets,ads,waiting}'
# {"stage":"creating","adSets":{"created":143,"failed":0,"total":200},"ads":{"created":812,"failed":0,"total":1000},"waiting":null}POST /launches/{id}/publish/cancel stops a publish that hasn't
finished, such as a scheduled one (scheduleAt); objects already created in Meta stay live.
POST /launches/{id}/readback reads the published objects back from Meta now. A failed object's error
has a code, a message and the field to fix (an API path such as settings/page), so you can fix it
and retry the job.
The checklist also refuses an ad account Meta reports as disabled, unsettled (unpaid balance), closing or
closed (account_status 2, 3, 100, 101 in the
Ad Account reference), with
what to do. An account that changes after the approval stops the job before anything is created: the
objects fail with error.code account_unavailable.
Video ads. The job's media stage uploads each video once per ad account: the poster frame as an ad
image (image_hash, the creative's thumbnail), then the file in the ranges Meta asks for
(POST /act_{id}/advideos with upload_phase start, transfer, finish,
advideos reference).
An interrupted upload resumes from the last range Meta confirmed. The item then waits until Meta reports
status.video_status ready
(video status); while it waits
it is queued with error "Waiting for Meta to process the video (N%).", and waiting spends no
attempts. It fails after 2 hours of processing, or with Meta's reason when processing fails; the ads
using the video then fail on mediaId, and retrying the job or publishing again uploads the video anew.
The media item's output is {videoId, thumbnailHash}, or {videoId, thumbnailUrl} when the video has
no poster frame and the creative uses Meta's preferred thumbnail. The creative carries
object_story_spec.video_data (video_id, the thumbnail, message, title, link_description,
call_to_action). On a live ad, a new mediaId is a pending change that publishes a new creative, which
Meta reviews again.
When Meta stops accepting access, a published object's error (code reauthorize) and
GET /integrations/meta say why without parsing text: reason is expired (the token is gone) or
permission (Meta no longer grants one), and permission / missingPermission names the missing Meta
permission (e.g. ads_management) when Meta named one.
Live controls. These act on what is live in Meta, need publish, and answer 202 with a job and
availableAt, when the change runs:
| Route | Body |
|---|---|
POST /launches/{id}/campaign/status, POST /ad-sets/{id}/status, POST /ads/{id}/status | {"status":"ACTIVE"} or {"status":"PAUSED"} |
PATCH /launches/{id}/campaign/budget, PATCH /ad-sets/{id}/budget | {"amount":"45.00"}, a decimal string in the account currency |
PATCH /ad-sets/{id}/bid | {"bidAmount":"2.50"} (bid cap or cost per result goal) or {"roasGoal":"2.5"} |
PATCH /ad-sets/{id}/schedule | {"startTime":...,"endTime":...}, or {"schedule":[...],"scheduleTimezone":"viewer"} in a separate call |
curl -s -X PATCH "$PLUTO_API/launches/$LAUNCH_ID/campaign/budget" -H "authorization: Bearer $PLUTO_API_KEY" \
-H 'content-type: application/json' -H 'idempotency-key: budget-45' -d '{"amount":"45.00"}'
# {"jobId":"01a0d8dd-0750-7486-b1ca-3923ef0a7e72","availableAt":"2026-09-25T13:59:19.120194Z"}Meta limits how often a budget can change, so a budget change over that limit waits: availableAt says
until when. A change Meta refuses is recorded on the object as changeError (code, field,
metaField, message, jobId) in the publication.
Any Meta object, by its Meta ID. The same controls work for every campaign, ad set and ad the sync
lists (GET /integrations/meta/campaigns|ad-sets|ads), including ones created in Meta Ads Manager and never
imported. They take the Meta ID in the path, need publish and answer 202 with a job. An object that
belongs to a launch changes through the launch, exactly like the routes above, so the launch never sends the
old value back. Anything else changes in Meta directly, and what Meta reports afterwards shows up in the
synced lists at once, without waiting for the next sync.
| Route | Body |
|---|---|
POST /integrations/meta/campaigns/{id}/status, .../ad-sets/{id}/status, .../ads/{id}/status | {"status":"PAUSED"} |
PATCH /integrations/meta/campaigns/{id}/budget, .../ad-sets/{id}/budget | {"amount":"120"}: the budget keeps its period (daily or lifetime); a campaign without a campaign budget, or an ad set that uses one, is a 409 |
PATCH /integrations/meta/ad-sets/{id}/bid | {"bidAmount":"2.75"}, {"roasGoal":"1.5"}, optionally with bidStrategy |
PATCH /integrations/meta/ad-sets/{id}/schedule | {"startTime":...,"endTime":...,"schedule":[{"days":[1,2,3,4,5],"startMinute":540,"endMinute":1020}],"scheduleTimezone":"account"} |
POST /integrations/meta/ad-sets/{id}/delete, .../ads/{id}/delete | none (optional expectedVersion for an ad set or ad a launch manages); irreversible |
POST /integrations/meta/campaigns/{id}/copies, .../ad-sets/{id}/copies, .../ads/{id}/copies | {"deepCopy":true,"statusOption":"PAUSED","renameOptions":{"strategy":"ONLY_TOP_LEVEL_RENAME","suffix":" - Copy"}} |
curl -s -X PATCH "$PLUTO_API/integrations/meta/ad-sets/120210000000000021/bid" -H "authorization: Bearer $PLUTO_API_KEY" \
-H 'content-type: application/json' -H 'idempotency-key: bid-275' -d '{"bidAmount":"2.75"}'
# {"jobId":"01a0dca4-44d9-76d6-8391-2e7ddbaca7e5","availableAt":"2026-09-26T09:12:03.412Z"}Bids follow Meta's rules for the ad set's bid strategy: a bid cap (LOWEST_COST_WITH_BID_CAP) or cost per
result goal (COST_CAP) takes bidAmount, a decimal string in the ad account currency; a ROAS goal
(LOWEST_COST_WITH_MIN_ROAS) takes roasGoal (at most four decimals, 0.01 to 1000); highest volume
(LOWEST_COST_WITHOUT_CAP) takes neither. bidStrategy switches the strategy too, only for an ad set with its
own budget that isn't in a launch: with a campaign budget the campaign holds the strategy (a 409), and a
launch sets its own. A value that doesn't fit the strategy is a 422 naming the field.
Ad scheduling (schedule) sets the hours an ad set delivers per day part: days 0 (Sunday) to 6 (Saturday),
start and end on the hour and at least an hour apart. Meta only schedules ad sets with a lifetime budget (the
ad set's or its campaign's); otherwise it's a 409. An empty list turns it off. scheduleTimezone is
viewer (each person's time zone, the default) or account. For a launch's ad set, startTime and
endTime change the launch and schedule goes to Meta; send them in separate calls.
Copies start paused unless statusOption is ACTIVE or INHERITED_FROM_SOURCE. deepCopy copies an ad
set's ads, or a campaign's ad sets and ads. Meta copies at most 3 ads in one call, so a larger deep copy is
made level by level in the same job: first the campaign or ad set, then each ad set into the new campaign,
then each ad into its new ad set. intoAdSetId (ads) and intoCampaignId (ad sets) copy into another
parent in the same ad account. The job's items name each copy (output.copiedId), and every copy is in the
synced lists as soon as it exists. A retry never copies twice: when Meta's answer is lost, the job looks for
the copy in Meta before it asks again.
Live targeting. PATCH /ad-sets/{id}/targeting changes who a live ad set reaches, and
PATCH /integrations/meta/ad-sets/{id}/targeting does the same for any synced Meta ad set by its Meta ID,
including ones created in Ads Manager. Both need publish, because they change delivery. Name only what
changes; each field you send replaces its live value:
| Field | Value |
|---|---|
locations, excludedLocations | countries (ISO codes), regions and zips ([{"key": ...}] from Meta's targeting search), cities ([{"key": ..., "radius": 25, "distanceUnit": "mile"}]) |
minAge, maxAge | 13-65 (65 means 65+) |
gender | All, Men or Women |
placements | {"advantage": true}, or {"advantage": false, "platforms": [...], "positions": [...]} |
customAudiences, excludedAudiences | Audience IDs from GET /integrations/meta/ad-accounts/{id}/audiences |
detailedTargeting | Interests, behaviours and demographics as groups: [[{"id": ..., "type": "interests"}, ...], [...]]. People match any item in a group and every group, so each further group narrows the audience (up to 25 groups). [] removes it |
detailedExclusions | Only [], which removes the ad set's detailed targeting exclusions |
beneficiary, payer | The EU advertiser names, together, for ad sets that reach the EU |
An ad set a launch manages changes in its draft first, so a later publish never reverts it, and takes
locations as countries; when its live targeting has what Pluto can't represent (regions, cities, ...), the
change goes to Meta's own targeting instead (appliedThrough: meta). A Meta ad set changes on Meta's own
targeting: what you don't name stays as it is. Both answer 202 with the jobId, availableAt,
appliedThrough (draft or meta), metaAdSetId, the fields changed and the campaign's
specialAdCategories; the job reads
the ad set and its campaign again right before writing, then reads the change back.
curl -s -X PATCH "$PLUTO_API/ad-sets/$AD_SET_ID/targeting" -H "authorization: Bearer $PLUTO_API_KEY" \
-H 'content-type: application/json' -H 'idempotency-key: targeting-nordics' \
-d '{"locations":{"countries":["DK","SE","NO"]},"minAge":25,"maxAge":54}' | jq -c '{jobId,appliedThrough,specialAdCategories}'
# {"jobId":"01a0d9f4-6e21-7a08-b3c5-2f9d1e7c4a50","appliedThrough":"draft","specialAdCategories":[]}Find detailed targeting with GET /integrations/meta/ad-accounts/{id}/targeting-search?q=running (read):
each match has Meta's id and type, its taxonomy path, a category (interest, behaviour or
demographic) and Meta's size estimate (null when Meta gives none). Filter with type. Meta's search
isn't exhaustive, so try other words.
curl -s "$PLUTO_API/integrations/meta/ad-accounts/$AD_ACCOUNT/targeting-search?q=running" \
-H "authorization: Bearer $PLUTO_API_KEY" | jq -c '.items[] | {id,name,type}'
# {"id":"6003107902433","name":"Running","type":"interests"}
# {"id":"6003384248805","name":"Trail running","type":"interests"}Meta no longer accepts new detailed targeting exclusions: since Marketing API v22.0 it refuses them for
every ad set. Exclude a custom audience with excludedAudiences instead. An ad set a launch manages takes
one group of interests.
An ad set that reaches the EU needs the advertiser (beneficiary) and payer names. They can come from the
change, from the ad set, or from the ad account's defaults. When none of those has them, the change is a
409 with reason: eu_advertiser_required. missing names what's missing, and fix links to where it's
set: the ad set in Pluto for one a launch manages, Meta Ads Manager otherwise. Send beneficiary and
payer with the change to fix it in one step.
Meta's special ad category rules are checked before anything is queued, and a refusal is a 422 naming
the field. Housing, employment and financial products and services ads keep ages 18-65+ and every
gender. They take no excluded locations and no postal codes or other small areas, and city radii must be
at least 15 miles (25 km), or 15 km in Europe. Lookalike audiences aren't allowed. Meta no longer offers
Special Ad Audiences, so a lookalike can't be swapped for one; use a custom audience instead. These ads
also take no behaviours, demographics or detailed exclusions, only interests on Meta's approved list:
targeting-search with specialAdCategory=HOUSING (or the campaign's category) returns just those.
Social issue, election and political ads can't add EU countries.
Meta's rules
GET /integrations/meta/ad-accounts/{id}/audiences (read) lists an ad account's custom and lookalike
audiences, read from Meta when you ask. Each has its kind (custom, lookalike or other) and Meta's size
estimate (null when Meta can't estimate it). specialAdCategoryAllowed says whether housing, employment
and financial ads may use it. Filter with subtype.
Pending revisions. Editing something that is live doesn't change Meta. The edit waits beside the live version as a pending revision, per object and field, until someone publishes it:
curl -s "$PLUTO_API/launches/$LAUNCH_ID/revisions" -H "authorization: Bearer $PLUTO_API_KEY" \
| jq -c '{revisionsVersion:.revisionsVersion[0:8],change:.objects[0].changes[0]|{field,live,pending,changeable,review}}'
# {"revisionsVersion":"a4a82a5c","change":{"field":"headline","live":"New season","pending":"Autumn is here","changeable":true,"review":true}}changeable says whether Meta can change the field on a live object; review: true means the change
sends the ad back to Meta's review, and learning: true that it restarts Meta's learning phase for the
object's learningAdSets (targeting, creative, optimization and bid strategy changes, and budget, bid or
ROAS goal changes of 50% or more; Meta counts these as significant edits and names no exact amount).
impact counts both for every pending change: {"backToReview": 12, "restartsLearning": 3} (ads, ad sets).
While changes are on their way, applying shows the progress per level, for example
"ads": {"done": 143, "failed": 0, "total": 400}.
POST /launches/{id}/revisions/publish (publish) applies changes Meta can make, one call per object, as
job apply_revisions; POST /launches/{id}/revisions/discard (write) reverts them. Send the
revisionsVersion you reviewed to act on every change, or changes to act on some: each item names an
object (id, with the object's version) or one field of it (id, field, with the change's
version). Everything you leave out stays pending:
curl -s -X POST "$PLUTO_API/launches/$LAUNCH_ID/revisions/publish" -H "authorization: Bearer $PLUTO_API_KEY" \
-H "content-type: application/json" -H "idempotency-key: $(uuidgen)" \
-d '{"changes":[{"id":"'$AD_ID'","version":"3f9c0a1e2b7d4c55"},{"id":"'$AD_SET_ID'","field":"budget","version":"91be20c4d0a37f18"}]}' \
| jq -c '{jobId:.jobId[0:8],impact}'
# {"jobId":"0192f3a1","impact":{"backToReview":1,"restartsLearning":1}}If a chosen change was edited since you read it, the answer is 409 naming it in stale, with the
current revisionsVersion; the same goes for a stale revisionsVersion. Changes Meta can't take come
back as blocked or, when the value is invalid, as problems. Publishing everything, an object with a
blocked change waits whole; chosen changes publish on their own. With changes, discard puts one object
or one field back to its live value; a change that comes from the launch settings (the object has no
difference of its own, revertable: false) can't be reverted on one object alone and answers 409.
Removing what is live. A live ad or ad set can't be deleted from the draft: DELETE answers 409
(This ad is live in Meta. Deleting it here would leave it running and spending. Pause it, or delete it in Meta, which removes it from Meta first and then from the launch.). Pausing is the reversible
alternative. POST /ads/{id}/meta-delete and POST /ad-sets/{id}/meta-delete (publish,
expectedVersion) are irreversible: the job (delete_at_meta) deletes the object in Meta (an ad set
with its live ads), reads it back as deleted and then archives it in the launch.
Archived items, and ads with comments or activity deleted with DELETE /ads/{id}, stay as read-only
history: GET /launches/{id}/archived lists them, their comments and activity stay readable, and every
change to them is a 409 (This ad is archived. Restore it to change it.). POST /ads/{id}/restore and
POST /ad-sets/{id}/restore (write, optional expectedVersion) put them back as drafts: the same item
when it never reached Meta, a new draft copy (copy: true) when Meta deleted it, so the original keeps
its Meta record and its results.
Archiving a launch changes nothing in Meta: its live objects keep running until you pause or delete them.
Meta integration
Connecting Meta is Facebook Login in a browser (GET /integrations/meta/connect, admin), so it happens
in the app. An agent acting for a person can start it with POST /integrations/meta/connect, which returns a
one-time authorizationUrl (15 minutes) for that person to open, signed in to Pluto Ads (reconnect: true
signs in again); an API key gets 403. GET /integrations/meta returns the connection status (connected, reauthorize,
disconnected), the reason to reconnect, the last sync, and the ad accounts with currency, timezone,
account status and the Pages each may publish from. PUT /integrations/meta/ad-accounts/{id}/pages
(admin) sets those Pages. POST /integrations/meta/sync (write) queues a sync of ad accounts, Pages,
campaigns, ad sets and ads, or returns the one already queued or running. The sync runs on connect and
when someone asks for it; insights have their own hourly import (see Analytics).
POST /integrations/meta/disconnect (admin) revokes our access in Meta when possible and deletes the
stored token; launches and synced data stay.
Spending limit. Each ad account carries spendingLimit: Meta's account spending limit (amount, a decimal
string in the account currency, or null for no limit), what was spent toward it (in total without one) and
readAt; spendingLimit is null until Meta has reported it. When the account has spent the limit, Meta stops
every ad in it, whatever any budget, loop or person says. It syncs with the ad accounts.
PUT /integrations/meta/ad-accounts/{id}/spending-limit (admin) sets or changes it ({"amount": "5000.00"}) or
removes it ({"amount": null}; amount is required, so an empty body never removes one). A new limit counts spend
from the moment it's set. The answer is 202 with the job applying it: the job reads Meta first, writes only when
the limit differs, reads it back and stores what Meta reports. A retry with the same Idempotency-Key returns the
same job, and repeating a limit Meta already has sends nothing to Meta. One change per ad account runs at a time
(another is a 409), and it needs the ads_management permission in Meta.
curl -s -X PUT "$PLUTO_API/integrations/meta/ad-accounts/$AD_ACCOUNT/spending-limit" \
-H "authorization: Bearer $PLUTO_API_KEY" -H "idempotency-key: $(uuidgen)" \
-H "content-type: application/json" -d '{"amount": "5000.00"}'missingPermissions lists permissions Pluto asks for that the connection wasn't granted (for example
pages_manage_engagement, which comment moderation needs). The connection keeps working;
what those permissions power stays off until an admin reconnects Meta and grants them.
Meta itself calls two public routes, which accept only a signed_request signed with Pluto's Meta app secret
and answer 400 to anything else. POST /integrations/meta/deauthorize runs when a person removes Pluto Ads in
their Facebook settings: every workspace they connected is disconnected, as with disconnect.
POST /integrations/meta/data-deletion runs when they ask Facebook to have their data deleted: their token and
connection details (Meta user ID, name, granted permissions) are deleted in every workspace, and the workspace
shows Meta as not connected. GET /integrations/meta/data-deletion/{code} returns the request's status for the
confirmation code Facebook showed them.
GET and POST /integrations/meta/webhook are Meta's webhook endpoint for comment changes: the GET answers
Meta's verification handshake, and a POST counts only with an X-Hub-Signature-256 signed with the app secret
(403 otherwise). A delivery only tells Pluto which posts to read again; comments always come from the Graph
API.
Meta structure and "Import from Meta"
Connecting Meta syncs the structure of every ad account as read-only metadata: campaigns
(GET /integrations/meta/campaigns), ad sets (GET /integrations/meta/ad-sets) and ads
(GET /integrations/meta/ads), keyset-paged by Meta ID. Campaigns filter by ad account (required) and
status; ad sets and ads by adAccountId, campaignId, status and search (part of the name, or the
exact ID), and ads also by adSetId. Budgets (dailyBudget, lifetimeBudget) are decimal strings in the
account currency; null means Meta reported none, never zero. On ad sets and ads, launchId names the
launch that manages the object, if any.
Thumbnails are Meta-hosted links. The sync never creates launches and never downloads creative files. Use
the IDs in analytics filters (adSetId) and for imports.
Every object also carries what loops and agents test before they act, as the sync last read it from Meta:
createdTime and ageDays (whole days since then), adAccountName, the campaign's
specialAdCategories ([] when it declares none), and deliveryIssues (errorCode, errorMessage,
errorSummary, errorType, level; errorType HARD_ERROR stops an ad's delivery) with
hasDeliveryIssue. smartPromotionType is the campaign's type as Meta reports it (GUIDED_CREATION, or
AUTOMATED_SHOPPING_ADS / SMART_APP_PROMOTION for legacy Advantage+ shopping and app campaigns), and
changesBlocked says why nothing can change the object through Meta's API: Meta no longer lets its API
update legacy Advantage+ shopping and app campaigns or anything in them. It is null when changes work. Ad
sets add their learning phase (learningStatus: LEARNING, SUCCESS once out of
learning, FAIL when learning is limited; learning has the conversions so far and the last significant
edit) and placements (placements, publisherPlatforms, and automaticPlacements when Meta chooses). Ads
add creativeType (Meta's creative type: PHOTO, VIDEO, SHARE for a link ad, ...) and show their ad
set's learning phase and placements. null means not read yet, never "none". Meta doesn't change an
object's update time when its learning phase or issues change, so every sync reads both again for every
delivering ad set and ad.
curl -s "$PLUTO_API/integrations/meta/ad-sets?adAccountId=act_1234567890" -H "authorization: Bearer $PLUTO_API_KEY" \
| jq -c '.items[] | {name,learningStatus,hasDeliveryIssue,ageDays,publisherPlatforms}'
# {"name":"United States","learningStatus":"FAIL","hasDeliveryIssue":false,"ageDays":24,"publisherPlatforms":["facebook","instagram"]}curl -s "$PLUTO_API/integrations/meta/ad-sets?adAccountId=act_1234567890" -H "authorization: Bearer $PLUTO_API_KEY" \
| jq -c '.items[] | {id,name,campaignId,dailyBudget,currency,launchId}'
# {"id":"120212345678900002","name":"United States","campaignId":"120212345678900001","dailyBudget":"40.00","currency":"USD","launchId":"01a0d8dc-a033-743f-921b-79f50a719a05"}
# {"id":"120209876543210002","name":"Broad","campaignId":"120209876543210001","dailyBudget":null,"currency":"USD","launchId":null}Nothing turns into a launch by itself. To manage live Meta campaigns or ad sets in Pluto, import them:
curl -s -X POST "$PLUTO_API/integrations/meta/imports/preview" -H "authorization: Bearer $PLUTO_API_KEY" \
-H 'content-type: application/json' -d '{"adAccountId":"act_1234567890","campaignIds":["120209876543210001"]}' \
| jq -c '.items[0] | {kind,name,adSets,ads,launchId}'
# {"kind":"campaign","name":"Spring sale","adSets":1,"ads":1,"launchId":null}
curl -s -X POST "$PLUTO_API/integrations/meta/imports" -H "authorization: Bearer $PLUTO_API_KEY" \
-H 'content-type: application/json' -H 'idempotency-key: import-spring' \
-d '{"adAccountId":"act_1234567890","campaignIds":["120209876543210001"]}' | jq -c '{jobId}'
# {"jobId":"01a0d8e0-eda4-776a-9550-0f73960956ab"}
curl -s "$PLUTO_API/integrations/meta/imports/$JOB_ID" -H "authorization: Bearer $PLUTO_API_KEY" \
| jq -c '{status,result:(.items[0].result | {launchId,action,ads:(.ads|length),notImported})}'
# {"status":"succeeded","result":{"launchId":"01a0d8e0-ef14-7712-89ec-966a2083e180","action":"created","ads":1,"notImported":[]}}The preview (read) changes nothing; it also says which picks refresh an earlier import (launchId)
and which another launch already manages (linkedLaunchId). Each imported pick (write, job
meta_import) becomes a launch linked to the live objects, with the live version read back from Meta: it
reads as published, and live controls and pending revisions work as on a launch Pluto published. Values
equal across the imported ad sets and ads become launch settings; the rest become overrides. Anything
Pluto can't represent (dynamic creative, asset customization, targeting or Advantage+ creative
enhancements it doesn't model, and similar) is listed per object as managedInMeta
[{field, metaField, reason}] in the report and in GET /launches/{id}/publication. It's shown
read-only and never written back: revisions hold such changes, and live controls answer 409 with
managedInMeta. Only the imported ads' images and videos are copied into the media library
(deduplicated by SHA-256). Ads Pluto can't hold (carousels, existing posts, catalog ads) stay in Meta and
are listed in notImported with the reason. Importing the same pick again refreshes its launch; it's
refused while that launch has pending changes.
Comments
The comments people leave on the posts your Meta ads run as: the ad's Facebook Page post and its Instagram media. Pluto reads them from Meta and you reply, hide, unhide or delete them here. This isn't the team's conversation on an ad in Pluto (see Collaboration).
GET /social/comments (read) lists top-level comments by other people, newest first. Each has its
replies (oldest first), author (name and id, null when Meta doesn't share them), platform
(facebook or instagram), the ad its post runs as (Meta ad ID, name, and the launchId that manages
it), hidden, answered (your Page or Instagram account replied in the thread) and what's possible now
(canReply, canHide, canDelete). Filter with platform, hidden=true|false, unanswered=true,
adId (Meta ad ID), postId or search; page with after and limit (1-200, default 50). access says
which platforms are read (status: ready, partial, missing_permissions, reauthorize,
disconnected), which Meta permissions are missing, and when comments were last read. GET /social/comments/{id} returns one comment; a deleted one is 404.
curl -s "$PLUTO_API/social/comments?unanswered=true&limit=2" -H "authorization: Bearer $PLUTO_API_KEY" \
| jq -c '.items[] | {id,platform,author:.author.name,message,ad:.ad.name}'
# {"id":"01a0d8e2-4c11-7a3e-9d0f-3c5b7e2a9f10","platform":"instagram","author":"maya.runs","message":"Does it come in blue?","ad":"Spring sale US"}
# {"id":"01a0d8e2-4c0e-7b21-8e44-9a1f0c6d2b77","platform":"facebook","author":"Tom Berg","message":"Shipping to Canada?","ad":"Spring sale US"}Replying and moderating act publicly as your Page or Instagram account, so they need publish:
POST /social/comments/{id}/replieswith{"message": "..."}(1-2,200 characters) replies in the comment's thread. A reply to a reply goes to the thread's top-level comment, as on Meta. Instagram doesn't take replies to hidden comments.POST /social/comments/{id}/hideand/unhidehide a comment from everyone but its author and your Page's admins, and show it again. Your own Page's comments can't be hidden.DELETE /social/comments/{id}deletes the comment and its replies in Meta. It can't be undone; confirm with the person first, or hide it instead.
Each answers 202 with the queued action (kind, status: pending, jobId, actor) and the comment as
it stands. Meta usually confirms within seconds: the comment's pending empties, and a reply appears in the
thread with sentBy, a hide or delete with moderated (the person, agent or key that asked). A refusal
leaves the action failed with a readable error, shown as the comment's failed. Hiding a hidden comment
(or unhiding a visible one) answers 200 with action: null. The Idempotency-Key makes each command
safe to retry: a retried reply is never posted twice.
curl -s -X POST "$PLUTO_API/social/comments/$COMMENT_ID/replies" -H "authorization: Bearer $PLUTO_API_KEY" \
-H 'content-type: application/json' -H 'idempotency-key: reply-blue' \
-d '{"message":"Yes, blue ships next week."}' | jq -c '{action:.action|{kind,status},pending:(.comment.pending|length)}'
# {"action":{"kind":"reply","status":"pending"},"pending":1}409 means the comment was deleted, another hide, unhide or delete is still pending, Meta doesn't allow it
(an Instagram comment that is hidden, your Page's own comment), or the Meta connection lacks a permission:
then reason is meta_permissions and missingPermissions names what an admin grants by reconnecting Meta.
Pluto reads comments every few minutes while an ad can deliver, and every few hours for other ads. Pages
that send Meta webhooks update within seconds. POST /social/comments/sync (write) reads now and returns
the job (or the one already queued).
Leads
The leads people submit through the lead forms of your connected Facebook Pages. New leads reach Pluto
within seconds through Meta's leadgen webhook. Pluto also reads each form's new leads on a schedule, so
leads a webhook missed (an outage on either side, a Page subscription removed in Meta) still arrive, once
each. Reading leads needs Meta's leads_retrieval permission; without it the rest of the Meta connection
keeps working and access.status is missing_permissions.
GET /leads/forms (read) lists the forms: Meta form id, pageId and pageName, name, status
(ACTIVE, ARCHIVED, DELETED or DRAFT), questions (key, label, type), Meta's leadsCount,
the storedLeads Pluto holds and leadsThrough (leads created up to then have been read). Meta keeps
leads readable for 90 days, so a newly connected Page brings the last 90 days.
GET /leads (read) lists leads newest first, without their answers: Pluto id, Meta metaId, the
form, the ad it came through (null for organic leads and form previews), platform, createdTime
and fields, the question keys the person answered. Filter with formId, pageId, adId (Meta IDs) or
since (RFC 3339); page with after and limit (1-200, default 50).
curl -s "$PLUTO_API/leads?limit=2" -H "authorization: Bearer $PLUTO_API_KEY" \
| jq -c '.items[] | {id,form:.form.name,ad:.ad.name,fields,createdTime}'
# {"id":"01a0d9f1-2c4e-7b10-9a3d-5e8f1c2b7a64","form":"Free quote","ad":"Spring sale US","fields":["email","full_name"],"createdTime":"2026-09-26T09:41:07Z"}
# {"id":"01a0d9ef-8b12-7c55-a1e0-4d2f9b6c3e18","form":"Free quote","ad":null,"fields":["email","phone_number"],"createdTime":"2026-09-26T08:15:52Z"}GET /leads/{id} returns one lead with its answers (name, the form's label for it, values) and
disclaimers (Meta's custom disclaimer responses, as sent). Answers are personal data about someone who
isn't a Pluto user: Pluto stores them encrypted, returns them only here, needs write for it (guests and
read-only keys get 403), and records each read in the workspace's audit log.
curl -s "$PLUTO_API/leads/$LEAD_ID" -H "authorization: Bearer $PLUTO_API_KEY" | jq -c '.answers[] | {label,values}'
# {"label":"Email","values":["maya@example.com"]}
# {"label":"Full name","values":["Maya Lind"]}Every new lead fires the lead.received webhook event once, with IDs only (leadId, metaLeadId,
pageId, formId, formName, adId, adSetId, campaignId, isOrganic, createdTime, via):
read the answers with GET /leads/{id}. The Loops "New lead" trigger follows the same event.
POST /leads/sync (write) reads every Page's forms and new leads now and returns the job (or the one
already queued); 409 with reason: meta_permissions means the connection lacks a lead permission.
Analytics
Performance comes from Meta insights we import into our own store; reads never call Meta.
GET /analytics/insights takes a period (since, until, inclusive days in each ad account's
timezone), an attribution setting (7d_click_1d_view, 7d_click, 1d_click or 1d_view), an
optional comparison period (compare_since, compare_until), a breakdown (launch, campaign,
adSet, ad) and filters that repeat (campaignId=A&campaignId=B; also adAccountId, adSetId,
adId, launchId, and by name account, campaign, adSet and launch), plus one currency.
Totals never mix currencies. With one currency in scope, currency names it and metrics holds the
totals. With several, currency is null, currencies lists them and byCurrency holds the totals of
each; we never convert. Each metric has its value and, with a comparison, comparisonValue and
change (absolute, and relative as a fraction). change is absent unless both periods have a value,
and relative is absent when the previous value is zero. Ratios such as ROAS are computed per period from
the summed parts, not averaged:
curl -s "$PLUTO_API/analytics/insights?since=2026-09-24&until=2026-09-25&compare_since=2026-09-22&compare_until=2026-09-23&attribution=7d_click_1d_view" \
-H "authorization: Bearer $PLUTO_API_KEY" \
| jq -c '{currency,spend:(.metrics.spend|{value,comparisonValue,change}),roas:(.metrics.roas|{value,comparisonValue,change})}'
# {"currency":"EUR","spend":{"value":"380.14","comparisonValue":"273.19","change":{"absolute":"106.95","relative":"0.391486"}},"roas":{"value":"9.002183","comparisonValue":"14.371207","change":{"absolute":"-5.369023","relative":"-0.373596"}}}Every response carries coverage, so you can tell "no results"
from "not imported yet":
state:fresh(imported),importing,notImported(the period isn't imported yet) ornoDelivery(imported, and Meta reported no delivery).freshAsOfand, per ad account,coveredThrough,importingandlastError.history: how far back insights reach. On connect we import the last 90 days first, then older history newest first, up to 37 months, in the background.from,complete,coveredDaysoftotalDaysandnextAtsay where that stands per account.notYetImportedandmissingAccountsname what the period is missing;importJobIdnames a running import.
notIngested lists breakdowns we don't import yet (for example spend by placement), so they aren't
shown as zero.
Besides spend, delivery, clicks, purchases and video, metrics has results (what the campaign's objective
counts, such as purchases or leads) and cost_per_result, leads and cost_per_lead, outbound_clicks,
and video_plays_25, video_plays_50, video_plays_75 and video_plays_95 (plays that reached that
share of the video). Results of different kinds never add up: when the period mixes, say, purchases and
leads, results and cost_per_result have no value. reach and frequency count people, so they
don't add up across days, ads or accounts either. They have a value only when the filters come down to
one ad account, campaign, ad set or ad, and the period is one Meta reports reach for: today, yesterday, the
day before yesterday, the last 3, 7, 14, 28, 30 or 90 days, the 7, 14 or 30 days before those, last week, last month or this month.
A single ad account also gets its daily reach as a series.
Imports run every hour per connected workspace, after a Meta sync, and for the ad account of a launch
that went live. POST /analytics/refresh (write, a command) imports now: it returns
202 {"jobId","queued"}, hands back the import already queued or running (queued: false) instead of
starting another, and starts at most 2 new imports per workspace every 15 minutes (429 with
Retry-After beyond that). It answers 409 when Meta isn't connected or shares no ad accounts.
GET /analytics/launches/{launch_id} is one launch's results in the same shape plus launch, broken
down by ad set unless breakdown says otherwise. Without a period it covers the launch's whole delivery;
with nothing delivered yet, period is null and coverage.state says why.
The dashboard layout is GET, PUT and DELETE on /analytics/dashboard. PUT {sections, expectedVersion} replaces the whole layout for the workspace: sections and cards you leave out are
removed, and there is no partial update. DELETE goes back to the default layout; send
{"expectedVersion"} so a layout saved since you read it isn't dropped. A stale version is a 409
with the current layout and saves nothing. Every response includes defaultSections, the layout a
reset restores. The app works the same way: customize edits a local draft and saves it in one write
on Done.
Profit (Pluto Profit)
When a workspace connects its Pluto Profit store, the API can say whether ads make money, not only what they sell. A revenue ROAS of 3 still loses money when product costs, fees and shipping take more than two thirds of revenue; profit is what's left after them. Every figure comes from Pluto Profit's public API and is read-only: Pluto Ads never changes anything in Pluto Profit.
Connecting. An admin connects one store per workspace, the way Meta and Slack connect: they approve read
access in Pluto Profit, where they sign in, pick the store and allow Pluto Ads to read profit, orders, products
and costs. In a browser, GET /integrations/pluto-profit/connect?returnTo=/integrations sends them there and
back to the app with profit=connected (or profit=error&reason=...). From the API or an agent,
POST /integrations/pluto-profit/authorize (optional { "reconnect": true, "returnTo": "/path" }) returns
authorizationUrl for the person the credential acts for to open, signed in to Pluto Ads; it works once, for
15 minutes. It needs a credential that acts for a person (an API key gets 403) and is the same command as MCP
connect_pluto_profit and reconnect_pluto_profit. The approval acts for that person: it never reads more than
they can see in Pluto Profit, and when they lose access to the store the connection asks to be reconnected.
Pluto Ads stores the approval encrypted and renews it by itself; responses never include it.
Connecting with an API key stays available (oauthAvailable in the connection says whether approving in Pluto
Profit is offered). Use the store's Pluto Profit API key (Pluto Profit, Developers). A publishable key
(pk_plp_...) is enough, because Pluto Ads only reads.
POST /integrations/pluto-profit/verify with { "apiKey": "pk_plp_..." } answers which store the key belongs to
(its Shopify domain, currency and channels, and alsoConnectedTo, other workspaces of the organization
already connected to it) without storing anything; a key belongs to exactly one store,
so choosing the store is choosing its key. POST /integrations/pluto-profit/connect with the same body
connects it, replacing an earlier store. The key is stored encrypted and is never returned, logged or kept
in receipts: responses show only its kind and last four characters (keyHint). A key Pluto Profit
doesn't accept is a 422 on apiKey. POST /integrations/pluto-profit/disconnect deletes the stored
approval or key and cached figures. Nothing changes in Pluto Profit: remove Pluto Ads there (Settings,
Connected apps) to end an approval everywhere, or revoke the key (Developers).
GET /integrations/pluto-profit returns status (connected, reauthorize or disconnected), method
(oauth or apiKey), the store (Pluto Profit's id and name, Shopify domain, currency, channels), who
connected it and when (connectedBy, connectedByName, connectedAt), syncedThrough (the latest day with
synced store data in Pluto Profit) and checkedAt. When Pluto Profit stops accepting the approval (it was
removed or expired, or the person who approved lost the store) or the key (it was revoked), the status becomes
reauthorize with a statusReason, and every profit read answers 409 with reason: pluto_profit_reauthorize
until an admin reconnects. Without a
connection the reads answer 409 with reason: pluto_profit_not_connected. Connecting, a key being
refused and disconnecting send an integration.changed webhook with provider: pluto_profit.
Reading. Every read takes since and until (YYYY-MM-DD, inclusive, in the store's timezone, at
most 366 days). Money is decimal strings in the store currency; ratios are decimal fractions. A value Pluto
Profit can't compute, for example net profit while a shipping cost is missing, is null, never 0, and
coverage says what is missing.
GET /profit/summary: revenue, refunds, COGS, fees, shipping, other costs, ad spend, gross, contribution and net profit, blended ROAS, POAS (gross profit / ad spend), MER, the break-even ROAS (the ROAS at which ads exactly pay for themselves) and margins, plus spend and platform-reported revenue per ad channel.GET /profit/products: per product its revenue, COGS, gross margin, the fees, shipping and ad spend allocated to it, andcontribution, what it left after them.sortismargin(worst first, the default),revenueorrefundRate; page withlimit(1-100) andoffset(nextOffset).adAllocationsays how ad spend was allocated and how much stayed unallocated.GET /profit/ads: an estimate per Meta ad, ad set or campaign (level), worst first. Pluto Profit doesn't report profit per ad, so each row applies the store's break-even ROAS to the purchase value Meta attributes to it: estimated contribution = purchase value / break-even ROAS - spend, estimated POAS = ROAS / break-even ROAS.estimateis alwaystrueandbasisexplains the method. A row without purchase value, or a store whose costs are incomplete, has no estimate (null,verdict: unknown). Filter withadAccountId,campaignId,adSetIdandadId(repeatable), and choose Meta'sattributionsetting.
curl -s "$PLUTO_API/profit/summary?since=2026-09-01&until=2026-09-07" -H "authorization: Bearer $PLUTO_API_KEY" \
| jq -c '{currency, revenue: .metrics.revenue, netProfit: .metrics.netProfit, poas: .metrics.poas, breakEvenRoas: .metrics.breakEvenRoas, fresh: .freshness}'
# {"currency":"USD","revenue":"20300.00","netProfit":"6465.90","poas":"2.1996","breakEvenRoas":"1.6536","fresh":{"asOf":"2026-09-26T09:12:04Z","syncedThrough":"2026-09-26","fetchedAt":"2026-09-26T09:12:05Z","stale":false,"source":"pluto_profit"}}Freshness. Pluto Profit doesn't push changes, so figures are read when asked for and reused for about a
minute. freshness.asOf is when Pluto Profit computed them and fetchedAt when Pluto Ads read them. If
Pluto Profit can't be reached, the last figures are returned with stale: true; if there are none, the
answer is 503 (or 429 with Retry-After when Pluto Profit is rate limiting).
Profit is for the people who run the workspace: members, admins, owners and their credentials. Guests get
403.
Slack
A workspace can connect one Slack workspace through the Pluto Ads Slack app, then post to the channels it chose: from loops, from agents and from your own code. Pluto Ads only posts; it never reads messages.
Connecting. An admin signed in to the app opens GET /integrations/slack/connect?returnTo=/integrations
in the browser. Slack asks them to allow Pluto Ads in one Slack workspace and returns to the app with
?slack=connected (or ?slack=error&reason=...). The consent is always a person's, so this route needs a
session. Installs for a whole Enterprise Grid organization aren't supported: install into one Slack workspace.
Pluto Ads asks Slack for four permissions: posting messages (chat:write), posting in public channels
without being invited first (chat:write.public), and listing public channels and the private channels it
was invited to (channels:read, groups:read). The app's token is stored encrypted and is never returned.
GET /integrations/slack returns configured (whether this deployment offers Slack), appName (the app's
name in Slack, for /invite @...), status
(connected, reauthorize or disconnected), the Slack team, the granted scopes and the connected
channels. When someone removes Pluto Ads from the Slack workspace, or Slack stops accepting its access, the
status becomes reauthorize and posting answers 409 with reason: slack_reauthorize until an admin
reconnects. Without a connection, Slack actions answer 409 with reason: slack_not_connected.
POST /integrations/slack/disconnect deletes the stored access; when no other workspace uses the same Slack
installation, Pluto Ads is also removed from the Slack workspace. Connecting, disconnecting and removal in
Slack send an integration.changed webhook with provider: slack.
Channels. People, agents and loops post only to channels an admin connected, at most 50.
GET /integrations/slack/channels/available lists the public channels and the private channels Pluto Ads was
invited to (/invite @Pluto Ads in Slack), a page at a time (cursor, nextCursor), each with connected.
PUT /integrations/slack/channels with { "channelIds": ["C0123456789"] } replaces the connected set; Slack
confirms each new channel exists and isn't archived, and a channel it refuses is a 422 on channelIds/<n>.
A connected channel that Slack later reports archived, deleted or closed to Pluto Ads shows
status: unavailable with a statusReason.
Posting. POST /integrations/slack/messages (write) with channelId, text (Slack formatting, up to
3,000 characters) and an optional title (up to 150) posts one message. The message carries a line saying
who sent it with Pluto Ads, and link previews are off. Broadcast mentions (<!channel>, <!here>,
<!everyone> and user group mentions) are sent as plain text, so a loop or an agent can't notify a whole
channel.
curl -s "$PLUTO_API/integrations/slack/messages" -H "authorization: Bearer $PLUTO_API_KEY" \
-H "idempotency-key: spend-alert-2026-09-26" -H "content-type: application/json" \
-d '{"channelId":"C0123456789","title":"Spend alert","text":"*Spring sale US* spent 80% of its daily budget by noon."}'
# {"channelId":"C0123456789","channelName":"ads-alerts","ts":"1790412345.000200","postedAt":"2026-09-26T12:05:45Z"}A message is posted at most once. Retrying with the same idempotency key returns the first result. If Slack
didn't answer in time, the message may or may not be in the channel, so a retry answers 409 with
reason: slack_message_outcome_unknown instead of posting again. Check the channel, then send with a new key
if it isn't there. Slack takes about one message per second per channel: Pluto Ads spaces messages to the
same channel and waits up to a few seconds for the next slot; beyond that, and whenever Slack asks us to slow
down, the answer is 429 with Retry-After. A channel that isn't connected is a 409 with
reason: slack_channel_not_connected, and one Slack no longer lets Pluto Ads post in is a 409 with
reason: slack_channel_unavailable.
Slack is for the people who run the workspace: members, admins, owners and their credentials. Guests get
403.
Media
Images and videos live in the workspace's media library. Accepted types are JPG, PNG and WebP images (up to 30 MB) and MP4 and MOV videos (up to 4 GB). Media never travels through the JSON API:
POST /media/uploadswithname,contentType,byteSizeand the file'ssha256(64 hex characters) returns the media item and presigned URLs:modesingle, or multipartparts. If the same content is already in the library, you get that item withdeduplicated: trueandupload: null.PUTthe bytes to each URL with the headers the response lists.POST /media/{id}/complete(for multipart, with every part's ETag) queues processing and returns thejobId(process_media).POST /media/{id}/abortcancels an upload.
Processing verifies the bytes, then records width, height and, for videos, durationMs, and makes a
JPEG preview of at most 640 pixels: an image's thumbnail or a video's poster frame (hasPreview).
warnings lists Meta's ad specs the file
misses, without blocking it: resolution, aspect_ratio, format, duration, dimensions_unknown,
duration_unknown, and for videos placement_feed (taller than 4:5), placement_stories_reels (not
9:16), placement_in_stream (under 5 seconds or over 10 minutes), placement_reels (over 15 minutes),
placement_instagram (over 60 minutes) and poster_unavailable (no frame could be taken; publishing
uses Meta's thumbnail).
An upload with no activity for 1 hour 15 minutes (no new part stored, and no POST /media/uploads of the same file,
which resumes it with fresh URLs) is stopped: the item becomes failed with error "The upload didn't
finish." and releases its sha256, so uploading the same file again starts a new item. Archive the failed
one with POST /media/{id}/archive.
POST /media/imports downloads files from HTTPS URLs on the server instead (1 to 100 per request, no
private or link-local addresses) and returns a job (import_media). GET /media/{id} returns
short-lived read URLs and usage: each launch whose ads use the item (launchId, launchName,
launchStatus, adCount), most recently updated first; GET /ads?mediaId= lists those ads.
GET /media/{id}/preview redirects to a fresh read URL, so a saved link keeps working.
GET /media/lookup?ids= reads up to 200 items at once.
Creative Memory. POST /creative-memory/context resolves {kind: "media", mediaId},
{kind: "ad", adId} for a current Pluto draft, or {kind: "meta_ad", adId, adAccountId?} for a recorded
published Meta ad. adAccountId is optional and checked when given: the same Meta ad named with or without it
is one subject, with the same fingerprint and the same recorded relationships, and the answer's subject always
includes its account. It returns declared copy, available media IDs, a composition fingerprint, observation time
and explicit completeness. This is a read: it starts no analysis or provider request. Published context never
substitutes edited draft copy, and advertiser claims are not independently verified facts.
POST /creative-memory/compare takes {subjects: [...]} with 2-8 distinct subjects in that same format.
It returns their variants, deduplicated media evidence and recorded copy/media differences, with values
aligned to the requested order. Each media item includes up to 64 observations and an evidenceTruncated
flag. Missing summaries and copy stay null, and coverage remains explicit. This read starts no inference.
Shared media does not establish lineage, and content differences do not establish what caused performance;
current composition must not be assigned to historical metrics without a matching observed version.
Its lineage object lists only claims already recorded for these exact revisions: relations (variant claims
with parent and child) and families, each with its recorded status and basis, and truncated. A
member's variantIndex points into variants, or is null when that revision is not compared. Claims recorded
for an earlier revision of the same ad or media are not shown for the changed one.
Relationships and experiments. POST /creative-memory/lineage records a variant (parent, child,
changedComponents) or family (members) claim, each revision as {subject, expectedFingerprint} from
/creative-memory/context, with status and provenance. Similarity can only support a candidate; a
confirmed claim needs a creator declaration or a recorded workflow. Amend with supersedesId and
amendmentReason; earlier records stay readable. POST /creative-memory/experiments registers an immutable
design (hypothesis, held-constant components, allocation, 2-8 groups, primary metric, account-local window and
stopping rule). Nothing is launched or spent, and a declared randomized allocation is not proof that it ran.
The response says whether the design is preregistered: only one registered before its window, with no prior
results declared or assessed. Amendments (expectedVersion, reason, design) append versions and never
regain preregistration once the window started or results were assessed. POST /creative-memory/experiments/{id}/outcomes snapshots stored ad/day facts for each group's delivery ads over
completed days; outcomes are descriptive_only and never allocated to assets. A stale expectedVersion
returns 409 with current.
POST /creative-memory/performance takes {adIds, since, until, attribution}: 1-100 distinct Meta ad IDs,
an inclusive account-local date range of at most 31 days (YYYY-MM-DD), and one of 7d_click_1d_view,
7d_click, 1d_click or 1d_view. It returns original per-ad/day facts, currency, account timezone,
attribution, reported denominators and fetchedAt, alongside the observed composition status:
observed_stable, mixed or unknown. Stable means matching snapshots bracket the day within the returned
maxBracketHours policy; assumedContinuity: true explicitly records that intermediate changes were not
verified. It does not identify which dynamic creative asset was actually delivered.
Outcomes remain at ad/day level and are never copied onto each asset. Missing days are omitted, null metrics
remain unknown, and missingAdIds identifies requested ads with no facts. Daily reach is not additive across
days. These are the latest stored restatements, not historical training snapshots as they were known then;
provider attribution describes association, not causation. The read starts no fetching or analysis.
Published analysis stays searchable without using another allowance. GET /creative-memory/search?q= returns matching media and source-linked moments, coverage.indexed and
coverage.total, and the retrieval mode. tags (tag IDs, comma-separated) narrows it to media used by an ad
with any of those tags, as on GET /media; coverage then counts that media only, and nextAfter keeps the
filter. truncated and resultLimit expose bounded ranked results: narrow the query when truncated; do not use ranked search as an exhaustive count. Source timestamps use the original media in milliseconds.
GET /creative-memory/media/{id} returns published interpretation, evidence, examined coverage and requests.
Missing or unexamined evidence is unknown; a search result is not an exhaustive count or proof of absence.
To find ads rather than files, POST /creative-memory/ad-search takes {q?, filters?, example?, after?, limit?}
and returns ads: current Pluto drafts and ads observed at Meta, each as its current copy and verified media.
q matches ad copy, ad names and the recorded evidence of the ad's media; any word can match, and a
"quoted phrase" must appear with that exact wording ("free shipping" does not match free-shipping) and
turns off meaning-based matching. filters takes subjectKind (ad or meta_ad), mediaKind (image or
video), minDurationMs, maxDurationMs, adAccountId (act_123..., observed Meta ads only) and tags
(tag IDs, up to 50: ads whose Pluto ad isn't archived and has any of them; an ad observed at Meta counts
through the Pluto ad that published it, and one Pluto didn't publish has no tags).
example: {mediaId, evidenceId?} instead of q finds ads whose media looks like an indexed file; similarity
is not evidence of a shared hook, lineage or performance. Each item has creativeRevisionId, context (the
same shape as POST /creative-memory/context, with assetsComplete, copyComplete and limitations),
mediaEvidence (complete, partial or none) and up to three matches (copy fields have source: "copy"
and a field; media evidence has mediaId and original-media startMs/endMs). coverage reports eligible
ads in scope (total, narrowed by tags but not by media type or length), searchable ads (indexed), ads still being
prepared (pending) and partial. mode is text, hybrid or example; semantic is used,
not_requested, literal or unavailable (text matching only). Results are ranked once, at most 200
(truncated, resultLimit); send nextAfter as after with the same body within two minutes. A 409 means
an ad, its media or the example changed: search again. The read writes nothing and starts no analysis.
GET /creative-memory/usage reads the organization pool: deep-analysis units, basic video minutes and images,
with separate used, reserved and remaining quantities. A trial uses one fixed window; paid allowances reset on
UTC calendar months, including annual subscriptions. available: false and unavailableReason explain missing
processing availability or agreed Enterprise terms; a missing limit never means unlimited work.
POST /creative-memory/evaluate reads {mediaId, condition, scope?} and returns value (true, false
or unknown), cited evidence, coverage, and version/source/evaluation fingerprints. Conditions use op:
speech or on_screen_text with text and optional caseSensitive; feature with kind; before
with endMs and a child condition; within with startMs, endMs and condition; all or any
with conditions; or not with condition. Optional scope has startMs and endMs. Times use the original
video in milliseconds. The limit is 64 condition nodes, 6 levels and 500 characters per phrase. Negation
preserves unknown. A literal false applies to recorded text; it does not prove physical absence. This read
uses exact evidence rather than a ranked search subset, starts no model calls, and remains available in
read-only or archived workspaces.
Timing conditions take an event (a speech, on_screen_text or feature leaf) and original-video times:
starts_before (endMs), after (anchor, minDelayMs, maxDelayMs), relative_window (anchor,
anchorEdge start or end, startOffsetMs, endOffsetMs), overlaps (left, right, minOverlapMs),
duration (minMs, maxMs), first_occurrence (startMs, endMs) and repetitions (minCount, optional
maxCount, speech only), each with an optional toleranceMs of 0-1,000. They describe recorded evidence: a
transcript segment only bounds where a phrase was said, aligned words are estimates, and on-screen text seen in
sampled frames never proves how long it stayed visible or that it was absent in between. When timing crosses a
threshold, earlier coverage is missing or evidence was edited, the answer is unknown.
Targeted precision. For one explicit question about an indexed image or an original video interval of at
most 90 seconds, POST /creative-memory/precision/quote takes {mediaId, interval, question, method, language?}
with method visual, native_av or word_timing. Visual and native audio/video inspection use one deep
analysis per started minute (one for an image) plus basic processing; word_timing uses basic processing only.
The five-minute estimate is tied to the person or agent who requested it and pins the current evidence and
corrections. Accept it with POST /creative-memory/precision/requests and {quoteId}; accepting it again returns
the same request. GET /creative-memory/precision/requests/{id} returns the answer with raw and aligned timing
kept apart, or stale: true and no result once the source, its evidence or its corrections changed. Languages
without a word aligner keep their transcript and leave fine timing unknown. POST /creative-memory/precision/requests/{id}/cancel releases the unused reservation, also while read-only. A failed
or uncertain provider call is never charged and never repeated.
Corrections and learning labels. GET /creative-memory/media/{id}/correction-context returns correctable
evidence and the exact snapshot to send back. POST /creative-memory/corrections takes {snapshot, evidenceId, replacement, rationale, provenance, rebasesCorrectionId?}; replacement: null retracts the entry. The new wording
is searchable immediately (by wording, not meaning), earlier interpretations are withheld, and evidence shows
origin: authorized_correction: an attributed assertion, not verified truth. Later processing keeps the
correction; an observation that disagrees with it shows origin: revalidation_required and cannot prove a
condition. A changed snapshot returns 409. POST /creative-memory/corrections/release removes all active
corrections for a source and keeps their history. POST /creative-memory/labels records a separate
content_accuracy, query_relevance or user_utility judgment that never edits evidence.
To start new processing, create POST /creative-memory/quote with kind: index or deep and items (1-100
objects with mediaId; videos may include both startMs and endMs for a section). The quote shows reused work,
required units, length limits, expiry and eligibility. If onDemand is present, it includes the maximum
additional AI usage as the decimal string maxUsageUsd; present this before approval. Quoting starts no
inference and reserves nothing. One deep-analysis unit is one image/card or one started video minute.
Accept that exact quote with POST /creative-memory/requests and { "quoteId": "..." }, using a stable
Idempotency-Key. The whole request is revalidated and reserved atomically before any new work is dispatched;
a stale quote returns 409 and needs a fresh quote. Insufficient allowance or AI budget returns 402.
Basic indexing and deep analysis have separate allowances. Business continuation requires explicitly enabled
on-demand AI and must fit the organization's remaining monetary limit; no new subscription add-on is created.
Failures on Pluto's side release unfinished work without charging for it.
GET /creative-memory/requests lists requests (after, limit, nextAfter) and their jobId for the existing
job APIs. POST /creative-memory/requests/{id}/cancel stops unfinished work and releases unused reservations
once bounded running calls finish; successful published work remains available. Cancel remains available in
read-only organizations. Reads need read; quoting, accepting and cancelling need write. Guests cannot start
analysis. All routes are scoped to the current workspace; allowances are pooled across its organization.
Indexing an existing library. POST /creative-memory/imports with { "scope": { "kind": "all" } }, or
{ "kind": "selected", "mediaIds": [...] } (1-1,000), starts one basic-index import. Its membership is pinned when
it starts: media added later are not part of it, while changes to member media are picked up. Each item becomes
an ordinary index request with its own allowance reservation; an import never requests deep analysis or on-demand
AI. One import runs per workspace; repeating the same scope returns it. GET /creative-memory/imports/{id} and
/items report progress per source revision and attempt, joined with the real status of each accepted request.
POST .../pause, .../resume and .../cancel control new submissions: accepted requests keep running and are
reported as they are (cancel a request to stop it). A used-up allowance pauses the import until you resume it.
POST .../items/{itemId}/retry queues a new attempt for a failed or cancelled item and keeps the earlier one.
Pause and cancel work in read-only organizations; starting and resuming don't.
Monitoring every creative. A loop's creative monitor step checks a condition over every subject in its scope
(media, draft ads or Meta ads), not a ranked subset, and offers at most its action limit (1-1,000) of known matches
to the next steps; false and unknown never continue. A match already offered isn't offered again unless its
recorded content changes. GET /creative-memory/monitors?loopId= lists coverage runs; GET /creative-memory/monitors/{id} reads one: its membership cutoff (watermarkAt; later subjects join the next run),
whether enumeration and evaluation finished, fresh against later content changes, counts of true, false,
unknown, pending, missing, deleted and failed subjects, and how many matches were offered or held back by the
limit. GET /creative-memory/monitors/{id}/items lists the subjects the run examined with their truth and
selection. Offered is not a claim that an action ran; the loop run shows what happened. Guests can't read them.
Looking at media. GET /media/{id}/view answers what a vision model sees of an item: stills,
short-lived JPEG URLs sized for models (an image's still of at most 1568 pixels, or up to 8 evenly spaced
frames of a video, at most 1024 pixels, each with atMs and a timestamp like 0:04), with aspectRatio
and, for videos, hasAudio. Stills are made once and kept (they don't count toward media storage); the
first view of a video may wait a few seconds while its frames are taken. note says why there's nothing
to look at yet, such as media still processing. GET /ads/{id}/view does the same for an ad, with the
copy it runs with and, once published, its Meta ad ID and delivery status; GET /integrations/meta/ads/{id}/view takes a Meta ad ID, including ads that exist only in Meta, whose
creative and copy are read from Meta the first time within its rate limits. The MCP tools view_media
and view_ads return the same stills as images.
Folders. Every item lives in one place: the library root, or one folder. folderId on
GET /media is a folder ID (that folder only), unfiled (the root: media outside every folder) or
all (everywhere, the default). Search and filters (q, kind, status, usage, archived of
exclude (the default), include or only, tags, and sort of newest, oldest, name or size,
largest first) combine with it. Tags belong to ads, not files: tags takes tag IDs (the workflow labels from
GET /workflow/labels), comma-separated, up to 50, and keeps the files that an ad which isn't archived and has
any of those tags uses. GET /creative-memory/search takes the same tags with the same meaning:
for f in "folderId=$FOLDER_ID" "folderId=unfiled" "folderId=all&q=hero"; do
curl -s "$PLUTO_API/media?$f" -H "authorization: Bearer $PLUTO_API_KEY" | jq -c '[.items[].name]'
done
# ["hero.png"]
# ["square.png"]
# ["hero.png"]POST /media/folders, PATCH and DELETE /media/folders/{id} create, rename and delete folders;
deleting a folder moves its media to the root. PATCH /media/{id} renames an item or moves it
(folderId: null is the root). POST /media/move (folderId, items with versions) and
POST /media/archive (items) change many items in one command with a changeset and an outcome per item.
Archiving hides media from the library; ads that already use it keep working. POST /media/unarchive
(items) brings archived media back the same way: each item returns to its folder, and the launches that use
it are unchanged. Media that isn't archived is accepted as it is; an item is rejected when it was deleted, or
when the same file was uploaded again after it was archived and is already in the library as another item.
Storage. Media counts toward your organization's media storage: your plan's, shared across its workspaces
(50 GB on Pro, 500 GB on Business, and 50 GB more per workspace beyond the included ones). Archived media still
counts; previews and video posters are included. A new upload or import that doesn't fit waits with
402 plan_limit (feature: media_storage) until an owner or admin turns on extra storage or raises its limit.
Pluto never deletes your media on its own. DELETE /media/{id}, or POST /media/delete (items) for many with
an outcome per item, removes files you choose for good; it can't be undone. It's refused (409, reason: used_in_unpublished_launch) while a launch that isn't published yet uses the media; archive it instead.
Collaboration
Comments live on ads. POST /ads/{ad_id}/comments posts a comment, or a reply with parentId. People,
agents and API keys comment as themselves (author.kind is person, agent or api_key). Mention
people as @[Name](user:<userId>); they get a notification.
curl -s "$PLUTO_API/ads/$AD_ID/comments" -H "authorization: Bearer $PLUTO_API_KEY" \
-H 'content-type: application/json' -H 'idempotency-key: comment-1' \
-d '{"body":"Headline is too long for Stories."}' | jq -c '{author:.author|{kind,name},body,version}'
# {"author":{"kind":"api_key","name":"Reporting agent"},"body":"Headline is too long for Stories.","version":1}PATCH /comments/{id}edits your own comment;DELETE /comments/{id}deletes yours (admins any).POST /comments/{id}/restoreundoes a delete within an hour: content, reactions and notifications come back.POST /comments/{id}/resolveand/reopenresolve and reopen a thread.PUTandDELETE /comments/{id}/reactions/{emoji}add and remove your reaction.- Attachments: library media by
mediaId, or a file you upload first withPOST /comments/attachments/upload-url(returnsuploadUrl,headersand theobjectKeyto attach).GET /comments/{id}/attachments/{attachment_id}redirects to a fresh download URL. GET /ads/{ad_id}/activityis the ad's history with who made each change.PUT /ads/{ad_id}/subscriptionandPUT /comments/{id}/subscriptionfollow an ad or a thread.GET /inboxis the acting person's notifications (mentions, replies, status and assignment changes) with filters;/inbox/read,/unread,/archive,/unarchiveand/snooze(until) change them, andGET /inbox/unread-countcounts.
Reactions, following and the inbox belong to a person (see Authentication).
Notify people
POST /notifications posts a notification to the Inbox of people in this workspace and, with
channels: ["email"], emails them. It only ever reaches people in the workspace: to is me (the person
behind the credential; the default), workspace (every owner, admin and member, never guests) or
people with userIds (naming people implies people). If someone named isn't in the workspace, the
request is a 422 on userIds and nobody is notified. launchId or adId says what it's about, and the
Inbox item links to it. A guest can be named only about a launch shared with guests; otherwise it's a 422
on userIds and nobody is notified. It needs write; an API key acts for no person, so it
names people or sends to the workspace.
curl -s "$PLUTO_API/notifications" -H "authorization: Bearer $PLUTO_API_KEY" \
-H 'content-type: application/json' -H 'idempotency-key: pacing-2026-09-26' \
-d '{"to":"workspace","channels":["inbox","email"],"subject":"Budget pacing","message":"Spring sale US spent 80% of its daily budget by noon."}' \
| jq -c '{to,channels,recipients:[.recipients[]|{name,inbox,email}]}'
# {"to":"workspace","channels":["inbox","email"],"recipients":[{"name":"Anna","inbox":true,"email":"queued"},{"name":"Bo","inbox":true,"email":"queued"}]}subject(up to 200 characters, one line) is the Inbox title and the email subject;messageis up to 2,000 characters. One of them is required. In the Inbox the notification haskind: "notification", itstitle, and no ad.- Each credential may send 30 notifications an hour and all credentials of a workspace 200; past that the
answer is
429withRetry-After. Each person gets at most 20 of these emails a day; past that they get the Inbox notification only, and the response saysemail: "limited"for them. - Emails go out within a minute. Retrying with the same
Idempotency-Keyreturns the first result and notifies nobody twice. - MCP:
notify_members. The Loops Notify people step sends through it too.
Workflow and settings
- Review statuses (
/workflow/statuses): each has acategory(unstarted,started,completed,changes) fixed at creation. Create, update, reorder, archive, set the default, or delete one nobody uses; deleting one in use is a409withusageCount. A new workspace starts with Not started (the default), In review, Approved and Changes requested. - Labels (
/workflow/labels), shown as tags in the app: create, update, archive, delete (it detaches from every ad). A new workspace starts with Concept, Variation and Winner. - Copy templates (
/templates) and country groups (/country-groups, ISO 3166-1 alpha-2 codes). - Launch defaults (
/meta-defaults): the settings every new launch starts with, for the workspace and overridden per ad account (/meta-defaults/accounts/{ad_account_id}). - Preferences (
/preferences,PUT /preferences/{key}): the acting person's view settings. Keys:creativeView,boardDisplay,tableDisplayandnotificationstake an object of settings;gettingStarted.skippeda list ofmeta,invite,launch,loop,agent;gettingStarted.hiddentrue or false;agentApprovalswhen the in-app Agent asks before it acts (risky,alwaysorauto, see Agent). Any other key is a422.
Workspace and members
GET /workspace is the current workspace. PATCH /workspace changes its settings (admin): the name
(with the expectedVersion you read) and shareNewLaunchesWithGuests (every new launch starts shared with guests; see "Sharing with guests" under
Authentication); fields you leave out stay. The logo is uploaded
with POST /workspace/logo/upload-url (PNG, JPEG or WebP, at most 2 MB), then set with
PUT /workspace/logo. POST /workspaces creates another workspace and is for sessions only: keys and
agents belong to one workspace.
GET /members lists members with their role (owner, admin, member, guest) and status. Admins invite by
email; the invitation expires after 7 days:
curl -s "$PLUTO_API/members/invitations" -H "authorization: Bearer $PLUTO_API_KEY" \
-H 'content-type: application/json' -H 'idempotency-key: invite-anna' \
-d '{"email":"anna@example.com","name":"Anna","role":"member"}' | jq -c '{email,role,status,expiresAt}'
# {"email":"anna@example.com","role":"member","status":"pending","expiresAt":"2026-10-02T14:03:26.712363Z"}POST /members/invitations/{id}/revoke cancels a pending invitation. PATCH /members/{user_id} changes a
role (owners for anything involving the owner role). POST /members/{user_id}/suspend blocks a member and
every agent acting for them while keeping their work; /reactivate restores access.
DELETE /members/{user_id} removes someone (admin), or, signed in, yourself to leave the workspace, unless
you are its last owner. An API key or agent never removes the person it acts for (403), the same rule as
leaving the organization. Only an owner signed in to the app grants or removes ownership; no credential
does. Their past work, comments and assignments stay.
Organization owners and admins manage the organization's people, seats and billing. A workspace admin who
is only a member of the organization runs that workspace: they invite guests and manage members, but can't
change, suspend or remove an organization owner or admin (organization admins are admins in every
workspace; change their role in the organization), and can't add a paid seat, so inviting a member or
making a guest a member needs an organization owner or admin (403). An invitation is accepted when the
invited person signs in with that address verified. Losing a workspace revokes the API keys, agent clients
and connected agents the person had there, for good: inviting them again doesn't bring those back.
Archiving and deleting workspaces
Organization owners and admins change a workspace's state; a credential needs the organization scope.
Every call names the organization and the workspace, so it works from any workspace of the organization.
| Call | What happens |
|---|---|
POST /organizations/{id}/workspaces/{workspace_id}/archive | Read-only, out of the switcher and the plan's workspace count; loops pause, queued publishes and jobs are cancelled; nothing is deleted |
POST /organizations/{id}/workspaces/{workspace_id}/delete with {"confirmName":"Acme"} | Everything archiving does, plus its API keys, agents and webhooks are revoked, guests and pending invitations removed, Meta, Slack and Pluto Profit disconnected (nothing is called or deleted in Meta; live ads keep running). Data and media are deleted after 30 days |
POST /organizations/{id}/workspaces/{workspace_id}/restore | Back to active from archived, or back to what it was from scheduled for deletion. Loops stay paused; revoked credentials, removed guests and disconnected integrations stay that way. 402 plan_limit when the plan has no room |
GET /organizations/{id}/workspaces/archived | Archived workspaces and ones scheduled for deletion, with purgeAfter |
GET /organizations/{id}/workspaces/{workspace_id} | One workspace's status, dates and who changed it |
GET /organizations/{id}/workspaces/{workspace_id}/export/ads.csv | Launches, ad sets and ads as CSV |
GET /organizations/{id}/workspaces/{workspace_id}/export/media.zip | The original media files, streamed as a zip |
curl -s "$PLUTO_API/organizations/$ORG/workspaces/$WS/delete" -H "authorization: Bearer $PLUTO_API_KEY" \
-H 'content-type: application/json' -H 'idempotency-key: delete-acme' \
-d '{"confirmName":"Acme"}' | jq -c '{status,purgeAfter}'
# {"status":"deleting","purgeAfter":"2026-10-26T09:41:12Z"}confirmName must match the workspace's name exactly. The organization's last active workspace can't be
archived or deleted (409). Requests into an archived workspace answer 409 workspace_archived for every
write; into one scheduled for deletion 410 workspace_deleted with purgeAfter. A signed-in owner or admin
can restore and export from the app even when no other workspace is open. Exports work until the purge.
After purgeAfter a daily job deletes the data, the media and the analytics for good; no request can start
or stop it, and restoring is refused once it has begun.
People leaving and ownership
Suspending (PATCH /organizations/{id}/members/{user_id} with "status":"suspended", or
POST /members/{user_id}/suspend for one workspace) ends the person's access at once and revokes their API
keys, agent clients and connected agents; loops that run as them pause and the workspace's admins get an
Inbox item. A suspended person isn't a seat. Reactivating restores access, not credentials. Suspended
people get 403 member_suspended.
DELETE /organizations/{id}/members/{user_id}?transferTo={user_id} removes someone: the same effects, the
membership ends, and their loops and Agent chats move to transferTo (an active owner or admin, or you;
by default you, or an owner when someone leaves; required, else 422, when an API key or agent removes
someone who owns loops or chats), paused until that person turns them back on. API keys are revoked, never moved, because
the former member still holds them. Their work stays, attributed to "Name (former member)", and they get
403 member_removed. GET /organizations/{id}/members/{user_id}/offboarding lists what they own first.
POST /organizations/{id}/members/{user_id}/ownership with {"stepDown":true} (optional) makes an admin an
owner, and with stepDown makes you an admin. Several owners are allowed; the last owner can't leave, be
removed or be suspended. Only an owner signed in to the app can do this: credentials get 403.
POST /organizations/{id}/invitations/{invitation_id}/resend sends a pending or expired invitation again,
valid for 7 more days.
Deleting an organization or your account
These are for a signed-in person only (the session cookie): API keys, agents and MCP get 403.
POST /organizations/{id}/deletewith{"confirmName":"Acme"}(owners): every workspace is scheduled for deletion with the same date, the subscription ends with the current period, owners are emailed.POST /organizations/{id}/restorebrings it back until then, unless deleting has already begun (409).GET /account: who you are, the organizations you're the only owner of, organizations scheduled for deletion and ones you were removed from.GET /account/export: your profile, memberships, preferences, comments, reactions, Agent chats and activity as JSON. No secrets.POST /account/deletewith{"confirmEmail":"anna@example.com"}: deletes your account at once. Refused with409(reason: sole_owner,organizations) while you're the only active owner of an active organization.
Jobs
Long or provider-facing work returns a job ID instead of waiting. The job reports counts; its items report per-item outcomes.
| Kind | Started by |
|---|---|
publish_launch, meta_readback | Publishing a launch, and reading it back from Meta |
apply_revisions, delete_at_meta | Publishing pending changes and live controls; deleting in Meta |
meta_live_change, meta_copy | Changing and duplicating objects by Meta ID |
live_targeting | Changing a live ad set's targeting |
meta_spend_cap | Setting an ad account's spending limit |
meta_sync, meta_import | Syncing Meta; Import from Meta |
meta_insights, meta_insights_backfill | Importing insights; the history backfill |
process_media, import_media | Finishing an upload; importing media from URLs |
apply_launch_spec, bulk_apply, bulk_revert | Large launch specs; bulk edits and reverts |
loop_run, change_set_revise | Loop runs; revising a change set from feedback |
leads_sync, social_comments_sync, social_comment_action | Syncing leads and comments; replying to and moderating comments |
webhook_delivery | Webhook deliveries |
curl -s "$PLUTO_API/jobs/$JOB_ID" -H "authorization: Bearer $PLUTO_API_KEY" \
| jq -c '{kind,status,total,succeeded,failed}'
# {"kind":"apply_launch_spec","status":"succeeded","total":206,"succeeded":206,"failed":0}
curl -s "$PLUTO_API/jobs/$JOB_ID/items?status=failed&limit=50" -H "authorization: Bearer $PLUTO_API_KEY"
# {"items":[],"nextAfter":null}- Job status:
queued,running,succeeded,failed,partially_failed,cancelled. - Item status:
queued,running,succeeded,failed,skipped,cancelled. Items carrykey,position,stage,attempts,providerId,outputanderror. A queued item'serrorsays what it waits for (a retry, or Meta processing a video). POST /jobs/{id}/retryqueues a finished job's failed items again with fresh attempts, re-opens the job and returns it withretried. The retrying credential owns the remaining work. It works forpublish_launch(needspublish; the launch must still match what was approved, and what was skipped because of the failures runs again, then the readback),import_mediaandapply_launch_spec. A job still running, cancelled, with nothing failed, or of another kind is a409, for exampleNothing in this job failed, so there is nothing to retry.POST /jobs/{id}/cancelstops queued items. Completed items keep their effects: a published ad stays published.- An unknown job, or one of another workspace, is a
404on every job route.
What's running
GET /jobs lists running jobs and those that finished in the last 15 minutes: running first, newest
first. It is what the app's Activity indicator shows, with the same access: read (guests get 403), and
only this workspace's jobs. Webhook deliveries and the stills made for viewing media and ads are never
listed. MCP: list_jobs.
| Parameter | Meaning |
|---|---|
status | active (queued or running) or recent (finished in the last 15 minutes). Both when omitted. |
startedBy | me: only jobs your person started, directly or through their agents and API keys. |
kind | Only these kinds, comma-separated (publish_launch,import_media). |
limit | 1-100, default 50. |
Each job carries the fields of GET /jobs/{id} and:
finished: items no longer queued or running (succeeded, failed, skipped or cancelled);totalonce the job finished.units: what people count when it isn't the items. Apublish_launchjob counts its ads:{"total": 240, "done": 132}. Null for other kinds.throughput: running jobs only.{"items": 18, "seconds": 120}finished during the last two minutes (less for a younger job). Publishing is paced by Meta's limits, so estimate time left from this rate, never from a guess.nextAt: a running job that waits (a scheduled publish, or items held back by a provider limit): when its next item is due. Null while an item runs and for finished jobs.subjectName(the launch's name, or the ad account's for an import from Meta),launchId, andstartedBy(kindandid).canCancelandcanRetry: what you may do now. A publish is cancelled withPOST /launches/{id}/publish/canceland needspublish; everything else usesPOST /jobs/{id}/cancelwithwrite. Retrying followsPOST /jobs/{id}/retry.
curl -s "$PLUTO_API/jobs?status=active&startedBy=me" -H "authorization: Bearer $PLUTO_API_KEY" \
| jq -c '.jobs[] | {kind,status,finished,total,units,throughput}'
# {"kind":"publish_launch","status":"running","finished":412,"total":530,"units":{"total":240,"done":132},"throughput":{"items":18,"seconds":120}}Clients connected to the realtime socket get an invalidate of the jobs collection whenever a job is
accepted, moves or finishes: refetch then instead of polling. When a publish, an import, a bulk edit or
another job people follow finishes with failures, the person who started it (or whose agent or key did) gets
one Inbox notification with a link to where they can see why and retry (pluto:launch/<id>,
pluto:page/media, pluto:page/integrations). A cancelled job notifies no one.
We retry transient failures up to 8 attempts per item before an item is marked failed. Poll a job as a
fallback; for pushes, use webhooks (below) or MCP's wait_for_job, which streams progress.
Webhooks
A webhook receives a signed POST when something changes in the workspace. List the events you can
subscribe to:
curl -s "$PLUTO_API/webhooks/events" -H "authorization: Bearer $PLUTO_API_KEY" | jq -r '.[].name'| Event | When it fires |
|---|---|
launch.changed | A launch or its settings changed. |
launch.deleted | A draft launch was deleted. |
launch.published | A launch finished publishing to Meta (every ad created and read back). |
launch.publish_failed | Publishing a launch stopped with failed items. |
ad.review_changed | Meta's review of a published ad changed (approved, rejected, back in review). |
ad_set.changed | An ad set was created, changed, moved or deleted. |
ad.changed | An ad was created or changed (content, status, assignee, tags, ad set). |
ad.deleted | An ad left its launch: deleted, or archived with its history (archived: true). |
workflow.changed | Review statuses, tags, templates or country groups changed. |
integration.changed | A provider connection was connected, synced, or needs attention. |
lead.received | Someone submitted a lead form on a connected Facebook Page. Names the lead, never its answers (GET /leads/{id}). |
meta.campaign.status_changed, meta.ad_set.status_changed, meta.ad.status_changed | A campaign's, ad set's or ad's status in Meta changed (configured or effective, e.g. disapproved), including ones made in Ads Manager. data: metaId, adAccountId, name, from and to (status, effectiveStatus). |
social.comment.created | Someone commented on the Facebook or Instagram post an ad runs as. data: commentId (GET /social/comments/{id}), platform, postId, adIds, isReply. |
media.uploaded | An image or video finished uploading and is ready to use. data: mediaId, name, kind, launchIds. |
metric.threshold_crossed | A metric of a campaign, ad set or ad went past a threshold set in a loop (metric, operator, threshold, value, period). |
budget.spent | A campaign or ad set spent the share of its daily or lifetime budget set in a loop (percent, spentPercent, spent, budgetAmount, currency). |
comment.created, comment.updated, comment.deleted, comment.restored, comment.resolved, comment.reopened | Comment activity. |
job.completed | A job finished with every item done (publishing, media processing, imports, syncs, spec applies). |
job.failed | A job finished with failed items. Its items say which and why. |
job.cancelled | A job was cancelled. Items that finished before keep their effects. |
Subscribe with * to receive every event, including ones added later. Admins create webhooks; the
signing secret is in the create response only:
curl -s "$PLUTO_API/webhooks" -H "authorization: Bearer $PLUTO_API_KEY" -H 'content-type: application/json' \
-H 'idempotency-key: webhook-launches' \
-d '{"url":"https://example.com/pluto","events":["launch.changed","ad.changed"]}' | jq -c '{id,events,secret}'
# {"id":"01a0d8e0-9546-765b-bd99-ec52caab670e","events":["launch.changed","ad.changed"],"secret":"whsec_..."}URLs must be HTTPS on a public address. An unknown
event name is a 422 that points at GET /webhooks/events, the catalogue above with a description per
event.
A delivery looks like this:
POST /pluto HTTP/1.1
content-type: application/json
user-agent: PlutoAds-Webhooks/1
pluto-event: launch.changed
pluto-delivery: 01a0d7ea-73c8-7141-88fc-ca7e0a0f499b
pluto-signature: t=1790328861,v1=b2715e3196cd794c2f5da6d1d7d6265311e548dbee524ee6af3dfc9fb2a52034
{"data":{"launchId":"01a0d7e6-f543-7789-b177-75980045174c","version":4},"event":"launch.changed","id":"cbc4a711-9e0b-5c60-8991-f6b58af28200","occurredAt":"2026-09-25T09:34:21.612154Z","workspaceId":"01a0d7e5-acb8-7176-a698-b6947a8016fe"}The payload names what changed; read the entity through the API for its current state. Delivery is at
least once, so deduplicate on the body's id. Answer with any 2xx within 10 seconds. Failed deliveries
retry after 30 seconds, 2, 10 and 30 minutes, then 1, 2 and 4 hours, up to 8 attempts in total.
GET /webhooks/{id}/deliveries shows each attempt and its response status.
Verifying signatures. Pluto-Signature is t=<unix seconds>,v1=<hex HMAC-SHA256>, computed with
your signing secret over <t>.<raw body>. Verify it over the raw bytes, before parsing JSON, and reject
old timestamps so a captured request can't be replayed.
// Node.js
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 300;
// rawBody: the exact bytes Pluto sent (a Buffer), before any JSON parsing.
export function verifyPlutoSignature(rawBody, header, secret, now = Date.now() / 1000) {
const parts = Object.fromEntries(header.split(",").map((part) => part.split("=", 2)));
const timestamp = Number(parts.t);
if (!Number.isInteger(timestamp) || !parts.v1) return false;
if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) return false;
const expected = createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest();
const received = Buffer.from(parts.v1, "hex");
return received.length === expected.length && timingSafeEqual(received, expected);
}# Python
import hashlib, hmac, time
TOLERANCE_SECONDS = 300
def verify_pluto_signature(raw_body: bytes, header: str, secret: str, now: float | None = None) -> bool:
parts = dict(part.split("=", 1) for part in header.split(","))
try:
timestamp = int(parts["t"])
received = parts["v1"]
except (KeyError, ValueError):
return False
if abs((now or time.time()) - timestamp) > TOLERANCE_SECONDS:
return False
expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, received)Both functions accepted the delivery above with its secret, and rejected it with one byte appended to
the body or with a timestamp 1,000 seconds old. The secret is the whole whsec_... string.
Job events fire once each time a job finishes, for every kind except webhook deliveries themselves. The payload carries the job's counts, so you know whether to page through its failed items:
{"event":"job.failed","data":{"jobId":"01a0d80b-...","kind":"import_media","status":"failed","subjectType":null,"subjectId":null,"total":1,"succeeded":0,"failed":1,"finishedAt":"2026-09-25T10:04:12.311Z"}}status is the job's final status, so job.failed also covers partially_failed. A retried job that
finishes again sends a new event. There is no event per item or for progress: a 10,000-item job would
send 10,000 requests. Follow progress through GET /jobs/{id} or MCP's wait_for_job.
The webhook list (GET /webhooks) shows each webhook's lastDelivery (status, responseStatus,
createdAt), so a failing endpoint stands out without opening its deliveries.
Changing, testing and rotating. PATCH /webhooks/{id} changes the url and/or events (send the
webhook's version as expectedVersion; the secret stays). POST /webhooks/{id}/test sends one signed
webhook.test event to that endpoint only and answers 202 with the delivery and its job; the outcome
shows in its deliveries. POST /webhooks/{id}/rotate-secret replaces the signing secret at once: every
delivery from then on, retries included, is signed with the new one, which is in that response only.
Switch your receiver to the new secret right away. The same operations are MCP tools for admin agents.
Audit log
Every change in the workspace is recorded, whoever made it: a person in the app, the in-app Agent, a loop, an
API key or an MCP client. Each entry also says who the change was for and where it came from, so an automated
change always leads back to a person. Workspace admins read the log with GET /audit-log; a credential needs
admin, and its reads are recorded too.
curl -s "$PLUTO_API/audit-log?origin=loop&limit=2" -H "authorization: Bearer $PLUTO_API_KEY" \
| jq -c '.items[] | {summary, actor: .actor.name, for: .onBehalfOf.name, origin: .origin.kind, loop: .origin.name}'
# {"summary":"Changed the budget of ad set \"Retargeting\"","actor":"Anna","for":"Anna","origin":"loop","loop":"Daily optimizer"}
# {"summary":"Changed delivery of ad \"Spring sale US\"","actor":"Anna","for":"Anna","origin":"loop","loop":"Daily optimizer"}Each entry has:
summary: what happened, in one sentence with the target's name in quotes.actionis the same as a stable machine name (launch.publish_requested), for filtering.actor: who acted: a person, an API key, an agent, or Pluto itself (system).onBehalfOf: the person accountable. That's the person themself, the person an agent acts for, the person who created an API key, or a loop's owner.origin: where it came from.kindisapp,api_key(the REST API),mcp,agent_chat(the in-app Agent),looporsystem(Pluto itself, such as Meta confirming a change).idis the Agent chat or the loop run, andparentIdis the run's loop.nameis the loop's name, or the chat's title when you can open the chat.
approval: the approval that let the change run, if one did.kindisagent(an Agent approval card),loop(a loop's approval) orchange_set(a plan), andapprovedByis the person who approved it.target: what changed (type,id, its currentname). For an ad, ad set or comment,launchIdis its launch.commandId: the command (Idempotency-Key) it belongs to, if any.
A change made in Meta is recorded twice: once when it's accepted (live.budget_changed), and again when Meta
has applied it (live.applied_at_meta), or published it (launch.published). Both entries carry the same
origin.
Filter with any of these:
actorKind,actorId;person: what a person did, what was done for them, and what they approved;origin,originId(a chat or a loop run),loopId(every run of a loop);targetType,targetId;action: exact, or a prefix ending in., such aslaunch.;since,until(RFC 3339);q: words to find.
Page through with after and limit (1-200, default 50). MCP clients use list_audit_log with the same filters.
Members, guests and credentials without admin get 403.
Loops
A loop is an automation drawn in the loop builder, saved as a LoopDefinition:
{ version: 1, name, trigger, nodes, edges }. A loop is draft until someone turns it on (active);
turning it off makes it paused, and archived loops can't change or run. Its trigger starts runs while
it's on, and any loop can be run by hand or as a test run. The MCP tools list_loops, get_loop,
validate_loop, create_loop, update_loop, archive_loop, unarchive_loop, delete_loop, set_loop_enabled, run_loop,
test_run_loop, list_loop_runs, get_loop_run, cancel_loop_run and undo_loop_changes do the same
with the same permissions
(MCP). Reading loops, their runs and their chat needs read, and guests (people who
can't edit the workspace) get 403; changing, turning on, running, cancelling and undoing need write.
GET /loopslists loops, most recently changed first:status=current(the default: drafts and loops that are on or off), or one ofdraft,active,pausedandarchived;mine=truefor the ones the acting person created themselves or through an agent. Each hasenabled, itstriggerblock and that step's settings (triggerConfig, e.g. a schedule'severyandat),nextRunAtfor a schedule,lastRun(id,status,atwhen it started andfinishedAt, null while it runs), itsowner(id,name; null when nobody owns it yet) andupdatedAt. One page is one read: nothing is looked up per loop.POST /loops(definition, optionalsourceChatId) saves a new draft as version 1.GET /loops/{id}returns it with itsdefinition,version,issues(what to finish before it can run),lastChange,enabled,trigger,nextRunAt,actionsAllowedandlastRun(withfinishedAt). Both answer with the version asETag.PATCH /loops/{id}(definition) saves a new version: the definition replaces the saved one. Send the version you edited from asIf-Match: "3"orexpectedVersion; a loop saved since is a409whosecurrentis the loop as it is now, and nothing changes. Saving what's already saved makes no new version. A loop that's on stays on and its next runs use the new version; while the saved version hasissues, its trigger starts no runs. Archived loops can't change.POST /loops/{id}/archivearchives it and stops its trigger; its chat, revisions and runs stay.POST /loops/{id}/unarchivebrings it back, Off (paused) when it was on once, else adraft.DELETE /loops/{id}deletes a draft for good, with its revisions: only a loop that was never turned on and never ran (deletableon the loop and in the list). A loop with a history is a409saying to archive it instead, so what it did stays on record. Its Agent chat stays with its person.GET /loops/{id}/revisionslists every saved version, newest first: theactor,via(app,agent_chat,mcporapi), for the Agent thechatIdand themessageIdof the answer that made the change, and asummarysuch as "Added 3 steps".POST /loops/{id}/chatreturns the loop's Agent chat, starting and attaching one the first time. It needs a person who can use the Agent.
Drafts may be unfinished (no trigger yet, an AI step without instructions, a step not connected yet). A malformed definition is a
422 listing every problem by path: an unknown block, a step whose tool isn't its block's, a connection
from a path its block doesn't have or two steps on one path, a cycle, anything leading back to the trigger,
and numbers in a step's config (numbers and money are decimal strings, and a currency is an ISO 4217
code):
{"type":"https://docs.plutoads.ai/api/errors#invalid","title":"The request has 2 problems.","status":422,"code":"invalid","errors":[{"field":"definition/nodes/1/tool","message":"The pause block runs `set_delivery_status`."},{"field":"definition/nodes/1/config/budget","message":"Write numbers and money as decimal strings, e.g. \"50.00\"."}]}On and Off. PUT /loops/{id}/enabled (enabled, optional allowActions and expectedVersion, or
If-Match) turns a loop on or off and answers with the loop. Turning it on checks that it's complete: every
problem comes back as a 422 naming its step (steps/<id>, or loop for the loop as a whole), and a
schedule whose time zone Pluto can't tell (its own IANA zone, or the ad account's: the one its Find steps
name, or the one all the workspace's ad accounts share) is a 422 on steps/trigger. Turning it off stops
new runs; runs already going finish. Neither is a new version. Runs act for the loop's owner (ownerId, the
person behind its creator, or else the person who turns it on), as "Pluto Agent via" and the owner's name,
capped by the person who last changed its steps: a member who edits an admin's loop runs it with a member's
permissions. runsAs on the loop names whom its runs act as. Both are read on every run; a person who left
or can only view stops the run with a note. Loops never use credential, webhook, billing, member or
organization tools, nor any admin tool.
allowActions: true lets this version of the loop act without asking (budgets, bids, delivery, public comment
replies and moderation). Only a person with publish, signed in to the app, can
allow it, with the expectedVersion they reviewed (422 without it); an API key or agent gets 403. false
withdraws it, and so does turning the loop off. actionsAllowed on the loop names who allowed it and for which
version; once the loop is saved again current is false and it asks again. Deletions at Meta always ask,
and the loop's own limits and the guards below hold either way. Test runs: at most 20 per loop per person an hour
(429 after); their AI usage counts for whoever started them.
Triggers. trigger is the trigger block:
| Block | Starts a run |
|---|---|
schedule | At every: 15-minutes, hourly, daily at at, weekly on weekdays at at, or times, in timeZone (an IANA zone, or account for the ad account's) |
data-updated | After each Meta structure sync, insights import or either (scope: structure, insights or any) |
metric-threshold | When an object at level crosses the conditions over lookback, once per object that crosses |
budget-spent | When a campaign or ad set has spent percent (default 80) of its daily or lifetime budget, once per object; a daily budget can fire only before a time of day |
new-comment | On a new comment on the posts your ads run as (platform: both, facebook or instagram); other Page posts aren't read |
status-changed | When a campaign, ad set or ad at level changes status, or to to |
new-lead | On a new lead (form: part of the form's name, its ID, or empty for any) |
creative-uploaded | On a creative uploaded to the media library or to a launch (where) |
manual | Only by hand |
Local times that don't exist (clocks springing forward) run at the first moment after; times that happen twice run once; due times missed while runs couldn't start run once. Thresholds and budgets are watched while the loop is on and checked after each insights import. Event triggers follow the workspace's event stream within seconds and see only what happened after the loop was turned on. Each schedule time, sync or event starts at most one run; a schedule or data update due while a live run of the loop is still going starts none.
Runs. POST /loops/{id}/runs (mode: live, the default, or test) starts a run now, whatever the
trigger, and answers 202 with the run queued. The loop has to be complete (422 by step, as above) and
not archived. A live run while another live run of the loop is going is a 409. Retrying with the same
Idempotency-Key returns the same run. A test run reads your data, checks every condition and runs AI
steps, then plans every action, notification and wait and executes none: its steps are planned with what
they would call (tool, input) and, for changes that would ask first, the effect.
GET /loops/{id}/runsis the run history, newest first (modefor only live or test runs,after,limit). A run hasmode,status(queued,running,waiting,awaiting_approval,succeeded,failed,cancelled, orskippedwhen a loop with AI steps can't use AI because the organization is read-only; one skipped run a day records why),loopVersion(the version it ran),trigger(block,sourceand thesubjectit was about),startedBy,counts(items, and items by outcome:acted,planned,approval,no_action,skipped,deferred,failed;steps,actionsDone,actionsPlanned,approvalsPending) anderrorwhen it failed.GET /loop-runs/{id}returns the run withsteps(every step instance: one per step and scope, where the scope is""for the run or the item a For each found, such aseach-ad=123, withstatus,tool,input,output,summary,error, andavailableAtfor a waiting or deferred one),stepStates(per step across its items: the status that matters most, counts by status and what an AI step is doing),items(up to 500, each with itsoutcomeanddetail;itemsTruncatedsays there were more) andapprovals.POST /loop-runs/{id}/cancelstops a run that's still going: nothing more starts, its open approvals are cancelled and what already ran stays done. Cancelling a finished run answers with it.POST /loop-runs/{id}/undo(stepId,scope, optionalindex) sets back what a step changed in Meta: each budget or delivery status in the step'soutput.changesreturns to the value it replaced, or only the change atindex. It runs as the caller, who needs the permission each change needs (publish), and passes the same guards as any other change. It answers{ undone, refused }: the positions undone now, and each change that couldn't be with why. Undoing twice changes nothing more; a test run has nothing to undo (409).
A run advances along the connections: For each runs the steps after it once per item (up to 1,000 per step),
checks end an item's path when they don't match, If/else takes the first path that matches, Split takes
every path, a merge waits for all its paths (or the first, with join: any), Wait pauses (up to 30 days)
and Stop ends a path. A step that fails goes down its error path when its config.onError is path;
otherwise the run fails with the step's error. Steps run the same actions as the MCP tools, as the owner.
Loops change only objects Pluto manages (published or imported); others are skipped with a note.
The creative-has condition block uses tool: evaluate_creative_condition. Its config stores
predicateVersion: creative-predicates-v1, source: current (the current ad/media) or source: media
with mediaId, quantifier: any or all, and the structured condition described under Creative Memory.
Each run resolves current source and evidence versions afresh; it starts no inference. Unknown stays
unknown through negation and ends the path with item outcome no_action. Missing composition prevents
an unproven universal match or unproven absence across all assets.
For each processes at most 1,000 matching items per scope and reads at most 50,000 candidates. Its output
includes count, total, complete and truncated; narrow the scope when results are truncated.
A scheduled loop does not imply an unbounded, exhaustive whole-library monitor.
Approvals. Actions that need publish (budgets, bids, delivery, deletes in Meta, public comment
replies and moderation) wait for approval unless the version is allowed to act. The run is
awaiting_approval and its approvals carry the exact effect (money as decimal strings with the
currency), the permission approving needs and canApprove for the viewer. An approval expires after 24
hours.
POST /loop-approvals/{id}/approveruns the action once, as the approving person, and answers with the approval (approved, orfailedwith the reason). It needs the action's permission.POST /loop-approvals/{id}/reject(optionalreason, up to 1,000 characters) declines it; nothing changes and that item's path ends.
Only a person signed in to the app can approve or reject: an API key or agent gets 403. Deciding again
answers with the decision and runs nothing again; an approval of a run that ended is a 409.
Guards. Actions reach Meta through the publishing queue, within Meta's rate limits. Loops make at most
60 changes per ad account per hour and 4 budget changes per campaign or ad set per hour; the rest are
deferred until they fit. An object changed outside Pluto after the run read it, or within the last hour,
is skipped. A budget change moves at most +100% or -90%, never to 0 and only in the account's known
currency; a set amount stays within 10x of the current budget, and the result is rounded to the currency's
minor units. These per-change guards hold for every automated change: loops, change sets, agents and API
keys. Loops never set special ad categories.
Limits. A loop's limits live in its steps: the Agent step's maxChange and dailyCap (below), Change
budget's and Change bid's amount with min and max, Rebalance budget's maxShift, and Scale toward a
target's step, min and max. Turning something back on counts as raising spend (its daily budget counts
toward a daily cap). Every change is checked when it's proposed and again right before it runs, an approved
one too; one over a limit is skipped with the reason.
AI steps. Agent, Classify, Write reply and Summarize run on the Agent's models: config.model is
auto (the default) or a model the workspace allows (GET /agent/models). They count toward AI
usage like the Agent on every plan, including the free trial, recorded as source loop for the person the loop
acts for and the workspace it runs in; past a usage limit they fail with spend_limit. While the organization
is read-only, runs of a loop with AI steps are skipped, and a loop without them can only pause and
lower budgets; turning a loop off (PUT /loops/{id}/enabled) and archiving it still work.
Every AI step needs instructions (the older rules is accepted and saved as instructions); a loop can't be
turned on while one has none.
The Agent step manages budgets like a media buyer. Its config holds its guardrails next to its instructions:
accounts (all or one act_...), actions (any of budgets, status, bids), tools (the other catalogue
tools it may change things with; groups such as ["spend", "comments"] is a shorthand saved as actions and
tools, see the MCP guide), goal (profit, cpa
or roas) with goalValue (and currency for a CPA), maxChange (percent per object per run, default
"20", at most 100), dailyCap (in currency: what the ad account's active daily budgets may add up to,
counting changes on their way and the run's other changes; resuming and copies that start delivering count
too) and minSpend (over the last 7 days). Every change it makes is checked against them and the guards; ad sets in the learning phase keep their budget and bid.
Instructions like "figure it out" (or none, on a step saved before they were required) follow Meta's best
practice: small moves, scale what beats the goal, cut spend without results, pause ads before ad sets. ask decides when it asks: always (default:
every change in one change set per run), above-limits (budget rises over askAbove percent, default
"10", or askAboveAmount per day; resumes, campaign pauses and bids) or never (acts once the version is
allowed to act). Action blocks take ask too. A change set goes to the Inbox of the person the loop acts
for (a test run's to whoever started it), where it's approved, rejected or sent back with feedback; a newer
live run supersedes one still pending, and a test run's is marked as a test and can't be approved. The step's output has report (what changed, waits or
was left alone, with money and currency), changes (what it changed, each with the value it replaced, for
undo), proposed, planned and refused; Inbox, Email and Slack steps after it send the report. An Agent
step makes at most 40 model calls a run and stops cleanly at a usage limit: what it did stays, and the run's
error is { "code": "partial" } with why.
The loop's chat. A loop built by the Agent keeps the chat that built it as sourceChatId, from Home,
the Agent panel or the Loops page. A loop made by hand gets a chat the first time someone asks in its
builder, shared from the start. That chat is the loop's history and can't be deleted while the loop exists.
A chat that built a loop elsewhere stays private to its person until they share it
(PATCH /agent/chats/{id} with "shared": true; false stops sharing); until then, POST /loops/{id}/chat
answers 403 for anyone else. A shared chat: everyone who can see the loop can read it with
GET /agent/chats/{id}, and people who can use the Agent write in it. A proposed action in it is approved or
declined only by the person it was proposed for, or an organization owner or admin (403 otherwise). Send context.loopId (and context.selectedStepIds for the steps selected on the canvas) with a
message, and the Agent reads the loop's current definition and saves its changes as new versions, each
linked to the answer that made it.
Change sets
A change set is a plan of changes to live budgets, delivery and bids that a person reviews like a pull
request: they edit it, send it back with feedback, and approve one exact version, which Pluto then applies
change by change. Loops' Agent steps, the in-app Agent and your own agents propose them. Reading takes
read (guests get 403); money is always a decimal string with its currency.
curl -s -X POST "$PLUTO_API/change-sets" -H "Authorization: Bearer $PLUTO_KEY" \
-H "Idempotency-Key: $(uuidgen)" -H "Content-Type: application/json" \
-d '{"summary":"Scale Summer sale, trim Retargeting.","items":[
{"tool":"set_budget","input":{"adSetId":"120210000001","amount":"115"},"reason":"ROAS 3.4 over 7 days"},
{"tool":"set_budget","input":{"adSetId":"120210000003","amount":"90"},"reason":"Frequency above 4"}]}' \
| jq -c '{id,status,version,totals,items:[.items[]|{key,before,after,currency,percent}]}'
# {"id":"0192...","status":"pending","version":1,"totals":[{"adAccountId":"act_1234567890","currency":"USD","netDaily":"+5.00","increases":1,"decreases":1,"pauses":0,"resumes":0,"bids":0,...}],
# "items":[{"key":"ad_set:120210000001:budget","before":"100.00","after":"115.00","currency":"USD","percent":"15"},...]}POST /change-setsproposes:summary(Markdown for the person),items(1 to 500 calls ofset_budget,set_delivery_statusorset_bid, each with itsinputexactly as the tool takes it and an optionalreason) and an optionaltitle. It needswriteand a credential that acts for a person: the plan is for that person. Pluto builds each change from the call and the synced object:before,after(written in the currency's minor units),currency,percentandbasedOn(the value it was planned against and when Meta last changed the object). A call it can't show exactly, or one over the per-change guards, is left out with the reason inleftOut; a plan with nothing left is a422. WithchangeSetId(a plan you proposed, still open) and optionallyexpectedVersion, it makes that plan's next version instead and answers200. It expires 24 hours after it's proposed.GET /change-setslists plans, most recently changed first:status(openfor pending, revising and approving;decided; or statuses separated by commas),loopId,runId,chatId,mine=true(proposed for you),after,limit.GET /change-sets/{id}returns the plan with the shown version'ssummary,items(each withbounds, the lowest and highest value one change may take now, for editing and, once applied, itsresult),leftOut, andversions(each withsource:agent,feedbackoredit, thefeedbacktext, itsauthorand adiffof item keys added, removed and changed from the one before).?version=nshows an older version.canDecide,canApproveandcannotApproveReasonsay what the caller may do, andcanUndowhether they may undo applied changes.POST /change-sets/{id}/edit(version,remove: item keys,set:[{ key, after }]) makes a new version without AI. A value over the limits is a422namingset/<n>/after.POST /change-sets/{id}/feedback(text, optionalversion,retryConflicts) answers202with the planrevising: a loop's step runs again on fresh data with the previous version and the feedback (AI usage of the person the loop acts for), a chat's Agent answers in its chat (AI usage of whoever sent the feedback), and a plan from the API waits for its client to revise it withchangeSetId. Feedback, edits and approving while it revises are a409("Agent is updating this plan."). If no new version comes within 15 minutes it'spendingagain withrevisionError. A loop plan with no Agent step behind it (only action steps proposed it) is edited instead (409). After applying,retryConflicts: true(withtextoptional) sends back only the changes that conflicted: the next version holds just those, planned again from the current values, pending for 24 hours;canRetryConflictssays when it's offered.POST /change-sets/{id}/approve(version) applies exactly that version, as you, change by change under its own key, so a retry never applies anything twice. Each change is re-read first: a value that moved since the plan was made isconflict(never applied onto the new value), a change a limit stops now isblocked, one Meta refuses isfailed, the restapplied(Meta accepted it; once a later read of the object is newer,result.readbackislive, ordifferswith the value Meta shows). Anotheritem (a duplicate, a comment action) runs as bound, without a conflict or limit check. The plan endsapplied,partially_appliedorfailed. A newer version is a409whosecurrentis the plan now; a plan already decided is a409"Already decided."; so are a test run's plan, an empty one, and one whose loop was archived, whose run stopped or whose chat was deleted.POST /change-sets/{id}/reject(optionalreason, up to 1,000 characters) declines it. The reason goes back to the loop for its next run.POST /change-sets/{id}/items/{key}/undoputs an applied change back to its value before, through the same per-change guards;result.undosays how it went. It needspublish.
Who decides. The person a plan is for, or an organization owner or admin, may edit it, send it back,
approve or reject it (403 otherwise); approving and undoing also need publish. Approving and rejecting
are for a person signed in to the app: API keys and agents get 403, so an agent never approves what an
agent proposed. Loops. A run's steps that ask (Agent steps and action steps) propose one change set per run (with origin.runId)
and its run waits. A newer live run of the loop replaces a plan still waiting (superseded, with
supersededBy). Live updates. Every change announces the change-sets collection; the person's Inbox
item (kind: change_set, with changeSetId) is refreshed and unread again on each new version.
Agent
The Agent is the AI assistant in the app (see Agent). Its chats belong to a
person: an API key gets 403, and another member's chat is a 404, except a loop's chat its person
shared, which everyone who can see the loop reads. The Agent calls the same actions
as the MCP tools, named "Pluto Agent via" and the person's name, with the
person's permissions except publish. Tools that spend or change what's live, change members, credentials,
billing or settings, send something outside Pluto, or delete what can't be brought back never run from the
model on their own: they raise an approval, and only the person, signed in to the app, can approve it.
When the Agent asks is each person's choice, their agentApprovals preference
(PUT /preferences/agentApprovals, Settings, Agent in the app):
| Value | In the app | What asks |
|---|---|---|
risky (default) | Ask for risky actions | The actions above |
always | Ask every time | Every action that changes something; reads never ask |
auto | Run everything | Nothing, apart from the rules below |
With Run everything the Agent runs each action as the person, exactly as their approval would: their role, their access and the action's own permission still apply, and an action they can't run still raises an approval for someone who can. It still asks for:
- a budget or bid increase, or new spend (publishing, resuming, a copy that starts delivering), above the workspace's budget guardrail, and any such change Pluto can't measure from what it synced; decreases and pauses never ask;
- changing people (invitations, roles, removals), credentials and signing secrets (their secret is shown once, on the approval), AI usage limits and these approval settings, and deleting a workspace.
Only a person signed in to Pluto chooses auto (an API key or agent gets 403, and the Agent can't change it),
and only where the workspace lets members, or as an owner or admin. It applies to the person's own chats; loops
keep their own "Ask before acting".
-
GET /agent/approval-settingsreturns the workspace's rules:membersCanTurnOffApprovals(members may choose Run everything; default true),budgetGuardrail(percent, default"50", and an optionalamountper change with itscurrency, which applies only to ad accounts in that currency; decimal strings),version, and for the callercanEdit,canRunEverythingand areasonwhen they can't.PUT /agent/approval-settings(owners and admins; either setting, andexpectedVersion; a newer save is a409withcurrent) changes them. MCP has the same asget_agent_approval_settingsandset_agent_approval_settings. -
An approval's
titlesays what it does in one sentence with real names, never IDs (Delete loop "Facebook comment moderator"), andeffect.donesays the same once it ran (Deleted loop "Facebook comment moderator").effect.linesare human details (for a loop, its trigger and status);effect.summaryis empty when it would only repeat the title. -
Pluto's Agent runs on Claude Opus 5.5.
GET /agent/modelslists the models you can use (today one,claude-opus-5-5), each withid,provider,name,efforts(reasoning effort levels it accepts), anddefaultModel, what a chat uses when you give none.PUT /agent/models/settings(owners and admins;defaultModel,allowedModelsornullfor all,expectedVersion) keeps the workspace's choice among them. MCP has the same aslist_modelsandupdate_model_settings. -
POST /agent/chats(source:page,panel,homeorloop; optionalmodelandeffort) starts a chat.modelis a modelidfromGET /agent/models(orauto, which is the same model); any other model answers422onmodel, and an effort the model doesn't accept answers422oneffort. The chat summary'smodelandeffortare the chat's choices.GET /agent/chatslists chats with messages, most recently updated first (qfilters titles).GET /agent/chats/{id}returns a chat with its newestmessages(100 by default,limitup to 200) and theapprovalsthey carry;hasEarliersays older messages exist, andbefore(a messageid) returns the page before that message; itsownerIdis its person,loopIdthe loop it is the history of andsharedwhether that loop's viewers read it.PATCHrenames it (title, 1-120 characters) or, for a chat with aloopId, shares it (shared;422for any other chat), only by its person,DELETEdeletes it with its messages (a loop's chat is a409while the loop exists), andPOST /agent/chats/{id}/readmarks it read. -
POST /agent/chats/{id}/messages(text, up to 16,000 characters, and optionalcontext:path,launchId,adId,mediaIds, up to 20 media library items to attach,loopIdandselectedStepIdsfor the loop open in the builder, and optionalmodelandeffortto switch the chat's model or effort from this message on) sends a message and answerstext/event-streamwith the turn. The text may be empty when media is attached. The message lists them asattachments, and the Agent uses them by ID (for example to create ads). A turn already running in the chat is a409. Sending closes the chat's open approvals assuperseded. -
POST /agent/chats/{id}/stopstops the running turn; steps that already ran stay done.POST /agent/chats/{id}/retryruns a failed or stopped turn again, as a stream. -
POST /agent/approvals/{id}/approveruns the approved action as the person, once, and continues the turn as a stream. It needs the permission the action needs (publish,admin, or with Ask every time the action's own). An approval already decided answers with its decision and runs nothing again. A launch that changed since the card was raised fails the approval with the conflict's message. An action that creates a secret (an API key, an agent client, a webhook signing secret) returns it once in this stream asresult.secret; it is never stored, so the chat shows it only there.POST /agent/approvals/{id}/reject(optionalreason) tells the model the person declined. -
Agent usage is metered per organization: see Billing (
GET /organizations/{id}/usage). Each answer'smodel, and each usage record'sproviderandmodel, name the model that did the work.
Each stream event has a name and a JSON data line; lines starting with : keep the connection open:
| Event | Data |
|---|---|
chat | chat: the chat's summary; first, and again when the title arrives |
message | message: the stored user message, or the Agent message when it starts or changes shape |
text | messageId, delta: answer text to append |
answerReset | messageId: the text since the last step was narration and moved into a note step |
thinking | messageId, stepId, delta: summarized reasoning |
step | messageId, step: a tool call, note or thinking step started, changed or finished |
approval | approval: raised or decided, with its effect (money as decimal strings with currency) |
error | code, message, retryable and, for a limit, reason: the turn failed |
done | message, chat: last event |
Closing a stream doesn't stop the turn; it keeps running and is saved. Every model call is checked against
the organization's usage limits before it runs: the member's, the workspace's and the organization's (see
Billing). Over one, sending a message answers 402 with code spend_limit and a reason naming the
limit (member_limit, workspace_limit, spend_limit, included_used, trial_cap or complimentary_used);
without access it answers 402 payment_required. A limit reached during a turn ends it with an error event of that code and
reason:
{"type":"https://docs.plutoads.ai/api/errors#spend_limit","title":"The workspace Spring reached its AI usage limit for this billing period. An organization owner or admin can raise it in Organization settings, Spending; it resets on October 26.","status":402,"code":"spend_limit","reason":"workspace_limit","targetId":"0192c9a4-7c1e-7b6e-9a51-3f1d2c4b5a60","targetName":"Spring","limit":"150.00","used":"150.02","currency":"usd","raisedBy":"organization_admin","resetsAt":"2026-10-26T00:00:00Z","usageUrl":"https://app.plutoads.ai/settings/organization/spending"}Agent personalization
A person's own guidance and skills for the Agent, in this workspace (Settings, Agent in the app). They belong to the person like chats: the signed-in person, or the person a connected agent or an mcp
agent client acts for, reads and changes their own; an API key or M2M client without a person gets 403, a
guest gets 403, and another person's skill is a 404. Reading needs read, changing needs write. MCP has
the same as get_agent_guidance, set_agent_guidance, list_agent_skills, get_agent_skill,
create_agent_skill, update_agent_skill and delete_agent_skill. The in-app Agent reads them but never
writes them, so nothing it reads in an ad, a comment or a file can change how it answers you later.
- Guidance.
GET /agent/guidancereturnstext,version(0 before the first save) andupdatedAt.PUT /agent/guidance(text, up to 4,000 characters, empty clears it; optionalexpectedVersion, a newer save is a409withcurrent) replaces it. It goes into the system prompt of the chats you start from then on, after the Agent's own rules: it never grants a permission, skips an approval or turns data into instructions. A loop's chat, which is shared from the start, and a loop's steps never read it: a loop runs on its own Instructions, whoever owns it. - Skills. Reusable prompts, like Anthropic's Agent Skills:
a
name(up to 64 lowercase letters, numbers and single hyphens, unique per person), adescription(1-1,024 characters, no tags: what it does and when to use it) and the prompt,body(up to 20,000 characters); up to 50 per person.GET /agent/skillslists them by name without their bodies,POST /agent/skillscreates one (201; a name you already use is a422onname),GET /agent/skills/{id}returns one with its body,PATCH /agent/skills/{id}changes any ofname,descriptionandbodyoverexpectedVersion, andDELETE /agent/skills/{id}deletes it (204). - How the Agent uses them. Every skill's name and description is in the system prompt of the chats you
start; when a request fits a description, the Agent loads that skill's body (
get_agent_skill) and follows it. A message that starts with/nameruns your skill of that name: the message is stored as you wrote it, and the skill's body goes to the model with it. Like guidance, a skill never grants anything. - Guidance and the skill list are read when a chat starts (the system prompt is fixed per chat);
/namealways runs the skill as it is now.
Feedback and "Help improve Pluto's AI"
- Rating answers.
PUT /agent/chats/{id}/messages/{message_id}/feedbackrates one of the Agent's answers:ratingupordown, an optionalreasonwhen down (wrong_answer,ignored_request,wrong_changes,too_slow,other) and an optionalcomment(up to 500 characters). One rating per answer and person; rating again replaces it,DELETEon the same path clears it (204), andGET /agent/chats/{id}/feedbacklists yours in the chat. Your own chat or a loop's shared chat you can read; another person's chat is a404, rating a question instead of an answer a422, guests403. Thecommentis kept only while the organization has "Help improve Pluto's AI" on (comment: nullotherwise). - Signals. Besides thumbs, Pluto learns from what you do: approving or rejecting what the Agent proposed, undoing its changes, and writing right back to correct or re-ask an answer (only that it happened is kept, never your words).
- Help improve Pluto's AI.
GET /organizations/{id}/ai-improvementreturnsenabledandchangedAt(members; guests403);PUTwith{"enabled": true|false}changes it (owners and admins; a credential needs theorganizationscope; it works while the organization is read-only, since it's a privacy choice). Off by default. While it's on, the text of the organization's Agent and loop AI runs (questions, answers, tool inputs and results) is kept with their traces, redacted of secrets, so Pluto can find and fix failures; once it's turned off, kept text is deleted within 30 days. It's never used to train models. Off, Pluto keeps only metadata: tokens, timings, tool names and outcomes. MCP:get_ai_improvement,set_ai_improvement(the in-app Agent can't change it).
Billing
Billing belongs to the organization, which holds one or more workspaces (brands or clients): one
subscription and one invoice for all of them. There are three plans: Pro (pro: per member; 1 workspace
included and up to 5, each extra one a monthly add-on), Business (business: a base price that includes 5
members and 10 workspaces, then per extra member and per extra workspace with no cap, priced in steps for
workspaces 11-25, 26-100 and 101 and up, each lower per workspace; workspace limits, usage by workspace and connected agent, and the CSV export)
and Enterprise (enterprise, through sales: volume pricing and custom terms on a 1 to 3 year agreement,
invoiced with net 30 terms). Every new organization starts a 14-day free
trial: one workspace, a set amount of AI usage for the whole trial, 5 deep analyses, 50 GB of media and usage by
member and source. The Agent and Loops work on every plan, including the trial. Guests are free and unlimited, and
there's no limit on Meta ad accounts, Pages or Instagram accounts. Each paid member includes AI usage, pooled
across the organization; on-demand usage beyond it is billed monthly after use at each model's rates
(Models & rates), only when an owner or admin turns it on, up to a monthly limit. Media storage is pooled too; extra
storage is billed per GB-month on the daily average, when an owner or admin turns it on. Prices are on the
pricing page, and the overview below returns them with this organization's
numbers. See Billing for what people see.
Your own AI clients calling this API or the MCP server aren't billed as usage; only the rate limits apply.
The routes take the organization's ID (GET /organizations lists yours). Billing and limits are for the
organization's owners and admins; a credential needs organization access (with admin), which only an
organization owner or admin gives. An agent with it can read billing, start a subscription, change the plan,
limits, extra storage and billing details, but payment always happens in a browser. The payment routes return a Stripe URL for a person
to open; the subscription changes once Stripe confirms it, usually within seconds. Usage is readable by everyone
who isn't a guest: members get their own, owners and admins the whole organization.
-
GET /organizations/{id}/billing(organization) is the overview:plan({key, name},nullduring the trial and without a subscription) withsource(subscription,trial,complimentaryornone),status,access(full,graceornone) withaccessReasonandaccessUntil,limits(what the plan or trial includes now:workspaces,nullfor unlimited,storageGb,includedUsagePerMember,includedMembers,features),subscription(cadence, period end, cancellation, and a scheduled change asscheduledPlan,scheduledCadenceandscheduledAt, ornull),members(paid) andguests,workspaces(used,cap,included,price: what the next workspace adds, at its step, andtiers),includedUsage,plans(Pro, Business and Enterprise:key,name,terms,selfServe,fits,recommended,current,support,yearlyDiscountPercentandestimatesfor this organization, monthly and yearly),volumePricing,enterprise,trialAvailable,trial(endsAt,daysLeft,workspaces) ornull,readOnlyornull,storage,nextInvoice(amount,currency,date), recentinvoices,paymentActionRequired,syncedAt,salesUrl(book a call with sales: Enterprise, volume pricing, agreed limits and custom pricing),contactUrl(the contact page, to send the team a message) andsupport(the support level:emailon the trial and Pro,priorityon Business,dedicatedon Enterprise).workspaces.tiersisnullexcept on Business, where the price of an extra workspace falls with volume: each step hasfromandto(workspace numbers: 11 to 25, 26 to 100, and from 101 withtonull),amount(per workspace per billing interval of the subscription, or of the estimate's cadence) andbilled(how many of the organization's workspaces are in that step now).workspaces.price(amount,currency,interval) is what the next workspace beyond the included ones adds, at its step;nullwithout a subscription and on Enterprise (extras are at the agreed rate).- Each estimate has
cadence,members,billedMembers,workspaces,billedWorkspaces,baseAmount,memberAmount,workspaceAmount,workspacePrice(the next workspace's price),workspaceTiers(the steps as inworkspaces.tierswith this estimate'sbilled;nullon Pro),totalandcurrency. recommendedmarks the self-serve plan that fits the team, exactly one of Pro and Business: Pro when the workspaces fit Pro (up to 5) and Pro costs no more than Business for these members, otherwise Business.fitssays only whether the workspaces fit the plan's cap.volumePricingistrueon Business from 100 workspaces: offer a quiet "Talk to us about volume pricing" withsalesUrl. It's an option, never a requirement or a refusal.enterpriseisnullwithout an Enterprise agreement. Otherwise it hasagreement(startsAt,endsAt: the date the commitment runs until,termYears: 1 to 3,year: the current contract year,invoicing:yearlyupfront ormonthlyfor the same commitment,paymentTerms:net_30orcard,renews, andrenewal: the next term'sstartsAt,endsAtandtermYearsonce it's agreed, elsenull),membersandworkspaces(contracted,used,extra: beyond the agreement, added to the next invoice at the agreed rate and never blocked,percent),includedAi(this month's pool:contracted,used,percent,currency,periodStart,periodEnd; beyond it AI continues on demand within the organization's limit) andnotices(metric:members,workspacesorincluded_ai,threshold:80or100, and a ready-to-showmessage; from 80% of what's in the agreement, highest first). On Enterpriselimits.workspacesandworkspaces.caparenullandworkspaces.includedis the agreement's workspaces.
Each invoice has
id,number,status,periodStart,periodEnd,subtotal,tax,total,amountDue,currency,customerTaxIds,reverseCharge(truewhen the reverse charge applies),collection(card: charged to the payment method automatically, orinvoice: sent and paid within 30 days, on Enterprise),dueDate(invoiced ones, elsenull),poNumber(the PO number printed on it, elsenull),hostedInvoiceUrl,pdfUrlandlines(description,kind:plan,member,workspace,ai_usage,deep_analysis,storage,prorationorother,quantity,unitAmount,amount,periodStart,periodEnd, andtier: on Business workspace lines the step they bill, labelled by its first and last workspace number joined with an en dash, or101+for the last, with one line per step; elsenull). Lines come in a fixed order: plan, members, workspaces (step by step), AI usage, deep analyses, storage, then prorations and anything else.paymentActionRequiredisnull, or the open invoice whose payment waits for the customer's authentication (invoiceId,hostedInvoiceUrl,amount,currency): a person opens the link to confirm the payment.storagehasincludedGbandincludedBytes,usedGbandusedBytes,workspaces(workspaceId,name,usedGb,usedBytes) andextra(available,enabled,limit,isDefault,version,pricePerGbMonth,maxExtraGb,maxExtraBytes,extraGb,extraBytes,costSoFar). Amounts and GB are decimal strings; each…Gbis rounded to 2 decimals and its…Bytestwin is the exact integer. Storage uses one unit everywhere, the one it's billed in: 1 GB = 1024³ bytes (so 500 GB included is 536,870,912,000 bytes). -
POST /organizations/{id}/billing/checkout(organization;plan:proorbusiness,cadence:monthlyoryearly) answers201with theurlof a Stripe Checkout page for the plan and the organization's members. Paying ends the trial at once. An organization that already subscribes gets a409(reason: subscribed; change the plan instead), and one with more workspaces than the plan includes a409(reason: too_many_workspaces). A command: sendIdempotency-Key. -
POST /organizations/{id}/billing/plan(organization;plan, optionalcadence) changes a subscribed organization's plan and returns the overview. Pro to Business applies at once, prorated and invoiced right away. Business to Pro applies at the end of the period (subscription.scheduledPlan) and needs 5 workspaces or fewer, as many as Pro holds: otherwise409(reason: too_many_workspaces; move or archive workspaces first, nothing is deleted). While it's scheduled, creating a workspace past 5 answers402 plan_limit(reason: scheduled_plan_cap,nextAction: keep_plan). Workspace limits stop applying on Pro and are kept; they apply again on Business. An Enterprise plan answers409(reason: contact_sales), an organization without a subscription409(reason: not_subscribed), and upgrading while switching to monthly billing409(reason: one_change_at_a_time: upgrade first). A command. -
DELETE /organizations/{id}/billing/plan(organization) cancels a scheduled plan change and returns the overview. -
POST /organizations/{id}/billing/trial(organization) starts the free trial for an organization that has no plan and never had a trial or a subscription (trialAvailablein the overview) and returns the overview; while a trial already runs it changes nothing, and otherwise it's a409(reason: trial_unavailable). -
POST /organizations/{id}/billing/portal(organization; optionalflow:manage, the default,payment_methodorcancel) answers201with theurlof the billing portal, where a person updates the payment method, downloads invoices, changes billing details, cancels, or resumes a scheduled cancellation (manage). Plans change here, not in the portal. An organization that never subscribed gets a409. On Enterprise the portal has payment methods, invoices and billing details but no cancelling: an agreement runs until its end date, soflow: cancelanswers409(reason: agreement_term,runsUntil,nextAction: contact_sales,salesUrl,contactUrl). -
GET /billing/rates(public, no credential) lists every model's usage rates:currency(usd) andmodels(model,provider,input,cacheWrite,cacheRead,outputper million tokens,webSearchPerThousand, and for models with long-context rateslongContextThreshold,longContextInput,longContextOutput), as decimal strings. The same table is on Models & rates. -
GET /organizations/{id}/billing/details(organization) returns what invoices show about the customer:name(the business's legal or billing name, never a person's),email(the billing email, which receives invoices and receipts),address(line1,line2,city,postalCode,state,country: ISO 3166-1 alpha-2),taxIds(id,type,value,country,verification:pending,verified,unverifiedorunavailable),taxExempt(none,exemptorreverse: an EU business with a valid VAT ID in another EU country is reverse charged),taxStatus(ready;address_needed: add or complete the address, and a US address needs itsstateandpostalCode; ornot_collecting: no tax is collected there),poNumber(the PO number printed on every invoice, Enterprise only, elsenull) andpaymentTerms(card: charged automatically, ornet_30: Enterprise invoices due within 30 days; read-only). -
PUT /organizations/{id}/billing/details(organization;name,email,address,poNumber) changes them and returns the details. They apply from the next invoice; tax follows the new address.poNumberis 1 to 40 characters andnullremoves it; it's for Enterprise, and other plans get a422onpoNumber. -
POST /organizations/{id}/billing/tax-ids(organization;type, for exampleeu_vat,gb_vat,no_vat,ch_vat,au_abnorca_gst_hst, andvalue) adds a tax ID and returns the details. The format is checked at once; EU VAT numbers, UK VAT numbers and Australian Business Numbers are then verified against the government register, andverificationchanges frompendingwhen that finishes. -
DELETE /organizations/{id}/billing/tax-ids/{tax_id}(organization) removes a tax ID and returns the details. -
POST /organizations/{id}/billing/sync(organization) reads the subscription from Stripe now and returns the overview. Stripe notifies Pluto Ads of every change by itself; use this right after a person comes back from a Stripe page. -
PUT /organizations/{id}/billing/on-demand(organization;enabled,limitas a decimal string ornullfor the default,expectedVersion) turns usage beyond what's included on or off and sets the organization's monthly limit. A stale version is a409whosecurrentis the current setting. Turning it on needs a paid plan: during the trial, with complimentary access or without a subscription it's a409withreasontrial,complimentaryorno_subscription. A command. -
PUT /organizations/{id}/billing/storage(organization;enabled,limitas a decimal string ornullfor the default,expectedVersion) turns extra storage on or off and sets its monthly limit; it answers with thestorageobject. Only for an organization with a subscription (extra.available). A command. -
GET /organizations/{id}/usage(?period=currentorprevious) is AI usage for a billing period:scope(organizationfor owners and admins,selfotherwise),period,currency,included(amount,used),onDemand(enabled,spent,limit),used,stopReason,bySource(human: people's Agent chats,agent: actions the Agent takes for people,loop,api_mcp: your own AI clients, never charged),byWorkspace,byDayandresetsAt. Amounts are decimal strings. Owners and admins can filter byuserId,source(human,agent,loop,api_mcporcreative_memory),loopIdandmodelon every plan, and on Business byworkspaceIdandagentId(a connected agent's credential). Without Business,byWorkspaceis empty. -
GET /organizations/{id}/usage/breakdown(by:member, the default,source,loop,model,type(the kind of usage),workspaceoragent;periodand the filters above) listsitemswithkey,label,amount,requests,inputTokensandoutputTokens. By member, source, loop, model and type works on every plan, including the trial; members get their own.by=workspaceandby=agentare Business.by=agentgroups by connected agent (an MCP client, agent client or API key, by name;keyis the credential's ID): their own AI isn't charged, soamountis"0"andrequestscounts their activity. -
GET /organizations/{id}/usage/records(period, the filters above,q,after,limit1-500) pages through the usage records, newest first: when, source and its reference (a chat, a loop, a credential), person, workspace, provider, model, tokens (input, output, cache write, cache read) and the charge with its included and on-demand parts (nullwhen the model isn't priced yet). Members see only their own. Without Business,workspaceIdandworkspacearenull. -
GET /organizations/{id}/usage/export(same filters) answerstext/csvwith the same records, every one of the period's records however many there are (streamed). Business (the trial has no CSV export); members get their own. -
GET /workspace/usageis the current workspace's AI usage this billing period, its limit and its storage (storageGb, 2 decimals, andstorageBytes, exact; for members, their own usage): the cost of one workspace. Business. -
GET /organizations/{id}/billing/workspace-costs(organization;period:current, the default,previousor a billing period'sidfromcycles; orfromandto,YYYY-MM-DD, UTC, at most 400 days; orinvoice, an invoice'sidfrom the overview) is what each workspace cost in the period, or on that invoice, so an agency can rebill its clients. Withinvoiceit's that invoice's per-client statement, andtotals.totalequals the invoice's subtotal before tax, to the cent. Every invoice's footer points customers to it ("Usage by client: see Plan & billing in Pluto.").itemshas one row per workspace (active ones, and archived or deleted ones that cost something in the period, oldest first) withworkspaceId,name,status(active,archivedordeleted),fee(its share of the workspace add-on:0.00for the workspaces the plan includes, prorated when a workspace was added or removed mid-period; on Business the add-on's steps are shared evenly, so each extra workspace carries the average price),aiIncluded(AI usage paid by the included pool),aiOnDemand(AI usage billed on demand),storage,total(fee + aiOnDemand + storage) andcurrency, plusstoredBytes(the media stored in the workspace now, exact bytes; for reference, since storage is pooled and billed for the organization). The last row,Shared(workspaceId: null,status: shared), is what no workspace causes: the plan's base price and members (breakdown:plan,members,adjustments), extra media storage (pooled across the organization; for a period,storageGbMonthsis the extra storage it bills, in GB-months with 6 decimals) and usage recorded outside a workspace. The rows add up tototals, which equalinvoiced, the period's charges before tax on its invoices (for the current period, with the upcoming invoice), once the period's usage is metered (hourly).cycleslists the billing periods, newest first.format=csvorAccept: text/csvanswers the same rows as CSV with the columnsperiod_start,period_end,workspace_id,workspace,status,fee,ai_included,ai_on_demand,storage,total,currency,stored_bytesandstorage_gb_months. Owners and admins; Business, like the usage CSV export (402plan_limitotherwise). -
GET /organizations/{id}/usage/limits(organization) returns the limits:onDemand(the organization's),workspacesandmembers, each withlimit(null= none),usedthis period andversion, andworkspaceLimits: whether workspace limits apply on this plan. Without Business, stored workspace limits are listed but don't apply; they apply again on Business. -
PUT /organizations/{id}/usage/limits/workspaces/{workspace_id}andPUT /organizations/{id}/usage/limits/members/{user_id}(organization;limitas a decimal string ornullto remove it,expectedVersion) set a monthly limit for one workspace (included and on-demand usage attributed to it) or one member. Setting a workspace limit is Business; removing one (null) works on every plan. A command.
Without Business, the Business-only routes and filters answer 402 plan_limit (feature: workspace_limits or
usage_breakdown, nextAction: upgrade, upgradeTo: business).
ORG=$(curl -s "$PLUTO_API/organizations" -H "authorization: Bearer $PLUTO_API_KEY" | jq -r '.items[] | select(.current) | .id')
curl -s "$PLUTO_API/organizations/$ORG/billing" -H "authorization: Bearer $PLUTO_API_KEY" | jq '{plan, source, members, workspaces, nextInvoice}'
curl -s "$PLUTO_API/organizations/$ORG/usage/breakdown?by=member" -H "authorization: Bearer $PLUTO_API_KEY" | jq '.items[:5]'
curl -s -X PUT "$PLUTO_API/organizations/$ORG/billing/on-demand" -H "authorization: Bearer $PLUTO_API_KEY" \
-H 'content-type: application/json' -H "idempotency-key: $(uuidgen)" \
-d '{"enabled":true,"limit":"100.00","expectedVersion":1}'
curl -s -X POST "$PLUTO_API/organizations/$ORG/billing/checkout" -H "authorization: Bearer $PLUTO_API_KEY" \
-H 'content-type: application/json' -H "idempotency-key: $(uuidgen)" \
-d '{"plan":"business","cadence":"yearly"}' | jq -r .url
# hand the URL to an owner or admin; after paying they land on the app's billing page
curl -s -X POST "$PLUTO_API/organizations/$ORG/billing/plan" -H "authorization: Bearer $PLUTO_API_KEY" \
-H 'content-type: application/json' -H "idempotency-key: $(uuidgen)" \
-d '{"plan":"business"}' | jq '{plan, subscription}'Access and limits. Access follows the subscription (or the trial): trialing and active subscriptions have
full access; after a failed payment (past_due) the organization keeps access for a grace period while Stripe
retries the card, and a subscription is never cancelled for a failed payment; an organization without access
(a trial that ended, a canceled subscription, an unpaid invoice past the grace period) is read-only until it
subscribes or pays. The same list applies to sessions, API keys and MCP. What still works:
- Reads, including exports (
ads.csv,media.zip, the account's data). - Pausing campaigns, ad sets and ads, and lowering budgets. Resuming and raising are refused.
- Turning a loop off and archiving it, and approving a change set that only pauses or lowers budgets.
- Cancelling a publish, a job or a loop run; revoking API keys, agent clients and agent connections; deleting webhooks; disconnecting Meta, Slack and Pluto Profit.
- Archiving or deleting workspaces, deleting the organization, removing members and leaving.
- The account routes, and billing and usage.
Everything else answers 402 payment_required with accessReason. Manual refreshes (POST /leads/sync,
POST /social/comments/sync) wait for a plan; background syncs from Meta continue. Something the plan doesn't
include answers 402 plan_limit with feature (workspaces, workspace_limits, usage_breakdown or media_storage) and the
next step (upgrade with upgradeTo, contact_sales with salesUrl and contactUrl, or keep_plan). A sixth
workspace on Pro answers plan_limit with feature: workspaces, reason: plan_cap, limit: 5 and upgradeTo: business; Business and Enterprise have no workspace cap. When the Agent (or a loop's AI step)
reaches a limit, it answers 402 spend_limit with the reason (trial_cap, included_used, spend_limit,
workspace_limit, member_limit or complimentary_used). See API errors.
Contact
POST /contact sends the Pluto Ads team a message, the same one the contact page
sends. It needs no credentials. The team answers by email.
| Field | Required | What it is |
|---|---|---|
name | yes | Who to answer, 1-200 characters |
email | yes | The email to answer |
company | no | The company, up to 200 characters |
topic | yes | sales, support, partnerships or press |
message | yes | The message, 1-5,000 characters |
source | no | Where it was sent from, up to 120 lowercase letters, digits and -/_., such as docs/billing |
website | no | Leave it empty. A form field people don't see; a message with it filled in is accepted and dropped |
curl -s "$PLUTO_API/contact" -H 'content-type: application/json' \
-d '{"name":"Anna","email":"anna@example.com","topic":"sales","message":"We run ads for 30 brands."}'It answers 202 with {"received": true}, 422 with every field problem at once, and 429 with Retry-After
after five messages an hour from one address or three an hour for one email. To talk to someone instead, book a
call with sales: salesUrl in the billing overview.
Sync and realtime
The app keeps a copy of the workspace on each device, so it opens instantly and keeps working offline. It needs no polling. These routes serve the app; integrations rarely need them.
GET /sync/tokenreturns a five-minute sync token (token,endpoint,expiresAt) for the signed-in person's device.GET /sync/jwkspublishes the keys that verify it.POST /sync/uploadapplies a device's queued offline edits (ops, at most 500 per call). Each op carries its own command ID and expected version, runs the same command the API runs, and gets its own outcome, so one refused edit never blocks the rest. Edits to different fields of the same item both apply; the same field conflicts, as in Versions and conflicts.GET /realtimeupgrades to a WebSocket for presence and live updates. The client sends{"type":"join","room":...},leave,focusand apingabout every 10 seconds; the server sendspresence(who is in a room and what each focuses on) andinvalidate(a collection changed). Rooms areworkspace,launch:<id>,ad:<id>andmedia.GET /realtime/rooms/{room}returns who is in a room without a socket.
Agents that write through the API show in the presence of the launches, ads and media they change, so people see them work.
Rate limits
Budgets are per 10-second window, per credential and per workspace:
| Budget | Requests per 10 seconds |
|---|---|
| Each API key or agent client | 1,000 |
| Each person in the app | 300 |
| All API keys and agent clients of one workspace together | 3,000 |
Each person's device sync (/sync/token, /sync/upload) | 120, separate from the person's 300 |
The workspace budget stops many keys from adding up to more than one workspace should send. People
don't count against it, so a swarm of agents that uses up the workspace budget never slows down the
people in the app. Device sync has its own lane, so a busy person's other requests can't stall their
sync, and sync can't use up their request budget. Over any budget, the API answers 429 with
Retry-After in seconds:
curl -s -i "$PLUTO_API/launches/stats" -H "authorization: Bearer $PLUTO_API_KEY" | grep -i 'retry-after\|rate_limited'
# retry-after: 2
# {"type":"https://docs.plutoads.ai/api/errors#rate_limited","title":"Too many requests. Try again in 2 seconds.","status":429,"code":"rate_limited"}Wait Retry-After seconds, then retry with the same idempotency key. The response looks the same for
the credential's and the workspace's budget. A request refused by its credential's budget doesn't count
against the workspace's. Provider limits are separate: Meta caps changes per ad account, and publishing
jobs pace themselves to those caps.
One route has its own workspace-wide budget because it spends Meta's: POST /analytics/refresh starts at
most 2 new imports per workspace every 15 minutes (see Analytics).
Errors
Errors are application/problem+json (RFC 9457). code is stable; title is written for the person or
agent reading it and never contains provider or database internals. Validation returns every field
problem at once in errors. Each type links to its section of API errors.
curl -s "$PLUTO_API/launches" -H "authorization: Bearer $PLUTO_API_KEY" \
-H 'content-type: application/json' -d '{"name":""}'
# {"type":"https://docs.plutoads.ai/api/errors#invalid","title":"The request has 1 problem.","status":422,"code":"invalid","errors":[{"field":"name","message":"Use a name of 1 to 120 characters."}]}| Status | code | Meaning | What to do |
|---|---|---|---|
| 400 | bad_request | Malformed JSON, wrong types, a bad header | Fix the request; title names the problem |
| 401 | unauthenticated | No credential, or it is invalid, expired or revoked | Get a new credential |
| 403 | forbidden | Missing permission, or another workspace | Ask an admin for the permission |
| 404 | not_found | Not in this workspace | Check the ID and workspace |
| 409 | conflict | Stale expectedVersion (with current), a reused idempotency key (reason), a launch not ready to publish (with items), something live or archived | Re-read, merge, retry |
| 422 | invalid | Field problems, all listed in errors | Fix every listed field |
| 429 | rate_limited | Over the credential's or the workspace's budget | Wait Retry-After seconds |
| 402 | payment_required | The organization has no access, so it's read-only (accessReason, upgradeUrl) | An owner or admin subscribes or fixes the payment method |
| 402 | plan_limit | The organization's media storage is full, the plan holds no more workspaces, or it's a Business feature (feature, reason, limit, used, nextAction, upgradeTo) | Turn on or raise extra storage, or upgrade to the plan in upgradeTo |
| 402 | spend_limit | The Agent or a loop reached a limit this period (reason, limit, used, currency, resetsAt, usageUrl) | An owner or admin raises the limit named in reason, or wait for resetsAt |
| 409 | workspace_archived | The workspace is archived: readable, not changeable | An organization owner or admin restores it |
| 410 | workspace_deleted, organization_deleted | Scheduled for deletion (purgeAfter) | An organization owner or admin restores it before purgeAfter |
| 403 | member_suspended, member_removed | Your access to the organization was suspended or ended | Ask an organization owner or admin |
| 503 | unavailable | A dependency is down | Retry later with the same idempotency key |
| 500 | internal | Our fault | Retry with the same idempotency key |
A 409 may carry more fields for you to act on: current for a version conflict, reason for a reused
command ID, items for a publish checklist, revisionsVersion, problems or blocked for pending
changes, managedInMeta for values an imported launch leaves to Meta, and usageCount for a status
still in use. API errors lists them.
Lists
Lists page with ?after=<cursor>&limit=<n>. The response has items and nextAfter: pass it as after
for the next page; null means you reached the end. A cursor belongs to its list: the media library
refuses one from another sort with a 422 on after.
Route index
Every route of the API, grouped as in the OpenAPI document. Permission is what the credential needs;
none routes take no credential, and session only routes need a person signed in to the app.
Identity. Sign-in, sessions and the signed-in person.
| Route | Permission | What it does |
|---|---|---|
GET /auth/authorize | none | Start OAuth sign-in |
GET /auth/callback | none | Complete OAuth sign-in |
POST /auth/magic | none | Email a sign-in code |
POST /auth/magic/verify | none | Sign in with the emailed code |
POST /auth/sign-out | read, session only | Sign out |
GET /me | read, session only | The signed-in person and their workspaces |
GET /me/agents | read, session only | Your connected agents in every workspace |
DELETE /me/agents/{id} | read, session only | Disconnect one of your agents |
PUT /me/workspace | read, session only | Switch the current workspace |
Organization. Organizations hold workspaces, people and billing: members (paid seats) reach every workspace or selected ones, guests (free) review in the workspaces they're invited to. An organization's audience (brand, multi_brand or agency; "Who is Pluto for?" in the app, set with PATCH /organizations/{id}) only changes words: the app calls a workspace a client for an agency and a brand for several brands, and plans count clients or brands. The API always says workspace. An agent client (/agent-clients) is a credential, not a workspace.
| Route | Permission | What it does |
|---|---|---|
GET /organizations | read | List organizations |
GET /organizations/{id} | read | Get an organization |
PATCH /organizations/{id} | organization | Update an organization |
GET /organizations/{id}/ai-improvement | read | Whether the organization shares usage data to improve Pluto's AI |
PUT /organizations/{id}/ai-improvement | organization | Turn Help improve Pluto's AI on or off |
GET /organizations/{id}/workspaces | read | List an organization's workspaces |
POST /organizations/{id}/workspaces | organization | Add a workspace |
GET /organizations/{id}/members | organization | List an organization's people |
PATCH /organizations/{id}/members/{user_id} | organization | Change a person's role or workspaces |
DELETE /organizations/{id}/members/{user_id} | organization | Remove a person, or leave |
POST /organizations/{id}/invitations | organization | Invite someone to the organization |
DELETE /organizations/{id}/invitations/{invitation_id} | organization | Revoke an invitation |
GET /organizations/{id}/credentials | organization | Every API key, agent client and connected agent in the organization |
DELETE /organizations/{id}/credentials/{credential_id} | organization | Revoke any of them |
POST /organizations/{id}/members/{user_id}/credentials/transfer | organization | Take over a person's API keys (re-issued to you with new secrets) |
GET /organizations/{id}/members/{user_id}/offboarding | organization | What a person owns before they're removed |
POST /organizations/{id}/members/{user_id}/ownership | owner, session only | Make an admin an owner |
POST /organizations/{id}/invitations/{invitation_id}/resend | organization | Send an invitation again |
GET /organizations/{id}/workspaces/archived | organization | Archived and scheduled workspaces |
GET /organizations/{id}/workspaces/{workspace_id} | read | A workspace's state (owners and admins any workspace, members their own) |
POST /organizations/{id}/workspaces/{workspace_id}/archive | organization | Archive a workspace |
POST /organizations/{id}/workspaces/{workspace_id}/delete | organization | Schedule a workspace for deletion |
POST /organizations/{id}/workspaces/{workspace_id}/restore | organization | Restore a workspace |
GET /organizations/{id}/workspaces/{workspace_id}/export/ads.csv | organization | Export launches, ad sets and ads |
GET /organizations/{id}/workspaces/{workspace_id}/export/media.zip | organization | Export media |
POST /organizations/{id}/delete | owner, session only | Delete the organization (30 days to restore) |
POST /organizations/{id}/restore | owner, session only | Restore the organization |
GET /account | read, session only | Your account |
GET /account/export | read, session only | Export your data |
POST /account/delete | read, session only | Delete your account |
Workspace. The workspace, members and invitations.
| Route | Permission | What it does |
|---|---|---|
GET /workspace | read | The current workspace |
PATCH /workspace | admin | Update the workspace |
POST /workspaces | write, session only | Create another workspace |
GET /workspace/logo | read | Redirect to the logo |
PUT /workspace/logo | admin | Set the uploaded logo |
DELETE /workspace/logo | admin | Remove the logo |
POST /workspace/logo/upload-url | admin | Presigned upload for a logo |
GET /workspaces/{id}/logo | read | Redirect to a workspace's logo |
GET /members | read | List members |
PATCH /members/{user_id} | admin | Change a member's role |
DELETE /members/{user_id} | admin | Remove a member, or leave the workspace |
POST /members/{user_id}/suspend | admin | Suspend a member (and their agents) |
POST /members/{user_id}/reactivate | admin | Reactivate a member |
GET /members/invitations | read | List invitations |
POST /members/invitations | admin | Invite someone by email |
POST /members/invitations/{id}/revoke | admin | Revoke an invitation |
Credentials. API keys, agent clients and webhooks.
| Route | Permission | What it does |
|---|---|---|
GET /api-keys | admin | List API keys |
POST /api-keys | admin | Create an API key |
DELETE /api-keys/{id} | admin | Revoke an API key |
POST /api-keys/{id}/rotate | admin | Replace a key with a new one and revoke the old one |
GET /agent-clients | admin | List agent clients |
POST /agent-clients | admin | Register an agent client |
DELETE /agent-clients/{id} | admin | Disconnect an agent client |
GET /agent-connections | read | List connected agents |
POST /agent-connections | write, session only | Consent to an agent connection |
POST /agent-connections/claims | write, session only | Claim an agent registered with auth.md |
DELETE /agent-connections/{id} | read | Disconnect a connected agent |
GET /webhooks | admin | List webhooks |
POST /webhooks | admin | Create a webhook |
GET /webhooks/events | none | Events a webhook can subscribe to |
PATCH /webhooks/{id} | admin | Change a webhook |
DELETE /webhooks/{id} | admin | Delete a webhook |
GET /webhooks/{id}/deliveries | admin | Recent deliveries |
POST /webhooks/{id}/rotate-secret | admin | Rotate a webhook's signing secret |
POST /webhooks/{id}/test | admin | Send a test event |
Workflow. What Settings calls Statuses (an ad's review status, not its delivery status in Meta), Tags (labels here), Copy templates (templates) and country groups.
| Route | Permission | What it does |
|---|---|---|
GET /workflow/statuses | read | List review statuses |
POST /workflow/statuses | write | Create a review status |
POST /workflow/statuses/reorder | write | Move a status to another's place |
PATCH /workflow/statuses/{id} | write | Update a review status |
DELETE /workflow/statuses/{id} | write | Delete an unused review status |
POST /workflow/statuses/{id}/archive | write | Archive a review status |
POST /workflow/statuses/{id}/unarchive | write | Restore a review status |
POST /workflow/statuses/{id}/default | write | Make a status the default |
GET /workflow/labels | read | List labels |
POST /workflow/labels | write | Create a label |
POST /workflow/labels/delete | write | Delete several labels |
PATCH /workflow/labels/{id} | write | Update a label |
DELETE /workflow/labels/{id} | write | Delete a label |
POST /workflow/labels/{id}/archive | write | Archive a label |
POST /workflow/labels/{id}/unarchive | write | Restore a label |
GET /templates | read | List copy templates |
POST /templates | write | Create a copy template |
PATCH /templates/{id} | write | Update a copy template |
DELETE /templates/{id} | write | Delete a copy template |
GET /country-groups | read | List country groups |
POST /country-groups | write | Create a country group |
PATCH /country-groups/{id} | write | Update a country group |
DELETE /country-groups/{id} | write | Delete a country group |
Settings. Launch defaults, dashboards and personal preferences.
| Route | Permission | What it does |
|---|---|---|
GET /meta-defaults | read | Launch defaults for Meta |
PUT /meta-defaults | write | Set the workspace launch defaults |
PUT /meta-defaults/accounts/{ad_account_id} | write | Override defaults for an ad account |
DELETE /meta-defaults/accounts/{ad_account_id} | write | Drop an ad account's overrides |
GET /analytics/dashboard | read | The analytics dashboard layout |
PUT /analytics/dashboard | write | Replace the dashboard layout |
DELETE /analytics/dashboard | write | Restore the default layout |
GET /preferences | read | The person's view preferences |
PUT /preferences/{key} | read | Save a view preference |
Integrations. Connections: ad providers (Meta), the store's profit (Pluto Profit) and Slack.
| Route | Permission | What it does |
|---|---|---|
GET /integrations/meta | read | Meta connection, ad accounts and Pages |
GET /integrations/meta/connect | admin, session only | Start Facebook Login |
POST /integrations/meta/connect | admin | Start Facebook Login for a credential; returns the URL a person opens |
GET /integrations/meta/callback | admin, session only | Complete Facebook Login |
POST /integrations/meta/sync | write | Sync ad accounts, Pages and campaigns |
POST /integrations/meta/disconnect | admin | Disconnect Meta |
GET /integrations/meta/campaigns | read | Synced campaigns of an ad account |
GET /integrations/meta/ad-sets | read | Synced ad sets (read-only structure) |
GET /integrations/meta/ads | read | Synced ads (read-only structure) |
GET /integrations/meta/ads/{id}/view | read | What a model sees of an ad in Meta |
POST /integrations/meta/imports/preview | read | Review an import from Meta |
POST /integrations/meta/imports | write | Import campaigns or ad sets from Meta |
GET /integrations/meta/imports/{id} | read | Import report |
PUT /integrations/meta/ad-accounts/{id}/pages | admin | Pages an ad account may publish from |
PUT /integrations/meta/ad-accounts/{id}/spending-limit | admin | Set or remove an ad account's spending limit |
POST /integrations/meta/deauthorize | none (signed by Meta) | Meta Deauthorize callback |
POST /integrations/meta/data-deletion | none (signed by Meta) | Meta Data Deletion callback |
GET /integrations/meta/data-deletion/{code} | none | Status of a Meta data deletion request |
GET /integrations/meta/webhook | none (verify token) | Meta webhook verification |
POST /integrations/meta/webhook | none (signed by Meta) | Meta webhook events |
GET /integrations/pluto-profit | read | Pluto Profit connection |
POST /integrations/pluto-profit/authorize | admin | Start connecting Pluto Profit (returns the approval URL) |
GET /integrations/pluto-profit/connect | admin, session only | Open Pluto Profit's approval page |
GET /integrations/pluto-profit/callback | admin, session only | Complete connecting Pluto Profit |
POST /integrations/pluto-profit/verify | admin | Check a Pluto Profit key |
POST /integrations/pluto-profit/connect | admin | Connect Pluto Profit |
POST /integrations/pluto-profit/disconnect | admin | Disconnect Pluto Profit |
GET /integrations/slack | read | Slack connection and connected channels |
GET /integrations/slack/connect | admin, session only | Start connecting Slack |
GET /integrations/slack/callback | admin, session only | Complete connecting Slack |
POST /integrations/slack/disconnect | admin | Disconnect Slack |
GET /integrations/slack/channels/available | admin | Slack channels Pluto Ads can post in |
PUT /integrations/slack/channels | admin | Choose the connected Slack channels |
POST /integrations/slack/messages | write | Post a message to a connected Slack channel |
POST /integrations/slack/events | none (signed by Slack) | Slack events (app removed, access revoked) |
PATCH /integrations/meta/ad-sets/{id}/targeting | publish | Change a Meta ad set's targeting |
GET /integrations/meta/ad-accounts/{id}/targeting-search | read | Search interests, behaviours and demographics |
GET /integrations/meta/ad-accounts/{id}/audiences | read | An ad account's audiences |
Media. Images and videos: uploads, URL imports, folders.
| Route | Permission | What it does |
|---|---|---|
POST /creative-memory/evaluate | read | Evaluate an exact condition over recorded creative evidence |
POST /creative-memory/context | read | Resolve draft or published creative composition |
POST /creative-memory/compare | read | Compare recorded creative copy and evidence |
POST /creative-memory/performance | read | Read ad/day facts and observed composition coverage |
GET /creative-memory/usage | read | Read pooled Creative Memory allowances |
GET /creative-memory/search | read | Search published creative evidence and moments |
POST /creative-memory/ad-search | read | Search ads by their copy and creative evidence |
GET /creative-memory/media/{id} | read | Read creative evidence and coverage |
POST /creative-memory/quote | write | Quote new indexing or analysis without starting it |
POST /creative-memory/requests | write | Accept an exact quote and reserve the whole request |
GET /creative-memory/requests | read | List creative processing requests |
POST /creative-memory/requests/{id}/cancel | write | Cancel unfinished creative processing |
GET /creative-memory/imports | read | List library imports |
POST /creative-memory/imports | write | Start a library import |
GET /creative-memory/imports/{id} | read | Read a library import |
GET /creative-memory/imports/{id}/items | read | List library import items |
POST /creative-memory/imports/{id}/pause | write | Pause a library import |
POST /creative-memory/imports/{id}/resume | write | Resume a library import |
POST /creative-memory/imports/{id}/cancel | write | Stop a library import |
POST /creative-memory/imports/{id}/items/{itemId}/retry | write | Retry a library import item |
GET /creative-memory/monitors | read | List creative monitor coverage |
GET /creative-memory/monitors/{id} | read | Read creative monitor coverage |
GET /creative-memory/monitors/{id}/items | read | List creative monitor items |
POST /creative-memory/precision/quote | write | Quote a targeted precision inspection without starting it |
POST /creative-memory/precision/requests | write | Accept an exact precision estimate and reserve it |
GET /creative-memory/precision/requests/{id} | read | Read a precision result, withheld when stale |
POST /creative-memory/precision/requests/{id}/cancel | write | Cancel a precision request |
GET /creative-memory/media/{id}/correction-context | read | Read correctable evidence and its reviewed snapshot |
GET /creative-memory/corrections | read | List evidence corrections |
POST /creative-memory/corrections | write | Correct or retract one evidence entry |
GET /creative-memory/corrections/{id} | read | Read an evidence correction |
POST /creative-memory/corrections/release | write | Release all corrections for a source |
GET /creative-memory/labels | read | List learning labels |
POST /creative-memory/labels | write | Record a learning label |
GET /creative-memory/experiments | read | List registered creative experiments |
POST /creative-memory/experiments | write | Register an immutable experiment design |
GET /creative-memory/experiments/{id} | read | Read an experiment and a design version |
POST /creative-memory/experiments/{id}/amendments | write | Append a new experiment design version |
POST /creative-memory/experiments/{id}/status | write | Record an experiment's status |
GET /creative-memory/experiments/{id}/outcomes | read | List observed experiment outcomes |
POST /creative-memory/experiments/{id}/outcomes | write | Snapshot observed facts for an experiment |
GET /creative-memory/lineage | read | List recorded creative relationships |
POST /creative-memory/lineage | write | Record a creative relationship claim |
GET /creative-memory/lineage/{id} | read | Read a recorded creative relationship |
GET /creative-memory/labels/{id} | read | Read a learning label |
GET /media | read | List the media library |
POST /media/uploads | write | Start an upload |
POST /media/uploads/batch | write | Start many uploads |
POST /media/{id}/complete | write | Finish an upload and queue processing |
POST /media/{id}/abort | write | Cancel an upload |
POST /media/imports | write | Import files from URLs |
POST /media/move | write | Move media to a folder |
POST /media/archive | write | Archive media |
POST /media/unarchive | write | Unarchive media |
POST /media/delete | write | Delete media |
GET /media/folders | read | List folders |
POST /media/folders | write | Create a folder |
PATCH /media/folders/{id} | write | Rename a folder |
DELETE /media/folders/{id} | write | Delete a folder |
GET /media/{id} | read | One media item with short-lived read URLs |
PATCH /media/{id} | write | Rename or move a media item |
DELETE /media/{id} | write | Delete a media item |
POST /media/{id}/archive | write | Archive a media item |
GET /media/lookup | read | Several media items at once |
GET /media/{id}/preview | read | Stable link to a preview or original |
GET /media/{id}/view | read | What a model sees of a media item |
Launches. Launches, ad sets and ads with override-only inheritance.
| Route | Permission | What it does |
|---|---|---|
GET /launches | read | List launches |
POST /launches | write | Create a launch |
POST /launches/batch | write | Create launches in bulk |
GET /launches/{id} | read | The launch document |
PATCH /launches/{id} | write | Update a launch |
DELETE /launches/{id} | write | Delete a draft launch |
POST /launches/{id}/archive | write | Archive or restore a launch |
GET /launches/stats | read | Workspace launch counts |
POST /launches/{id}/duplicate | write | Duplicate a launch |
GET /launches/{id}/review | read | Readiness checklist |
POST /launches/{id}/ad-sets | write | Create an ad set |
POST /launches/{id}/ad-sets/batch | write | Create ad sets in bulk |
PATCH /ad-sets/{id} | write | Update an ad set |
DELETE /ad-sets/{id} | write | Delete an ad set |
POST /ad-sets/{id}/duplicate | write | Duplicate an ad set with its ads, here or into another launch |
POST /ad-sets/{id}/move | write | Reorder an ad set |
POST /launches/{id}/ads/batch | write | Create ads in bulk |
PATCH /ads/{id} | write | Update an ad |
DELETE /ads/{id} | write | Delete an ad |
POST /ads/{id}/duplicate | write | Duplicate an ad |
GET /ads/{id}/view | read | What a model sees of an ad: creative stills and copy |
GET /launches/{id}/archived | read | Archived ad sets and ads of a launch |
POST /ads/{id}/restore | write | Restore an archived ad as a draft |
POST /ad-sets/{id}/restore | write | Restore an archived ad set as a draft |
GET /ads | read | Search ads across launches |
POST /ads/move | write | Move ads to an ad set, or to another launch |
Bulk editing. Rule-based edits with preview, apply, changesets and revert.
| Route | Permission | What it does |
|---|---|---|
POST /ads/bulk/preview | write | Preview a bulk edit of ads |
POST /ads/bulk/apply | write | Apply a previewed bulk edit of ads |
POST /ad-sets/bulk/preview | write | Preview a bulk edit of ad sets |
POST /ad-sets/bulk/apply | write | Apply a previewed bulk edit of ad sets |
GET /changesets/{id} | read | A changeset: who changed what |
POST /changesets/{id}/revert | write | Revert a changeset |
Launch specs. Declarative launch documents: validate, then apply as one job.
| Route | Permission | What it does |
|---|---|---|
POST /launches/specs/validate | write | Validate a launch spec (dry run) |
POST /launches/specs/apply | write | Apply a launch spec |
Collaboration. Comments, activity, subscriptions and the inbox.
| Route | Permission | What it does |
|---|---|---|
GET /ads/{ad_id}/comments | read | Comment threads on an ad |
POST /ads/{ad_id}/comments | review | Comment on an ad |
PATCH /comments/{id} | review | Edit your comment |
PUT /comments/{id}/internal | write | Make a thread internal |
DELETE /comments/{id} | review | Delete a comment |
POST /comments/{id}/restore | review | Undo deleting a comment |
GET /comments/{id}/attachments/{attachment_id} | read | Stable link to a comment attachment |
POST /comments/{id}/resolve | review | Resolve a thread |
POST /comments/{id}/reopen | review | Reopen a thread |
PUT /comments/{id}/reactions/{emoji} | review | Add your reaction |
DELETE /comments/{id}/reactions/{emoji} | review | Remove your reaction |
PUT /comments/{id}/subscription | read | Follow a thread |
POST /comments/attachments/upload-url | review | Presigned upload for an attachment |
GET /ads/{ad_id}/activity | read | Activity on an ad |
GET /ads/{ad_id}/subscription | read | Who follows an ad |
PUT /ads/{ad_id}/subscription | read | Follow an ad |
GET /inbox | read | The person's inbox |
GET /inbox/unread-count | read | Unread notifications |
POST /inbox/read | read | Mark read |
POST /inbox/unread | read | Mark unread |
POST /inbox/archive | read | Archive notifications |
POST /inbox/unarchive | read | Unarchive notifications |
POST /inbox/snooze | read | Snooze notifications |
POST /notifications | write | Notify people |
Analytics. Performance from ingested Meta insights.
| Route | Permission | What it does |
|---|---|---|
GET /analytics/insights | read | Ad performance for a period |
GET /analytics/launches/{launch_id} | read | Results of one launch |
POST /analytics/refresh | write | Import Meta insights now |
Profit. Whether the store, its products and its ads make money, from the connected Pluto Profit store: profit after product costs, fees and ad spend, not only revenue or ROAS. Members and their credentials; never guests.
| Route | Permission | What it does |
|---|---|---|
GET /profit/summary | read | Store profit |
GET /profit/products | read | Product profitability |
GET /profit/ads | read | Ad profitability (estimate) |
Publishing. Publishing launches to Meta and controlling what is live. What spends money or changes what's live needs the publish permission.
| Route | Permission | What it does |
|---|---|---|
GET /launches/{id}/publish | read | What a publish would do, and what blocks it |
POST /launches/{id}/publish | publish | Publish a launch to Meta |
POST /launches/{id}/publish/cancel | publish | Stop a publish that hasn't finished |
GET /launches/{id}/publication | read | Published objects and their status |
POST /launches/{id}/readback | read | Read published objects back from Meta now |
GET /launches/{id}/revisions | read | Pending changes to published objects |
POST /launches/{id}/revisions/publish | publish | Apply pending changes in Meta |
POST /launches/{id}/revisions/discard | write | Discard pending changes |
POST /launches/{id}/campaign/status | publish | Pause or activate the launch's campaign |
PATCH /launches/{id}/campaign/budget | publish | Change the launch's campaign budget |
POST /ad-sets/{id}/status | publish | Pause or activate an ad set |
PATCH /ad-sets/{id}/budget | publish | Change an ad set's budget |
POST /ads/{id}/status | publish | Pause or activate an ad |
POST /ads/{id}/meta-delete | publish | Delete a live ad in Meta |
POST /ad-sets/{id}/meta-delete | publish | Delete a live ad set in Meta |
GET /ads/{id}/meta-preview | read | Meta's preview of an ad in one placement |
PATCH /ad-sets/{id}/targeting | publish | Change a live ad set's targeting |
PATCH /ad-sets/{id}/bid | write | Change a live ad set's bid |
PATCH /ad-sets/{id}/schedule | write | Change a live ad set's dates or ad scheduling |
POST /integrations/meta/campaigns/{id}/status | write | Pause or activate a campaign in Meta |
PATCH /integrations/meta/campaigns/{id}/budget | write | Change a campaign budget in Meta |
POST /integrations/meta/campaigns/{id}/copies | write | Duplicate a campaign in Meta |
POST /integrations/meta/ad-sets/{id}/status | write | Pause or activate an ad set in Meta |
PATCH /integrations/meta/ad-sets/{id}/budget | write | Change an ad set budget in Meta |
PATCH /integrations/meta/ad-sets/{id}/bid | write | Change an ad set's bid in Meta |
PATCH /integrations/meta/ad-sets/{id}/schedule | write | Change an ad set's dates or ad scheduling in Meta |
POST /integrations/meta/ad-sets/{id}/delete | write | Delete an ad set in Meta |
POST /integrations/meta/ad-sets/{id}/copies | write | Duplicate an ad set in Meta |
POST /integrations/meta/ads/{id}/status | write | Pause or activate an ad in Meta |
POST /integrations/meta/ads/{id}/delete | write | Delete an ad in Meta |
POST /integrations/meta/ads/{id}/copies | write | Duplicate an ad in Meta |
Comments. Comments on your ads' Facebook and Instagram posts: read, reply, hide, unhide and delete. Replying and moderating act publicly as your Page: needs the publish permission.
| Route | Permission | What it does |
|---|---|---|
GET /social/comments | read | Comments on your ads |
GET /social/comments/{id} | read | A comment |
POST /social/comments/{id}/replies | publish | Reply to a comment |
POST /social/comments/{id}/hide | publish | Hide a comment |
POST /social/comments/{id}/unhide | publish | Unhide a comment |
DELETE /social/comments/{id} | publish | Delete a comment |
POST /social/comments/sync | write | Read comments now |
Jobs. Accepted background work with per-item outcomes.
| Route | Permission | What it does |
|---|---|---|
GET /jobs | read | Running and recently finished jobs |
GET /jobs/{id} | read | A job's status and counts |
GET /jobs/{id}/items | read | Per-item outcomes |
POST /jobs/{id}/cancel | write | Cancel queued items |
POST /jobs/{id}/retry | write | Retry failed items |
Loops. Loops, their revisions and the Agent chat that holds their history, turning them on and off, their runs, undo and the approvals their actions wait for. Guests get 403.
| Route | Permission | What it does |
|---|---|---|
GET /loops | read | List loops |
POST /loops | write | Create a loop draft |
GET /loops/{id} | read | A loop with its definition |
PATCH /loops/{id} | write | Save a new version (If-Match) |
POST /loops/{id}/archive | write | Archive a loop |
POST /loops/{id}/unarchive | write | Unarchive a loop |
DELETE /loops/{id} | write | Delete a draft |
GET /loops/{id}/revisions | read | A loop's revisions |
POST /loops/{id}/chat | write | The loop's Agent chat (started on first use) |
PUT /loops/{id}/enabled | write | Turn a loop on or off (allowActions: publish, session only) |
POST /loops/{id}/runs | write | Run a loop now, or as a test run |
GET /loops/{id}/runs | read | A loop's runs |
GET /loop-runs/{id} | read | A run with its steps, items and approvals |
POST /loop-runs/{id}/cancel | write | Cancel a run |
POST /loop-runs/{id}/undo | write | Undo what a step changed; each change needs its own permission (publish) |
POST /loop-approvals/{id}/approve | write, session only | Approve a loop's action; needs the action's permission |
POST /loop-approvals/{id}/reject | write, session only | Reject a loop's action |
Agent. Agent chats, approvals and usage. Chats belong to a person. A loop's chat its person shared is read by everyone who can see the loop.
| Route | Permission | What it does |
|---|---|---|
POST /agent/chats | write | Start a chat |
GET /agent/chats | read | List chats |
GET /agent/chats/{id} | read | A chat with its messages and approvals |
PATCH /agent/chats/{id} | read | Rename or share a chat |
DELETE /agent/chats/{id} | read | Delete a chat |
POST /agent/chats/{id}/read | read | Mark a chat read |
POST /agent/chats/{id}/messages | write | Send a message (event stream) |
POST /agent/chats/{id}/retry | write | Run a failed or stopped turn again (event stream) |
POST /agent/chats/{id}/stop | read | Stop the running turn |
POST /agent/approvals/{id}/approve | write, session only | Approve an action (event stream); needs the action's permission |
POST /agent/approvals/{id}/reject | write, session only | Reject an action (event stream) |
GET /agent/models | read | The models the Agent can run on, and the workspace's model settings |
PUT /agent/models/settings | admin | Set the default model and the models members may pick |
GET /agent/approval-settings | read | When the Agent may act without asking: whether members may choose Run everything, and the budget guardrail |
PUT /agent/approval-settings | admin | Set whether members may choose Run everything, and the budget guardrail |
GET /agent/guidance | read | Your Agent guidance |
PUT /agent/guidance | write | Set your Agent guidance |
GET /agent/skills | read | Your Agent skills |
POST /agent/skills | write | Create a skill |
GET /agent/skills/{id} | read | A skill with its prompt |
PATCH /agent/skills/{id} | write | Change a skill |
DELETE /agent/skills/{id} | write | Delete a skill |
GET /agent/chats/{id}/feedback | read | Your ratings of the Agent's answers in a chat |
PUT /agent/chats/{id}/messages/{message_id}/feedback | write | Rate an answer thumbs up or down |
DELETE /agent/chats/{id}/messages/{message_id}/feedback | write | Clear your rating |
Billing. The organization's plan, subscription, trial, usage and limits, Stripe checkout and billing portal links.
| Route | Permission | What it does |
|---|---|---|
GET /organizations/{id}/billing | organization | Plan, subscription, what the plan includes, the plans with this organization's prices, an Enterprise agreement, access, trial, storage and recent invoices |
POST /organizations/{id}/billing/checkout | organization | A Stripe link to subscribe to Pro or Business |
POST /organizations/{id}/billing/plan | organization | Change the plan (upgrade now, downgrade at period end) |
DELETE /organizations/{id}/billing/plan | organization | Cancel a scheduled plan change |
POST /organizations/{id}/billing/portal | organization | A link to the billing portal |
GET /organizations/{id}/billing/details | organization | Business name, billing email, address, tax IDs, tax status and PO number shown on invoices |
PUT /organizations/{id}/billing/details | organization | Change the business name, billing email, address and PO number |
POST /organizations/{id}/billing/tax-ids | organization | Add a VAT or other tax ID |
DELETE /organizations/{id}/billing/tax-ids/{tax_id} | organization | Remove a tax ID |
POST /organizations/{id}/billing/sync | organization | Read the subscription from Stripe now |
POST /organizations/{id}/billing/trial | organization | Start the free trial of an organization that never had one |
POST /organizations/{id}/sales-leads | write | Contact sales about Enterprise |
PUT /organizations/{id}/billing/on-demand | organization | Turn usage beyond what's included on or off and set its monthly limit |
PUT /organizations/{id}/billing/storage | organization | Turn extra media storage on or off and set its monthly limit |
GET /organizations/{id}/billing/workspace-costs | organization | The cost of each workspace in a billing period or on one invoice, as JSON or CSV (Business) |
GET /organizations/{id}/usage | read | Usage for a billing period (your own, or the organization's for admins) |
GET /workspace/usage | read | This workspace's usage, limit and storage (your own for members) (Business) |
GET /organizations/{id}/usage/breakdown | read | Usage by member, source, loop or model; by workspace or connected agent on Business |
GET /organizations/{id}/usage/records | read | The period's usage records, paged |
GET /organizations/{id}/usage/export | read | The period's usage records as CSV (Business) |
GET /organizations/{id}/usage/limits | organization | The organization, workspace and member limits |
PUT /organizations/{id}/usage/limits/workspaces/{workspace_id} | organization | Set a workspace's monthly limit (Business), or remove it (every plan) |
PUT /organizations/{id}/usage/limits/members/{user_id} | organization | Set or remove a member's monthly limit |
GET /billing/rates | none | Every model's usage rates |
POST /billing/stripe/webhook | none | Stripe's event notifications (signed by Stripe) |
Realtime. Presence and live updates.
| Route | Permission | What it does |
|---|---|---|
GET /realtime | read | WebSocket for presence and invalidations |
GET /realtime/rooms/{room} | read | Who is in a room |
Sync. Device sync tokens and the offline upload queue.
| Route | Permission | What it does |
|---|---|---|
GET /sync/token | read, session only | A five-minute device sync token |
GET /sync/jwks | none | Keys that verify sync tokens |
POST /sync/upload | review | Apply queued offline edits |
MCP. The Model Context Protocol server and its OAuth metadata.
| Route | Permission | What it does |
|---|---|---|
POST /mcp | write | MCP endpoint (Streamable HTTP) |
GET /mcp | read | Not offered (405) |
DELETE /mcp | read | Not offered (405) |
GET /.well-known/oauth-protected-resource | none | OAuth protected resource metadata (RFC 9728) |
Contact. Messages to the Pluto Ads team.
| Route | Permission | What it does |
|---|---|---|
POST /contact | none | Send the team a message |
Meta. Health and this document.
| Route | Permission | What it does |
|---|---|---|
GET /health | none | Health check |
GET /health/ready | none | Readiness check |
GET /openapi.json | none | This document |
Change sets. Plans of changes to live ads that a loop, the Agent or an agent client proposes and a person approves: versions, feedback, edits, approve, reject and undo.
| Route | Permission | What it does |
|---|---|---|
GET /change-sets | read | List change sets |
POST /change-sets | write | Propose changes |
GET /change-sets/{id} | read | A change set with its changes |
POST /change-sets/{id}/approve | publish | Approve and apply a version |
POST /change-sets/{id}/reject | write | Reject a plan |
POST /change-sets/{id}/feedback | write | Send a plan back with feedback |
POST /change-sets/{id}/edit | write | Edit a plan without AI |
POST /change-sets/{id}/items/{key}/undo | publish | Undo an applied change |
Leads. Leads people submit on your Pages' lead forms. Answers need write, and every read of one is audited.
| Route | Permission | What it does |
|---|---|---|
GET /leads/forms | read | Lead forms |
GET /leads | read | Leads |
GET /leads/{id} | write | A lead with its answers |
POST /leads/sync | write | Read leads now |
Audit log. Every change in the workspace and where it came from: who acted, the person it was for, the Agent chat or loop run behind it, and who approved it. Workspace admins.
| Route | Permission | What it does |
|---|---|---|
GET /audit-log | admin | Audit log |
Good to know
- Every request gets an
x-request-idresponse header. Include it when you report a problem. - Writes from a browser session must come from the app's own origin. Keys and tokens have no origin rule.
- Some responses (launches, media, members) carry more fields than the OpenAPI document lists. They are stable.