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

# Product Discovery

> Discover available media products from agents, evaluate proposals, and apply them to your campaigns

## Overview

<Note>
  Building a buyer agent or testing AdCP behavior? Start with [Get products
  across storefronts](/v2/buyer/discovery/multi-storefront-get-products). It
  returns a bounded canonical product/proposal page, can query named or all
  connected storefronts, and optionally screens and refines proposals from
  buyer instructions. Use the Discover Products compatibility flow below when
  you need grouped pagination, summary/budget context, browse/SSE, or an
  existing `discoveryId` workflow. It projects the same raw fan-out and optional
  screen; it does not invoke a separate ranking brain.
</Note>

Product Discovery is the buyer-side loop for finding inventory across one or more
sales agents (publishers, exchanges, content owners). You provide a brief — what
you're trying to accomplish, who you're targeting, when it runs, and how much
you can spend — and Scope3 fans the request out to every agent your advertiser has
access to. The agents return products and (when supported) recommended
**proposals** that allocate budget across those products.

The flow:

<Steps>
  <Step title="Connect private storefronts (optional)">
    For storefronts that gate inventory, register per-source credentials first — see the [Storefront object guide](/v2/object-guides/storefront#registering-credentials-per-source).
  </Step>

  <Step title="Run discovery">
    `POST /api/v2/buyer/discovery/discover-products` with a brief. Returns a
    `discoveryId` plus product groups, agent proposals, and budget context.
  </Step>

  <Step title="Browse and refine">
    Page through results with `GET /api/v2/buyer/discovery/{id}/discover-products`
    or iterate by sending `refine` instructions back to the same `discoveryId`.
  </Step>

  <Step title="Pick products or apply a proposal">
    Add specific products with `POST /api/v2/buyer/discovery/{id}/products` or
    apply a full proposal with `POST /api/v2/buyer/discovery/{id}/apply-proposal`.
  </Step>

  <Step title="Promote to a campaign">
    Call `create_media_buys` to stage or execute several qualified selections
    at once. Supply an existing campaign or let the operation create the DRAFT
    campaign cart. `auto-select-products` remains available for performance
    campaigns.
  </Step>
</Steps>

A discovery execution is the long-lived search and refinement record identified
by `discoveryId` or `get_products.ext.interchange.execution_id`. You can re-open
it and refine results without losing context. The DRAFT campaign is the durable
shopping cart: it owns selected proposals/products and every media buy. Legacy
session-selection endpoints project selections through the same reconciliation
path for compatibility.

## Progressive delivery

Discovery fans out to many sellers, and they answer at very different speeds —
warmed catalogs in milliseconds, live agents in tens of seconds. By default the
response waits for everyone (bounded by `waitMode`/`waitSeconds`). Pass
`progressive: true` and sellers' answers arrive as they land instead: the first
response returns within a couple of seconds with whoever has answered,
`resultsComplete: false`, and a `pendingAgents` list; you then poll
[Browse products](/v2/buyer/discovery/tasks/browse-products) with
`sinceRevision` to receive only newly landed seller groups until
`resultsComplete` is true. Every progressive response includes a `guidance`
string spelling out the exact next call, so agent clients need no special
handling. The complete worked sequence is in
[Discover products → Progressive delivery](/v2/buyer/discovery/tasks/discover-products#progressive-delivery).

In Interchange chat, Murph prefers `get_products` for new canonical
product/proposal exploration and uses quick replacement snapshots while sellers
answer. Murph uses `discover_products` when the buyer explicitly asks for the
legacy grouped/session presentation or continues an existing `discoveryId`
workflow. It does not call both surfaces by default.

## Storefront agents in discovery

Discovery fans out to both third-party sales agents and Scope3 storefront agents. A storefront agent has `salesAgentId: "storefront-{platform_id}"`, where `platform_id` is the storefront's public `platformId` slug, and represents the storefront as a buyer-facing route for product discovery and media-buy execution. That ID identifies the storefront surface; it is not a source ID or underlying source agent ID.

Buyers call the same discovery endpoint for all agents. They do not need to distinguish between storefronts that compose products from raw inventory and storefronts that passthrough to an upstream inventory source. Composition storefronts return products assembled from active inventory sources and active operating instructions. Passthrough storefronts proxy `get_products` to an active source and apply storefront metadata and buyer-instruction overlays before results are returned. Buyer instructions are resolved from operator domain, brand domain, and optional country. Use `storefrontIds` or `storefrontNames` when you want to restrict discovery to specific storefronts.

<Note title="Marketplace review gate">
  Public buyer discovery only includes Storefronts that have passed marketplace
  review and are listed. A Storefront can be live for known transactions while it
  is still pending review, but it will not appear in public discovery results or
  buyer marketplace browsing until an admin lists it.
</Note>

### Murph account analysis for adapter storefronts

Operators can ask Murph to run `analyze_account` for adapter-routed
storefronts with delegated provider credentials. The tool analyzes a connected
advertising account through the storefront adapter and can render an
interactive account-analysis viewer in compatible MCP clients.

Inputs:

| Field                  | Type    | Notes                                                                                                                                                                                                                                                                     |
| ---------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `provider`             | string  | Optional for self-service. Required when a super admin needs to disambiguate a client storefront. Supported values are the providers that can hold delegated credentials: `amazon`, `google`, `linkedin`, `meta`, `pinterest`, `reddit`, `snap`, `spotify`, and `tiktok`. |
| `targetCustomerId`     | integer | Super-admin-only account ID whose adapter storefront should be analyzed.                                                                                                                                                                                                  |
| `storefrontPlatformId` | string  | Super-admin-only public storefront platform ID to analyze instead of `targetCustomerId`.                                                                                                                                                                                  |
| `accountId`            | string  | Optional upstream provider account ID to request from the connected delegated credential.                                                                                                                                                                                 |

The response includes an `analysis` object returned by the adapter, plus a
`recommendations[]` array for the operator. Adapter implementations may include
findings, metrics, account health signals, and other provider-specific details
inside `analysis`.

`analyze_account` is read-only but requires the storefront to be adapter-routed
and connected with delegated provider auth. Without a stored delegated
credential, Murph returns an auth-required response that includes the provider
OAuth setup path. Cross-account targeting is restricted to SuperAdmins; normal
storefront operators can analyze only their own storefront.

## Public vs private storefronts

Storefronts fall into two visibility tiers:

| Tier                | Who can discover                                  | How to access                                                      |
| ------------------- | ------------------------------------------------- | ------------------------------------------------------------------ |
| **Public**          | Any authenticated buyer                           | Available by default after marketplace review                      |
| **Private / gated** | Buyers with credentials registered for the source | Register credentials per inventory source (API key, OAuth, or JWT) |

Public discovery is also gated by marketplace review: pending-review and hidden
Storefronts are omitted from public buyer discovery even if they are otherwise
live. Private storefronts gate inventory behind per-source authentication.
Scope3 speaks to all sources - public and private - over the **Ad Context
Protocol (ADCP)**; the difference is just whether the source requires the buyer
to present credentials before products are returned. The buyer never needs an
AAO key directly: AAO registry and compliance signals are handled server-side by
Scope3.

## Step 1: Connect private storefronts (optional)

If you only need public inventory, skip ahead to [Step 2](#step-2-run-discovery).

To unlock private storefronts, register credentials for the relevant inventory
source. Three auth types are supported (API key, OAuth, JWT) — the source
declares which it requires. Full walkthrough lives in the [Storefront object
guide](/v2/object-guides/storefront#registering-credentials-per-source); the
short version:

```bash theme={null}
curl -X POST \
  'https://api.interchange.io/api/v2/buyer/storefronts/42/sources/agt_premium_ctv/credentials' \
  -H 'Authorization: Bearer scope3_<your_api_key>' \
  -H 'Content-Type: application/json' \
  -d '{ "auth": { "type": "api_key", "token": "<source-issued-token>" } }'
```

For OAuth-secured sources, request an authorize URL and complete the buyer
consent flow — Scope3 hosts the callback at `/api/v2/storefront/oauth/callback`
so AI agents and back-end clients don't need to handle redirects themselves.

<Tip>
  Once credentials are registered for a source, every discovery call from your
  account automatically queries it — no per-call configuration needed.
</Tip>

## Step 2: Run discovery

`POST /api/v2/buyer/discovery/discover-products` is the main entry point. You
can pass a brief inline or seed the request from an existing campaign with
`campaignId`.

### Request

```bash theme={null}
curl -X POST 'https://api.interchange.io/api/v2/buyer/discovery/discover-products' \
  -H 'Authorization: Bearer scope3_<your_api_key>' \
  -H 'Content-Type: application/json' \
  -d '{
    "advertiserId": 12345,
    "brief": "Premium CTV inventory for Q3 sports launch — adults 25-54, US only, video creative",
    "budget": 250000,
    "channels": ["ctv", "olv"],
    "countries": ["US"],
    "flightDates": {
      "startDate": "2026-07-01T00:00:00Z",
      "endDate": "2026-09-30T23:59:59Z"
    },
    "groupLimit": 10,
    "productsPerGroup": 10
  }'
```

| Field                           | Type                     | Description                                                                                                                                                                                       |
| ------------------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `advertiserId`                  | integer                  | **Required.** Resolves the brand reference used to score agent results.                                                                                                                           |
| `discoveryId`                   | string                   | Reuse an existing session. Required when sending `refine`.                                                                                                                                        |
| `campaignId`                    | string                   | Seed brief, flight dates, and budget from a campaign. Inline values override.                                                                                                                     |
| `brief`                         | string (≤5000 chars)     | Natural-language search context.                                                                                                                                                                  |
| `budget`                        | number                   | Total budget in account currency. Used for budget context + proposals.                                                                                                                            |
| `channels`                      | string\[]                | `display`, `olv`, `ctv`, `social`, or `video` (alias for `olv`). Defaults to `["display","olv","ctv","social"]`.                                                                                  |
| `countries`                     | string\[]                | ISO 3166-1 alpha-2 country codes. Defaults to brand agent countries.                                                                                                                              |
| `flightDates`                   | `{ startDate, endDate }` | ISO 8601 datetimes (e.g. `2026-07-01T00:00:00Z`). Filters by agent availability.                                                                                                                  |
| `publisherDomain`               | string                   | Filter to a single publisher domain.                                                                                                                                                              |
| `pricingModel`                  | string                   | Filter by AdCP pricing model (`cpm`, `cpcv`, etc.).                                                                                                                                               |
| `storefrontIds`                 | integer\[]               | Restrict to specific storefronts (IDs from `list_storefronts`). Empty array = same as omitted (no request-level filter); falls back to the campaign-level pin if a `campaignId` is also provided. |
| `storefrontNames`               | string\[]                | Restrict to storefronts whose name matches (case-insensitive substring). Empty array = same as omitted; falls back to the campaign-level pin if available.                                        |
| `groupLimit`                    | integer (≤10)            | Max product groups per page. Default 10.                                                                                                                                                          |
| `productsPerGroup`              | integer (≤15)            | Max products inside each group. Default 10.                                                                                                                                                       |
| `groupOffset` / `productOffset` | integer                  | Pagination cursors.                                                                                                                                                                               |
| `debug`                         | boolean                  | Include per-agent ADCP request/response logs in the response.                                                                                                                                     |
| `screening`                     | object                   | Optional plain-language screening instructions plus refinement/pass limits. Uses the same managed accept, reject, and seller-refinement engine as buyer `get_products`.                           |
| `refine`                        | array                    | Refinement directives (see [Refining](#refining-results)). Requires `discoveryId`.                                                                                                                |

### Response

```json theme={null}
{
  "discoveryId": "disc_01HZX3YQ7K9R6V3M2P1E0F8B2T",
  "productGroups": [
    {
      "groupId": "ctx_disc_01HZX3-group-0",
      "groupName": "Acme Media Connected TV",
      "description": "Premium CTV across Acme Media's owned-and-operated apps",
      "productCount": 4,
      "totalProducts": 12,
      "hasMoreProducts": true,
      "products": [
        {
          "productId": "prod_acme_ctv_sports",
          "name": "Acme Sports CTV — 30s",
          "channel": "ctv",
          "formatTypes": ["video", "30s"],
          "cpm": 32.5,
          "salesAgentId": "agent_acme_media",
          "salesAgentName": "Acme Media Ad Sales",
          "deliveryType": "guaranteed",
          "briefRelevance": "Lives squarely on Acme Media's sports verticals — adults 25-54 over-index here",
          "pricingOptions": [
            { "pricingOptionId": "po_fixed_30", "pricingModel": "cpm", "isFixed": true, "fixedPrice": 32.5, "currency": "USD" }
          ],
          "estimatedExposures": 7700000
        }
      ]
    }
  ],
  "totalGroups": 18,
  "hasMoreGroups": true,
  "summary": {
    "totalProducts": 142,
    "publishersCount": 18,
    "priceRange": { "min": 4.5, "max": 78.0, "avg": 21.4 }
  },
  "budgetContext": {
    "sessionBudget": 250000,
    "allocatedBudget": 0,
    "remainingBudget": 250000
  },
  "proposals": [
    {
      "proposalId": "proposal_acme_q3_sports",
      "name": "Q3 Sports — premium CTV mix",
      "salesAgentName": "Acme Media Ad Sales",
      "briefAlignment": "Combines guaranteed sports with non-guaranteed sports-adjacent OLV to extend reach",
      "allocations": [
        { "productId": "prod_acme_ctv_sports", "allocationPercentage": 60, "rationale": "Anchors the plan with guaranteed delivery" },
        { "productId": "prod_acme_olv_sports", "allocationPercentage": 40 }
      ],
      "totalBudgetGuidance": { "min": 150000, "recommended": 250000, "max": 400000, "currency": "USD" }
    }
  ]
}
```

The first call always creates a new session unless you pass `discoveryId`.
Persist the returned `discoveryId` — every subsequent call references it.

<Note>
  Discovery fans out across all reachable agents in parallel. Slow agents do
  not block fast ones; agents that fail or are skipped are surfaced under
  `agentResults` only when you pass `debug: true`.

  Agents whose advertised channel coverage does not overlap with the requested
  channels are skipped before fanout (no round-trip), and appear in
  `agentResults` with a `skipReason` like
  `Agent does not sell requested channels (supports: display, ctv; requested: social)`.
  For agents that respond with an error, `skipReason` carries the
  human-readable rejection text from the agent (e.g. `"We do not support the
      list of channels you specified"`); prefer it over `error` when surfacing the
  reason in a UI. `skipReason` is agent-controlled content, sanitize before
  rendering as HTML.
</Note>

Sellers operating third-party sales-agent inventory sources can inspect the
source-side view in [Diagnose third-party sales agents](/v2/storefront/inventory-sources/diagnostics).

## Step 3: Page through results

Use the GET endpoint to browse the same session without spending another
LLM-enriched discovery call. Filters narrow the cached result set in place.

```bash theme={null}
curl -X GET 'https://api.interchange.io/api/v2/buyer/discovery/disc_01HZX.../discover-products?groupOffset=10&publisherDomain=acmemedia&pricingModel=cpm' \
  -H 'Authorization: Bearer scope3_<your_api_key>'
```

Query parameters mirror the discover request: `groupLimit`, `groupOffset`,
`productsPerGroup`, `productOffset`, `publisherDomain`, `pricingModel`,
`storefrontIds`, `storefrontNames`, `debug`.

### Refining results

Iterate on a previous response by sending `refine` instructions back to the
same `discoveryId`. Refinements come in three scopes:

<CodeGroup>
  ```json Request-scoped theme={null}
  {
    "advertiserId": 12345,
    "discoveryId": "disc_01HZX...",
    "refine": [
      { "scope": "request", "ask": "Less display, more video. Drop anything below $10 CPM." }
    ]
  }
  ```

  ```json Product-scoped theme={null}
  {
    "advertiserId": 12345,
    "discoveryId": "disc_01HZX...",
    "refine": [
      { "scope": "product", "id": "prod_acme_ctv_sports", "action": "more_like_this", "ask": "Same audience but with 16:9 social format" },
      { "scope": "product", "id": "prod_random_display", "action": "omit" }
    ]
  }
  ```

  ```json Proposal-scoped theme={null}
  {
    "advertiserId": 12345,
    "discoveryId": "disc_01HZX...",
    "refine": [
      { "scope": "proposal", "id": "proposal_acme_q3_sports", "action": "finalize" }
    ]
  }
  ```
</CodeGroup>

The agent's reply to each instruction comes back under `refinementApplied`
(matched by position) with `status: "applied" | "partial" | "unable"` and an
optional explanation.

You can include `screening` on the same request. Interchange first applies the
explicit seller refinement, then runs the refined proposals through the same
managed screening and bounded autonomous-refinement loop used by multi-storefront
`get_products`. Discover returns the accepted snapshot in its existing grouped
response shape. Screened Discover calls are terminal; use buyer `get_products`
when you need progressive screened replacement pages while sellers are still
responding.

A proposal-scoped `finalize` asks the seller to commit the draft plan: the
seller re-validates that every product in the proposal is still available at
current pricing, then answers `status: "applied"` with the commitment window
(the committed proposal's `expires_at`). A committed proposal is a price and
composition commitment — not an inventory hold — and can be executed directly
by passing its `proposal_id` and a `total_budget` to `create_media_buy`
before it expires. Each committed proposal executes at most once; a proposal
whose products have changed since the draft answers `status: "unable"` with
the reason, and you can re-run discovery for a fresh plan.

## Step 4: View specific products

Add products you want to evaluate to the session. Each selection records the
`productId`, the `salesAgentId` it came from, and the group it was discovered
in. Optionally pin a budget allocation, pricing option, or bid.

```bash theme={null}
curl -X POST 'https://api.interchange.io/api/v2/buyer/discovery/disc_01HZX.../products' \
  -H 'Authorization: Bearer scope3_<your_api_key>' \
  -H 'Content-Type: application/json' \
  -d '{
    "products": [
      {
        "productId": "prod_acme_ctv_sports",
        "salesAgentId": "agent_acme_media",
        "groupId": "ctx_disc_01HZX3-group-0",
        "groupName": "Acme Media Connected TV",
        "pricingOptionId": "po_fixed_30",
        "budget": 150000
      }
    ]
  }'
```

For non-fixed pricing, include `bidPrice` (read it from the product's
`pricingOptions[].rate` or `floorPrice` in the discovery response).

List the current session selection at any time:

```bash theme={null}
curl -X GET 'https://api.interchange.io/api/v2/buyer/discovery/disc_01HZX.../products' \
  -H 'Authorization: Bearer scope3_<your_api_key>'
```

Pull a single product's full detail (including extended specs from the sales
agent) with the per-product detail endpoint:

```bash theme={null}
curl -X GET 'https://api.interchange.io/api/v2/buyer/discovery/disc_01HZX.../products/prod_acme_ctv_sports/details?salesAgentId=agent_acme_media' \
  -H 'Authorization: Bearer scope3_<your_api_key>'
```

Remove products you no longer want:

```bash theme={null}
curl -X DELETE 'https://api.interchange.io/api/v2/buyer/discovery/disc_01HZX.../products' \
  -H 'Authorization: Bearer scope3_<your_api_key>' \
  -H 'Content-Type: application/json' \
  -d '{ "productIds": ["prod_random_display"] }'
```

## Step 5: Apply a proposal

When an agent returns a proposal, you can accept its full allocation in one
call instead of adding products one at a time.

```bash theme={null}
curl -X POST 'https://api.interchange.io/api/v2/buyer/discovery/disc_01HZX.../apply-proposal' \
  -H 'Authorization: Bearer scope3_<your_api_key>' \
  -H 'Content-Type: application/json' \
  -d '{
    "proposalId": "proposal_acme_q3_sports",
    "totalBudget": 250000,
    "replace": true
  }'
```

| Field         | Description                                                       |
| ------------- | ----------------------------------------------------------------- |
| `proposalId`  | Required. ID from the discover-products response.                 |
| `totalBudget` | Optional. Defaults to `proposal.totalBudgetGuidance.recommended`. |
| `replace`     | When `true`, clears existing selected products before applying.   |

The response echoes the applied proposal, the budget actually distributed, the
products that were added, and any products from the proposal that could not be
matched back to the discovery results (`productsSkipped`).

<Warning>
  `productsSkipped` is non-empty when an agent's proposal references a product
  that has aged out of the cached discovery results. Re-run discover to refresh
  the session, then re-apply.
</Warning>

## Auto-select on a campaign

For agentic / hands-off flows, attach an existing campaign and let Scope3 pick
products for you. This wraps discovery + selection in a single call against the
campaign's existing brief, flight dates, and budget.

```bash theme={null}
curl -X POST 'https://api.interchange.io/api/v2/buyer/campaigns/cmp_987654321/auto-select-products' \
  -H 'Authorization: Bearer scope3_<your_api_key>' \
  -H 'Content-Type: application/json' \
  -d '{
    "maxProducts": 5,
    "minBudgetPerProduct": 5000
  }'
```

The body is optional — omit it for a fully automated first pass. To iterate,
send back the same `refine` directives accepted by `discover-products`:

```json theme={null}
{
  "refine": [
    { "scope": "request", "ask": "Drop anything social. Lean into CTV." },
    { "scope": "product", "id": "prod_low_quality", "action": "omit" }
  ],
  "maxProducts": 8
}
```

The response includes the underlying `discoveryId` so you can drop into the
manual flow at any point to inspect or adjust the selection.

## Best practices

<Tabs>
  <Tab title="Briefs">
    * Lead with the **outcome**, not just the demographic. Agents score against
      campaign objective + creative + audience together.
    * Include guardrails that matter: brand-safety needs, format
      constraints (`16:9`, `:30s`), exclusions.
    * Keep briefs under \~500 characters when possible — long briefs are
      auto-summarized for LLM enrichment but lose nuance.
  </Tab>

  <Tab title="Pagination & polling">
    * The first `discover-products` call does the heavy work (LLM
      enrichment, agent fan-out). Subsequent `GET /discover-products` calls
      hit per-group cache and are cheap.
    * Cache TTL is 4 hours. After that, re-run `POST /discover-products`
      with the same brief to refresh.
    * Pre-warm extra pages by paging through `groupOffset` immediately —
      results stay in cache for the rest of the session.
  </Tab>

  <Tab title="Source credentials">
    * Treat per-source credentials (API keys, OAuth tokens) as secrets —
      never log them, never embed them in client bundles. Scope3 stores
      them encrypted at rest and redacts them from server logs.
    * Rotate per the source's policy. Discovery results are cached per
      `(account, source-credential-fingerprint)` so private results never
      leak across buyers.
    * Public-only discovery is always available without credentials — fall
      back gracefully if the buyer hasn't registered any.
  </Tab>

  <Tab title="Refinement loops">
    * Combine scopes in one `refine` array. The seller responds per
      instruction in `refinementApplied`.
    * `more_like_this` works best with a clear `ask` (audience, format,
      pricing band).
    * Use `proposal.action: "finalize"` when you're committing — it asks
      the seller for firm pricing and an inventory hold.
  </Tab>
</Tabs>

## Related

* [Campaigns guide](/v2/object-guides/campaign) — promote a discovery selection into a live media buy
* [Storefronts](/v2/object-guides/storefront) — connect, refresh, and manage credentials per inventory source
* [Buyer storefronts](/v2/setup/buyer-onboarding) — register against operator-hosted storefronts
* [Buyer API reference](/v2/buyer-api-reference) — full endpoint and schema reference
