Skip to main content

Documentation Index

Fetch the complete documentation index at: https://agenticadvertisingorg-changeset-release-main.mintlify.app/llms.txt

Use this file to discover all available pages before exploring further.

AdCP uses a consistent error handling approach across all operations. Understanding error categories and implementing proper recovery strategies is essential for building robust integrations.

Compliance Levels

Sellers can adopt error handling incrementally. Each level builds on the previous:
LevelWhat to implementWhat agents can do
Level 1Return code + message on every errorAgents match on error code to classify failures
Level 2Add recovery, retry_after, field, and suggestionAgents auto-retry transient errors and self-correct correctable ones
Level 3Use transport bindings to put errors in structuredContent (MCP) or artifact DataPart (A2A)Programmatic clients get typed errors without parsing text
Level 1 is the minimum for a conformant implementation. Level 2 is where agent-driven recovery becomes possible β€” without recovery, agents must guess from the error code. Level 3 is where client libraries like @adcp/sdk can provide fully typed error objects.

Error Categories

1. Protocol Errors

Transport/connection issues not related to AdCP business logic:
  • Network timeouts
  • Connection refused
  • TLS/SSL errors
  • JSON parsing errors
Handling: Retry with exponential backoff.

2. Task Errors

Business logic failures returned as status: "failed":
  • Insufficient inventory
  • Invalid targeting
  • Budget validation failures
  • Resource not found
Handling: Check the recovery field to determine whether to retry, fix the request, or escalate.

3. Validation Errors

Malformed requests that fail schema validation:
  • Missing required fields
  • Invalid field types
  • Out-of-range values
Handling: Fix request format and retry. Usually development-time issues.

Error Response Format

Failed operations return status failed with error details. The error object follows the error.json schema:
{
  "status": "failed",
  "message": "Budget is below the seller's minimum for this product",
  "errors": [
    {
      "code": "BUDGET_TOO_LOW",
      "message": "Budget is below the seller's minimum for this product",
      "recovery": "correctable",
      "field": "budget.total",
      "suggestion": "Increase budget to at least 500 USD",
      "details": {
        "minimum_budget": 500,
        "currency": "USD"
      }
    }
  ]
}

Envelope vs. payload errors β€” the two-layer model

AdCP exposes errors in two distinct places, and implementers need to populate the right layer for the right situation. This is the single most common source of error-shape drift between agents and storyboards.
LayerKeyWhen to populateShape
Task payloadpayload.errors[] (or top-level errors[], depending on transport)The task ran; the payload reports one or more issues (fatal or non-fatal)Array of error objects per error.json
Transport envelopeadcp_errorThe task failed and the transport needs a typed, extractable signalSingle error object per error.json
A fatal task failure SHOULD populate both layers. The payload carries the structured errors[] array that any protocol can read verbatim, and the transport envelope carries adcp_error so MCP/A2A clients can extract a typed error without re-parsing the payload. Populating only one of the two is the source-of-truth for most interop bugs β€” a runner that reads the transport envelope sees no error, and a runner that reads the payload sees no error signal on the transport:
// MCP β€” structuredContent AND payload both carry the error
{
  "content": [{"type": "text", "text": "{\"adcp_error\":{\"code\":\"BUDGET_TOO_LOW\", ...}}"}],
  "isError": true,
  "structuredContent": {
    "adcp_error": { "code": "BUDGET_TOO_LOW", "message": "...", "recovery": "correctable" },
    "payload": {
      "errors": [
        { "code": "BUDGET_TOO_LOW", "message": "...", "recovery": "correctable", "field": "budget.total" }
      ]
    }
  }
}
// A2A β€” artifact DataPart carries adcp_error; if the agent also surfaces payload via a sibling DataPart, errors[] lives there
{
  "status": { "state": "failed" },
  "artifacts": [{
    "artifactId": "error-result",
    "parts": [
      { "kind": "data", "data": { "adcp_error": { "code": "BUDGET_TOO_LOW", ... } } },
      { "kind": "data", "data": { "errors": [{ "code": "BUDGET_TOO_LOW", "field": "budget.total", ... }] } }
    ]
  }]
}
Non-fatal errors populate only the payload. A status: "submitted" or status: "input-required" task reporting a warning (e.g., β€œthis media buy requires manual approval β€” [warning details]”) populates errors[] in the payload with severity: "warning" but MUST NOT populate adcp_error. The transport envelope signals β€œthe task failed” β€” it is not a warning channel. Storyboard validators. Prefer check: error_code over check: field_present, path: "errors" when asserting on a failed task’s error code. error_code is shape-agnostic β€” the runner resolves it from either adcp_error.code (transport) or errors[0].code (payload). Direct path: "errors" checks pin the assertion to the payload shape and fail against agents that surface errors only via the transport envelope, even when the agent is conformant. See Storyboard authoring β€” Asserting on errors. Discriminated rejection arms. When the task response defines a structured rejection arm (e.g., AcquireRightsRejected, CreativeRejected β€” see the wire-placement guidance on GOVERNANCE_DENIED for the rule), the spec-correct denial response carries no error code on the wire β€” the rejection arm enforces not: { required: [errors] } at the schema layer. Asserting check: error_code will fail against a conformant agent. Assert on the discriminator instead: check: field_value, path: "status", value: "rejected". This is the pattern for governance denial on acquire_rights and policy denial on creative_approval; assertions that mix the two paths (error_code for tasks with rejection arms, field_value for tasks without) bake non-spec opinions into the storyboard.

Error Object Fields

These fields are defined by the error.json schema:
FieldTypeRequiredDescription
codestringYesMachine-readable error code from the standard vocabulary or a seller-specific code
messagestringYesHuman-readable error description
recoverystringNoAgent recovery classification: transient, correctable, or terminal
retry_afternumberNoSeconds to wait before retrying (transient errors)
fieldstringNoField path in JSONPath-lite format (e.g., packages[0].targeting). When issues is present, sellers MUST set this to issues[0].pointer translated from RFC 6901 to JSONPath-lite (e.g., /packages/0/targeting β†’ packages[0].targeting). Will be deprecated in a future major version.
issuesarrayNoStructured list of validation failures. Each entry carries pointer (RFC 6901), message, keyword (JSON Schema keyword that rejected β€” required / type / format / etc.), and optionally schema_id, schemaPath, discriminator. See Validator-internals fields for the schema_id / schemaPath / discriminator semantics, the production-emit rules, and the resolution path for schema_id.
suggestionstringNoSuggested fix for the error
detailsobjectNoAdditional context-specific information. Sellers MAY mirror issues[] here as details.issues for backward compatibility with pre-3.1 consumers; new consumers SHOULD prefer the top-level issues field.

Validator-internals fields on issues

