Manage ad sets and ads via the Spotify Ads API. Read settings from the active platform settings file.
Setup
Set the plugin root and define the request wrapper:
PLUGIN_ROOT="${CODEX_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT:-.}}"
api() { "$PLUGIN_ROOT/scripts/api-request.sh" ads "$@"; }
Before the first Ads API v3 call, read and follow $PLUGIN_ROOT/skills/api-reference/references/live-openapi.md.
To retrieve settings values (TOKEN, AD_ACCOUNT_ID, AUTO_EXECUTE, BASE_URL, SDK_HEADER, SKILL_HEADER, PLUGIN_VERSION) for use outside API calls, run api --env. The output is eval-safe, so eval $(api --env) assigns them all.
Parsing Arguments
The argument format is: <resource> <operation> [id]
- Resource:
ad-setsorads - Operation:
list,create,get,update - If no argument, ask which resource and operation.
For create and update, default to draft endpoints even when the user does not mention drafts. create-live and update-live are explicit escape hatches for direct published writes.
Ad Set Operations
ad-sets list
api GET "ad_accounts/{ad_account_id}/ad_sets?limit=50&sort_direction=DESC"
Format as table: ID | Name | Campaign ID | Status | Format | Budget | Start
ad-sets create
Collect the required fields below first. Before the POST, read and follow
$PLUGIN_ROOT/skills/api-reference/references/ad-product-validation.md. Fetch the live
catalog, parent campaign, and any runtime inputs required by its rules, then validate
the final ad set body against ad_set.create plus ad_set.both. Do not add a separate
validation confirmation.
Prompt for required fields:
- name (2-200 chars)
- campaign_id (uuid — suggest listing campaigns first)
- start_time (ISO 8601 datetime)
- end_time (ISO 8601 — required if budget type is LIFETIME)
- budget — ask for amount in the ad account's billing currency and type (DAILY/LIFETIME), convert to micro_amount
- asset_format (AUDIO, VIDEO, IMAGE, CATALOG)
- category (required — valid
ADV_X_Ycode, fetch fromGET /ad_categoriesif needed) - targets — ask for targeting preferences:
- Age range (e.g., 18-34) →
"age_ranges": [{"min": 18, "max": 34}] - Geo targeting — see detailed instructions below
- Genders (optional) →
"genders": ["MALE", "FEMALE", "NON_BINARY"] - Platforms (optional) →
"platforms": ["ANDROID", "DESKTOP", "IOS"](NOT "MOBILE" or "CONNECTED_DEVICE") - Placements (required) →
"placements": ["MUSIC"]
- Age range (e.g., 18-34) →
- bid_strategy — plain string:
MAX_BID,COST_PER_RESULT,AUTOBID, orUNSET. Default toMAX_BID. - bid_micro_amount (on the ad set: required with MAX_BID or COST_PER_RESULT, omitted with AUTOBID; this omission applies only to ad set payloads, not audience estimates) — ask for the bid cap in the ad account's billing currency, convert to micro-amount. This is the maximum CPM. Example: $15 USD =
15000000, ¥160 JPY =160000000
Important: Convert amounts to micro-amounts by multiplying by 1,000,000. This applies to both budget.micro_amount and bid_micro_amount.
Ad set validation guardrails before any POST:
- Never send zero or negative
budget.micro_amount; ask for a positive budget and convert it to micro-units. - Never send
bid_micro_amount: 0withMAX_BIDorCOST_PER_RESULT; ask for a positive bid cap. - For auction buys with
bid_strategy=UNSET, omitbid_micro_amountunless the current API contract or user-provided source requires it. For Reserved buys, follow the live product rules and use the fixed rate returned byreserved_pricesasbid_micro_amount. - Keep
budgettomicro_amountandtype; do not includecurrencyon ad set create payloads. - Valid
targets.platformsvalues are onlyANDROID,DESKTOP, andIOS; never sendWEB,MOBILE,CONNECTED_DEVICE, orad_platforms. - Do not send
cost_model,skippable,is_skippable, orad_platformsin ad set create payloads. - Use age ranges with
min >= 18unless the user has explicitly confirmed a market/category that allows minors. - If using
city_ids,postal_code_ids, orregion_ids, include the parentcountry_codein the samegeo_targetsobject.
Geo-Targeting
Structure: geo_targets is a flat object (NOT an array) with a required country_code and optional refinement arrays.
Lookup Geo IDs: Use the /targets/geos endpoint to find geo IDs:
# Search by location name
api GET "targets/geos?country_code=US&q=Connecticut&limit=20"
# Search by postal code
api GET "targets/geos?country_code=US&q=06103&limit=20"
Response includes id, type, name, and parent_geo_name for each geo.
Geo Types:
REGION— States/provinces (e.g., Connecticut, California, Ontario)DMA_REGION— Designated Market Areas for media targeting (e.g., "Hartford & New Haven, CT"). Note: DMA-level targeting viadma_idsis no longer supported. DMAs can still be looked up but cannot be used as targeting refinements.CITY— Cities and townsPOSTAL_CODE— ZIP codes (format: "US:06103", "CA:M5H")
Targeting Examples:
- Country-level (broadest):
"geo_targets": {
"country_code": "US"
}
- State/Region-level:
"geo_targets": {
"country_code": "US",
"region_ids": ["4831725"] // Connecticut
}
- City-level:
"geo_targets": {
"country_code": "US",
"city_ids": ["4845411", "5284283"] // West Hartford, Colchester
}
- Postal code-level (most granular):
"geo_targets": {
"country_code": "US",
"postal_code_ids": ["US:06103", "US:06105"]
}
- Multi-level (combine different geo types):
"geo_targets": {
"country_code": "US",
"region_ids": ["4831725"], // Connecticut
"city_ids": ["4845411"] // West Hartford
}
Workflow:
- Ask user for geo preference (e.g., "Connecticut", "Hartford DMA", "West Hartford")
- Call
/targets/geoswith user's query - Display results with type, name, and parent location
- Let user select from results or refine search
- Build
geo_targetsobject with appropriate IDs - NEVER fall back to country-only without asking user first
Pre-flight audience estimate: Before executing the POST, run an audience estimate to validate targeting. If the parent campaign uses CONTENT or FPMNG, follow skills/api-reference/references/endpoints.md (Reserved Pricing and Forecasting) first: fetch the fixed rate for the planned product, dates, format, and targeting; use cost_micro in both the ad set and audience estimate as bid_micro_amount; set ad_product and the returned currency in the estimate, and resolve the other product-specific fields from the current OpenAPI contract and live catalog. Report the fixed rate separately from forecast CPM and bid suggestions. The generic example below is for auction buys.
api POST "estimates/audience" \
'{
"ad_account_id": "<AD_ACCOUNT_ID>",
"start_date": "<start_time>",
"asset_format": "<AUDIO|VIDEO|IMAGE|CATALOG>",
"objective": "<campaign_objective>",
"bid_strategy": "<MAX_BID|COST_PER_RESULT|AUTOBID|UNSET>",
"bid_micro_amount": <bid>,
"budget": {"micro_amount": <budget>, "type": "<DAILY|LIFETIME>", "currency": "USD"},
"targets": { <same targets as above> }
}'
Note: This endpoint is NOT scoped under /ad_accounts/{id}/ — it's at the top level: POST /estimates/audience. Use the base URL directly followed by /estimates/audience.
Display the estimate summary:
Audience Estimate:
Projected unique users: ~142,000
Estimated daily reach: 8,500 – 12,000
Estimated daily impressions: 15,000 – 22,000
Estimated CPM: $12.50 – $18.00
If the audience is too small (low projected users or a "Min audience threshold was not met" 400), warn the user and suggest:
- Broadening the age range
- Adding more platforms
- Switching from VIDEO to AUDIO format (lower thresholds)
- Expanding geo targeting
A "Min audience threshold was not met" 400 means the targeting is too narrow. Do not resend the same request, and do not widen the targeting on your own. Show bid_suggestion only when the response includes it, ask the user how to broaden the targeting, then estimate again with their choice.
If the estimate came back valid but low, ask whether to proceed, adjust targeting, or cancel before creating the ad set. After a threshold 400, do not offer to proceed with the same targeting; offer only to adjust targeting or cancel.
Create the draft ad set:
The campaign_id must reference a draft campaign. If the supplied campaign is published, first check for and reuse its draft or create one with POST /campaigns/{campaign_id}/drafts.
api POST "ad_accounts/{ad_account_id}/drafts/ad_sets" \
'{...}'
ad-sets get <id>
api GET "ad_accounts/{ad_account_id}/ad_sets/$AD_SET_ID"
ad-sets update <id>
Before the PATCH, read and follow
$PLUGIN_ROOT/skills/api-reference/references/ad-product-validation.md. Fetch the live
catalog, current ad set, parent campaign, and runtime state required by applicable
rules. Deep-merge the proposed PATCH into the current ad set and validate the effective
entity against ad_set.update plus ad_set.both. Do not add a separate validation
confirmation.
Prompt for fields to update (min 1). Same fields as create, all optional.
Read the published ad set, then check GET /drafts/ad_sets/{id}. If it returns 404, create a draft with POST /ad_sets/{id}/drafts. If a draft already exists, disclose its pending state before combining changes. PATCH /drafts/ad_sets/{id}, resolve its draft campaign_id, fetch the draft campaign's current hierarchy version, and validate it. Keep the result staged.
Ad Operations
ads list
api GET "ad_accounts/{ad_account_id}/ads?limit=50&sort_direction=DESC"
Format as table: ID | Name | Ad Set ID | Status | Delivery
ads create
Collect the required fields and asset selections below first. Before the POST, read and
follow $PLUGIN_ROOT/skills/api-reference/references/ad-product-validation.md. Fetch
the live catalog, current parent ad set and campaign, and referenced assets, then
validate the final ad body against ad.create plus ad.both. Do not add a separate
validation confirmation.
Prompt for required fields:
- name (2-200 chars)
- ad_set_id (uuid — suggest listing ad sets first)
- tagline (2-40 chars, ad headline; required for live ads, optional for drafts)
- advertiser_name (2-25 chars)
- assets — fetch available assets from
GET /assetsand prompt user to select:asset_id(required for live ads, optional for drafts — audio/video/image creative matching ad set format)logo_asset_id(required — logo image)companion_asset_id(required for AUDIO format — companion image)
- call_to_action — uses field
key(NOTtype) andclickthrough_url(NOTurl):key: SHOP_NOW, LEARN_MORE, LISTEN_NOW, SIGN_UP, WATCH_NOW, BUY_NOW, DOWNLOAD, etc.clickthrough_url: landing page URL (required for live ads, optional for drafts)
- delivery (ON/OFF, default ON)
- third_party_tracking (optional) — array of tracking pixels. Each entry needs:
measurement_event: required —IMPRESSION,CLICKED,START,FIRST_QUARTILE,MIDPOINT,THIRD_QUARTILE,COMPLETE, orVIEWABLE_IMPRESSION. If omitted, defaults to IMPRESSION — always set explicitly, especially for click trackers.measurement_partner:DCM,IAS,MOAT,DOUBLEVERIFY, orUNSETurl: the tracking pixel URL- Example with both impression and click trackers:
"third_party_tracking": [ {"measurement_event": "IMPRESSION", "measurement_partner": "DCM", "url": "https://ad.doubleclick.net/ddm/trackimp/..."}, {"measurement_event": "CLICKED", "measurement_partner": "DCM", "url": "https://ad.doubleclick.net/ddm/trackclk/..."} ]
api POST "ad_accounts/{ad_account_id}/drafts/ads" \
'{...}'
The ad_set_id must reference a draft ad set. If the supplied ad set is published, first check for and reuse its draft or create one with POST /ad_sets/{ad_set_id}/drafts.
ads get <id>
api GET "ad_accounts/{ad_account_id}/ads/$AD_ID"
ads update <id>
Before the update, read and follow
$PLUGIN_ROOT/skills/api-reference/references/ad-product-validation.md. Fetch the live
catalog, current ad, parent ad set and campaign, and any referenced assets. Deep-merge
the proposed changes into the current ad and validate the effective entity against
ad.update (when present) plus ad.both. Do not add a separate validation
confirmation.
Read the published ad, then check GET /drafts/ads/{id}. If it returns 404, create a draft with POST /ads/{id}/drafts. If a draft already exists, disclose its pending state before combining changes. PATCH /drafts/ads/{id}, fetch its parent draft ad set to resolve the draft campaign, fetch the campaign's current hierarchy version, and validate it. Keep the result staged.
Draft ad updates support name, advertiser_name, tagline, assets, asset_format, call_to_action, third_party_tracking, placements, weight, and status. Always preserve third-party tracking entries the user did not explicitly remove or replace, and set measurement_event explicitly on every entry.
Explicit direct writes
Use ad-sets create-live, ad-sets update-live <id>, ads create-live, or ads update-live <id> only when the user explicitly requests an immediate/direct change to a published entity or asks to skip drafts.
If a direct write returns HTTP 403 or an edit-permission error, do not retry it and do not conclude that the credentials are entirely read-only. State that direct editing of the published entity was denied and offer draft staging instead.
Execution Behavior
- If
auto_executeistrue, execute directly. - If
auto_executeisfalse, present the curl command and ask for confirmation. - Display responses in readable format.
- Always check the
HTTP_STATUS:line from curl output to determine success or failure before interpreting the response body. - On error, show the error message from the response body. Never automatically retry POST or PATCH requests — they may have succeeded server-side despite an error response.
- When converting budgets, always confirm the micro-amount with the user (e.g., "$50/day = 50,000,000 micro-amount").
- Treat a 404 from a draft existence check as "no draft exists"; for any other error, stop and show the response.
- Never publish a draft without a separate user request and explicit confirmation immediately before
PUBLISH.