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

# Publisher domains

> How storefront publisher domains are stored and synchronized to a managed sales agent

## Overview

Use `businessProfile.publisherDomains` to tell Apostra which publisher
domains your storefront is authorized to sell. This is the canonical publisher
domain set for a managed storefront.

`operatorDomain` is separate: it identifies the company or domain operating the
storefront. For network sellers, the brand domain is often not the same as
the publisher domains being sold. A third domain — the one your account is
registered under — decides whether your brand domain can be verified at all.
See [Which domain is which](/v2/concepts/domains) if any two of them differ.

<Note>
  The top-level `publisherDomain` field on `POST /storefront` and
  `PUT /storefront` is legacy metadata. Keep using `operatorDomain` for the
  storefront identity and `businessProfile.publisherDomains` for the publisher
  domains the storefront can sell.
</Note>

## Discovery publication origins

A Discovery publication origin is separate from the publisher-domain set
above. It is the verified hostname where an approved, public listing
(previously called the media kit or Discovery Card) is served. A registered origin serves the last verified published revision while
its hostname verification remains active. When a replacement revision is
published, the verified revision stays live until the replacement passes its
checks. Drafts, private publications, suspended or expired registrations, and
revisions that have never been verified fail closed.

### Publish from the Listing page

Seller admins manage this from the storefront's **Listing** page (the retired
Settings → Discovery & distribution tab, and the retired Branding &
distribution page, both redirect here). The page previews the canonical
listing identity and reads the name, description, website, logo, and colors
from the storefront's `brand.json` projection. Directory and composer icons
are generated from that logo when the card is published, so those values do
not need to be uploaded or synchronized separately.

The Listing page has four steps, completed in order: **Identity**, **What you
do**, **Listed on Apostra marketplace** (a switch included with every
account — turning it off unlists the storefront, with a confirmation), and
**Public distribution**. There is no segmented Apostra/Public control and
no selectable "Hidden" option — a hidden storefront can never be found, so it
isn't a distribution choice. A storefront that hasn't published yet is shown
as a state, **Not listed yet**, never as a step to pick.

An active, unexpired [Demo Storefront](/v2/storefront/inventory-sources/demo-storefront)
counts only **Identity** and **What you do** towards Demo readiness. Marketplace
publication and Public distribution remain optional choices. The Demo does not
publish itself or add a paid Distribution package to make its setup complete.
Expired and inactive Demos return to the ordinary four-step model.

**Public distribution** is the gate for every app destination, and it has two
parts: add the Distribution package (the paid upgrade), then point your
domain at Apostra with one CNAME at your registrar — Apostra checks
it for you. Once the domain verifies, choose **Make it public** to take Public
distribution live — verifying alone does not publish — and verifying also
requires the storefront to already have a published Apostra listing
(step three, above). Apostra first-party storefront is attached to
the platform-managed `api.apostra.com` origin automatically; it does not
need a CNAME or a separate storefront hostname, and never shows the domain
step.

An already-listed storefront returns to **Not listed yet** by turning off
the **Listed on Apostra marketplace** switch (step three), which asks for
a confirmation. Unlisting takes the storefront off Apostra and the public
web; buyers and directories can no longer find it until it's published again.
An app still served from its own legacy per-app hostname is not affected: it
keeps serving until the hostname is disabled or the app moves to your public
listing domain.

Below the four steps, **Distribution** shows the destinations where buyers
find the listing, as tabs: the **Listing card** (a downloadable PDF) comes
first (its tab is always shown; the download becomes available once your
listing is live on Apostra), then app destinations once Public
distribution is live — the **ChatGPT app** today, with Claude, Slack,
Discord, and WhatsApp listed in the add-destination picker as coming soon.
Before Public distribution is live, the app-destination tab is dimmed with a
note that apps unlock with that step.

OpenAI-specific submission values are entered in the OpenAI portal and are
not copied into the listing; the directory category is the exception —
Apostra sets it to Business for new registrations (an existing
registration keeps its saved category), declares it in the package, and
shows it before you download. OpenAI's
domain-ownership proof is one of the ChatGPT destination's own steps — paste
the token OpenAI gives you and publish it at the address they check — not a
control on the domain step.