Three optional fields on each issues[] entry name the schema element that rejected the payload, so agents can recover from validation errors in one iteration instead of probing variants:
FieldShapePurpose
schema_idstring β€” a published $id (e.g. /schemas/3.1.0/core/activation-key.json)Canonical name of the rejecting (sub-)schema. 3.1+ consumers’ primary handle.
schemaPathstring β€” a JSON Schema tree path (e.g. #/properties/packages/items/oneOf/1)Validator-internal traversal. Retained for 3.0.x backward compatibility; 3.1+ consumers SHOULD prefer schema_id. (Renamed to schema_path in a future major.)
discriminatorarray of {property_name, value}Variant the validator selected for const-discriminated oneOf / anyOf, sourced from values present in the payload. Aligns with OpenAPI 3.x discriminator.propertyName.
Resolving schema_id. The string is the schema’s $id. To load the schema:
  • HTTPS canonical: prepend https://adcontextprotocol.org (e.g. https://adcontextprotocol.org/schemas/3.1.0/core/activation-key.json). Cacheable; immutable per version.
  • SDK-bundled: @adcp/sdk and adcp-client-python ship the schema bundle for offline resolution β€” look up by $id against the bundled tree.
  • Bundled-tree caveat. Tools served from the pre-resolved bundled tree (/schemas/{version}/bundled/...) inline sub-schemas with their $id preserved (3.1+, see #3868) β€” but only on the first occurrence of each sub-schema within a bundle. Same source schema referenced from multiple co-locations gets $id only on the first inline; subsequent occurrences fall back to the nearest $id-bearing ancestor (typically the response root) when SDK error reporting walks up the schema tree. Sub-schemas whose subtrees contain hoisted $defs references also have $id stripped at bundle time, because preserving $id there would break local-fragment resolution. Consumers reading bundles produced before #3868 see only the response-root $id. Detect the pre-#3868 case by checking whether the $id ends in /bundled/<tool>-response.json β€” if so, fall back to walking the bundled schema by pointer.
Discriminator semantics. Sellers populate discriminator only when (a) the rejecting schema is a const-discriminated oneOf / anyOf, and (b) the discriminator property is present in the payload. The wire field reports the value the caller sent β€” not a validator inference on partial-match heuristics β€” so the field is deterministic across Ajv, Python jsonschema, and gojsonschema. When zero variants survive validation, sellers MUST omit discriminator (omission is the signal to the agent that β€œthe validator could not localize a target variant”). For compound discriminators (e.g. audience-selector’s (type, value_type)), entries are ordered by declaration in the rejecting schema’s properties block. When the discriminator property is missing from the payload (the validator can’t even start branch selection), sellers omit discriminator β€” the recovery signal comes from a sibling issues[] entry with keyword: "required" whose pointer names the absent discriminator property. Agents reading β€œmissing required discriminator field” + β€œno discriminator on this issue” recover by populating the named property; agents seeing a discriminator array with a value that didn’t match any branch recover by switching to a different value. Production-emit rules (the public-spec stance). All three fields are emit-on-production safe when the rejecting element is in the published spec at the version the seller advertises via get_adcp_capabilities. The rationale is replay-locally: schemas are published at adcontextprotocol.org and bundled with every SDK, so an adversary running the same validator against the same payload derives the same branch selection β€” the wire field carries no information they can’t compute. The replay-locally argument has carve-outs sellers MUST honor:
  • Private extensions. Sellers running schemas with custom oneOf branches, server-only sub-schemas, or enum subsets layered via additionalProperties: true MUST NOT emit schema_id, schemaPath, or discriminator when the rejecting element is not present in the published spec. Emission would leak seller-internal validation state the adversary cannot replay. Implementation: sellers running a mixed public + private validation tree typically (a) compile public-only and private-only validators separately and emit schema_id only from the public run, or (b) instrument compiled schemas with a side-table mapping each $id to its source bundle.
  • Version skew. Sellers validating against a pre-release or post-release schema MUST NOT emit a schema_id whose $id is not present in the published bundle for the version named in get_adcp_capabilities.
  • Server-narrowed public elements. When the seller server-side filters a public enum, pattern, or numeric range to a tenant-specific subset (e.g. accepts ["a","b","c"] per the public schema, rejects everything except ["a"] for this caller), sellers MUST NOT return VALIDATION_ERROR with keyword: "enum" (or pattern / minimum / maximum) against the public schema_id. The public-spec replay would accept the value; the seller rejects on private state. Use POLICY_VIOLATION or UNSUPPORTED_FEATURE instead so the rejection isn’t mis-attributed to a public schema element. The structural delta between β€œpublic-replay accepts, seller rejects” is itself a fingerprint of the seller’s private subset.
  • Custom keywords. keyword MUST be drawn from the JSON Schema Draft 7 / 2020-12 vocabulary β€” sellers using validator-specific custom keywords (Ajv addKeyword, instanceof) MUST NOT emit them on the wire.
  • Probe terseness. Sellers MAY scope these three fields to dev/sandbox responses on rate-limited endpoints to keep production envelopes terse, even when the carve-outs above don’t apply. Field omission is always conformant.

Standard Error Codes

Standard error codes are defined in error-code.json. The vocabulary is open: error.code is wire-typed as string, the standard codes are documentary, and senders MAY emit codes outside the standard set.

Forward-compatible decoding (normative)

The error-code vocabulary is open. error.code is typed as string in core/error.json β€” not as a closed enum β€” so a strict JSON Schema validator MUST accept any string value. The standard vocabulary in error-code.json is documentary; it constrains neither sender nor receiver at the wire level. Receivers MUST decode unknown codes. A receiver pinned to AdCP version X that decodes a response carrying an error.code introduced in version X+1 (or a platform-specific code outside the standard vocabulary) MUST:
  1. Treat the response as well-formed β€” MUST NOT reject the envelope, throw a deserialization exception, or downgrade to a generic protocol error.
  2. Recover the recovery classification from error.recovery (top-level field on the error envelope) when present. error.recovery is the normative carrier; the enumMetadata.recovery in error-code.json is the documentary mirror.
  3. When error.recovery is absent (legacy senders), apply a conservative default. transient is the safe default for unknown codes β€” retry-with-backoff cannot do worse than terminal classification, and the manifest’s error_code_policy.default_unknown_recovery documents this as the canonical fallback. The transient default is bounded by the retry rules in Β§ Retry Logic β€” receivers MUST apply maxRetries and the jittered exponential-backoff schedule, and MUST NOT loop indefinitely on a transient default. A hostile or buggy sender cannot induce unbounded retries against a conformant client; the open-enum decoding rule does not exempt receivers from the retry budget.
Senders MAY emit codes outside the receiver’s pinned vocabulary. A sender emitting a 3.1-era code (e.g., PROPOSAL_NOT_FOUND) to a 3.0-pinned receiver does not violate the spec β€” the receiver is required by the rule above to handle it. When a sender knows the receiver’s pinned version (via adcp_version envelope echo or capability discovery), senders SHOULD prefer a code from that version’s vocabulary when an equivalent exists; senders MAY emit newer or platform-specific codes when no equivalent is available. Senders MUST populate error.recovery on every error. This is the normative carrier of recovery semantics across version skew β€” receivers cannot reliably classify a code they don’t know, but they can always read error.recovery. Omitting it defeats the forward-compat rule. Why this matters. Forward-compatible decoding is the wire-level invariant that lets future maintenance lines (3.1.x, 4.0.x, …) ship new error codes additively without breaking older receivers. Without it, every new code is a wire change held to the next minor β€” the current drift-lint policy on 3.0.x. With it in 3.1+, new codes can register during a maintenance line’s lifetime, which matters as adopters’ real-world rejection paths surface new codes. 3.0.x policy unchanged. 3.0.x receivers predate this normative rule, so 3.0.x stays wire-stable for the remainder of its support window β€” new codes still land at the next minor. This rule sets the receiver contract from 3.1 onward. Not-found precedence. When a referenced identifier does not resolve, sellers SHOULD return the resource-specific code when the resolved type is known from the request: PRODUCT_NOT_FOUND for product_id, PACKAGE_NOT_FOUND for package_id, MEDIA_BUY_NOT_FOUND for media_buy_id, CREATIVE_NOT_FOUND for creative_id, SIGNAL_NOT_FOUND for signal_id, SESSION_NOT_FOUND for SI session_id, ACCOUNT_NOT_FOUND for account_id, PLAN_NOT_FOUND for governance plan_id. Fall back to REFERENCE_NOT_FOUND for resource types without a dedicated code (e.g., property lists, content standards, rights grants, SI offerings, proposals, catalogs, event sources, collection lists, brands, individual properties). Typed parameters that lack a dedicated standard code MUST use REFERENCE_NOT_FOUND rather than minting a custom *_NOT_FOUND code β€” the vocabulary grows by upstream spec change, not by per-seller inflation. Clients SHOULD switch on error.code first; the resource-specific codes let clients dispatch without parsing error.field. Polymorphic parameters. When the unresolved identifier was supplied via a polymorphic or untyped parameter (a field that accepts multiple resource types), sellers MUST use REFERENCE_NOT_FOUND even if a resource-specific code exists for the resolved type. Using the type-specific code on a polymorphic parameter leaks the resolved type to an unauthorized caller. Polymorphism is evaluated against the parameter’s declared shape in the tool schema β€” before any lookup β€” so a generic reference_id parameter dispatches to REFERENCE_NOT_FOUND regardless of what the id resolves to. Evaluating on the resolved type after dispatch reintroduces the leak. A tool’s declared parameter shape MUST be identical across all callers for a given tool version; dispatch rules MUST NOT be conditioned on caller identity. A schema that exposes property_list_id to tenant B but only reference_id to tenant A turns capability discovery itself into an enumeration oracle (read the schema under two identities, diff). Uniform response for inaccessible references. The uniform-response requirement applies to every not-found code in this vocabulary (REFERENCE_NOT_FOUND, SIGNAL_NOT_FOUND, CREATIVE_NOT_FOUND, MEDIA_BUY_NOT_FOUND, PACKAGE_NOT_FOUND, SESSION_NOT_FOUND, ACCOUNT_NOT_FOUND, PLAN_NOT_FOUND): sellers MUST return the same response for β€œexists but the caller lacks access” as for β€œdoes not exist”. Never distinguish the two β€” this is how cross-tenant enumeration lands. The MUST covers every observable channel, not just error.code:
  • Error object. error.code, error.message, error.field, error.details MUST be byte-equivalent between the two cases. On typed parameters that fall back to REFERENCE_NOT_FOUND, error.field MUST be identical across true-miss and resolve-then-deny β€” either omitted on both or replaced with a type-neutral name on both. error.field MAY name the input parameter when the parameter name is type-neutral (e.g., reference_id); when the original parameter name is type-revealing (e.g., property_list_id), error.field MUST be omitted or replaced with a neutral name. error.message MUST be generic (no resource-qualified text like "Property list not found"). For REFERENCE_NOT_FOUND specifically, sellers MUST NOT leak the resolved resource type via error.field, error.details, or a resource-qualified error.message. When the parameter is an array (e.g., catalog_ids, format_ids), error.field MUST name the array parameter itself. Sellers MAY enumerate specific unresolvable elements in error.details β€” but only when the elements were supplied verbatim by the caller. Sellers MUST NOT distinguish β€œsupplied element resolved but caller unauthorized” from β€œsupplied element does not exist” at the element level; that reintroduces the enumeration oracle at the array-entry granularity.
  • Transport status. HTTP status code, A2A task.status.state, and MCP isError MUST be identical between the two cases.
  • Response headers. ETag, Cache-Control, per-resource-type rate-limit buckets, CDN tags, and any header whose value or presence differs by resource type MUST be identical.
  • Side effects. Webhook dispatch and audit-log writes MUST be identical β€” a resolve-then-deny path MUST NOT write tenant audit rows, enqueue background work (search-indexer updates, cache warmers, access-log aggregators), increment per-resource-type quota/rate-limit counters, or fire webhooks to any subscriber (including the resource owner) in ways a true-miss would not. If the resolve-then-deny path touches a per-tenant DB shard or cache, the true-miss path MUST touch the same shape of storage (e.g., route both through a tenant-agnostic resolver) so that co-tenant observers cannot distinguish via storage-layer metrics.
  • Observability. Downstream logs, APM spans, and third-party error-reporting telemetry (Sentry, Datadog, Rollbar, and equivalents) MUST NOT be tagged with the resolved resource type when the caller lacks access; the trace a true-miss emits MUST be structurally indistinguishable from the trace a resolve-then-deny emits.
To make latency parity a consequence rather than a separate requirement, sellers MUST perform the same resolution-and-authorization work on both paths β€” resolve-then-authorize, never short-circuit on β€œunknown id.” On a true-miss, sellers MUST still execute an authorization decision of equivalent shape (e.g., against an empty principal set, or against the caller’s own tenant as a decoy) so that authorizer latency β€” which varies with the size and shape of the ACL graph β€” is not a side channel. Pre-lookup input validation (UUID format, length, regex) is permitted iff it is deterministic in request content only (same input β†’ same verdict, regardless of caller or existence). Non-normative implementation note: a single-query pattern like SELECT ... WHERE id = ? AND tenant = ? looks uniform but differs in execution plan, buffer-pool touches, and authorizer invocation depending on whether the row exists in another tenant. Prefer the two-step pattern β€” resolve by id, then authorize against the loaded row (or against an empty row on true-miss) β€” as the only pattern that naturally produces observational uniformity. Cache warmth is a distinct oracle: a warm cache on tenant B’s id indicates someone accessed it recently. Sellers MUST NOT gate cache population on authorization β€” true-miss ids MUST be cached-as-miss with the same TTL as resolve-then-deny, or cache reads MUST be bypassed for not-found responses. Verifying this yourself. The paired-probe adcp fuzz invariant checks uniform-response compliance by comparing two responses per tool. See Validate Your Agent β€” Preparing to test uniform error responses for tenant-setup requirements and CLI invocation. Full-strength testing requires two isolated tenants; single-tenant runs cover only the β€œdoes not exist” leg.

Authentication and Access

CodeRecoveryDescriptionResolution
AUTH_REQUIREDcorrectable*Authentication is required, or presented credentials were rejectedProvide credentials when missing; escalate to operator when rejected β€” see the warning below
CREDENTIAL_IN_ARGSterminalBuyer-principal credential was placed in request args (top-level, context, ext, or any nested location) instead of arriving on the transport’s authentication channelDo NOT auto-retry β€” auto-retry re-logs the credential. Move the credential onto the transport channel (Credential placement), rotate the leaked credential, then resubmit
ACCOUNT_NOT_FOUNDterminalAccount reference could not be resolvedVerify via list_accounts or contact seller
ACCOUNT_SETUP_REQUIREDcorrectableAccount needs setup before useCheck details.setup for URL or instructions
ACCOUNT_AMBIGUOUScorrectableNatural key resolves to multiple accountsPass explicit account_id or a more specific natural key
ACCOUNT_PAYMENT_REQUIREDterminalOutstanding balance requires paymentBuyer must resolve billing
ACCOUNT_SUSPENDEDterminalAccount has been suspendedContact seller to resolve
AUTH_REQUIRED sub-cases β€” do not auto-retry rejected credentials. The wire code carries two operationally distinct cases that agents MUST handle differently:
  • Credentials missing β†’ provide credentials and retry once. Correctable inside the agent loop.
  • Credentials presented but rejected (expired, revoked, or malformed signature) β†’ do not auto-retry; escalate to operator for credential rotation. Re-presenting a rejected credential against an SSO endpoint creates retry-storm patterns indistinguishable from brute-force probes β€” the seller’s fraud detection may rate-limit, suspend, or alert on the calling agent.
CREDENTIAL_IN_ARGS is the related β€” but distinct β€” case where the buyer placed a credential in the task payload instead of the transport’s authentication channel. That code is terminal (auto-retry re-logs the credential) and the rule + carve-outs (push-notification webhook auth, relay topology) live in Credential placement.A future minor release splits this code into AUTH_MISSING (correctable) and AUTH_INVALID (terminal). Until then, agents branch on whether credentials were attached to the failing request:
case 'AUTH_REQUIRED': {
  // The caller's request builder records whether an auth header was attached.
  // The error-handling SDK surfaces this on `error.request_had_credentials` (or you
  // pass it in from your own request wrapper).
  const requestHadCredentials = Boolean(error.request_had_credentials);
  if (!requestHadCredentials) {
    // Sub-case (a) β€” provide credentials and retry.
    await refreshCredentials();
    return retry();
  }
  // Sub-case (b) β€” credentials were presented and rejected.
  // Treat as terminal at the application layer; surface to operator.
  console.error('Credential rejected β€” needs human rotation:', error.message);
  throw error;
}

Billing and Account Setup

Returned by sync_accounts when the request’s billing or account-shape values are not acceptable to the seller. The two billing-rejection codes distinguish which gate fired so agents can dispatch on the right recovery β€” autonomous retry vs surface-to-human β€” without parsing prose. See Buyer-agent identity for the two-layer identity model these codes sit on top of.
CodeRecoveryDescriptionResolution
BILLING_NOT_SUPPORTEDcorrectableSeller declines the requested billing value at the seller-wide capability level (supported_billing does not include the value) or at the per-account-relationship level (e.g., the seller accepts operator billing in general but has no direct relationship with the operator on this specific account)Check get_adcp_capabilities for supported_billing, resubmit with a supported value, or omit billing; when present, dispatch on error.details.scope ("capability" or "account") per billing-not-supported.json
BILLING_NOT_PERMITTED_FOR_AGENTcorrectableSeller-wide capability accepts the requested value, but the calling buyer agent’s commercial relationship does not (e.g., onboarded as passthrough-only β€” no payments relationship β€” so only operator billing is permitted)Retry with error.details.suggested_billing (typically operator) when present; when absent, the rejection is terminal-pending-onboarding β€” the agent MUST NOT auto-retry and MUST surface to a human at the buyer to complete payments-relationship onboarding with the seller offline
PAYMENT_TERMS_NOT_SUPPORTEDcorrectableSeller does not accept the requested payment_terms valueOmit payment_terms to accept the default, retry with a different supported value, or negotiate offline
BRAND_REQUIREDcorrectableBillable operation attempted without a brand referenceInclude brand (domain plus optional brand_id) on the request
Normative requirements:
  • Uniform response without established agent identity. BILLING_NOT_PERMITTED_FOR_AGENT differs from BILLING_NOT_SUPPORTED based on the caller’s onboarded commercial state with the seller. Returning the per-agent code without established identity lets unauthenticated probes use the code-choice as an oracle for β€œis this agent onboarded as agent-billable?” β€” same shape as the *_NOT_FOUND uniform-response rule. The bright line: sellers MUST emit BILLING_NOT_PERMITTED_FOR_AGENT only when agent identity has been established via signed-request derivation per Agent identity or via a credential-to-agent mapping in the seller’s onboarding record. In all other cases β€” including bearer credentials not mapped to a specific agent record β€” sellers MUST return BILLING_NOT_SUPPORTED, and error.details.scope MUST be omitted on this path so a "account" scope hint cannot itself act as a per-account-relationship oracle.
  • BILLING_NOT_PERMITTED_FOR_AGENT details shape is clamped. error.details MUST conform to error-details/billing-not-permitted-for-agent.json: rejected_billing (echoed) plus an optional single suggested_billing retry value. The schema sets additionalProperties: false. The shape MUST NOT carry the agent’s full permitted-billing subset, rate cards, payment terms, credit limit, billing entity, or any other per-agent commercial state β€” full-subset disclosure in a single probe is exactly the oracle the clamp prevents.
  • One-shot retry. A buyer agent that retries with error.details.suggested_billing and receives a second BILLING_NOT_PERMITTED_FOR_AGENT MUST surface to a human rather than retrying again. Recovery is bounded to a single seller-suggested fallback; further iteration indicates seller misconfiguration or onboarding state the agent cannot resolve autonomously.
Recovery dispatch β€” example. The two billing codes recover differently. Implementers SHOULD branch explicitly rather than collapsing to a single retry path. The snippet below uses the response shape returned directly by the seller (the accounts[].errors[] array on a sync_accounts response β€” see task reference), not the @adcp/sdk/testing wrapper used elsewhere in task examples.
async function syncAccountsWithRecovery(client, account) {
  const result = await client.syncAccounts({ accounts: [account] });
  const error = result.accounts[0]?.errors?.[0];
  if (!error) return result;

  switch (error.code) {
    case 'BILLING_NOT_SUPPORTED': {
      // Seller-wide or per-account gate. Check capabilities, dispatch on scope.
      const scope = error.details?.scope;  // "capability" | "account"
      if (scope === 'capability') {
        // The seller never accepts this value. Pick from supported_billing.
        const supported = error.details?.supported_billing ?? [];
        if (supported.length === 0) return surfaceToHuman(error);
        return client.syncAccounts({
          accounts: [{ ...account, billing: supported[0] }],
        });
      }
      // Per-account-relationship reject β€” the operator-on-this-account isn't
      // billable directly. Try the next-most-permissive value the seller's
      // capability allows.
      return tryNextBillingValue(client, account, error);
    }

    case 'BILLING_NOT_PERMITTED_FOR_AGENT': {
      // Per-buyer-agent commercial gate. Autonomous retry only when the seller
      // suggests a fallback; otherwise surface β€” the agent cannot extend its
      // own commercial relationship.
      const suggested = error.details?.suggested_billing;
      if (!suggested) return surfaceToHuman(error);
      return client.syncAccounts({
        accounts: [{ ...account, billing: suggested }],
      });
    }

    default:
      throw error;
  }
}
Example envelope for BILLING_NOT_PERMITTED_FOR_AGENT β€” passthrough-only buyer agent receives a fallback to operator:
{
  "accounts": [{
    "brand": { "domain": "nova-brands.com", "brand_id": "spark" },
    "operator": "pinnacle-media.com",
    "action": "failed",
    "status": "rejected",
    "errors": [{
      "code": "BILLING_NOT_PERMITTED_FOR_AGENT",
      "message": "This buyer agent is onboarded as passthrough-only; only operator billing is permitted.",
      "recovery": "correctable",
      "details": {
        "rejected_billing": "agent",
        "suggested_billing": "operator"
      }
    }]
  }]
}

Authorization (RBAC)

Returned when the caller is authenticated but lacks the specific scope for the request. Enforcement is seller-local; discoverability is via the authorization object on per-account entries in sync_accounts and list_accounts responses. See Caller authorization for the full shape.
CodeRecoveryDescriptionResolution
PERMISSION_DENIEDcorrectableGeneric authorization failure, or a required signed credential (e.g., governance_context) is missing, failed verification, or was issued for a different plan/seller/phaseCall check_governance to mint a valid token, or contact the seller to resolve the underlying permission
SCOPE_INSUFFICIENTcorrectableThe invoked task is not in the caller’s allowed_tasks for this accountRe-read authorization on the account via sync_accounts or list_accounts to discover the caller’s actual allowed_tasks; use a permitted task or request a broader scope
READ_ONLY_SCOPEcorrectableCaller’s scope is read_only: true; the invoked task would mutate stateUse a non-mutating alternative, or request a scope that permits mutation
FIELD_NOT_PERMITTEDcorrectableA request field is not in the caller’s field_scopes allowlist for this taskDrop the disallowed field, or request broader field scope
AGENT_SUSPENDEDterminalCalling buyer agent’s commercial relationship with this seller is temporarily pausedSurface to a human at the buyer; re-onboarding with the seller offline may resolve. The agent cannot unilaterally lift a suspension.
AGENT_BLOCKEDterminalCalling buyer agent’s commercial relationship with this seller is permanently deniedSurface to a human at the buyer; relationships are reinstated only through offline operator action with the seller.
Normative requirements:
  • FIELD_NOT_PERMITTED MUST populate error.field with the exact offending field path (e.g., packages[0].budget, end_time). Without it, agents cannot reliably auto-recover by stripping the field and retrying. When multiple fields are disallowed, sellers SHOULD return one error per offending field so each error.field is unambiguous; if returning a single error, error.details.fields MAY contain the full list.
  • SCOPE_INSUFFICIENT SHOULD include error.details.introspection_hint when the seller supports the authorization object on sync/list. Strawman shape: { "task": "sync_accounts", "account": { ... } } pointing the caller at where to rediscover scope. This closes the β€œI hit an error; what do I do?” loop for coding agents.
  • All four codes MUST populate both the adcp_error envelope field and the payload errors[] array per the two-layer model above β€” scope errors carry through to typed client libraries the same way schema errors do.
SCOPE_INSUFFICIENT vs. PERMISSION_DENIED: use SCOPE_INSUFFICIENT when the task itself is not granted to this caller on this account. Reserve PERMISSION_DENIED for credential-shaped failures (missing signed context, failed signature verification) and for generic seller policy rejections where scope is not the right abstraction. SCOPE_INSUFFICIENT vs. UNSUPPORTED_FEATURE: when both apply β€” the seller doesn’t implement the task AND the caller wouldn’t be scoped for it anyway β€” sellers SHOULD return SCOPE_INSUFFICIENT because it is more actionable (the caller can request broader scope) than UNSUPPORTED_FEATURE (the caller must switch sellers). A seller that returns UNSUPPORTED_FEATURE for a task it does implement but is not exposed to this caller is leaking capability information the caller cannot act on. FIELD_NOT_PERMITTED vs. VALIDATION_ERROR: the field is valid per the task schema and would be accepted from a differently-scoped caller; it is rejected specifically because this caller’s field_scopes does not include it. About recovery: correctable on authorization errors. All four authz codes classify as correctable, meaning the request can be fixed and re-sent. This does NOT mean the agent can fix the request autonomously β€” the β€œcorrection” for SCOPE_INSUFFICIENT and READ_ONLY_SCOPE is an out-of-band scope grant from the operator, which the agent cannot perform on its own. Agents SHOULD surface authz errors to the operator rather than auto-retrying against the same credential; a retry loop against a fixed scope is a bug, not defensive posture β€” with the narrow exception of the bounded disambiguation retries described below. Only FIELD_NOT_PERMITTED has an agent-autonomous recovery path (strip the disallowed field and resubmit). Retry disambiguation for SCOPE_INSUFFICIENT and READ_ONLY_SCOPE. A single response with either code inside the seller’s 300s authorization refresh window is observationally indistinguishable from cross-replica flicker β€” a transient infrastructure artifact that the spec forbids sellers from producing but buyers will still encounter in practice. Before classifying the error as a definitive correctable signal requiring operator intervention, buyers MAY exhaust a bounded retry budget (no more than 3 attempts, each separated by 1–5 seconds of jittered backoff) to establish whether the scope is genuinely insufficient. This is disambiguation logic β€” not recovery from a correctable error. After bounded retries exhaust without success, buyers MUST surface the error and MUST NOT autonomously continue retrying. This retry exception does NOT apply to FIELD_NOT_PERMITTED β€” the agent-autonomous strip-and-resubmit path supersedes it. See Buyer response to SCOPE_INSUFFICIENT within the refresh window for the full guidance including the READ_ONLY_SCOPE caveat on revocation scenarios. FIELD_NOT_PERMITTED β€” example envelope and payload populating both layers:
{
  "adcp_error": {
    "code": "FIELD_NOT_PERMITTED",
    "message": "Caller's field_scopes for update_media_buy does not include this field",
    "recovery": "correctable",
    "field": "packages[0].budget"
  },
  "payload": {
    "errors": [
      {
        "code": "FIELD_NOT_PERMITTED",
        "message": "Caller's field_scopes for update_media_buy does not include this field",
        "recovery": "correctable",
        "field": "packages[0].budget",
        "details": {
          "task": "update_media_buy",
          "permitted_fields": ["reporting_webhook"]
        }
      }
    ]
  }
}
The field value identifies exactly what to strip; details.permitted_fields (optional, advisory) lists the allowlist for the offending task so an agent can verify before retrying. For SCOPE_INSUFFICIENT, populate details.introspection_hint: { "task": "list_accounts", "account": { ... } } to point the caller at where to rediscover scope.

Per-Agent Authorization Gate

The per-buyer-agent gate fires across three distinct rejection paths, each with its own discriminator so callers can dispatch without parsing prose:
Codedetails.scopedetails.reasonMeaning
AGENT_SUSPENDEDβ€” (no details.scope; the code is the discriminator)β€”Agent’s commercial relationship with the seller is temporarily paused. Re-onboarding may resolve; recovery: "terminal".
AGENT_BLOCKEDβ€” (no details.scope; the code is the discriminator)β€”Agent’s commercial relationship is permanently denied. No autonomous recovery; recovery: "terminal".
PERMISSION_DENIED"agent""sandbox_only"Agent is provisioned for sandbox traffic only and the request was against a non-sandbox account. Per error-details/agent-permission-denied.json, additionalProperties: false.
Per-agent commercial-status rejections (AGENT_SUSPENDED, AGENT_BLOCKED) follow the BILLING_NOT_PERMITTED_FOR_AGENT precedent β€” the code itself is the discriminator and the response carries no explicit error.details.scope field. The PERMISSION_DENIED + scope:"agent" path is reserved for non-status provisioning gates whose reason is registered in the closed enum on error-details/agent-permission-denied.json. The "agent" value is a registered subset of the shared discriminator vocabulary in enums/error-scope.json. When to mint a new code vs. extend the reason enum. Lifecycle-terminal per-agent states (suspended, blocked, future deny-list-style states) get dedicated codes β€” they carry distinct recovery classifications and warrant first-class discriminators. Non-status provisioning gates and transient rejections extend the error-details/agent-permission-denied.json reason enum (e.g., sandbox_only) instead; throttling-shaped rejections reuse RATE_LIMITED, not a new per-agent code. The dedicated-code-per-state pattern composes only because the lifecycle vocabulary is finite β€” not as an open registry for every new gate. Migration note from the 3.0.5 placeholder. 3.0.5 shipped agent-permission-denied.json with a details.status: ["suspended", "blocked"] axis as a placeholder for the per-agent lifecycle. 3.1 consolidates that placeholder into the dedicated AGENT_SUSPENDED / AGENT_BLOCKED codes β€” the status axis is removed from agent-permission-denied.json and the schema accepts only scope:"agent" + reason:"sandbox_only". Sellers that integrated against the 3.0.5 placeholder MUST migrate to the dedicated codes; the placeholder shape is not preserved. Normative requirements (apply to all three codes uniformly):
  • Uniform response without established agent identity. Per-agent rejections are meaningful only when buyer-agent identity has been established via signed-request derivation per Agent identity or via a credential-to-agent mapping in the seller’s onboarding record. Sellers MUST emit AGENT_SUSPENDED / AGENT_BLOCKED or PERMISSION_DENIED with details.scope: "agent" ONLY on that path; in all other cases β€” including bearer credentials not mapped to a specific agent record β€” sellers MUST return generic PERMISSION_DENIED and MUST omit error.details.scope. Returning the per-agent code (or per-agent scope) without established identity lets unauthenticated probes use the code-choice as a cross-tenant onboarding oracle, the same shape the *_NOT_FOUND uniform-response rule and BILLING_NOT_PERMITTED_FOR_AGENT close.
  • Channels covered by the omit-on-unestablished-identity rule. The MUST applies across every observable channel β€” error.code / error.message / error.field / error.details (message MUST be generic on the unestablished-identity path); HTTP status, A2A task.status.state, and MCP isError; response headers (ETag, Cache-Control, per-agent rate-limit buckets, CDN tags); side effects (audit-log writes, webhook dispatch, background-job enqueues, per-agent quota counters, DB-shard routing, onboarding-record cache population); observability (logs, APM spans, Sentry/Datadog/Rollbar tags MUST NOT carry agent identity, agent-record references, or per-agent gate classification on the unestablished-identity path). Polymorphism is evaluated against the tool-schema’s declared parameter shape before any onboarding-record lookup β€” a tool’s declared shape MUST be identical across all callers regardless of whether identity has been established.
  • Latency parity. Sellers MUST execute the same shape of onboarding-record lookup on both paths (e.g., resolve-then-authorize against an empty agent-record on the unmapped-credential path) so that lookup latency does not distinguish β€œcredential is not mapped to an agent” from β€œcredential maps to a suspended/blocked agent.” Resolve-then-authorize, decision-of-equivalent-shape, the same posture the *_NOT_FOUND rule requires. Cache population MUST NOT be gated on identity establishment β€” the cache itself is otherwise an oracle through hit/miss timing.
  • Retry-counter side channel. The β€œno autonomous retry” rule (below) MUST NOT itself become a side channel. Sellers MUST NOT increment a per-agent retry/backoff counter, mint a different retry_after, or surface different rate-limit headers on the unestablished-identity path than they would on a true-unauthenticated path. Buyer agents that comply with the no-retry rule MUST NOT have that compliance reflected in seller-side per-agent observability that another tenant can read.
  • No per-agent commercial state on any of the three paths. None of AGENT_SUSPENDED, AGENT_BLOCKED, or PERMISSION_DENIED + scope:"agent" carry the agent’s full permitted-billing subset, rate cards, payment terms, credit limit, billing entity, contact channels, custom reason strings, or any other per-agent commercial state β€” full-subset disclosure in a single probe is exactly the oracle the clamp prevents. AGENT_SUSPENDED / AGENT_BLOCKED carry no error.details payload; PERMISSION_DENIED + scope:"agent" MUST conform to error-details/agent-permission-denied.json (scope + registered reason, with additionalProperties: false). New reason values MUST be added to the schema’s enum so cross-language SDKs can dispatch without parsing prose; future per-agent state surfaces (escalation channels, lift-policy URLs) belong on new dedicated codes with their own clamped details shapes, NOT by relaxing this shape. The schema’s additionalProperties: false is a schema-validation guard; sellers MUST treat unrecognized or extension keys received from a seller-side composer as a defect and drop them before transmission.
  • No autonomous retry on the per-agent gate. All three codes are terminal in practice: AGENT_SUSPENDED and AGENT_BLOCKED declare it directly via recovery: "terminal" in enumMetadata; the PERMISSION_DENIED + scope:"agent" path is correctable at the wire level (matching the registered PERMISSION_DENIED classification) but is terminal-pending-onboarding in practice β€” the agent cannot unilaterally lift a sandbox-only provisioning. Buyer agents MUST surface to a human at the buyer rather than auto-retrying on any of the three; re-attempts only reinforce the gate.
Recovery dispatch β€” example. Branch on error.code first; only fall through to details.scope for the PERMISSION_DENIED per-agent gate:
async function dispatchAuthzError(error) {
  // Per-agent commercial status β€” code is the discriminator, no details payload.
  if (error.code === 'AGENT_SUSPENDED' || error.code === 'AGENT_BLOCKED') {
    return surfaceToHuman({ code: error.code });
  }

  if (error.code !== 'PERMISSION_DENIED') throw error;

  // Generic credential-shaped failure β€” no scope on details.
  if (!error.details?.scope) {
    return refreshGovernanceContextAndRetry(error);
  }

  // Per-agent provisioning gate β€” terminal-pending-onboarding.
  if (error.details?.scope === 'agent') {
    const { reason } = error.details;
    // reason: 'sandbox_only'
    return surfaceToHuman({ code: error.code, reason });
  }

  throw error;  // Unknown scope β€” surface rather than guess.
}
Example envelope for AGENT_SUSPENDED:
{
  "adcp_error": {
    "code": "AGENT_SUSPENDED",
    "message": "Buyer agent's commercial relationship with this seller is suspended.",
    "recovery": "terminal"
  }
}
Example envelope for PERMISSION_DENIED with the sandbox-only provisioning gate:
{
  "adcp_error": {
    "code": "PERMISSION_DENIED",
    "message": "This buyer agent is provisioned for sandbox traffic only.",
    "recovery": "correctable",
    "details": {
      "scope": "agent",
      "reason": "sandbox_only"
    }
  }
}
The wire-level recovery: "correctable" on the sandbox-only path is the registered classification on PERMISSION_DENIED per error-code.json enumMetadata β€” SDKs MUST NOT switch the registered value based on details.scope. Buyer agents MUST treat the rejection as terminal-pending-onboarding regardless of the wire-level recovery field, surface to a human, and not auto-retry. (For the suspended/blocked paths, the code itself carries recovery: "terminal" directly, so this caveat does not apply.)

Request Validation

CodeRecoveryDescriptionResolution
INVALID_REQUESTcorrectableRequest is malformed or violates schema constraintsCheck request parameters and fix
UNSUPPORTED_FEATUREcorrectableRequested feature not supported by this sellerCheck get_adcp_capabilities and remove unsupported fields
POLICY_VIOLATIONcorrectableRequest violates content or advertising policiesReview policy requirements in the error details
COMPLIANCE_UNSATISFIEDcorrectableRequired disclosure cannot be satisfied by the target formatChoose a format that supports the required disclosure capabilities
GOVERNANCE_DENIEDcorrectableA registered governance agent denied the transactionRestructure the buy, escalate to human spending authority, or contact the governance agent

Inventory and Products

CodeRecoveryDescriptionResolution
PRODUCT_NOT_FOUNDcorrectableReferenced product IDs are unknown or expiredRemove invalid IDs, or re-discover with get_products
PRODUCT_UNAVAILABLEcorrectableProduct is sold out or no longer availableChoose a different product
PROPOSAL_EXPIREDcorrectableReferenced proposal has passed its expires_atRun get_products to get a fresh proposal
PROPOSAL_NOT_FOUNDcorrectableproposal_id is unknown to the seller (never finalized, wrong tenant, or evicted from cache)Re-issue get_products with buying_mode: "refine" + action: "finalize" to obtain a current proposal_id
MULTI_FINALIZE_UNSUPPORTEDcorrectablerefine[] carried multiple action: "finalize" entries; seller cannot guarantee atomic multi-proposal commitSequence single-proposal finalize calls (one finalize per get_products call)
REQUOTE_REQUIREDcorrectableRequested update falls outside the envelope (budget, dates, volume, targeting) the original quote was priced against; pricing_option remains lockedCall get_products with buying_mode: "refine" against the existing proposal_id, then resubmit against the new proposal_id
SIGNAL_NOT_FOUNDcorrectableReferenced signal does not exist in the catalogVerify signal_id via get_signals, or confirm availability from this agent
AUDIENCE_TOO_SMALLcorrectableAudience segment below minimum sizeBroaden targeting or upload more audience members

Budget and Creative

CodeRecoveryDescriptionResolution
BUDGET_TOO_LOWcorrectableBudget below seller’s minimumIncrease budget or check capabilities.media_buy.limits
BUDGET_EXHAUSTEDterminalAccount or campaign budget fully spentBuyer must add funds or increase budget cap
CREATIVE_NOT_FOUNDcorrectableReferenced creative does not exist in the libraryVerify creative_id via list_creatives, or register it via sync_creatives
CREATIVE_REJECTEDcorrectableCreative failed content policy reviewRevise per seller’s advertising_policies

System

CodeRecoveryDescriptionResolution
RATE_LIMITEDtransientRequest rate exceededWait for retry_after seconds, then retry
SERVICE_UNAVAILABLEtransientSeller service temporarily unavailableRetry with exponential backoff
STALE_RESPONSEtransient (advisory)Non-fatal: seller served a populated payload from cache past its freshness target because an upstream/sub-agent was unreachable. Distinct from SERVICE_UNAVAILABLE (empty payload + fatal). error.details follows error-details/stale-response.jsonAccept the cached payload, or retry later for fresh data β€” inspect error.details.cache_age_seconds to decide
CONFIGURATION_ERRORterminalSeller-side deployment misconfiguration (e.g., missing mock_upstream_url, undeclared upstream_url, unset env var). Distinct from SERVICE_UNAVAILABLE (transient) and INVALID_REQUEST (buyer-fixable). Sellers MUST flip transport failure markers (HTTP 5xx, MCP isError: true, A2A failed); error.message carries operator-actionable detail and MUST NOT include credentials, connection strings, or stack tracesSurface to the seller’s operator; do not auto-retry β€” retries will not resolve a misconfigured deployment
CONFLICTtransientConcurrent modification detectedRe-read the resource and retry with current state
REFERENCE_NOT_FOUNDcorrectableGeneric fallback for referenced resources without a dedicated not-found code. See Not-found precedenceVerify the identifier via the appropriate discovery task; prefer a resource-specific code when one exists

Recovery Classification

Use the recovery field to determine how to handle errors:
RecoveryMeaningAction
transientTemporary failure (rate limit, service unavailable, conflict)Retry after retry_after or with exponential backoff
correctableRequest can be fixed and resent (invalid field, budget too low, creative rejected)Modify the request and retry
terminalRequires human action (account suspended, payment required)Escalate to a human operator
For unknown recovery values (forward compatibility), treat as terminal.
function isRetryable(error) {
  // Use recovery field when available
  if (error.recovery) {
    return error.recovery === 'transient';
  }

  // Network errors are retryable
  if (error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT') {
    return true;
  }

  // Fall back to error code matching
  return ['RATE_LIMITED', 'SERVICE_UNAVAILABLE', 'CONFLICT'].includes(error.code);
}

Retry Logic

The rules in this section bound every transient-classified error a caller may retry, including the transient default applied to unknown error codes under Β§ Forward-compatible decoding. A receiver that decodes an unknown code and falls back to transient MUST apply maxRetries and the jittered exponential-backoff schedule below; the open-enum decoding rule does not exempt receivers from the retry budget. A hostile or buggy sender emitting code=GO_FOREVER, recovery=transient cannot induce unbounded retries against a conformant client.

Normative throttling behavior

These rules apply when a caller receives a throttling-category error (RATE_LIMITED, or any error whose recovery is transient and whose details conform to the rate-limited detail shape):
  • Callers MUST honor retry_after when present and MUST NOT retry the same request sooner than the indicated number of seconds.
  • Callers SHOULD use exponential backoff with jitter when retry_after is absent. A base of 2 seconds, a cap of 60 seconds, and Β±25% jitter is a safe default.
  • Callers MUST NOT treat non-throttling errors (e.g., INVALID_REQUEST, CREATIVE_REJECTED) as if they were throttled. Retrying a rejected-for-other-reasons response at backoff cadence is a bug, not a defensive posture.
  • Callers SHOULD surface repeated throttling to their operator rather than retrying indefinitely; a persistent RATE_LIMITED response is a capacity or policy signal, not a transient blip.
  • Sellers SHOULD return RATE_LIMITED with a populated retry_after rather than silently queuing or dropping requests, so well-behaved callers can back off intentionally.
  • Sellers MAY populate the rate-limited detail shape (limit, remaining, window_seconds, scope) to let callers plan ahead rather than react per-429.

Exponential Backoff

Implement exponential backoff for retryable errors:
async function retryWithBackoff(fn, options = {}) {
  const {
    maxRetries = 3,
    baseDelay = 1000,
    maxDelay = 60000
  } = options;

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      if (!isRetryable(error) || attempt === maxRetries) {
        throw error;
      }

      // Use retry_after when available, otherwise exponential backoff
      const retryAfter = error.retry_after ||
        Math.min(baseDelay * Math.pow(2, attempt), maxDelay);

      // Add jitter to prevent thundering herd
      const jitter = retryAfter * (0.75 + Math.random() * 0.5);
      await sleep(jitter);
    }
  }
}

