Skip to main content
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.
This page covers v2 REST endpoints. MCP tool errors follow the ADCP error spec and are returned in structuredContent rather than HTTP status codes.

Error envelope

Every non-success response has data: null and a populated error object:
Successful responses have the inverse shape: { "data": <result>, "error": null }. List endpoints add a meta block (see Pagination).
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.

HTTP status codes

Common error codes

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

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:
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:
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. An account that sends account_id together with brand or operator never reaches that check. It fails request validation with VALIDATION_ERROR, field: "account", and details.issues[] entries for /account (the additionalProperties and oneOf rules). Send either { "account_id": … } or { "brand": …, "operator": … }. On a seller in complete-key mode, see Address your approved account from a direct AdCP 3.0 or 3.1 agent for the full set of refusals. When you launch a campaign on an Apostra seller whose inventory runs on Apostra’s managed sales agent, the seller needs an account for the advertiser. That account is created when the advertiser is activated for the seller, so a launch that runs before activation finishes fails that buy with ACCOUNT_SETUP_REQUIRED (recovery: "correctable") before anything reaches the seller. The buy’s safeMessage and details.setup_steps name the step the advertiser’s activation still needs:
  • Markets and channels missing: activation cannot create the account until the advertiser has markets and channels. Set brandCountries and channels with save_advertiser.
  • Not enabled for the seller: turn the advertiser on for the seller with save_connection({ sellerId, advertiserActivation: { advertiserId, decision: "ENABLED" } }).
  • Still being set up, or waiting for the seller’s approval: wait, then retry.
  • Another required action, such as payment: complete it. details.setup_url links to it when one exists.
In every case, retry the launch once get({ kind: "seller", id }) shows the advertiser’s activation state as active. The buy is recorded as failed with the same explanation, owned by the buyer (invalid_request).

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.
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. For managed ad-server inventory, local targeting validation can return buyer_reason.code: "unsupported_targeting" with recovery: "correctable". Its message names the targeting field or product setting that needs attention. Update that setting and submit a new media buy. Other provider, authentication, and transport messages are not promoted into buyer-visible reasons.

Media-buy change with an unknown outcome

A media-buy update, pause, or cancel that reaches a connected ad platform can fail after the platform may already have applied part of it. When Apostra cannot confirm the change and cannot prove it was undone, the error is as follows. Today Meta and ChatGPT ads media buys report this outcome. Undoing a change is not proof on its own. If the platform timed out, returned a server error, or read back without the requested change, the original change can still land after Apostra’s undo. Apostra still attempts the undo, but it reports this outcome instead of rolled_back.
Do not resend the same change. Resending it could apply it twice. Read the media buy’s current state first (for example with Get media buy status), then submit any further change as a new request. Branch on details.reason and details.retry_safe, not on the message. This is the update counterpart of the create-time scope3_create_uncertain and scope3_mutation_uncertain outcomes described in Create media buys.

Media-buy request stopped before reaching the ad platform

When Apostra rejects a create_media_buy or update_media_buy before sending anything to the connected ad platform, the error carries details.mutation_outcome: "not_dispatched". Nothing in the request was applied on the platform. Pinterest, Snap, LinkedIn and ChatGPT ads media buys report this outcome whenever Apostra can prove nothing was sent. A few rejections cannot be proved, for example a failure while Apostra serializes concurrent changes to the same account or media buy. Those carry no mutation_outcome.
What this means for a retry with the same idempotency key:
  • update_media_buy: the key is released. Resending with the same key runs the request again instead of returning the saved failure. Fix what the error names first; an identical request usually fails the same way.
  • create_media_buy: the key is released only when recovery is correctable. Otherwise the same key keeps returning the saved failure, so send the corrected request with a new key.
A Snap create_media_buy that Apostra deleted from Snap after a later step failed reports details.mutation_outcome: "rolled_back" instead: the request reached Snap and was removed. The idempotency key is kept. A ChatGPT ads media buy does the same. A create_media_buy that failed after the campaign was written reports rolled_back once Apostra has archived the campaign and read the archive back, and unknown when it could not. A update_media_buy that failed after part of it was applied puts back the campaign budget and targeting, the ad group settings and the creative it changed, and reports rolled_back when the platform reads back the original values. If the campaign was active, it stays paused after the failed update; resume it once you have checked the buy. An error without mutation_outcome may have reached the ad platform. Do not assume a retry is safe; read the media buy’s current state first. Branch on details.mutation_outcome and recovery, not on the message.

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

Approved buy that could not be sent yet

After an operator approves a buy on an approval-gated storefront, sending it to the seller can fail for a reason that may clear on its own or once someone acts, for example a seller that has not yet linked your account to its inventory source, an advertiser not yet mapped to an ad account on a storefront that books through an ad platform such as Meta or TikTok, or a seller that is briefly unreachable. The platform retries the same buy for up to six hours; time the publisher spends with intake paused does not count. While it retries, the buy’s create_media_buy task stays submitted and the buy stays pending. When a later attempt succeeds, the task completes as normal. Do not resubmit the buy during this window: a new create_media_buy creates a second buy. If no attempt succeeds before the window ends, the platform stops retrying and the task fails:
correctable refers to a corrected, new request, not to the media buy that failed. A failure that no retry can fix, such as every source refusing the request itself, fails the task on the first attempt with that diagnosis, the masked details.per_source, and no retry_after.