The public routes depend on how the origin is hosted:

| Hosting                         | Card                                | JSON                                          | Embed                                     |
| ------------------------------- | ----------------------------------- | --------------------------------------------- | ----------------------------------------- |
| Dedicated customer hostname     | `https://{hostname}/`               | `https://{hostname}/card.json`                | `https://{hostname}/embed`                |
| Apostra first-party publication | `https://api.apostra.com/discovery` | `https://api.apostra.com/discovery/card.json` | `https://api.apostra.com/discovery/embed` |

The first-party card uses `/discovery` because `api.apostra.com` is also the
shared API origin. It does not replace the API root. Dedicated customer
hostnames can serve their card at `/`.

Every registered origin also exposes a routing proof at
`/.well-known/interchange-discovery-registration`. Apostra periodically
rechecks the hostname, HTTPS response, publication identity, and replacement
revision. Public serving switches revisions only after those checks pass, and
stops when active verification expires or the publication is no longer public.

### OpenAI Apps domain verification

The ChatGPT destination's domain-ownership step stores the Apps challenge
token OpenAI provides on the active hostname registration. The exact token is
then available as plain text, with caching disabled, at:

```text theme={null}
https://{hostname}/.well-known/openai-apps-challenge
```

For Apostra first-party publication, that URL is
`https://api.apostra.com/.well-known/openai-apps-challenge`. The challenge
URL is fixed by OpenAI and is not nested under `/discovery`.

Replacing or removing a token requires explicit confirmation. A replacement
invalidates the previous value immediately, and publication, replacement, and
removal events are retained in an immutable audit history. The protected
management view displays the saved token, its public URL, and whether the latest
reachability test returned the exact value. That test does not call OpenAI or
claim that OpenAI has verified the domain.

<Note>
  Publishing remains explicit. Saving storefront branding does not change
  Discovery visibility or advance its immutable public revision.
</Note>

## Set publisher domains

Send the full current domain set on the storefront business profile:

<Note>
  The REST `PUT` below replaces the supplied business profile, so send its full
  current value. MCP clients can instead call `update_business_profile` with only
  `publisherDomains`; fields omitted from the profile state read for that call
  are preserved. Overlapping patch calls use last-writer-wins behavior.
</Note>

<CodeGroup>
  ```bash curl theme={null}
  curl -X PUT https://api.apostra.com/api/v2/storefront \
    -H "Authorization: Bearer scope3_..." \
    -H "Content-Type: application/json" \
    -d '{
      "businessProfile": {
        "publisherDomains": ["mediavine.com", "cafemedia.com"]
      }
    }'
  ```
</CodeGroup>

Apostra normalizes valid domains to lowercase, deduplicates them, and saves
the sorted set. Invalid placeholder values are ignored.

## Managed sales-agent sync

For a storefront backed by an Apostra-managed sales agent,
`businessProfile.publisherDomains` is also synchronized to that managed sales
agent as the authoritative publisher-domain set.

When the set changes:

* Added domains are registered with the managed sales agent.
* Removed domains are cleared from the managed sales agent's authorization
  projection.
* Current domains are rechecked against AAO and `adagents.json` so their
  authorization status stays fresh.

## Authorization status in setup

Storefront setup lists each configured publisher domain separately. A domain can
be:

| Status                | Meaning                                                                                                            |
| --------------------- | ------------------------------------------------------------------------------------------------------------------ |
| Checking              | The first AAO and `adagents.json` resolution has not completed.                                                    |
| Authorized            | The resolved publisher document positively authorizes the applicable storefront agent.                             |
| Not authorized        | The publisher document was resolved but does not authorize the applicable storefront agent for the intended scope. |
| Authorization unknown | No publisher document, an unreadable one, or no conclusive authorization evidence.                                 |
| Check failed          | The latest publisher-document resolution failed.                                                                   |

