# INSTRUCTIONS-TABOOLA.md

# adsmcp — Taboola Native Ads API Server

## Overview
adsmcp wraps the **Taboola Backstage API** for native-advertising campaigns. Each request runs against the connected user's own live Taboola credentials. The server normalizes ergonomic camelCase JSON into the exact snake_case shapes Taboola expects, resolves friendly date presets into the `start_date`/`end_date` pairs Taboola's reports require, and surfaces Taboola's original error envelope (`http_status` + `message`) when something goes wrong. Every endpoint listed below is testable from the API Docs page in the browser.

This document covers **only** the Taboola endpoints (`/api/taboola/*`). Meta endpoints (`/api/meta/*`) are documented in `instructions.md` and Google Search endpoints (`/api/google/*`) in `instructions-google.md` — no file references another's endpoints.

## Authentication
Every endpoint in this document requires an **API key** sent as the `X-API-Key` request header. Each user account has a single long-lived key shown on the API Docs page; from there it can be copied or regenerated. Regenerating immediately invalidates the previous key.

```
X-API-Key: amk_<64 hex chars>
```

The in-browser tester sends this header for you automatically. External callers (Postman, curl, AI agents) must add the header explicitly. Requests without a valid `X-API-Key` return `401 { error: "Missing API key (X-API-Key) or bearer token" }` or `401 { error: "Invalid API key" }`.

### Connecting a Taboola account
Taboola has no browser OAuth flow. Your Taboola account manager issues an **API `client_id` / `client_secret` pair**; you paste those into the **Taboola connection** card on the dashboard once. The server validates them by minting an OAuth2 `client_credentials` access token and discovering every account the credentials may operate on. Tokens live ~12 hours and are re-minted automatically server-side — there is nothing to refresh manually. The `client_secret` is stored server-side and never echoed back.

Programmatic connect (JWT or API key auth):

```
POST /api/integrations/taboola/connect
{ "clientId": "...", "clientSecret": "..." }
```

Other integration endpoints: `GET /api/integrations/taboola/status` (connection state + cached account list — never includes the secret), `POST /api/integrations/taboola/default-account` (`{ "accountId": "my-advertiser-account" }` — sets which account is used when `accountId` is omitted on calls), `POST /api/integrations/taboola/disconnect`.

## Core concepts — read before calling anything

1. **Account ids are textual slugs, not numbers.** Every Taboola account has both a numeric `id` and a textual `account_id` (e.g. `"my-advertiser-account"`). The **`account_id` slug** is what every `/api/taboola/*` endpoint takes as `accountId`. Get it from `GET /api/taboola/accounts`. When `accountId` is omitted, the server falls back to the default account chosen at connect time (the first ADVERTISER-typed account).

2. **Money is decimal dollars on the wire — no conversion.** This is the opposite of the Meta side (dollars → cents) and the Google side (dollars → micros). `cpc: 0.45` means 45 cents; `spending_limit: 1000` means $1,000; report `spent` values are already dollars. Amounts are in the **account currency** (see `currency` on the accounts list).

3. **The campaign hierarchy is flat: campaign → items.** There is no ad-set/ad-group layer. Targeting, budget, bidding, and scheduling all live on the **campaign**. An **item** is one ad: a landing URL + title + thumbnail + optional description/CTA.

4. **Item creation is asynchronous.** `POST /api/taboola/items` accepts exactly ONE field — the landing `url`. Taboola's crawler then fetches the page to derive a provisional title and thumbnail, and the item sits in status `CRAWLING` (**read-only**) until it finishes. Poll `GET /api/taboola/items?campaignId=…` until the status moves to `RUNNING` or `NEED_TO_EDIT`, then `PATCH /api/taboola/items/:id` to set the real `title`, `thumbnailUrl`, `description`, and `cta`. Attempting to PATCH while still `CRAWLING` returns Taboola's own error.

