Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions docs/architecture/ssp-connectors.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,17 +108,17 @@ Magnite does not provide a buyer-facing deal creation API. The connector reads d

**File:** `connectors/index_exchange.py`

Index Exchange uses API key authentication and a simple REST endpoint. Publishers create deals in IX and specify buyer seat IDs; this connector discovers deals that have been targeted to the buyer's seat.
Index Exchange uses bearer-token authentication against the real Deals API's `/v3/deals` route. Publishers create deals in Index and specify buyer seat IDs; this connector discovers deals that have been targeted to the buyer's seat. There is no client-supplied seat parameter — visibility is determined server-side by the caller's authenticated identity.

| Detail | Value |
|--------|-------|
| Base URL | `https://api.indexexchange.com` |
| Auth | API key in `X-API-Key` header |
| Seat filtering | `seatId` query parameter |
| Endpoints | `GET /deals`, `GET /deals/{deal_id}` |
| Required env vars | `IX_API_KEY`, `IX_SEAT_ID` |
| Base URL | `https://app.indexexchange.com/api/deals` |
| Auth | `Authorization: Bearer <keycloak-jwt>` |
| Seat filtering | None — server-side, based on the authenticated caller |
| Endpoints | `GET /v3/deals`, `GET /v3/deals/{internalDealID}` |
| Required env vars | `IX_API_KEY` |

Deal type mapping: `PG` → `PG`, `PD` → `PD`, `PMP` → `PA`.
Deal type mapping (derived from `classID` + `directConfigurations.programmaticGuaranteed` + `auctionType`, not a `dealType` field): `classID=1,PG=true` → `PG`; `classID=1,PG=false,auctionType=fixed` → `PD`; everything else → `PA` (there is no `PMP` DealStore type).

---

Expand Down
11 changes: 5 additions & 6 deletions docs/guides/ssp-connectors.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,22 +119,21 @@ The connector authenticates, fetches all deals targeted to your seat, and normal

## Index Exchange

Index Exchange uses a simple API key header (`X-API-Key`). Deal creation in IX is publisher-side only -- this connector discovers deals that publishers have already targeted to your seat.
Index Exchange uses bearer-token authentication (`Authorization: Bearer <token>`). Deal creation in Index is publisher-side only -- this connector discovers deals that publishers have already targeted to your seat. There's no seat ID to configure -- which deals you see is determined server-side by your authenticated identity.

### Credentials

| Variable | Description |
|----------|-------------|
| `IX_API_KEY` | Your Index Exchange API key |
| `IX_SEAT_ID` | Your IX buyer seat/member ID |
| `IX_API_KEY` | Your Index Exchange Keycloak bearer token |
| `IX_API_URL` | Optional. Overrides the API base URL. Defaults to production if not set. A staging token will not authenticate against production, or vice versa — get this wrong and every call fails with a connection/auth error unrelated to the credential itself. |

Get these from your Index Exchange account team.

### Setup

```bash
export IX_API_KEY="your-api-key"
export IX_SEAT_ID="your-seat-id"
export IX_API_KEY="your-bearer-token"
```

### Test Connectivity
Expand Down Expand Up @@ -186,7 +185,7 @@ One or more required environment variables is missing or empty. Run `list_ssp_co

- **PubMatic**: The bearer token is invalid or expired. Regenerate it in the PubMatic API portal.
- **Magnite**: The access key or secret key is wrong. Check for leading/trailing whitespace.
- **Index Exchange**: The API key is invalid. Contact your IX account team.
- **Index Exchange**: The API key is invalid. Contact your Index Exchange account team.

### Rate limit errors (429)

Expand Down
53 changes: 26 additions & 27 deletions docs/planning/SSP_INTEGRATION_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -335,38 +335,39 @@ class MagniteConnector(SSPConnector):

## 6. Index Exchange Connector (Priority 3)

Index Exchange is the #1 US web SSP (19% share). Its deal model is strictly publisher-side: publishers create deals via IX's UI or API, specifying buyer seats. The buyer agent's IX connector discovers and imports deals that have been created by publishers and targeted to the buyer's seat.
Index Exchange is the #1 US web SSP (19% share). Its deal model is strictly publisher-side: publishers create deals via Index's UI or API, specifying buyer seats. The buyer agent's Index connector discovers and imports deals that have been created by publishers and targeted to the buyer's seat.

### 6.1 API details

- **Base URL:** `https://api.indexexchange.com`
- **Authentication:** API key (login required via IX API portal to obtain)
Confirmed against the Deals API, the service implementing these routes.

- **Base URL:** `https://app.indexexchange.com/api/deals`
- **Authentication:** `Authorization: Bearer <keycloak-jwt>` (not an API key header)
- **Key endpoints:**
- `GET /deals` — list deals targeted to the buyer's seat
- `GET /deals/{deal_id}` — single deal detail
- **Note:** The "Create deal" endpoint in IX's API docs is publisher-side. The buyer agent uses the GET endpoints only.
- **Deal activation model:** IX uses "easy deal activation" — buyers can discover and activate deals created by publishers without manually entering deal IDs
- `GET /v3/deals` — list deals targeted to the buyer's seat
- `GET /v3/deals/{internalDealID}` — single deal detail
- **Note:** The "Create deal" endpoint (`POST /v3/deals`) is publisher-side. The buyer agent uses the GET endpoints only.
- **No seat ID parameter:** which deals are visible is determined server-side by the caller's authenticated identity — there's no client-supplied `seatId` on `GET /v3/deals`.