Rate Limit Handling

async function handleRateLimit(error, retryFn) {
  if (error.recovery !== 'transient' &&
      error.code !== 'RATE_LIMITED') {
    throw error;
  }

  const retryAfter = error.retry_after || 60;
  console.log(`Rate limited. Waiting ${retryAfter} seconds...`);

  await sleep(retryAfter * 1000);
  return retryFn();
}

Error Handling Patterns

Basic Error Handler

async function handleAdcpError(error) {
  // Use recovery classification when available
  switch (error.recovery) {
    case 'transient':
      const delay = error.retry_after
        ? error.retry_after * 1000
        : 5000;
      await sleep(delay);
      return retry();

    case 'correctable':
      // Surface suggestion so the request can be fixed
      if (error.suggestion) {
        console.log('Suggestion:', error.suggestion);
      }
      if (error.field) {
        console.log('Problem field:', error.field);
      }
      throw error;

    case 'terminal':
      console.error('Terminal error:', error.message);
      throw error;
  }

  // Fall back to error code matching
  switch (error.code) {
    case 'AUTH_REQUIRED': {
      // Two sub-cases share this code; see the AUTH_REQUIRED warning above.
      const requestHadCredentials = Boolean(error.request_had_credentials);
      if (!requestHadCredentials) {
        await refreshCredentials();
        return retry();
      }
      // Credentials were presented and rejected β€” surface to operator.
      throw error;
    }

    case 'INVALID_REQUEST':
      console.error('Validation error:', error);
      throw error;

    default:
      console.error('AdCP error:', error);
      throw error;
  }
}