5. **`is_active` is the writable on/off switch; `status` is read-only.** A campaign's (or item's) `status` is computed by Taboola — `RUNNING`, `PAUSED`, `PENDING_APPROVAL`, `DEPLETED`, `EXPIRED`, `TERMINATED`, `FROZEN`, `REJECTED` for campaigns; `CRAWLING`, `RUNNING`, `NEED_TO_EDIT`, `PAUSED`, `PENDING_APPROVAL`, `REJECTED`, `STOPPED`, `CRAWLING_ERROR` for items. To pause or resume, write `is_active` (or use the `/status` sugar endpoints with `ACTIVE`/`PAUSED`).

6. **Campaigns are created PAUSED by default.** Every `POST /api/taboola/campaigns` defaults `isActive: false` so accidental writes never spend money. Flip live with `PATCH /api/taboola/campaigns/:id/status` `{ "status": "ACTIVE" }` when ready. (New campaigns also pass through Taboola review — `PENDING_APPROVAL` — before serving.)

7. **Targeting uses `{ type, value }` blocks with dictionary-looked-up codes.** Every targeting field is an object `{ "type": "INCLUDE" | "EXCLUDE", "value": [...] }`. Country codes come from `GET /api/taboola/countries`, region codes from `GET /api/taboola/regions?countryCode=…`, platform codes from `GET /api/taboola/platforms`. **Never guess or invent codes.** On updates, a targeting block **replaces** the existing block wholesale — send the full object you want, not a delta.