### 6.2 Data mapping (Index Exchange API response → DealStore)

| IX Field | DealStore Field | Notes |
| Index Field | DealStore Field | Notes |
|----------|-----------------|-------|
| `dealId` | `seller_deal_id` | |
| `externalDealID` | `seller_deal_id` | The OpenRTB deal ID buyers reference in bid requests. |
| `internalDealID` | `description` | No first-class DealStore field for this; preserved for future write ops. |
| `name` | `display_name` | |
| `status` | `status` | |
| `dealType` | `deal_type` | Map: `PMP`→`PA`, `PG`→`PG`, `PD`→`PD` |
| `floorPrice` | `bid_floor_cpm` | |
| `price` | `fixed_price_cpm` | PG/PD |
| `currency` | `currency` | |
| `publisherDomain` | `seller_domain` | |
| `adType` | `media_type` | Map: `video`→`CTV`, `display`→`DIGITAL` |
| `status` | `status` | Map: `active`→`active`, `paused`/`expired`/`auto-paused`→`paused`. |
| `classID` + `directConfigurations.programmaticGuaranteed` + `auctionType` | `deal_type` | Map: `classID=1,PG=true`→`PG`; `classID=1,PG=false,fixed`→`PD`; everything else (`classID` 1-first/3/4/5)→`PA`. There is no `PMP` DealStore type. |
| `floor` | `fixed_price_cpm` / `bid_floor_cpm` | Single field; goes to `fixed_price_cpm` for PG and PD deals, `bid_floor_cpm` otherwise. |
| `targeting[].keyName` (geo keys only) | `geo_targets` | `targeting` is a structured array, not a flat dict — filter to known geo `keyName`s (`Country`, `region`, `continent`, `place`, `area`, `zipcode`, `domain`). |
| `startDate` | `flight_start` | |
| `endDate` | `flight_end` | |
| `impressions` | `impressions` | |
| `description` | `description` | |
| `directConfigurations.impressionGoal` | `impressions` | PG deals only. |
| (hardcoded) | `currency` | `"USD"` — not returned by the API. |
| (hardcoded) | `seller_org` | `"Index Exchange"` |
| (hardcoded) | `seller_type` | `"SSP"` |
| (hardcoded) | `seller_url` | `"https://api.indexexchange.com"` |
| (hardcoded) | `seller_url` | `"https://app.indexexchange.com/api/deals"` |
| (always `None`) | `media_type`, `formats`, `seller_domain`, `content_categories`, `audience_segments` | No `adType`, `formats`, `publisherDomain` fields exist on this API; no confirmed standard targeting key for content/audience. |

### 6.3 Connector configuration

Expand All @@ -378,8 +379,7 @@ class IndexExchangeConnector(SSPConnector):
imports deals that publishers have targeted to the buyer's seat.

Credentials:
api_key: Index Exchange API key
seat_id: Buyer seat/member ID
api_key: Index Exchange Keycloak bearer token
"""
```

Expand Down Expand Up @@ -419,10 +419,10 @@ Returns a JSON list of available connectors, each with:
- `page_size`: int, default 100
- Returns: same structure as `import_deals_csv`

**`import_deals_index_exchange(status, deal_type, page_size)`**
- `status`: filter, default `"all"`
- `deal_type`: filter, default `"all"`
- `page_size`: int, default 100
**`import_deals_index_exchange(status, class_ids, page_size)`**
- `status`: filter — `"active"`, `"paused"`, `"expired"`, `"auto-paused"`, or `"all"` (default `"all"`)
- `class_ids`: filter — list of Index `classID` ints (`1`, `3`, `4`, `5`); omitted means no filter. There is no `deal_type` filter param on this API.
- `page_size`: int, default 100 (capped at 2000, the API maximum)
- Returns: same structure as `import_deals_csv`

### 7.3 Credential management
Expand All @@ -440,8 +440,7 @@ MAGNITE_SECRET_KEY=<secret_key>
MAGNITE_SEAT_ID=<seat_id>

# Index Exchange
IX_API_KEY=<api_key>
IX_SEAT_ID=<seat_id>
IX_API_KEY=<keycloak_bearer_token>
```

If credentials for an SSP are not set, the corresponding MCP tool returns an informative error (rather than raising an exception) and `list_ssp_connectors` marks that connector as `configured: false`.
Expand Down
31 changes: 29 additions & 2 deletions src/ad_buyer/interfaces/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1393,15 +1393,24 @@ def create_deal_manual(
def get_portfolio_summary(
top_sellers_count: int = 5,
expiring_within_days: int = 30,
seller_org: str | None = None,
seller_domain: str | None = None,
) -> str:
"""Get aggregate statistics and summary for the deal portfolio.

Provides counts by status, deal type, media type, top sellers,
total portfolio value, and deals expiring soon.

