---
title: Errors
description: The one shape every AdCrunch failure arrives in, every status code you can meet, what is safe to retry, and how the same failure reaches an MCP client.
---

Every published AdCrunch API answers every failure with the same shape. Read this page once and you can write your error handling before you write your first call.

## The shape

```json
{
  "error": "revision_mismatch",
  "message": "This brand changed since you opened it",
  "currentRevision": 7
}
```

Two fields are always there, and they do different jobs.

| Field | What it is | Branch on it? |
| --- | --- | --- |
| `error` | A stable code, lower snake_case. | **Yes. Only this.** |
| `message` | One sentence, written for a person to read. | Never. |

`message` carries no contract. AdCrunch rewords it whenever it can be said better, and that is the whole reason the two are separate fields. If you match on prose, an improvement to the wording breaks your integration.

Some codes carry an **extension** field beside the two, and that field is what makes the failure actionable — `currentRevision` above tells you what to read before you retry.

:::tip[Always write a default branch]

A code you have never seen is what a new failure looks like. AdCrunch may add a code to an operation at any time — that is additive, and it is why every operation's list of codes is open. Renaming or removing a code is breaking, and it does not happen without notice.

:::

## The codes

| Status | `error` | Extension | What it means |
| --- | --- | --- | --- |
| `400` | `invalid_request` | — | The body or query does not match the operation. Nothing was started. |
| `400` | `invalid_cursor` | — | That pagination cursor could not be read. Pass back the `nextCursor` you received, unchanged. |
| `401` | `unauthorized` | — | No credential, or one that does not resolve. |
| `403` | `forbidden` | — | Your credential resolved, and it does not hold the permission this operation needs. |
| `403` | `missing_write_access` | — | The advertiser is connected without write access. Reconnect the provider to grant it. |
| `404` | `not_found` | — | No such thing **for your organization**. See below. |
| `404` | `not_attached` | — | That advertiser is not attached to this brand. |
| `409` | `revision_mismatch` | `currentRevision` | Someone changed it since you read it. |
| `409` | `slug_conflict` | `slug` | Another row of the same kind already uses that slug. |
| `409` | `no_object` | — | The upload was reserved and the bytes never arrived. |
| `409` | `currency_frozen` | `currency` | The Campaign Plan is denominated, and a line item already carries a budget. Clear those budgets to change the currency. Nothing is converted. |
| `409` | `persona_not_in_brand` | `lineItemIds` | You re-pointed a Campaign Plan at another brand, and the listed line items still name a persona of the old one. Clear those personas first. |
| `413` | `too_large` | `limitBytes`, `sizeBytes` | The file is over the limit. |
| `415` | `unsupported_type` | `allowed` | That media type is not accepted. `allowed` lists the ones that are. |
| `422` | `advertiser_not_owned` | — | That ad account is not connected to your organization. See [below](#a-missing-thing-and-a-thing-that-is-not-yours). |
| `422` | `invalid_age_range` | — | A persona's age bounds are whole numbers, and `age_min` may not exceed `age_max`. |
| `422` | `invalid_amount` | — | Amounts are whole numbers of minor units, and never negative. |
| `422` | `invalid_countries` | — | Countries are ISO 3166-1 alpha-2 codes, such as `["FR", "BE"]`. |
| `422` | `invalid_currency` | — | A currency is a three-letter ISO 4217 code, such as `EUR`. |
| `422` | `invalid_logo` | — | The document you nominated as a logo is not one this brand owns. |
| `422` | `invalid_slug` | — | The slug you sent holds no letter or digit. |
| `422` | `invalid_window` | — | Dates are real `YYYY-MM-DD` dates, and the start may not follow the end. |
| `422` | `persona_not_in_brand` | — | The persona you named on a line item belongs to another brand. A plan with no brand has none available. |

Two of these codes carry two statuses, because they answer two different questions:

- **`persona_not_in_brand`** is `422` when you name the wrong persona on one line item, and `409` — with `lineItemIds` — when you move a whole plan to another brand and its existing rows still point at the old one.
- **`not_found`** is what you get for a thing you named that your organization does not have. **`advertiser_not_owned`** is `422`, not `404`, because an ad account is an input to an operation rather than the thing the operation reads.

This page covers what AdCrunch **publishes**. Brand enrichment is reachable and deliberately undocumented, and it sends two codes that are not listed here.

## The one failure that looks different

A request that fails the operation's own schema is rejected by the framework before any AdCrunch code runs. That body is the framework's, not ours:

```json
{
  "type": "validation",
  "on": "body",
  "property": "budget",
  "message": "Expected number",
  "found": { "…": "…" },
  "errors": [{ "…": "…" }]
}
```

It arrives as `422` and it has **no `error` field**. Documented rather than translated, so that you meet it here rather than in production. Treat a `422` with a `type` of `validation` as "I sent the wrong shape", and read `property` to find out where.

## What to retry

| Status | Retry? | Do this instead |
| --- | --- | --- |
| `400`, `422` | No | Fix the request. Retrying sends the same wrong thing. |
| `401` | Not as-is | Refresh the token, or authorize AdCrunch again from your client's settings. Then retry once. |
| `403` | No | The credential is right and its permissions are not. Change the scope or reconnect the provider. |
| `404` | No | Re-read the parent listing. The id may be stale, or it may never have been yours. |
| `409 revision_mismatch` | Yes, after a read | Read the object again, apply your change to the version that came back, and send it with the new revision. |
| `409` (any other) | No | `slug_conflict`, `no_object`, `currency_frozen` and `persona_not_in_brand` each describe a state you must change first. The same request will fail again. |
| `413`, `415` | No | Change the file. |
| `5xx` | Yes, with backoff | Something failed on our side or on the provider's. Nothing was necessarily left half-done — check the status of what you started before you start it again. |

A `409 revision_mismatch` is the one worth handling rather than surfacing. Read the object again, fold your edit into the version that came back, and send it with the new revision. To read the whole object again and start over discards what the author had typed.

## A missing thing and a thing that is not yours

**Read one thing, and the two answers are the same.** Every read is scoped to your organization, in the query itself, so an id that does not exist and an id that belongs to another organization both answer `404 not_found` — or, for a list, an empty list.

This is deliberate. A surface that told the two apart lets a caller find which ids exist by asking for them.

Two consequences:

- **A `404` is not proof the thing was deleted.** It can be alive in an organization you are not in.
- **An empty result from [`query_insights`](/mcp/tools/query-insights) has two meanings.** Either your organization does not own that advertiser, or the advertiser reports no data for that range. The response cannot tell you which.

**Name an ad account, and the answer is different.** An operation that takes an `advertiserId` as an input — attach it to a brand, put it on a line item, record an execution — answers `422 advertiser_not_owned` rather than `404`.

That is not a hole in the rule above. The answer states one fact about **your** organization: this ad account is not connected to it. It says nothing about whether the account exists anywhere else, so there is nothing for a caller to find by asking.

## How a failure reaches an MCP client

An MCP tool call is not an HTTP request from your agent's point of view, so **there is no status code to read.** A tool that fails returns a normal tool result with `isError` set, and a sentence in its text content:

```json
{
  "content": [
    { "type": "text", "text": "Entity not found: meta/campaign/123." }
  ],
  "isError": true
}
```

Your agent reads that sentence, the same way it reads a successful result. In practice this means:

- **The sentence is the whole error.** There is no code beside it on the MCP surface today.
- **The agent will usually recover on its own** — it re-reads the parent list and tries again with a real id. That is the behaviour to expect, not a bug.
- **A tool result is not a failed call.** `isError: true` means the tool ran and refused. A transport-level failure is something else, and your client reports it as one.

An OAuth token that has expired is the one failure that stops every tool at once. Authorize AdCrunch again from your client's settings.

## What's next

- [Auth & scopes](/mcp/auth) — what a credential is allowed to touch in the first place.
- [API reference](/api) — every operation, with the codes it can send.
- [Concepts](/use/concepts) — the words these messages use.