8. **Time ranges: `datePreset` OR `since`+`until`, never both. Default `last_30d`.** Same convention as the Meta and Google sides. Allowed presets: `today`, `yesterday`, `last_3d`, `last_7d`, `last_14d`, `last_28d`, `last_30d`, `last_90d`, `this_month`, `last_month`, `this_year`, `last_year`, `lifetime`, `maximum` (the last two map to Taboola's 3-year reporting retention). Custom ranges are `YYYY-MM-DD`, inclusive, in the account's timezone.

## Workflow Guidelines

### Discovering available resources
1. `GET /api/taboola/accounts` — list the accounts the credentials can operate on. Use the `account_id` slug from here as `accountId` everywhere. Accounts flagged with `partner_types: ["ADVERTISER"]` are launch targets; `PUBLISHER`-only accounts are not.
2. `GET /api/taboola/campaigns?accountId=…` — existing campaigns (add `includeStats=true` for performance).
3. `GET /api/taboola/countries` / `GET /api/taboola/regions?countryCode=US` / `GET /api/taboola/platforms` — the dictionary codes targeting blocks need.

### Launching a campaign — the full sequence
1. **Create the campaign (paused).** `POST /api/taboola/campaigns` with `name`, `brandingText`, `marketingObjective`, bidding, budget, and targeting. Capture `campaign.id` from the response.
2. **Create the item(s).** `POST /api/taboola/items` with `{ campaignId, url }` per ad. Each comes back in `CRAWLING`.
3. **Poll until crawling finishes.** `GET /api/taboola/items?accountId=…&campaignId=…` — wait for each item's `status` to be `RUNNING` or `NEED_TO_EDIT`. Typically seconds to a couple of minutes.
4. **Set the real creative.** `PATCH /api/taboola/items/:id` with `{ campaignId, title, thumbnailUrl, description, cta }`. The crawler-derived title/thumbnail is almost never what you want to run.
5. **Go live.** `PATCH /api/taboola/campaigns/:id/status` `{ "status": "ACTIVE" }`. The campaign then passes Taboola review (`PENDING_APPROVAL`) before serving.

### Launch playbook for AI agents
- **`brandingText` is your advertiser display name** — it renders as "By {brandingText}" under the headline on most placements. Use the brand's real name, not a campaign label.
- **Pick the bid strategy by what you can measure.** `FIXED` (manual CPC — predictable, good starting point), `SMART` (enhanced CPC — Taboola adjusts around your `cpc` baseline), `MAX_CONVERSIONS` (fully automated — needs conversion tracking installed), `TARGET_CPA` (needs conversion tracking + a realistic `cpaGoal`). If the account has no Taboola pixel/conversion events, use `FIXED` or `SMART`; the conversion-based strategies have nothing to optimize toward.
- **Budget guardrails.** Set `spendingLimit` + `spendingLimitModel` (`ENTIRE` = lifetime, `MONTHLY` = resets monthly) AND a `dailyCap` so a new campaign can't burn the whole budget in a day. `dailyAdDeliveryModel: "STRICT"` enforces the daily cap as a hard stop (requires `dailyCap`); `BALANCED` paces it.
- **Launch multiple items per campaign — 3 to 10.** Taboola's engine optimizes by rotating items and shifting impressions to the ones that earn clicks; a single item gives it nothing to learn from. Vary the headline angle across items (curiosity, benefit, social proof, urgency), not just the image.
- **Native headlines are not search ads.** What works on Taboola's feed placements is editorial-sounding copy ("How Retirees Are Cutting Their Bills In Half") rather than transactional copy ("Buy Cheap Insurance Now"). Keep titles under ~60 characters so they don't truncate on smaller placements.
- **Thumbnails: real photos beat graphics.** Taboola placements sit inside editorial feeds; stocky compositions and text-overlaid banners underperform. Use a publicly fetchable image URL (Taboola re-hosts it).
- **Review the site report weekly, then block.** `GET /api/taboola/insights?level=site&campaignId=…` shows spend per publisher. Native networks have long tails of low-quality placements — find sites spending money with zero conversions and exclude them via `publisherTargeting: { "type": "EXCLUDE", "value": ["site-name-1", …] }` on a campaign PATCH (the `site` field in report rows is the value to block; the block list replaces wholesale, so always send the full accumulated list).
- **Iterate on items like RSAs.** Pull `GET /api/taboola/items?campaignId=…&includeStats=true` after 5–7 days, pause the items with high spend + low CTR/conversions (`PATCH /api/taboola/items/:id/status` → `PAUSED`), and add fresh items testing new angles. CTR is the dominant ranking signal in Taboola's auction — higher CTR lowers your effective CPC.

## Conventions (apply to every endpoint below)
- `accountId` = the textual `account_id` slug; optional everywhere when a default account was set at connect time.
- Request bodies are camelCase; the server maps to Taboola's snake_case. Raw snake_case targeting keys (`country_targeting`, …) are also accepted as pass-through.
- Responses return Taboola's objects **unreshaped** (snake_case, Taboola's field names) inside a thin envelope (`{ accountId, count, data }` for lists; `{ success, campaign | item }` for writes).
- All `*Ids` query params accept comma-separated lists.
- Errors return Taboola's envelope: `{ statusCode, error, http_status, message_code, raw }` with the original HTTP status preserved.

---

## Read Endpoints

### GET /api/taboola/accounts
Lists every Taboola account the connected credentials can operate on.

Query params: `cached` (`true` returns the connect-time snapshot; default `false` does a live fetch and refreshes the cache).

Response: `{ data: [ { id, account_id, name, partner_types, type, campaign_types, currency, time_zone_name } ], count, cached }`. Use `account_id` (the slug) as `accountId` on every other endpoint.

### GET /api/taboola/campaigns
Lists campaigns under the account, or fetches specific ids directly.

Query params:
- `accountId` — optional with a default account set.
- `campaignIds` — comma-separated; when set, each id is fetched directly (a bad id is reported in `failed[]` without sinking the rest).
- `keyword` — case-insensitive substring filter on campaign name (server-side).
- `status` — filter on Taboola's computed status (`RUNNING`, `PAUSED`, `PENDING_APPROVAL`, `DEPLETED`, `EXPIRED`, `TERMINATED`, `FROZEN`, `REJECTED`).
- `isActive` — `true` | `false`, filter on the writable flag.
- `fetchLevel` — Taboola's `fetch_level`: `R` (recent non-paused only) or `RAP` (recent including paused). Omit for all campaigns.
- `includeStats` — `true` decorates each campaign with `.insights = { range, summary, raw }`. One `campaign_breakdown` report covers the whole account for the chosen range (a single extra round-trip regardless of campaign count).
- `datePreset` / `since`+`until` — stats range; default `last_30d`.