The underlying `adagentsStatus` separates "there is no file" from "there is a
file and we cannot read it", because the fix is different:

| `adagentsStatus` | Meaning                                                                                                                                                                                     |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `resolved`       | A usable `adagents.json` was retrieved.                                                                                                                                                     |
| `no_adagents`    | We looked and found no file. Publish one.                                                                                                                                                   |
| `invalid`        | A document is published at the well-known path and is not a usable `adagents.json` — commonly an HTML page or a redirect served instead of JSON. Fix the file; do not publish a second one. |
| `error`          | The resolution could not complete (timeout, DNS, 5xx). Retried automatically.                                                                                                               |
| `pending`        | Not yet resolved.                                                                                                                                                                           |

Declaring a domain (`POST /api/v2/storefront/publishers`, or a business-profile
update) kicks off its `adagents.json` resolution immediately — you do not wait
for a background sweep. The result usually lands within seconds; re-read the
publisher list to see it. A failed immediate resolve is retried by the sweep.

Re-declaring a domain you already configured re-checks it the same way,
subject to a short cooldown (currently 10 minutes) since its last completed
sync — so if you just fixed your `adagents.json`, re-declaring the domain (or
resending it in a `PUT` of the full set) gets you a fresh answer immediately
instead of waiting for the sweep. Re-declaring inside the cooldown is a no-op:
it does not reset the domain to "Checking" or trigger another fetch.

Changing the effective inventory-source authorization identity also resets the
current result to **Checking**. This happens when the storefront moves between
a usable external-only identity and operator-run mode, or when an external-only
storefront changes its active agents or one of their URLs. A storefront with no
usable external agent URL already uses `https://interchange.io`, so adding or
removing its first operator-run source does not reset the result. Adding another
operator-run source also keeps the same identity.
Apostra rechecks each configured publisher automatically.

## Why a domain is not authorized

Every resolved domain also carries a machine-readable `authorizationReason`
naming the exact outcome, so you know what to fix rather than seeing a bare
"not authorized":

| `authorizationReason`      | What it means                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      | What to do                                                                                                                                                                                     |
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `no_file`                  | No `adagents.json` was found (a definite 404 at `https://{domain}/.well-known/adagents.json`, and no ads.txt `MANAGERDOMAIN` delegation resolved one). A transient fetch failure does not set this reason.                                                                                                                                                                                                                                                                                         | Publish an `adagents.json` on the publisher domain.                                                                                                                                            |
| `file_invalid`             | A file was retrieved but cannot be used: unparseable JSON, wrong shape, or an invalid typed authorization entry. Repeating the same canonical agent URL is supported when separate entries grant separate scopes; those scopes are unioned.                                                                                                                                                                                                                                                        | Fix the file so it parses as a JSON object with `authorized_agents[]`, and give every entry a valid typed selector.                                                                            |
| `agent_not_listed`         | The file is valid but your sales agent's URL is not in `authorized_agents[]`.                                                                                                                                                                                                                                                                                                                                                                                                                      | Add the agent URL to `authorized_agents[]` with a typed authorization.                                                                                                                         |
| `agent_untyped`            | The agent is listed, but its entry carries no authorization mapping the platform can resolve — most often a pre-3.x file with bare `{url, authorized_for}` entries; also an unrecognized `authorization_type`, or a known type whose selector is missing or empty. Authorization is a function of the per-entry `authorization_type` + selector, so these entries fail closed.                                                                                                                     | Give the entry a resolvable typed authorization: `authorization_type` (`property_ids`, `property_tags`, `inline_properties`, or `publisher_properties`) plus its matching, non-empty selector. |
| `agent_not_property_bound` | The agent's typed entries are well-formed but grant no property authority on this publisher domain — the selector matched nothing, the resolved properties belong to other domains, or the entry is signals-only (`signal_ids`/`signal_tags`), which never conveys property authority. If the file resolved via an ads.txt `MANAGERDOMAIN` delegation, the `adagents.json` that needs the fix belongs to the manager domain, not yours — coordinate with the manager to map your properties there. | Map the agent to properties on this domain: fix the selector, add the domain's properties to the file, or add a property-typed entry beside a signals-only one.                                |
| `authorized`               | The resolved scope authorizes the agent for this domain.                                                                                                                                                                                                                                                                                                                                                                                                                                           | Nothing — you're live.                                                                                                                                                                         |

