---
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

Send the advertiser and one action to `POST /mutations`. This request pauses a campaign:

```bash
curl -X POST https://api.adcrunch.dev/mutations \
  -H "Authorization: Bearer $ADCRUNCH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "advertiserId": "acc_1203456789012345",
    "action": {
      "kind": "meta_set_status",
      "level": "campaign",
      "id": "120210000000000",
      "status": "PAUSED"
    }
  }'
```

The answer is the id of the Mutation, and not the result of the change:

```json
{ "workflowId": "b3d1f0c4-6a2e-4a1f-9f77-2c0d1e5a8b94" }
```

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 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, or it belongs to a different organization. |
| `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 Mutation with its id:

```bash
curl https://api.adcrunch.dev/mutations/b3d1f0c4-6a2e-4a1f-9f77-2c0d1e5a8b94 \
  -H "Authorization: Bearer $ADCRUNCH_API_KEY"
```

`status` has one of three values:

| `status` | Meaning |
| --- | --- |
| `running` | The change has not ended. Wait a few seconds, then poll again. |
| `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 actions

Each action has a `kind`. The name of the kind starts with its provider.

| `kind` | What it does |
| --- | --- |
| `meta_set_status` | Sets the status of a campaign, an ad set, or an ad: `ACTIVE`, `PAUSED`, or `ARCHIVED`. |
| `meta_update_budget` | Sets the daily budget or the lifetime budget of a campaign or an ad set. |
| `meta_update_bidding` | Sets the bid strategy of a campaign or an ad set, and the amount that the strategy takes, at the level where the budget sits. |
| `meta_update_schedule` | Sets the start time or the end time of an ad set, or removes its end time. |
| `meta_rename` | Sets the name of a campaign, an ad set, or an ad. |
| `meta_create_campaign` | Creates a campaign. |
| `meta_create_adset` | Creates an ad set in a campaign. |
| `meta_create_creative` | Creates a creative from an Asset that you registered to this advertiser. |
| `meta_create_ad` | Creates an ad from an ad set and a creative. |
| `meta_copy_campaign` | Copies a campaign, with no ad set and no ad, into the same ad account. |
| `meta_copy_adset` | Copies an ad set, with no ad, into its own campaign or into another campaign. It takes an optional start time and end time. |

[Start a mutation](/api/mutations/start-mutation) shows each field of each action.

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_rename`. 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 `meta_set_status` with `ACTIVE` in a separate request. `ACTIVE` starts spend.

:::

- **A change acts only on an object of the advertiser.** Before `meta_set_status`, `meta_update_budget`, `meta_update_bidding`, `meta_update_schedule` or `meta_rename` 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 body refuses any other id with `400` `invalid_request`.
- **The API deletes nothing.** To retire an object, send `meta_set_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.