`insights.summary` fields: `spend`, `clicks`, `impressions`, `visible_impressions`, `conversions`, `conversions_value`, `ctr`, `vctr`, `cpc`, `cpa`, `roas`. Money in dollars.

### GET /api/taboola/items
Lists the items (ads) under **one campaign**. `campaignId` is **required** — items only exist inside a campaign.

Query params: `accountId`, `campaignId` (required), `itemIds` (comma-separated filter), `keyword` (substring on title/url), `status` (`CRAWLING`, `RUNNING`, `NEED_TO_EDIT`, `PAUSED`, `PENDING_APPROVAL`, `REJECTED`, `STOPPED`, `CRAWLING_ERROR`), `includeStats`, `datePreset`/`since`+`until`.

Item rows: `{ id, campaign_id, type, url, title, description, thumbnail_url, cta, approval_state, is_active, status }`. With `includeStats=true`, each item gets `.insights = { range, summary, raw }` joined from the top-campaign-content report.

This is also the **polling endpoint** for the async item-creation flow — call it until a freshly created item's `status` leaves `CRAWLING`.

### GET /api/taboola/insights
Aggregated metrics at any level — the friendly wrapper over Taboola's reports. Mirrors `/api/meta/insights` and `/api/google/insights`.

Query params:
- `accountId`
- `level` — `campaign` (default) | `item` | `day` | `week` | `month` | `hour` | `site` | `country` | `region` | `dma` | `platform` | `os` | `browser`.
- `campaignId` — optional scope-to-one-campaign filter (recommended for `level=item`).
- `datePreset` / `since`+`until`.

Response: `{ accountId, level, dimension, range, count, summary, data, lastDataUpdate }`. `data` rows are Taboola's raw report rows (metric names: `spent`, `clicks`, `impressions`, `visible_impressions`, `cpa_actions_num`, `conversions_value`, `ctr`, `vctr`, `cpc`, `cpa`, `cpm`, `vcpm`, `roas`, `currency`). `summary` is the server's roll-up (same fields as the campaigns `includeStats` summary). `level=item` pulls from the top-campaign-content report; everything else from campaign-summary.

Use `level=site` to find publishers to block, `level=platform` to compare desktop/mobile/tablet, `level=hour` to inform an `activitySchedule`, `level=item` to decide which ads to pause.

### GET /api/taboola/reports/campaign-summary
Raw passthrough to **any** documented campaign-summary dimension — the escape hatch for breakdowns `/insights` doesn't map.

Query params: `accountId`, `dimension` (default `campaign_breakdown`; allowed: `day`, `week`, `month`, `by_hour_of_day`, `campaign_breakdown`, `campaign_day_breakdown`, `campaign_hour_breakdown`, `campaign_site_day_breakdown`, `site_breakdown`, `country_breakdown`, `region_breakdown`, `dma_breakdown`, `platform_breakdown`, `os_family_breakdown`, `os_version_breakdown`, `browser_breakdown`, `user_segment_breakdown`, `contextual_breakdown`, `content_provider_breakdown`, `content_provider_country_breakdown`), `campaignId`, `platform` (`DESK`|`PHON`|`TBLT`), `country` (2-letter ISO), `site`, `datePreset`/`since`+`until`.

⚠ `platform`, `country`, and `site` are **mutually exclusive** filters on Taboola's side — pass at most one.

Response: `{ accountId, dimension, range, count, summary, data, metadata, lastDataUpdate }`. `lastDataUpdate` is Taboola's `last-used-rawdata-update-time` freshness marker — report data lags real time by a few hours.

