# INSTRUCTIONS.md

# adsmcp — Meta Ads API Server

## Overview
adsmcp wraps the Meta (Facebook) Marketing API. Each request runs against the connected user's own live Meta credentials. The server normalizes ergonomic JSON into the exact Graph API shapes Meta expects, follows pagination automatically, and surfaces Meta's original error codes when something goes wrong. Every endpoint listed below is testable from the API Docs page in the browser.

## Authentication
Every endpoint listed in this document requires an **API key** sent as the `X-API-Key` request header. Each user account has a single long-lived key that is 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 on the API Docs page sends this header for you automatically using the logged-in user's key. 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" }`.

## Workflow Guidelines

### Discovering Available Resources
1. Call `GET /api/meta/accounts` to list ad accounts the user can manage
2. Call `GET /api/meta/pages` to list Facebook Pages the user can publish from
3. Call `GET /api/meta/pixels?adAccountId=…` to list pixels for a chosen ad account

### Creating a New Campaign + Ad Set + Ad
1. Call `POST /api/meta/campaigns` → capture `campaign.id`
2. Call `POST /api/meta/adsets` with `campaignId` → capture `adset.id`
3. Call `POST /api/meta/ads` (multipart) with `adsetId` + `media` file → returns `ad.id`
4. Default `status` is `PAUSED` for all three so accidental writes never spend money. Flip the campaign live in Ads Manager when you are ready

### Pulling Performance Data
- Add `includeStats=true` to `/campaigns`, `/adsets`, or `/ads` to embed insight rows + a summary on each object
- Use `datePreset` (default `last_30d`) **or** `since`+`until` — never both
- For raw insights on a specific object id, call `GET /api/meta/insights?objectId=…&level=…`

### Searching
- Use `keyword=` on `/campaigns`, `/adsets`, or `/ads` for case-insensitive substring match on `name`
- Use `campaignIds=`, `adsetIds=`, or `adIds=` (comma-separated) to fetch specific known objects directly
- Resolution order when multiple id lists are sent: `adIds` > `adsetIds` > `campaignIds` > `adAccountId`

## Tool Usage Notes

### GET /api/meta/accounts
- No query params. Returns every ad account on the connected user
- Pagination is followed automatically — you always get the full list
- Fields: `id, name, account_id, account_status, business_name, currency, timezone_name, amount_spent, balance, disable_reason`

### GET /api/meta/pages
- No query params. Returns every Facebook Page the connected user can manage
- Fields: `id, name, category, tasks, access_token`

### GET /api/meta/pixels
- Query: `adAccountId` (optional — falls back to first account on the user)
- Returns `{ adAccountId, data: [{ id, name, last_fired_time, code, … }] }`

### GET /api/meta/campaigns
- Query: `adAccountId`, `keyword`, `campaignIds`, `includeStats`, `datePreset`, `since`, `until`
- `adAccountId` is required unless `campaignIds` is provided
- `keyword` filter is applied server-side after Meta returns the list
- When `includeStats=true`, each campaign gets `.insights = { rows, summary }` with spend, impressions, clicks, reach, ctr, cpc, cpm, and per-`action_type` totals (so you can compute conversions / CPA for any pixel event)
- Per-campaign fields fetched: `id, name, status, effective_status, objective, buying_type, special_ad_categories, daily_budget, lifetime_budget, budget_remaining, bid_strategy, start_time, stop_time, created_time, updated_time, configured_status, account_id`

### GET /api/meta/adsets
- Query: `adAccountId`, `keyword`, `campaignIds`, `adsetIds`, `includeStats`, `datePreset`, `since`, `until`
- One of `adAccountId`, `campaignIds`, or `adsetIds` is required
- Per-adset fields fetched: `id, name, campaign_id, status, effective_status, daily_budget, lifetime_budget, budget_remaining, bid_amount, bid_strategy, billing_event, optimization_goal, targeting, promoted_object, start_time, end_time, created_time, updated_time, configured_status, attribution_spec, destination_type`

