---
title: Make a change
description: Change what runs on an advertiser with the Mutations API — start a Mutation, poll it until it ends, and read the history of each change.
---

A change to a live advertiser is **asynchronous**. A provider write is slow, and it can fail halfway. So the API never does the provider write inside your request. It records your request as a **Mutation**, and it answers the id of that Mutation. The change runs after the answer. You poll the Mutation until the change ends.

Not every provider accepts a change. See [What each provider supports](/connect/providers).

## Start a Mutation

Each change has its own route, on the path of the object that it changes. The path is the path of the live read, with `/mutations` in place of `/observe`. `GET /observe/{advertiserId}/campaigns/{id}` reads a campaign, and `PATCH /mutations/{advertiserId}/campaigns/{id}/status` changes its status. The path names the advertiser and the object. The body holds only the change. This request pauses a campaign:

```bash
curl -X PATCH https://api.adcrunch.dev/mutations/acc_1203456789012345/campaigns/120210000000000/status \
  -H "Authorization: Bearer $ADCRUNCH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "status": "PAUSED" }'
```

The answer is `202 Accepted`. It is not the result of the change. `Location` holds the path of the Mutation, and the body holds its id and its status:

```http
HTTP/1.1 202 Accepted
Location: /mutations/mut_k3x9q2v7m4n8p1r5t6w0y2z4

{ "mutationId": "mut_k3x9q2v7m4n8p1r5t6w0y2z4", "status": "running" }
```

The body has the shape of the answer of `GET /mutations/{id}`, so you parse one shape.

Before AdCrunch starts the change, it checks four things: the body, the owner of the advertiser, a usable connection, and write access. A request that fails a check starts nothing. It also adds no row to the history.

| Status | `error` | Cause |
| --- | --- | --- |
| `400` | `invalid_request` | The body, or an id in the path, does not match the schema. |
| `403` | `missing_write_access` | The connection of the advertiser has no write access. |
| `404` | `not_found` | The advertiser does not exist, it belongs to a different organization, or it is not a Meta ad account. A route that does not exist, such as a budget on an ad, also answers `404`. |
| `409` | `provider_not_connected` | The advertiser has no usable connection: a person removed it, it expired, or the provider refused it. A person must connect it again. |

## Poll until it ends

Read the path in `Location`:

```bash
curl https://api.adcrunch.dev/mutations/mut_k3x9q2v7m4n8p1r5t6w0y2z4 \
  -H "Authorization: Bearer $ADCRUNCH_API_KEY"
```

`status` has one of four values:

| `status` | Meaning |
| --- | --- |
| `running` | The change has not ended. Wait a few seconds, then poll again. |
| `awaiting_confirmation` | A person must confirm the change in the AdCrunch console. Only a change that an AI agent started through MCP waits. A change that you start through this API never waits. |
| `complete` | AdCrunch reached the provider and has its answer. **Read `result` to learn what that answer was.** |
| `errored` | The change ran into a failure that AdCrunch did not expect. It cannot say whether the provider applied the change, so read the object at the provider before you send the request again. |

:::warning[`complete` is not the same as "it worked"]

A provider that refuses a change also answers `complete`. The refusal is in `result`, as `ok: false` with the reason. Branch on `result.ok`, and never on `status` alone.

:::

For a create that succeeds, `result` holds the id of the new object. For a copy, `result` also holds the `budget` that the copy carries: `{ "dailyBudget": 50 }` or `{ "lifetimeBudget": 500 }` in whole units of the currency of the advertiser, `"campaign"` for an ad set copy that spends the budget of its campaign, or `"adset"` for a campaign copy whose ad sets each carry a budget. That budget is what the copy can spend after you activate it. For a schedule change that gives an `ACTIVE` ad set that has ended a new end time, or no end, `result` also holds `deliveryStartsAgain` of `true`: the ad set delivers again.

For a change to an object that exists, the answer also holds `priorValue`. It holds the values that the change replaced, read from the provider just before the change, with the field names and units of the action: `{ "dailyBudget": 800 }` for a budget, `{ "status": "ACTIVE" }` for a status, `{ "startTime": "2026-10-01T00:00:00+0200", "endTime": null }` for a schedule with no end, `{ "name": "Spring Prospecting" }` for a name. To undo the change, send the same kind again with these values. A create and a copy have no `priorValue`.

To poll, you need `mutation:write`, the permission that started the change.

## The routes

The name of each kind starts with its provider. The route takes the same body as the action, without `kind`, `level` and `id`: the path gives them. In the reference, each route sits under the tag of its object, beside the live reads of that object.