### GET /api/taboola/reports/top-campaign-content
Item-level performance — one row per item with creative fields (`item`, `item_name`, `url`, `thumbnail_url`, `campaign`, `campaign_name`) plus the full metric set. The only dimension Taboola supports here is `item_breakdown`, so there's no dimension param.

Query params: `accountId`, `campaignId` (optional filter), `datePreset`/`since`+`until`. Reporting data is retained for 3 years.

### GET /api/taboola/countries
Dictionary: country codes for `countryTargeting.value`. Rows: `{ name: "US", value: "United States" }` — the `name` (2-letter code) goes in the targeting array. No params.

### GET /api/taboola/regions
Dictionary: region codes for `subCountryTargeting.value` (e.g. US states). Query params: `countryCode` (required, 2-letter ISO, case-insensitive). Rows: `{ name: "AL", value: "Alabama" }`. **Always look codes up here before applying sub-country targeting.**

### GET /api/taboola/platforms
Dictionary: device platform codes for `platformTargeting.value`: `DESK` (Desktop), `PHON` (Smartphone), `TBLT` (Tablet). No params.

---

## Write Endpoints

### POST /api/taboola/campaigns
Creates a campaign. **Created paused (`isActive: false`) by default** — flip live via the status endpoint when ready.

```json
{
  "accountId": "my-advertiser-account",
  "name": "Spring 2026 - Native",
  "brandingText": "Acme Co",
  "marketingObjective": "DRIVE_WEBSITE_TRAFFIC",
  "bidStrategy": "FIXED",
  "cpc": 0.45,
  "spendingLimit": 1000,
  "spendingLimitModel": "ENTIRE",
  "dailyCap": 50,
  "dailyAdDeliveryModel": "BALANCED",
  "startDate": "2026-06-15",
  "endDate": "2026-07-15",
  "countryTargeting": { "type": "INCLUDE", "value": ["US"] },
  "platformTargeting": { "type": "INCLUDE", "value": ["DESK", "PHON"] },
  "isActive": false
}
```

Field rules:
- `name`, `brandingText`, `marketingObjective` — **required**. `marketingObjective` ∈ `DRIVE_WEBSITE_TRAFFIC`, `LEADS_GENERATION`, `ONLINE_PURCHASES`, `BRAND_AWARENESS`.
- `bidStrategy` ∈ `MAX_CONVERSIONS` (default), `TARGET_CPA`, `SMART`, `FIXED`. **`FIXED` and `SMART` require `cpc`** (decimal dollars — the fixed bid / the optimization baseline). **`TARGET_CPA` requires `cpaGoal`.** The server enforces these with a 400 before calling Taboola.
- `spendingLimit` (dollars) + `spendingLimitModel` ∈ `ENTIRE` (lifetime), `MONTHLY` (resets monthly), `NONE`. Defaults: `ENTIRE` when `spendingLimit` is set, `NONE` otherwise.
- `dailyCap` (dollars/day) + `dailyAdDeliveryModel` ∈ `BALANCED`, `ACCELERATED`, `STRICT` (STRICT requires `dailyCap`).
- `startDate` / `endDate` — `YYYY-MM-DD`. Omit `endDate` to run indefinitely.
- `trackingCode` — UTM suffix appended to every item URL.
- Targeting blocks (all optional, all `{ type: "INCLUDE"|"EXCLUDE", value: [...] }`): `countryTargeting`, `subCountryTargeting`, `postalCodeTargeting`, `contextualTargeting`, `platformTargeting`, `osTargeting` (`value: [{ "os_family": "Android" }]`), `connectionTypeTargeting`, `publisherTargeting` (block list — values are publisher `site` names from the site_breakdown report). Sub-country targeting generally requires a single country in `countryTargeting`.
- `activitySchedule` — Taboola day-parting: `{ "mode": "CUSTOM", "rules": [{ "type": "INCLUDE", "day": "MONDAY", "from_hour": 9, "until_hour": 17 }], "time_zone": "US/Eastern" }`. Omit for 24/7.