User-Friendly Messages

Convert technical errors to user-friendly messages:
const USER_MESSAGES = {
  'RATE_LIMITED': 'Too many requests. Please wait a moment and try again.',
  'BUDGET_TOO_LOW': 'This is below the seller\'s minimum budget. Increase your budget.',
  'PRODUCT_NOT_FOUND': 'One or more products could not be found. Try searching again.',
  'ACCOUNT_SUSPENDED': 'Your account has been suspended. Contact the seller to resolve.',
  'SERVICE_UNAVAILABLE': 'The service is temporarily unavailable. Please try again in a few minutes.',
  'CREATIVE_REJECTED': 'Your creative did not pass policy review. Check the suggestion for details.',
  'AUDIENCE_TOO_SMALL': 'Your target audience is too small. Try broadening your targeting.'
};

function getUserMessage(code, fallbackMessage) {
  return USER_MESSAGES[code] || fallbackMessage || 'An unexpected error occurred. Please try again.';
}

Structured Error Logging

Log errors with context for debugging:
function logError(error, context = {}) {
  console.error('AdCP Error:', {
    code: error.code,
    recovery: error.recovery,
    message: error.message,
    field: error.field,
    timestamp: new Date().toISOString(),
    ...context,
    // Don't log sensitive data
    // NO: credentials, briefs, PII
  });
}

