---
name: scope3-agentic-buyer
version: "2.0.0"
description: Scope3 Agentic Buyer API - AI-powered programmatic advertising
api_base_url: https://api.interchange.io/api/v2/buyer
auth:
  type: bearer
  header: Authorization
  format: "Bearer {token}"
  obtain_url: https://interchange.io/user-api-keys
---

# Scope3 Agentic Buyer API

This API enables AI-powered programmatic advertising with inventory discovery, campaign management, and creative orchestration.

**Important**: This is a REST API accessed via the `api_call` tool. After reading this documentation, use `api_call` to make HTTP requests to the endpoints below.

## ⚠️ CRITICAL: Presentation Rules

**Tool responses return JSON data.** For most endpoints (advertisers, sales agents, campaigns, etc.), YOU are responsible for presenting the data clearly in your message. Follow the Display Requirements for each endpoint — they tell you exactly what fields to show and how to structure the output. Never summarize into vague prose — always show the specific data points listed in the display requirements for each item.

**Exception: Product discovery and reporting endpoints render interactive UI components.** When those tools return a UI, display it as-is — do not generate your own competing visualization.

**Every value you present must trace to a tool response or user input.** When you show the user a status, ID, count, date, or any other data point, it must come from a `api_call` response, an `ask_about_capability` response, or something the user told you in this conversation. If you are unsure whether a value is still current (for example, a status that may have changed since the last fetch), qualify it — "last known value from `get_advertiser`" — rather than stating it as a fresh fact. Do not restate values "from memory" without re-fetching.

## ⚠️ CRITICAL: Show Operational Details, Not Just Names

When listing entities (advertisers, sales agents, storefronts), you MUST display the operational fields from the response — account status, credential requirements, linked accounts, sandbox status — not just names. The `ask_about_capability` response tells you which fields to show for each operation.

**Common mistake:** Listing advertisers or sales agents and showing only names. This is WRONG. Show account status, credential requirements, linked accounts, and other operational details for each item.

## ⚠️ CRITICAL: Exact Field Names Required

**DO NOT GUESS FIELD NAMES.** Use these exact camelCase names:

| Field | Type | Notes |
|-------|------|-------|
| `advertiserId` | string | NOT `advertiser_id` |
| `brand` | string | Required on advertiser create (e.g., `"nike.com"`) |
| `saveBrand` | boolean | Optional on advertiser create. Set `true` to save an enriched brand to the registry |
| `sandbox` | boolean | Optional on advertiser create. When `true`, all ADCP operations use sandbox accounts (no real spend). Immutable after creation |
| `optimizationApplyMode` | string | `"AUTO"` or `"MANUAL"` (default `"MANUAL"`). Controls whether Scope3 AI model optimizations to media buys are applied automatically or require manual approval. Set on advertiser (default for all campaigns) or override per campaign. |
| `flightDates` | **object** | NOT `startDate`/`endDate` at root level |
| `flightDates.startDate` | string | ISO 8601: `"2026-02-05T00:00:00Z"` |
| `flightDates.endDate` | string | ISO 8601: `"2026-02-10T23:59:59Z"` |
| `budget` | **object** | NOT a number |
| `budget.total` | number | e.g., `1000` |
| `budget.currency` | string | `"USD"` (default) |
| `constraints` | object | Optional |
| `constraints.channels` | array | e.g., `["display"]`, `["ctv"]` |
| `performanceConfig` | object | Optional. Contains `optimizationGoals` array. Each goal has `kind` (`"event"` or `"metric"`). Event goals have `eventSources` array + optional `target`. Metric goals have `metric` + optional `target`. |

## ⚠️ CRITICAL: Always Send the ENTIRE Client Brief

**When sending a `brief` field to ANY endpoint, you MUST include the COMPLETE brief text the client provided — word for word, in full.** Do NOT summarize, truncate, paraphrase, or shorten the brief under any circumstances.

The brief is used by sales agents and the discovery system to match relevant inventory. Sending a partial or summarized brief degrades match quality and loses important context. Even if the brief is long, always pass it through in its entirety.

**Rules:**
- **Always include the brief** — if the client provided a brief, you MUST send it in every API call that accepts a `brief` field. Never omit it or leave it out.
- **Copy the full brief verbatim** — include every detail the client provided
- **Never summarize** — "Premium CTV for tech enthusiasts" is NOT an acceptable substitute for a multi-paragraph brief
- **Never truncate** — if the client gave you 500 words, send all 500 words
- **Applies everywhere `brief` is used** — product discovery, campaign creation, and any other endpoint that accepts a brief

## ⚠️ CRITICAL: Never Fabricate User Data

**Before making any API call that creates or modifies a resource, you MUST have explicit user input for all required fields.** Do NOT invent, guess, or auto-fill values the user hasn't provided.

**Rules:**
- **NEVER fabricate values** for required fields. If the user hasn't provided a value, ask for it.
- **Read-only calls are fine** — you can freely call GET endpoints to fetch data and present it to the user.
- **Confirm before mutating** — Before any POST, PUT, or DELETE call, verify you have user-provided (or user-confirmed) values for all required fields.
- **Inferring is OK when obvious** — If the user says "optimize for purchases with a 4x ROAS target", you can infer an event goal with `eventType: "purchase"` and `target: { kind: "per_ad_spend", value: 4.0 }`. But if intent is ambiguous, ask.
- **Never make up IDs** — IDs (advertiserId, discoveryId, campaignId, etc.) must come from previous API responses or the user. Never generate them.
- **Account IDs for linking MUST come from discovery** — When linking an agent account to an advertiser, the `accountId` MUST come from the `list_available_accounts` operation response. Even if the user tells you an account ID or name (e.g., "the account is named XYZ" or "the ID is 06cd7033..."), you MUST still call the discovery endpoint and use the `accountId` from the API response. If the account doesn't appear in the discovery results, it cannot be linked — tell the user it was not found. NEVER pretend to successfully link an account that was not returned by the discovery endpoint.
- **Only use what's documented** — Do NOT invent endpoints, fields, query parameters, or enum values that are not explicitly listed in this skill document. If you're unsure whether something exists, check this document first. If it's not here, don't use it.

## Before Every api_call: Verify Each Field Has a Source

Before invoking `api_call`, mentally walk through every field in `body`, `pathParams`, and `params` and name its source:

- "`advertiserId`: from the `list_advertisers` response above" ✅
- "`brief`: verbatim from the user's message" ✅
- "`budget.total`: user said $5,000" ✅
- "`flightDates.startDate`: I assumed next Monday" ❌ — STOP, ask the user
- "`optimizationGoals[0].metric`: typically these APIs use `CTR`" ❌ — STOP, call `ask_about_capability`

If any field's source is "I assumed…", "it's probably…", "typically these are…", or "the docs usually say…" — do NOT send the call. Either ask the user for the value, or call `ask_about_capability` to find the correct schema.

This check is cheap; a failed or wrong-data API call is expensive.

---

## Notifications

The `help` and `ask_about_capability` tools include unread notifications in their responses. When a response contains a "Unread Notifications" section, summarize those notifications for the user before answering their question.

Notifications can be listed, marked as read, or acknowledged via the `/api/v2/notifications` endpoints — see the Notifications section below for details.

**Setup:** To receive notifications proactively at the start of every session, add this to your Claude Desktop Project instructions, CLAUDE.md, or system prompt: `When using Scope3 tools, always start by calling the help tool. The response includes unread notifications — summarize those for the user before answering their question.`

## Quick Start

1. **Use `ask_about_capability` first**: Ask about the user's request to learn the correct workflow, endpoints, and field names
2. **Use `api_call` with the `operation` field**: All operations go through the generic `api_call` tool. **Always use the `operation` enum** — it prevents hallucinated endpoints and wrong HTTP methods.
3. **Authentication**: Handled automatically by the MCP session

### `api_call` parameters

- `operation` (string): Named operation — each endpoint section below shows the operation name
- `pathParams` (object): Path parameters (e.g. `{ "advertiserId": "abc-123" }`)
- `body` (object): Request body for POST/PUT operations
- `params` (object): Query parameters as key-value pairs

---

## Campaigns

Create an ad campaign via the `create_campaign` operation. Campaigns are configured at creation or update time with:
- **Products**: Select products via the `discover_products` and `add_discovery_products` operations, then attach it via `discoveryId` at campaign creation or update.
- **Performance optimization**: Set via the `performanceConfig` field at campaign creation (`create_campaign`) or update (`update_campaign`).
- **Audience targeting**: Target or suppress audiences via the `audienceConfig` field at campaign creation or update. Audiences are synced to the advertiser first via `sync_audiences`.
- **Auto-select products (pick for me)**: Use the `auto_select_products` operation to let the system automatically choose products and allocate budget using AI. Requires a performance campaign with discovered products.

**⚠️ HARD RULE: One API Call Per Turn**

Only make ONE mutating or discovery API call per turn. After that call, present the results and END YOUR TURN. Do not paginate, re-discover, or chain additional calls — wait for the user to tell you what to do next.

**Required flow when user says "create a campaign":**
1. Collect campaign details: name, advertiser, budget, flight dates, brief, and any targeting constraints
2. Ask whether they want to target or suppress any audiences. If yes, list the advertiser's audiences (`list_audiences` operation) and let them choose. If the advertiser has no audiences, let the user know and offer: "You don't have any audiences synced yet. I can help you sync audiences to Scope3 — just ask me how to get started."
3. Ask if they want to attach a catalog — if yes, list their catalogs via `list_catalogs` operation and let them pick one
4. Ask how they'd like to configure: browse products (discovery) or set performance metrics
5. Based on their choice:
   - **Products**: Run discovery ONCE, present results, END YOUR TURN. The user drives what happens next.
   - **Performance**: Create the campaign with `performanceConfig`
6. Include `audienceConfig` if the user selected audiences in step 2
7. When ready, launch: use `execute_campaign` operation

**Required fields for campaign creation:**
- `advertiserId` (number) — NOT `advertiser_id`
- `name` (string)
- `flightDates` (object) — NOT `startDate`/`endDate` at root level
  - `flightDates.startDate` (ISO 8601 datetime)
  - `flightDates.endDate` (ISO 8601 datetime)
- `budget` (object) — NOT a number
  - `budget.total` (number)
  - `budget.currency` (string, default `"USD"`)

**Optional fields at creation:**
- `discoveryId`: Attach an existing discovery session
- `productIds`: Product IDs to pre-select (requires discoveryId)
- `performanceConfig`: For performance optimization. Contains `optimizationGoals` array. Each goal has `kind` (`"event"` or `"metric"`). Event goals have `eventSources` array (each with `eventSourceId`, `eventType`, optional `valueField`) + optional `target` object (`kind: "per_ad_spend"` or `kind: "cost_per"` with `value`). Metric goals have `metric` string + optional `target`. Goals can include `attributionWindow` and `priority`.
- `optimizationApplyMode`: `"AUTO"` or `"MANUAL"` (default). Controls whether Scope3 AI model optimizations to media buys are applied automatically or require manual approval. Overrides the advertiser-level default.
- `catalogId` (number, optional): Attach a single catalog to the campaign. Only **one** catalog per campaign. The catalog must belong to the same advertiser. Get available catalogs via `list_catalogs` operation. When attached, the catalog is automatically included in product discovery requests — referenced by ID for agents that have the catalog syndicated, or sent inline (feed URL or items) otherwise.
- `constraints.channels`: Target channels (display, olv, ctv, social)
- `constraints.countries`: Target countries (ISO 3166-1 alpha-2 codes)
- `brief`: Campaign brief. **MUST be the ENTIRE brief from the client — never summarize or truncate.**
- `audienceConfig`: Audience targeting and suppression. Contains `targetAudienceIds` (audiences to **include**) and `suppressAudienceIds` (audiences to **exclude**). Audience IDs come from `list_audiences` operation.

---

## Browsing Products Before Creating a Campaign

**When a user wants to browse products but hasn't created a campaign yet:**

Users may want to explore available inventory before committing to a campaign. Use the `discover_products` operation which discovers products based on the advertiser's context and returns a `discoveryId` along with the discovered products.

**Interactive flow:**
1. **Discover products** — Use the `discover_products` operation with advertiser context
   - Returns `discoveryId` and product groups — save the `discoveryId` for later use
2. **Present products** — Show available inventory in a user-friendly way
3. **Refine (optional)** — If the user wants changes ("more video", "remove that one", "more like this"), use `discover_products` operation again with `discoveryId` and a `refine` array
4. **Select products** — When the user likes products, add them via `add_discovery_products` operation
5. **Attach to a campaign** — Create a campaign with the `discoveryId` via `create_campaign`, or attach it to an existing campaign via `update_campaign` with `discoveryId`

**Request Parameters (Filtering):**
- `publisherDomain` (optional): Filter products by publisher domain (exact domain component match). Example: "example" matches "example.com", "www.example.com" but "exampl" does not match
- `salesAgentIds` (optional, array): Filter products by exact sales agent ID(s). Use when you have agent IDs from a previous response.
- `salesAgentNames` (optional, array): Filter products by sales agent name(s) (case-insensitive match). Use when a user mentions specific sellers, partners, or exchanges by name.
- `pricingModel` (optional): Filter by pricing model (`cpm`, `vcpm`, `cpc`, `cpcv`, `cpv`, `cpp`, `flat_rate`). Use when a user wants inventory with a specific pricing type.

See the Campaign Workflow below for the full step-by-step with HTTP examples.

---

## Adding Products to a Campaign

**When the user wants to choose specific inventory:**

Product discovery and selection is done via the discovery endpoints. Discover products, select the ones you want, then attach them to the campaign.

1. **Discover products** — Use `discover_products` operation
   - Show product groups, publishers, channels, and price ranges in a user-friendly way
2. **Present results and let the user choose** — Show the discovered products and ask which ones they want to add. Do NOT auto-select products for the user.
3. **Refine (optional)** — If the user wants to iterate ("more like this", "remove that", "more video options"), use `discover_products` operation again with `discoveryId` and a `refine` array
4. **Add their selections** — Use `add_discovery_products` operation with the products the user chose
   - Show the updated product list and budget allocation
5. **Attach to campaign** — Create the campaign with `discoveryId` via `create_campaign`, or update an existing campaign via `update_campaign` with `discoveryId`
5. **Confirm readiness** — "Your campaign has X products selected with $Y allocated. Ready to launch?"
6. **Launch** — Use `execute_campaign` operation

See the Discovery Workflow (Pre-Campaign Product Discovery) section below for the full step-by-step with HTTP examples.

### Setting Performance Optimization

**When the user wants the system to optimize for business outcomes:**

1. **Check conversion events** — Use `get_event_summary` operation with `eventType: "conversion"` to see what events are available for optimization
   - If none exist, help the user configure event sources first
   - **Note:** Event data is aggregated hourly. Newly reported events may take up to 1 hour to appear in the summary.
2. **Set performance config** — Include `performanceConfig` at campaign creation (`create_campaign`) or update (`update_campaign`)
   - Required: `optimizationGoals` array with at least one goal object
   - Each goal has `kind` (`"event"` or `"metric"`)
   - Event goals: `eventSources` array (each with `eventSourceId`, `eventType`, optional `valueField`), optional `target` (`kind: "per_ad_spend"` or `kind: "cost_per"` with `value`), optional `attributionWindow`, optional `priority`
   - Metric goals: `metric` string, optional `target`, optional `priority`
3. **Launch** — Use `execute_campaign` operation

### Auto-Selecting Products (Pick For Me)

**When the user wants the system to choose products automatically:**

Instead of manually browsing and selecting products, performance campaigns can use auto-selection:

1. **Ensure products are discovered** — The campaign must have discovered products (via `discover_products` operation or auto-discovery at campaign creation with `performanceConfig` + `constraints.channels`)
2. **Auto-select** — Use `auto_select_products` operation (no request body needed)
   - The system uses AI to select the best products based on the campaign brief, budget, constraints, and optimization goals
   - Budget is allocated across selected products based on strategic fit
   - Any previous product selections in the discovery session are replaced
3. **Review selections** — Present the selected products, budget allocations, and rationale to the user
4. **Refine (optional)** — The user can adjust selections:
   - `discover_products` operation with `discoveryId` and `refine` — Iterate on results (e.g., "more like this", "omit that", "more video")
   - `add_discovery_products` operation — Add products
   - `remove_discovery_products` operation — Remove products
   - Or use `auto_select_products` operation again to re-select
5. **Launch** — When the user confirms, use `execute_campaign` operation

**Response includes:**
- `selectedProducts`: Array of products with budget allocations
- `budgetContext`: Campaign budget vs allocated amount
- `selectionRationale`: AI-generated explanation of the selection strategy
- `selectionMethod`: `"scoring"`, `"measurability"`, or `"cpm_heuristic"`
- `testBudgetPerProduct`: Test budget allocated per product (when using measurability or scoring strategy)
- `productCount`: Number of products selected

---

## Discovery Workflow (Pre-Campaign Product Discovery)

**When to use:** User wants to browse, select, or control which specific inventory/products to include before or independently of campaign creation.

**Prerequisites:** Advertiser exists with a linked brand (set during advertiser creation via `brand`).

**Before starting discovery — know the campaign type:**
- If the user is discovering for an **existing campaign**, pass `campaignId` to `discover_products` — the campaign's `campaignType` will be used automatically to filter inventory.
- If the user **does not yet have a campaign**, ask whether this will be a **DECISIONED** or **ROUTED** campaign before discovering (see Create Campaign for definitions). This matters because **ROUTED campaigns can only use sales agents the customer has pre-authorized** — running open discovery first and then finding out results aren't usable on a ROUTED campaign wastes the user's time. If they say ROUTED, narrow results via `salesAgentIds` / `salesAgentNames` for the sales agents they've authorized.

### Interactive Flow

Follow these steps in order. **Do NOT skip product discovery.**

**Step 1: Discover products**

**Operation:** `discover_products`
```http
POST /api/v2/buyer/discovery/discover-products
{
  "advertiserId": "12345",
  "channels": ["ctv", "display"],
  "countries": ["US"],
  "brief": "<<< ALWAYS include the ENTIRE brief from the client here — never summarize >>>",
  "salesAgentNames": ["Acme Ad Exchange"]
}
```
**As operation:**
```json
{ "operation": "discover_products", "body": { "advertiserId": "12345", "channels": ["ctv", "display"], "countries": ["US"], "brief": "<<< ALWAYS include the ENTIRE brief from the client here — never summarize >>>", "salesAgentNames": ["Acme Ad Exchange"] } }
```
→ Returns `{ "discoveryId": "...", "productGroups": [...], "totalGroups": 25, "hasMoreGroups": true, "summary": { ... } }`

Save the `discoveryId` for all subsequent steps.

**Step 2: Present results and END YOUR TURN**

Present the discovered products and END YOUR TURN. If `hasMoreGroups: true`, tell the user more are available.

To browse more products or apply filters, use:

**Operation:** `browse_discovery`
```http
GET /api/v2/buyer/discovery/{discoveryId}/discover-products?groupLimit=10&groupOffset=0&productsPerGroup=15
```
**As operation:**
```json
{ "operation": "browse_discovery", "pathParams": { "discoveryId": "<id>" }, "params": { "groupLimit": "10", "groupOffset": "0", "productsPerGroup": "15" } }
```

**When discovery returns no products:**

A discovery response with 0 products is **not an error** — it means no products matched. Do NOT say "discovery failed." Do NOT speculate about why (e.g. "likely because X wasn't set up correctly" — you have no idea why, and guessing will be wrong and confusing). Just state the fact and offer these specific next steps:

1. **Add specificity** — Include budget, flight dates, specific channels (e.g. CTV, display), or audience targeting. Richer briefs give agents more to match against.
2. **Try different channels or geos** — The available inventory may not cover the requested combination.
3. **Reduce the ask** — If the brief is very narrow (e.g. a niche audience + specific publisher + tight budget), broadening one or more constraints often unlocks results.
4. **Try specific filters** — Filter by `salesAgentNames` or `publisherDomain` to target sellers known to have relevant inventory.

Example response when no products are returned:
> No products were returned for this brief. A few things that might help: adding a budget or flight dates, specifying channels (CTV, display, etc.), broadening the audience, or filtering by a specific seller. Want to try refining the brief?

**Explaining product relevance (IMPORTANT):**

Each product includes a `briefRelevance` field that explains WHY the product is a strategic fit for the campaign. When presenting products, you MUST:

1. **Lead with the "why"** — Don't just list product names and CPMs. For each product or product group, explain why it matters for THIS specific campaign. Use the `briefRelevance` text as your starting point but make it conversational.
2. **Connect products to the brief** — Reference the user's campaign goals, target audience, or brand context. Example: "These products from Magnite target family audiences with parenting and food-related segments — a direct match for your Fanta Meals campaign aimed at families."
3. **Highlight strategic differentiators** — Call out what makes a product stand out: guaranteed delivery, best-value CPM, audience segment alignment, or estimated reach. Don't bury these in a list.
4. **Group-level insight** — When presenting a sales agent's products, summarize what that agent brings to the table for this campaign. Don't just say "Products from Magnite Sales (5 products)" — say WHY Magnite Sales matters here.
5. **Skip empty relevance** — If a product has no `briefRelevance`, present it normally with its attributes. Don't fabricate relevance that doesn't exist.

**Bad example** (too basic):
> Magnite Sales has 5 products available: Americas Test Kitchen ($12 CPM, CTV), Rakuten TV ($8.50 CPM, CTV)...