Response: `{ success: true, accountId, campaign }` — `campaign` is Taboola's full campaign object; capture `campaign.id` for the item-creation step.

### PATCH /api/taboola/campaigns/:id
Partial update — pass `accountId` plus any subset of the create fields (plus `isActive`). Omitted fields are left untouched. Targeting blocks **replace** the existing block wholesale. (Taboola itself takes updates as POST on the campaign URL; this server exposes the conventional PATCH verb like the Meta/Google sides.)

Common uses: raise/lower `cpc`, change `spendingLimit`/`dailyCap`, swap a `countryTargeting` block, grow the `publisherTargeting` block list, pause via `isActive: false`.

### PATCH /api/taboola/campaigns/:id/status
`{ "accountId": "…", "status": "ACTIVE" | "PAUSED" }` — sugar over `is_active` so callers can use the same vocabulary as the Meta/Google sides.

### POST /api/taboola/items
Creates one item (ad) under a campaign. **The body takes exactly one creative field — `url`** (plus the addressing fields):

```json
{ "accountId": "my-advertiser-account", "campaignId": "12345678", "url": "https://example.com/landing-page" }
```

The item returns in status `CRAWLING` (read-only). The response carries a `note` reminding you of the flow: poll `GET /api/taboola/items?campaignId=…` until the status is `RUNNING` or `NEED_TO_EDIT`, then PATCH the creative fields. Create one item per ad variant — 3–10 per campaign is the healthy range.

### PATCH /api/taboola/items/:id
Partial update of an item. `campaignId` is required in the body (items are addressed under their campaign on Taboola's side).

```json
{
  "accountId": "my-advertiser-account",
  "campaignId": "12345678",
  "title": "You Won't Believe These 2026 Rates",
  "thumbnailUrl": "https://cdn.example.com/thumb.jpg",
  "description": "Compare offers in under 2 minutes.",
  "cta": "LEARN_MORE"
}
```

Fields: `title`, `description`, `thumbnailUrl` (publicly fetchable image URL — Taboola re-hosts it), `url`, `cta` (bare string like `"LEARN_MORE"` or Taboola's `{ "cta_type": "LEARN_MORE" }` — common values: `LEARN_MORE`, `SHOP_NOW`, `SIGN_UP`, `DOWNLOAD`, `INSTALL_NOW`, `BOOK_NOW`, `READ_MORE`, `GET_QUOTE`, `PLAY_NOW`), `isActive`. Fails with Taboola's own error while the item is still `CRAWLING`.

### PATCH /api/taboola/items/:id/status
`{ "accountId": "…", "campaignId": "…", "status": "ACTIVE" | "PAUSED" }` — item-scoped pause/resume sugar.

---

## Errors
The server preserves Taboola's HTTP status and envelope. Every error response looks like:

```json
{
  "statusCode": 400,
  "error": "<Taboola's human-readable message>",
  "http_status": 400,
  "message_code": null,
  "raw": { "http_status": 400, "message": "..." }
}
```

Common cases:
- `400 Taboola integration not connected` — connect on the dashboard first.
- `400` validation errors from this server (missing required field, invalid enum, `datePreset` + `since` passed together) — these have `error` but no `raw`.
- `401` from Taboola — the access token expired mid-flight; the server re-mints automatically on the next call. Retry once.
- `403` — the credentials aren't permitted on that `accountId`. Check `GET /api/taboola/accounts`.
- `404` — unknown account/campaign/item id.
- Item PATCH while `CRAWLING` — Taboola rejects it; poll `GET /api/taboola/items` until the status changes.

## Logging
Every request to `/api/taboola/*` produces exactly one row in the activity log (visible on the dashboard, system tag `taboola`), including the full trace of outbound Backstage API calls made while handling it — method, URL, status, duration, and Taboola's raw error body when applicable. Credentials are never written to the log.
