> ## Documentation Index
> Fetch the complete documentation index at: https://docs.apostra.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Errors

> Standard error response shape, HTTP status codes, and common error codes returned by the v2 API

The v2 REST API uses a single, predictable error envelope across every endpoint. Whatever the failure — a missing auth token, a Zod validation problem, a 404, a downstream rate limit — the body shape is the same. Build your error-handling once and reuse it everywhere.

<Note>
  This page covers v2 REST endpoints. MCP tool errors follow the [ADCP error
  spec](https://adcontextprotocol.org/schemas/3.0.0-rc.3/core/error.json) and
  are returned in `structuredContent` rather than HTTP status codes.
</Note>

## Error envelope

Every non-success response has `data: null` and a populated `error` object:

```json theme={null}
{
  "data": null,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Request validation failed",
    "field": "name",
    "details": {
      "issues": [
        { "path": "name", "message": "Required" }
      ]
    }
  }
}
```

| Field     | Type     | Always present | Description                                                              |
| --------- | -------- | -------------- | ------------------------------------------------------------------------ |
| `code`    | `string` | yes            | Machine-readable error code (see table below)                            |
| `message` | `string` | yes            | Human-readable message safe to surface to end users                      |
| `field`   | `string` | no             | Field path for validation errors (e.g. `start_date`, `targeting.geos`)   |
| `details` | `object` | no             | Structured payload — Zod issues, conflict resource ids, retry hints, etc |

Successful responses have the inverse shape: `{ "data": <result>, "error": null }`. List endpoints add a `meta` block (see [Pagination](/v2/reference/pagination)).

<Note>
  Throughout the Buyer and Storefront task references, the `## Response` examples
  show the **`data` payload only** — the inner result. On the wire it is always
  wrapped in the envelope above: `{ "data": <payload>, "error": null }`, plus a
  `meta` block on list endpoints. Read the result from `response.data`.
</Note>

## HTTP status codes

| Status | Meaning                                                     | Typical `code` values                                                                     |
| ------ | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| `400`  | Malformed request                                           | `BAD_REQUEST`, `VALIDATION_ERROR`, `CURRENCY_MISMATCH`                                    |
| `401`  | Missing or invalid auth                                     | `UNAUTHORIZED`                                                                            |
| `402`  | Payment required — committed spend would exceed your credit | `CREDIT_LIMIT_EXCEEDED`                                                                   |
| `403`  | Authenticated but not allowed                               | `FORBIDDEN`, `ACCESS_DENIED`, `ALPHA_OPT_IN_REQUIRED`, `TOS_ACCEPTANCE_REQUIRED`          |
| `404`  | Resource doesn't exist or isn't visible to you              | `NOT_FOUND`                                                                               |
| `409`  | Conflicting or incomplete account state                     | `CONFLICT`, `BUYER_SETUP_REQUIRED`, `PRICING_NOT_CONFIGURED`, `INSUFFICIENT_MEDIA_BUDGET` |
| `422`  | Semantically invalid (rare — most things use 400)           | `VALIDATION_ERROR`, `CAPABILITY_NOT_SUPPORTED`, `SPEND_DENOMINATION_UNRESOLVED`           |
| `429`  | Rate limit hit                                              | `RATE_LIMITED` (see [Rate Limits](/v2/reference/rate-limits))                             |
| `500`  | Unhandled server error                                      | `INTERNAL_ERROR`                                                                          |
| `501`  | Endpoint exists but not yet implemented                     | `NOT_IMPLEMENTED`                                                                         |
| `503`  | Upstream / dependency unavailable                           | `SERVICE_UNAVAILABLE`, `SIGNUP_EXPERIENCE_STATE_UNAVAILABLE`                              |

## Common error codes

| Code                                  | When you'll see it                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `VALIDATION_ERROR`                    | Zod schema rejected the request body, query, or params                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `BAD_REQUEST`                         | Generic 400 — usually a malformed param that didn't reach Zod                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `UNAUTHORIZED`                        | No bearer token, expired token, or unknown API key                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `FORBIDDEN`                           | Auth succeeded but you lack the role/permission                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `ACCESS_DENIED`                       | Resource exists but is owned by a different account/advertiser                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `NOT_FOUND`                           | Resource ID doesn't exist (or is hidden from your scope)                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `CONFLICT`                            | Duplicate resource, illegal state transition, or invariant violated                                                                                                                                                                                                                                                                                                                                                                                                                               |
| `BUYER_SETUP_REQUIRED`                | Buyer operator identity is incomplete. Open **Buyer Setup**, complete step 1, then retry; see the structured contract below.                                                                                                                                                                                                                                                                                                                                                                      |
| `RATE_LIMITED`                        | Too many requests — back off and retry after `Retry-After`                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| `INTERNAL_ERROR`                      | Unhandled exception — safe to retry once                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `SERVICE_UNAVAILABLE`                 | A dependency (downstream agent, billing, contract verification, signal provider) is temporarily down. Retry the request; unlike `TOS_ACCEPTANCE_REQUIRED`, this does not mean your organization needs to accept terms.                                                                                                                                                                                                                                                                            |
| `SIGNUP_EXPERIENCE_STATE_UNAVAILABLE` | A declared enrolled dark signup routing intent could not be stored or used as server-authoritative state. Returned only by legacy `GET /auth/url`, `GET /auth/sso/url`, `POST /auth/token`, and `POST /auth/sso/callback` as the flat **HTTP 503** body `{ "error": "SIGNUP_EXPERIENCE_STATE_UNAVAILABLE", "message": "...", "retryable": true }`, not the v2 error envelope. Restart sign-in for a new state. It does not expose the selected value or authorize any signup/provisioning action. |

Domain-specific codes you may encounter on campaign, currency, and reporting endpoints:

| Code                                   | Meaning                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `PRICING_NOT_CONFIGURED`               | Advertiser has no pricing rule for the selected sales agent. Also returned when a campaign budget change involves media buys whose fee terms cannot be determined — `details.unpricedBuyIds` names them; resolve pricing for those buys first                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `CURRENCY_MISMATCH`                    | Budget currency doesn't match the agent or storefront currency                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `INSUFFICIENT_MEDIA_BUDGET`            | The campaign's `budget.total` can't cover its media buys — e.g. lowering the total below what live buys already allocate. The error names the requested total and the committed allocation                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `CREDIT_LIMIT_EXCEEDED`                | Activating or raising a campaign would push your org's committed spend past its available Apostra credit (`available = funded + creditLimit − committed`). Returned as **HTTP 402**. `details` carries `availableMinor`, `committedMinor`, `fundedMinor`, `creditLimitMinor`, `newCommitmentMinor`, `exceededByMinor`, and `currency` (minor-unit strings). Free up committed budget or add funds, then retry                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `ROUTED_AGENT_REQUIRES_OPERATOR_AUTH`  | Deprecated compatibility code for older clients. If received, operator credentials are missing; do not infer a storefront type from the name.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `SPEND_DENOMINATION_UNRESOLVED`        | Reporting was asked for money it cannot denominate: one or more media buys in scope have delivery spend with no resolvable currency, so no figure would be correct. Returned as **HTTP 422** and classified `terminal` — **retrying reproduces it**, because the cause is data that is wrong at rest (a source reporting a currency it is not paid in, a buy missing its cross-currency booking evidence, or currencies mixed within the period). The affected buys are named in `message` and in `details.mediaBuyIds`, with `details.unresolvedCount`. Report those ids to support; to keep reporting meanwhile, scope the request to exclude them. Distinct from `FX_RATE_UNAVAILABLE`, which is a rate-feed outage and *is* retryable. See [Cross-currency](/v2/concepts/cross-currency)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| `FX_RATE_UNAVAILABLE`                  | No exchange rate could be resolved for a currency pair — the rate feed is down with nothing recent to carry forward. Returned as **HTTP 503** and classified `transient`; retry shortly                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `FX_QUOTE_EXPIRED`                     | The FX quote behind a selected pricing option is from an earlier UTC day. Rates are fixed per UTC day; re-run discovery for a current quote, then resubmit                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `ALPHA_OPT_IN_REQUIRED`                | Feature is in alpha — opt in via support before using                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `TOS_ACCEPTANCE_REQUIRED`              | Account must accept the latest terms of service before mutating                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `CAPABILITY_NOT_SUPPORTED`             | Campaign update targets a media buy whose downstream sales agent does not declare support for the requested operation. `details.unsupported[]` lists each `{ mediaBuyId, salesAgentId, field, reason }`. The update is rejected upfront with no state changes; fix by removing the unsupported `mediaBuys[]` entries or waiting for the agent to declare support.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `VERSION_UNSUPPORTED`                  | Either an AdCP source rejected the negotiated version, or `sync_accounts` contains `3.2` identity fields that the storefront cannot safely represent. For negotiation, this is correct only for a cross-major mismatch; a same-major release gap must be downshifted. For the request-shape gate, `details.reason` is `adcp_3_2_account_identity_not_supported`, `field` identifies the first incompatible value, and `details.unsupported_fields` lists all of them. See [AdCP versioning & negotiation](/v2/concepts/adcp-versioning).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `MANAGED_ACCOUNT_LINKAGE_REQUIRED`     | A `sync_creatives` call against a MANAGED storefront couldn't serve its creatives because the calling buyer principal has no *active* linked account on that storefront (grant not present, or in any non-`active` status). The error appears as an `errors[]` entry on the per-creative `sync_creatives` result (`action: 'failed'`) — one entry per creative, all with the same code. The `message` carries a copy-pasteable `sync_accounts` request: `Run sync_accounts with {"accounts":[{"brand":{"domain":"…"},"operator":"…","billing":"agent","sandbox":?}]} on this storefront to request an account, then wait for the seller's approval.` The `sandbox` field is included only when the buy is sandbox-scoped. **Recovery depends on the grant state — check via `list_accounts` first**, then act on the returned status: **no grant** → send the payload; the account enters `pending_approval` and retry after the seller approves. **`pending_approval`** → wait for the seller's disposition; resubmitting `sync_accounts` returns `unchanged` and does not accelerate approval. **`active` but you don't have the `account_id`** → retrieve it via `list_accounts` and include `account.account_id` on your next `sync_creatives`. **`payment_required`** → resolve the outstanding balance with the seller; per the AdCP spec the status transitions back to `active` after billing clears. **`suspended`** → contact the seller for reactivation; per the AdCP spec `suspended → active` is a seller-driven transition. **`rejected` / `closed`** → terminal per the AdCP Account Status Lifecycle; the grant cannot be re-approved and the buyer cannot resubmit under the same natural key without out-of-band re-onboarding with the seller. |
| `destination_profile_not_certified`    | A managed ad-server route has no authenticated evidence for this video ingress yet. The creative fails before provider mutation. Do not retry the same route; use a certified destination or wait for certification.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| `destination_rendition_required`       | The hosted source could satisfy the destination after a managed rendition. Until managed rendition jobs are available, provide a file that already matches the destination profile.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `destination_profile_rejected`         | The creative does not satisfy the managed destination profile. The per-creative message lists the failed constraints; correct or replace the source before retrying. A `missing:<field>` reason means required evidence is absent rather than wrong — most commonly `missing:vastVersion`, a VAST tag registered without a declared `vast_version`. See [video creatives](/v2/concepts/video-creatives).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `destination_destructive_sync_blocked` | A mixed `delete_missing` sync included both admitted and destination-rejected creatives. Nothing was forwarded because filtering the request could archive the rejected creatives. Correct the rejected creatives or retry without `delete_missing`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |

### Buyer identity and storefront account errors

Buyer operations that need a seller-side account fail closed when the buyer has
no confirmed operator identity. REST returns HTTP `409` with
`BUYER_SETUP_REQUIRED`; for a missing identity, branch on this exact structured
payload:

```json theme={null}
{
  "data": null,
  "error": {
    "code": "BUYER_SETUP_REQUIRED",
    "message": "Complete Buyer Setup step 1 by confirming your buyer operator identity before continuing.",
    "details": {
      "reason": "missing_operator_identity",
      "readinessCheck": "operator_identity",
      "setupStep": 1,
      "setupPage": "buyer_setup"
    }
  }
}
```

Apostra never falls back to `interchange.io` as a buyer operator. Doing so
would collapse unrelated buyers into one seller-side natural-key account.
Complete **Buyer Setup** and retry instead. Historical media buys are not
rewritten: status, creative re-sync, update, cancellation, and delivery keep
using the seller account persisted on the buy.

A legacy buyer profile that explicitly contains the platform domain returns
the same `BUYER_SETUP_REQUIRED` code with `details.reason:
"platform_operator_not_allowed"`. This is observable on the same buyer
surfaces as `missing_operator_identity`; replace the platform domain in
**Buyer Setup** rather than retrying it.

The Storefront MCP surface uses the ADCP error envelope rather than the REST
envelope above. When a request supplies an opaque `account_id` that is not
authorized for the authenticated buyer on that storefront, it returns:

```json theme={null}
{
  "code": "ACCOUNT_NOT_FOUND",
  "message": "The supplied account is not available to this authenticated buyer for this Seller.",
  "recovery": "correctable",
  "details": {
    "reason": "account_not_authorized_for_buyer"
  }
}
```

Do not interpret this as `account_context_not_found`, and do not retry with an
account ID discovered by another buyer. Use `list_accounts` as the current
buyer or complete the seller's account-linking flow.

### Buyer-actionable errors on `create_media_buy`, `update_media_buy`, `sync_creatives`

Buy-flow errors carry an optional `buyer_reason: { code, message }` sub-object on
the ADCP error envelope (AdCP 3.2 `core/error.json`). It classifies the failure
in a way the buyer can act on — creative-validation issues, budget/product/
permission problems — with a `message` that is safe to render directly (no
vendor identifiers, no internal IDs, no stack traces). When `buyer_reason` is
present, the envelope's `recovery` (`transient` | `correctable` | `terminal`) is
set alongside per spec and MUST agree with the buyer\_reason's classification.

`code` uses the AdCP `enums/error-code.json` vocabulary
(`BUDGET_TOO_LOW`, `PRODUCT_NOT_FOUND`, `PRODUCT_UNAVAILABLE`,
`PERMISSION_DENIED`, `ACCOUNT_SETUP_REQUIRED`) or an `X_{VENDOR}_{CODE}`
extension (Apostra extensions: `X_SCOPE3_CREATIVE_SIZE_MISMATCH`,
`X_SCOPE3_CREATIVE_MISSING_CLICK_URL`,
`X_SCOPE3_CREATIVE_VALIDATION_FAILED_GENERIC`). Receivers MUST preserve
unknown codes for forward compatibility — a newer AdCP standard code or a
seller-defined extension is passed through end-to-end.

```json theme={null}
{
  "code": "INVALID_REQUEST",
  "message": "The creative dimensions (300x250) don't match any accepted size on the selected packages (accepted: 640x480). Update the creative dimensions or select packages that accept 300x250.",
  "recovery": "correctable",
  "buyer_reason": {
    "code": "X_SCOPE3_CREATIVE_SIZE_MISMATCH",
    "message": "The creative dimensions (300x250) don't match any accepted size on the selected packages (accepted: 640x480). Update the creative dimensions or select packages that accept 300x250."
  },
  "details": {
    "reason": "all_sources_failed",
    "per_source": [
      {
        "source_id": "agent_1",
        "status": "failed",
        "error": "…",
        "buyer_reason": {
          "code": "X_SCOPE3_CREATIVE_SIZE_MISMATCH",
          "message": "…"
        },
        "recovery": "correctable"
      }
    ]
  }
}
```

The same `buyer_reason` may also appear on each entry of `details.per_source`
so a per-leg diagnosis is available when a fan-out mixed causes; each entry
carries its own `recovery` peer. On `sync_creatives`, `buyer_reason` +
`recovery` appear on each failed creative row's `errors[]` entry when the
source classified the batch failure.

Prefer reading the structured `buyer_reason.code` for programmatic routing
(retry, mutate-and-resubmit, escalate) and use `buyer_reason.message` for
what to show a human.

### Flight window elapsed on an approval-gated storefront

Some publisher storefronts hold an incoming `create_media_buy` for a human
operator to approve before it reaches the seller. Approval and dispatch are
separate moments: the buy waits in the operator's queue, and only once it is
approved does the platform send it on. That wait can be long — minutes while
an operator reviews, or longer if the publisher has paused intake.

If a buy's `end_time` has passed by the time it is dispatched, no seller can
accept it. The platform stops before contacting any seller and fails the buy's
task with `details.forward_error.code` of `flight_window_elapsed`:

```json theme={null}
{
  "code": "INVALID_REQUEST",
  "message": "This media buy's flight ended at 2026-05-08T23:59:59.000Z and can no longer be sent to a source. Submit a new media buy with a current flight window.",
  "recovery": "correctable",
  "details": {
    "reason": "storefront_forwarding_failed",
    "source_outcome": "precondition_failed",
    "forward_error": {
      "code": "flight_window_elapsed",
      "message": "This media buy's flight ended at 2026-05-08T23:59:59.000Z and can no longer be sent to a source. Submit a new media buy with a current flight window."
    }
  }
}
```

**How to recover.** Submit a **new** `create_media_buy` with a current
`end_time`. Retrying the same buy will not help and is not attempted on your
behalf: this media buy is finished, and the platform will not send it to a
seller under any later condition. The `recovery` of `correctable` refers to the
request — a corrected flight window succeeds — not to the buy that failed.

**Why you may not have seen it before.** A buy in this state used to be retried
against the seller for several hours, rejected each time, and then left
approved but never placed with no reason recorded. It now fails on the first
dispatch attempt with the message above, so the flight window is the thing you
fix rather than something you have to infer from silence.

<Note>
  A `start_time` that has merely slipped into the past is **not** an error. The
  platform re-reads the flight window at dispatch and sends a past start as
  `"asap"` — start on acceptance — so a buy whose start passed while it sat in
  the approval queue still runs. Only an elapsed `end_time` ends the buy.
</Note>

### MCP Page capability errors

Host-only Page aliases return the ADCP `ACCESS_DENIED` tool error when their
session-bound capability is missing, expired, belongs to another Page or
session, or the requested arguments fall outside that Page's exact allowlist.
The error is returned in MCP `structuredContent`, not the REST envelope.

For an expired capability, the Page caller makes one renewal attempt with the
tool associated with the same Page resource and, if renewal succeeds, retries
the unchanged operation once. If renewal fails, preserve the original denial.
Do not retry a wrong-resource or off-policy call through another Page. In
Teach, `ACCESS_DENIED` also prevents the Page from rejecting a
destination-owned Material candidate. Teach presents that candidate as a
handoff. A direct MCP client may record rejection with public `save_material`;
acceptance still goes through the candidate's named canonical typed owner.

## Validation errors

When request validation fails, `code` is `VALIDATION_ERROR` and `details.issues` enumerates every problem Zod found, with dotted field paths:

```json theme={null}
{
  "data": null,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Request validation failed",
    "details": {
      "issues": [
        { "path": "name", "message": "Required" },
        { "path": "budget.amount", "message": "Number must be positive" },
        { "path": "flightDates.start", "message": "Invalid date" }
      ]
    }
  }
}
```

When a single-field check fails (e.g. a route guard), `field` is set instead:

```json theme={null}
{
  "data": null,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Start date must be in the future",
    "field": "start_date"
  }
}
```

<Tip>
  Always render `details.issues[].path` in your UI — the user usually just needs
  to know which form field to fix.
</Tip>

## Handling errors in client code

<CodeGroup>
  ```bash curl theme={null}
  curl -i https://api.apostra.com/api/v2/buyer/campaigns \
    -H "Authorization: Bearer $SCOPE3_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"advertiserId": 123}'

  # HTTP/1.1 400 Bad Request
  # Content-Type: application/json
  #
  # {
  #   "data": null,
  #   "error": {
  #     "code": "VALIDATION_ERROR",
  #     "message": "Request validation failed",
  #     "details": {
  #       "issues": [
  #         { "path": "name", "message": "Required" },
  #         { "path": "budget", "message": "Required" }
  #       ]
  #     }
  #   }
  # }
  ```

  ```typescript TypeScript theme={null}
  async function createCampaign(body: unknown) {
    const res = await fetch("https://api.apostra.com/api/v2/buyer/campaigns", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.SCOPE3_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify(body),
    });

    const json = await res.json();

    if (!res.ok) {
      const { code, message, field, details } = json.error;

      switch (code) {
        case "VALIDATION_ERROR":
          // Surface details.issues[] in your form UI
          throw new ValidationError(message, details?.issues ?? []);
        case "RATE_LIMITED":
          // Honor Retry-After header
          const retryAfter = Number(res.headers.get("Retry-After") ?? "5");
          throw new RateLimitError(retryAfter);
        case "NOT_FOUND":
        case "ACCESS_DENIED":
          throw new NotFoundError(message);
        default:
          throw new ApiError(code, message, field);
      }
    }

    return json.data;
  }
  ```

  ```python Python theme={null}
  import requests

  def create_campaign(body):
      res = requests.post(
          "https://api.apostra.com/api/v2/buyer/campaigns",
          json=body,
          headers={"Authorization": f"Bearer {SCOPE3_API_KEY}"},
      )
      payload = res.json()

      if not res.ok:
          err = payload["error"]
          if err["code"] == "VALIDATION_ERROR":
              issues = err.get("details", {}).get("issues", [])
              raise ValidationError(err["message"], issues)
          if err["code"] == "RATE_LIMITED":
              raise RateLimitError(int(res.headers.get("Retry-After", "5")))
          raise ApiError(err["code"], err["message"])

      return payload["data"]
  ```
</CodeGroup>

<Warning>
  Don't pattern-match on `message` text — message strings may be reworded for
  clarity. Always branch on `error.code` (and on HTTP status as a fallback).
</Warning>

## Retrying safely

`RATE_LIMITED`, `INTERNAL_ERROR`, `SERVICE_UNAVAILABLE`, and `FX_RATE_UNAVAILABLE` are transient — retry GETs with exponential backoff. For creation/mutation requests after a 5xx, prefer to surface the error rather than auto-retry, since duplicate-create protection isn't enforced server-side.

`VALIDATION_ERROR`, `NOT_FOUND`, `FORBIDDEN`, `ACCESS_DENIED`, `CONFLICT`, and `SPEND_DENOMINATION_UNRESOLVED` are terminal — don't retry until the input or state changes.

`CONFLICT` has two narrow automatic-retry reasons, and they are machine-readable rather than a matter of reading the message. `name_creation_in_progress` means another writer holds that name and has not finished. `adoption_in_progress` means the source-identity state is still settling: another adoption is completing or its blocker changed during classification. Retry the identical request shortly for either reason. Every other adoption reason requires a changed input or state: `name_taken` and `name_creation_abandoned` require choosing another name or archiving the blocker; `adoption_abandoned` means a stale creation marker never cleared (an asset row may already exist), and `adoption_asset_unavailable` means the finalized asset is no longer usable; `adoption_name_mismatch` requires using the existing creative name or renaming it separately; and `source_delivery_revoked` or `source_deletion_requested` requires uploading a new source. MCP classifies those action-required cases as `correctable`, not transient. Branch on `details.reason`, never on the message.

<Warning>
  A 5xx status does not by itself mean "retry", and a 4xx does not mean "never".
  On MCP surfaces, branch on the `recovery` classification (`transient` /
  `correctable` / `terminal`) that every error carries; on REST, branch on
  `code`. A read that fails because stored data is inconsistent — such as
  `SPEND_DENOMINATION_UNRESOLVED` — is terminal even though nothing about your
  request was wrong, and an automatic retry loop on it will simply burn quota.
</Warning>
