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

# Create and manage seller products

> Create, change, archive and delete a wholesale product on an ad-server source with save_wholesale_product, and check whether buyers can see it.

A **wholesale product** is what you author from synced ad-server inventory so
buyers can discover and buy it. In V3 you create and manage one with
`save_wholesale_product` and read it with `search` and `get`. The field-level
rules for prices, formats, channels and audience declarations are in
[Create products from ad-server inventory](/v2/storefront/inventory-sources/create-products).
This page is the V3 workflow.

## Which sources hold products you can author

You author products only on a source Apostra manages for you, which means an
ad-server-backed source. Pass its `sourceId`, as returned by
`search({ "kind": "inventory_source" })`, on every call. A source that does not
hold products you author refuses the write with `refused:
"not_our_system_of_record"` and says why:

| Source | Why it refuses |
| - | - |
| Third-party sales agent | Its products live in that agent. Apostra reads them and does not write them. Change them in the agent. |
| Modular source | Its products come from the avails file you upload. Upload a new file to change what it offers. See [Modular source Pages](/v3/seller-modular-source-pages). |
| Linked storefront | Its products belong to the other Seller and are resolved when a buyer asks. |

A source whose ad server is disconnected is refused with `source_disconnected`.
Reconnect it first. A source still being set up upstream is refused with
`source_not_provisioned`. Read it with `get` and `include: ["diagnostics"]`,
then retry.

## Create a product

A create has no `id`. It needs `name`, `inventory` and `channels`. When you send
`pricingOptions` you also send `deliveryType`.

```json theme={null}
{
  "sourceId": "SOURCE_ID",
  "name": "Homepage takeover",
  "channels": ["display"],
  "inventory": { "...": "the ad-server selectors to sell, in the source's own shape" },
  "deliveryType": "guaranteed",
  "pricingOptions": [
    { "pricing_model": "cpm", "currency": "USD", "fixed_price": 12.5 }
  ]
}
```

* `channels` are AdCP media channels. The tool refuses `audio` and `video`, an
  empty list, and more distinct channels than a product can hold.
* `inventory` is the source's own selector shape. V3 has no tool that searches
  selectors. Reuse the `inventory` object that `get` returns for an existing
  product on the same source, or the values you already hold.
* `status` is `draft`, `active` or `archived`. A `draft` is hidden from buyers.
  The result states the status the source gave the product. Save
  `status: "active"` when the product is ready.
* A create is validated against the source before anything is written. A
  blocking finding stops the save and the result carries `saved: false`,
  `refused: "validation_failed"` and the findings as `errors`, each with a
  `code`, a `message` and a `field` when the source named one. Advisory
  findings do not block the save and come back as `warnings`.

### Check a create without saving it

Add `dryRun: true` to a create to validate it and write nothing. A valid
product returns `saved: false`, `dryRun: true` and `valid: true`. A change
cannot be dry-run, because a change carries only the fields you named and the
source validates complete products.

## Change a product

Pass the product `id` and only the fields you want to change. Every field you
omit keeps its stored value.

```json theme={null}
{
  "sourceId": "SOURCE_ID",
  "id": "PRODUCT_ID",
  "deliveryType": "guaranteed",
  "pricingOptions": [
    { "pricing_model": "cpm", "currency": "USD", "fixed_price": 14 }
  ]
}
```

* `pricingOptions` are replaced whole. Send every price you want to keep.
* If you change prices without a `deliveryType`, the tool reads the product's
  stored delivery type and uses it.
* A change with `id` and no other field is refused as nothing to change.
* A change cannot empty `channels`.
* The fields `includedAgeSignal` and `audienceEvidence` follow one rule: omit to
  keep, a value to replace, `null` to clear.

## Take a product off the market, or delete it

`status: "archived"` stops buyers from seeing a product and is reversible. Save
`status: "active"` to bring the same product back.

`delete: true` removes the product from the source for good. The tool enforces
three conditions before it deletes:

1. The product is `draft` or `archived`. An `active` product is refused with
   `refused: "product_not_off_market"` and `nextAction: "archive_first"`.
2. You pass `confirmName`, the product's exact stored name. Read the product
   with `get`, show the operator its name, and send that name back. A wrong
   name is refused with `name_confirmation_mismatch`, and the stored name is not
   echoed to you.
3. The call carries no other field. A delete cannot also edit, and it cannot be
   dry-run.

Deleting a product that no longer exists reports `action: "unchanged"` with
`code: "NOT_FOUND"` instead of failing. Deleting does not touch the ad-server
inventory the product packaged. To sell that inventory again, create a new
product, which gets a new id.

## Read the result

A successful save returns `saved: true` with these fields:

| Field | Meaning |
| - | - |
| `created` | `true` for a create, `false` for a change. |
| `id`, `sourceId` | The product and its source. |
| `status` | The status the source holds after the write, read from the source and not echoed from your request. |
| `pricingStatus` | Whether the source could resolve a price: `priced`, `unresolved` or `ignored`. A product can save and still be unsellable for want of a price. |
| `pricingCurrencyWarning` | Present when a fixed price is in the wrong currency. It names the currency the storefront needs. |
| `warnings` | Advisory findings that did not block the save. |

A status of `active` means the product is eligible. It does not prove buyers
can see it. Buyers read the storefront catalogue, which refreshes after the
save. Check buyer visibility with `get` once it has.

## Find and inspect products

List a source's products with `search`:

```json theme={null}
{
  "kind": "wholesale_product",
  "sourceId": "SOURCE_ID",
  "filter": { "productStatus": "draft" }
}
```

`filter.productStatus` is `draft`, `active` or `archived`. Each result has an
`origin`: `ad_server` for a product you author, and `modular_source` for one a
modular source offers.

Read one product with `get`. It needs the `sourceId`:

```json theme={null}
{ "kind": "wholesale_product", "id": "PRODUCT_ID", "sourceId": "SOURCE_ID" }
```

The read returns what was saved, including `inventory`, `channels`,
`formatOptions`, `pricingOptions`, `deliveryType` and
[`prebidIntegrationActive`](/v3/seller-prebid-toggle). It also returns:

* `buyerVisibility`, which is `visible`, or `hidden` with the check that failed,
  such as a paused storefront, a catalogue that has not warmed, unresolved
  pricing, or no price in a storefront currency;
* `inferredChannels`, a guess made from the formats, shown only when you have
  declared none. It is never stored; and
* `composerCompleteness.missing`, the facts the proposal composer still needs.
  The composer can pitch an active product only when it has a channel, a
  canonical format and a priced option with a currency and delivery type.

## Limits

* The tool needs a Seller Account.
* V3 has no tool for bulk product creation, for the wholesale avails
  percentile-CPM feed, or for searching ad-server selectors. Create products one
  at a time with `save_wholesale_product`.

## Related

* [Create products from ad-server inventory](/v2/storefront/inventory-sources/create-products)
* [Declare the Scope3 Prebid module on a product](/v3/seller-prebid-toggle)
* [Seller workflows](/v2/setup/v3/seller-workflows#add-and-diagnose-inventory)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.