The reason appears on `GET /api/v2/storefront/publishers` items and on each
domain in the readiness checklist, which also summarizes the most common fix
(for example, "2 domains need an adagents.json upgrade to typed
authorizations").

Publisher-domain declaration is a go-live readiness check. Setup remains
incomplete until at least one publisher domain is configured. Product mapping
to the configured set is shown as a rollout warning while older catalogs are
backfilled. Authorization is shown separately as advisory provenance and does
not block transactions.

The status also shows the number of resolved properties when available. That
count describes what the current publisher document contains; it does not prove
that a network's document includes every site the network represents. Network
sellers should compare it with their own managed-site roster before enabling
buyer-visible coverage — the roster's `propertyCount` is the number to compare
against, and it agrees with this one.

If the managed sales agent cannot accept the replacement set, Apostra keeps
the prior local publisher-domain sync state instead of deleting rows first. That
lets the update retry without losing the previous authorization projection.

<Tip>
  `businessProfile.publisherDomains` is the whole set, so send all of it each
  time. To remove `cafemedia.com` and keep `mediavine.com`, send
  `"publisherDomains": ["mediavine.com"]`. If you only want to add or remove one
  domain, use the single-domain routes below instead — they do not require you to
  restate the rest, and restating a set you read incompletely is how domains get
  deleted by a request that meant to add one.
</Tip>

## Adding or removing one domain

Two routes change exactly one domain and leave every other one alone:

| Route                                           | Behavior                                                                                                                                                                                            |
| ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST /api/v2/storefront/publishers`            | Declare one domain. Body `{ "domain": "vox.com" }`; full URLs are accepted and normalized. Re-declaring a domain you already have is not an error — it re-checks it, subject to the cooldown above. |
| `DELETE /api/v2/storefront/publishers/{domain}` | Un-declare one domain. Returns `204`, including when the domain was already absent, so retrying is safe.                                                                                            |

Prefer these over `PUT /api/v2/storefront/publishers` (or a business-profile
update) whenever you are changing one domain. The `PUT` replaces the entire
declared set: any domain you declared that is missing from the body is deleted,
and it has no conditional-write check, so a set you assembled from an earlier
read can also discard a change someone else made in between. The single-domain
routes have neither problem, because they never send a set.

The `PUT` applies as a diff, so it is safe to resend a set you already have. A
domain that is in both your current set and the body keeps its row and
everything resolved under it — its properties, AdCP Collections, creative formats,
your own property claims, and its authorization verdict. Only the declared
domains you left out are deleted, and only domains that were not there before
are created (those start at `pending`, since nothing has resolved them yet).

Only domains you declared are yours to remove, whichever route you use. `DELETE`
returns `409` when the domain exists but its provenance is not `declared` — a
domain we discovered or crawled for you follows its own source, and nothing is
changed. The `PUT` behaves consistently rather than silently: it replaces your
declarations only, so a discovered domain left out of the body stays on the list.
Leaving one out is not a way to delete it.

## Empty or missing values

An empty array means "clear the publisher-domain set" for the storefront:

```json theme={null}
{
  "businessProfile": {
    "publisherDomains": []
  }
}
```

Omitting `publisherDomains` means the authorized domains are unknown or have not
been captured yet. It does not authorize the storefront to sell every domain.

Setting `businessProfile` to `null` clears the business profile and clears the
publisher-domain set for managed sales-agent sync.

## Buyer visibility

Updating the publisher-domain set does not by itself change the storefront name,
operator identity, or buyer-facing seller profile. Buyers may see publisher
domain coverage on products when those products expose
`publisher_properties`; see [Publisher properties and coverage](/v2/storefront/inventory-sources/publisher-properties-coverage).

Authorization is necessary but does not by itself enable buyer disclosure. The
storefront's resolved property coverage must also be synchronized and its
coverage-disclosure rollout enabled.

## The property roster

Every declared publisher domain is resolved through two deliberately separate
reads. AAO's publisher lookup supplies creative format declarations, including
AAO registry declarations for platforms that do not publish their own file.
If that lookup is temporarily unavailable and the successfully resolved live
publisher file explicitly declares `formats[]`, those authoritative
publisher-origin declarations keep the format catalog current until AAO is
available again. A successful AAO lookup still owns publisher/community
precedence.
The live publisher-origin `adagents.json` read supplies authorization and
continues to fail closed. The **property roster** is the canonical seller view
of the result: each domain's declared `properties[]` (with their identifiers,
types, and tags), its `collections[]` (canonical AdCP Collection identity
`{publisher_domain, collection_id}`), its canonical creative `formats[]`, and
the authorization outcome.

Fetch it with `GET /api/v2/storefront/property-roster`, the
`get_property_roster` operation, or by asking your agent to open the Property
Roster page (the `open_property_roster` tool in any MCP host).

Each publisher in the roster carries:

* `authorizationStatus` — `authorized`, `unauthorized`, or `unknown`.
* `authorizationReason` — the distinct resolution outcome behind that status:
  `no_file` (no adagents.json found), `file_invalid` (the file could not be
  read), `agent_not_listed` (the file does not list your sales agent),
  `agent_untyped` (your agent is listed in the pre-3.x format without a typed
  authorization mapping), `agent_not_property_bound` (typed entries exist but
  none map your agent to this publisher's properties), or `authorized`.
  `null` means the domain has not yet been resolved under the reason contract.
* `properties[]` and `collections[]` — Properties and AdCP Collections
  persisted from the most recent
  successful resolution. Entries a newer document no longer declares are
  removed from the live roster (history is retained internally). A publisher
  that stops serving an adagents.json entirely has its declared identity
  removed from the live roster until the file returns.
* `formats[]` — canonical, URL-free `formatKind` + `params` declarations from
  the AAO publisher lookup, or from an explicit live publisher declaration
  while that lookup is unavailable. Each entry retains its publisher
  property-id/tag scope, the live roster properties that scope matches, and
  provenance: `publisher_adagents` for publisher-origin declarations or
  `aao_registry` for an AAO registry declaration. If neither lookup provides a
  definitive catalog, the roster preserves the last successful catalog instead
  of treating the failure as an empty catalog.
* `formatCatalogSyncedAt` — when the format catalog was last definitively
  reconciled from AAO or an authoritative live-origin fallback. This timestamp
  is independent of `lastSyncedAt`, which describes the live-origin
  authorization and identity check. Use the catalog timestamp to judge the
  freshness of preserved formats.
* `propertyCount`, `collectionCount`, and `formatCount` — how many the domain
  actually has. For a large network these can be bigger than the corresponding
  arrays are long, because a single roster read is size-bounded. Read the
  counts, not the array lengths, when you want to know what a domain covers.

The response also carries `totals` for the whole storefront and a `truncated`
object:

```json theme={null}
{
  "totals": { "publishers": 1, "authorizedPublishers": 1, "properties": 6843, "collections": 0, "formats": 4 },
  "truncated": { "publishers": false, "properties": true, "collections": false, "formats": false }
}
```

`totals` always describes the storefront, never the response. When any
`truncated` flag is `true`, the response left rows out — so **an absence in
`properties[]` is not evidence a property is missing.** Look the specific
property up, or open the Property Roster page, rather than concluding a site
is not covered.

### Whether one property is authorized

Every property carries its own `authorization`:

```json theme={null}
{ "status": "unauthorized", "grain": "domain", "reason": "agent_not_listed" }
```

Read `grain` before acting on `status`. Authorization is granted by the
publisher domain's `adagents.json`, and that is where we resolve it, so:

* **`unauthorized` is definite.** The domain does not authorize your agent, so
  nothing under it can be sold — however many properties the file declares.
* **`authorized` is inherited from the domain** (`grain: "domain"`), not proven
  against the specific selector mapped to your agent. Under AdCP 3.x a file
  can map an agent to a subset of the properties it declares.
* **`unknown`** means no publisher-origin declaration backs this property —
  most often a property you declared yourself, which stays on your roster but
  cannot read as authorized until the publisher's file declares it.

### Storefronts where coverage does not apply

The response also carries `coverageApplies`. It is `false` for a storefront that
sells through a platform account connection (Meta, Google, TikTok and the like):
selling rights come from that connection, no `adagents.json` will ever resolve,
and the authorization verdict is not something to fix. Do not read those
verdicts as a problem, and do not publish a file to change them.

The header totals include the number of discovered formats, and each publisher
card can expand its format list. An unscoped declaration applies to every
property under that publisher; a scoped declaration matches a property when
either its `propertyId` is listed or one of its tags overlaps.

Discovered publisher formats describe capacity — what the publisher says its
inventory accepts. A product's `format_options[]` is the closed executable set
that a buyer can actually purchase from that sales agent, so it may be
narrower. AAO registry provenance never authorizes a sales agent.

In Property Roster, **Create product** opens **Create a product**
with that publisher format already selected. Choose an **Ad server**,
select the exact inventory to package, and choose one or more **Delivery
formats** reported by that source. Select **Review product**, then **Create
draft product** after validation succeeds. The format stays in the task instead
of being sent back through chat.

**Review property mapping** means the complete publisher roster was checked and
the format did not match a current property. **Mapping not shown** and **Check
inventory mapping** mean the roster view was size-bounded, so it does not
contain enough properties to prove whether the format matches. Use the check
action to inspect the mapping instead of treating the format as unavailable.
Format IDs, sources, and the complete creative parameter object remain under
**Technical details**.

When a product uses a publisher-catalog option, copy its `format_option_id`,
`publisher_domain`, `format_kind`, and `params` into the product's
`format_options[]`. Product validation compares that identity and canonical
kind with the connected roster and warns when the option is unknown, uses a
different kind, or applies to none of the product's selected publisher
properties. The product may narrow the catalog option's parameters. These are
authoring advisories: a catalog read failure does not block a product write or
change the sales agent's own validation verdict. Product validation does not
yet check placement-specific format availability.

The roster only reflects what publishers declare — Apostra never invents
property identity. To appear authorized, the publisher's `adagents.json` must
list your sales agent URL with a typed authorization mapping
(`authorization_type` plus the matching selector, e.g. `property_ids`);
listing the URL alone is not authorization under AdCP 3.x. See
[adagents.json](/v2/storefront/inventory-sources/adagents-json) for the file
format and deployment guidance.

### Declaring properties before the publisher does

A seller can record properties conversationally — through their agent or via
`POST /api/v2/storefront/property-roster/properties` (the
`declare_roster_property` operation) — before the publisher's adagents.json
declares them. Declared entries carry `provenance: seller_declared` and are
visibly distinct from publisher-origin identity:

* A declared property needs at least a `propertyId`, an identifier, or a
  name. The roster never invents identity.
* When the publisher's adagents.json later declares the same property, the
  publisher-origin record supersedes the declared one (declaring it again
  returns `outcome: already_resolved`). Your claim is retained either way: if
  the publisher later stops declaring the property, it returns to the roster
  as your declaration instead of disappearing.
* adagents.json changes never remove seller-declared entries; remove one with
  `DELETE /api/v2/storefront/property-roster/properties/{domain}/{propertyKey}`
  (`remove_declared_roster_property`). Publisher-origin entries cannot be
  removed this way — they follow the publisher's file.

## Publisher onboarding

<Card title="Prepare inventory source inputs" icon="clipboard-check" href="/v2/setup/publisher-onboarding-starter-kit">
  Request property-roster evidence together with avails, pricing, lifecycle
  ownership, and proof briefs.
</Card>