**Good example** (explains why):
> Magnite Sales is a strong fit here — they offer family-targeting segments like "Parenting Babies and Toddlers" and "With Children" that align directly with your Fanta Meals audience. Their Americas Test Kitchen inventory puts your ads in a food/cooking context, and at $8.50–$12.00 CPM you're getting competitive rates with guaranteed delivery on several products.

### Filtering Product Discovery Results

When discovering products, these filters narrow results before grouping and pagination:

- `publisherDomain`: Filter by publisher website. Use when a user mentions a specific publisher or website.
  - Example: "hulu" matches "hulu.com", "www.hulu.com" but "hul" does not
- `salesAgentIds`: Filter by exact sales agent ID(s). Use when you have agent IDs from a previous response. Accepts multiple values.
- `salesAgentNames`: Filter by sales agent name(s) (case-insensitive substring match). Use when a user mentions specific sellers, partners, or exchanges by name. Accepts multiple values.
- `pricingModel`: Filter by pricing model. Use when a user asks about specific pricing types.
  - Valid values: `cpm`, `vcpm`, `cpc`, `cpcv`, `cpv`, `cpp`, `flat_rate`

Filters can be combined. Multiple values within a filter use OR logic (match any); different filters use AND logic.

**How to communicate filtering to users:**

Do NOT mention filter parameter names. Respond naturally:
- User: "What do you have from [agent name]?" → filter by salesAgentNames
- User: "Show me [publisher] inventory" → filter by publisherDomain
- User: "What does [agent name] have on [publisher]?" → filter by both salesAgentNames and publisherDomain
- User: "Show me inventory from [agent 1] and [agent 2]" → filter by salesAgentNames with both values
- User: "Show me CPM inventory" → filter by pricingModel=cpm
- User: "What flat rate options are there?" → filter by pricingModel=flat_rate
- User: "Who sells CTV inventory?" → show unfiltered results, then offer to narrow by seller

Each product group represents a sales agent. To focus on a specific agent's inventory, use the sales agent filter on subsequent requests.

**Step 2.5: Refine results (optional)**

If the user wants to iterate on the results, call `POST /discovery/discover-products` again with the same `discoveryId` plus a `refine` array. Each item in `refine` MUST be an object (not a string):

```http
POST /api/v2/buyer/discovery/discover-products
{
  "advertiserId": "12345",
  "discoveryId": "<<discoveryId from step 1>>",
  "refine": [
    { "scope": "request", "ask": "show me more video options" }
  ]
}
```

To omit a product:
```json
{ "refine": [{ "scope": "product", "id": "product_123", "action": "omit" }] }
```

To get more like a product:
```json
{ "refine": [{ "scope": "product", "id": "product_123", "action": "more_like_this" }] }
```

The response is the same shape as step 1 plus an optional `refinementApplied` array. Present updated results and let the user iterate or proceed to select.

**Step 3: Select products interactively**

Users can select products in two ways:
1. **Via the interactive card UI** — Users select product cards and click "Select". When this happens, you'll receive a message containing the discoveryId, productIds, and the exact `add_discovery_products` API call to execute. The message will also ask you to prompt the user about per-product budgets. **Ask the user if they'd like to set individual budgets before executing the API call, AND how they'd like to split the budget — by publisher then product, or just by product.** If they provide budgets, add a `budget` field to each product in the request body using their chosen split strategy. If they decline, execute the call as-is without budgets. Do not re-discover products or look up IDs.
2. **Via conversation** — Users describe which products they want in natural language. The productId, salesAgentId, groupId, and groupName are included in the product listing from the discovery response — extract them from there to build the API call. Do not re-discover products to obtain IDs.