Approved update that could not be sent yet

An update_media_buy that an operator approves is sent to the seller the same way, and follows the same rules. While the platform retries it, for up to six hours not counting time the publisher spends with intake paused, the update’s task stays submitted. That includes an update that has to wait for the media buy itself to settle at the seller, such as a buy the seller is still accepting or a cancellation still in progress. A later successful attempt completes the task. Do not submit the same change again during this window. If no attempt succeeds before the window ends, the platform stops retrying and the update’s task fails:
An update that no retry can fix fails its task on the first attempt: a payload the media buy cannot take, such as packages that match none of the buy’s packages, fails with INVALID_REQUEST, and every source refusing the update fails with that diagnosis and the masked details.per_source. Neither carries retry_after. Except for a cancellation, the platform does not send an update again after its task has failed, so submitting it again does not apply it twice. A cancellation that cannot be sent leaves the media buy running; the seller sees the blocked cancellation in its approval queue.

Plan-gated refusals

When Apostra refuses an action because of an organization’s plan, the message says the plan is the reason, and details.planSwitch gives a typed next step so an agent can tell the person to switch plans instead of guessing. The error code and the other details fields of each refusal do not change. The field is added beside them on REST responses and in MCP structuredContent.
These refusals carry it:
  • The organization’s plan does not cover the change (details.reason plan_does_not_cover_account). When the first step is to pick a different seller product, there is no plan switch.
  • Custom modular inventory sources need an Enterprise Merchandising plan (403, FEATURE_NOT_ENABLED on REST).
  • Operating agents for clients needs an active Certified Agent Partner Program entitlement.
  • A Seller has not selected a plan (INVALID_REQUEST, details.reason plan_selection_required). The caller cannot switch another organization’s plan, so canAct is false and there is no link.
  • A Seller has not met the prerequisites for running briefs (INVALID_REQUEST, details.reason seller_prerequisites_missing). details.missing lists brief_acceptance_rules, payment_authority or storefront_unmatched (the storefront handling the brief is not the Seller’s), and details.prerequisites gives each step’s nextStep. canAct is false for the caller, and the Seller’s administrator completes the steps (see Before your storefront runs briefs).

Platform policy refusal

When the platform safety policy declines a discovery brief or a campaign write, the error is INVALID_REQUEST (HTTP 422) with the message “Request declined by platform policy.”, details.reason of safety_refused, and details.policy_documentation_url. It never names the matched rule. Revise the request and retry, or contact support to ask for a review. If the check cannot run, the error is a retryable SERVICE_UNAVAILABLE (HTTP 503); for a discovery brief, details.reason is safety_check_unavailable. That is not a finding about the request: retry it after a short wait. See Request declined by platform policy.

Account type refusal

A Buyer API call needs a Buyer account, and a Seller API call needs a Seller account. A call from any other account type is refused, and the error says which. Branch on details, not on the message:
  • details.reason is account_type_not_allowed when the account type is refused, and account_type_unverified when the platform could not confirm the account type. In the second case nothing was decided: retry after a short wait.
  • details.surface is buyer or storefront, the API the call was refused on.
  • details.accountType is the refused account’s type: parent (an organization, which is a container and not a Buyer or Seller account), developer, buyer or seller. It is absent when reason is account_type_unverified.
On REST the refusal is 403 CUSTOMER_ROLE_DENIED (503 CUSTOMER_ROLE_UNAVAILABLE for account_type_unverified). On MCP it is the ADCP ACCESS_DENIED tool error in structuredContent. To act, switch to an account of the right type, for example a child account inside the organization.

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:
When a single-field check fails (e.g. a route guard), field is set instead:
Always render details.issues[].path in your UI — the user usually just needs to know which form field to fix.

Lists that need at least one item

Since October 27, 2026, the v2 buyer API follows AdCP 3.3 for many list fields: a list that is present needs at least one item, and an empty list is refused with VALIDATION_ERROR. To say “none”, leave the field out instead of sending []. These v2 buyer requests carry such a list: Reads do not take this rule: a response returns what is stored, so a stored empty list still comes back as an empty list.

Handling errors in client code

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

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. A storefront’s AdCP update_media_buy on a Meta media buy, when it is sent straight to Meta rather than held for seller approval, returns CONFLICT if the request’s revision no longer matches the buy. details.mutation_outcome is not_dispatched: nothing in the request was applied on Meta. MCP classifies it as transient in the AdCP sense: re-read the buy with get_media_buys, check its current state, and resubmit with the returned revision. Resending the identical request unchanged fails again.
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.