Scope. This guide is for adopters already on the 8.x beta line, moving from the 8.0 beta cut to 8.1. The big theme is AdCP 3.1.0-beta.5 catch-up: response envelopes became stricter, several domain
statusfields were renamed to stop colliding with task status, and webhook verification moved from "example code" to a production recipe.
- Add envelope
statuseverywhere you construct raw wire responses. Server framework users get this stamped for them. - Emit release-precision
adcp_versionvalues such as3.1-beta.5, not full semver values such as3.1.0-beta.5. - Rename domain-level status fields that collided with task status:
MediaBuy.status->media_buy_status, creative approvalstatus->approval_status, rights acquisitionstatus->rights_status. - Drop
governance_agents[].categories; 8.1 validates those items as closed. - Use
packageRefsForFormatOptions()for beta.5 media-buy package requests.packageRefsForCapabilities()is beta.3-only and now emits a one-time warning because beta.5 sellers rejectcapability_ids. - Treat
PROPOSAL_NOT_FOUNDas correctable. Projection diagnostics now reportformat_option_idrather than the beta.3capability_idname. - Fully migrated off
format_ids[]? UsetoCanonicalOnlyProduct/toCanonicalOnlyResponseto drop them — diagnostics flag any ref that didn't project, so dropping legacy never silently loses a format. - For webhook receivers, move to RFC 9421 verification and a shared replay store before running more than one replica. See Verifying inbound webhooks.
- Generic response verification remains unsupported in AdCP 3.x. The SDK keeps signing-only response helpers for compatibility with adopters that publish signed JSON responses.
- If your code extends
ProductSchema, upgrade to 8.1.ProductSchemais intentionally aZodObjectagain, so.extend(),.omit(),.pick(), and.shapework. - Replace new seller-side uses of
AuthRequiredErrorwithAuthMissingErrororAuthInvalidError. The SDK still reads legacyAUTH_REQUIREDresponses, andAuthRequiredErrorremains as a deprecatedAUTH_REQUIREDwrapper for source and wire compatibility. comply_test_controlleris now hidden from live principals. Sandbox/mock principals still see it; targeting a live or unresolved non-sandbox account from that sandbox surface returnsPERMISSION_DENIED.
8.1 does not support generic transport response verification because AdCP 3.x does not authorize RFC 9421 §2.2.9 transport response signing as a protocol surface. Request signing and webhook signing are unchanged.
The SDK keeps the signing-only compatibility surface for agents that already
sign JSON transport responses and publish adcp_use: 'response-signing' JWKs:
signResponse, signResponseAsync, buildResponseSignatureBase,
ResponseLike, ResponseSignatureError, RESPONSE_SIGNING_TAG,
RESPONSE_MANDATORY_COMPONENTS, prepareResponseSignature,
finalizeResponseSignature, SignResponseOptions,
PreparedResponseSignature, and SignedResponse.
Still unsupported:
| Unsupported API | Replacement |
|---|---|
verifyResponseSignature, createResponseVerifier |
None for generic transport responses |
VerifyResponseOptions, VerifyResponseResult, CreateResponseVerifierOptions |
None |
pemToAdcpJwk({ adcp_use: 'response-signing' }) and
mintEphemeralEd25519Key({ adcp_use: 'response-signing' }) are accepted for
this compatibility signing path. signRequestAsync() and signWebhookAsync()
still fail closed when given response-signing keys. signResponseAsync() also
requires providers to declare adcpUse: 'response-signing'; legacy providers
that omit adcpUse remain accepted by request/webhook helpers but are refused
for response signing.
There is no conformant AdCP 3.x replacement for generic transport response verification. Future designated-task payload JWS support should be added under a fresh spec-defined purpose and helper surface rather than expanding this compatibility signing path.
AdCP 3.1.0-beta.2 requires top-level envelope status on every response,
including error responses. The SDK server framework now defaults success
responses to status: 'completed' and error responses to status: 'failed'
unless a tool explicitly returns a richer task state such as submitted or
working.
Raw-handler adopters should audit for hand-constructed responses:
rg -n "return \\{|status:" srcIf the object is a protocol response envelope, it needs a task-status value.
If it is a domain object nested inside a response, use the renamed domain
fields below rather than overloading status.
Wire adcp_version values use MAJOR.MINOR precision with an optional
prerelease suffix. Examples:
| Internal bundle/version | Wire value |
|---|---|
3.1.0-beta.5 |
3.1-beta.5 |
3.1.0 |
3.1 |
The SDK framework normalizes this automatically. Custom emitters should not copy package or schema-cache semver strings directly into wire envelopes.
Top-level status is the task envelope. Domain resources use their own names:
| Old 8.0 beta field | 8.1 field |
|---|---|
media_buy.status |
media_buy.media_buy_status |
creative_approvals[].status |
creative_approvals[].approval_status |
acquire_rights.status |
acquire_rights.rights_status |
This removes the ambiguity where status could mean either the task lifecycle
(completed, failed, submitted) or a business lifecycle (active,
approved, licensed).
The 3.1 schema now treats governance-agent entries as closed objects and
removes the legacy categories field. If you used that field for operator
metadata, move it to your own registry or extension store; do not emit it in
sync_governance, account responses, or test fixtures.
Mutating request validation now follows the AdCP 3.1 rule that vendor
extensions can travel on request shapes. Runtime validation accepts unknown
extension keys on those mutating requests, while SDK-owned fields are still
validated strictly. If you had tests asserting "unknown key rejected" on
mutating requests, update them to assert that known fields still validate and
that extension keys are carried intentionally. Do not use this as a place for
credentials; ctx_metadata and extension objects can still land in logs and
error envelopes.
AdCP 3.1.0-beta.5 removed the beta.3 capability_ids request path from
PackageRequest; beta.5 sellers reject that field rather than treating it as
an extension. New buyer code should compose packages with:
import { packageRefsForFormatOptions } from '@adcp/sdk/v2/projection';packageRefsForCapabilities() remains available from the explicit
@adcp/sdk/v2/projection migration subpath for callers pinned to beta.3
fixtures or sellers. It is deprecated and emits a one-time warning because its
return value is intentionally beta.3-only.
Projection diagnostics also use the beta.5 field name:
diagnostic.error.details.format_option_id. If you log or branch on the old
beta.3 diagnostic detail capability_id, update that reader while keeping any
stored historical logs as-is.
PROPOSAL_NOT_FOUND now reports recovery: 'correctable' in proposal refine /
finalize paths. This matches beta.5 storyboards: buyers can correct the
proposal_id and retry rather than treating the failure as terminal. The SDK's
retry policy already routes this code through its per-code policy, so callers
using decideRetry() do not need a custom override.
AdCP 3.1 splits the old AUTH_REQUIRED code into two explicit cases:
| Code / class | Meaning | Default buyer retry policy |
|---|---|---|
AUTH_MISSING / AuthMissingError |
No credentials were presented | Escalate as auth; retry only if your agent can supply credentials. |
AUTH_INVALID / AuthInvalidError |
Credentials were presented but rejected or revoked | Escalate as terminal; do not retry blindly. |
AUTH_REQUIRED / AuthRequiredError |
Deprecated 3.0 compatibility code | Still accepted on the wire; the typed class still emits AUTH_REQUIRED for compatibility. |
New seller code should throw AuthMissingError when no credential arrived and
AuthInvalidError when verification rejects a presented credential. Existing
buyer code using BuyerRetryPolicy does not need a custom override: the
default table already escalates AUTH_MISSING as an auth problem and
AUTH_INVALID as terminal to avoid retry storms against revoked credentials.
Buyer code must continue to handle legacy AUTH_REQUIRED: older sellers and
some compatibility helper paths still surface it during the 3.x deprecation
window.
AdCP 3.1 tightens deterministic-test discovery: production callers must be
byte-equivalent to a seller that never wired comply_test_controller.
For createAdcpServerFromPlatform adopters that supply complyTest, the
framework now resolves the auth-derived principal with
platform.accounts.resolve(undefined, ctx) before answering
get_adcp_capabilities, tools/list, or a direct controller call:
| Principal mode | Capability block | tools/list |
Direct controller call |
|---|---|---|---|
sandbox / mock |
Includes compliance_testing |
Includes comply_test_controller |
Dispatches, then target-account gate applies |
legacy resolved { sandbox: true } |
Includes compliance_testing |
Includes comply_test_controller |
Dispatches, then target-account gate applies |
live / unresolved |
Omits compliance_testing |
Filters the tool | MCP method-not-found |
Within the visible sandbox/mock surface, the target account is resolved from
the request parameters. If that target resolves to live or cannot be resolved
as sandbox/mock, the controller returns PERMISSION_DENIED; this is the
intentional denial path for "sandbox caller, non-sandbox target." Keep
capabilities.compliance_testing declared when using complyTest, and make
accounts.resolve(undefined, ctx) resolve the authenticated principal if you
want discovery without the legacy env bridge. Legacy ADCP_SANDBOX=1 still
exposes the controller for old conformance deployments, but it fails closed if
the process has resolved any explicit live-mode account. A buyer-supplied
account.sandbox: true is only consulted as an unresolved target-account
fallback after the principal visibility check has already passed; it never makes
a live principal visible and never overrides a resolved live target account.
8.0's generated ProductSchema could appear as a marker-only intersection,
which made object helpers disappear even though validation behavior was still
object-shaped. 8.1 collapses those marker-only intersections during codegen.
This is intentional: the schema keeps the same runtime validation semantics, but TypeScript users can again write:
import { ProductSchema } from '@adcp/sdk';
import { z } from 'zod';
const ProductWithLocalField = ProductSchema.extend({
local_score: z.number(),
});Use this for local validation/adaptation. Do not infer that extension fields belong on the AdCP wire unless the relevant request schema permits extensions.
The generated TypeScript now narrows the mutually exclusive
SyncAccountsRequest.accounts[] branches correctly. ProvisioningMode and
SettingsUpdateMode expose their real fields instead of a loose passthrough
shape, including notification_configs[] for account-scoped events. Existing
valid payloads keep working; the adopter-visible change is better
autocomplete/type-checking and fewer accidental unknown-field escapes.
products can be absent in valid response arms such as unchanged/cache-hit
responses. Any response that carries products or unchanged: true must now
also carry cache_scope. Test fixtures that assumed products was always
present or omitted cache_scope on populated or unchanged responses need to be
updated.
Use this decision table for server payloads:
| Request / pricing shape | cache_scope |
|---|---|
No inline account and no auth-derived/resolved account context |
public |
Request has account, but response uses the universal rate card |
public |
Request has account and response includes account-specific pricing or overlays |
account |
The SDK framework may fill public only when there is no inline account
and no auth-derived/resolved account context. It does not infer public
for account-scoped requests because agents may omit overlay capability
declarations for confidentiality; account-scoped product responses should set
cache_scope explicitly.
Client/storefront composition code that consumes upstream inventory sources can normalize older upstream responses before caching:
import { ensureGetProductsCacheScope } from '@adcp/sdk';
const upstream = await seller.getProducts(req);
const response = ensureGetProductsCacheScope(upstream, {
defaultCacheScope: 'account', // fail-closed for composed storefronts
onInject: event => logger.warn('Injected get_products cache_scope', event),
});validateGetProductsCacheScope(response) is the non-mutating check. It returns
{ ok: false, reason: 'missing_cache_scope' | 'invalid_cache_scope' } when a
populated or unchanged response is not cache-safe.
8.1 makes the canonical-format migration path easier to find from the package
root and from @adcp/sdk/v2/projection.
The package root exposes the CanonicalFormat.* namespace and migration
types. Individual builder functions such as imageFormatDeclaration() remain
available from @adcp/sdk/v2/projection for callers who prefer named imports.
Use format_options[] as the canonical product surface. Keep format_ids[]
only as the v1 fallback during the migration window, using the v2 declaration's
v1_format_ref[] as the authoritative pairing:
import { CanonicalFormat } from '@adcp/sdk';
import { packageRefsForCapabilities, withFormatOptions } from '@adcp/sdk/v2/projection';
const homepageMrec = CanonicalFormat.image(
{ width: 300, height: 250 },
{
format_option_id: 'homepage_mrec',
display_name: 'Homepage MREC',
}
);
const product = {
product_id: 'homepage_takeover',
name: 'Homepage Takeover',
format_options: [homepageMrec],
format_ids: homepageMrec.v1_format_ref,
product_card: CanonicalFormat.productCard({
title: 'Homepage Takeover',
price_label: 'From $12 CPM',
}),
};Do not put format_id on product_card. Product cards describe the product UI;
creative acceptance lives in format_options[] and the v1 fallback
format_ids[].
withFormatOptions / augmentProductWithFormatOptions are additive — they
add format_options[] but preserve format_ids[] for back-compat. Once a
consumer has fully migrated, that preserved format_ids[] is a foot-gun: naive
downstream code keeps reading the stale { agent_url, id } shape and silently
bypasses the canonical model. toCanonicalOnlyProduct / toCanonicalOnlyResponse
are the canonical-only counterparts — format_options[] only, format_ids[]
dropped:
import { toCanonicalOnlyResponse } from '@adcp/sdk';
const { response, diagnostics } = toCanonicalOnlyResponse(getProductsResponse);
// response.products[i] has format_options[] and NO format_ids[]
if (diagnostics.length > 0) logger.warn('Dropped legacy refs that did not project', diagnostics);Dropping legacy never silently loses a format. Every input format_id is either
represented in format_options[] or surfaced in diagnostics —
FORMAT_PROJECTION_FAILED on the v1→v2 projection path, or
LEGACY_FORMAT_ID_DROPPED_UNMAPPED when a v2-native product carries a
format_ids[] entry no format_options[].v1_format_ref covers. Keep
withFormatOptions while you still read format_ids[]; switch to
toCanonicalOnly* when you no longer do.
Buyer/write-side migration:
const { response, diagnostics } = withFormatOptions(getProductsResponse);
if (diagnostics.length > 0) logger.warn('Format projection diagnostics', diagnostics);
const product = response.products[0];
const refs = packageRefsForCapabilities(product, ['homepage_mrec']);
await agent.createMediaBuy({
packages: [{
product_id: product.product_id,
pricing_option_id: product.pricing_options[0].pricing_option_id,
...refs, // capability_ids + format_ids when a v1 fallback exists
budget: 5000,
}],
});If you stored bare format-id strings (display_300x250_image,
video_standard_30s) before the structured-ref convention and maintain a local
inferFormatKindFromFormatId heuristic, delete it. resolveCanonicalFormatKind
and canonicalDeclarationFromBareId resolve a bare id against the same catalog +
registry the projection uses — one source of truth instead of a heuristic that
drifts from the canonical registry:
import { resolveCanonicalFormatKind, canonicalDeclarationFromBareId } from '@adcp/sdk';
resolveCanonicalFormatKind('display_300x250_image'); // 'image'
resolveCanonicalFormatKind('video_standard_30s'); // 'video_hosted'
// Lift a bare id to a full v2 declaration carrying v1_format_ref in one step:
const decl = canonicalDeclarationFromBareId('display_300x250_image');
// decl.format_kind === 'image'
// decl.v1_format_ref === [{ agent_url: 'https://creative.adcontextprotocol.org/', id: 'display_300x250_image' }]Both fail closed — they return null, never a guess, for an unknown id, an
under-specified id (display_300x250, which the catalog only carries as
_image / _html / _generative variants), or a bare id with more than one
AAO-published meaning. A seller-owned URL does not make an arbitrary custom id
resolvable; it is accepted only when the exact bare id has one unambiguous AAO
catalog meaning.
If you hold the asset type (e.g. a format_type field), pass assetType
to disambiguate an under-specified id — the resolver retries the catalog
variant <id>_<suffix> instead of you re-deriving the suffix:
resolveCanonicalFormatKind('display_300x250', { assetType: 'image' }); // 'image'
resolveCanonicalFormatKind('display_300x250', { assetType: 'html' }); // 'html5'
resolveCanonicalFormatKind('display', { assetType: 'javascript' }); // 'display_tag'
// canonical-kind aliases work too: 'html5' → '_html', 'display_tag' → '_js'.
// Still fails closed if <id>_<suffix> isn't a catalog entry.Pass { agentUrl } to preserve the deployed owner when a seller emitted an
unambiguous AAO standard id under its own creative-agent URL. For the
structured diagnostic explaining why an id didn't resolve, run it through
projectV1ProductToV2 inside a one-format product. Seller-specific catalogs
still require an exact injected catalog snapshot or custom converter.
ListCreativeFormatsPayload is the canonical alias for the server handler
payload shape of list_creative_formats. ListCreativeFormatsResponsePayload
and ListCreativeFormatsServerPayload remain equivalent aliases for search
and older local naming conventions. Prefer the canonical alias in new
platform/server code instead of annotating handlers with the generated wire
response type.
Framework server adopters should return SDK server payload aliases from
handlers. Do not hand-stamp protocol envelope fields such as status,
task_id, or adcp_version when using createAdcpServerFromPlatform; the
framework owns those fields and validates after wrapping.
Raw/manual server adopters using response builders still need to pass schema-valid payloads into the builder. For example:
productsResponse({ products, cache_scope: 'public' });
mediaBuyResponse({ media_buy_id, media_buy_status: 'pending_creatives', packages: [] });SyncCreativesPayload now covers both sync success rows and operation-level
failure payloads:
const failed: SyncCreativesPayload = {
errors: [{ code: 'INVALID_REQUEST', message: 'invalid creative batch' }],
};Raw/manual server adopters can pass either arm to syncCreativesResponse();
operation-level errors produce an MCP error response with envelope
status: 'failed'.
Buyer, CLI, and testing users should use @adcp/sdk@beta or an exact
8.1.0-beta.N pin while validating AdCP 3.1 behavior. @latest remains on
the last GA line until 8.1 exits prerelease.
There are two similarly named concepts:
| Import/source | Use it for |
|---|---|
TaskResult['status'] from the root client API |
Handling SDK call results, including client result states like deferred and governance-denied. |
TaskStatus from @adcp/sdk's conversation/client types |
Legacy client-side status vocabulary used inside the SDK, including compatibility states such as pending, running, needs_input, and aborted. |
Protocol TaskStatus from generated protocol types / server payload helpers |
Wire envelope status values on AdCP responses and webhook payloads. |
Do not use the root client TaskStatus alias as the schema for wire
responses. It intentionally includes client-side compatibility states. For
server handler domain payloads, prefer the SDK's server payload aliases so
envelope fields owned by the framework are stripped from handler return types.
8.1 keeps the legacy HMAC helper for older push_notification_config buyers,
but the spec-current path is RFC 9421 webhook signing with
adcp_use: "webhook-signing" keys. The short version:
- Capture raw request bytes before JSON parsing.
- Resolve the expected sending agent from your operation state or route, not from attacker-controlled signature headers.
- Verify with
createWebhookVerifier/verifyWebhookSignature. - Use a shared
ReplayStorefor multi-replica receivers. - Keep HMAC as an explicit legacy branch only; do not fail open from one scheme to another.
Full recipe: Verifying inbound webhooks.
- Raw response fixtures include envelope
status. - Raw error fixtures include
status: 'failed'. - Wire
adcp_versionis release-precision. - Business lifecycle fields use
media_buy_status,approval_status, andrights_status. -
governance_agents[]fixtures no longer emitcategories. -
get_productsfixtures with products includecache_scope. - Storefront/upstream adapters normalize legacy get_products responses with
ensureGetProductsCacheScope()before caching. - Products that support canonical creative formats publish
format_options[]and keepformat_ids[]only as the v1 fallback. - Fully-migrated readers use
toCanonicalOnlyProduct/toCanonicalOnlyResponseinstead ofwithFormatOptions, and surface the diagnostics those return. - Webhook receivers capture raw body bytes and verify before processing.
- Multi-replica webhook receivers use Redis/Postgres replay storage.