Webhook Error Handling

Failed Webhook Delivery

When webhook delivery fails, fall back to polling:
class WebhookErrorHandler {
  async onDeliveryFailure(taskId, error) {
    console.warn(`Webhook delivery failed for ${taskId}:`, error);

    // Start polling as fallback
    this.startPolling(taskId);

    // Track failure for monitoring
    this.metrics.incrementCounter('webhook_failures');
  }

  async startPolling(taskId) {
    const response = await adcp.call('tasks/get', {
      task_id: taskId,
      include_result: true
    });

    if (['completed', 'failed', 'canceled'].includes(response.status)) {
      await this.processResult(taskId, response);
    } else {
      // Schedule next poll
      setTimeout(() => this.startPolling(taskId), 30000);
    }
  }
}

Webhook Handler Errors

Handle errors in your webhook endpoint gracefully:
app.post('/webhooks/adcp', async (req, res) => {
  try {
    // Always respond quickly
    res.status(200).json({ status: 'received' });

    // Process asynchronously
    await processWebhookAsync(req.body);
  } catch (error) {
    // Log error but don't fail the response
    console.error('Webhook processing error:', error);

    // Move to dead letter queue for investigation
    await deadLetterQueue.add(req.body, error);
  }
});

Recovery Strategies

Context Recovery