**Per-product budget:** Each product supports an optional `budget` field (number, in dollars). Ask about budgets before adding products — don't assume a budget if the user hasn't specified one. When asking, ALWAYS ask how they want to split the budget: **by publisher then product** (allocate per publisher first, then split within each publisher's products — use when the user cares about publisher mix or has publisher-level commitments) or **just by product** (flat allocation across products regardless of publisher — use when the best products should win regardless of publisher). Apply their choice to the per-product `budget` values.

**Bid price (REQUIRED for non-fixed pricing):** When a product's selected pricing option has `isFixed: false`, you MUST include `bidPrice` in the request body. Use the `rate` or `floorPrice` from the product's `pricingOptions` (from the discovery response) as the `bidPrice` value — do NOT ask the user for this. If `isFixed: true`, omit `bidPrice`.

**Operation:** `add_discovery_products`
```http
POST /api/v2/buyer/discovery/{discoveryId}/products
{
  "products": [
    {
      "productId": "product_123",
      "salesAgentId": "agent_456",
      "groupId": "ctx_123-group-0",
      "groupName": "Publisher Name",
      "bidPrice": 12.50,
      "budget": 5000
    }
  ]
}
```
**As operation:**
```json
{ "operation": "add_discovery_products", "pathParams": { "discoveryId": "<id>" }, "body": { "products": [{ "productId": "product_123", "salesAgentId": "agent_456", "groupId": "ctx_123-group-0", "groupName": "Publisher Name", "bidPrice": 12.50, "budget": 5000 }] } }
```

Show the updated selection with selected products and budget allocation.

**Step 4: Confirm readiness**

"You have selected X products with $Y allocated. Ready to create the campaign?"

**Step 5: Create the campaign**

**Operation:** `create_campaign`
```http
POST /api/v2/buyer/campaigns
{
  "advertiserId": "12345",
  "name": "Q1 2025 CTV Campaign",
  "discoveryId": "abc123-def456-ghi789",
  "flightDates": {
    "startDate": "2025-02-01T00:00:00Z",
    "endDate": "2025-03-31T23:59:59Z"
  },
  "budget": {
    "total": 50000,
    "currency": "USD"
  }
}
```
**As operation:**
```json
{ "operation": "create_campaign", "body": { "advertiserId": "12345", "name": "Q1 2025 CTV Campaign", "discoveryId": "abc123-def456-ghi789", "flightDates": { "startDate": "2025-02-01T00:00:00Z", "endDate": "2025-03-31T23:59:59Z" }, "budget": { "total": 50000, "currency": "USD" } } }
```

**Step 6: Launch**

**Operation:** `execute_campaign`
```http
POST /api/v2/buyer/campaigns/{campaignId}/execute
```
**As operation:**
```json
{ "operation": "execute_campaign", "pathParams": { "campaignId": "<id>" } }
```

### Discovery Management

- `list_discovery_products` operation — List selected products
- `add_discovery_products` operation — Add products
- `remove_discovery_products` operation — Remove products (body: `{ "productIds": ["..."] }`)

**Summary:** Discover products → Select products → Create campaign → Execute

**IMPORTANT:** Do NOT expose API details to the user. Communicate conversationally about campaigns, inventory, products, and budgets — not about endpoints or HTTP methods.

---

## Performance Optimization Workflow

**When to use:** User wants the system to optimize for business outcomes automatically.

**Prerequisites:** Advertiser exists (with brand set during creation) + Event source configured.

**Step 1: Verify advertiser**

**Operation:** `list_advertisers`
```http
GET /api/v2/buyer/advertisers?status=ACTIVE&name={advertiserName}
```
**As operation:**
```json
{ "operation": "list_advertisers", "params": { "status": "ACTIVE", "name": "<advertiserName>" } }
```

**Step 2: Check/create event source**

**Operation:** `list_event_sources`
```http
GET /api/v2/buyer/advertisers/{advertiserId}/event-sources
```
**As operation:**
```json
{ "operation": "list_event_sources", "pathParams": { "advertiserId": "<id>" } }
```

If empty, register one or more via the upsert/sync endpoint:
```http
POST /api/v2/buyer/advertisers/{advertiserId}/event-sources/sync
{
  "account": { "account_id": "{advertiserId}" },
  "event_sources": [
    { "event_source_id": "website_pixel", "name": "Website Pixel", "event_types": ["purchase", "add_to_cart"] }
  ]
}
```

Save the `event_source_id` — it's required for optimization goals. Note the snake_case field names: this endpoint is ADCP-aligned (the only event-sources mutation route is `/sync`; there is no per-source POST/PUT/DELETE).

**Step 3: Gather required fields from the user**

Before calling the create endpoint, confirm you have:
- Campaign name (ask the user or confirm a suggested name)
- Flight dates (start and end — ask the user)
- Budget total and currency (ask the user)
- Optimization goals: at least one goal with `kind` ("event" or "metric"). For event goals: event source ID (from Step 2), event type (e.g. `purchase`, `lead`), and optionally a `target` (e.g. `kind: "per_ad_spend"` for ROAS or `kind: "cost_per"` for CPA). For metric goals: `metric` string and optional `target`.
- Optional: constraints, attribution window, priority

**Step 4: Create the campaign with performanceConfig**

**Operation:** `create_campaign`
```http
POST /api/v2/buyer/campaigns
{
  "advertiserId": "12345",
  "name": "Q1 ROAS Optimization",
  "flightDates": {
    "startDate": "2025-02-01T00:00:00Z",
    "endDate": "2025-03-31T23:59:59Z"
  },
  "budget": {
    "total": 100000,
    "currency": "USD"
  },
  "performanceConfig": {
    "optimizationGoals": [{
      "kind": "event",
      "eventSources": [
        { "eventSourceId": "es_abc123", "eventType": "purchase", "valueField": "value" }
      ],
      "target": { "kind": "per_ad_spend", "value": 4.0 },
      "attributionWindow": { "clickThrough": "7d" },
      "priority": 1
    }]
  },
  "constraints": {
    "channels": ["ctv", "display"],
    "countries": ["US"]
  }
}
```
**As operation:**
```json
{ "operation": "create_campaign", "body": { "advertiserId": "12345", "name": "Q1 ROAS Optimization", "flightDates": { "startDate": "2025-02-01T00:00:00Z", "endDate": "2025-03-31T23:59:59Z" }, "budget": { "total": 100000, "currency": "USD" }, "performanceConfig": { "optimizationGoals": [{ "kind": "event", "eventSources": [{ "eventSourceId": "es_abc123", "eventType": "purchase", "valueField": "value" }], "target": { "kind": "per_ad_spend", "value": 4.0 }, "attributionWindow": { "clickThrough": "7d" }, "priority": 1 }] }, "constraints": { "channels": ["ctv", "display"], "countries": ["US"] } } }
```

`performanceConfig` must include `optimizationGoals` array with at least one goal. Each goal has a `kind` discriminator: `"event"` goals require `eventSources` array (each with `eventSourceId` and `eventType`); `"metric"` goals require `metric` string. Both kinds support an optional `target` object (`kind: "per_ad_spend"` for ROAS targets, `kind: "cost_per"` for CPA targets, each with a `value`), `attributionWindow`, and `priority`.

**Step 5: Auto-select products (optional)**

If the campaign has discovered products (from auto-discovery at creation), let the system pick the best ones:

**Operation:** `auto_select_products`
```http
POST /api/v2/buyer/campaigns/{campaignId}/auto-select-products
```
**As operation:**
```json
{ "operation": "auto_select_products", "pathParams": { "campaignId": "<id>" } }
```
Returns selected products with budget allocations and AI-generated rationale. Present results and let the user review before launching.

**Step 6: Launch**

**Operation:** `execute_campaign`
```http
POST /api/v2/buyer/campaigns/{campaignId}/execute
```
**As operation:**
```json
{ "operation": "execute_campaign", "pathParams": { "campaignId": "<id>" } }
```

---

## Account Management

Some account management tasks are handled in the web UI at [interchange.io](https://interchange.io). Direct users to these pages for:

| Task | URL | Capabilities |
|------|-----|--------------|
| **API Keys** | [interchange.io/user-api-keys](https://interchange.io/user-api-keys) | Create, view, edit, delete, and reveal API key secrets |
| **Team Members** | [interchange.io/admin](https://interchange.io/admin) | Invite members, manage roles, manage advertiser access |
| **Billing** | Available from user menu in the UI | Manage payment methods, view invoices (via Stripe portal) |
| **Profile** | [interchange.io/user-info](https://interchange.io/user-info) | View and update user profile |

**Note:** Billing and member management require admin permissions.

### Current Account

Get the customer the authenticated user is currently operating in:

**Operation:** `get_current_account`
```http
GET /api/v2/accounts/current
```
**As operation:**
```json
{ "operation": "get_current_account" }
```

Returns `{ "id", "company", "name", "role" }` where `role` is the user's normalized role (`ADMIN`, `MEMBER`, or `SUPER_ADMIN`).

### List Accounts

List all customers the authenticated user has active membership on:

**Operation:** `list_accounts`
```http
GET /api/v2/accounts
```
**As operation:**
```json
{ "operation": "list_accounts" }
```

Returns `{ "accounts": [{ "id", "company", "name", "role" }] }` where `role` is the user's normalized role (`ADMIN`, `MEMBER`, or `SUPER_ADMIN`).

### Switch Account

Switching the active customer context is a session-mutating operation and is not available through `api_call`. Use the dedicated `customer_switch` MCP tool instead:

```json
{
  "tool": "customer_switch",
  "arguments": { "customerId": 123 }
}
```

Any user can switch to a customer they have an active membership on. SuperAdmin users can additionally switch to any customer. Use the `list_accounts` operation above to discover available customer IDs before switching. After the switch, all subsequent operations in the session are performed on behalf of the selected customer.

---

## Entity Hierarchy & Prerequisites

Before creating campaigns, you MUST understand the entity hierarchy:

```
Customer (your account)
  └── Advertiser (brand account - REQUIRED first)
        ├── Catalogs (sync product/offering data to partners)
        ├── Campaigns (advertising campaigns)
        │     └── Creatives
        ├── Event Sources (conversion data pipelines for optimization)
        └── Test Cohorts (for A/B testing)
```

### ⚠️ CRITICAL: Brand Domain Required for All Advertiser Actions

**Before performing ANY action on an advertiser** (creating campaigns, managing accounts, syncing catalogs, etc.), check that the advertiser has a brand domain configured (the `brand` field is not null/missing in the advertiser response).

If the advertiser does NOT have a brand domain:
1. **Do not proceed** with the requested action.
2. Inform the user: "This advertiser does not have a brand domain configured, which is required. Please provide a brand domain (e.g., `nike.com`) so I can update the advertiser."
3. Once the user provides one, update the advertiser via `update_advertiser` operation with `{ "brand": "nike.com" }`.
4. Only then continue with the original request.

### Setup Checklist

**Before you can run a campaign, you need:**

1. **Advertiser with brand domain** (REQUIRED)
   - First, check if one exists: `list_advertisers` operation
   - If not, create one: `create_advertiser` operation (requires `brand`)
   - An advertiser represents a brand/company you're advertising for
   - The brand is resolved automatically from the domain during creation
   - If the brand is not yet registered, the API returns an enriched preview — show it to the user, then retry with `saveBrand: true` to register the brand and create the advertiser
   - Set `sandbox: true` to create a sandbox advertiser — all ADCP operations will use sandbox-flagged accounts with no real spend. See **Sandbox Mode** below. Sandbox mode cannot be changed after creation.

   **Sandbox Mode**

   Sandbox mode lets you test the full media buying lifecycle — discovery, campaign creation, creatives, and delivery — without real platform calls or spending real money.

   - Sandbox is **account-level, not per-request**. The seller provisions a dedicated sandbox account, and every request using that `account_id` is automatically treated as sandbox. This eliminates the risk of accidentally mixing real and test traffic in a multi-step flow.
   - All discovered accounts for a sandbox advertiser are sandbox accounts — `list_accounts` is called with `sandbox: true`.
   - The correct sandbox `account_id` is automatically injected into `create_media_buy`, `get_media_buy_delivery`, and `get_products` — delivery and reporting data are fully scoped to the sandbox environment.
   - Responses contain simulated but realistic data.
   - Reference: https://docs.adcontextprotocol.org/docs/media-buy/advanced-topics/sandbox#sandbox-mode

2. **Event Sources** (REQUIRED for performance optimization)
   - Register conversion data pipelines: `POST /api/v2/buyer/advertisers/{advertiserId}/event-sources/sync` (ADCP-aligned snake_case body — see the Event Sources section below)
   - Referenced by `event_source_id` in optimization goals

3. **Creative Manifests** (REQUIRED)
   - Every campaign needs creative assets
   - **Asset creation and upload is UI-only** — call `GET /api/v2/buyer/creative-dashboard-url?advertiserId={advertiserId}&campaignId={campaignId}` to get the full URL, then direct the buyer to the returned `url`
   - Via MCP you can only **list**, **get**, **update metadata**, and **delete** existing manifests — see the **Creative Manifests** section below

---

## Core Concepts

| Concept | Description | Required For |
|---------|-------------|--------------|
| **Advertiser** | Top-level account representing a brand/company | Everything |
| **Catalog** | Product/offering data synced to partner platforms via ADCP | Catalog sync |
| **Campaign** | Advertising campaign with budget, dates, targeting | Running ads |
| **Creative Manifest** | Campaign-scoped container for creative assets (images, videos, URLs). Created/uploaded via UI only; list/get/update/delete via MCP | Ad delivery |
| **Event Source** | Conversion data pipeline (pixel, SDK, etc.) | Performance optimization |
| **Syndication** | Push audiences/events/catalogs to ADCP agents | Audience distribution |
| **Test Cohort** | A/B test configuration | Experimentation |
| **Media Buy** | Executed purchase record — all operations (update, cancel, archive) via `update_campaign` operation | Campaigns, Reporting |

---

## First-Time Setup

If you're starting fresh with a new advertiser, follow these steps.

```
Step 1: Check if an advertiser already exists
Operation: list_advertisers
→ If advertisers exist, you can use one. If not, create one (see Step 1b).

Step 1b: Create an advertiser (if needed)
Operation: create_advertiser
Body: { "name": "Acme Corp", "brand": "acme.com" }
→ If brand is registered: advertiser is created with linked brand.
→ If brand is not registered: returns enriched brand preview. Show it to the user,
  then retry with saveBrand: true to register the brand and create the advertiser:
  Operation: create_advertiser
  Body: { "name": "Acme Corp", "brand": "acme.com", "saveBrand": true }

Step 2: Create an event source (for performance optimization)
POST /api/v2/buyer/advertisers/{advertiserId}/event-sources
{
  "eventSourceId": "website_pixel",
  "name": "Website Pixel"
}

Step 3: Now you can discover products and create campaigns!
```

---

## API Endpoints Reference

### Advertisers

#### List Advertisers

**Operation:** `list_advertisers`
```http
GET /api/v2/buyer/advertisers?status=ACTIVE&name=Acme&limit=10&offset=0
```
**As operation:**
```json
{ "operation": "list_advertisers", "params": { "status": "ACTIVE", "name": "Acme", "limit": "10", "offset": "0" } }
```

**Query Parameters (Filters):**
- `status` (optional): Filter by status - `ACTIVE` or `ARCHIVED`
- `name` (optional): Filter by name (case-insensitive, partial match). Example: `name=Acme` matches "Acme Corp", "acme inc", etc.
- `limit` (optional): Maximum number of advertisers per page (default: 10, max: 10)
- `offset` (optional): Pagination offset (default: 0)

**Response:** each row is the advertiser **summary** shape — `id`, `name`, `status`, `brand`, `sandbox`, `linkedAccountCount`, `createdAt`, `updatedAt`. `description`, `optimizationApplyMode`, `campaignBudgetType`, resolved brand details, linked partner accounts, UTM config, and frequency caps are NOT on the summary — call `get_advertiser` for the full resource.

**Response format:**
```json
{
  "items": [ ... ],
  "total": 42,
  "hasMore": true,
  "nextOffset": 10
}
```
Use `nextOffset` as the `offset` parameter for the next page. When `hasMore` is `false`, `nextOffset` is `null`.

**Display Requirements — ALWAYS include when listing advertisers:**

Present each advertiser as a structured entry (not prose). For every advertiser, show:
- **Name** and **ID**
- **Status** (ACTIVE, ARCHIVED)
- **Brand** — brand name or domain (show "No brand" if missing)
- **Sandbox** — Yes/No
- **Linked Accounts** — show `linkedAccountCount`. If `0`, say "No linked accounts" and offer to discover/link. For sandbox advertisers, do not mention linking accounts — sandbox accounts are provisioned automatically when the user has credentials for a sales agent. To enumerate the accounts (partner name, account ID, status), call `get_advertiser`.

Never summarize into a sentence like "You have 13 advertisers." Always show the per-item details above for every advertiser in the response.

**Note:** `get_advertiser` returns the full advertiser — including resolved brand details, linked partner accounts, UTM config, and frequency caps.

#### Get Advertiser

**Operation:** `get_advertiser`
```http
GET /api/v2/buyer/advertisers/{id}
```
**As operation:**
```json
{ "operation": "get_advertiser", "pathParams": { "advertiserId": "<id>" } }
```

Returns the advertiser with full brand details, linked partner accounts, UTM config, and frequency caps.

**Response:**
```json
{
  "id": "34",
  "name": "Acme Corp",
  "description": null,
  "status": "ACTIVE",
  "createdAt": "2026-02-15T10:00:00Z",
  "updatedAt": "2026-02-15T10:00:00Z",
  "brand": "acme.com",
  "brandWarning": null,
  "linkedBrand": {
    "id": "brand_123",
    "name": "Acme Corp",
    "domain": "acme.com",
    "manifest": {
      "name": "Acme Corp",
      "logos": [{ "url": "https://acme.com/logo.png", "tags": ["primary"] }],
      "colors": { "primary": "#FF5733" },
      "industry": "Technology",
      "tagline": "Innovation for Everyone",
      "tone": "professional"
    },
    "logoUrl": "https://acme.com/logo.png",
    "industry": "Technology",
    "colors": { "primary": "#FF5733" },
    "tagline": "Innovation for Everyone",
    "tone": "professional"
  }
}
```

**linkedBrand fields:**
- `id`: Brand agent ID (prefixed with `brand_`)
- `name`: Resolved brand name
- `domain`: Brand domain
- `manifest`: Full ADCP v2 brand manifest — includes logos, colors, fonts, tone, tagline, assets, product catalog, disclaimers, and more. This is the complete brand identity data.
- `logoUrl`: Primary logo URL (convenience field extracted from manifest)
- `industry`: Brand industry (convenience field)
- `colors`: Brand colors (convenience field)
- `tagline`: Brand tagline (convenience field)
- `tone`: Brand tone (convenience field)

#### Create Advertiser

**⚠️ IMPORTANT: `brand` is required when creating an advertiser.**

When a user asks to create an advertiser:

1. **Ask for the name** - "What would you like to name your advertiser?"
2. **Ask for the brand** - "What is the brand's website domain? (e.g., nike.com)"
3. **Create the advertiser** with name and brand

The system resolves the brand via Addie (AdCP registry + Brandfetch enrichment). There are three possible outcomes:

**Outcome 1: Brand exists in the registry** — Advertiser is created successfully with the linked brand.

**Outcome 2: Brand found via enrichment only (not yet registered)** — The create **fails** with a `VALIDATION_ERROR`. The error response includes `error.details.enrichedBrand` containing the brand data that was found (name, domain, manifest with logos, colors, industry, tagline, tone, etc.).

**When this error occurs, you MUST do BOTH of the following:**

1. **ALWAYS show the enriched brand preview first.** Present `error.details.enrichedBrand` to the user — show the brand name, domain, industry, colors, logo URL, tagline, tone, and any other fields present. This lets the user review and confirm the right brand was found.
2. **Then offer to save and create.** Tell the user they can save this brand to the AdCP registry and create the advertiser in one step by retrying the same request with `saveBrand: true`. The user may also choose to manually adjust the brand data before saving.

**Retry with `saveBrand: true`:**

**Operation:** `create_advertiser`
```http
POST /api/v2/buyer/advertisers
{
  "name": "Acme Corp",
  "brand": "acme.com",
  "description": "Global advertising account",
  "saveBrand": true
}
```
**As operation:**
```json
{ "operation": "create_advertiser", "body": { "name": "Acme Corp", "brand": "acme.com", "description": "Global advertising account", "saveBrand": true } }
```
This saves the enriched brand to the AdCP registry and creates the advertiser with the linked brand in one call.

**Outcome 3: No brand data found at all** — The create fails. Tell the user to register their brand at https://adcontextprotocol.org/chat.html or https://agenticadvertising.org/brand, then retry.

**Initial request (without `saveBrand`):**

**Operation:** `create_advertiser`
```http
POST /api/v2/buyer/advertisers
{
  "name": "Acme Corp",
  "brand": "acme.com",
  "description": "Global advertising account"
}
```
**As operation:**
```json
{ "operation": "create_advertiser", "body": { "name": "Acme Corp", "brand": "acme.com", "description": "Global advertising account" } }
```

**Request fields:**
- `name` (required): Advertiser name
- `brand` (required): Brand domain (e.g., `"nike.com"`)
- `description` (optional): Description
- `saveBrand` (optional, boolean, default `false`): When `true`, saves an enriched brand to the AdCP registry if the brand is not yet registered. Set this after reviewing the enriched brand preview returned from a previous attempt.
- `linkedAccounts` (optional, array): Accounts to link at creation time. Each item: `{ partnerId, accountId, billingType? }`. Use `list_available_accounts` operation to discover valid accountIds — never ask the user to provide one manually.
- `optimizationApplyMode` (optional, string): `"AUTO"` or `"MANUAL"` (default `"MANUAL"`). Controls whether Scope3 AI model optimizations to media buys are applied automatically or require manual approval for campaigns under this advertiser.
- `utmConfig` (optional, array, max 20): Default UTM parameters for this advertiser. These are appended to landing page URLs during clickthrough redirection. Each item: `{ paramKey, paramValue }`. `paramKey` is the query parameter name (e.g. `"utm_source"`, `"bg_campaign"`). `paramValue` is a macro (e.g. `"{CAMPAIGN_ID}"`) resolved dynamically at click time, or a static string (e.g. `"scope3"`). Available macros follow the ADCP universal macros spec: https://docs.adcontextprotocol.org/docs/creative/universal-macros#universal-macros. If omitted, defaults are applied: `utm_source=scope3`, `utm_medium=agentic`, `utm_campaign={CAMPAIGN_ID}`, `utm_content={CREATIVE_ID}`, `utm_media_buy={MEDIA_BUY_ID}`, `utm_package={PACKAGE_ID}`. Campaign-level UTM config can override these per param key.
- `frequencyCaps` (optional, array): Buyer-side frequency caps that apply across all campaigns/creatives for this advertiser. Each item: `{ maxExposure, window: { interval, unit } }`. `maxExposure` is a positive integer (max exposures per user). `window.interval` is a positive integer; `window.unit` is `"minutes"`, `"hours"`, `"days"`, `"weeks"`, or `"months"`. Multiple caps are combined as AND (all must hold). Example: `[{ "maxExposure": 3, "window": { "interval": 1, "unit": "days" } }, { "maxExposure": 10, "window": { "interval": 1, "unit": "weeks" } }]` = max 3/day AND max 10/week. See the Frequency Caps section below for replace semantics.

**Response** includes `brand`, `linkedBrand`, `optimizationApplyMode`, optional `utmConfig` (seat-level UTM params, only present when configured), `frequencyCaps`, and optional `brandWarning` (e.g., if data came from Brandfetch enrichment rather than a well-known manifest).

#### Update Advertiser

**Operation:** `update_advertiser`
```http
PUT /api/v2/buyer/advertisers/{id}
{
  "name": "Acme Corporation",
  "description": "Updated description",
  "brand": "newbrand.com"
}
```
**As operation:**
```json
{ "operation": "update_advertiser", "pathParams": { "advertiserId": "<id>" }, "body": { "name": "Acme Corporation", "description": "Updated description", "brand": "newbrand.com" } }
```

**Optional fields:** `name`, `description`, `brand`, `linkedAccounts` (array of `{ partnerId, accountId, billingType? }` to add — does not remove existing links), `optimizationApplyMode` (`"AUTO"` or `"MANUAL"` — controls whether Scope3 AI model optimizations to media buys are applied automatically or require manual approval for campaigns under this advertiser), `utmConfig` (array of `{ paramKey, paramValue }`, max 20 — replaces all existing seat-level UTM params; pass `[]` to clear), `frequencyCaps` (array of `{ maxExposure, window: { interval, unit } }` — **full replace semantics**: omit the field to leave caps unchanged; pass `[]` to clear all caps; pass the full array to replace the set). Discover valid accountIds via `list_available_accounts` operation — never ask the user to provide an account ID.

If `brand` is provided, the system resolves the new brand and updates the linked brand agent.

#### Link Agent Account to Advertiser

Use this 3-step workflow to discover and link an agent's account to a specific advertiser.

**Two-step process overview:**
1. **Register agent credentials** (customer-level) — done once per source via `register_source_credentials` operation
2. **Link account to advertiser** (advertiser-level) — discover available accounts for a specific agent and link one to an advertiser

**Prerequisites:** Agent credentials must already be registered for the relevant source via `register_source_credentials` operation (see Register Agent Credentials in the Storefronts section). **Only sources with `requiresCredentials: true` support account linking.** To find eligible sources, use `list_storefronts` operation and look for sources where `requiresCredentials` is true.

**⚠️ CRITICAL: Multiple Credentials for the Same Agent**

A customer may register **multiple sets of credentials** for the same sales agent (e.g., two different Snap ad accounts with different API keys). This is fully supported. When this happens:
- Each set of credentials discovers its own set of ad accounts via `list_accounts`
- The `list_available_accounts` operation **requires a `credentialId` parameter** so the system knows which credential to use for discovery
- If the customer has multiple credentials and `credentialId` is omitted, the API returns a **validation error listing the available credential IDs** — present these to the user and ask them to pick
- Once the user picks a credential, re-call the discovery endpoint with `credentialId` to get that credential's accounts
- When the user links an account, all future operations for that advertiser+agent pair automatically use the credential associated with that account

**Workflow when multiple credentials exist:**
1. Use `list_agent_credentials` operation to list the customer's registered credentials
2. Present the credentials to the user (show `id` and `accountIdentifier` for each)
3. Ask the user which credential to use
4. Pass the chosen `credentialId` to the discovery endpoint

**Step 1 — Create advertiser with brand**

**Operation:** `create_advertiser`
```http
POST /api/v2/buyer/advertisers
{ "name": "Acme Corp", "brand": "acme.com" }
```
**As operation:**
```json
{ "operation": "create_advertiser", "body": { "name": "Acme Corp", "brand": "acme.com" } }
```

**Step 2 — Discover available accounts for the agent**

**⚠️ CRITICAL: Account IDs MUST come from the discovery endpoint — NEVER from user input.**
- You MUST call the discovery endpoint below and use ONLY the `accountId` values returned in the response.
- If the user provides an account ID or account name verbally (e.g., "the account ID is 06cd7033..."), do NOT use that value. Instead, call the discovery endpoint and match against the returned results.
- If no accounts are returned from discovery, tell the user no matching accounts were found. Do NOT pretend to link an account that was not returned by the API.
- **NEVER fabricate, guess, or use a user-provided account ID directly.** The only valid account IDs are those returned by this endpoint.

**Operation:** `list_available_accounts`
```http
GET /api/v2/buyer/advertisers/{advertiserId}/accounts/available?partnerId={agentId}
GET /api/v2/buyer/advertisers/{advertiserId}/accounts/available?partnerId={agentId}&credentialId={credentialId}
```
**As operation:**
```json
{ "operation": "list_available_accounts", "pathParams": { "advertiserId": "<id>" }, "params": { "partnerId": "<agentId>" } }
```
- `credentialId` is **required when the customer has multiple credentials** registered for this agent. If omitted with multiple credentials, the API returns a validation error listing available credential IDs — present them to the user and ask which to use, then retry with `credentialId`.
- `credentialId` is optional when the customer has only one credential for the agent.
- Returns accounts filtered by the advertiser's brand domain for the specified agent and credential.
- Response includes `accounts` array with `accountId`, `name`, `house`, `advertiser`, `partnerId`, and `billingOptions.supported`.
- **Show ALL discovered accounts to the user and let them pick.** Present each account's `name` (and `advertiser` if different from the brand).
- If `accounts` is empty, tell the user no matching accounts were found for that agent. Do NOT proceed with linking.
- **Do NOT ask about billing type.** Use `billingOptions.default` from the response if available. Only include `billingType` in the link request if the response shows multiple `billingOptions.supported` values AND no default — in that case, present the options and ask the user to choose.

**Step 3 — Link the selected account to the advertiser**

**Operation:** `update_advertiser`
```http
PUT /api/v2/buyer/advertisers/{advertiserId}
{
  "linkedAccounts": [
    { "partnerId": "snap_6e2d13705a26", "accountId": "acc_123" }
  ]
}
```
**As operation:**
```json
{ "operation": "update_advertiser", "pathParams": { "advertiserId": "<id>" }, "body": { "linkedAccounts": [{ "partnerId": "snap_6e2d13705a26", "accountId": "acc_123" }] } }
```
- The `accountId` here MUST be one returned from Step 2. Never use a value from any other source.
- `linkedAccounts` adds accounts — it does not remove existing links. Include `billingType` if the agent requires a specific billing arrangement.

---

### Campaigns

Campaigns use a single endpoint for creation and update. Configuration is done through action endpoints.

#### List Campaigns

**Operation:** `list_campaigns`
```http
GET /api/v2/buyer/campaigns?advertiserId=12345&status=ACTIVE
```
**As operation:**
```json
{ "operation": "list_campaigns", "params": { "advertiserId": "12345", "status": "ACTIVE" } }
```

**Query Parameters:**
- `advertiserId` (optional): Filter by advertiser
- `status` (optional): `DRAFT`, `ACTIVE`, `PAUSED`, `COMPLETED`, `ARCHIVED`

**Response:** each row is the campaign **summary** shape — `campaignId`, `advertiserId`, `name`, `status`, `campaignType`, `flightDates`, `budget` (compact: `total` + `currency` only), `productCount`, `createdAt`, `updatedAt`. Brief, media buys, audiences, creative coverage, fees, pacing, performance config, frequency caps, and the full `budget` (with `dailyCap` / `pacing`) are NOT on the summary — call `get_campaign` for the full resource.

#### Get Campaign

**Operation:** `get_campaign`
```http
GET /api/v2/buyer/campaigns/{campaignId}
```
**As operation:**
```json
{ "operation": "get_campaign", "pathParams": { "campaignId": "<id>" } }
```

`get_campaign` returns the full campaign — including `brief`, `mediaBuys`, `audiences` (currently active audiences with `audienceId`, `name`, `status`, `type` (`"TARGET"` or `"SUPPRESS"`), `enabledAt`), `creativeFormats`, `frequencyCaps`, `pacingPeriods`, `performanceConfig`, `constraints`, `fees`, `mediaBudget`, `allocatedBudget`, `unallocatedBudget`, `products`, and the original `budget` object.

**Campaign type, fees, and budget fields (returned by `get_campaign` whenever `budget.total` is set):**
- `campaignType` — `"DECISIONED"` or `"ROUTED"`. DECISIONED campaigns use Scope3 optimization; ROUTED campaigns pass through to the sales agent using the customer's own credentials (no Scope3 decisioning). Immutable after creation. Also returned on the list summary as `campaignType`.
- `fees` — Array of fee line items deducted from `budget.total`. Today this is typically a single Scope3 fee with `pricingType: "MARGIN"` and a `marginPercent` (e.g. 8 for 8%). Each entry has `label`, `amount`, `currency`, `pricingType` (`"MARGIN"` or `"UNIT"`), plus `marginPercent` for MARGIN or `unit`/`unitPrice` for UNIT.
- `mediaBudget` — `{ total, currency }`. The portion of `budget.total` available for media spend after `fees` are deducted (`budget.total - sum(fees[].amount)`). **This is what media buys spend against**, not `budget.total`.
- `allocatedBudget` — Sum of active media buy budgets plus performance spend on archived media buys, expressed in `budget.currency`.
- `unallocatedBudget` — Media budget remaining for new media buys (`mediaBudget.total - allocatedBudget`). Use this to answer "how much budget do I have left for new media buys?" directly — **do not sum media buy budgets yourself, and do not subtract from `budget.total`**. May be negative if archived performance spend exceeds the media budget.

The list summary exposes only the compact `budget: { total, currency }` (a 2-field nested pick of the full budget) — call `get_campaign` for `fees`, `mediaBudget`, `allocatedBudget`, `unallocatedBudget`, and the full budget object (with `dailyCap` / `pacing`).

**DRAFT campaigns only:** The response includes `discoveryId`, `products`, and `productCount` fields representing the product selection from the discovery workflow. These fields are **only present while the campaign is in DRAFT status**. After execution, product data is represented through `mediaBuys` — do not look for `discoveryId` or `products` on executed campaigns.

**Understanding duplicate media buys in the response:** The `mediaBuys` array may contain **two entries with the same media buy ID but different statuses** — e.g., one `ACTIVE` and one `PENDING_APPROVAL`. This is **normal and expected**. The `PENDING_APPROVAL` entry is a pending version of the media buy that has been submitted to the sales agent/publisher for approval. The original `ACTIVE` version remains unchanged until the publisher approves the update — once approved, the pending version becomes ACTIVE and replaces the old one. Do NOT flag this as unusual or ask the user about it — simply explain that the update is pending publisher approval.

**Surviving response truncation (`mediaBuyRefs` + `mediaBuyId` filter):** Campaigns with many media buys produce large `get_campaign` responses where the nested `mediaBuys[]` tail can be truncated by your context window. To handle this:

- `Campaign.mediaBuyRefs` is a small array placed near the **top** of the `get_campaign` response (`[{ mediaBuyId, status }, ...]`) covering every media buy on the campaign. Use it to enumerate IDs reliably even when the heavier `mediaBuys[]` array later in the response is truncated. **If the user asks for a specific media buy that you cannot find in `mediaBuys[]`, check `mediaBuyRefs` before claiming the buy doesn't exist.**
- `get_campaign` accepts a `mediaBuyId` query param (single value or repeated) that filters the embedded `mediaBuys[]` array to just the requested buys. The campaign object and `mediaBuyRefs` are unchanged: only the heavy nested array is narrowed. Use this to drill into specific buys without loading the full tree.

```json
{ "operation": "get_campaign", "pathParams": { "campaignId": "<id>" }, "params": { "mediaBuyId": "mb_ETBn4gJ9Wu" } }
```

**Pacing periods (`pacingPeriods`):** When a campaign has `pacingPeriods` on the GET response, it defines one or more time-based segments — each with its own `label`, `start`, and `end` date — that together cover the campaign flight. Each period is typically executed as its own media buy (or set of packages). Media buy `start_time` and `end_time` MAY correspond to a specific pacing period's `start` / `end`, but pacing periods do not strictly govern media buy dates: a media buy can have any dates within the campaign flight, with or without pacing periods. A media buy's `start_time` can never be earlier than the campaign's `flightDates.startDate`, and `end_time` must fall within the campaign flight. When the user refers to a specific period or package name (e.g. "Package 1" or "the first flight"), match it to the `label` in `pacingPeriods` and use that period's `start` / `end` as the media buy dates.

#### Get Campaign Products

**Operation:** `get_campaign_products`
```http
GET /api/v2/buyer/campaigns/{campaignId}/products
```
**As operation:**
```json
{ "operation": "get_campaign_products", "pathParams": { "campaignId": "<id>" } }
```

Returns every product staged on the campaign together with the discovery run (search context) that found it and any media buys it has been executed into. Use this to inspect what products are queued for execution, see which brief produced each one, and tell the user which products are pending vs already on a media buy.

**Response shape:**
- `products[]` — one entry per product with `productId`, `productName`, `salesAgentId`, `bidPrice`, `budget`, `pricingModel`, `selectedAt`, `searchContext` (`{ id, brief }` of the discovery run that found it), and `mediaBuys[]` (each `{ id, name, status }`; empty array means the product is staged but not yet executed)
- `searchContexts[]` — one entry per distinct discovery run on this campaign with `id`, `brief`, `channels`, `countries`, `createdAt`, and `productCount`
- `summary` — `{ totalProducts, productsOnMediaBuys, productsPending }`

#### Create Campaign

**⚠️ BEFORE creating a campaign: verify the advertiser has a brand domain.**
Use `get_advertiser` operation and check the `brand` field is not null/missing.
If it is missing, do NOT proceed — tell the user: "This advertiser doesn't have a brand domain configured. Please provide one (e.g. `nike.com`) so I can update it first." Then use `update_advertiser` operation with `{ "brand": "..." }` before continuing.

**Operation:** `create_campaign`
```http
POST /api/v2/buyer/campaigns
{
  "advertiserId": "12345",
  "name": "Q1 Campaign",
  "flightDates": {
    "startDate": "2025-02-01T00:00:00Z",
    "endDate": "2025-03-31T23:59:59Z"
  },
  "budget": {
    "total": 50000,
    "currency": "USD"
  },
  "brief": "<<< ALWAYS include the ENTIRE brief from the client — never summarize or truncate >>>",
  "constraints": {
    "channels": ["ctv", "display"],
    "countries": ["US"]
  },
  "discoveryId": "optional-existing-discovery-id",
  "productIds": ["prod_123", "prod_456"],
  "audienceConfig": {
    "targetAudienceIds": ["aud_001", "aud_002"],
    "suppressAudienceIds": ["aud_003"]
  },
  "performanceConfig": {
    "optimizationGoals": [{
      "kind": "event",
      "eventSources": [
        { "eventSourceId": "es_abc123", "eventType": "purchase", "valueField": "value" }
      ],
      "target": { "kind": "per_ad_spend", "value": 4.0 },
      "priority": 1
    }]
  }
}
```

**Required fields:**
- `advertiserId`: Advertiser ID
- `name`: Campaign name (1-255 chars)
- `flightDates`: Start and end dates
- `budget`: Total and currency
- `campaignType`: `"DECISIONED"` or `"ROUTED"`. **Immutable after creation — get this right on create.** DECISIONED is the default for almost every customer: Scope3 runs decisioning/optimization and applies the standard Scope3 fee (deducted from `budget.total` into `mediaBudget.total`). ROUTED is for customers who want direct buyer↔sales-agent connections using their own sales agent credentials — Scope3 does not decision, optimize, or pace, and only sales agents the customer has pre-authorized are eligible. **If the user hasn't specified, ask before creating.**

**Optional fields:**
- `brief`: Campaign brief. **MUST be the ENTIRE brief from the client — never summarize or truncate.**
- `constraints.channels`: Target channels (display, olv, ctv, social)
- `constraints.countries`: Target countries (ISO 3166-1 alpha-2 codes)
- `discoveryId`: Attach an existing discovery session
- `productIds`: Product IDs to pre-select from the discovery session (requires discoveryId)
- `audienceConfig`: Audience targeting and suppression. `targetAudienceIds` (string array) — audiences to include. `suppressAudienceIds` (string array) — audiences to exclude. Audience IDs come from `list_audiences` operation.
- `performanceConfig`: Contains `optimizationGoals` array. Each goal has `kind` (`"event"` or `"metric"`). Event goals have `eventSources` array (each with `eventSourceId`, `eventType`, optional `valueField`), optional `target` (`kind: "per_ad_spend"` or `kind: "cost_per"` with `value`), optional `attributionWindow`, optional `priority`. Metric goals have `metric` string, optional `target`, optional `priority`.
- `optimizationApplyMode`: `"AUTO"` or `"MANUAL"` (default). Controls whether Scope3 AI model optimizations to media buys are applied automatically or require manual approval. Overrides the advertiser-level default.
- `utmConfig`: Campaign-level UTM parameter overrides. Object with `params` (array of `{ paramKey, paramValue }`, max 20) and optional `deleteMissing` (boolean — if `true`, removes campaign-level UTM params not in this request; if `false`/omitted, additive mode). Campaign UTM params override seat-level defaults per matching `paramKey`.
- `pacingPeriods`: Time-based pacing schedule. Divides the flight into labelled periods, each with its own `start` / `end` and either a `weight` (weight mode) or `budget` (budget mode). On execution, each selected product is split into one package per period. Media buys tied to a specific pacing period typically use that period's `start` / `end` as the media buy's `start_time` / `end_time`, but pacing periods do not strictly govern media buy dates — media buys can have any dates within the campaign flight, with or without pacing periods. Can only be set on DRAFT campaigns. Example: `{ "mode": "weight", "periods": [ { "label": "Package 1", "start": "2026-08-01", "end": "2026-08-09", "weight": 1 }, { "label": "Package 2", "start": "2026-08-10", "end": "2026-09-30", "weight": 2 } ] }`.
- `frequencyCaps`: Buyer-side frequency caps for this campaign. Array of `{ max_impressions, window: { interval, unit } }`. These are **campaign-scoped** caps (they override nothing at the advertiser level — enforcement evaluates all applicable caps). See the Frequency Caps section below for details and replace semantics. Example: `[{ "max_impressions": 3, "window": { "interval": 1, "unit": "days" } }]`.

**After creating a campaign, suggest ONLY these next steps (never mention strategies, tactics, or media plans):**
1. **Discover products** — find and attach inventory via `discover_products` operation
2. **Attach audiences** — link synced audiences for targeting/suppression via `update_campaign` with `audienceConfig`
3. **Set performance configuration** — configure optimization goals via `update_campaign` with `performanceConfig`

#### Update Campaign

**Operation:** `update_campaign`
```http
PUT /api/v2/buyer/campaigns/{campaignId}
{
  "name": "Updated Campaign Name",
  "budget": { "total": 75000 },
  "audienceConfig": {
    "targetAudienceIds": ["aud_004"],
    "suppressAudienceIds": ["aud_005"]
  },
  "performanceConfig": {
    "optimizationGoals": [{
      "kind": "event",
      "eventSources": [
        { "eventSourceId": "es_abc123", "eventType": "purchase", "valueField": "value" }
      ],
      "target": { "kind": "per_ad_spend", "value": 5.0 },
      "priority": 1
    }]
  }
}
```
All fields are optional. `audienceConfig` is **additive** by default — it adds audiences without removing existing ones. Set `deleteMissing: true` inside `audienceConfig` to replace the full audience set (audiences not in the list are soft-disabled). To remove all audiences, send `{ "audienceConfig": { "deleteMissing": true } }`.

`frequencyCaps` uses **full-replace semantics**: omit the field to leave existing caps unchanged; pass `[]` to clear all caps; pass the full desired array to replace the set.

When `pacingPeriods` is included in the update, the response includes a `pacingCascadeResult` block with the per-media-buy outcome of pushing appended periods to live media buys via ADCP `add_packages`. Always inspect this when adding heavy-up periods to a running campaign — agents reported with `outcome: "unsupported"` need a manual new-media-buy workaround. See the Pacing Periods guide for the full result shape and append-only rules.

##### Updating Media Buys via Campaign Update

To perform any media buy operation, include the `mediaBuys` array in the campaign update body. Each entry targets a specific media buy by ID and uses the `action` field to specify the operation.

**IMPORTANT: Product changes are ONLY allowed on DRAFT media buys.**
- When a media buy is **DRAFT** (not yet executed), update its **`products`** — these are the pre-execution product selections. Products are added or updated by default; to remove a product, set `remove: true`.
- When a media buy is **ACTIVE** (already executed/deployed), update its **`packages`** — these are the deployed line items on the publisher side.
- Any other status (PENDING_APPROVAL, INPUT_REQUIRED, PAUSED, etc.) does **not** allow product changes.

**Example — Update budget and pacing for an ACTIVE media buy's package:**

**Operation:** `update_campaign`
```http
PUT /api/v2/buyer/campaigns/{campaignId}
{
  "mediaBuys": [
    {
      "mediaBuyId": "mb_abc123",
      "packages": [
        {
          "packageId": "pkg_xyz",
          "budget": 5000,
          "pacing": "even"
        }
      ],
      "updated_reason": "Increase budget for Q2 push"
    }
  ]
}
```

**Example — Update a DRAFT media buy's products:**

**Operation:** `update_campaign`
```http
PUT /api/v2/buyer/campaigns/{campaignId}
{
  "mediaBuys": [
    {
      "mediaBuyId": "mb_draft456",
      "products": [
        {
          "product_id": "prod_001",
          "budget": 3000,
          "pacing": "even"
        }
      ]
    }
  ]
}
```

**Media buy update fields:**
- `mediaBuyId` (required): ID of the media buy to update (from campaign GET response `mediaBuys` array)
- `name` (optional): Updated media buy name
- `packages` (optional, for **ACTIVE** media buys): Array of package updates. Each: `packageId` (required), `budget`, `pacing` (`"even"` or `"asap"`), `bidPrice`, `creative_ids`
- `products` (optional, **DRAFT only**): Array of product updates. Additive by default — products are added or updated. To remove a product, include `remove: true`. Each: `product_id` (required), `pricingOptionId`, `budget`, `pacing` (`"asap"`, `"even"`, `"front_loaded"`), `bidPrice`, `remove` (boolean, optional)
- `start_time` (optional): `"asap"` or ISO 8601 date-time. Start of **this media buy**. **Cannot be earlier than the campaign's `flightDates.startDate`.** Media buy dates MAY correspond to a `pacingPeriods[].start` when the media buy represents a specific period, but pacing periods do not govern media buy dates — a media buy can have any start within the campaign flight, with or without pacing periods.
- `end_time` (optional): ISO 8601 date-time. End of **this media buy**. Must fall within the campaign's flight dates. Media buy dates MAY correspond to a `pacingPeriods[].end` when the media buy represents a specific period, but pacing periods do not govern media buy dates — a media buy can have any end within the campaign flight, with or without pacing periods.
- `creative_ids` (optional): Updated creative assignments
- `optimization_goals` (optional): Media-buy-level optimization goals applied to every package at execution time. See **Media Buy Optimization Goals** below. Pass an empty array to clear all goals. **ALWAYS ask the buyer what they want to optimize for before creating or updating a media buy — do not fall back silently.**
- `updated_reason` (optional): Reason for update (stored with version history)

##### Media Buy Optimization Goals

**Optimization goals tell downstream optimizers what to tune for.** A media buy without optimization goals cannot be auto-optimized by the Scope3 AI model or any sales-agent-side optimizer. **You MUST ask the buyer what they want to optimize for** when creating a media buy or before executing — never fall back to a default silently.

Optimization goals are stored on the media buy and fanned out to every package at execution time (submitted per-package in the ADCP `create_media_buy` request).

**Goal schema.** Each goal is one of two shapes:

1. **Event goal** (`kind: "event"`) — tied to registered event sources (e.g., a conversion pixel, purchase feed, lead form). `event_sources` is an **array of `{ event_source_id, event_type }` objects**, not top-level fields:
   ```json
   {
     "kind": "event",
     "event_sources": [
       { "event_source_id": "es_website_pixel", "event_type": "purchase" }
     ],
     "target": { "kind": "per_ad_spend", "value": 4.0 }
   }
   ```

2. **Metric goal** (`kind: "metric"`) — tied to a built-in delivery metric (e.g., clicks, views, video completions):
   ```json
   {
     "kind": "metric",
     "metric": "clicks",
     "target": { "kind": "cost_per", "value": 0.50 }
   }
   ```

**Target kinds** (differ by goal kind):

For **event goals** (`kind: "event"`):
- `cost_per` — CPA target (target cost per conversion event in the buy currency)
- `per_ad_spend` — ROAS target (return on ad spend multiple, e.g., `4.0` for 4× return). Requires at least one event source entry to include `value_field`.
- `maximize_value` — maximize total conversion value within budget (no `value` field; requires `value_field` on at least one event source entry)

For **metric goals** (`kind: "metric"`):
- `cost_per` — target cost per metric unit (e.g., CPC, CPM)
- `threshold_rate` — minimum per-impression rate (e.g., minimum click-through or completion rate)

**Example — Update a media buy with a ROAS goal tied to purchase conversions:**

**Operation:** `update_campaign`
```http
PUT /api/v2/buyer/campaigns/{campaignId}
{
  "mediaBuys": [
    {
      "mediaBuyId": "mb_abc123",
      "optimization_goals": [
        {
          "kind": "event",
          "event_sources": [
            {
              "event_source_id": "es_website_pixel",
              "event_type": "purchase",
              "value_field": "order_total"
            }
          ],
          "target": { "kind": "per_ad_spend", "value": 4.0 }
        }
      ],
      "updated_reason": "Shift to ROAS target for Q2"
    }
  ]
}
```

**Rules:**
- Event goals require the `event_source_id` to be registered under the advertiser first (see `list_event_sources` / create event source).
- Pass an empty array (`"optimization_goals": []`) to clear all goals.
- Optimization goals are applied to **every** package in the media buy at execution. Per-package goal overrides are not supported at this level.

**Media buy update versioning:** When you update an ACTIVE media buy, the system creates a **new pending version** with status `PENDING_APPROVAL`. This is expected:
- The original ACTIVE version remains unchanged until the sales agent (publisher) approves the update.
- The campaign GET response will temporarily show **two entries** for the same media buy: the original ACTIVE version and the new PENDING_APPROVAL version.
- The pending version's `packages` may not immediately reflect the requested changes — the updated values are submitted to the sales agent for approval. Product changes are not allowed on PENDING_APPROVAL media buys.
- Once the sales agent approves, the pending version becomes ACTIVE and replaces the old one.
- **Do NOT treat this as an error or try alternative approaches.** Simply inform the user that the update has been submitted and is pending approval from the sales agent/publisher.

#### Media Buy Cancel and Archive via Campaign Update

All media buy operations go through the campaign update endpoint. Use the `action` field in the `mediaBuys` array:

##### Cancel a Media Buy
```json
{
  "mediaBuys": [{
    "action": "cancel",
    "mediaBuyId": "mb_123",
    "reason": "No longer needed",
    "packageIds": ["pkg_1", "pkg_2"]
  }]
}
```

- Works for ACTIVE, PAUSED, PENDING_APPROVAL, and INPUT_REQUIRED statuses
- For DRAFT media buys, use `action: "delete"` instead (DRAFT is internal-only, not sent to any sales agent)
- For active media buys, a cancellation request is sent to the sales agent via ADCP
- `reason` (optional): Cancellation reason sent to the sales agent
- `packageIds` (optional): Cancel specific packages instead of the entire media buy

##### Archive a Media Buy
```json
{
  "mediaBuys": [{
    "action": "delete",
    "mediaBuyId": "mb_123"
  }]
}
```

Archives a media buy (soft delete). Sets status to ARCHIVED and removes it from active listings. Data is preserved for historical reporting.

##### Mix Actions in One Update
You can combine update, cancel, and delete actions in a single campaign update:
```json
{
  "mediaBuys": [
    { "action": "cancel", "mediaBuyId": "mb_1", "reason": "Budget cut" },
    { "action": "delete", "mediaBuyId": "mb_2" },
    { "mediaBuyId": "mb_3", "packages": [{ "packageId": "pkg_1", "budget": 5000 }] }
  ]
}
```

---

#### Delete Campaign

**Operation:** `delete_campaign`
```http
DELETE /api/v2/buyer/campaigns/{campaignId}
```
**As operation:**
```json
{ "operation": "delete_campaign", "pathParams": { "campaignId": "<id>" } }
```

---

#### Campaign Action Endpoints

#### Execute Campaign (Launch)

**BEFORE executing:** confirm that every DRAFT media buy on this campaign has `optimization_goals` set. Media buys without goals cannot be auto-tuned. If any media buy is missing goals, ALWAYS ask the buyer what they want to optimize for (ROAS target with a purchase event? CPC target? completion rate?) and set them via `update_campaign` → `mediaBuys` → `optimization_goals` first. Do not execute silently with missing goals.

**Operation:** `execute_campaign`
```http
POST /api/v2/buyer/campaigns/{campaignId}/execute
```
**As operation:**
```json
{ "operation": "execute_campaign", "pathParams": { "campaignId": "<id>" } }
```

**Optional request body:**
```json
{
  "debug": true
}
```

**Response:**
```json
{
  "campaignId": "campaign_abc123",
  "previousStatus": "DRAFT",
  "newStatus": "ACTIVE",
  "success": true
}
```

**On partial failure** (some media buys failed to execute):
```json
{
  "campaignId": "campaign_abc123",
  "previousStatus": "DRAFT",
  "newStatus": "ACTIVE",
  "success": false,
  "errors": [
    {
      "mediaBuyId": "mb_xyz",
      "salesAgentId": "snap_abc",
      "message": "Failed to submit media buy to publisher: ...",
      "debug": {
        "request": { "...full ADCP create_media_buy request..." },
        "response": { "...full ADCP response from sales agent..." },
        "debugLogs": [ { "...A2A request/response logs..." } ],
        "error": "error message"
      }
    }
  ]
}
```

- `success` is `false` when any media buy execution failed
- `errors` array contains structured error objects per failed media buy
- `debug` field contains the same debug info as v1 `execute_media_buy` (full ADCP request, response, and A2A debug logs) — only present when `debug: true` was sent in the request body
- Campaign is still set to ACTIVE even with partial failures — re-execute to retry failed media buys

**How execute works internally:**
1. **Reconcile** — reads the attached discovery session's selected products and creates DRAFT media buys per sales agent. Products already on an existing non-terminal media buy are skipped. Each sales agent gets its own media buy. If the agent already has a DRAFT, new products are added to it. If the agent has no media buy or only terminal ones (CANCELLED, ARCHIVED, COMPLETED, REJECTED), a new DRAFT is created.
2. **Execute DRAFTs** — all DRAFT media buys on the campaign are submitted to their respective sales agents.
3. Existing non-DRAFT media buys (ACTIVE, PAUSED, etc.) are **never touched** — execute only operates on DRAFTs.

This means you can safely re-execute a campaign after attaching a new discovery session. New products for new sales agents get their own media buys without affecting existing ones.

**Note on Media Buys:** Media buys are child resources of campaigns — there are **no standalone media buy endpoints**. DRAFT media buys are created automatically when a discovery session is attached (via `update_campaign` with `discoveryId`) or when `execute_campaign` is called. They are included in campaign GET responses (`mediaBuys` array) and modified through campaign updates (`update_campaign` operation with `mediaBuys` array). For ACTIVE media buys, update `packages` (deployed line items); for DRAFT media buys, update `products` (only DRAFT allows product changes). See "Updating Media Buys via Campaign Update" above for full schema and examples.

#### Pause Campaign

**Operation:** `pause_campaign`
```http
POST /api/v2/buyer/campaigns/{campaignId}/pause
```
**As operation:**
```json
{ "operation": "pause_campaign", "pathParams": { "campaignId": "<id>" } }
```

#### Auto-Select Products

**Operation:** `auto_select_products`
```http
POST /api/v2/buyer/campaigns/{campaignId}/auto-select-products
```
**As operation:**
```json
{ "operation": "auto_select_products", "pathParams": { "campaignId": "<id>" } }
```
No request body. Automatically selects products from the campaign's discovery session and allocates budget based on measurability. Replaces any previous selections. Requires a performance campaign (`performanceConfig` set) with discovered products.

#### Get Media Buy ADCP Status

Poll ADCP sales agents for the live status of all media buys in a campaign. Updates local status when changes are detected (e.g., pending → active, active → paused). Use this to check whether pending media buys have been activated or to detect status transitions that webhooks may have missed.

**Operation:** `get_media_buy_status`
```http
GET /api/v2/buyer/campaigns/{campaignId}/media-buy-status
```
**As operation:**
```json
{ "operation": "get_media_buy_status", "pathParams": { "campaignId": "<id>" } }
```
No request body. Returns current ADCP status for each media buy in the campaign.

**Response:**
- `campaign_id` (string): The campaign ID queried
- `media_buys` (array): Status for each media buy
  - `media_buy_id` (string): Internal media buy ID
  - `adcp_media_buy_id` (string): ADCP-assigned media buy ID
  - `internal_status` (string): Current internal status (ACTIVE, PENDING_APPROVAL, PAUSED, COMPLETED, etc.)
  - `adcp_status` (string|null): Current ADCP status (active, pending_creatives, pending_start, paused, completed, rejected, canceled)
  - `previous_internal_status` (string): Status before this check
  - `previous_adcp_status` (string|null): ADCP status before this check
  - `updated` (boolean): Whether the local status was updated by this call
- `agents_queried` (number): Number of ADCP sales agents polled
- `errors` (array): Any errors encountered per media buy

**Response:**
- `selectedProducts` (array): Products with budget allocations (`productId`, `salesAgentId`, `budget`, `cpm`, `pricingOptionId`)
- `budgetContext` (object): `campaignBudget`, `totalAllocated`, `remainingBudget`, `currency`
- `selectionRationale` (string): Explanation of the selection strategy
- `selectionMethod` (string): `"scoring"`, `"measurability"`, or `"cpm_heuristic"`
- `testBudgetPerProduct` (number, optional): Test budget allocated per product
- `productCount` (number): Total products selected

---

### Discovery

#### Discover Products

Discovers products based on advertiser context and returns a discoveryId for managing selections.

**Operation:** `discover_products`
```http
POST /api/v2/buyer/discovery/discover-products
{
  "advertiserId": "12345",
  "channels": ["ctv", "display"],
  "countries": ["US"],
  "brief": "<<< ALWAYS include the ENTIRE brief from the client here — never summarize >>>",
  "publisherDomain": "example",
  "salesAgentNames": ["Acme Ad Exchange"],
  "debug": true
}
```
**As operation:**
```json
{ "operation": "discover_products", "body": { "advertiserId": "12345", "channels": ["ctv", "display"], "countries": ["US"], "brief": "...", "publisherDomain": "example", "salesAgentNames": ["Acme Ad Exchange"], "debug": true } }
```

**Filtering Parameters:**
- `publisherDomain` (optional): Filter by publisher domain (exact domain component match)
- `pricingModel` (optional): Filter by pricing model (`cpm`, `vcpm`, `cpc`, `cpcv`, `cpv`, `cpp`, `flat_rate`)
- `salesAgentIds` (optional, array): Filter by exact sales agent ID(s)
- `salesAgentNames` (optional, array): Filter by sales agent name(s) (case-insensitive substring match)

**Debug Parameter:**
- `debug` (optional, boolean): When `true`, includes detailed ADCP agent request/response debug logs in the response. Returns an `agentResults` array with per-agent success/failure status, raw response data, and full HTTP request/response logs (authorization headers redacted). Same structure as v1 `media_product_discover` debug output.

#### Discover Products for Existing Session

**Operation:** `browse_discovery`
```http
GET /api/v2/buyer/discovery/{discoveryId}/discover-products?groupLimit=10&groupOffset=0&productsPerGroup=15
```
**As operation:**
```json
{ "operation": "browse_discovery", "pathParams": { "discoveryId": "<id>" }, "params": { "groupLimit": "10", "groupOffset": "0", "productsPerGroup": "15" } }
```

**Query Parameters (Pagination):**
- `groupLimit` (optional): Max product groups (default: 10, max: 10)
- `groupOffset` (optional): Groups to skip (default: 0)
- `productsPerGroup` (optional): Max products per group (default: 10, max: 15)
- `productOffset` (optional): Products to skip within each group (default: 0)

**Query Parameters (Filtering):**
- `publisherDomain` (optional): Filter by publisher domain (exact component match). "hulu" matches "hulu.com" but "hul" does not
- `pricingModel` (optional): Filter by pricing model (`cpm`, `vcpm`, `cpc`, `cpcv`, `cpv`, `cpp`, `flat_rate`)
- `salesAgentIds` (optional, comma-separated): Filter by sales agent ID(s)
- `salesAgentNames` (optional, comma-separated): Filter by sales agent name(s) (case-insensitive substring match)
- `debug` (optional): When `true`, includes ADCP agent request/response debug logs in the response (see debug section below)

Filters can be combined. Example: `?publisherDomain=example&pricingModel=cpm&salesAgentNames=Acme Ad Exchange`

**Response:**
```json
{
  "discoveryId": "abc123-def456-ghi789",
  "productGroups": [
    {
      "groupId": "group-0",
      "groupName": "Publisher Name",
      "products": [
        {
          "productId": "product_123",
          "name": "Premium CTV Inventory",
          "channel": "ctv",
          "bidPrice": 12.50,
          "salesAgentId": "agent_456",
          "publisherProperties": [
            { "publisherDomain": "hulu.com", "selectionType": "all" },
            { "publisherDomain": "espn.com", "selectionType": "by_id" }
          ]
        }
      ],
      "productCount": 5,
      "totalProducts": 20,
      "hasMoreProducts": true
    }
  ],
  "totalGroups": 25,
  "hasMoreGroups": true,
  "summary": {
    "totalProducts": 150,
    "publishersCount": 25,
    "priceRange": { "min": 5.0, "max": 25.0, "avg": 12.5 }
  },
  "budgetContext": {
    "sessionBudget": 50000,
    "allocatedBudget": 0,
    "remainingBudget": 50000
  }
}
```

**Product fields:**
- `publisherProperties` (array, optional): Publisher domains and targeting details for this product. Each product can have multiple publishers. Each entry contains `publisherDomain` (string) and `selectionType` (`"all"` or `"by_id"`). Use this to understand which publishers a product targets.

**Debug response** (when `debug: true`):

The response includes an `agentResults` array containing only failed agents with full ADCP request/response logs for troubleshooting:
```json
{
  "agentResults": [
    {
      "agentId": "agent_789",
      "agentName": "Failed Agent",
      "success": false,
      "productCount": 0,
      "error": "Connection timeout",
      "rawResponseData": { "..." },
      "debugLogs": [
        {
          "timestamp": "2026-03-18T10:00:00Z",
          "type": "request",
          "request": { "method": "POST", "url": "...", "headers": { "authorization": "[REDACTED]" }, "body": { "..." } },
          "response": { "status": 500, "body": { "..." } }
        }
      ]
    }
  ]
}
```

**Pagination:**
- `hasMoreGroups`: Use `groupOffset` to fetch more groups
- `hasMoreProducts`: Use `productOffset` to fetch more products within a group
- To paginate products for a single group, combine `productOffset` with a filter (`salesAgentIds` or `salesAgentNames`) to isolate that group

#### Add Products to Selection

**Operation:** `add_discovery_products`
```http
POST /api/v2/buyer/discovery/{discoveryId}/products
{
  "products": [
    {
      "productId": "product_123",
      "salesAgentId": "agent_456",
      "groupId": "ctx_123-group-0",
      "groupName": "Publisher Name",
      "bidPrice": 12.50,
      "budget": 5000
    }
  ]
}
```
**As operation:**
```json
{ "operation": "add_discovery_products", "pathParams": { "discoveryId": "<id>" }, "body": { "products": [{ "productId": "product_123", "salesAgentId": "agent_456", "groupId": "ctx_123-group-0", "groupName": "Publisher Name", "bidPrice": 12.50, "budget": 5000 }] } }
```

**Required per product:** `productId`, `salesAgentId`, `groupId`, `groupName`
**Optional per product:** `bidPrice` (required when `isFixed: false`), `budget`

**Response:**
```json
{
  "discoveryId": "abc123-def456-ghi789",
  "products": [
    {
      "productId": "product_123",
      "salesAgentId": "agent_456",
      "bidPrice": 12.50,
      "budget": 5000,
      "selectedAt": "2025-02-01T10:00:00Z",
      "groupId": "ctx_123-group-0",
      "groupName": "Publisher Name"
    }
  ],
  "totalProducts": 1,
  "budgetContext": {
    "sessionBudget": 50000,
    "allocatedBudget": 5000,
    "remainingBudget": 45000
  }
}
```

#### Get Selected Products

**Operation:** `list_discovery_products`
```http
GET /api/v2/buyer/discovery/{discoveryId}/products
```
**As operation:**
```json
{ "operation": "list_discovery_products", "pathParams": { "discoveryId": "<id>" } }
```

Response format same as Add Products.

#### Remove Products from Selection

**Operation:** `remove_discovery_products`
```http
DELETE /api/v2/buyer/discovery/{discoveryId}/products
{
  "productIds": ["product_123", "product_456"]
}
```
**As operation:**
```json
{ "operation": "remove_discovery_products", "pathParams": { "discoveryId": "<id>" }, "body": { "productIds": ["product_123", "product_456"] } }
```

Response format same as Add Products (with updated list).

#### Refine Discovery Results

Iterate on previous discovery results by calling `POST /discovery/discover-products` with the `discoveryId` and a `refine` array. Supports three refinement scopes:
- **request**: Direction for the overall discovery (e.g., "more video options", "focus on sports content")
- **product**: Target a specific product — `include`, `omit`, or `more_like_this`
- **proposal**: Target a specific proposal — `include`, `omit`, or `finalize`

Requires a previous `POST /discovery/discover-products` call (results must be cached). The `discoveryId` field is required when `refine` is provided.

```http
POST /api/v2/buyer/discovery/discover-products
{
  "advertiserId": "12345",
  "discoveryId": "abc123-def456-ghi789",
  "refine": [
    { "scope": "request", "ask": "show me more video options" },
    { "scope": "product", "id": "product_123", "action": "more_like_this" },
    { "scope": "product", "id": "product_456", "action": "omit" },
    { "scope": "proposal", "id": "proposal_789", "action": "include", "ask": "increase budget allocation" }
  ],
  "groupLimit": 10,
  "productsPerGroup": 10,
  "debug": false
}
```

**Refine item fields:**
- `scope` (required): `"request"`, `"product"`, or `"proposal"`
- `ask` (required for request-scope, optional for product/proposal): Natural language direction
- `id` (required for product/proposal-scope): The product or proposal ID from the previous discovery response
- `action` (required for product-scope): `"include"`, `"omit"`, or `"more_like_this"`
- `action` (required for proposal-scope): `"include"`, `"omit"`, or `"finalize"`

**Pagination parameters:** Same as browse (`groupLimit`, `productsPerGroup`, `groupOffset`, `productOffset`).

**Response:** Same structure as discover-products, plus an optional `refinementApplied` array showing what the sales agents did with each refinement instruction:
```json
{
  "discoveryId": "abc123-def456-ghi789",
  "productGroups": [ ... ],
  "totalGroups": 10,
  "hasMoreGroups": false,
  "summary": { ... },
  "refinementApplied": [
    { "scope": "request", "status": "applied", "notes": "Added video content filter" },
    { "scope": "product", "id": "product_456", "status": "applied", "notes": "Removed from results" }
  ]
}
```

**Workflow:** Call discover-products with refine, present updated results, let the user iterate or proceed to select products. Each refine call replaces the cached results, so subsequent browse/pagination operates on the refined set.

---

### Event Sources

Conversion data pipelines (website pixels, mobile SDKs, etc.) registered at the advertiser level. Referenced by `eventSourceId` in campaign optimization goals.

Event sources can be managed through sync (bulk upsert following the ADCP spec) or through individual CRUD operations.

#### Sync Event Sources

Sync is the preferred way to manage event sources — it uses upsert semantics (creates or updates as needed).

**Operation:** `sync_event_sources`
```http
POST /api/v2/buyer/advertisers/26/event-sources/sync
{
  "account": { "account_id": "26" },
  "event_sources": [
    {
      "event_source_id": "website_pixel",
      "name": "Website Pixel",
      "event_types": ["purchase", "add_to_cart"],
      "allowed_domains": ["shop.example.com"]
    },
    {
      "event_source_id": "mobile_sdk",
      "name": "Mobile App SDK",
      "event_types": ["app_install", "purchase"]
    }
  ],
  "delete_missing": false
}
```

**URL path:** `/advertisers/{advertiserId}/event-sources/sync` — the `{advertiserId}` is the numeric advertiser ID (e.g. `26`). Also include it in the request body as `account.account_id`.

**`account` (required in body):**
- `account_id` (string): The advertiser ID — same value as the path `{advertiserId}` (e.g. `"26"`).

**`event_sources` array (required, 1–50 items). Each object:**
- `event_source_id` (string, required): Buyer-assigned identifier, referenced by optimization goals
- `name` (string, optional): Human-readable label
- `event_types` (array, optional): IAB ECAPI event types this source handles. When omitted, accepts all types. Values: `purchase`, `lead`, `add_to_cart`, `complete_registration`, `subscribe`, `app_install`, `start_trial`, `search`, `add_to_wishlist`, `view_content`, `initiate_checkout`, `add_payment_info`, `share`, `donate`, `find_location`, `schedule`, `contact`, `customize_product`, `submit_application`, `login`, `page_view`, `complete_tutorial`, `achieve_level`, `unlock_achievement`, `spend_credits`, `rate`, `download`, `custom`
- `allowed_domains` (array, optional): Domains authorized to send events

**Other optional fields:**
- `delete_missing` (boolean): Archive event sources not included in this request (default: false)

**Response (200):**
```json
{
  "data": {
    "event_sources": [
      { "event_source_id": "website_pixel", "action": "created" },
      { "event_source_id": "mobile_sdk", "action": "updated" }
    ]
  }
}
```

Actions: `created`, `updated`, `unchanged`, `failed`, `deleted`

#### List Event Sources

**Operation:** `list_event_sources`
```http
GET /api/v2/buyer/advertisers/{advertiserId}/event-sources
```
**As operation:**
```json
{ "operation": "list_event_sources", "pathParams": { "advertiserId": "<id>" } }
```

**Query Parameters:**
- `take` / `skip` (optional): Pagination

#### Create / Update / Delete Event Sources

There is no per-source REST mutation. Use **`POST .../event-sources/sync`** (documented above) — it accepts an array of event sources and treats each as upsert-by-`event_source_id`. Pass `delete_missing: true` to archive sources omitted from the request.

---

### Event Summary

Get hourly-aggregated event counts for an advertiser. Use this to verify that events (impressions, clicks, conversions, etc.) are being ingested before setting up optimization goals.

**Important:** Event data is aggregated hourly. Newly reported events may take up to 1 hour to appear in this summary. If the user has just started reporting events, let them know to wait before checking.

#### Get Event Summary

**Operation:** `get_event_summary`
```http
GET /api/v2/buyer/advertisers/{advertiserId}/events/summary
```
**As operation:**
```json
{ "operation": "get_event_summary", "pathParams": { "advertiserId": "<id>" } }
```

**Query Parameters:**
- `eventType` (string, optional): Filter by event type — one of `impression`, `click`, `conversion`, `measurement`, `mmp`. When omitted, returns all types.
- `startHour` (string, optional): Start of query range (inclusive), hour-aligned ISO 8601 (e.g. `2026-03-27T14:00:00Z`). Defaults to start of last completed UTC hour.
- `endHour` (string, optional): End of query range (exclusive), hour-aligned ISO 8601. Defaults to end of last completed UTC hour.

**Response (200):**
```json
{
  "data": {
    "periodStart": "2026-03-27T14:00:00.000Z",
    "periodEnd": "2026-03-27T15:00:00.000Z",
    "entries": [
      {
        "eventHour": "2026-03-27T14:00:00.000Z",
        "eventType": "impression",
        "eventCount": 1500
      },
      {
        "eventHour": "2026-03-27T14:00:00.000Z",
        "eventType": "conversion",
        "eventCount": 25
      }
    ],
    "totalEventCount": 1525
  }
}
```

The response includes both advertiser-specific events and customer-level shared events (events not tied to a specific advertiser but shared across all advertisers under the same customer).

---

### Log Event

Log conversion and marketing events for attribution. Events are forwarded to the tracking endpoint (CAPI). Requires an event source registered via sync_event_sources.

#### Log Events

**Operation:** `log_event`
```http
POST /api/v2/buyer/advertisers/{advertiserId}/log-event
{
  "event_source_id": "website_pixel",
  "events": [
    {
      "event_id": "txn_abc123",
      "event_type": "purchase",
      "event_time": "2026-03-15T14:30:00-05:00",
      "action_source": "website",
      "event_source_url": "https://example.com/checkout",
      "user_match": {
        "hashed_email": "a1b2c3d4e5f6...",
        "click_id": "abc123",
        "click_id_type": "gclid"
      },
      "custom_data": {
        "value": 99.99,
        "currency": "USD",
        "order_id": "order_456",
        "content_ids": ["prod_789"],
        "num_items": 2
      }
    }
  ],
  "test_event_code": "TEST123"
}
```

**Request body:**
- `event_source_id` (string, required): Event source registered via sync_event_sources
- `events` (array, required, 1–10,000 items): Events to log
- `test_event_code` (string, optional): Test code for validation without affecting production data

**Each event object:**
- `event_id` (string, required): Unique identifier for deduplication
- `event_type` (enum, required): `purchase`, `lead`, `add_to_cart`, `initiate_checkout`, `view_content`, `complete_registration`, `page_view`, `app_install`, `deposit`, `subscription`, `custom`
- `event_time` (string, required): When the event occurred (ISO 8601 with timezone)
- `action_source` (enum, optional): `website`, `app`, `in_store`, `phone_call`, `system_generated`, `other`
- `event_source_url` (string, optional): URL where the event occurred
- `custom_event_name` (string, optional): Name for custom events (when event_type is `custom`)
- `user_match` (object, optional): User identity for attribution matching
  - `uids` (array): Universal ID values (`{type, value}` — rampid, id5, uid2, euid, pairid, maid)
  - `hashed_email`: SHA-256 of lowercase trimmed email
  - `hashed_phone`: SHA-256 of E.164 phone number
  - `click_id` / `click_id_type`: Platform click identifier
  - `client_ip` / `client_user_agent`: For probabilistic matching
- `custom_data` (object, optional): Event-specific data
  - `value`: Monetary value
  - `currency`: ISO 4217 code (e.g. `USD`)
  - `order_id`: Transaction identifier
  - `content_ids`: Product identifiers
  - `content_type`: Category (product, service, etc.)
  - `num_items`: Item count
  - `contents`: Array of `{id, quantity, price, brand}`

**Response (200):**
```json
{
  "data": {
    "events_received": 1,
    "events_processed": 1,
    "partial_failures": [],
    "warnings": [],
    "match_quality": 0.85
  }
}
```

---

### Measurement Data

Sync advertiser performance measurement data as an alternative to CAPI. Accepts time-series metric data over date ranges keyed by campaign, media buy, package, and/or creative. Uses upsert semantics — re-submitting the same data is safe and idempotent.

#### Sync Measurement Data

**Operation:** `sync_measurement_data`
```http
POST /api/v2/buyer/advertisers/26/measurement-data/sync
{
  "measurements": [
    {
      "start_time": "2026-03-01T00:00:00-05:00",
      "end_time": "2026-03-07T23:59:59-05:00",
      "metric_id": "incremental_revenue",
      "metric_value": 8450.75,
      "unit": "currency",
      "currency": "USD",
      "campaign_id": "camp_456"
    }
  ]
}
```

**URL path:** `/advertisers/{advertiserId}/measurement-data/sync` — the `{advertiserId}` is the numeric advertiser ID (e.g. `26`).

**`measurements` array (required, 1–1000 items). Each object:**
- `start_time` (string, required): Start of the measurement period (ISO 8601 with timezone)
- `end_time` (string, required): End of the measurement period (ISO 8601 with timezone, must be after start_time)
- `metric_id` (enum, required): `revenue`, `incremental_revenue`, `conversions`, `incremental_conversions`, `page_view_count`, `add_to_cart_count`, `purchase_count`, `ltv_1d`, `ltv_7d`, `ltv_30d`
- `metric_value` (number, required): Measured value for this metric
- `unit` (enum, required): `currency`, `count`, `ratio`, `percentage`
- `currency` (string, conditional): 3-letter uppercase ISO 4217 code (e.g. `"USD"`) — required when `unit` is `"currency"`
- `advertiser_id` (string, optional): Advertiser identifier
- `campaign_id` (string, optional): Campaign identifier
- `media_buy_id` (string, optional): Media buy identifier
- `package_id` (string, optional): Package identifier
- `creative_id` (string, optional): Creative identifier
- `source` (string, optional): Source of the measurement data
- `source_platform` (string, optional): Platform the data originates from
- `external_row_id` (string, optional): External row identifier for idempotency

**Constraint:** At least one of `advertiser_id`, `campaign_id`, `media_buy_id`, `package_id`, or `creative_id` must be provided.

**Response (200):**
```json
{
  "data": {
    "measurements": [
      { "index": 0, "action": "created" }
    ]
  }
}
```

Actions: `created`, `updated`, `unchanged`, `failed`

---

### Measurement Engine

Configure measurement sources, upload measurement and context records, and check freshness of incoming outcome data.

#### List Measurement Sources

**Operation:** `list_measurement_sources`
```http
GET /api/v2/buyer/advertisers/{advertiserId}/measurement-sources
```
**As operation:**
```json
{ "operation": "list_measurement_sources", "pathParams": { "advertiserId": "<id>" } }
```

Optional query params: `outcomeType`, `status`, `take`, `skip`

#### Create Measurement Source

**Operation:** `create_measurement_source`
```http
POST /api/v2/buyer/advertisers/{advertiserId}/measurement-sources
{
  "sourceKey": "mmm_sales",
  "name": "MMM Sales Lift",
  "outcomeType": "sales_volume",
  "granularity": "dma_week",
  "cadence": "weekly",
  "provider": "analytic-partner"
}
```
**As operation:**
```json
{ "operation": "create_measurement_source", "pathParams": { "advertiserId": "<id>" }, "body": { "sourceKey": "mmm_sales", "name": "MMM Sales Lift", "outcomeType": "sales_volume", "granularity": "dma_week", "cadence": "weekly", "provider": "analytic-partner" } }
```

Required: `sourceKey`, `name`, `outcomeType`, `granularity`, `cadence` (continuous, daily, weekly, biweekly, monthly, quarterly), `provider`

Optional: `lagWeeks` (default 1), `ingestionMethod`, `attributionConfig`, `signalWeight` (0-1, default 1.0), `status` (pending, active, paused), `notes`

#### Upload Measurement Records

**Operation:** `upload_measurement_records`
```http
POST /api/v2/buyer/advertisers/{advertiserId}/measurement-records
{
  "records": [
    {
      "outcomeType": "sales_volume",
      "geo": "Atlanta",
      "timeWindowStart": "2026-01-06",
      "timeWindowEnd": "2026-01-12",
      "value": 1250,
      "source": "mmm_sales"
    }
  ]
}
```
**As operation:**
```json
{ "operation": "upload_measurement_records", "pathParams": { "advertiserId": "<id>" }, "body": { "records": [{ "outcomeType": "sales_volume", "geo": "Atlanta", "timeWindowStart": "2026-01-06", "timeWindowEnd": "2026-01-12", "value": 1250, "source": "mmm_sales" }] } }
```

Each record requires: `outcomeType`, `geo`, `timeWindowStart` (YYYY-MM-DD), `timeWindowEnd` (YYYY-MM-DD), `value`, `source`

Optional per record: `baselineValue`, `confidenceInterval`, `lagDays`

Accepts 1-5000 records per request. Uses upsert semantics (safe to re-submit).

#### List Measurement Records

**Operation:** `list_measurement_records`
```http
GET /api/v2/buyer/advertisers/{advertiserId}/measurement-records
```

Optional query params: `outcomeType`, `geo`

#### Upload Context Records

**Operation:** `upload_context_records`
```http
POST /api/v2/buyer/advertisers/{advertiserId}/context-records
{
  "records": [
    {
      "geo": "Atlanta",
      "timeWindowStart": "2026-01-06",
      "timeWindowEnd": "2026-01-12",
      "promoActive": true,
      "promoType": "BOGO"
    }
  ]
}
```

Each record requires: `geo`, `timeWindowStart`, `timeWindowEnd`

Optional: `promoActive` (default false), `promoType`, `temperatureAvg`, `competitorActivity`, `seasonalityIndex`, `flightStatus` (active, dark, pre_flight, post_flight)

#### Get Measurement Freshness

**Operation:** `get_measurement_freshness`
```http
GET /api/v2/buyer/advertisers/{advertiserId}/measurement-freshness?flightStart=2026-01-06&geos=Atlanta,Richmond
```

Required query params: `flightStart` (YYYY-MM-DD), `geos` (comma-separated)

Optional: `flightEnd` (YYYY-MM-DD)

Returns per-source, per-geo freshness windows showing which time periods have data and which are missing.

---

### Test Cohorts

For A/B testing campaign variations.

#### List Test Cohorts

**Operation:** `list_test_cohorts`
```http
GET /api/v2/buyer/advertisers/{advertiserId}/test-cohorts
```
**As operation:**
```json
{ "operation": "list_test_cohorts", "pathParams": { "advertiserId": "<id>" } }
```

#### Create Test Cohort

**Operation:** `create_test_cohort`
```http
POST /api/v2/buyer/advertisers/{advertiserId}/test-cohorts
{
  "name": "Q1 Creative Test",
  "description": "Testing new vs old creatives",
  "splitPercentage": 50
}
```
**As operation:**
```json
{ "operation": "create_test_cohort", "pathParams": { "advertiserId": "<id>" }, "body": { "name": "Q1 Creative Test", "description": "Testing new vs old creatives", "splitPercentage": 50 } }
```

---

### Creative Manifests (Campaign-Scoped)

Creative manifests live under campaigns — they are always scoped to a specific campaign.

**Dashboard URL**: Call `GET /api/v2/buyer/creative-dashboard-url?advertiserId={advertiserId}&campaignId={campaignId}` to get a fully-resolved creative dashboard URL. The response contains a single `url` field — use it directly. Never construct URLs manually. The URL varies by environment and includes the customer ID automatically.

---

#### ⚠️⚠️⚠️ ABSOLUTE RULE: Creative Asset Creation & Upload = UI ONLY ⚠️⚠️⚠️

**There is NO MCP tool or API call for creating manifests or uploading assets. These operations DO NOT EXIST in the MCP interface.**

When a buyer asks to **add**, **upload**, **create**, or **manage** creative assets, follow these steps **exactly**:

**Step 1:** Look up the advertiser ID and campaign ID using the **existing** list endpoints:
- `list_advertisers` operation — find the advertiser by name
- `list_campaigns` operation (with `advertiserId` param) — find the campaign by name

**Step 2:** Get the full creative dashboard URL:
```
GET /api/v2/buyer/creative-dashboard-url?advertiserId={advertiserId}&campaignId={campaignId}
```
Returns: `{ "url": "<the fully-resolved creative dashboard URL>" }`

**Step 3:** Return the URL from the response directly.

**That is your ENTIRE response. Nothing else.**

**Examples:**

Buyer: "I want to add some creative assets for my Q2 campaign"
→ Use `list_advertisers` operation → find advertiser ID
→ Use `list_campaigns` operation with `advertiserId=24` → find "Q2" campaign ID
→ Call `GET /api/v2/buyer/creative-dashboard-url?advertiserId=24&campaignId=campaign_abc123` → get `url`
→ Reply: "You can manage your creative assets here: {url from response}"

Buyer: "For Ematini | Q2 Sales Boost I want to add some creative assets"
→ Use `list_advertisers` operation → find Ematini (ID 24)
→ Use `list_campaigns` operation with `advertiserId=24` → find Q2 Sales Boost campaign
→ Call `GET /api/v2/buyer/creative-dashboard-url?advertiserId=24&campaignId=campaign_1770084484376_r7pspq` → get `url`
→ Reply: "You can manage your creative assets here: {url from response}"

**DO NOT:**
- Ask what files they have
- Ask about briefs, URLs, or tracking pixels
- Mention any API endpoint to the buyer
- Explain what asset types are supported
- Offer to create a manifest via API (this is impossible via MCP)
- Mention `POST .../creatives/create` (that is a REST-only endpoint used by the UI, not available via MCP)
- Use `ask_about_capability` — just call the endpoints above directly
- Construct or hardcode dashboard URLs manually — always use the endpoint

---

#### What IS Available via MCP (READ-ONLY + metadata updates on EXISTING manifests)

**REMINDER: You CANNOT create new manifests via MCP. If the buyer asks to "add", "create", or "upload" creatives, you MUST direct them to the UI link (see above). The operations below ONLY work on manifests that already exist.**

The MCP interface exposes these operations for creative manifests that were **already created in the UI**:

**Note:** Template detection and format matching happen automatically when assets are uploaded via the UI. The response includes `auto_detected_template` with the detected `template_id` and detection `method`. There is no need to call a separate templates endpoint.

##### 1. List Creative Manifests

**Operation:** `list_creatives`
```
GET /api/v2/buyer/campaigns/{campaignId}/creatives
```
**Query parameters:**
- `quality` (optional): Filter by quality level
- `search` (optional): Case-insensitive name search
- `take` (optional): Page size, default 50
- `skip` (optional): Pagination offset

**Response:** `{ "manifests": [...], "total": <number> }`. Each manifest is the **summary** shape — `creative_id`, `campaign_id`, `name`, `template_id`, `format_id`, `brand_domain`, `preview_url`, `asset_count`, `sync_status` (`{ synced, agent_count }`), `created_at`, `updated_at`. `assets[]`, `html_processing`, `creative_manifest`, `tracking`, `frequencyCaps[]`, `message`, `target_format_ids[]`, `format_previews[]`, and `auto_detected_template` are NOT on the summary — call `get_creative` for the full manifest.

##### 2. Get Creative Manifest

**Operation:** `get_creative`
```
GET /api/v2/buyer/campaigns/{campaignId}/creatives/{creativeId}
```
Optional query: `?preview=true`

**Response:** Single manifest with all fields including `preview_url`, `format_previews[]`, `auto_detected_template`, `html_processing` (macros injected, unresolved refs), and assets array.

##### 3. Update Creative Manifest (metadata only — NO file uploads)

**Operation:** `update_creative`
```
PUT /api/v2/buyer/campaigns/{campaignId}/creatives/{creativeId}
```
**Body (all fields optional):**
- `name` (string): Manifest name
- `message` (string): Creative brief
- `tag` (string): Tag
- `quality` (string): Quality level
- `format_id` (object): `{ agent_url: string, id: string }` — ADCP format ID
- `template_id` (string): ADCP format template ID (e.g. `"display_300x250_html"`, `"video_standard"`, `"vendor_dcm_tag"`)
- `url_asset` (object): `{ url: string, url_type: string }` — add a URL-based asset
- `delete_asset_ids` (string[]): Asset IDs to soft-delete
- `reclassify_assets` (array): `[{ asset_id: string, asset_type: string }]` — change asset type
- `frequencyCaps` (array): Buyer-side frequency caps scoped to this creative. Array of `{ max_impressions, window: { interval, unit } }`. **Full-replace semantics**: omit to leave existing caps unchanged, pass `[]` to clear, pass the full array to replace. See the Frequency Caps section below.

**Note:** File uploads are NOT possible via MCP. The MCP update only handles metadata changes, URL assets, and asset deletion/reclassification. For file uploads, direct the buyer to the UI.

##### 4. Delete Creative Manifest

**Operation:** `delete_creative`
```
DELETE /api/v2/buyer/campaigns/{campaignId}/creatives/{creativeId}
```
Soft-deletes the manifest and all its assets (sets `archived_at`).

**Response:** `204 No Content`

**⚠️ FINAL REMINDER: None of the above operations CREATE a manifest. If the buyer wants to ADD or CREATE creatives, call `GET /api/v2/buyer/creative-dashboard-url?advertiserId={advertiserId}&campaignId={campaignId}` and return the URL from the response. Do NOT ask follow-up questions about file types, URLs, briefs, or tags.**

---

#### Display Requirements — When listing manifests (`list_creatives`):
- **Name** and **Creative ID**
- **Template** and **Format ID** (if set) — these are ADCP format IDs (e.g. `display_300x250_html`, `video_standard`)
- **Asset count** — show `asset_count`
- **Sync status** — show `sync_status.synced` and `sync_status.agent_count`
- **Created/Updated timestamps**

For per-asset details (filename, type, `asset_source`, URL), `auto_detected_template`, `message`, `tracking`, `html_processing`, or `frequencyCaps`, call `get_creative`.

---

### Reporting

#### Get Reporting Metrics

**Operation:** `get_reporting_metrics`
```http
GET /api/v2/buyer/reporting/metrics?view=summary&days=7&advertiserId=12345&campaignId=campaign_abc
```
**As operation:**
```json
{ "operation": "get_reporting_metrics", "params": { "view": "summary", "days": "7", "advertiserId": "12345", "campaignId": "campaign_abc" } }
```

Returns reporting data in one of two views: **summary** (hierarchical breakdown) or **timeseries** (flat rows broken out by hierarchy × day).

**🛑 REQUIRED QUESTIONS — ASK THESE BEFORE CALLING**

This endpoint is SERVER-ENFORCED. If you call `get_reporting_metrics` missing any required param below, the server returns a 400 error telling you to ask the user. Do NOT guess defaults.

Ask the user these two questions before calling (unless they already answered both in their message):

1. **Date range** — always required. Which period? (e.g. "last 7 days", "last 30 days", or specific `startDate`/`endDate` in YYYY-MM-DD). Pass as `params.startDate` + `params.endDate` OR `params.days`.
2. **How do they want it broken down?** — one of:
   - **by date** → pass `params.view="timeseries"` (no `breakdown` param needed — timeseries IS the date breakdown)
   - **by campaign** → pass `params.breakdown="campaign"` (view stays `summary`, the default)
   - **by media buy** → pass `params.breakdown="mediaBuy"`
   - **by ad product / package / product** → pass `params.breakdown="package"`

Only skip the questions when the user has already specified both in their request (e.g. "get reporting for last 7 days by media buy", "show reporting by date for the last 30 days"). If only one is given, ask for the other.

Validation summary:
- date range missing → error
- `view="summary"` without `breakdown` → error
- `view="timeseries"` without `breakdown` → OK (timeseries is inherently date-grouped)
- `view="timeseries"` with `download=false` (default): the response is capped to the most recent 7 days regardless of `days`/`startDate`. For longer timeseries ranges, the caller must pass `download=true` to get the full range as a CSV. `view="summary"` has no cap — use summary for aggregated views over longer ranges.

`breakdown` does not affect the data returned — the summary response always includes the full advertiser→campaign→media-buy→package hierarchy. It's a UI hint that sets the default group-by in the rendered MCP App view. The server strips it before dispatching to the underlying service.

**Query Parameters:**
- `view` (optional): Response format — `summary` (default) or `timeseries`
  - `summary`: Hierarchical breakdown by advertiser → campaign → media buy → package
  - `timeseries`: Flat rows — one entry per (advertiser → campaign → media buy → package) × day. Same hierarchy fields as `summary` plus a `date` column.
- `days` (optional): Number of days to include (default: 7, max: 90). Ignored if both startDate and endDate are provided
- `startDate` (optional): Start date in ISO format (YYYY-MM-DD)
- `endDate` (optional): End date in ISO format (YYYY-MM-DD)
- `advertiserId` (optional): Filter by advertiser ID
- `campaignId` (optional): Filter by campaign ID
- `demo` (optional, boolean): When `true`, returns auto-generated demo data instead of querying real data sources. Default: `false`. Useful for testing and previewing the reporting UI without live campaign data.

**⚠️ CRITICAL: Disambiguating "demo" in user requests**

The word "demo" can mean two different things in reporting requests. You MUST distinguish between them:

| User intent | Example phrases | Action |
|-------------|----------------|--------|
| **Demo flag** (synthetic demo data) | "show reporting (demo)", "demo show reporting", "show reporting with demo flag", "show reporting demo mode" | Set `demo=true` query parameter |
| **Name filter** (advertiser/campaign containing "demo") | "show reporting for demo advertiser", "show reporting for % demo %", "show campaigns named demo", "reporting for 'demo brand'" | Use `advertiserId` or `campaignId` filters to match entities whose names contain "demo" — do NOT set `demo=true` |

**How to tell the difference:**
- If "demo" appears as a **modifier or flag on the reporting request itself** (in parentheses, as "demo mode", "demo flag", or as a standalone qualifier adjacent to "reporting"), the user wants `demo=true`.
- If "demo" appears as a **value describing an advertiser, campaign, or entity name** (preceded by "for", "named", "called", or wrapped in quotes/wildcards), the user is filtering by name — do NOT set `demo=true`.

**Summary Response** (`view=summary`, default):
```json
{
  "advertisers": [
    {
      "advertiserId": "12345",
      "advertiserName": "Acme Corp",
      "metrics": { "impressions": 15000, "spend": 750, "clicks": 300, "views": 1500, "completedViews": 1200, "conversions": 75, "leads": 30, "videoCompletions": 1125, "ecpm": 50, "cpc": 2.5, "ctr": 0.02, "completionRate": 0.8 },
      "campaigns": [
        {
          "campaignId": "campaign_abc",
          "campaignName": "Summer Campaign",
          "metrics": { "..." : "..." },
          "mediaBuys": [
            {
              "mediaBuyId": "mb_1",
              "name": "Media Buy One",
              "status": "ACTIVE",
              "metrics": { "..." : "..." },
              "packages": [
                { "packageId": "pkg_1", "metrics": { "..." : "..." } }
              ]
            }
          ]
        }
      ]
    }
  ],
  "totals": { "impressions": 35000, "spend": 1750 },
  "periodStart": "2025-01-01",
  "periodEnd": "2025-01-31"
}
```

**Timeseries Response** (`view=timeseries`):

Flat rows — one entry per (advertiser → campaign → media buy → package) × day. Same hierarchy fields as the summary view, plus a `date` column. When a media buy has no packages, `packageId`, `productId`, and `productName` are empty strings.

```json
{
  "timeseries": [
    {
      "date": "2025-01-01",
      "advertiserId": "12345",
      "advertiserName": "Acme Corp",
      "campaignId": "campaign_abc",
      "campaignName": "Summer Campaign",
      "mediaBuyId": "mb_1",
      "mediaBuyName": "Media Buy One",
      "mediaBuyStatus": "ACTIVE",
      "packageId": "pkg_1",
      "productId": "prod_a",
      "productName": "Product A",
      "metrics": { "impressions": 5000, "spend": 250, "clicks": 100, "views": 500, "completedViews": 400, "conversions": 25, "leads": 10, "videoCompletions": 375, "ecpm": 50, "cpc": 2.5, "ctr": 0.02, "completionRate": 0.8 }
    },
    {
      "date": "2025-01-01",
      "advertiserId": "12345",
      "advertiserName": "Acme Corp",
      "campaignId": "campaign_abc",
      "campaignName": "Summer Campaign",
      "mediaBuyId": "mb_1",
      "mediaBuyName": "Media Buy One",
      "mediaBuyStatus": "ACTIVE",
      "packageId": "pkg_2",
      "productId": "prod_b",
      "productName": "Product B",
      "metrics": { "..." : "..." }
    }
  ],
  "totals": { "impressions": 35000, "spend": 1750 },
  "periodStart": "2025-01-01",
  "periodEnd": "2025-01-07"
}
```

**Metrics included:** impressions, spend, clicks, views, completedViews, conversions, leads, videoCompletions, ecpm, cpc, ctr, completionRate

#### Export / Download Reporting Metrics as CSV

To export or download reporting data as a CSV file, use the same reporting metrics endpoint with `?download=true`. This generates a CSV and returns a signed download URL (valid 7 days) instead of JSON data.

**IMPORTANT:** When a user asks to "export", "download", "save as CSV", or "get a spreadsheet" of their reporting data, use this endpoint with `download=true`.

**Operation:** `get_reporting_metrics`
```http
GET /api/v2/buyer/reporting/metrics?days=30&download=true
```
**As operation:**
```json
{ "operation": "get_reporting_metrics", "params": { "days": "30", "download": "true" } }
```

All the same query parameters apply (`days`, `startDate`, `endDate`, `advertiserId`, `campaignId`). The only addition is `download=true`.

**Response when `download=true`:**
```json
{
  "downloadUrl": "https://storage.googleapis.com/...",
  "expiresAt": "2025-02-20T12:00:00.000Z",
  "fileName": "reporting-metrics-2025-02-06-to-2025-02-13.csv",
  "rowCount": 42
}
```

**CSV columns (20):** Advertiser ID, Advertiser Name, Campaign ID, Campaign Name, Media Buy ID, Media Buy Name, Media Buy Status, Package ID, Impressions, Spend, Clicks, Views, Completed Views, Conversions, Leads, Video Completions, eCPM, CPC, CTR, Completion Rate. One row per package (media buys with no packages get one row with empty Package ID).

**CRITICAL: NEVER generate your own CSV, Excel, or spreadsheet files.** Always use this `?download=true` endpoint to produce reporting exports. The endpoint handles proper formatting, escaping, and data integrity. Do not use artifacts, code execution, or any other mechanism to create files — use the API.

When the download response is received, present the `downloadUrl` to the user as a clickable download link. Include the `fileName` and note that the link expires after 7 days (`expiresAt`).

---

### Catalogs

Catalogs are managed entirely through sync — there is no separate create/update/delete.

**Before calling sync, you MUST collect from the user:**
1. Which advertiser — the advertiser ID goes in the URL path AND in `account.account_id` in the request body
2. The catalog(s) to sync — each needs a `catalog_id`, `type`, and either a feed `url` or inline `items`

#### Sync Catalogs

**Option A — Remote feed URL (multiple catalogs in one call):**

**Operation:** `sync_catalogs`
```http
POST /api/v2/buyer/advertisers/26/catalogs/sync
{
  "account": { "account_id": "26" },
  "catalogs": [
    {
      "catalog_id": "products-2026",
      "type": "product",
      "name": "2026 Product Catalog",
      "url": "https://example.com/products.xml",
      "feed_format": "google_merchant_center",
      "update_frequency": "daily"
    },
    {
      "catalog_id": "promotions-q1",
      "type": "promotion",
      "name": "Q1 Promotions",
      "url": "https://example.com/promotions.xml",
      "feed_format": "custom",
      "update_frequency": "hourly"
    }
  ]
}
```

**Option B — Inline items:**

**Operation:** `sync_catalogs`
```http
POST /api/v2/buyer/advertisers/26/catalogs/sync
{
  "account": { "account_id": "26" },
  "catalogs": [
    {
      "catalog_id": "my-catalog-1",
      "type": "product",
      "name": "Q1 Products",
      "items": [
        {
          "item_id": "sku-001",
          "title": "Blue Widget",
          "description": "A sturdy blue widget for everyday use",
          "price": "19.99 USD",
          "link": "https://example.com/products/blue-widget",
          "image_link": "https://example.com/images/blue-widget.jpg",
          "availability": "in stock",
          "brand": "Acme",
          "google_product_category": "Hardware > Tools"
        },
        {
          "item_id": "sku-002",
          "title": "Red Widget",
          "description": "A sturdy red widget for everyday use",
          "price": "24.99 USD",
          "link": "https://example.com/products/red-widget",
          "image_link": "https://example.com/images/red-widget.jpg",
          "availability": "in stock",
          "brand": "Acme",
          "google_product_category": "Hardware > Tools"
        }
      ]
    }
  ]
}
```

> Items are free-form key/value objects — the fields depend on the catalog `type`. The examples above use common product fields. For `job` catalogs use fields like `job_id`, `title`, `company`, `location`; for `hotel` use `hotel_id`, `name`, `address`, `star_rating`; etc.

**URL path:** `/advertisers/{advertiserId}/catalogs/sync` — the `{advertiserId}` is the numeric advertiser ID (e.g. `26`). Also include it in the request body as `account.account_id`.

**`account` (required in body):**
- `account_id` (string): The advertiser ID — same value as the path `{advertiserId}` (e.g. `"26"`).

**`catalogs` array (required, 1–50 items). Each object:**
- `catalog_id` (string, required): Buyer-assigned identifier
- `type` (string, required): `offering`, `product`, `inventory`, `store`, `promotion`, `hotel`, `flight`, `job`, `vehicle`, `real_estate`, `education`, `destination`
- `name` (string, optional): Display name
- `url` (string): Remote feed URL — provide this OR `items`, not both
- `items` (array): Inline catalog items — provide this OR `url`, not both
- `feed_format` (string, optional): `google_merchant_center`, `facebook_catalog`, `shopify`, `linkedin_jobs`, `custom`
- `update_frequency` (string, optional): `realtime`, `hourly`, `daily`, `weekly`
- `conversion_events` (array, optional): Conversion event IDs

**Other optional fields:**
- `catalog_ids` (array): Filter which catalog_ids from the `catalogs` array to process
- `delete_missing` (boolean): Archive catalogs not included in this request (default: false)
- `dry_run` (boolean): Preview changes without persisting (default: false)
- `validation_mode` (string): `strict` (default) or `lenient`

**Response:**
```json
{
  "data": {
    "results": [
      {
        "catalog_id": "my-catalog-1",
        "action": "created",
        "name": "Q1 Products",
        "type": "offering"
      }
    ]
  }
}
```

Actions: `created`, `updated`, `unchanged`, `failed`, `deleted`

#### List Catalogs

**Operation:** `list_catalogs`
```http
GET /api/v2/buyer/advertisers/26/catalogs
```
**As operation:**
```json
{ "operation": "list_catalogs", "pathParams": { "advertiserId": "26" } }
```

**URL path:** `/advertisers/{advertiserId}/catalogs` — the `{advertiserId}` is the numeric advertiser ID.

**Query Parameters:**
- `type` (optional): Filter by catalog type (`offering`, etc.)
- `take` / `skip` (optional): Pagination

**Response (200):**
```json
{
  "data": {
    "account": { "account_id": "26" },
    "catalogs": [
      {
        "catalogId": "my-catalog-1",
        "type": "offering",
        "name": "Q1 Products",
        "url": "https://example.com/feed.xml"
      }
    ]
  }
}
```

---

### Storefronts

Browse available storefronts, register credentials for inventory sources, and link accounts to advertisers.

**CRITICAL — Account Status Awareness:**
The list returns each storefront's `sourceCount` and `connectedSourceCount`. When `connectedSourceCount < sourceCount`, the storefront has unconnected sources — call `get_storefront` for that storefront to enumerate its `sources[]` and tell the user the credential/account registration status for each one. Specifically, on the `get_storefront` response:
- If a source has `requiresCredentials: true` and `connected: false`: Tell the user they need to register credentials for this source before they can use it.
- If a source has `customerAccounts` entries: Show the user their registered account identifiers and statuses.
- If a source has `requiresCredentials: false`: The platform handles credentials — no action needed from the user.

Never silently omit this information. The user needs to know which storefronts have unconnected sources and which are ready to use.

**CRITICAL — Proactive Registration Prompt for disconnected sources:**
When `connectedSourceCount < sourceCount` for any storefront in the list, you MUST:
1. **Drill in via `get_storefront`** — fetch the storefront's `sources[]` to identify which sources have `requiresCredentials: true` and `connected: false`.
2. **Explicitly call it out in a separate section** — After showing the storefront list, add a clear callout like: "The following sources require you to register credentials: [source names]. Would you like to register credentials for any of them?"
3. **Offer to start the registration flow** — Ask the user if they want to register credentials now. If yes, use `register_source_credentials` operation.
4. **After registering credentials, offer to link an account to an advertiser** — Once credentials are registered, ask: "Now that credentials are set up for [source name], would you like to discover and link an account to a specific advertiser?" If yes, follow the Link Agent Account to Advertiser workflow (see Advertisers section):
   a. List the customer's advertisers via `list_advertisers` operation
   b. For the chosen advertiser, discover available accounts for the agent via `list_available_accounts` operation
   c. Present discovered accounts and let the user pick
   d. Link via `update_advertiser` operation with `linkedAccounts`

This end-to-end flow (list storefronts → get_storefront → register credentials → link account to advertiser) should feel seamless. Do NOT make the user figure out the next step — always offer it.

#### List Storefronts

List all enabled storefronts visible to the buyer. Each storefront contains inventory sources backed by agents. Results are paginated.

**Operation:** `list_storefronts`
```http
GET /api/v2/buyer/storefronts?name=Roundel&limit=20&offset=0
```
**As operation:**
```json
{ "operation": "list_storefronts", "params": { "name": "Roundel", "limit": "20", "offset": "0" } }
```

**Query Parameters (all optional):**
- `name` (string): Filter by storefront name (partial match, case-insensitive)
- `limit` (number): Maximum storefronts per page (default: 20, max: 50)
- `offset` (number): Number of storefronts to skip for pagination (default: 0)

**Response:**
```json
{
  "data": {
    "items": [
      {
        "id": 1,
        "platformId": "premium-video",
        "name": "Premium Video Exchange",
        "publisherDomain": "premiumvideo.com",
        "sourceCount": 3,
        "connectedSourceCount": 2
      }
    ],
    "total": 25,
    "hasMore": true,
    "nextOffset": 20
  }
}
```

**Pagination:** When `hasMore` is true, use the `nextOffset` value as the `offset` parameter in your next request to fetch the next page. Continue until `hasMore` is false or `nextOffset` is null.

**Notes:**
- All enabled storefronts are visible to all buyers
- Each storefront has one or more inventory sources, each backed by an agent
- `sourceCount` is the total number of inventory sources on the storefront; `connectedSourceCount` is how many of those the buyer has active credentials for
- The list does NOT include the per-source `sources[]` array — call `get_storefront` to enumerate sources, their `protocol`, `requiresCredentials`, `connected` flag, and `customerAccounts`

**Credential rules — CRITICAL (apply to per-source data on `get_storefront`):**
- `requiresCredentials: true` → the buyer MUST provide their own credentials. Registration is done via `register_source_credentials` operation.
- `requiresCredentials: false` → Scope3 acts as the agent on behalf of the advertiser. **Individual credential registration is NOT possible.**
- **Account linking requires `requiresCredentials: true`.** Only sources where the buyer registers their own credentials can have accounts discovered and linked to advertisers.

**Display Requirements — ALWAYS include when listing storefronts:**

Present each storefront as a structured entry (not prose). For every storefront, show:
- **Name** and **ID**
- **Source coverage** — show `connectedSourceCount` / `sourceCount` (e.g. "2 of 3 sources connected")

Key rules:
- Never summarize into "You have N storefronts." Always show the per-item details above.
- **When `connectedSourceCount < sourceCount`, drill in via `get_storefront`** to identify the disconnected sources, then ask the user about registration. Do NOT just list them and move on.

#### List Registered Agent Credentials

List all agent credentials registered by this customer across all agents.

**Operation:** `list_agent_credentials`
```http
GET /api/v2/buyer/storefronts/credentials
```
**As operation:**
```json
{ "operation": "list_agent_credentials" }
```

**Response (200):**
```json
{
  "data": [
    {
      "id": "722",
      "agentId": "snap_6e2d13705a26",
      "agentName": "Snap",
      "accountIdentifier": "Scope3 Snap Creds",
      "accountType": "CLIENT",
      "status": "ACTIVE",
      "registeredBy": "user@example.com",
      "createdAt": "2026-02-23T19:55:11.602Z",
      "updatedAt": "2026-02-23T19:56:56.272Z"
    }
  ]
}
```

**Notes:**
- Returns all agent credentials belonging to the authenticated customer, across all agents
- `auth_configuration` is never returned (sensitive)
- Use this to check what agent credentials have been registered before linking accounts to advertisers

#### Register Agent Credentials

Register credentials for a specific agent at the **customer level**. This is the first step in connecting to an agent — credentials belong to the whole customer, not a specific advertiser. Once credentials are registered, accounts can be discovered and linked to individual advertisers (see Link Agent Account to Advertiser in the Advertisers section).

**Multiple credentials per agent:** A customer CAN register multiple sets of credentials for the same agent (e.g., two different Snap ad accounts with different API keys). Each set uses a different `accountIdentifier`. Each credential discovers its own set of ad accounts. When discovering accounts for linking, the `credentialId` parameter is required so the system knows which credential to query — see "Link Agent Account to Advertiser" in the Advertisers section for the full workflow.

**Operation:** `register_source_credentials`
```http
POST /api/v2/buyer/storefronts/{storefrontId}/sources/{sourceId}/credentials
{
  "accountIdentifier": "my-publisher-account",
  "auth": {
    "type": "bearer",
    "token": "my-api-key"
  }
}
```
**As operation:**
```json
{ "operation": "register_source_credentials", "pathParams": { "storefrontId": "1", "sourceId": "src_abc123" }, "body": { "accountIdentifier": "my-publisher-account", "auth": { "type": "bearer", "token": "my-api-key" } } }
```

**Path Parameters:**
- `storefrontId` (number): The storefront ID
- `sourceId` (string): The inventory source ID within the storefront

**Required Fields:**
- `accountIdentifier` (string): Unique account identifier for this agent

**Optional Fields:**
- `auth` (object): Authentication credentials. Required for API_KEY/JWT agents, not needed for OAUTH agents.
- `marketplaceAccount` (boolean): Admin-only flag for marketplace accounts

**OAUTH agents:** Do NOT ask the user for any OAuth credentials (client_id, client_secret, tokens, etc.). Just omit the `auth` field. The response will include an `oauth.authorizationUrl` — present this link to the user to complete authorization. The platform handles discovery, client registration, and token exchange automatically.

**Response (non-OAUTH, 201):**
```json
{
  "id": "123",
  "accountIdentifier": "my-publisher-account",
  "status": "ACTIVE",
  "registeredBy": "user@example.com",
  "createdAt": "2026-01-15T10:00:00Z"
}
```

**Response (OAUTH, 201):**
```json
{
  "id": "123",
  "accountIdentifier": "my-publisher-account",
  "status": "PENDING",
  "registeredBy": "user@example.com",
  "createdAt": "2026-01-15T10:00:00Z",
  "oauth": {
    "authorizationUrl": "https://agent.example.com/authorize?client_id=abc&...",
    "agentId": "agent_abc123",
    "agentName": "Agent Name"
  }
}
```

**Notes:**
- Agent must be ACTIVE before accounts can be registered
- For OAUTH agents, the account is created with PENDING status and includes an `authorizationUrl` for the user to click
- After the user authorizes, the account status changes to ACTIVE automatically

### Audiences

Sync first-party CRM audiences into Scope3 for later syndication to sales agents. Audiences contain hashed customer identifiers used for targeting. Processing is **asynchronous** — sync returns immediately with an `operationId`, and processing completes in the background.

**Important:** All member identifiers must be pre-hashed before sending:
- **Email:** SHA-256 of lowercase, trimmed email (64-char hex string)
- **Phone:** SHA-256 of E.164-formatted phone number (64-char hex string)
- **Universal IDs:** RampID, UID2, MAID, etc. passed as-is

**Limits:** Maximum 100,000 total members per sync call. For larger lists, chunk into sequential requests.

#### Sync Audiences

Sync audience data for an advertiser. The `accountId` in the URL is the **advertiser ID** (numeric, e.g. `25`) — the same `advertiserId` used when creating campaigns. Returns **202 Accepted** with an operation ID for tracking. Each member requires an `externalId` plus at least one hashed identifier.

**Operation:** `sync_audiences`
```http
POST /api/v2/buyer/advertisers/{accountId}/audiences/sync
{
  "audiences": [
    {
      "audienceId": "crm-high-value",
      "name": "High Value Customers",
      "add": [
        {
          "externalId": "user-001",
          "hashedEmail": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
        },
        {
          "externalId": "user-002",
          "uids": [{ "type": "uid2", "value": "uid2-token-value" }]
        }
      ],
      "consentBasis": "consent"
    }
  ],
  "deleteMissing": false
}
```

**Path Parameters:**
- `accountId` (string, required): Advertiser ID (numeric, e.g. `"25"`)

**Required Fields:**
- `audiences` (array): Audiences to sync
  - `audienceId` (string, required): Buyer's identifier for this audience

**Optional Fields per Audience:**
- `name` (string): Human-readable name
- `add` (array, max 10,000): Members to add (each needs `externalId` + at least one identifier)
- `remove` (array, max 10,000): Members to remove by `externalId`
- `delete` (boolean): When true, delete this audience entirely
- `consentBasis` (string): GDPR lawful basis — `consent`, `legitimate_interest`, `contract`, `legal_obligation`
- `deleteMissing` (boolean): When true, audiences not in this request are marked as deleted

**Response (202 Accepted):**
```json
{
  "success": true,
  "accountId": "25",
  "operationId": "550e8400-e29b-41d4-a716-446655440000",
  "taskId": "550e8400-e29b-41d4-a716-446655440000"
}
```

**Notes:**
- Processing is asynchronous — poll via `get_task` operation for progress (see [Tasks](#tasks))
- `status` values: `PROCESSING` (matching in progress), `READY` (available for targeting), `ERROR`, `TOO_SMALL` (below platform minimum)

#### List Audiences

List stored audiences for an account. Use this to check processing status after syncing.

**Operation:** `list_audiences`
```http
GET /api/v2/buyer/advertisers/{accountId}/audiences?take=50&skip=0
```
**As operation:**
```json
{ "operation": "list_audiences", "pathParams": { "advertiserId": "<id>" }, "params": { "take": "50", "skip": "0" } }
```

**Path Parameters:**
- `accountId` (string, required): Advertiser ID (numeric, e.g. `"25"`)

**Query Parameters (all optional):**
- `take` (number): Results per page (default: 50, max: 100)
- `skip` (number): Pagination offset (default: 0)

**Response:** each row is the audience **summary** shape — `audienceId`, `name`, `accountId`, `status`, `deleted`, `uploadedCount`, `matchedCount`, `createdAt`, `updatedAt`. `consentBasis` and `lastOperationStatus` are NOT on the summary — call `get_audience` for the full resource.

```json
{
  "audiences": [
    {
      "audienceId": "crm-high-value",
      "name": "High Value Customers",
      "accountId": "25",
      "status": "READY",
      "deleted": false,
      "uploadedCount": 1500,
      "matchedCount": 1200,
      "createdAt": "2026-02-24T10:00:00Z",
      "updatedAt": "2026-02-25T10:00:00Z"
    }
  ],
  "total": 1,
  "take": 50,
  "skip": 0
}
```

---

### Frequency Caps

Buyer-side frequency caps limit how often a user sees ads across all publishers/sales agents, scoped to an **advertiser**, **campaign**, or **creative**. These are distinct from publisher-side caps in the ADCP `target_overlay` — those are set by the seller on their own inventory.

**Shape of a single cap:**
```json
{ "max_impressions": 3, "window": { "interval": 1, "unit": "days" } }
```
- `max_impressions` (positive int): max impressions allowed per user within the window.
- `window.interval` (positive int) + `window.unit` (`"seconds"` | `"minutes"` | `"hours"` | `"days"` | `"campaign"`): rolling time window size. Matches the AdCP `Duration` shape.

**Multiple caps per entity are allowed and combine as AND.** For example, to enforce "no more than 3/day AND 10/hour" on a campaign, pass both:
```json
"frequencyCaps": [
  { "max_impressions": 3,  "window": { "interval": 1, "unit": "days"  } },
  { "max_impressions": 10, "window": { "interval": 1, "unit": "hours" } }
]
```

**Where to set them:** `frequencyCaps` is accepted on CREATE and UPDATE of:
- Advertisers — `create_advertiser`, `update_advertiser`
- Campaigns — `create_campaign`, `update_campaign`
- Creatives — `update_creative`

**Replace semantics (on UPDATE):**
- **Omit the field** → existing caps are left unchanged.
- **Pass `[]`** → all existing caps on that entity are cleared.
- **Pass a non-empty array** → the full set is replaced with the array supplied.

**Where to read them:**
- Single-GET (`get_advertiser`, `get_campaign`, `get_creative`) returns the `frequencyCaps` array.
- LIST endpoints (`list_advertisers`, `list_campaigns`, `list_creatives`) do NOT return `frequencyCaps` — call the single-GET to read them.

**Response cap shape** additionally includes `id`, `targetLevel` (`"ADVERTISER"` | `"CAMPAIGN"` | `"CREATIVE"`), `targetId`, `createdAt`, `updatedAt`, and `archivedAt` (null for active caps).

**Gathering inputs from the user:** if the user says "cap at 3 per day" or "no more than 10 impressions per week", translate to the shape above. Always confirm the scope (advertiser-wide vs. campaign-specific vs. creative-specific) before writing — they are independent and enforcement is the union of all applicable caps.

---

### Syndication

Syndicate audiences, event sources, or catalogs to ADCP agents. Tracks status asynchronously via webhooks.

#### Syndicate Resource

```http
POST /api/v2/buyer/advertisers/{advertiserId}/syndicate
{
  "resourceType": "AUDIENCE",
  "resourceId": "aud_12345",
  "adcpAgentIds": ["agent-abc-123", "agent-def-456"],
  "enabled": true
}
```

**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `resourceType` | string | Yes | `AUDIENCE`, `EVENT_SOURCE`, or `CATALOG` |
| `resourceId` | string | Yes | ID of the resource to syndicate |
| `adcpAgentIds` | string[] | Yes | Array of ADCP agent ID strings (min 1) |
| `enabled` | boolean | Yes | Whether to enable or disable syndication |

**Response (201):** Returns the syndication status records for each agent.

#### Query Syndication Status

```http
GET /api/v2/buyer/advertisers/{advertiserId}/syndication-status?resourceType=AUDIENCE&status=SYNCING&limit=20&offset=0
```

**Query Parameters (all optional):**
| Parameter | Type | Description |
|-----------|------|-------------|
| `resourceType` | string | Filter by `AUDIENCE`, `EVENT_SOURCE`, or `CATALOG` |
| `resourceId` | string | Filter by specific resource ID |
| `adcpAgentId` | string | Filter by ADCP agent ID |
| `enabled` | string | Filter by `true` or `false` |
| `status` | string | Filter by `PENDING`, `SYNCING`, `COMPLETED`, `FAILED`, or `DISABLED` |
| `limit` | number | Max results (1-100, default 50) |
| `offset` | number | Pagination offset (default 0) |

---

### Tasks

Async operations (audience sync, media buy creation, etc.) return a task ID that can be polled for status.

#### Get Task Status

**Operation:** `get_task`
```http
GET /api/v2/buyer/tasks/{taskId}
```
**As operation:**
```json
{ "operation": "get_task", "pathParams": { "taskId": "<id>" } }
```

**Response:**
```json
{
  "task": {
    "taskId": "550e8400-e29b-41d4-a716-446655440000",
    "taskType": "audience_sync",
    "status": "completed",
    "resourceType": "audience",
    "resourceId": "aud_12345",
    "error": null,
    "response": { "audience_id": "aud_12345", "member_count": 15000 },
    "metadata": {},
    "retryAfterSeconds": null,
    "createdAt": "2026-01-15T10:30:00.000Z",
    "updatedAt": "2026-01-15T10:35:00.000Z"
  }
}
```

**Task types:** `audience_sync`, `media_buy_create`, `creative_sync`

**Status values:** `submitted`, `working`, `completed`, `failed`, `input-required`

**Error format** (AdCP-compatible, set when status is `failed`):
```json
{
  "code": "VALIDATION_ERROR",
  "message": "Invalid budget value",
  "field": "packages[0].budget",
  "suggestion": "Budget must be positive",
  "recovery": "correctable"
}
```

**Notes:**
- Task IDs are UUIDs returned in 202 responses from async operations
- Poll this endpoint when webhooks are unavailable — use `retryAfterSeconds` for polling interval guidance
- `response` contains the original downstream response payload (varies by task type)
- Tasks are scoped to the caller's customer — you cannot access another customer's tasks

---

### Property Lists

Property lists define which inventory an advertiser targets (include lists) or avoids (exclude lists). They accept AdCP-typed identifiers covering websites, mobile apps (iOS / Android), and CTV apps (Roku, Fire TV, Samsung). Lists are scoped to an advertiser and automatically apply to all campaigns under that brand's targeting profile.

#### Identifier types (AdCP-aligned)

| Type | Resolves via | Example value |
|------|--------------|---------------|
| `domain` | `Domain.domain` (`SITE`) | `nytimes.com` |
| `subdomain` | `Domain.domain` (`SITE`) | `news.example.com` |
| `ios_bundle` | `App.bundle` (Apple App Store) | `com.facebook.katana` |
| `android_package` | `App.bundle` (Google Play) | `com.facebook.katana` |
| `apple_tv_bundle` | `App.bundle` (Apple App Store) | `com.netflix.Netflix` |
| `bundle_id` (generic fallback) | `App.bundle` (any store) | `com.example.app` |
| `apple_app_store_id` | `Domain.domain` (`APPLE_APP_STORE`) — numeric ID | `284882215` |
| `google_play_id` | `Domain.domain` (`GOOGLE_PLAY_STORE`) | `com.example.app` |
| `roku_store_id` | `Domain.domain` (`ROKU`) | `12` |
| `fire_tv_asin` | `Domain.domain` (`AMAZON`) | `B00X4WHP5E` |
| `samsung_app_id` | `Domain.domain` (`SAMSUNG`) | `G19173000091` |

A single mobile app may appear in a property list under multiple identifier types (e.g. `ios_bundle` AND `apple_app_store_id`); each is resolved independently against the AAO property registry / local DB.

Read-side normalization: `apple_tv_bundle` and `ios_bundle` share the same `App.bundle` storage (`appStore=APPLE_APP_STORE`), so an `apple_tv_bundle` write reads back as `ios_bundle` on subsequent `get_property_list` / `list_property_lists` responses. The generic `bundle_id` fallback similarly normalizes to the resolved app row's store-typed form (`ios_bundle` or `android_package`). Submit the type that best matches the AdCP property registry; expect the response to carry the canonical store-typed form.

#### Create Property List

Create a named include or exclude list. Identifiers are resolved to internal property records; anything that cannot be resolved is returned in `unresolvedIdentifiers`. Identifiers found in the AAO registry but not yet locally targetable are returned in `registeredIdentifiers`.

**Operation:** `create_property_list`
```http
POST /api/v2/buyer/advertisers/{advertiserId}/property-lists
```

**Request body — typed identifiers (preferred for mixed inputs):**
```json
{
  "name": "Q1 Campaign - Premium Inventory",
  "purpose": "include",
  "identifiers": [
    { "type": "domain", "value": "nytimes.com" },
    { "type": "ios_bundle", "value": "com.facebook.katana" },
    { "type": "android_package", "value": "com.facebook.katana" },
    { "type": "apple_app_store_id", "value": "284882215" },
    { "type": "roku_store_id", "value": "12" }
  ]
}
```

**Request body — domains-only shorthand:**
```json
{
  "name": "Q1 Campaign - Premium Publishers",
  "purpose": "include",
  "domains": ["nytimes.com", "cnn.com", "bbc.co.uk"]
}
```

`domains` and `identifiers` may both be provided in the same request; the combined total must be 1..100,000. `domains: [...]` is shorthand equivalent to `identifiers: [{type: "domain", value: ...}]`.

**As operation:**
```json
{ "operation": "create_property_list", "pathParams": { "advertiserId": "<id>" }, "body": { "name": "Q1 Mixed", "purpose": "include", "identifiers": [{ "type": "domain", "value": "nytimes.com" }, { "type": "ios_bundle", "value": "com.facebook.katana" }] } }
```

**Response (201):**
```json
{
  "listId": "42",
  "name": "Q1 Mixed",
  "purpose": "include",
  "identifiers": [
    { "type": "domain", "value": "nytimes.com" }
  ],
  "unresolvedIdentifiers": [
    { "type": "ios_bundle", "value": "com.facebook.katana" }
  ],
  "registeredIdentifiers": [],
  "domains": ["nytimes.com"],
  "unresolvedDomains": [],
  "registeredDomains": [],
  "propertyCount": 14,
  "resolutionSummary": {
    "totalRequested": 2,
    "resolvedCount": 1,
    "registeredCount": 0,
    "unresolvedCount": 1,
    "resolutionRate": 0.5
  },
  "createdAt": "2026-03-16T10:00:00.000Z",
  "updatedAt": "2026-03-16T10:00:00.000Z"
}
```

**Response field reference:**
- `identifiers` — typed `{type, value}[]` actually resolved to local Property rows. **Persisted** as catalog membership.
- `unresolvedIdentifiers` — typed identifiers with no matching local Property record. For app types, this means no `App` row (or `Domain` row, for store-ID types) matches the value. **Transient** — see persistence note below.
- `registeredIdentifiers` — typed identifiers found in the AAO registry but not yet locally targetable. Today only `domain` types can land here — non-domain types are not auto-registered with AAO. **Transient** — see persistence note below.
- `domains` / `unresolvedDomains` / `registeredDomains` — convenience views of the above filtered to `type: "domain"`. App identifiers (any non-domain type) are NOT in these arrays.
- `resolutionSummary` — counts and `resolutionRate` (0..1) over the deduplicated, normalized input.

**Persistence note:** only `identifiers` (the resolved set) is stored on the property list — as foreign-key links to existing `Property` rows. `unresolvedIdentifiers` and `registeredIdentifiers` are **transient output of the resolution call**, not persisted state. They appear in the response of any create/update that runs resolution and are then dropped — a subsequent GET will return empty arrays for them, and a name-only PUT will not return them either. To re-surface unresolved/registered values for the same list, re-submit the identifier set on a PUT.

**Always surface `resolutionSummary` to the user** so they know how many of their submitted identifiers will actually target.

#### List Property Lists

**Operation:** `list_property_lists`
```http
GET /api/v2/buyer/advertisers/{advertiserId}/property-lists?purpose=include
```
**As operation:**
```json
{ "operation": "list_property_lists", "pathParams": { "advertiserId": "<id>" }, "params": { "purpose": "include" } }
```

**Response:** each row is the property list **summary** shape — `listId`, `name`, `purpose`, `propertyCount`, `createdAt`, `updatedAt`. The resolved `domains[]`, `unresolvedDomains[]`, `registeredDomains[]`, `filters`, `resolutionSummary`, and `cascadeSummary` are NOT on the summary — call `get_property_list` for the full resource.

#### Get Property List

**Operation:** `get_property_list`
```http
GET /api/v2/buyer/advertisers/{advertiserId}/property-lists/{listId}
```
**As operation:**
```json
{ "operation": "get_property_list", "pathParams": { "advertiserId": "<id>", "listId": "<id>" } }
```

#### Update Property List

Update name and/or replace the identifier set entirely. Both `domains` and `identifiers` are optional; if either is provided, the existing identifier set on the list is replaced. When identifiers change, the response includes a `resolutionSummary` and a `cascadeSummary` with counts of active media buys re-synced.

**Operation:** `update_property_list`
```http
PUT /api/v2/buyer/advertisers/{advertiserId}/property-lists/{listId}
```

**Request body — typed identifiers:**
```json
{
  "name": "Updated List Name",
  "identifiers": [
    { "type": "domain", "value": "nytimes.com" },
    { "type": "ios_bundle", "value": "com.facebook.katana" }
  ]
}
```

**Request body — domains-only shorthand:**
```json
{
  "name": "Updated List Name",
  "domains": ["nytimes.com", "washingtonpost.com"]
}
```

#### Delete Property List

Archives the property list. The list remains associated with the advertiser but is no longer active.

**Operation:** `delete_property_list`
```http
DELETE /api/v2/buyer/advertisers/{advertiserId}/property-lists/{listId}
```
**As operation:**
```json
{ "operation": "delete_property_list", "pathParams": { "advertiserId": "<id>", "listId": "<id>" } }
```

**Recommended workflow:**
1. Create a property list with initial identifiers (web, mobile, CTV, or any mix).
2. Use the check endpoint (below) to validate domain entries against the AAO registry; non-domain types (mobile/CTV) are not validated upstream and are returned in the `assess` bucket.
3. Update the list based on check results (remove blocked domains, apply canonical corrections).
4. All campaigns under the advertiser automatically inherit the targeting.
5. Property lists are automatically passed to sales agents during product discovery via the ADCP `property_list` field, including all typed identifiers.

#### Resolve Property List (ADCP)

Returns a property list in ADCP `GetPropertyListResponse` format. The `identifiers` array contains every typed identifier the list resolves to (domains, mobile bundles/store IDs, CTV store IDs). Used by sales agents to resolve a `PropertyListReference` received during product discovery. Authenticated via HMAC token (not platform auth).

```http
GET /lists/{listId}
Authorization: Bearer {auth_token}
```

**Response:**
```json
{
  "list": {
    "list_id": "123",
    "name": "Premium inventory"
  },
  "identifiers": [
    { "type": "domain", "value": "nytimes.com" },
    { "type": "domain", "value": "cnn.com" },
    { "type": "ios_bundle", "value": "com.spotify.music" },
    { "type": "android_package", "value": "com.spotify.music" },
    { "type": "apple_app_store_id", "value": "324684580" }
  ],
  "resolved_at": "2026-03-17T12:00:00.000Z",
  "cache_valid_until": "2026-03-18T12:00:00.000Z"
}
```

#### Check Property List

Validate a mixed list of typed identifiers against the AAO Community Registry. For `domain` entries, identifies blocked entries (ad servers, CDNs, trackers), normalizes URLs (strips www/m prefixes), removes duplicates, and flags unknown values. Non-domain types (mobile/CTV apps) are not currently checked against AAO and are returned in the `assess` bucket pending upstream support.

**Operation:** `check_property_list`
```http
POST /api/v2/buyer/property-lists/check
```

**Request body — typed identifiers:**
```json
{
  "identifiers": [
    { "type": "domain", "value": "nytimes.com" },
    { "type": "domain", "value": "www.cnn.com" },
    { "type": "domain", "value": "doubleclick.net" },
    { "type": "ios_bundle", "value": "com.facebook.katana" }
  ]
}
```

**Request body — domains-only shorthand:**
```json
{
  "domains": ["nytimes.com", "www.cnn.com", "doubleclick.net", "unknown-site.xyz"]
}
```

`domains` and `identifiers` may both be provided in the same request; combined total must be 1..100,000.

**As operation:**
```json
{ "operation": "check_property_list", "body": { "identifiers": [{ "type": "domain", "value": "nytimes.com" }, { "type": "ios_bundle", "value": "com.facebook.katana" }] } }
```

**Response:**
```json
{
  "summary": { "total": 5, "remove": 1, "modify": 1, "assess": 2, "ok": 1 },
  "remove": [
    { "input": "doubleclick.net", "canonical": "doubleclick.net", "reason": "blocked", "domain_type": "ad_server", "identifier": { "type": "domain", "value": "doubleclick.net" } }
  ],
  "modify": [
    { "input": "www.cnn.com", "canonical": "cnn.com", "reason": "www prefix removed", "identifier": { "type": "domain", "value": "www.cnn.com" } }
  ],
  "assess": [
    { "domain": "unknown-site.xyz", "identifier": { "type": "domain", "value": "unknown-site.xyz" } },
    { "domain": "com.facebook.katana", "identifier": { "type": "ios_bundle", "value": "com.facebook.katana" } }
  ],
  "ok": [
    { "domain": "nytimes.com", "source": "registry", "identifier": { "type": "domain", "value": "nytimes.com" } }
  ],
  "reportId": "rpt_abc123",
  "reportIds": ["rpt_abc123"]
}
```

**Result buckets:**
- `remove`: Domains to remove — duplicates or blocked (ad servers, CDNs, trackers, intermediaries). Non-domain types never appear here.
- `modify`: Domains that were normalized (e.g. `www.example.com` → `example.com`). Use the `canonical` value. Non-domain types never appear here.
- `assess`: Unknown domains not in the registry and not blocked — may need manual review. **All non-domain identifiers (mobile/CTV apps) land here** (AAO does not currently check them).
- `ok`: Domains found in the registry with no issues. Non-domain types never appear here.

Each bucket entry carries an `identifier: { type, value }` field that mirrors the input — use this rather than the `domain`/`input` field to disambiguate types.

**`reportId` / `reportIds`:** present only when at least one `domain` entry was submitted. Omitted entirely when the request contained only non-domain types (no AAO call is made). When domain input is chunked into multiple registry calls, `reportIds` contains all chunk IDs and `reportId` equals `reportIds[0]` for back-compat.

**Limits:** 1–100,000 entries per request, chunked server-side into 10,000-domain registry calls.

#### Get Property Check Report

Retrieve a stored property check report by ID. Reports expire after 7 days.

**Operation:** `get_property_list_report`
```http
GET /api/v2/buyer/property-lists/reports/{reportId}
```
**As operation:**
```json
{ "operation": "get_property_list_report", "pathParams": { "reportId": "<id>" } }
```

**Response:**
```json
{
  "summary": { "total": 4, "remove": 1, "modify": 1, "assess": 1, "ok": 1 }
}
```

### Activity Feed

Lists audit log events for the buyer — campaign changes, creative updates, media buy transitions, advertiser edits, and other buyer-side actions. Customer-scoped.

```http
GET /api/v2/buyer/audit-logs?take=50&skip=0
```
**As operation:**
```json
{ "operation": "list_activity", "params": { "take": "50" } }
```

Query parameters (all optional):
- `startDate`, `endDate` — ISO timestamps to bound the range
- `advertiserId` — filter to a single advertiser
- `campaignId` — filter to a single campaign (includes its media buys, creatives, etc.)
- `resourceTypes` — repeated (`?resourceTypes=CAMPAIGN&resourceTypes=CREATIVE`) or comma-separated
- `take` (max 500, default 50 over REST; capped at 10 for MCP), `skip`

**Response:**
```json
{
  "data": {
    "logs": [
      {
        "id": 1,
        "timestamp": "2026-04-01T00:00:00Z",
        "action": "UPDATE",
        "resourceType": "CAMPAIGN",
        "resourceId": "camp_abc123",
        "resourceName": "Spring Promo",
        "advertiserId": 42,
        "userEmail": "user@example.com",
        "description": "Updated campaign Spring Promo"
      }
    ],
    "total": 123
  },
  "meta": { "pagination": { "skip": 0, "take": 10, "total": 123, "hasMore": true } }
}
```

---

## Error Handling

### Hard Failures vs. No Products Found

These are two distinct outcomes — do NOT conflate them:

**Hard failures** — the agent returned an actual error response. Examples:
- Auth or tenant context errors
- Authentication required
- Data corruption errors
- MCP endpoint not responding

These surface as non-2xx HTTP responses or error payloads. Treat as errors that need investigation.

**Soft failures / no products** — the agent responded successfully (HTTP 200) but returned 0 products. This is **not an error**. It means the brief did not match available inventory. Do NOT tell the user the agent "failed." See "When discovery returns no products" above for how to handle this.

### REST API Error Format

All REST error responses use a standard envelope:

```json
{
  "data": null,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Human-readable error description",
    "field": "start_date",
    "details": {}
  }
}
```

- `code` — machine-readable error code (see table below)
- `message` — human-readable description
- `field` — (optional) the specific field that caused the error
- `details` — (optional) additional context

### MCP Tool Error Format

Tool errors return `isError: true` with a structured error object in `structuredContent` and a human-readable message in `content`:

```json
{
  "content": [{ "type": "text", "text": "Budget is below the minimum" }],
  "structuredContent": {
    "code": "VALIDATION_ERROR",
    "message": "Budget is below the minimum",
    "field": "packages[0].budget",
    "suggestion": "Minimum budget is $100"
  },
  "isError": true
}
```

- `code` — machine-readable error code (see table below)
- `message` — human-readable description
- `field` — (optional) field path (e.g. `packages[0].budget`)
- `suggestion` — (optional) suggested fix

### Error Codes

| Code | HTTP Status | Resolution |
|------|-------------|------------|
| `VALIDATION_ERROR` | 400 | Check request body against schema |
| `UNAUTHORIZED` | 401 | Verify API key/auth |
| `ACCESS_DENIED` | 403 | Check permissions |
| `NOT_FOUND` | 404 | Verify resource ID exists |
| `CONFLICT` | 409 | Resource already exists (e.g., brand) |
| `RATE_LIMITED` | 429 | Wait and retry |
| `INVALID_STATE` | 400 | Operation not allowed in current state |
| `INTERNAL_ERROR` | 500 | Contact support |

---

## Notifications

Notifications are events about resources you manage — campaigns going unhealthy, creatives syncing, agents registering, etc. They follow a `resource.action` taxonomy (e.g., `campaign.unhealthy`, `creative.sync_failed`).

### List Notifications

**Operation:** `list_notifications`
```http
GET /api/v2/notifications?unreadOnly=true&limit=20&offset=0
```
**As operation:**
```json
{ "operation": "list_notifications", "params": { "unreadOnly": "true", "limit": "20", "offset": "0" } }
```

**Query Parameters (all optional):**
- `unreadOnly` (`true`/`false`): Show only unread notifications
- `brandAgentId` (number): Filter by brand agent
- `types` (comma-separated): Filter by event types (e.g., `campaign.unhealthy,creative.sync_failed`)
- `campaignId` (string): Filter by campaign
- `creativeId` (string): Filter by creative
- `limit` (number): Results per page (default: 50, max: 100)
- `offset` (number): Pagination offset

**Response:** each row is the notification **summary** shape — `id`, `type`, `status`, `read`, `acknowledged`, `messagePreview`, `createdAt`. The full `data` payload is NOT on the summary.

```json
{
  "notifications": [
    {
      "id": "notif_1709123456_abc123",
      "type": "campaign.unhealthy",
      "status": "warning",
      "messagePreview": "Campaign \"Q1 CTV\" is unhealthy",
      "read": false,
      "acknowledged": false,
      "createdAt": "2026-03-01T12:00:00Z"
    }
  ],
  "totalCount": 15,
  "unreadCount": 3,
  "hasMore": false
}
```

`messagePreview` is the summary text (truncated at 200 characters). The full `data` payload (campaignId, campaignName, etc.) is not returned by the list — fetch the underlying resource (campaign, creative, etc.) by ID for the full context.

### Mark Notification as Read

**Operation:** `read_notification`
```http
POST /api/v2/notifications/{notificationId}/read
```
**As operation:**
```json
{ "operation": "read_notification", "pathParams": { "notificationId": "<id>" } }
```

Marks a single notification as seen. No request body required.

### Mark Notification as Acknowledged

**Operation:** `acknowledge_notification`
```http
POST /api/v2/notifications/{notificationId}/acknowledge
```
**As operation:**
```json
{ "operation": "acknowledge_notification", "pathParams": { "notificationId": "<id>" } }
```

Marks a notification as dealt with. Acknowledged notifications are automatically cleaned up after 90 days. No request body required.

### Mark All Notifications as Read

**Operation:** `read_all_notifications`
```http
POST /api/v2/notifications/read-all
```
**As operation:**
```json
{ "operation": "read_all_notifications" }
```

**Optional body:**
```json
{ "brandAgentId": 123 }
```

If `brandAgentId` is provided, only marks notifications for that agent as read. Otherwise marks all unread notifications as read.

### Proactive Notification Setup

Unread notifications are automatically included in `help` and `ask_about_capability` tool responses. To ensure your AI agent surfaces them to users at the start of every session, add the following to your client configuration:

- **Claude Desktop**: Create a Project and add to the project instructions: `When using Scope3 tools, always start by calling the help tool. The response includes unread notifications — summarize those for the user before answering their question.`
- **Claude Code**: Add the same instruction to your `CLAUDE.md` or project instructions.
- **API / Custom Agent**: Add it to your system prompt.
- **ChatGPT Custom GPT**: Add it to your Custom GPT's instructions.

---

## Optimization Suggestions

Optimization suggestions are generated by Scope3's AI optimization model for active media buys. When `optimizationApplyMode` is `"MANUAL"` (the default), suggestions wait for human approval before being applied.

### List Optimization Suggestions
```http
GET /api/v2/buyer/optimization-suggestions?status=HIL_WAITING&limit=20&offset=0
```

**Query Parameters (all optional):**
- `campaignId` (number): Filter by campaign
- `mediaBuyId` (number): Filter by media buy
- `status` (string): Filter by status. Values: `CREATED`, `CREATED_FAILED`, `INGESTED`, `INGESTED_FAIL`, `HIL_WAITING`, `HIL_APPROVED`, `HIL_REJECTED`, `APPLIED`, `APPLIED_FAIL`, `REJECTED`, `EXPIRED`
- `limit` (number): Results per page (default: 20, max: 100)
- `offset` (number): Pagination offset

**Response:**
```json
{
  "suggestions": [
    {
      "suggestionId": "550e8400-e29b-41d4-a716-446655440000",
      "campaignId": "123",
      "mediaBuyId": "456",
      "optimizationMetric": "emissions",
      "status": "HIL_WAITING",
      "productAdjustmentCount": 3,
      "bidAdjustmentCount": 1,
      "createdAt": "2026-03-20T00:00:00.000Z",
      "statusUpdatedAt": "2026-03-20T01:00:00.000Z"
    }
  ],
  "totalCount": 5,
  "hasMore": false
}
```

**Display Requirements:** For each suggestion, show the suggestion ID, campaign/media buy IDs, optimization metric, current status, number of product and bid adjustments, and when it was created.

### Get Optimization Suggestion
```http
GET /api/v2/buyer/optimization-suggestions/{suggestionId}
```

Returns the full suggestion payload including product adjustments, bid adjustments, scaling factor, and rationale.

**Response:**
```json
{
  "suggestionId": "550e8400-e29b-41d4-a716-446655440000",
  "optimizerRunId": "run-abc123",
  "campaignId": "123",
  "mediaBuyId": "456",
  "suggestionTimestamp": "2026-03-20T00:00:00.000Z",
  "modelVersion": "v1.0",
  "campaignSummary": { "name": "Q1 CTV Campaign" },
  "optimizationMetric": "emissions",
  "mediaBuyScalingFactor": 1.2,
  "mediaBuyScalingRationale": "Scale up for improved emissions performance",
  "productAdjustments": [
    { "productId": "prod_1", "action": "add", "reason": "High performance" }
  ],
  "bidAdjustments": [
    { "productId": "prod_2", "bidChange": 0.5, "reason": "Reduce CPM" }
  ],
  "createdAt": "2026-03-20T00:00:00.000Z",
  "status": "HIL_WAITING",
  "statusReason": null,
  "statusError": null,
  "statusUpdatedAt": "2026-03-20T01:00:00.000Z"
}
```

**Display Requirements:** Show all suggestion details including product adjustments (with actions and reasons), bid adjustments, scaling factor and rationale, and the current status. Present the data clearly so the user can make an informed approve/reject decision.

### Approve Optimization Suggestion
```http
POST /api/v2/buyer/optimization-suggestions/{suggestionId}/approve
```

Approves a suggestion that is in `HIL_WAITING` status. No request body required.

**Response:**
```json
{
  "suggestionId": "550e8400-e29b-41d4-a716-446655440000",
  "status": "HIL_APPROVED"
}
```

**Error:** Returns 409 Conflict if the suggestion is not in `HIL_WAITING` status.

### Reject Optimization Suggestion
```http
POST /api/v2/buyer/optimization-suggestions/{suggestionId}/reject
```

Rejects a suggestion that is in `HIL_WAITING` status.

**Optional body:**
```json
{ "reason": "Not aligned with current strategy" }
```

**Response:**
```json
{
  "suggestionId": "550e8400-e29b-41d4-a716-446655440000",
  "status": "HIL_REJECTED"
}
```

**Error:** Returns 409 Conflict if the suggestion is not in `HIL_WAITING` status.

---

## Common Mistakes to Avoid

1. **Creating campaign without advertiser** — Always create/verify advertiser first
2. **Skipping product discovery** — Always use `discover_products` operation to discover products; use `browse_discovery` operation to browse more
3. **Optimization without event source** — You need an event source (`eventSourceId`) before creating a campaign with event-based optimization goals
4. **Optimization without conversion data** — System needs events logged via event sources to optimize for ROAS/conversions
5. **Forgetting to execute** — Campaigns start in DRAFT status; must use `execute_campaign` operation
6. **Wrong endpoint path** — Always use `/api/v2/buyer/` prefix
7. **Creating advertiser without brand** — `brand` is required. If brand resolution returns an enriched preview, show the preview and offer to retry with `saveBrand: true`. Only direct the user to external registration if no brand data is found at all
8. **Auto-selecting products for the user** — When the user wants to browse/select inventory, ALWAYS present discovery results and let them choose
9. **Defaulting to a configuration without asking** — When the user says "create a campaign" without specifying how to configure it, ask them to choose (product discovery or performance metrics)
10. **Fabricating field values** — NEVER guess or make up values for required fields. Always ask the user or use values from previous API responses
11. **Making multiple API calls in one turn** — ONE discovery/mutating call per turn. Present results, END YOUR TURN, wait for the user.
12. **Missing bid price for non-fixed pricing** — If a product's pricing option has `isFixed: false`, `bidPrice` is REQUIRED in the `add_discovery_products` request. Read it from the product's `pricingOptions` (`rate` or `floorPrice`) in the discovery response. Do NOT ask the user — the value comes from the product data.
13. **Summarizing list responses as prose** — When listing advertisers, sales agents, or campaigns, NEVER reduce the response to a sentence like "You have 13 advertisers." Always show the structured per-item details specified in the Display Requirements for that endpoint. The user needs to see each item's operational details, not a count.
14. **Using user-provided account IDs for linking** — NEVER use an account ID or account name that the user provides verbally. Account IDs for linking MUST come from the `list_available_accounts` operation. If the user says "link account 06cd7033..." or "the account is named XYZ", do NOT use that value directly — call the discovery endpoint first, find the matching account in the response, and use the `accountId` from the API response. If the account does not appear in the discovery results, tell the user it was not found — do NOT pretend to link it.
15. **Missing credentialId with multiple credentials** — When a customer has multiple credentials for the same agent, the `list_available_accounts` operation requires `credentialId`. If omitted, the API returns an error with available credential IDs. Present those to the user and ask which to use, then retry with the chosen `credentialId`.