### GET /api/meta/ads
- Query: `adAccountId`, `keyword`, `adIds`, `adsetIds`, `campaignIds`, `includeStats`, `datePreset`, `since`, `until`
- One of `adAccountId`, `adIds`, `adsetIds`, or `campaignIds` is required
- Per-ad fields fetched: `id, name, adset_id, campaign_id, status, effective_status, configured_status, creative, tracking_specs, conversion_specs, created_time, updated_time, preview_shareable_link, recommendations, source_ad_id`

### GET /api/meta/insights
- Query: `objectId` (required), `level`, `datePreset`, `since`, `until`, `breakdowns`
- `level` accepts: `account`, `campaign`, `adset`, `ad`
- `breakdowns` is comma-separated, e.g. `age,gender`, `region,publisher_platform`, `country,impression_device`
- `action_breakdowns=action_type` is always included, so every entry in `actions[]` and `action_values[]` carries an `action_type` you can filter on
- Default field set: `spend, impressions, reach, frequency, clicks, unique_clicks, inline_link_clicks, inline_link_click_ctr, cpc, cpm, cpp, ctr, unique_ctr, actions, action_values, conversions, conversion_values, cost_per_action_type, cost_per_conversion, cost_per_unique_click, cost_per_inline_link_click, video_p25_watched_actions, video_p50_watched_actions, video_p75_watched_actions, video_p100_watched_actions, video_play_actions, video_avg_time_watched_actions, website_purchase_roas, purchase_roas, objective, optimization_goal, quality_ranking, engagement_rate_ranking, conversion_rate_ranking`
- Returns `{ rows, summary }`

### Allowed values for time ranges (everywhere insights are fetched)
- `datePreset`: `today`, `yesterday`, `this_month`, `last_month`, `this_quarter`, `last_quarter`, `this_year`, `last_year`, `last_3d`, `last_7d`, `last_14d`, `last_28d`, `last_30d` (default), `last_90d`, `last_week_mon_sun`, `last_week_sun_sat`, `this_week_mon_today`, `this_week_sun_today`, `maximum`, `lifetime`
- Or pair `since=YYYY-MM-DD` with `until=YYYY-MM-DD`. Never send both `datePreset` and a since/until pair

### POST /api/meta/campaigns
- JSON body. Required: `adAccountId`, `name`, `objective`
- Budgets are in **dollars** — server converts to cents
- Returns `{ success: true, campaign: { id }, requestPayload }`
- **adAccountId** (string, required) — may include or omit the `act_` prefix
- **name** (string, required)
- **objective** (enum, required): `OUTCOME_LEADS`, `OUTCOME_SALES`, `OUTCOME_TRAFFIC`, `OUTCOME_AWARENESS`, `OUTCOME_ENGAGEMENT`, `OUTCOME_APP_PROMOTION`
- **status** (enum): `ACTIVE`, `PAUSED` — default `PAUSED`
- **dailyBudget** (number, USD) — either daily or lifetime, not both
- **lifetimeBudget** (number, USD) — requires `stopTime`
- **bidStrategy** (enum): `LOWEST_COST_WITHOUT_CAP` (default "maximize results"), `LOWEST_COST_WITH_BID_CAP`, `COST_CAP`. `COST_CAP` requires `bidAmount` on the ad set
- **specialAdCategories** (array of enum): `NONE`, `EMPLOYMENT`, `HOUSING`, `CREDIT`, `ISSUES_ELECTIONS_POLITICS`, `ONLINE_GAMBLING_AND_GAMING`, `FINANCIAL_PRODUCTS_SERVICES`. Always include the field — pass `[]` if none apply
- **specialAdCategoryCountry** (array of ISO codes): required when `specialAdCategories` is non-empty for restricted categories. e.g. `["US"]`
- **buyingType** (enum): `AUCTION` (default), `RESERVED`
- **startTime** / **stopTime** (ISO 8601 with offset): e.g. `2026-04-15T00:00:00-0700`