If context expires, start a new conversation:
async function callWithContextRecovery(request) {
  try {
    return await adcp.call(request);
  } catch (error) {
    if (error.code === 'INVALID_REQUEST' &&
        error.message?.includes('context not found')) {
      // Clear stale context and retry
      delete request.context_id;
      return await adcp.call(request);
    }
    throw error;
  }
}

Partial Success Handling

Some operations may partially succeed:
{
  "status": "completed",
  "message": "Created media buy with warnings",
  "media_buy_id": "mb_123",
  "errors": [
    {
      "code": "COMPLIANCE_UNSATISFIED",
      "message": "Required disclosure position not supported by one placement",
      "field": "packages[0].placements[2]",
      "suggestion": "Choose a format that supports the required disclosure positions"
    }
  ]
}
Handle partial success:
function handlePartialSuccess(response) {
  if (response.status === 'completed' && response.errors?.length) {
    // Show warnings to user
    for (const warning of response.errors) {
      showWarning(warning.message, warning.suggestion);
    }
  }

  // Continue with successful result
  return response;
}

Governance Error Patterns

check_governance returns a status field rather than an error object. Governance results are not errors in the protocol sense β€” they are decisions. Handle them separately from AdCP task errors.
Governance statusMeaningAction
approvedPlan passes governanceProceed
conditionsApproved with constraintsApply conditions, re-check
deniedPlan violates governanceBlock the operation
If the governance agent needs human review internally (e.g., the action exceeds the agent’s authority), check_governance behaves like any async task β€” it returns submitted/working status and eventually resolves to approved or denied. Handle this with the standard async task lifecycle, not special-case logic. Governance errors from the protocol layer (as opposed to governance decisions) use the standard error format. The most common:
CodeRecoveryWhen it occurs
PLAN_NOT_FOUNDcorrectablesync_plans was not called before check_governance
INVALID_REQUESTcorrectableMissing required fields (e.g., plan_id, caller)
AUTH_REQUIREDcorrectableGovernance agent requires authentication