| `kind` | Route, after `/mutations/{advertiserId}` | What it does |
| --- | --- | --- |
| `meta_update_status` | `PATCH /{campaigns, ad-groups or ads}/{id}/status` | Sets the status: `ACTIVE`, `PAUSED`, or `ARCHIVED`. |
| `meta_update_name` | `PATCH /{campaigns, ad-groups or ads}/{id}/name` | Sets the name. |
| `meta_update_budget` | `PATCH /{campaigns or ad-groups}/{id}/budget` | Sets the daily budget or the lifetime budget. |
| `meta_update_bidding` | `PATCH /{campaigns or ad-groups}/{id}/bidding` | Sets the bid strategy, and the amount that the strategy takes, at the level where the budget sits. |
| `meta_update_schedule` | `PATCH /ad-groups/{id}/schedule` | Sets the start time or the end time of an ad set, or removes its end time. |
| `meta_create_campaign` | `POST /campaigns` | Creates a campaign. |
| `meta_create_adset` | `POST /ad-groups` | Creates an ad set in a campaign. |
| `meta_create_creative` | `POST /creatives` | Creates a creative from an Asset that you registered to this advertiser. |
| `meta_create_ad` | `POST /ads` | Creates an ad from an ad set and a creative. |
| `meta_copy_campaign` | `POST /campaigns/{id}/copies` | Copies a campaign, with no ad set and no ad, into the same ad account. It takes no body. |
| `meta_copy_adset` | `POST /ad-groups/{id}/copies` | Copies an ad set, with no ad, into its own campaign or into another campaign. It takes an optional start time and end time. |

The path uses the word of Observe, `ad-groups`. The kind and the body keep the word of Meta, `adset`.

The page of each route shows each field of its body. For example, see [Update a campaign’s status](/api/campaigns/update-campaign-status) and [Create an ad group](/api/ad-groups/create-ad-group).

`POST /mutations` is deprecated. It takes `{ "advertiserId": "acc_…", "action": { "kind": "meta_update_status", "level": "campaign", "id": "…", "status": "PAUSED" } }`, starts the same Mutation, and answers the same `202`. Use the route of the kind instead.

To build an ad, create the objects in this order: the campaign, the ad set, the creative, and the ad. Each step takes the new id from the `result` of the step before it. The creative takes the `ast_` id of an Asset that you registered to the same advertiser. See [Upload a file](/api/upload-a-file).

A copy is shallow: it holds the one object that you copy, and none of the objects under it. Meta adds " - Copy" to the name of the copy. To give it a full name, send `meta_update_name`. To copy an ad, create an ad with the `creativeId` of the source ad. A copy starts its own learning phase. Meta refuses to copy an Advantage+ shopping campaign or an Advantage+ app campaign, and a copy that targets the EU needs the default payor and the default beneficiary of the ad account.

A creative also needs the id of a Facebook Page. An ad set that optimizes for a conversion also needs a pixel. The REST API has no operation that lists Pages or pixels. Over MCP, the tools `meta_list_pages` and `meta_list_pixels` list them.

## Rules for each write

:::warning[A create never starts spend]

Each campaign, ad set, and ad that you create starts `PAUSED`, and so does each copy. To start delivery, send `PATCH …/status` with `ACTIVE` in a separate request. `ACTIVE` starts spend.

:::

- **A change acts only on an object of the advertiser.** Before `meta_update_status`, `meta_update_budget`, `meta_update_bidding`, `meta_update_schedule` or `meta_update_name` writes, and before a copy, AdCrunch reads the object at Meta. When the object is in another ad account, the Mutation ends `complete` with `result.error` `not_found`, and nothing reaches Meta.
- **An id is the native Meta id, digits only.** The path and the body refuse any other id with `400` `invalid_request`.
- **The API deletes nothing.** To retire an object, send `PATCH …/status` with `ARCHIVED`. On Meta, you cannot make an archived object active again from this API.
- **A budget is in whole units of the currency of the advertiser.** `10.5` is 10.50. A budget that you read from Observe is in the same unit. See [Conventions](/api/conventions#money). A bid cap and a cost cap use the same unit. A ROAS floor is a ratio: `1.5` is a purchase value of 1.5 times the spend.
- **Read the live value before you change it.** `GET /observe/{advertiserId}/campaigns` and `GET /observe/{advertiserId}/ad-groups` read Meta now: the budget, the bid, and the start time and the end time of an ad set. A daily budget is not a hard limit for one day: Meta can spend up to 175% of it on one day, and up to 7 times it in one week.
- **An action takes only the fields that its schema names.** No field sends a value to the provider unchanged.

## Read the history

Each Mutation stays in the history of your organization. The history shows the advertiser, the change, who asked for it, and how it ended. A row of a change to an object that exists also holds its `priorValue`. `GET /mutations` lists the history, and needs `mutation:read`:

```bash
curl https://api.adcrunch.dev/mutations \
  -H "Authorization: Bearer $ADCRUNCH_API_KEY"
```

The answer holds `data`, at most `limit` rows (100 by default). When more rows exist, it also holds `nextCursor`. Send that value back as `cursor` to read the next page. The console shows the same history on the **Activity** page.