By default this summarizes every deal in the local deal library,
across all sellers and SSPs ever synced — not just one account. Pass
seller_org and/or seller_domain to scope the summary down to a single
seller for an apples-to-apples comparison against that seller's API.

Args:
top_sellers_count: Number of top sellers to include (default 5).
expiring_within_days: Show deals expiring within N days (default 30).
seller_org: Restrict to deals from this seller_org (e.g. "Index Exchange").
seller_domain: Restrict to deals from this seller_domain.

Returns a JSON object with:
- total_deals: total number of deals
Expand All @@ -1419,6 +1428,8 @@ def get_portfolio_summary(
store,
top_sellers_count=top_sellers_count,
expiring_within_days=expiring_within_days,
seller_org=seller_org,
seller_domain=seller_domain,
)
return json.dumps(result, indent=2)
finally:
Expand Down Expand Up @@ -2352,7 +2363,7 @@ def list_ssp_connectors() -> str:

@mcp.tool()
@_require_operator
def import_deals_ssp(ssp_name: str) -> str:
def import_deals_ssp(ssp_name: str, filters: dict[str, Any] | None = None) -> str:
"""Import deals from a specified SSP connector into the deal portfolio.

Instantiates the named SSP connector, calls its API to fetch deals,
Expand All @@ -2363,6 +2374,13 @@ def import_deals_ssp(ssp_name: str) -> str:
ssp_name: Which SSP to import from. One of:
``"pubmatic"``, ``"magnite"``, ``"index_exchange"``.
Case-insensitive.
filters: Optional connector-specific fetch filters, passed straight
through as keyword arguments to that connector's fetch_deals().
Each SSP accepts different keys — for Index Exchange:
{"status": "active", "class_ids": [1, 4], "account_ids": [1470498],
"page_size": 100}. Without account_ids, a broadly-scoped
credential can page through every deal on the platform, not
just the caller's own account(s) — scope this whenever possible.

Returns a JSON object with:
- total_rows: total deals fetched from the SSP
Expand Down Expand Up @@ -2405,9 +2423,18 @@ def import_deals_ssp(ssp_name: str) -> str:
# Fetch + persist normalised deals via the deal service
store = _get_deal_store()
try:
result = deal_service.import_deals_ssp(store, connector)
result = deal_service.import_deals_ssp(store, connector, filters=filters)
result["ssp_name"] = key
return json.dumps(result, indent=2)
except ValueError as exc:
return json.dumps(
{
"error": str(exc),
"ssp_name": key,
"timestamp": datetime.now(UTC).isoformat(),
},
indent=2,
)
finally:
if _deal_store_override is None:
store.disconnect()
Expand Down
29 changes: 25 additions & 4 deletions src/ad_buyer/services/deal_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ def list_deals(
deal_summaries = [
{
"deal_id": d["id"],
"seller_deal_id": d.get("seller_deal_id"),
"display_name": d.get("display_name") or d.get("product_name") or "(unnamed)",
"status": d.get("status", "unknown"),
"deal_type": d.get("deal_type", "unknown"),
Expand Down Expand Up @@ -118,6 +119,7 @@ def search_deals(store: DealStore, query: str) -> dict[str, Any]:
matches.append(
{
"deal_id": deal["id"],
"seller_deal_id": deal.get("seller_deal_id"),
"display_name": (
deal.get("display_name") or deal.get("product_name") or "(unnamed)"
),
Expand Down Expand Up @@ -217,9 +219,19 @@ def portfolio_summary(
*,
top_sellers_count: int = 5,
expiring_within_days: int = 30,
seller_org: str | None = None,
seller_domain: str | None = None,
) -> dict[str, Any]:
"""Aggregate portfolio statistics (counts, value, top sellers, expiring)."""
deals = store.list_deals(limit=10000)
"""Aggregate portfolio statistics (counts, value, top sellers, expiring).

By default this summarizes every deal in the local deal library,
across all sellers and SSPs ever synced -- not just one account. Pass
seller_org and/or seller_domain to scope the summary down to a single
seller for an apples-to-apples comparison against that seller's API.
"""
deals = store.list_deals(limit=10000, seller_domain=seller_domain)
if seller_org is not None:
deals = [d for d in deals if d.get("seller_org") == seller_org]
total = len(deals)

if total == 0:
Expand Down Expand Up @@ -356,14 +368,23 @@ def import_deals_csv(
}


def import_deals_ssp(store: DealStore, connector: Any) -> dict[str, Any]:
def import_deals_ssp(
store: DealStore,
connector: Any,
*,
filters: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Fetch deals from an already-configured SSP connector and persist them.

The connector instance (and its configuration checks) are resolved by
the caller; this function performs the fetch + persistence and shapes
the result identically to ``import_deals_csv``.

Args:
filters: Optional connector-specific fetch filters, passed straight
through as keyword arguments to the connector's fetch_deals().
"""
fetch_result = connector.fetch_deals()
fetch_result = connector.fetch_deals(**(filters or {}))

deal_ids: list[str] = []
today = datetime.now(UTC).strftime("%Y-%m-%d")
Expand Down
Loading