Configuration Error Patterns

CONFIGURATION_ERROR signals a seller-side deployment defect β€” an account declared with mode: 'mock' but no mock_upstream_url, a platform on mode: 'live' or mode: 'sandbox' with no upstream_url, a required environment variable unset on the seller process. The buyer cannot fix it; retries cannot resolve it; an operator at the seller has to. The catalog has no generic INTERNAL_ERROR code by design, and CONFIGURATION_ERROR is deliberately narrower β€” it covers the actionable slice where the remediation is β€œreport this to the seller’s operator.” Opaque crashes that don’t fit that profile remain catalog-uncoded; sellers MAY return platform-specific codes, and buyers fall back to the recovery classification per the forward-compatibility rule.

Aggregate signal: per-request terminal, per-seller outage

A single CONFIGURATION_ERROR is terminal for the request that received it β€” the buyer MUST surface to a human at the seller and MUST NOT auto-retry. Repeated CONFIGURATION_ERROR from the same seller in a short window is an operational signal of a different kind: a seller-side outage. Buyer-side dashboards and alerting SHOULD treat aggregate CONFIGURATION_ERROR rate per seller as an outage indicator (e.g., page on N occurrences in M minutes from a single seller), distinct from the per-request-terminal handling. This convergence matters because a buyer that buckets aggregate CONFIGURATION_ERROR with generic terminal errors loses the seller-isolated outage signal that motivated the code’s existence.