### POST /api/meta/adsets
- JSON body. Required: `adAccountId`, `name`, `campaignId`
- Budgets and `bidAmount` are in **dollars**
- Returns `{ success: true, adset: { id }, requestPayload }`
- **adAccountId** (string, required)
- **name** (string, required)
- **campaignId** (string, required) — Meta campaign id from the create-campaign response, not an internal id
- **status** (enum): `ACTIVE`, `PAUSED` — default `PAUSED`
- **dailyBudget** / **lifetimeBudget** (number, USD)
- **bidAmount** (number, USD): required when the campaign uses `COST_CAP` or `LOWEST_COST_WITH_BID_CAP`
- **bidStrategy** (enum): `LOWEST_COST_WITHOUT_CAP`, `LOWEST_COST_WITH_BID_CAP`, `COST_CAP`
- **billingEvent** (enum): `IMPRESSIONS` (default), `LINK_CLICKS`, `THRUPLAY` (video)
- **optimizationGoal** (enum): `OFFSITE_CONVERSIONS` (default), `LINK_CLICKS`, `LANDING_PAGE_VIEWS`, `LEAD_GENERATION`, `REACH`, `IMPRESSIONS`, `THRUPLAY`, `VALUE`, `APP_INSTALLS`, `POST_ENGAGEMENT`, `PAGE_LIKES`. Must be compatible with the parent campaign's objective
- **destinationType** (enum): `WEBSITE`, `APP`, `MESSENGER`, `INSTAGRAM_DIRECT`, `ON_AD`
- **promotedObject** (object): required for conversion optimization
  - `pixelId` (string) — the standard pixel-event setup
  - `pageId` (string) — for lead-form ads
  - `customEventType` (enum): `PURCHASE`, `LEAD`, `COMPLETE_REGISTRATION`, `ADD_TO_CART`, `INITIATE_CHECKOUT`, `VIEW_CONTENT`, `SEARCH`, `ADD_PAYMENT_INFO`, `ADD_TO_WISHLIST`, `CONTACT`, `CUSTOMIZE_PRODUCT`, `DONATE`, `FIND_LOCATION`, `SCHEDULE`, `START_TRIAL`, `SUBMIT_APPLICATION`, `SUBSCRIBE`, `OTHER`
  - `customEventStr` (string): only when `customEventType` is `OTHER`. e.g. `lead_submit`
- **targeting** (object, full Meta targeting spec, pass-through). Defaults to `{ geo_locations: { countries: ["US"] } }` if omitted. Common keys:
  - `geo_locations` — `countries`, `regions`, `cities`, `zips`
  - `age_min`, `age_max`, `genders`
  - `interests`, `behaviors`, `flexible_spec`, `exclusions`
  - `custom_audiences`, `excluded_custom_audiences`
  - `publisher_platforms`: `facebook`, `instagram`, `audience_network`, `messenger`
  - `facebook_positions`: `feed`, `right_hand_column`, `marketplace`, `video_feeds`, `story`, `search`, `instream_video`
  - `instagram_positions`: `stream`, `story`, `explore`, `reels`, `shop`
- **startTime** / **endTime** (ISO 8601)
- **attributionSpec** (array): `[{ event_type: "CLICK_THROUGH" | "VIEW_THROUGH", window_days: 1 | 7 | 28 }]`

### POST /api/meta/ads
- Two ways to send this request:
  1. **multipart/form-data** with a `media` file for the creative
  2. **application/json** with a `mediaUrl` the server fetches itself
- Required (both modes): `adAccountId`, `adsetId`, `pageId`, `name`, `link`
- For multipart, the `media` field must be a single image (`jpg`, `png`, `gif`, `webp`) or video (`mp4`, `mov`, `m4v`, `webm`). Max 500 MB. Type is detected from MIME, with extension fallback for URL inputs
- For JSON mode, set `mediaUrl` to a public URL of the image or video. For video URLs the server tries Meta's `file_url` shortcut first, then falls back to download+upload
- **Thumbnails are automatic.** For video creatives the server uploads the video to Meta, polls `/{video_id}/thumbnails` for the auto-generated preferred thumbnail, and uses that on the creative. Callers never provide a thumbnail. Anything you might pass is ignored
- Optional fields (both modes):
  - **message** (string) — primary text shown above the creative
  - **headline** (string) — `link_data.name` for images, `video_data.title` for videos
  - **description** (string) — link description
  - **callToAction** (object in JSON mode, JSON-stringified in multipart): default `{"type":"LEARN_MORE","value":{"link":"<link>"}}`. Allowed `type` values: `LEARN_MORE`, `SIGN_UP`, `SHOP_NOW`, `GET_OFFER`, `BOOK_TRAVEL`, `DOWNLOAD`, `APPLY_NOW`, `CONTACT_US`, `DONATE_NOW`, `GET_QUOTE`, `LISTEN_MUSIC`, `MESSAGE_PAGE`, `ORDER_NOW`, `REQUEST_TIME`, `SAVE`, `SEE_MENU`, `SUBSCRIBE`, `WATCH_MORE`, `WHATSAPP_MESSAGE`, `INSTALL_APP`, `USE_APP`, `PLAY_GAME`
  - **status** (enum): `ACTIVE`, `PAUSED` — default `PAUSED`
  - **creativeId** (string): if you already have an `adcreatives` object, pass its id and the media upload is skipped
  - **trackingSpecs** (array in JSON mode, JSON-stringified in multipart) — pass-through Meta tracking specs
- Returns `{ success: true, ad: { id }, creativeId, requestPayload }`

JSON example:
```json
{
  "adAccountId": "act_123456789",
  "adsetId": "1234567890",
  "pageId": "987654321",
  "name": "Ad - Variant A",
  "link": "https://example.com/landing",
  "message": "Body / primary text shown above the creative",
  "headline": "Catchy headline",
  "description": "Optional link description",
  "mediaUrl": "https://cdn.example.com/creatives/banner.jpg",
  "callToAction": { "type": "LEARN_MORE" },
  "status": "PAUSED"
}
```

## Common Mistakes to Avoid
- Do NOT pre-multiply budgets by 100 — pass dollars (`25.00`), the server converts to cents
- Do NOT include ids in URL paths (`/campaigns/123`). Always use query params (`?campaignIds=123`)
- Do NOT pass both `datePreset` and `since`/`until` — pick one
- Do NOT skip `specialAdCategories: []` on campaign creation — Meta requires the field even when empty
- Do NOT call `POST /api/meta/ads` without a `media` file (or `mediaUrl` in JSON mode) unless you are passing a pre-existing `creativeId`
- Do NOT supply a thumbnail file or URL — the server fetches Meta's auto-generated thumbnail
- Do NOT pair an `optimizationGoal` with an incompatible campaign `objective` (e.g. `OUTCOME_SALES` with `optimizationGoal: PAGE_LIKES`)
- Do NOT guess pixel IDs or page IDs. Always look them up first via `/pixels`, `/pages`
- Do NOT retry blindly on a Meta error code `190` — that means the user's Meta token is dead. Have the user reconnect

## Rate Limits
- Meta Graph API enforces app-level + ad-account-level rate limits. Common error codes you should back off on: `4` (app limit), `17` (user request limit), `613` (custom rate limit), `80004` (ads management write limit). Wait at least 60 seconds before retrying

## Error Shapes
- **Validation (this server)**: `{ error: "<reason>" }` — HTTP 400
- **Meta passthrough**: `{ statusCode, error, code, type, fbtrace_id, raw }`. The HTTP status mirrors Meta's. Read `raw.error.error_user_msg` for the most specific failure detail Meta provides