error.message: operator-actionable, not deployment-internal

The code itself is the discriminator β€” CONFIGURATION_ERROR carries no error.details shape (the minimal-disclosure precedent of AGENT_SUSPENDED / AGENT_BLOCKED applies). error.message carries the diagnostic, and sellers SHOULD calibrate it to a level useful to a seller-side operator without leaking deployment internals to the buyer. The message is wire-visible β€” it MUST NOT include credentials, connection strings, full file paths, or stack traces. Useful (operator can act, buyer learns nothing exploitable):
{
  "code": "CONFIGURATION_ERROR",
  "message": "account is mode='mock' but no mock_upstream_url declared in metadata; populate it in the AccountStore",
  "recovery": "terminal"
}
Not useful (the operator already knew there was a problem; the buyer learns where the seller’s filesystem is):
{
  "code": "CONFIGURATION_ERROR",
  "message": "configuration error",
  "recovery": "terminal"
}
Leaks (don’t):
{
  "code": "CONFIGURATION_ERROR",
  "message": "ECONNREFUSED postgres://admin:hunter2@10.0.1.42:5432/prod (at /opt/seller/src/db/pool.ts:127)",
  "recovery": "terminal"
}

Best Practices

  1. Check recovery first β€” it’s the most reliable signal for how to handle an error
  2. Implement retries β€” use exponential backoff for transient errors
  3. Respect rate limits β€” honor retry_after values
  4. Handle unknown codes gracefully β€” fall back to error.recovery; default to transient when absent (see Forward-compatible decoding)
  5. Log with context β€” include code, recovery, and field for debugging
  6. Fallback strategies β€” always have a backup (e.g., polling for webhooks)
  7. Don’t retry terminal errors β€” escalate to a human operator
  8. Handle partial success β€” process warnings in successful responses

Next Steps

  • Transport Bindings: See Transport Errors for how errors travel over MCP and A2A
  • Task Lifecycle: See Task Lifecycle for status handling
  • Webhooks: See Webhooks for webhook error handling
  • Security: See Security for authentication errors