{"openapi":"3.1.0","info":{"title":"Affiliateo API","version":"1.0.0","description":"The Affiliateo v1 API. Manage apps and affiliates, read the caller’s own affiliate data and wallet, mint hosted links and embedded Elements sessions, register webhooks, and run ads across five networks (Meta, Google, TikTok, Apple Search Ads, Snapchat). Authenticate with an account-level `afk_` key (dashboard → API tab). See https://affiliateo.com/docs/api and https://affiliateo.com/docs/elements.","contact":{"url":"https://affiliateo.com/docs/api"},"license":{"name":"Proprietary"}},"servers":[{"url":"https://affiliateo.com"}],"security":[{"bearerAuth":[]}],"tags":[{"name":"Businesses & Apps","description":"Create, read and edit a business’s apps. Funding, ending and provider connects stay dashboard actions. On budget apps (clipping, web_traffic, collab) funding is free and the 1% platform fee is charged per payout out of the same budget, so a $100 payout costs the budget $101. `budget_cents` is therefore GROSS: `budget_cents - spent_cents` is the money still uncommitted (and refunded in full if the app ends), while the derived, read-only `payable_capacity_cents` is the largest single payout that remainder can still cover with its fee. Read `payable_capacity_cents` for “how much can still reach a creator”."},{"name":"Affiliates"},{"name":"Storefront Affiliates"},{"name":"Me"},{"name":"Embedded Elements"},{"name":"Webhooks"},{"name":"Ads","description":"Campaigns, reporting and account management across Meta, Google, TikTok, Apple Search Ads and Snapchat. EVERY OPERATION UNDER THIS TAG HAS A DASHBOARD SCREEN (the business → Ads tab) WITH ONE EXCEPTION, so an owner can almost always be pointed at a place to click rather than at an HTTP request. The exception is the catalog WRITES: POST and DELETE …/ads/catalogs (create a TikTok or Snapchat product catalog, unlink a Google Merchant Center account, answer a link invitation) exist only here — the dashboard READS catalogues, through …/ads/lookups, and the campaign wizard says in so many words to create one from this API. The six that were the last reads and writes without a screen now have one: ad-review, comments, creatives, experiments, impression-share and keyword-recommendations. Two of those are deliberately not tables, because the endpoint is not one — ad review hangs off the ad row (both of its reads need an ad id and no list stands behind them) and creatives publishes no creative list and no delete on either network — its one GET lists Snapchat interaction zones, which is the reuse and recovery path after a half-built collection rather than a table of creatives. The screens are gated on the ads capability, matching this API exactly — a co-founder with ads access reaches them — while connect-link, balance, billing and pixel stay owner-only on both doors. Two things here are deliberately not API calls at all: the OAuth consent a network demands (mint a hosted link with connect-link) and topping up the wallet a 402 PAYMENT_REQUIRED asks for."}],"paths":{"/api/v1/businesses":{"get":{"summary":"List businesses this key can act on.","operationId":"get_api_v1_businesses","tags":["Businesses & Apps"],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/businesses/{slug}/apps":{"get":{"summary":"List every app on the business.","operationId":"get_api_v1_businesses_slug_apps","tags":["Businesses & Apps"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"summary":"Create an app (same validation as the dashboard wizard).","operationId":"post_api_v1_businesses_slug_apps","tags":["Businesses & Apps"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBody":{"content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}}},"/api/v1/businesses/{slug}/apps/{appId}":{"get":{"summary":"Full settings for one app (budget apps include payable_capacity_cents).","operationId":"get_api_v1_businesses_slug_apps_appId","tags":["Businesses & Apps"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"appId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"patch":{"summary":"Edit an app.","operationId":"patch_api_v1_businesses_slug_apps_appId","tags":["Businesses & Apps"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"appId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBody":{"content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}}},"/api/v1/businesses/{slug}/apps/{appId}/affiliates":{"post":{"summary":"Enroll an affiliate (account auto-created if needed).","operationId":"post_api_v1_businesses_slug_apps_appId_affiliates","tags":["Affiliates"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"appId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK (already existed; returned as-is)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AffiliateEnrollResponse"}}}},"201":{"description":"Created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AffiliateEnrollResponse"}}}},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AffiliateEnrollRequest"}}}}},"get":{"summary":"Look up one affiliate by email, or list the roster without one.","operationId":"get_api_v1_businesses_slug_apps_appId_affiliates","tags":["Affiliates"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"appId","in":"path","required":true,"schema":{"type":"string"}},{"name":"email","in":"query","required":false,"description":"Omit to list the whole roster, cursor-paginated. With it, one affiliate, and every include is applied without asking.","schema":{"type":"string"}},{"name":"include","in":"query","required":false,"description":"Comma-separated opt-ins. `recurring` adds the MRR / ARR / subscribers / not_renewing / churn block to every row (ONE query for the whole page, so page size does not change the cost). `payouts` adds the pending/payable/paid buckets. Always present without asking on a single read and on the summaries. No date range filters `recurring`: MRR is a rate at the current subscription set, not a sum over a period.","schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AffiliateResponse"}}}},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/businesses/{slug}/apps/{appId}/summary":{"get":{"summary":"Program summary + payout buckets, with date windows.","operationId":"get_api_v1_businesses_slug_apps_appId_summary","tags":["Affiliates"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"appId","in":"path","required":true,"schema":{"type":"string"}},{"name":"from","in":"query","required":false,"schema":{"type":"string"}},{"name":"to","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/businesses/{slug}/apps/{appId}/affiliates/conversions":{"get":{"summary":"Conversions feed for the app.","operationId":"get_api_v1_businesses_slug_apps_appId_affiliates_conversions","tags":["Affiliates"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"appId","in":"path","required":true,"schema":{"type":"string"}},{"name":"from","in":"query","required":false,"schema":{"type":"string"}},{"name":"to","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/businesses/{slug}/apps/{appId}/affiliates/verification-link":{"post":{"summary":"Mint a hosted \"get paid-ready\" link for an affiliate.","operationId":"post_api_v1_businesses_slug_apps_appId_affiliates_verification_link","tags":["Affiliates"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"appId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HostedLinkResponse"}}}},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HostedLinkRequest"}}}}}},"/api/v1/businesses/{slug}/apps/{appId}/affiliates/withdrawal-link":{"post":{"summary":"Mint a hosted \"cash out\" link for an affiliate.","operationId":"post_api_v1_businesses_slug_apps_appId_affiliates_withdrawal_link","tags":["Affiliates"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"appId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HostedLinkResponse"}}}},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HostedLinkRequest"}}}}}},"/api/v1/businesses/{slug}/apps/{appId}/affiliates/free-access":{"post":{"summary":"Comp an affiliate into your paid app, or take it back.","operationId":"post_api_v1_businesses_slug_apps_appId_affiliates_free_access","tags":["Affiliates"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"appId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FreeAccessResponse"}}}},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/FreeAccessRequest"}}}}}},"/api/v1/businesses/{slug}/apps/{appId}/affiliates/embed-session":{"post":{"summary":"Mint an embedded Elements session.","operationId":"post_api_v1_businesses_slug_apps_appId_affiliates_embed_session","tags":["Embedded Elements"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"appId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EmbedSessionResponse"}}}},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EmbedSessionRequest"}}}}}},"/api/v1/businesses/{slug}/affiliates":{"get":{"summary":"Storefront affiliates for the business (owner view).","operationId":"get_api_v1_businesses_slug_affiliates","tags":["Storefront Affiliates"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"include","in":"query","required":false,"description":"Comma-separated opt-ins. `recurring` adds the MRR / ARR / subscribers / not_renewing / churn block to every row (ONE query for the whole page, so page size does not change the cost). `payouts` adds the pending/payable/paid buckets. Always present without asking on a single read and on the summaries. No date range filters `recurring`: MRR is a rate at the current subscription set, not a sum over a period.","schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/businesses/{slug}/affiliates/conversions":{"get":{"summary":"Storefront affiliate conversions.","operationId":"get_api_v1_businesses_slug_affiliates_conversions","tags":["Storefront Affiliates"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/businesses/{slug}/affiliates/summary":{"get":{"summary":"Storefront affiliate program summary.","operationId":"get_api_v1_businesses_slug_affiliates_summary","tags":["Storefront Affiliates"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/me/summary":{"get":{"summary":"The caller’s cross-business affiliate summary + wallet balance.","operationId":"get_api_v1_me_summary","tags":["Me"],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/me/affiliations":{"get":{"summary":"The caller’s app affiliations, cursor-paginated.","operationId":"get_api_v1_me_affiliations","tags":["Me"],"parameters":[{"name":"include","in":"query","required":false,"description":"Comma-separated opt-ins. `recurring` adds the MRR / ARR / subscribers / not_renewing / churn block to every row (ONE query for the whole page, so page size does not change the cost). `payouts` adds the pending/payable/paid buckets. Always present without asking on a single read and on the summaries. No date range filters `recurring`: MRR is a rate at the current subscription set, not a sum over a period.","schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/me/conversions":{"get":{"summary":"The caller’s own conversions.","operationId":"get_api_v1_me_conversions","tags":["Me"],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/me/wallet":{"get":{"summary":"The caller’s account wallet snapshot.","operationId":"get_api_v1_me_wallet","tags":["Me"],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WalletResponse"}}}},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/me/transactions":{"get":{"summary":"The caller’s wallet transaction ledger.","operationId":"get_api_v1_me_transactions","tags":["Me"],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/me/transactions/{id}":{"get":{"summary":"One transaction, with its deposit receipt.","operationId":"get_api_v1_me_transactions_id","tags":["Me"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/me/business-summary":{"get":{"summary":"The caller’s storefront-affiliate summary across businesses.","operationId":"get_api_v1_me_business_summary","tags":["Me"],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/me/business-affiliations":{"get":{"summary":"The caller’s storefront-affiliate memberships.","operationId":"get_api_v1_me_business_affiliations","tags":["Me"],"parameters":[{"name":"include","in":"query","required":false,"description":"Comma-separated opt-ins. `recurring` adds the MRR / ARR / subscribers / not_renewing / churn block to every row (ONE query for the whole page, so page size does not change the cost). `payouts` adds the pending/payable/paid buckets. Always present without asking on a single read and on the summaries. No date range filters `recurring`: MRR is a rate at the current subscription set, not a sum over a period.","schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/me/business-conversions":{"get":{"summary":"The caller’s storefront-affiliate sales.","operationId":"get_api_v1_me_business_conversions","tags":["Me"],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/me/verification-link":{"post":{"summary":"Self-serve \"get paid-ready\" link for the caller.","operationId":"post_api_v1_me_verification_link","tags":["Me"],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HostedLinkResponse"}}}},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SelfLinkRequest"}}}}}},"/api/v1/me/topup-link":{"post":{"summary":"Self-serve \"add funds\" link (answers a 402 on ads).","operationId":"post_api_v1_me_topup_link","tags":["Me"],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HostedLinkResponse"}}}},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SelfLinkRequest"}}}}}},"/api/v1/me/withdrawal-link":{"post":{"summary":"Self-serve \"cash out\" link for the caller.","operationId":"post_api_v1_me_withdrawal_link","tags":["Me"],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HostedLinkResponse"}}}},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SelfLinkRequest"}}}}}},"/api/v1/webhooks":{"get":{"summary":"List registered webhook endpoints.","operationId":"get_api_v1_webhooks","tags":["Webhooks"],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"summary":"Register a webhook receiver.","operationId":"post_api_v1_webhooks","tags":["Webhooks"],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookRegisterRequest"}}}}}},"/api/v1/webhooks/{webhookId}":{"patch":{"summary":"Update or re-enable a webhook.","operationId":"patch_api_v1_webhooks_webhookId","tags":["Webhooks"],"parameters":[{"name":"webhookId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBody":{"content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}},"delete":{"summary":"Delete a webhook.","operationId":"delete_api_v1_webhooks_webhookId","tags":["Webhooks"],"parameters":[{"name":"webhookId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/businesses/{slug}/ads":{"get":{"summary":"Ad stats for a network and date range.","operationId":"get_api_v1_businesses_slug_ads","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}},{"name":"start","in":"query","required":false,"schema":{"type":"string"}},{"name":"end","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/businesses/{slug}/ads/campaigns":{"get":{"summary":"List campaigns: the live entity tree with statuses, budgets and bids. Meta campaign→ad set→ad, Apple campaign→ad group→keyword in `entities` plus `ads` and `creatives` beside it — the two Apple levels that were previously write-only, carrying the servingStateReasons that say why an ad is not delivering and the productPageId and review state a Custom Product Page creative was built on, TikTok campaign→ad group→ad (manual and Smart+ merged), Snapchat campaign→ad squad→ad. Google is campaign→ad group→ads + keywords PLUS asset groups with their assets and signals (Performance Max has no ad groups at all), campaign- and ad-group-level asset links, and campaign and non-keyword ad-group criteria; every campaign names its advertisingChannelType, which is what tells an empty PMax campaign from a half-built Search one. Every Google campaign node also carries budgetExplicitlyShared and budgetCampaignCount, which are what say whether dailyBudgetCents on that node is a number the campaign OWNS or one it SHARES: a budgetCampaignCount above 1 means editing it through PATCH …/ads/entities/{id} is refused with 409 CONFLICT rather than moving several campaigns’ money at once. explicitlyShared alone does not settle it, because Google lets that flag go false to true and never back.","operationId":"get_api_v1_businesses_slug_ads_campaigns","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}},{"name":"account","in":"query","required":false,"description":"Ad account id, when several are connected. Aliases: adAccountId, advertiserId.","schema":{"type":"string"}},{"name":"include","in":"query","required":false,"description":"Google: asset_groups, assets, criteria (default all three). Apple: ads, creatives (default both). include=none returns the entity tree alone on either. Ignored on the other three networks.","schema":{"type":"string"}},{"name":"from","in":"query","required":false,"description":"Google only, with `to`: adds per-asset-group performance. The structure is read without a window.","schema":{"type":"string"}},{"name":"to","in":"query","required":false,"description":"Google only. See `from`.","schema":{"type":"string"}},{"name":"campaignIds","in":"query","required":false,"description":"Apple only: comma-separated ids. Becomes an Apple `id IN` selector condition, so only these campaigns are read and a big org costs what a small one costs. Overrides detailOffset.","schema":{"type":"string"}},{"name":"servingStatus","in":"query","required":false,"description":"Apple only: RUNNING or NOT_RUNNING, applied as Apple’s own servingStatus condition. Its DELIVERY state, which is not the same as ENABLED or PAUSED.","schema":{"type":"string"}},{"name":"detailOffset","in":"query","required":false,"description":"Apple only: where the ad-group/keyword detail window starts. The response carries truncation.next_detail_offset.","schema":{"type":"string"}},{"name":"keywordPages","in":"query","required":false,"description":"Apple only: 1,000-keyword pages per campaign. Default 2.","schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"summary":"Create a whole campaign stack, always PAUSED and rolled back if a step fails. A 201 therefore means BUILT, NOT LIVE: the response carries action_required=\"activate_campaign\" and the campaign spends nothing until it is PATCHed active on …/ads/entities/{id}. Branch on that field rather than on the status code. WHAT THE CAMPAIGN OPTIMISES TOWARD is set in a different place per network, and on two of them it is PERMANENT: Meta keeps optimization_goal on the ad set and editable, while TikTok (optimization_event on the ad group) and Snapchat (optimization_goal on the ad squad) freeze it at create, so those responses carry optimization_locked true and a wrong choice means rebuilding rather than editing. Apple has no such field. Google differs again: its primary_for_goal lives on the CONVERSION ACTION and is account-wide, so it is the only network where this choice can change what OTHER campaigns chase. The objective field differs per network — `objective` on Meta (traffic|sales|awareness|engagement|video_views|app_promotion) and on TikTok (traffic|conversions|engagement|leads|app_promotion|product_sales), `goal` on Snapchat (traffic|conversions|awareness|engagement|video_views|leads|app_promotion), the ?type= campaign type on Google, and a placement plus a bidding strategy on Apple. Meta, TikTok and Snapchat read an unrecognised value as traffic instead of refusing it, so a word from the wrong network launches the wrong campaign — with one exception: TikTok REFUSES awareness and video_views by name, because it used to serve both and reading a retired word as traffic would silently build the wrong campaign for an existing integration. TWO GOALS DO NOT LOOK LIKE THE REST. TikTok `engagement` is SPARK ADS: the ad promotes a post already on a TikTok account, so it takes ad.itemId (the post URL or the number at the end of it) and uploads nothing at all. And GOOGLE collects leads by CREATING the form rather than naming one — send `leadForm` on the Search body and the launch adds a LEAD_FORM asset plus its campaign_asset to the same mutate. THREE NETWORKS TAKE A FIELD THEY DID NOT BEFORE, each optional and each leaving an untouched body byte for byte what it was. Apple: ad = { productPageId?, name? }, which promotes a Custom Product Page and is REQUIRED on APPSTORE_TODAY_TAB whenever adGroup travels with the launch. Meta: specialAdCategoryCountry (which jurisdictions a regulated declaration covers) and contextualMultiAds (OPT_IN | OPT_OUT, where UNSET IS NOT OPT_OUT because Meta enrols any creative created without it). Snapchat: profileId, plus ageMin / ageMax that are now REFUSED out of range instead of silently clamped.","operationId":"post_api_v1_businesses_slug_ads_campaigns","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}},{"name":"account","in":"query","required":false,"description":"Ad account id, when several are connected. Aliases: adAccountId, advertiserId.","schema":{"type":"string"}},{"name":"type","in":"query","required":false,"description":"google only, and the campaign type is what Google has instead of an objective. Omitted (or anything unrecognised) builds a Search stack. \"pmax\" builds a Performance Max stack (asset group + assets); \"display\" a responsive display ad; \"demand_gen\" a multi-asset ad on YouTube, Discover and Gmail; \"app\" an install campaign (needs app.appId and bidding.targetCpaCents); \"shopping\" a product campaign off a linked Merchant Center feed (needs shopping.merchantId, and carries no creative). There is no \"video\": the Google Ads API can only read Video campaigns, never create them.","schema":{"type":"string"}},{"name":"validate","in":"query","required":false,"description":"google only: \"1\" dry-runs the whole stack server-side without creating anything.","schema":{"type":"string"}}],"responses":{"200":{"description":"OK (already existed; returned as-is)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignLaunchResponse"}}}},"201":{"description":"Created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignLaunchResponse"}}}},"400":{"description":"VALIDATION_ERROR, and four of these are new refusals of bodies that previously answered 201. APPLE: supplySource APPSTORE_TODAY_TAB with an adGroup and no ad.productPageId — the Today tab’s tap destination IS a Custom Product Page, Apple publishes no default, and the campaign this used to build could never serve. It is also the code for a failed Apple AD create that was OUR check rather than Apple’s (nothing was sent), and the campaign is rolled back either way. META: a special ad category other than NONE targeting two or more countries without specialAdCategoryCountry; and an adset.publisherPlatforms list of which NOT ONE value is among facebook, instagram, threads, messenger and audience_network, which used to launch on every platform Meta has (dropping SOME unknown values is unchanged). SNAPCHAT: ageMin outside 13-35, ageMax outside 13-55, or an ageMax below ageMin — all three were previously clamped, and the clamp turned \"45 to 30\" into \"45 to 45\".","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"FORBIDDEN. Snapchat with no Public Profile linked to the connection and no profileId in the body: every Snapchat creative needs one and the Public Profile API is a different host behind a second OAuth scope behind an allowlist. THIS WAS A 502 UPSTREAM_ERROR — anything branching on that code needs updating. The message is unchanged; what changed is that it no longer claims Snapchat refused something Snapchat was never asked, which read as \"try again\" for a condition retrying cannot fix. Google also answers 403 here while its developer token is pending.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"UPSTREAM_ERROR: the network refused. On Apple, a failed AD create now lands here and ROLLS THE CAMPAIGN BACK, where the launch previously answered 201 with a campaign that could never serve. The creative is the one thing a rollback cannot undo, because Apple publishes no creative delete; that is harmless and slightly useful, since it is organisation-scoped and found again on the retry.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignLaunchRequest"}}}}}},"/api/v1/businesses/{slug}/ads/adgroups":{"post":{"summary":"Create an ad group (Apple Search Ads), PAUSED, with the full targetingDimensions block: age, gender, deviceClasses, daypartHours, adminAreas/localities, appDownloaders and appCategories. appCategories exists only on the product-pages placement (100 means “the same category as my app”); cpaGoalCents is the ad group’s CPA benchmark, not the campaign’s targetCpa, and is refused on an automated ad group.","operationId":"post_api_v1_businesses_slug_ads_adgroups","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}},{"name":"account","in":"query","required":false,"description":"Ad account id, when several are connected. Aliases: adAccountId, advertiserId.","schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBody":{"content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}}},"/api/v1/businesses/{slug}/ads/connect-link":{"post":{"summary":"Mint an ad-network connect link.","operationId":"post_api_v1_businesses_slug_ads_connect_link","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBody":{"content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}}},"/api/v1/businesses/{slug}/ads/conversions":{"get":{"summary":"Server-side conversion reporting health per network: is it on, what is blocking it, and — where the network offers such a read — when it last saw an event. reporting=on means the setup looks healthy on our side: a pixel is configured, a credential exists, nothing is blocked. It is NOT confirmation that events reached the network.","operationId":"get_api_v1_businesses_slug_ads_conversions","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | tiktok | snapchat | google. Omit for all four. Apple is absent because its API has no conversion upload at all.","schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"summary":"Fire one test event and report what the network said. { network, testEventCode?, url? }. Meta/TikTok take a code from their Events Manager test tab and the event is not counted as a conversion; Snapchat has no test stream so this runs the credential probe instead; Google is refused (an upload IS the test, and the account is not allowlisted).","operationId":"post_api_v1_businesses_slug_ads_conversions","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBody":{"content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}},"patch":{"summary":"Per-network reporting settings. { network, matchWithoutClickId } reports click-id-less sales that carry a hashed buyer email (off by default; refused for Google). { network: \"snapchat\", capiToken } stores the hand-minted Conversions API token and clears any auth block; { clearBlock: true } drops the block alone.","operationId":"patch_api_v1_businesses_slug_ads_conversions","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBody":{"content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}}},"/api/v1/businesses/{slug}/ads/objects":{"get":{"summary":"Read one ad object’s full field surface.","operationId":"get_api_v1_businesses_slug_ads_objects","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"summary":"Create one ad object.","operationId":"post_api_v1_businesses_slug_ads_objects","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBody":{"content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}},"patch":{"summary":"Edit one ad object. A Google bidding scheme is editable here: send it as an object, e.g. { \"target_spend\": { \"cpcBidCeilingMicros\": \"3000000\" } } to cap cost per click on Maximise Clicks, or {} to select a scheme and let its settings default. Google rejects an update mask naming a message that has subfields, so these edits used to answer 502; the mask is now built from the leaf fields.","operationId":"patch_api_v1_businesses_slug_ads_objects","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}},{"name":"id","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBody":{"content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}},"delete":{"summary":"Detach a link (asset links, signals, listing group filters, criteria). Google only; the rest answer 400. The only way to retire an asset: status REMOVED is refused by Google, because removal is a separate operation. Spend-carrying levels are refused and point at PATCH status.","operationId":"delete_api_v1_businesses_slug_ads_objects","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}},{"name":"level","in":"query","required":true,"description":"The object type, e.g. campaign_asset.","schema":{"type":"string"}},{"name":"id","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/businesses/{slug}/ads/entities/{id}":{"patch":{"summary":"Pause/resume/re-budget an ad entity. A GOOGLE CAMPAIGN’S DAILY BUDGET IS NOT ALWAYS EDITABLE: Google lets several campaigns spend from one campaign_budget, and rewriting a shared one through a single campaign would change every campaign attached to it, so that write is refused with 409 CONFLICT and nothing is sent to Google. GET …/ads/campaigns?network=google tells you in advance — every campaign node carries budgetExplicitlyShared and budgetCampaignCount, and a budgetCampaignCount above 1 is the shared case.","operationId":"patch_api_v1_businesses_slug_ads_entities_id","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}},{"name":"account","in":"query","required":false,"description":"Ad account id, when several are connected. Aliases: adAccountId, advertiserId.","schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"409":{"description":"TWO CODES SHARE THIS STATUS AND A CALLER MUST TELL THEM APART BY error.code. CONFLICT: network=google, editing dailyBudgetCents on a campaign whose Google budget is shared with other campaigns. The request was well formed, the caller is permitted, and NOTHING was sent to Google — the account’s state is what makes the write wrong. Change the shared budget in Google Ads, or give the campaign a budget of its own first. THIS USED TO BE 502 UPSTREAM_ERROR, which is the code an agent retries, and retrying it will never succeed. NOT_CONNECTED: the network is not connected for this business, or the ?account= id is not one this connection holds. Reconnect, or pick another account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBody":{"content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}}},"/api/v1/businesses/{slug}/ads/keywords":{"post":{"summary":"Add keywords.","operationId":"post_api_v1_businesses_slug_ads_keywords","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBody":{"content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}},"patch":{"summary":"Update keywords.","operationId":"patch_api_v1_businesses_slug_ads_keywords","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBody":{"content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}},"delete":{"summary":"Remove keywords.","operationId":"delete_api_v1_businesses_slug_ads_keywords","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/businesses/{slug}/ads/negative-keywords":{"get":{"summary":"List negative keywords.","operationId":"get_api_v1_businesses_slug_ads_negative_keywords","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"campaignId","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"summary":"Add negative keywords.","operationId":"post_api_v1_businesses_slug_ads_negative_keywords","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBody":{"content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}},"patch":{"summary":"Update negative keywords.","operationId":"patch_api_v1_businesses_slug_ads_negative_keywords","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBody":{"content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}},"delete":{"summary":"Remove negative keywords.","operationId":"delete_api_v1_businesses_slug_ads_negative_keywords","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/businesses/{slug}/ads/keyword-recommendations":{"get":{"summary":"Keyword ideas with volume and bid ranges (Google), or Apple’s suggested bid per keyword. On Apple, adGroupId picks the ad-group-scoped keyword report — a narrower Apple endpoint rather than a filter.","operationId":"get_api_v1_businesses_slug_ads_keyword_recommendations","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"campaignId","in":"query","required":false,"schema":{"type":"string"}},{"name":"adGroupId","in":"query","required":false,"description":"Apple only: read /reports/campaigns/{cid}/adgroups/{agid}/keywords instead of every keyword in the campaign.","schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}},{"name":"account","in":"query","required":false,"description":"Ad account id, when several are connected. Aliases: adAccountId, advertiserId.","schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/businesses/{slug}/ads/targeting-search":{"get":{"summary":"Search targeting options. LEGACY: it answers in a different shape per network (id here, adamId there, locationId elsewhere) and covers eight lookups on four of them. GET …/ads/lookups is a strict superset in ONE shape across all five; prefer it for anything new. This one is unchanged and stays because the campaign launcher calls it.","operationId":"get_api_v1_businesses_slug_ads_targeting_search","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}},{"name":"type","in":"query","required":false,"schema":{"type":"string"}},{"name":"q","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/businesses/{slug}/ads/lookups":{"get":{"summary":"One id-discovery surface for all five networks: every lookup returns {id, label, meta}. Omit type to get the catalogue of what each network has and whether it can be answered yet. On Google, `id` is the RESOURCE NAME (\"customers/123/assets/456\") for assets, audiences, saved audiences and conversion actions, because that is what Google takes and it refuses the bare id.","operationId":"get_api_v1_businesses_slug_ads_lookups","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}},{"name":"type","in":"query","required":false,"schema":{"type":"string"}},{"name":"q","in":"query","required":false,"schema":{"type":"string"}},{"name":"limit","in":"query","required":false,"schema":{"type":"string"}},{"name":"cursor","in":"query","required":false,"schema":{"type":"string"}},{"name":"account","in":"query","required":false,"description":"Ad account id, when several are connected. Aliases: adAccountId, advertiserId.","schema":{"type":"string"}},{"name":"level","in":"query","required":false,"description":"Snapchat geo only: country (default) | region | metro. Three FIELDS of one geos entry, not three levels of one field.","schema":{"type":"string"}},{"name":"country","in":"query","required":false,"description":"ISO-2 country code. Apple geo, Google geo, Snapchat interest (scls taxonomy) — and Snapchat geo needs it for level=region and level=metro, which Snapchat lists one country at a time.","schema":{"type":"string"}},{"name":"taxonomy","in":"query","required":false,"description":"Snapchat interest only: scls (default) | dlxs | dlxc | dlxp | nln. One field takes ids from all five and the id says which it came from.","schema":{"type":"string"}},{"name":"assetType","in":"query","required":false,"description":"Google asset lookup only: narrow to IMAGE, TEXT, YOUTUBE_VIDEO, SITELINK, CALLOUT, …","schema":{"type":"string"}},{"name":"geoTargetIds","in":"query","required":false,"description":"Google keyword_idea only: comma-separated geo ids scoping the volume figures.","schema":{"type":"string"}},{"name":"languageId","in":"query","required":false,"description":"Google keyword_idea only. Defaults to 1000 (English).","schema":{"type":"string"}},{"name":"pageUrl","in":"query","required":false,"description":"Google keyword_idea only: a landing page to seed suggestions from.","schema":{"type":"string"}},{"name":"locationIds","in":"query","required":false,"description":"TikTok isp only, REQUIRED: comma-separated country-level location ids. TikTok has no global provider list.","schema":{"type":"string"}},{"name":"catalogId","in":"query","required":false,"description":"TikTok/Meta/Snapchat product_set and product: which catalogue.","schema":{"type":"string"}},{"name":"productSetId","in":"query","required":false,"description":"TikTok product, REQUIRED: TikTok reads products through a set and has no whole-catalogue product list.","schema":{"type":"string"}},{"name":"bcId","in":"query","required":false,"description":"TikTok catalogue lookups: which Business Center, when the login can see several.","schema":{"type":"string"}},{"name":"appId","in":"query","required":false,"description":"TikTok app_event, REQUIRED alongside optimizationGoal.","schema":{"type":"string"}},{"name":"optimizationGoal","in":"query","required":false,"description":"TikTok app_event, REQUIRED: which events are legal depends on the goal as well as the app.","schema":{"type":"string"}},{"name":"itemId","in":"query","required":false,"description":"TikTok organic_post, REQUIRED: the Spark post URL or the number at the end of it.","schema":{"type":"string"}},{"name":"identityId","in":"query","required":false,"description":"TikTok organic_post, REQUIRED: the identity that owns the post.","schema":{"type":"string"}},{"name":"identityType","in":"query","required":false,"description":"TikTok organic_post, REQUIRED: AUTH_CODE, TT_USER or BC_AUTH_TT.","schema":{"type":"string"}},{"name":"adamId","in":"query","required":false,"description":"Apple product_page (REQUIRED — Apple offers no search over an app’s pages) and Apple creative (optional filter — creatives are organisation property, so omitting it browses them all).","schema":{"type":"string"}},{"name":"campaignId","in":"query","required":false,"description":"Apple keyword_idea, REQUIRED: keyword ideas on Apple are the SEARCH-TERM report and Apple has no org-wide one.","schema":{"type":"string"}},{"name":"days","in":"query","required":false,"description":"Apple keyword_idea only: how far back to read search terms, 1 to 89 (default 30). 89 is Apple’s own report horizon.","schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/businesses/{slug}/ads/recommendations":{"get":{"summary":"Optimizer recommendations.","operationId":"get_api_v1_businesses_slug_ads_recommendations","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"summary":"Apply/dismiss a recommendation.","operationId":"post_api_v1_businesses_slug_ads_recommendations","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBody":{"content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}}},"/api/v1/businesses/{slug}/ads/delivery-estimate":{"get":{"summary":"Delivery / reach estimate.","operationId":"get_api_v1_businesses_slug_ads_delivery_estimate","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/businesses/{slug}/ads/previews":{"get":{"summary":"Ad preview render. Meta returns embeddable iframe HTML for an ad. Google returns a signed LINK (never HTML — it sets X-Frame-Options: deny) for a Performance Max asset group via ?assetGroupId=, or a YouTube ad via ?adId=adGroupId~adId; it refuses responsive search and responsive display ads with UNSUPPORTED_AD_TYPE, so use …/ads/ad-strength for those. SNAPCHAT ANSWERS NARROWLY: it previews the MEDIA FILE rather than the assembled ad (pass the media id as ?adId=), because GET /media/{id}/preview is documented and the creative-level snapcode is not — so it settles \"is that the right video\" and cannot settle \"what will the ad look like\", since the headline, brand name and call to action live on the creative. TikTok and Apple report why they cannot as 501, and the two reasons differ: an Apple ad IS an App Store product page, while TikTok publishes an endpoint with a blank description and no stated id space, which is named and not called. Nothing here previews an ad that does not exist yet.","operationId":"get_api_v1_businesses_slug_ads_previews","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"adId","in":"query","required":false,"description":"A plain id on Meta, TikTok and Apple; the composite adGroupId~adId on Google; a MEDIA id (UUID) on Snapchat.","schema":{"type":"string"}},{"name":"assetGroupId","in":"query","required":false,"description":"Google: a Performance Max asset group id. One of adId or assetGroupId is required.","schema":{"type":"string"}},{"name":"format","in":"query","required":false,"description":"Meta: the placement. Google: the previewType, UI_PREVIEW or YOUTUBE_LIVE_PREVIEW.","schema":{"type":"string"}},{"name":"network","in":"query","required":false,"schema":{"type":"string"}},{"name":"account","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/businesses/{slug}/ads/ad-strength":{"get":{"summary":"Google’s grade for every ad and Performance Max asset group, plus its action items. PENDING and NO_ADS are not grades. Google-only.","operationId":"get_api_v1_businesses_slug_ads_ad_strength","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"schema":{"type":"string"}},{"name":"campaignId","in":"query","required":false,"schema":{"type":"string"}},{"name":"account","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/businesses/{slug}/ads/ad-review":{"get":{"summary":"Why a TikTok ad was refused, with the policy reasons and violation frames. TikTok publishes review endpoints for Smart+ only; family=manual returns the ad’s secondary_status plus the reason there is no more. materialIds reads individual creatives inside a Smart+ ad. TikTok-only.","operationId":"get_api_v1_businesses_slug_ads_ad_review","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"schema":{"type":"string"}},{"name":"adIds","in":"query","required":false,"description":"Comma-separated ad ids.","schema":{"type":"string"}},{"name":"materialIds","in":"query","required":false,"description":"Comma-separated ad material ids; reads per-creative review instead.","schema":{"type":"string"}},{"name":"family","in":"query","required":false,"description":"smart_plus (default) or manual.","schema":{"type":"string"}},{"name":"includeRejectInfo","in":"query","required":false,"schema":{"type":"string"}},{"name":"includeViolationFrame","in":"query","required":false,"schema":{"type":"string"}},{"name":"lang","in":"query","required":false,"schema":{"type":"string"}},{"name":"account","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"summary":"Appeal a refused Smart+ ad ({ action: \"appeal\", adId, reason?, attachments? }), or turn one of its creatives off and leave the rest running ({ action: \"material_status\", adId, materialIds, operationStatus }).","operationId":"post_api_v1_businesses_slug_ads_ad_review","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"schema":{"type":"string"}},{"name":"account","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBody":{"content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}}},"/api/v1/businesses/{slug}/ads/comments":{"get":{"summary":"Comments on TikTok ads. Requires TikTok’s own window and search pair (startTime, endTime, searchField, searchValue); commentId + commentType reads one thread instead. TikTok-only — Meta’s equivalent is behind an ungranted scope and the other three carry no comments.","operationId":"get_api_v1_businesses_slug_ads_comments","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"schema":{"type":"string"}},{"name":"startTime","in":"query","required":false,"schema":{"type":"string"}},{"name":"endTime","in":"query","required":false,"schema":{"type":"string"}},{"name":"searchField","in":"query","required":false,"schema":{"type":"string"}},{"name":"searchValue","in":"query","required":false,"schema":{"type":"string"}},{"name":"commentTypes","in":"query","required":false,"schema":{"type":"string"}},{"name":"commentStatuses","in":"query","required":false,"schema":{"type":"string"}},{"name":"sortField","in":"query","required":false,"schema":{"type":"string"}},{"name":"sortType","in":"query","required":false,"schema":{"type":"string"}},{"name":"commentId","in":"query","required":false,"schema":{"type":"string"}},{"name":"commentType","in":"query","required":false,"schema":{"type":"string"}},{"name":"originalCommentId","in":"query","required":false,"schema":{"type":"string"}},{"name":"app","in":"query","required":false,"schema":{"type":"string"}},{"name":"page","in":"query","required":false,"schema":{"type":"string"}},{"name":"pageSize","in":"query","required":false,"schema":{"type":"string"}},{"name":"account","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"summary":"Hide or unhide comments in bulk ({ action: \"status\", commentIds, operation }), or reply as the ad’s identity ({ action: \"reply\", adId, commentId, commentType, identityId, identityType, itemId, text }).","operationId":"post_api_v1_businesses_slug_ads_comments","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"schema":{"type":"string"}},{"name":"account","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBody":{"content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}},"delete":{"summary":"Delete one comment. Irreversible at TikTok — hiding is the reversible one.","operationId":"delete_api_v1_businesses_slug_ads_comments","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"schema":{"type":"string"}},{"name":"commentId","in":"query","required":false,"schema":{"type":"string"}},{"name":"adId","in":"query","required":false,"schema":{"type":"string"}},{"name":"identityId","in":"query","required":false,"schema":{"type":"string"}},{"name":"identityType","in":"query","required":false,"schema":{"type":"string"}},{"name":"itemId","in":"query","required":false,"schema":{"type":"string"}},{"name":"account","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/businesses/{slug}/ads/blocked-words":{"get":{"summary":"The ad account’s blocked-word list — account-level, unlike comment_disabled, which is per ad group and turns comments off entirely. ?check= asks TikTok whether words are already blocked. TikTok-only.","operationId":"get_api_v1_businesses_slug_ads_blocked_words","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"schema":{"type":"string"}},{"name":"check","in":"query","required":false,"schema":{"type":"string"}},{"name":"page","in":"query","required":false,"schema":{"type":"string"}},{"name":"pageSize","in":"query","required":false,"schema":{"type":"string"}},{"name":"account","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"summary":"Add words ({ words }), or replace one in a single call ({ action: \"rename\", oldWord, newWord }) — a delete plus an add leaves a window where neither word is blocked.","operationId":"post_api_v1_businesses_slug_ads_blocked_words","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"schema":{"type":"string"}},{"name":"account","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBody":{"content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}},"delete":{"summary":"Remove blocked words.","operationId":"delete_api_v1_businesses_slug_ads_blocked_words","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"schema":{"type":"string"}},{"name":"words","in":"query","required":false,"schema":{"type":"string"}},{"name":"account","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/businesses/{slug}/ads/change-history":{"get":{"summary":"Who changed what in the last 30 days, with clientType telling our own writes apart from Ads Manager, Editor, scripts and Google’s automation. Google keeps 30 days and answers older windows with an empty list, so this refuses them instead. Google-only.","operationId":"get_api_v1_businesses_slug_ads_change_history","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"schema":{"type":"string"}},{"name":"startDate","in":"query","required":false,"schema":{"type":"string"}},{"name":"endDate","in":"query","required":false,"schema":{"type":"string"}},{"name":"campaignId","in":"query","required":false,"schema":{"type":"string"}},{"name":"limit","in":"query","required":false,"schema":{"type":"string"}},{"name":"account","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/businesses/{slug}/ads/experiments":{"get":{"summary":"A/B experiments and campaign drafts. Google is the only network with an experiment API. ?experimentId= adds the arms and the async errors, which is the only place a failed schedule or promote reports why.","operationId":"get_api_v1_businesses_slug_ads_experiments","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"schema":{"type":"string"}},{"name":"kind","in":"query","required":false,"description":"experiment (default) or draft.","schema":{"type":"string"}},{"name":"experimentId","in":"query","required":false,"schema":{"type":"string"}},{"name":"draftId","in":"query","required":false,"description":"The composite baseCampaignId~draftId.","schema":{"type":"string"}},{"name":"account","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"summary":"Create an experiment (SETUP — nothing splits until scheduled) or act on one: schedule, promote (PERMANENT), graduate (needs a budget), end. kind=draft creates or promotes a campaign draft.","operationId":"post_api_v1_businesses_slug_ads_experiments","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"schema":{"type":"string"}},{"name":"account","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBody":{"content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}},"delete":{"summary":"Remove an experiment or a draft. Allowed because both are copies — the original campaign keeps serving.","operationId":"delete_api_v1_businesses_slug_ads_experiments","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"schema":{"type":"string"}},{"name":"kind","in":"query","required":false,"schema":{"type":"string"}},{"name":"experimentId","in":"query","required":false,"schema":{"type":"string"}},{"name":"draftId","in":"query","required":false,"schema":{"type":"string"}},{"name":"account","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/businesses/{slug}/ads/labels":{"get":{"summary":"Labels, or ?kind=campaign_group for campaign groups. Google-only — no other network has a label object.","operationId":"get_api_v1_businesses_slug_ads_labels","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"schema":{"type":"string"}},{"name":"kind","in":"query","required":false,"schema":{"type":"string"}},{"name":"account","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"summary":"Create a label, attach or detach one (campaign, adgroup, ad), or create/rename a campaign group and assign a campaign to it.","operationId":"post_api_v1_businesses_slug_ads_labels","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"schema":{"type":"string"}},{"name":"account","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBody":{"content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}},"delete":{"summary":"Remove a label or a campaign group. Safe: neither carries history and nothing stops serving.","operationId":"delete_api_v1_businesses_slug_ads_labels","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"schema":{"type":"string"}},{"name":"kind","in":"query","required":false,"schema":{"type":"string"}},{"name":"labelId","in":"query","required":false,"schema":{"type":"string"}},{"name":"groupId","in":"query","required":false,"schema":{"type":"string"}},{"name":"account","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/businesses/{slug}/ads/impression-share":{"get":{"summary":"Apple’s impression share report: ?reportId= for one, otherwise the most recent. Asynchronous — poll until state is COMPLETED, then use downloadUri at once, because it expires 90 seconds after the response that carries it. Known Apple-side fault: their gateway has answered these GETs with a 403 since 2026-03-16, and the response says so rather than showing the bare status.","operationId":"get_api_v1_businesses_slug_ads_impression_share","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}},{"name":"account","in":"query","required":false,"description":"Ad account id, when several are connected. Aliases: adAccountId, advertiserId.","schema":{"type":"string"}},{"name":"reportId","in":"query","required":false,"schema":{"type":"string"}},{"name":"limit","in":"query","required":false,"description":"1–50, default 20.","schema":{"type":"string"}},{"name":"offset","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"summary":"Queue an impression share report. { name, granularity: DAILY|WEEKLY, and either dateRange (LAST_WEEK|LAST_2_WEEKS|LAST_4_WEEKS) or startTime+endTime; optional adamIds and countriesOrRegions }. WEEKLY takes only dateRange. Apple allows ten per organisation per 24 hours.","operationId":"post_api_v1_businesses_slug_ads_impression_share","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}},{"name":"account","in":"query","required":false,"description":"Ad account id, when several are connected. Aliases: adAccountId, advertiserId.","schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBody":{"content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}}},"/api/v1/businesses/{slug}/ads/budget-schedules":{"get":{"summary":"Scheduled budget boosts already on a campaign or ad set (Meta high demand periods). The other four answer 501 with the reason.","operationId":"get_api_v1_businesses_slug_ads_budget_schedules","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"parentId","in":"query","required":false,"description":"REQUIRED: the campaign or ad set id. Meta exposes the same edge on both.","schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}},{"name":"account","in":"query","required":false,"description":"Ad account id, when several are connected. Aliases: adAccountId, advertiserId.","schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"summary":"Add a budget boost to a campaign or ad set that already exists — the half budget_schedule_specs cannot do, since that field is create-only. Body: { parentId, budgetValue, budgetValueType: ABSOLUTE|MULTIPLIER, timeStart, timeEnd } with unix seconds. Meta publishes no update and no delete on this object, so a schedule is PERMANENT: to undo one, lower the budget or pause. A window already in the past is refused rather than posted.","operationId":"post_api_v1_businesses_slug_ads_budget_schedules","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}},{"name":"account","in":"query","required":false,"description":"Ad account id, when several are connected. Aliases: adAccountId, advertiserId.","schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBody":{"content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}}},"/api/v1/businesses/{slug}/ads/rules":{"get":{"summary":"List automated rules (meta, tiktok). ?view=results is TikTok’s audit trail.","operationId":"get_api_v1_businesses_slug_ads_rules","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}},{"name":"account","in":"query","required":false,"description":"Ad account id, when several are connected. Aliases: adAccountId, advertiserId.","schema":{"type":"string"}},{"name":"ruleIds","in":"query","required":false,"description":"tiktok: comma-separated ids for an exact read instead of the list.","schema":{"type":"string"}},{"name":"view","in":"query","required":false,"description":"tiktok: \"results\" returns the audit trail (what the rule actually did) instead of the rules.","schema":{"type":"string"}},{"name":"detail","in":"query","required":false,"description":"tiktok, with view=results: \"1\" chains the per-result detail over the first 20 rows and reports details_truncated.","schema":{"type":"string"}},{"name":"status","in":"query","required":false,"schema":{"type":"string"}},{"name":"action","in":"query","required":false,"schema":{"type":"string"}},{"name":"dataDimension","in":"query","required":false,"schema":{"type":"string"}},{"name":"ruleInfo","in":"query","required":false,"schema":{"type":"string"}},{"name":"time","in":"query","required":false,"schema":{"type":"string"}},{"name":"tzone","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"summary":"Create an automated rule. TikTok money fields come in minor-unit (valuesCents) and major-unit (values) spellings; send one, never both.","operationId":"post_api_v1_businesses_slug_ads_rules","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}},{"name":"account","in":"query","required":false,"description":"Ad account id, when several are connected. Aliases: adAccountId, advertiserId.","schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBody":{"content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}}},"/api/v1/businesses/{slug}/ads/rules/{ruleId}":{"get":{"summary":"Read one rule (TikTok only). Prerequisite for PATCH, which is a full replace there.","operationId":"get_api_v1_businesses_slug_ads_rules_ruleId","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"ruleId","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}},{"name":"account","in":"query","required":false,"description":"Ad account id, when several are connected. Aliases: adAccountId, advertiserId.","schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"summary":"Bind an existing rule to more campaigns/ad groups/ads (TikTok only): { binds: [{ dimension, bindType, dimensionIds }] }.","operationId":"post_api_v1_businesses_slug_ads_rules_ruleId","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"ruleId","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}},{"name":"account","in":"query","required":false,"description":"Ad account id, when several are connected. Aliases: adAccountId, advertiserId.","schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBody":{"content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}},"patch":{"summary":"Update a rule. Meta takes a PARTIAL; TikTok is a FULL REPLACE and refuses a body missing any required part.","operationId":"patch_api_v1_businesses_slug_ads_rules_ruleId","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"ruleId","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}},{"name":"account","in":"query","required":false,"description":"Ad account id, when several are connected. Aliases: adAccountId, advertiserId.","schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBody":{"content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}},"delete":{"summary":"Delete a rule. META ONLY: TikTok publishes no rule delete and no on/off field, so ?network=tiktok is a 400.","operationId":"delete_api_v1_businesses_slug_ads_rules_ruleId","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"ruleId","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}},{"name":"account","in":"query","required":false,"description":"Ad account id, when several are connected. Aliases: adAccountId, advertiserId.","schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/businesses/{slug}/ads/audiences":{"get":{"summary":"List custom audiences — meta, tiktok, google and snapchat; Apple has no audience object of any kind. Unusable audiences are KEPT, each carrying notTargetableReason (too small, still processing, expired, closed), because hiding them reads as \"somebody deleted my audience\". ?catalog=1 with no ?network= answers the whole VOCABULARY instead — every kind on every network, whether a writer exists today, and where one does not whether that is the network’s limit, an ungranted permission, a product decision or an unconfirmed wire shape — plus derived capabilities. It asks no network anything.","operationId":"get_api_v1_businesses_slug_ads_audiences","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}},{"name":"account","in":"query","required":false,"description":"Ad account id, when several are connected. Aliases: adAccountId, advertiserId.","schema":{"type":"string"}},{"name":"catalog","in":"query","required":false,"description":"Any value: answer the cross-network vocabulary and capabilities instead of calling a network. Needs no connection.","schema":{"type":"string"}},{"name":"type","in":"query","required":false,"description":"tiktok: \"custom\" (default) or \"saved\". Saved audiences answer under savedAudiences, never mixed into audiences.","schema":{"type":"string"}},{"name":"ids","in":"query","required":false,"description":"tiktok: comma-separated audience ids, max 100 (TikTok’s own cap).","schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"summary":"Create an audience. google and snapchat take the SHARED vocabulary { kind, name, … } (see the schema); meta and tiktok keep their own older bodies. Customer-list uploads of hashed emails and phones are refused on all four — a product decision, not a technical gap. TikTok trap: lookbackDays picks WHO is in it, expireInDays picks when it dies; swapping them succeeds and builds the wrong audience.","operationId":"post_api_v1_businesses_slug_ads_audiences","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}},{"name":"account","in":"query","required":false,"description":"Ad account id, when several are connected. Aliases: adAccountId, advertiserId.","schema":{"type":"string"}},{"name":"type","in":"query","required":false,"description":"tiktok: \"saved\" creates a saved audience instead. It takes no kind, and sending one is a 400.","schema":{"type":"string"}}],"responses":{"200":{"description":"OK (already existed; returned as-is)"},"201":{"description":"Created"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AudienceCreateRequest"}}}}}},"/api/v1/businesses/{slug}/ads/audiences/{audienceId}":{"patch":{"summary":"Rename an audience ({ name }, plus { description } on meta, google and snapchat) or, TikTok only, refresh a lookalike ({ refresh: true }) — exactly one per call there, and every other network refuses `refresh` by name because none publishes a way to force a recalculation. NAME AND DESCRIPTION ARE ALL THERE IS on every network: the rule, retention window and lookalike spec decide who is IN the audience, and rewriting one silently rebuilds the membership of an audience live ad sets are targeting. On google the path takes the NUMERIC half of the resource name (a path segment cannot hold slashes); the full form is accepted too.","operationId":"patch_api_v1_businesses_slug_ads_audiences_audienceId","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"audienceId","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}},{"name":"account","in":"query","required":false,"description":"Ad account id, when several are connected. Aliases: adAccountId, advertiserId.","schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBody":{"content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}},"delete":{"summary":"Delete an audience — meta, tiktok, google, snapchat. Each network’s own refusal surfaces verbatim (Meta will not delete one live ad sets target; TikTok will not delete one shared in from another account; Google will not delete one a criterion still references). TikTok ?type=saved targets the other id space, so say which you mean.","operationId":"delete_api_v1_businesses_slug_ads_audiences_audienceId","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"audienceId","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}},{"name":"account","in":"query","required":false,"description":"Ad account id, when several are connected. Aliases: adAccountId, advertiserId.","schema":{"type":"string"}},{"name":"type","in":"query","required":false,"description":"tiktok: \"custom\" (default) or \"saved\".","schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/businesses/{slug}/ads/audiences/{audienceId}/share":{"get":{"summary":"Who this audience is currently lent to. TikTok only; ?network= defaults to tiktok here, not meta.","operationId":"get_api_v1_businesses_slug_ads_audiences_audienceId_share","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"audienceId","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}},{"name":"account","in":"query","required":false,"description":"Ad account id, when several are connected. Aliases: adAccountId, advertiserId.","schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"summary":"Lend the audience to other ad accounts ({ advertiserIds }). Not a customer-list operation: no data about anyone moves, an audience that already exists becomes targetable by a second account of the same advertiser.","operationId":"post_api_v1_businesses_slug_ads_audiences_audienceId_share","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"audienceId","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}},{"name":"account","in":"query","required":false,"description":"Ad account id, when several are connected. Aliases: adAccountId, advertiserId.","schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBody":{"content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}},"delete":{"summary":"Withdraw ONE loan. One account per call, because the networks cancel one share at a time and a loop that failed half way could not say where.","operationId":"delete_api_v1_businesses_slug_ads_audiences_audienceId_share","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"audienceId","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}},{"name":"account","in":"query","required":false,"description":"Ad account id, when several are connected. Aliases: adAccountId, advertiserId.","schema":{"type":"string"}},{"name":"advertiserId","in":"query","required":true,"description":"Which account the share is withdrawn from.","schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/businesses/{slug}/ads/custom-conversions":{"get":{"summary":"List custom conversions. Account-level on Meta; on TikTok what this wraps is event rules on ONE pixel, so pixelId is required. TikTok also publishes a separate first-class /custom_conversion/ family keyed by custom_conversion_id, which this endpoint does not cover.","operationId":"get_api_v1_businesses_slug_ads_custom_conversions","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}},{"name":"account","in":"query","required":false,"description":"Ad account id, when several are connected. Aliases: adAccountId, advertiserId.","schema":{"type":"string"}},{"name":"pixelId","in":"query","required":false,"description":"tiktok: required. One id for the list; comma-separated for view=stats.","schema":{"type":"string"}},{"name":"view","in":"query","required":false,"description":"tiktok: \"stats\" answers whether the events are actually firing.","schema":{"type":"string"}},{"name":"startDate","in":"query","required":false,"description":"tiktok, with view=stats: YYYY-MM-DD.","schema":{"type":"string"}},{"name":"endDate","in":"query","required":false,"description":"tiktok, with view=stats: YYYY-MM-DD.","schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"summary":"Create a custom conversion. TikTok eventType is the OPTIMIZATION vocabulary (SHOPPING), never the Events API one (Purchase), which is refused.","operationId":"post_api_v1_businesses_slug_ads_custom_conversions","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}},{"name":"account","in":"query","required":false,"description":"Ad account id, when several are connected. Aliases: adAccountId, advertiserId.","schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBody":{"content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}}},"/api/v1/businesses/{slug}/ads/custom-conversions/{conversionId}":{"patch":{"summary":"Edit a TikTok conversion ({ eventName?, currency?, valueCents? }). TikTok only; on Meta delete and re-create.","operationId":"patch_api_v1_businesses_slug_ads_custom_conversions_conversionId","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"conversionId","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}},{"name":"account","in":"query","required":false,"description":"Ad account id, when several are connected. Aliases: adAccountId, advertiserId.","schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBody":{"content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}},"delete":{"summary":"Delete a custom conversion. TikTok will not refuse while a live ad group still optimises toward the event.","operationId":"delete_api_v1_businesses_slug_ads_custom_conversions_conversionId","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"conversionId","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}},{"name":"account","in":"query","required":false,"description":"Ad account id, when several are connected. Aliases: adAccountId, advertiserId.","schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/businesses/{slug}/ads/catalogs":{"get":{"summary":"Product catalogs, and THE SCOPE IS WHAT DIFFERS, not the operations. tiktok: catalogs on a BUSINESS CENTER — the source of catalog_id and catalog_authorized_bc_id — so ?bcId= picks between them and ?account= is refused rather than ignored. snapchat: catalogs on the ORGANIZATION, which comes from the connection, so there is no scope parameter at all. google: not a catalogue but the LINK — the Merchant Center accounts this Ads account may sell out of, which is where shopping.merchantId comes from; ?account= is the right selector there. meta: 501, catalog_management was not granted by App Review. apple: 501, its API has no catalogue of any kind.","operationId":"get_api_v1_businesses_slug_ads_catalogs","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}},{"name":"bcId","in":"query","required":false,"description":"Business Center id. Omit and we use the only one this TikTok login can see; with several we refuse and list them. Catalogs take this instead of ?account=, which is a 400 here.","schema":{"type":"string"}},{"name":"account","in":"query","required":false,"description":"Ad account id, when several are connected. Aliases: adAccountId, advertiserId.","schema":{"type":"string"}},{"name":"resource","in":"query","required":false,"description":"tiktok: \"catalogs\" (default), \"countries\" (valid regionCode values), or \"currencies\" (needs no Business Center at all). google: \"invitations\" lists link requests waiting to be answered — an owner who linked from the Merchant Center side sees nothing in `catalogs` until one is accepted. Meaningless on snapchat.","schema":{"type":"string"}},{"name":"catalogId","in":"query","required":false,"description":"Narrow to one catalog. Does NOT descend into it: sets and products live on /ads/catalogs/{catalogId}.","schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"summary":"tiktok: create a catalog — currency and regionCode can NEVER be changed afterwards (TikTok’s update takes bc_id + catalog_id + name and nothing else), so a typo means delete and rebuild. snapchat: create a catalog { name, vertical? }, where vertical defaults to COMMERCE and currency/regionCode are REFUSED BY NAME because a Snapchat catalogue has neither — currency belongs to each product feed — so silence would teach an owner their catalogue is scoped to a market it is not. google: link a Merchant Center account { merchantCenterId } (needs admin on BOTH accounts), or &resource=invitation { resourceName, decision: ACCEPTED|REJECTED } to answer a request started from the Merchant Center side, which is the path most owners will actually use.","operationId":"post_api_v1_businesses_slug_ads_catalogs","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}},{"name":"bcId","in":"query","required":false,"description":"Business Center id. Omit and we use the only one this TikTok login can see; with several we refuse and list them. Catalogs take this instead of ?account=, which is a 400 here.","schema":{"type":"string"}},{"name":"account","in":"query","required":false,"description":"Ad account id, when several are connected. Aliases: adAccountId, advertiserId.","schema":{"type":"string"}},{"name":"resource","in":"query","required":false,"description":"google: \"invitation\" answers a pending link request instead of creating one.","schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBody":{"content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}},"delete":{"summary":"google ONLY: unlink a Merchant Center account. The catalogue, its feeds and its products survive an unlink untouched, which is why this is deliberately not filed as a catalogue delete. Deleting a TikTok or Snapchat catalogue rides /ads/catalogs/{catalogId}.","operationId":"delete_api_v1_businesses_slug_ads_catalogs","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}},{"name":"account","in":"query","required":false,"description":"Ad account id, when several are connected. Aliases: adAccountId, advertiserId.","schema":{"type":"string"}},{"name":"productLink","in":"query","required":true,"description":"google: the product link RESOURCE NAME (\"customers/123/productLinks/456\"), which each row of the GET carries in raw.productLinkResourceName. It is not the Merchant Center id — that one is the row’s `id`, and it is what a campaign writes into shopping_setting.merchant_id.","schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/businesses/{slug}/ads/catalogs/{catalogId}":{"get":{"summary":"One catalog and everything under it: product sets (product_set_id) and their products (sku_ids, item_group_ids). TIKTOK ONLY for the contents — Snapchat publishes no endpoint that lists a product SET anywhere in its Marketing API, and Google’s catalogue lives in the Merchant API, a second product with its own scope. That is exactly why Snapchat can list catalogues and still not launch a product-sales campaign: its dynamic creative needs a product set id and a dynamic template id, and neither is obtainable through any API.","operationId":"get_api_v1_businesses_slug_ads_catalogs_catalogId","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"catalogId","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}},{"name":"bcId","in":"query","required":false,"description":"Business Center id. Omit and we use the only one this TikTok login can see; with several we refuse and list them. Catalogs take this instead of ?account=, which is a 400 here.","schema":{"type":"string"}},{"name":"resource","in":"query","required":false,"description":"Omit for the catalog + counts; \"sets\", \"products\" or \"upload-log\".","schema":{"type":"string"}},{"name":"productSetId","in":"query","required":false,"description":"Required with resource=products; optional with resource=sets to narrow to one.","schema":{"type":"string"}},{"name":"returnProductCount","in":"query","required":false,"description":"resource=sets: \"false\" skips the per-set product count.","schema":{"type":"string"}},{"name":"feedLogId","in":"query","required":false,"description":"Required with resource=upload-log. Returned by the feed upload.","schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"summary":"Upload a product feed: { fileUrl, updateMode?, feedId? }. TikTok fetches the URL, so nothing is imported yet and this answers 202.","operationId":"post_api_v1_businesses_slug_ads_catalogs_catalogId","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"catalogId","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}},{"name":"bcId","in":"query","required":false,"description":"Business Center id. Omit and we use the only one this TikTok login can see; with several we refuse and list them. Catalogs take this instead of ?account=, which is a 400 here.","schema":{"type":"string"}},{"name":"resource","in":"query","required":true,"description":"Must be \"products\".","schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBody":{"content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}},"patch":{"summary":"RENAME ONLY, on both networks that have it, and for two different reasons. tiktok: its catalog update body is bc_id + catalog_id + name, so currency and region genuinely cannot change. snapchat: PUT is its only segment/catalogue update verb and it REPLACES the whole object, so this reads the catalogue first and sends it back with the new name — a minimal body would leave a working catalogue with no event sources, silently breaking attribution on every dynamic ad selling out of it. TikTok also takes ?resource=sets to rename a product set and/or replace its rule.","operationId":"patch_api_v1_businesses_slug_ads_catalogs_catalogId","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"catalogId","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}},{"name":"bcId","in":"query","required":false,"description":"Business Center id. Omit and we use the only one this TikTok login can see; with several we refuse and list them. Catalogs take this instead of ?account=, which is a 400 here.","schema":{"type":"string"}},{"name":"resource","in":"query","required":false,"description":"tiktok: \"sets\" to edit a product set instead of the catalog.","schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBody":{"content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}},"delete":{"summary":"Delete the catalog (tiktok, snapchat), or on TikTok ?resource=sets / ?resource=products. A product delete naming no ids is refused rather than sent, because TikTok does not document what an empty selection means and \"deletes everything\" is not worth finding out in production. Google has no catalogue to delete — unlinking a Merchant Center account is DELETE on the collection path and leaves the catalogue untouched.","operationId":"delete_api_v1_businesses_slug_ads_catalogs_catalogId","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"catalogId","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}},{"name":"bcId","in":"query","required":false,"description":"Business Center id. Omit and we use the only one this TikTok login can see; with several we refuse and list them. Catalogs take this instead of ?account=, which is a 400 here.","schema":{"type":"string"}},{"name":"resource","in":"query","required":false,"description":"tiktok: \"sets\" or \"products\"; omit to delete the whole catalog.","schema":{"type":"string"}},{"name":"productSetIds","in":"query","required":false,"description":"resource=sets: comma-separated. Sets cannot be recreated through the API.","schema":{"type":"string"}},{"name":"skuIds","in":"query","required":false,"description":"resource=products: comma-separated. Also destinationIds, flightIds, hotelIds, mediaTitleIds, vehicleIds.","schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/businesses/{slug}/ads/leads":{"get":{"summary":"TikTok Instant Forms and the leads they collected. With no pageId it lists the Instant Forms on the ad account, which needs no extra permission. Both LEAD reads require leadSource — INSTANT_FORM (with pageId) or DIRECT_MESSAGE (without one, for leads collected in TikTok DMs). There is NO paging: TikTok ignores page and pageSize here and publishes no list endpoint. ?resource=fields returns the form’s questions as plain strings plus its name and public URL. The \"Lead Management\" scope is granted, verified live 2026-08-05.","operationId":"get_api_v1_businesses_slug_ads_leads","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}},{"name":"account","in":"query","required":false,"description":"Ad account id, when several are connected. Aliases: adAccountId, advertiserId.","schema":{"type":"string"}},{"name":"leadSource","in":"query","required":false,"description":"INSTANT_FORM or DIRECT_MESSAGE. Required on the fields and leads reads — TikTok refuses either without it.","schema":{"type":"string"}},{"name":"pageId","in":"query","required":false,"description":"The Instant Form. Required for an INSTANT_FORM read; omit to list the forms and their ids.","schema":{"type":"string"}},{"name":"resource","in":"query","required":false,"description":"\"fields\" for the form’s questions instead of its leads. Defaults to \"leads\" when pageId is given and \"forms\" when it is not.","schema":{"type":"string"}},{"name":"startTime","in":"query","required":false,"description":"Only leads submitted at or after this time.","schema":{"type":"string"}},{"name":"endTime","in":"query","required":false,"description":"Only leads submitted at or before this time.","schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/businesses/{slug}/ads/apps":{"get":{"summary":"TikTok promoted apps: the source of the app_id campaign field, and of an ad group’s legal optimization_event values. Read-only.","operationId":"get_api_v1_businesses_slug_ads_apps","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}},{"name":"account","in":"query","required":false,"description":"Ad account id, when several are connected. Aliases: adAccountId, advertiserId.","schema":{"type":"string"}},{"name":"tiktokAppId","in":"query","required":false,"description":"One app’s detail. ?appId= is accepted as an alias, since appId means an Affiliateo app elsewhere in this API.","schema":{"type":"string"}},{"name":"resource","in":"query","required":false,"description":"\"optimization-events\" or \"retargeting-events\" instead of the app list.","schema":{"type":"string"}},{"name":"optimizationGoal","in":"query","required":false,"description":"Required with resource=optimization-events (CLICK, INSTALL, IN_APP_EVENT, VALUE …): the legal event set depends on it.","schema":{"type":"string"}},{"name":"availableOnly","in":"query","required":false,"description":"\"false\" also returns events the app declares but cannot use yet. Meaningful, so it is not coerced away.","schema":{"type":"string"}},{"name":"placements","in":"query","required":false,"schema":{"type":"string"}},{"name":"objective","in":"query","required":false,"schema":{"type":"string"}},{"name":"appPromotionType","in":"query","required":false,"schema":{"type":"string"}},{"name":"isSkan","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/businesses/{slug}/ads/creatives":{"post":{"summary":"Build one of Snapchat’s four richer creative formats from a { format } plus the inputs a person has: deep_link, reminder, story (a PREVIEW creative then a COMPOSITE holding 1–20 slides whose order is FROZEN after create) or collection (every tile as a creative element, then the interaction zone that orders them and carries the button, then the creative). Not atomic — Snapchat has no transaction across these and documents no delete for elements or zones — so a part-way failure leaves what landed; GET this path to list the zones already on the account and reuse them. Every response carries ad_type beside creative_id, because Snapchat spells each format differently on the ad and on the creative and refuses a mismatched pair at AD create. ?network=apple_search_ads takes Apple’s one format, { format: \"product_page\" }, and builds the TWO objects a Custom Product Page ad really is: the organisation-scoped creative, then the ad under a campaign AND an ad group that points at it. Apple allows ONE creative per product page per organisation, so this FINDS the existing one instead of colliding with it, and says so with creative_reused — which is also what makes a retry after a failed ad create cheap, since Apple publishes no creative delete. creative_state travels back on every answer: an ad on a creative that has not cleared review is accepted and then never delivers. Meta, Google and TikTok answer 501 and reach their creative shapes through …/ads/campaigns and …/ads/objects. Lenses and Filters cannot be created through any Marketing API.","operationId":"post_api_v1_businesses_slug_ads_creatives","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}},{"name":"account","in":"query","required":false,"description":"Ad account id, when several are connected. Aliases: adAccountId, advertiserId.","schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBody":{"content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}},"get":{"summary":"List the Snapchat interaction zones on the ad account (?resource=interaction_zones), each with its creative element ids and headline. Reusable objects: a second collection ad is one creative rather than four fresh tile uploads. Creative ELEMENTS have no listing — Snapchat documents their create and no read — and the zone carries their ids.","operationId":"get_api_v1_businesses_slug_ads_creatives","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}},{"name":"account","in":"query","required":false,"description":"Ad account id, when several are connected. Aliases: adAccountId, advertiserId.","schema":{"type":"string"}},{"name":"resource","in":"query","required":false,"description":"interaction_zones (the default and, today, the only value).","schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/businesses/{slug}/ads/billing":{"get":{"summary":"Billing / spend caps read. Meta returns balance and spend cap; Google returns billing setup, account budgets and (with invoiceYear + invoiceMonth) invoices; SNAPCHAT returns funding sources and billing centres, which are ORGANIZATION-scoped rather than ad-account-scoped — one line of credit can fund several ad accounts, so a SPENT or EXPIRED source stops delivery on campaigns that themselves report no error. Every Snapchat amount is in CENTS (Snapchat returns micro-currency at a flat 1e6, converted at the edge). TikTok exposes balances through …/ads/balance instead; Apple publishes no billing API.","operationId":"get_api_v1_businesses_slug_ads_billing","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}},{"name":"invoiceYear","in":"query","required":false,"description":"Google only, with invoiceMonth.","schema":{"type":"string"}},{"name":"invoiceMonth","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"patch":{"summary":"Update spend caps.","operationId":"patch_api_v1_businesses_slug_ads_billing","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBody":{"content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}}},"/api/v1/businesses/{slug}/ads/balance":{"get":{"summary":"Ad account balance (TikTok).","operationId":"get_api_v1_businesses_slug_ads_balance","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/businesses/{slug}/ads/breakdowns":{"get":{"summary":"Dimensional reporting. Omit ?dimension= for the catalogue of every breakdown each network has and whether it can be answered today; a declared-but-unbuilt one answers 501 rather than an empty bucket list, which would read as \"you had no delivery\". FOUR PARAMETERS NAME ONE OBJECT instead of the whole ad account: ?campaignId=, ?adGroupId=, ?assetGroupId= (Google Performance Max only) and ?adId=. Omit all four and the answer is the whole ad account, exactly as before. They may travel together when they CHAIN (a campaign id beside an ad group id is one question narrowed twice, and two networks need both halves to build the call); ?adGroupId= beside ?assetGroupId= is a 400 naming both, since a Performance Max campaign has no ad groups and an ordinary one has no asset groups. Every scoped report names the DEEPEST level in `scope` (account | campaign | adgroup | assetgroup | ad), the object in `scopeId`, echoes every id under its own parameter name, and carries `scopeVerified`. READ THAT FIELD: true means a live call has answered this network at this level and the catalogue dates it; false means the level is WIRED AND UNPROVEN, attempted for real and never once watched to work, with the sentence in `scopeNote`. Proven today: the ad account everywhere, and one campaign on meta, google, tiktok and snapchat. Attempted and unproven today: ad group and ad on those four, asset group on google, and CAMPAIGN on apple_search_ads, whose scope has never been called. A level a network neither serves nor attempts is 501 NOT_IMPLEMENTED naming the level and the parameter, never account-wide numbers under a narrower id, because a wrong number wearing a right label has no symptom; everything below campaign on apple_search_ads answers that way. A scoped call that cannot be built refuses in a sentence rather than returning buckets: [] (?adGroupId= with no ?campaignId= on snapchat; a Google age, gender, geo or product split asked about an asset group or an ad, since those live in reports carrying an ad group id and no ad or asset group id while the five plain segments reach every level; a TikTok ?adId= from the Upgraded Smart+ id space). The catalogue call takes the same four: each entry gains `scopeLevel` and `scopeState` (served | probe | refused), a refused level turns every dimension \"blocked\" with the reason, and the response gains a `scope` object carrying each network’s `levels` and `probe` lists, `probe` always present so an empty one is a fact rather than a missing key.","operationId":"get_api_v1_businesses_slug_ads_breakdowns","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}},{"name":"account","in":"query","required":false,"description":"Ad account id, when several are connected. Aliases: adAccountId, advertiserId.","schema":{"type":"string"}},{"name":"campaignId","in":"query","required":false,"description":"Optional: the network’s own campaign id, to split ONE campaign instead of the ad account. PROVEN LIVE on meta, google, tiktok and snapchat; on apple_search_ads it is attempted and answers scopeVerified false, because no live Apple call has ever carried the filter. /^[A-Za-z0-9_-]{1,128}$/, refused with 400 VALIDATION_ERROR before any network call. Google prints a resource name (\"customers/8249011858/campaigns/456\") and the id is the LAST SEGMENT, 456, not the whole name.","schema":{"type":"string"}},{"name":"adGroupId","in":"query","required":false,"description":"Optional: ONE ad group (Meta’s ad set, Snapchat’s ad squad, the same rung). WIRED AND UNPROVEN on meta, google, tiktok and snapchat, so the answer carries scopeVerified false and a scopeNote; apple_search_ads answers 501. May chain with campaignId, and on snapchat it MUST, since the ad squad split rides /campaigns/{id}/stats and an ad group id alone is refused in words. On google this is the one level below the campaign every dimension it serves reaches. Same shape check as campaignId.","schema":{"type":"string"}},{"name":"assetGroupId","in":"query","required":false,"description":"Optional, GOOGLE ONLY: a Performance Max asset group. A SIBLING of an ad group rather than a rung below it, because a Performance Max campaign has no ad groups and no individual ads, so sending it beside adGroupId or adId is a 400 naming both. WIRED AND UNPROVEN, so scopeVerified is false. Only the five plain segments (device, publisher, placement, hour_of_day, day_of_week) reach it: age, gender, country, region, city, metro and product are read from age_range_view, gender_view, geographic_view and shopping_performance_view, which carry an ad group id and no asset group id, and are refused by name. Every other network answers 501. Same shape check as campaignId.","schema":{"type":"string"}},{"name":"adId","in":"query","required":false,"description":"Optional: ONE ad. WIRED AND UNPROVEN on meta, google, tiktok and snapchat (scopeVerified false); apple_search_ads answers 501. May chain with campaignId and adGroupId. On google the same five plain segments reach it and the seven report-backed dimensions are refused by name. On tiktok the id is checked against the Upgraded Smart+ id space before the call, because TikTok splits ad ids into two families whose filters cannot travel together and the wrong one matches nothing, returning an empty report that would read as \"this ad reached nobody\". Same shape check as campaignId.","schema":{"type":"string"}},{"name":"dimension","in":"query","required":false,"description":"Canonical name (age, gender, country, region, city, metro, device, platform, publisher, placement, hour_of_day, day_of_week, interest, product, asset, frequency…) or that network’s own spelling. Omit for the catalogue. Meta serves every dimension it declares; Google serves all fourteen but ad_format and asset; TikTok serves the eight members of its required audience-dimension array (age, gender, country, platform, network_type, language, interest, publisher) and neither of the two it files under additional_dimension (region, metro); Snapchat serves eight of the nine it declares (age, gender, country, region, metro, platform, interest and device_make), leaving only device_model — Snapchat’s report_dimension table lists `make` under Device beside `os` and lists no `model`, so `model` really is an Ads Manager pivot name with no way to ask for it, while `make` was refused on that same reasoning until the table was read on 2026-08-06 and the reasoning turned out to be wrong about `make`; Apple serves all six it declares (age, gender, country, region, city, device), each of them the campaign-level report grouped by one groupBy value, where clicks is TAPS and conversions is totalInstalls and the window is capped at 89 days because Apple serves no report starting more than 90 days back. On Snapchat, region, metro, interest and device_make carry no conversion metrics at all (conversions is null, not 0 — Snapchat names those four itself as the pivots that lose them, and asking for conversion metrics beside one is a REJECTED request rather than a null column) and any day under 30 impressions is suppressed entirely, so its buckets do not add up to the Ad stats totals. On Meta, the device dimension’s `note` quotes Meta’s own opt-in notice: an empty result there is AMBIGUOUS rather than proof of no delivery. Google’s geo buckets key on the geo target constant id — the same id its geo lookup returns and a location criterion takes — and its age and gender come from their own resources rather than segments, so they only cover ad groups carrying those criteria.","schema":{"type":"string"}},{"name":"assetType","in":"query","required":false,"description":"Required by Meta’s asset dimension: ad_format | body | call_to_action | description | image | link_url | title | video. Meta ships eight separate creative-asset breakdowns rather than one with a type.","schema":{"type":"string"}},{"name":"timeZone","in":"query","required":false,"description":"Meta’s hour_of_day only: advertiser (default — when you were billed) or audience (when they were awake).","schema":{"type":"string"}},{"name":"from","in":"query","required":false,"description":"YYYY-MM-DD. Default: 30 days back. Clamped to the network’s per-query ceiling, which the response states.","schema":{"type":"string"}},{"name":"to","in":"query","required":false,"description":"YYYY-MM-DD. Default: today.","schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/businesses/{slug}/ads/resources":{"get":{"summary":"Managed resources: audiences, custom conversions, blocked words, identities, keywords, negative keywords, shared negative-keyword lists and their campaign attachments, labels, campaign groups, budget schedules, automated rules, recommendations, change history, ad strength, registered apps and balances, behind one contract. OMIT ?resource= for the catalogue — every resource on every network with its columns, form fields, parent and a per-operation status of available | not_built | blocked, asking no network anything. Answering today, all seventeen and several partly: resource=audience on meta, google, tiktok, snapchat (all four verbs); resource=rule on meta (all four, and Meta’s update is a real partial, so a name-only or status-only edit is safe — status DISABLED is the reversible off switch that the delete is not) and on tiktok (list, create, update, plus the bind row action, and NO DELETE and no on/off field at all). AN EMPTY RULE LIST ON META IS NOT PROOF NOTHING IS RUNNING: the adrules_library edge answered {\"data\":[]} live on 2026-08-05 for an account whose adrules_history in the same run listed hourly evaluations of an ENABLED rule on that same account, and that one was Meta-authored, so the library appears to carry rules an advertiser wrote rather than ones Meta installed — only THAT it was absent was established, never why. TikTok’s missing delete is a call rather than a reading: /optimizer/rule/delete/, /remove/, /unbind/, /batch_unbind/ and /status/update/ each answered HTTP 404 with no envelope while POST-only endpoints that certainly exist answered HTTP 405 on the same token in the same run. TikTok’s update is a FULL REPLACE, so a body missing name, conditions, actions, applyObjects, notification or ruleExecInfo is refused naming the missing parts instead of wiping the rule, its money thresholds are MAJOR units unless the valuesCents/valueCents/limitCents spelling is used, and an applyObjects entry with no dimensionIds covers EVERY object at that level in the account, which is why it needs confirmAccountWide. resource=custom_conversion on meta (all four), tiktok (LIST ONLY — its create/edit/delete exist on TikTok’s side and no request BODY for them has ever been observed, and TikTok answers an unrecognised field with silence, so they are left unbuilt rather than blocked) and snapchat (list, create, delete, parent ?eventSourceId= required plus &eventSourceType=PIXEL|APP, because Snapchat lists them per event source and publishes no account-wide enumeration; NO UPDATE and that one is a real blocker, since none of Snapchat’s six paths is a PUT or a PATCH — this row read “Snapchat publishes no endpoint” until the page was fetched on 2026-08-06 and turned out to publish six); resource=identity on tiktok (list, create, delete — CUSTOMIZED_USER identities only, because that endpoint addresses by int64 while every Spark identity id is a UUID); resource=registered_app on tiktok (list, edit); resource=ad_balance on tiktok (list, and a login managing no Business Center is refused rather than answered with an empty list); resource=blocked_word on tiktok (list, create, delete, plus the rename action); resource=keyword (parent ?campaignId= required) on apple_search_ads — Apple targeting keywords, all four verbs, and the LIST is a first implementation, because …/ads/keywords exports POST, PATCH and DELETE and no GET, so that door could add, re-bid, pause and remove a keyword and could not show you one: a new keyword is PAUSED unless status ACTIVE is asked for, omitting bidCents inherits the ad group’s default bid and 0 is refused as a bid of nothing, adGroupId is required at create and read back off the row on edit and delete because Apple’s writes are per ad group while its find is campaign-wide, and past &pages= × 1,000 rows the list is a prefix with Apple’s own count in total) and on google (LIST and EDIT, off the same GAQL query over ad_group_criterion that the dashboard already runs on every load; an edit carries status and cpc_bid_micros only, because Google answers [IMMUTABLE_FIELD] on keyword.text and keyword.match_type; CREATE and DELETE stay DECLARED-AND-UNBUILT there, deliberately not blocked, because a create is POST …/ads/objects at level ad_group_criterion which works today and a blocker would be a false denial; a keyword has no id of its own at create and the id handed back is the composite adGroupId~criterionId); resource=negative_keyword (parent ?campaignId= required) on apple_search_ads (all four, but an edit carries status only — Apple freezes the text and match type at create) and google (list, create, delete, no edit: Google answers [IMMUTABLE_FIELD] on keyword.text and keyword.match_type, so changing one is remove-and-add; the id is the composite campaignId~criterionId); resource=shared_set on google (list and create, plus three row actions that reach the TERMS in a list: keywords reads them, addKeywords adds and removeKeyword removes one. The create fills the list in the same call, atomically, because a half-created list is an empty list wearing a brand-safety name. NO DELETE, and that is a policy blocker of ours rather than Google’s: removing a list stops every campaign attached to it excluding every term it held, all at once. Its UPDATE is declared and unbuilt, because nobody has read whether SharedSetService publishes a rename); resource=campaign_shared_set on google (list, create, delete, which are attach and detach under their real names. Detaching removes the attachment and never the list, which is why this delete is offered while the one above is not. The id is the composite campaignId~sharedSetId read off Google’s own resource name, and the ?campaignId= parent is OPTIONAL, unlike every other Google parent here, because Google publishes both the account-wide read and the per-campaign one); resource=budget_schedule (parent ?parentId= required, a campaign or an ad set) on meta (list and create only — Meta publishes neither an update nor a delete for a high demand period, so the create cannot be undone; times are unix SECONDS); resource=label and resource=campaign_group on google (all four verbs, plus attach/detach on a label and assign on a group — a label DELETE detaches it from every campaign, ad group and ad carrying it in one go with no undo, so that operation carries a confirm sentence); resource=recommendation, resource=change_history and resource=ad_strength on google (LIST ONLY, with apply and dismiss as row actions on a recommendation — the writes are blocked because recommendations:mutate and changeEvents:mutate are not served at all and ad strength is an output-only field). Google ids differ per resource: a label or campaign-group row carries the plain number that this endpoint’s own verbs take, while a recommendation row carries the whole customers/{customerId}/recommendations/{id} name, because that is what apply and dismiss take. A negative-keyword row carries Google’s composite campaignId~criterionId, and it too travels whole. Three things are still declared and unbuilt anywhere: TikTok’s custom-conversion write trio, Google’s keyword create and delete, and Google’s shared_set update. Six triples in all, and every one of them is a gap rather than a denial: Google plainly creates and removes keywords, and nobody has read whether SharedSetService publishes a rename. None of the four refusals is an empty list: 400 is a malformed request (unknown resource, no network, a missing required parent), 501 says blocked-or-not-built-yet and which — a blocked one carrying the network’s own reason, who can change it (the network / an ungranted scope / a decision of ours), whether anybody has ever called the network to confirm it, and the networks whose same verb is open, 409 is not connected, 502 is the network refusing. So rows: [] means the network was asked and has none.","operationId":"get_api_v1_businesses_slug_ads_resources","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"resource","in":"query","required":false,"description":"audience | custom_conversion | blocked_word | identity | keyword | negative_keyword | shared_set | campaign_shared_set | label | campaign_group | budget_schedule | rule | recommendation | change_history | ad_strength | registered_app | ad_balance. Omit for the catalogue.","schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}},{"name":"account","in":"query","required":false,"description":"Ad account id, when several are connected. Aliases: adAccountId, advertiserId.","schema":{"type":"string"}},{"name":"q","in":"query","required":false,"description":"Filters where the network publishes no search of its own. An empty q is no filter, never \"matched nothing\".","schema":{"type":"string"}},{"name":"limit","in":"query","required":false,"description":"1–200, default 50.","schema":{"type":"string"}},{"name":"cursor","in":"query","required":false,"description":"The nextCursor from the previous page — the same opaque grammar the id lookups use. A cursor this endpoint did not issue is refused rather than read as offset 0.","schema":{"type":"string"}},{"name":"eventSourceId","in":"query","required":false,"description":"The parent, where the catalogue declares one. A TikTok custom conversion hangs off an event SOURCE — a pixel or an app. A missing required parent is refused BEFORE the connection loads, so it is a 400 about the id rather than a 409 about reconnecting.","schema":{"type":"string"}},{"name":"eventSourceType","in":"query","required":false,"description":"PIXEL (the default) or APP, TikTok’s own enumeration for what eventSourceId names. An app-sourced custom conversion needs BOTH the app id from …/ads/apps and this set to APP; an unknown value is refused naming the two TikTok accepts.","schema":{"type":"string"}},{"name":"campaignId","in":"query","required":false,"description":"The parent for negative keywords (required) and for change history and ad strength (optional narrowing).","schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"summary":"Create one, or run a row verb. A plain body creates and is checked against the catalogue’s declared fields before it reaches the network. A body carrying `action` runs a non-CRUD verb instead — rename on TikTok blocked words (one TikTok call, not a delete plus an add), attach/detach on Google labels, assign on campaign groups, apply/dismiss on recommendations, bind on TikTok rules (attaching an EXISTING rule to more campaigns, ad groups or ads; Meta has no equivalent because a Meta rule decides what it touches through its own filters) — with that action’s own fields beside it. Audience creates take the same shared vocabulary as POST …/ads/audiences and reach the same four writers: one implementation, two doors.","operationId":"post_api_v1_businesses_slug_ads_resources","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"resource","in":"query","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}},{"name":"account","in":"query","required":false,"description":"Ad account id, when several are connected. Aliases: adAccountId, advertiserId.","schema":{"type":"string"}}],"responses":{"200":{"description":"OK (already existed; returned as-is)"},"201":{"description":"Created"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBody":{"content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}},"patch":{"summary":"Edit one row. ?id= is the value the list handed back, verbatim (Google’s composite \"campaignId~criterionId\" travels whole). The body is checked in EDIT mode, so a create-only field is refused here rather than by the network. On audiences the editable set is name, description and TikTok’s lookalike refresh only: every other field decides who is IN the audience, and rewriting one silently rebuilds the membership of a list live ad sets are targeting.","operationId":"patch_api_v1_businesses_slug_ads_resources","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"resource","in":"query","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}},{"name":"account","in":"query","required":false,"description":"Ad account id, when several are connected. Aliases: adAccountId, advertiserId.","schema":{"type":"string"}},{"name":"id","in":"query","required":true,"description":"The row to change.","schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBody":{"content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}},"delete":{"summary":"Remove one row (?id=) or several (?ids=a,b) — plural underneath either way, because Google negatives and TikTok blocked words are bulk on the wire. A delete the network cannot perform is not offered: Meta publishes none for a budget schedule, and TikTok none for a registered app or an automated rule, and each answers 501 with the network’s own reason rather than a polite failure. TikTok’s identity delete used to be listed here as a third — it exists, it is wired, and the entry that said otherwise had never been called.","operationId":"delete_api_v1_businesses_slug_ads_resources","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"resource","in":"query","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}},{"name":"account","in":"query","required":false,"description":"Ad account id, when several are connected. Aliases: adAccountId, advertiserId.","schema":{"type":"string"}},{"name":"id","in":"query","required":false,"description":"One row.","schema":{"type":"string"}},{"name":"ids","in":"query","required":false,"description":"Several, comma-separated.","schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/businesses/{slug}/ads/identities":{"get":{"summary":"Ad identities (TikTok).","operationId":"get_api_v1_businesses_slug_ads_identities","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"summary":"Create a Custom User ad identity (TikTok; Pangle placements only).","operationId":"post_api_v1_businesses_slug_ads_identities","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBody":{"content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}}},"/api/v1/businesses/{slug}/ads/pages":{"get":{"summary":"Connected pages.","operationId":"get_api_v1_businesses_slug_ads_pages","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/businesses/{slug}/ads/pixel":{"get":{"summary":"Read pixel/conversion config. google: returns `conversions`, EVERY conversion action with its own id, type, status and `enabled` — a connection can hold several, so there is no single \"is tracking on\". TikTok pixel EVENTS ride here too, because there a custom conversion is a rule on a pixel.","operationId":"get_api_v1_businesses_slug_ads_pixel","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}},{"name":"account","in":"query","required":false,"description":"Ad account id, when several are connected. Aliases: adAccountId, advertiserId.","schema":{"type":"string"}},{"name":"resource","in":"query","required":false,"description":"tiktok: \"events\" (one pixel’s event rules) or \"stats\" (whether they are firing).","schema":{"type":"string"}},{"name":"pixelId","in":"query","required":false,"description":"tiktok, with resource=events. Required, one at a time.","schema":{"type":"string"}},{"name":"pixelIds","in":"query","required":false,"description":"tiktok, with resource=stats: comma-separated.","schema":{"type":"string"}},{"name":"startDate","in":"query","required":false,"description":"tiktok, with resource=stats: YYYY-MM-DD.","schema":{"type":"string"}},{"name":"endDate","in":"query","required":false,"description":"tiktok, with resource=stats: YYYY-MM-DD.","schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"summary":"Create a conversion (meta, google), or a TikTok pixel event rule. google takes an OPTIONAL body { name?, type?, category? }: type UPLOAD_CLICKS (default) is fed by our server-side sale uploads; type WEBPAGE is fed by a gtag on your OWN site and is the one to use for signups, leads and bookings. A WEBPAGE response carries `tag` (conversionId + label), `install` (ready-to-paste baseTag and eventSnippet) and action_required=\"install_tag\" — NOTHING IS TRACKED until that tag is live. primaryForGoal (default true) decides whether Google BIDS toward the conversion or merely records it; it is ACCOUNT-WIDE, so a second primary changes what every existing campaign optimises toward, and the response returns `bidding_toward` plus a warning when there is more than one. Idempotent: same name+type adopts the existing action, keeping its current primary setting. A bare POST ?network=tiktok { pixelName } creates the PIXEL itself and returns its pixel_code; pixelName is required rather than defaulted because TikTok has no pixel delete, only rename, so an empty POST would leave a permanent object behind. That response carries action_required=\"install_pixel_code\": a fresh pixel tracks nothing until its code is on the site and an event exists to bid on.","operationId":"post_api_v1_businesses_slug_ads_pixel","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}},{"name":"account","in":"query","required":false,"description":"Ad account id, when several are connected. Aliases: adAccountId, advertiserId.","schema":{"type":"string"}},{"name":"resource","in":"query","required":false,"description":"tiktok: omit to create the pixel itself, or \"events\" to create an event rule on one.","schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBody":{"content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}},"patch":{"summary":"google: { conversionActionId, enabled?, primaryForGoal? } changes ONE conversion. `enabled` flips Google-side status ENABLED/HIDDEN, which stops the count however the event arrives and is the only lever that works for a tag-reported one. `primaryForGoal` is a SEPARATE question: whether Google BIDS toward it, as opposed to just counting it. Either may be sent alone and only what you send is written. Without conversionActionId, { enabled } switches the connection-wide SERVER-SIDE sale uploader instead, which tag-reported conversions do not use. tiktok: rename the pixel ({ pixelId, pixelName }), or ?resource=events to edit one event.","operationId":"patch_api_v1_businesses_slug_ads_pixel","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}},{"name":"account","in":"query","required":false,"description":"Ad account id, when several are connected. Aliases: adAccountId, advertiserId.","schema":{"type":"string"}},{"name":"resource","in":"query","required":false,"description":"tiktok: \"events\" edits an event rule instead of renaming the pixel.","schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBody":{"content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}},"delete":{"summary":"Delete ONE TikTok pixel event rule. TikTok will not refuse while a live ad group still optimises toward it.","operationId":"delete_api_v1_businesses_slug_ads_pixel","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}},{"name":"account","in":"query","required":false,"description":"Ad account id, when several are connected. Aliases: adAccountId, advertiserId.","schema":{"type":"string"}},{"name":"resource","in":"query","required":true,"description":"Must be \"events\". There is no delete for a pixel itself.","schema":{"type":"string"}},{"name":"eventId","in":"query","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/v1/businesses/{slug}/ads/media":{"get":{"summary":"List the ad account’s creative library, so a lost upload response is recoverable. With ?video= it answers a different question: the candidate cover frames for that one video.","operationId":"get_api_v1_businesses_slug_ads_media","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"schema":{"type":"string"}},{"name":"kind","in":"query","required":false,"schema":{"type":"string"}},{"name":"uploads","in":"query","required":false,"description":"Google only: \"1\" lists the account’s YouTube video UPLOADS with their youtube_video_id and state, which is how a video uploaded as a FILE becomes usable: the POST answers before YouTube finishes processing, so its id is null until then.","schema":{"type":"string"}},{"name":"video","in":"query","required":false,"description":"Meta only: a video id. Returns { thumbnails } — each { id, url, isNetworkPick, width, height } — instead of the library. Send an id back as ad.media.thumbnailId on a launch to choose the frame; omit it and Meta picks, which is what isNetworkPick marks. The frames do not exist until Meta has finished processing the upload.","schema":{"type":"string"}},{"name":"account","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"summary":"Upload ad media: multipart file part, or JSON { kind, url } fetched server-side (the form agents use, since a JSON body carries no file part). Each network hands back its own handle: Meta image_hash/video_id, TikTok image_id/video_id, Snapchat media_id, Google a full asset resource name. GOOGLE VIDEO IS A YOUTUBE LINK, NOT BYTES — send { youtube_url } (or youtube_video_id) and get back { asset, asset_id, youtube_video_id }, which is what Demand Gen video ads and Performance Max asset groups both need; Google publishes no video upload that takes a file. 10MB images / 50MB video, and BOTH CEILINGS ARE OURS rather than any network’s (the file is buffered once). Snapchat’s used to be tighter and is not: above 32MB the upload is chunked automatically, so an ordinary 1080x1920 vertical video uploads instead of being refused.","operationId":"post_api_v1_businesses_slug_ads_media","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBody":{"content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}}},"/api/v1/businesses/{slug}/ads/sync":{"post":{"summary":"Trigger a stats sync.","operationId":"post_api_v1_businesses_slug_ads_sync","tags":["Ads"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"network","in":"query","required":false,"description":"meta | google | tiktok | apple_search_ads | snapchat","schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"default":{"description":"Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"requestBody":{"content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}}}},"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"afk_ key","description":"Account-level API key. Header: `Authorization: Bearer afk_…`."}},"schemas":{"Error":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","enum":["UNAUTHORIZED","FORBIDDEN","ACCOUNT_INELIGIBLE","BANNED_FROM_BUSINESS","NOT_FOUND","VALIDATION_ERROR","CONFLICT","NOT_CONNECTED","PAYMENT_REQUIRED","RATE_LIMIT_EXCEEDED","UPSTREAM_ERROR","NOT_IMPLEMENTED","INTERNAL_ERROR"],"description":"CONFLICT means the request was well formed and the caller is permitted, but the state of the account makes the write wrong to perform, and NOTHING was sent to the network. It is not VALIDATION_ERROR (there is nothing to fix in the body), not FORBIDDEN (permission is fine) and above all not UPSTREAM_ERROR (no network was asked), which is the distinction that matters to a retrying caller: only 502 UPSTREAM_ERROR is worth trying again. NOT_IMPLEMENTED (501) means the thing you asked for is real and we have not built it, or the network blocks it — never an empty result, which means the network was asked and had none."},"message":{"type":"string"}},"required":["code","message"]}},"required":["error"]},"EmbedFont":{"type":"object","required":["family","src"],"properties":{"family":{"type":"string","example":"Inter","description":"Family name; referenced by appearance.fontFamily."},"src":{"type":"string","format":"uri","example":"https://cdn.example.com/inter.woff2","description":"https URL to a .woff2/.woff/.ttf/.otf file. Loads only from this origin."},"weight":{"type":"string","example":"400","description":"Keyword, single weight, or variable range (\"100 900\")."},"style":{"type":"string","enum":["normal","italic","oblique"],"default":"normal"},"display":{"type":"string","enum":["auto","block","swap","fallback","optional"],"default":"swap"}}},"EmbedSessionRequest":{"type":"object","required":["email","components"],"properties":{"email":{"type":"string","format":"email","description":"Must already be an affiliate of this app; never enrolled here."},"components":{"type":"array","items":{"type":"string","enum":["affiliate","link","qr","stats","products","activity","balance","withdraw","identity"]},"minItems":1,"description":"What to render. Recommended layout, the one we ship in our own apps: three tabs in the platform's NATIVE bottom tab bar, each tab one scroll view with its components stacked. Link (qr, link, stats, products), Balance (balance, activity), Withdraw (withdraw). Mint all seven in one session. Do NOT give identity a tab of its own next to withdraw: withdraw already walks an unverified affiliate through the ID check exactly where they need it and skips it forever once they pass, so a separate tab shows the same step twice and asks for a second sign-in code. identity is for a standalone get-paid-ready page when you are NOT showing withdraw at all. One confirm covers every gated element and keeps covering it: confirming signs the person in, so siblings recognise that session with no second email. When it eventually lapses the element says so (onLocked in the web SDKs, aeLocked / AffiliateoAndroid.locked / __affiliateo:\"locked\" natively); if your app remembers that the person is signed in, clear it there or the rest of your UI keeps acting signed in beside a login form. See /docs/elements."},"allowed_origins":{"type":"array","items":{"type":"string","format":"uri"},"maxItems":10,"description":"Required for platform \"web\": exact origins allowed to frame the element. Omit for \"native\"."},"platform":{"type":"string","enum":["web","native"],"default":"web","description":"Use \"native\" for a real iOS/Android app opening the element URL top-level in a WebView. Three things a native host owns that a web page gets for free. THEME: send appearance or elements render in Affiliateo blue next to your brand. PADDING: send contentPadding (it takes the CSS box shorthand, e.g. \"0 16px\" for sides only) because elements carry no outer margin, which on the web comes from your own padded layout, while a WebView is edge to edge. BACKGROUND: make the WebView transparent (iOS isOpaque = false, Android a transparent background colour) or an element whose ground is transparent still sits on an opaque white card. The official SDKs handle the transparency for you."},"appearance":{"type":"object","additionalProperties":{"type":"string"},"description":"Theme tokens (colours, gradients on colorBackground/colorSurface/buttonColor, fonts, sizes, radii, spacing, button + link). See /docs/elements."},"fonts":{"type":"array","items":{"$ref":"#/components/schemas/EmbedFont"},"maxItems":6},"lang":{"type":"string","example":"fr","description":"BCP-47; localizes the popped withdraw/identity portals."},"logo":{"type":"string","enum":["affiliateo","app","none"],"default":"affiliateo","description":"Which logo the confirm/login screen shows: the Affiliateo mark, the app’s own cover image, or none."}}},"EmbedSessionResponse":{"type":"object","properties":{"client_secret":{"type":"string","description":"Hand to the frontend SDK. A JWT, 1-hour TTL."},"expires_in":{"type":"integer","example":3600},"platform":{"type":"string","enum":["web","native"]},"components":{"type":"array","items":{"type":"string"}},"allowed_origins":{"type":"array","items":{"type":"string"}},"requires_step_up":{"type":"array","items":{"type":"string"},"description":"Components that challenge the affiliate before rendering (balance, withdraw, identity)."},"element_urls":{"type":"object","additionalProperties":{"type":"string","format":"uri"},"description":"Paste-able per-component URLs for an <iframe> or native WebView."},"ignored_appearance_keys":{"type":"array","items":{"type":"string"}},"ignored_fonts":{"type":"integer","description":"Count of malformed font entries dropped."}}},"AffiliateEnrollRequest":{"type":"object","required":["email"],"properties":{"email":{"type":"string","format":"email"},"name":{"type":"string"}}},"PayoutReadiness":{"type":"object","description":"Payout-readiness hint for gating \"Verify\" / \"Cash out\" buttons, derived from webhook-fresh columns (no Stripe call). The portals re-check live before money moves.","properties":{"identity_verified":{"type":"boolean"},"payouts_enabled":{"type":"boolean"}},"required":["identity_verified","payouts_enabled"]},"PayoutBuckets":{"type":"object","description":"Per-affiliate payout buckets in cents. For web_traffic apps these are click-derived.","properties":{"pending_cents":{"type":"integer"},"payable_cents":{"type":"integer"},"paid_cents":{"type":"integer"},"refunded_cents":{"type":"integer"},"payable_count":{"type":"integer"}}},"Recurring":{"type":"object","description":"What this affiliate’s referrals pay every month, and the health of it. SCOPED TO THE APP OR BUSINESS BEING READ, never account-wide: the affiliate may earn on other programmes and none of that appears here. Present on single reads; on lists add ?include=recurring. Not affected by ?from/?to — MRR is a rate at today’s subscription set, not a sum over a period, so there is no \"MRR for March\" for a date range to produce.","properties":{"mrr_cents":{"type":"integer","description":"Commission arriving each month from referrals still subscribed. THE READER’S money, not the customers’ subscription value."},"arr_cents":{"type":"integer","description":"mrr_cents × 12, at today’s rate. Sent rather than left to the caller so twelve is not multiplied differently on different screens."},"subscribers":{"type":"integer","description":"Referred subscriptions still paying."},"not_renewing":{"type":"integer","description":"Still paying, auto-renew off. They leave on a known date and are counted inside `subscribers` until then — a cancellation is not a departure, and the money keeps arriving until the period they bought runs out."},"churned":{"type":"integer","description":"Subscriptions that have actually ENDED. The churn numerator."},"churn_rate":{"type":["number","null"],"description":"Percentage (0-100) of everyone who ever subscribed and has since left. NULL MEANS \"NOT ENOUGH HISTORY TO SAY\" — fewer than five ever subscribed — AND MUST NOT BE RENDERED AS ZERO. Below five, one departure moves the figure 25 points or more. A real 0 is sent as 0 and means everybody stayed."},"unknown_interval":{"type":"integer","description":"Live subscriptions whose billing period could not be read, so their contribution to mrr_cents is MISSING and the amount is a floor rather than a total. Ships beside the amount rather than in a footnote, because a caller who never sees it cannot discover the error. Zero is the normal case."}}},"Affiliate":{"type":"object","description":"An affiliate with their referral links and lifetime stats (money fields in cents). Email addresses are never returned.","properties":{"id":{"type":"string","format":"uuid"},"status":{"type":"string"},"ref_code":{"type":"string"},"short_code":{"type":["string","null"]},"joined_at":{"type":["string","null"],"format":"date-time"},"verified":{"type":"boolean","description":"Identity verification passed (the affiliate.verified webhook’s flag)."},"payout":{"$ref":"#/components/schemas/PayoutReadiness"},"user":{"type":"object","properties":{"username":{"type":["string","null"]},"display_name":{"type":["string","null"]}}},"links":{"type":"object","properties":{"short":{"type":["string","null"],"description":"Canonical /r/{short_code} link. QR codes should encode this."},"username":{"type":["string","null"],"description":"Readable /r/{app_slug}/{username} link."},"direct":{"type":["string","null"],"description":"Merchant-domain {website_url}?ref= link, when the app has one."},"coupon":{"type":["string","null"],"description":"NOT a URL: the Stripe discount code the buyer types at checkout (e.g. \"SARAH-J\"), derived from the affiliate’s username. Null unless the app has coupon codes switched on AND its Stripe account is still connected. Do not encode it in a QR code."}}},"personal_commission":{"type":["object","null"],"description":"The affiliate’s owner-negotiated deal, or null when they ride the program rates.","properties":{"type":{"type":"string","enum":["percent","flat"]},"rate":{"type":"number"}}},"stats":{"type":"object","properties":{"clicks":{"type":"integer","description":"UNIQUE visitors, deduplicated per app by IP, not raw taps."},"conversions":{"type":"integer"},"renewals":{"type":"integer"},"revenue_cents":{"type":"integer","description":"GROSS: the full sale before payment-processing fees, not what reached the merchant’s bank. Label it \"Gross revenue\" — a merchant who compares a net Stripe payout against this will conclude the number is wrong."},"commission_cents":{"type":"integer","description":"What the affiliate earned. The net figure of the pair."}}},"payouts":{"$ref":"#/components/schemas/PayoutBuckets"},"recurring":{"$ref":"#/components/schemas/Recurring"},"click_payout":{"type":"object","description":"web_traffic apps only: the click accounting behind the payout buckets.","properties":{"cpc_cents":{"type":"integer"},"approved_clicks":{"type":"integer"},"payable_clicks":{"type":"integer"}}}}},"AffiliateResponse":{"type":"object","properties":{"affiliate":{"$ref":"#/components/schemas/Affiliate"}},"required":["affiliate"]},"AffiliateEnrollResponse":{"type":"object","properties":{"affiliate":{"$ref":"#/components/schemas/Affiliate"},"account_created":{"type":"boolean","description":"A new Affiliateo account was created for the email."},"affiliate_created":{"type":"boolean","description":"False when the email was already an affiliate here (200 instead of 201)."}},"required":["affiliate"]},"WalletSnapshot":{"type":"object","description":"The caller’s account wallet (one per account). All amounts in cents.","properties":{"id":{"type":["string","null"],"description":"Wallet id, or null if the account has never had wallet activity."},"currency":{"type":"string","example":"usd"},"balance_cents":{"type":"integer","description":"Total wallet balance."},"withdrawable_cents":{"type":"integer","description":"Balance minus funds still under the 7-day hold."},"held_cents":{"type":"integer","description":"Credits younger than the 7-day hold."},"pending_deposits_cents":{"type":"integer","description":"Bank deposits still processing."},"locked_app_cents":{"type":"integer","description":"Unspent ad/app budget committed across the account’s businesses. App budgets are gross: part of this is the 1% still to be charged on the payouts it funds."},"disputed_cents":{"type":"integer"},"in_transit_cents":{"type":"integer","description":"Withdrawals on their way out, not yet confirmed by the bank."},"payout_account_connected":{"type":"boolean"},"payout":{"$ref":"#/components/schemas/PayoutReadiness"}}},"WalletResponse":{"type":"object","properties":{"wallet":{"$ref":"#/components/schemas/WalletSnapshot"}},"required":["wallet"]},"HostedLinkRequest":{"type":"object","required":["email","return_url"],"properties":{"email":{"type":"string","format":"email","description":"The affiliate the link is minted for."},"return_url":{"type":"string","format":"uri","description":"https or a mobile deep link; receives ?affiliateo_status."},"name":{"type":"string","description":"verification-link only: display name if the account is created."},"lang":{"type":"string"}}},"SelfLinkRequest":{"type":"object","required":["return_url"],"description":"The /me link endpoints act on the caller themselves, so no email is taken.","properties":{"return_url":{"type":"string","format":"uri","description":"https or a mobile deep link; receives ?affiliateo_status."},"amount_cents":{"type":"integer","description":"topup-link only: prefills the amount box."},"lang":{"type":"string"}}},"HostedLinkResponse":{"type":"object","properties":{"url":{"type":"string","format":"uri"},"expires_in":{"type":"integer"},"verified":{"type":"boolean","description":"verification-link only: whether the affiliate is already verified."},"withdrawable_cents":{"type":"integer","description":"/me/withdrawal-link only: the caller’s own withdrawable balance."},"balance_cents":{"type":"integer","description":"/me/topup-link only: the caller’s current balance."}}},"FreeAccessRequest":{"type":"object","description":"Switch complimentary access on or off for one affiliate of this app (RevenueCat apps only). Identify the affiliate with email OR ref_code. Sending revenuecat_email or entitlement_id without enabled just records them, with no RevenueCat call.","properties":{"email":{"type":"string","format":"email","description":"Identifies the affiliate. Either this or ref_code."},"ref_code":{"type":"string","description":"Identifies the affiliate. Either this or email."},"enabled":{"type":"boolean","description":"true grants, false revokes. Omit to change only the fields below."},"entitlement_id":{"type":"string","nullable":true,"description":"Which entitlement this affiliate gets, on apps with more than one. null clears it."},"revenuecat_email":{"type":"string","nullable":true,"description":"NOT the affiliate identifier. The address that person uses INSIDE your app, used to find their RevenueCat customer; frequently different from their Affiliateo address. null clears it and falls back to their Affiliateo address."}}},"FreeAccessResponse":{"type":"object","properties":{"enabled":{"type":"boolean","description":"The state the switch is now in."},"pending":{"type":"boolean","description":"Switched on, but no RevenueCat customer carries this affiliate's address yet, so nothing is granted. A waiting state, not a failure: the grant lands as soon as one appears."},"reason":{"type":"string","description":"Why it could not be switched on, e.g. not_revenuecat, no_entitlement, needs_reconnect."}}},"CampaignLaunchRequest":{"type":"object","description":"Launch a whole campaign stack (campaign plus everything under it), always PAUSED. Which fields apply is decided by ?network=, and the objective field itself differs per network: `objective` on Meta and TikTok, `goal` on Snapchat, and neither on Google (where ?type= picks the campaign type, which is what Google has instead of an objective) or Apple (where a campaign is an app, a placement and a bidding strategy). Meta, TikTok and Snapchat read an UNRECOGNISED objective as traffic rather than refusing it, so an enum value from the wrong network launches the wrong campaign and answers 201. Full per-network bodies: https://affiliateo.com/docs/api.","properties":{"campaignName":{"type":"string","description":"Required on every network. The campaign display name."},"objective":{"type":"string","enum":["traffic","sales","conversions","awareness","engagement","video_views","leads","app_promotion","product_sales"],"description":"Meta and TikTok, and the two do NOT accept the same set. Meta: traffic (default) | sales | awareness | engagement | video_views (a ThruPlay goal, so the creative must be a video) | app_promotion. TikTok: traffic (default) | conversions | engagement | leads | app_promotion | product_sales — awareness and video_views are REFUSED here (and only here): TikTok stops creating REACH and VIDEO_VIEWS campaigns at the end of 2026 and its Smart+ successor publishes neither, so they were retired rather than left to break. Every other network still serves both. Meta spells its conversion objective \"sales\" and TikTok spells it \"conversions\"; each network reads the other’s word as traffic. TikTok’s product_sales is a CATALOGUE campaign: it needs catalog.catalogId and uploads no creative at all, because TikTok renders every impression from the product itself — so format, videoId and imageIds are dropped and the ad goes out as CATALOG_CAROUSEL. Meta product sales is not offered and never will be on our side: App Review did not grant catalog_management. META LEADS IS NOT THE SAME KIND OF NO, and stating it as one is what this sentence used to do. It is HALF a wall and HALF a gap: the blocked half is Meta INSTANT FORMS (creating one needs pages_manage_ads, reading a lead back needs leads_retrieval, App Review granted neither, so that campaign could be built and never hand over a single lead), while a lead ad that sends people to the ADVERTISER’S OWN WEBSITE needs no permission Meta has withheld and is simply NOT BUILT YET. Do not report \"nothing on our side unlocks this\" about the goal as a whole. Google, TikTok and Snapchat run lead campaigns today; Apple Search Ads has no lead form anywhere in its API. TikTok engagement is SPARK ADS: send ad.itemId (the post URL, or the number at the end of it) and no creative at all — the post supplies the video, the caption and the account name, so format, videoId, imageIds, text and displayName are all dropped. It is the one TikTok objective Smart+ cannot express, so TikTok stops accepting new ENGAGEMENT campaigns at the end of 2026 with no successor."},"goal":{"type":"string","enum":["traffic","conversions","awareness","engagement","video_views","leads","app_promotion"],"description":"Snapchat ONLY, and it is spelled `goal` rather than `objective` — sending `objective` here is ignored and launches traffic. The goal also picks the creative, so `leads` takes leadFormId and `app_promotion` takes app{} INSTEAD of destinationUrl. Product sales is the one goal not offered."},"product":{"type":"string","enum":["manual","advantage_plus"],"default":"manual","description":"Meta only. advantage_plus moves the budget onto the campaign, shares it across ad sets and hands Meta the audience and placements. Valid on the sales and app_promotion objectives only (the two states Meta derives) and refused — never silently downgraded — alongside adset.ageMin/ageMax/gender."},"dailyBudgetCents":{"type":"integer","description":"Snapchat (the ad squad budget). Meta and TikTok carry it on their adset/adgroup object; Google on bidding; Apple on the campaign."},"countries":{"type":"array","items":{"type":"string"},"description":"Snapchat. ISO-2 codes. Meta uses adset.countries, TikTok adgroup.locationIds, Google geoTargetIds, Apple countriesOrRegions."},"destinationUrl":{"type":"string","format":"uri","description":"Snapchat’s five WEB goals. Not taken by leads or app_promotion, whose creatives have no URL field at all — which is also why those two cannot carry tracking tags or report ROAS."},"leadFormId":{"type":"string","description":"Snapchat leads. A form built in Snapchat Ads Manager; nothing lists them and submissions stay in Snapchat’s Leads Center. TikTok’s equivalent is ad.pageId. GOOGLE HAS NO EQUIVALENT: it is the one network that AUTHORS the form at launch rather than naming one — see `leadForm` below."},"leadForm":{"type":"object","additionalProperties":true,"required":["headline","description","businessName","cta","ctaDescription","privacyPolicyUrl","questions"],"properties":{"headline":{"type":"string","description":"Required. The form’s title."},"description":{"type":"string","description":"Required. Shows under the headline."},"businessName":{"type":"string","description":"Required. Who is collecting the details."},"cta":{"type":"string","enum":["LEARN_MORE","GET_QUOTE","APPLY_NOW","SIGN_UP","CONTACT_US","SUBSCRIBE","DOWNLOAD","BOOK_NOW","GET_OFFER","REGISTER","GET_INFO","REQUEST_DEMO","JOIN_NOW","GET_STARTED"],"description":"Required. The button that opens the form."},"ctaDescription":{"type":"string","description":"Required. The line beside the button: what someone gets for filling it in."},"privacyPolicyUrl":{"type":"string","format":"uri","description":"Required. Google refuses a lead form without a page describing how the details are handled."},"questions":{"type":"array","minItems":1,"items":{"type":"string"},"description":"Required, at least one. Google input types (FULL_NAME, EMAIL, PHONE_NUMBER and about a hundred more). THE ORDER IS THE FORM: Google lets a live form be reordered but never asked more or fewer questions, so a form that asks the wrong thing is replaced rather than edited."},"postSubmitHeadline":{"type":"string","description":"Optional. The thank-you screen’s title."},"postSubmitDescription":{"type":"string","description":"Optional. The thank-you screen’s body."},"postSubmitCta":{"type":"string","enum":["VISIT_SITE","DOWNLOAD","LEARN_MORE","SHOP_NOW"],"description":"Optional. What the thank-you screen’s button does."},"desiredIntent":{"type":"string","enum":["LOW_INTENT","HIGH_INTENT"],"description":"Optional, and OMITTED WHEN UNSET rather than defaulted: Google treats an unset intent as its own choice, and picking either one buys a different set of leads than you asked for."},"backgroundImageAsset":{"type":"string","pattern":"^customers/\\d+/assets/\\d+$","description":"Optional. An asset resource name, exactly 1200x628. Minted by POST …/ads/media?network=google."}},"description":"Google Search (?type= omitted) ONLY, and it is where Google differs from every other network on this endpoint: TikTok and Snapchat POINT AT a form that already exists, and Google has nothing to point at until the launch makes one. Sending it adds two operations to the same atomic mutate — a LEAD_FORM asset and the campaign_asset that links it — so either both exist or neither does. Six fields Google itself marks Required: { headline, description, businessName, cta, ctaDescription, privacyPolicyUrl } plus at least one entry in questions. cta is one of LEARN_MORE, GET_QUOTE, APPLY_NOW, SIGN_UP, CONTACT_US, SUBSCRIBE, DOWNLOAD, BOOK_NOW, GET_OFFER, REGISTER, GET_INFO, REQUEST_DEMO, JOIN_NOW, GET_STARTED. questions are Google input types (FULL_NAME, EMAIL, PHONE_NUMBER and about a hundred more); THE ORDER IS THE FORM and Google lets it be reordered afterwards but never added to or removed from, so a form that asks the wrong thing is replaced rather than edited. Optional: postSubmitHeadline, postSubmitDescription, postSubmitCta (VISIT_SITE | DOWNLOAD | LEARN_MORE | SHOP_NOW), desiredIntent (LOW_INTENT | HIGH_INTENT) and backgroundImageAsset (an asset resource name, exactly 1200x628). No pixel and no conversion action are needed — the form is submitted on Google and Google reports it. IGNORED rather than refused on any other ?type=: each campaign type has its own body parser and only the Search one reads this key."},"app":{"type":"object","additionalProperties":true,"description":"The promoted app, and every network spells it differently. Snapchat: { name, iosAppId | androidAppUrl (the Play PACKAGE name), iconMediaId }. Meta: adset.app = { applicationId, objectStoreUrl }. Google: app = { appId, appStore? } with ?type=app. TikTok: top-level appId + appPlatform. Apple: adamId."},"appId":{"type":"string","description":"TikTok app promotion. TikTok’s OWN app id from GET …/ads/apps?network=tiktok — not an App Store id and not a package name."},"appPlatform":{"type":"string","enum":["ios","android"],"description":"TikTok app promotion. The ad group promotes APP_IOS or APP_ANDROID and the app id alone does not say which."},"bidStrategy":{"type":"string","enum":["AUTO_BID","LOWEST_COST_WITH_MAX_BID","TARGET_COST"],"description":"Snapchat. The last two additionally need bidCents. Only read alongside bidStrategy, so a body that never mentions bidding builds the ad squad it always did."},"bidCents":{"type":"integer","minimum":1,"maximum":50000,"description":"Snapchat, required by the two manual bid strategies and ignored without one."},"adamId":{"type":"string","description":"Apple. The App Store id of the app being promoted; find it with GET …/ads/targeting-search?network=apple_search_ads&type=app."},"supplySource":{"type":"string","enum":["APPSTORE_SEARCH_RESULTS","APPSTORE_SEARCH_TAB","APPSTORE_TODAY_TAB","APPSTORE_PRODUCT_PAGES_BROWSE"],"default":"APPSTORE_SEARCH_RESULTS","description":"Apple. The PLACEMENT, which is what Apple has instead of an objective. Search results is the only one with keywords, negative keywords or Search Match, and the only one MAX_CONVERSIONS runs in."},"biddingStrategy":{"type":"string","enum":["MANUAL_CPT","MAX_CONVERSIONS"],"default":"MANUAL_CPT","description":"Apple. MAX_CONVERSIONS bids toward installs, requires targetCpaCents, runs in search results only, and hands the ad group to Apple."},"targetCpaCents":{"type":"integer","description":"Apple MAX_CONVERSIONS (target cost per install; Apple refuses the strategy without it). Google ?type=app takes the same number as bidding.targetCpaCents, for the same reason: the target IS the strategy."},"adGroup":{"type":"object","additionalProperties":true,"description":"Apple. { name, defaultBidCents, searchMatch?, targeting?, keywords?, negativeKeywords? }, created inside the campaign in the same call. OMITTING IT LEAVES A CAMPAIGN THAT CAN NEVER SERVE. searchMatch defaults true on search results, because an ad group with neither it nor keywords has nothing to match a query against. keywords/negativeKeywords are [{ text, matchType: EXACT|BROAD }] — Apple has no phrase match. Keywords need the search-results placement and MANUAL_CPT; negatives additionally work under MAX_CONVERSIONS, which is Apple’s own documented exception."},"adset":{"type":"object","additionalProperties":true,"description":"Meta. Budget, countries, age/gender, end date, and app{} on the app_promotion objective. adset.endDate is the end of that DAY IN THE AD ACCOUNT’S TIMEZONE, not in UTC, and a launch naming one makes one extra Graph read to learn which timezone that is."},"specialAdCategories":{"type":"array","items":{"type":"string"},"description":"Meta. The regulated declaration (HOUSING, EMPLOYMENT, CREDIT, ISSUES_ELECTIONS_POLITICS, ONLINE_GAMBLING_AND_GAMING, FINANCIAL_PRODUCTS_SERVICES), or NONE. Omitted entirely when the caller declares nothing, which is what every existing caller sends. CREATE-ONLY on Meta’s side: getting it wrong is a rollback, not an edit."},"specialAdCategoryCountry":{"type":"array","items":{"type":"string","pattern":"^[A-Za-z]{2}$"},"description":"Meta. Two-letter country codes: WHICH JURISDICTIONS the regulated declaration above covers. Required exactly when specialAdCategories declares something other than NONE and adset.countries names TWO OR MORE countries — that launch is now a 400 without it, where it previously succeeded and let Meta file the declaration under the country the ad account pays tax in, which is not where the ads run. A SINGLE targeted country still derives itself and is unchanged. Sending it with no regulated category is also a 400, because it is a country list for a statement nobody made."},"contextualMultiAds":{"type":"string","enum":["OPT_IN","OPT_OUT"],"description":"Meta. Whether these ads may be paired with other advertisers’ ads in Meta’s multi-advertiser units. UNSET IS NOT OPT_OUT: Meta enrols any creative created without an enroll_status, so omitting this keeps Meta’s own default, which is enrolled. Omitting it is what every launch built before this field existed did, and it still sends no key at all. CREATE-ONLY per creative on Meta’s side, so an ad created enrolled stays enrolled — which is why one value applies to every creative in the launch, fan-out included, rather than per ad. A value that is neither OPT_IN nor OPT_OUT is refused rather than dropped: a caller who meant to opt out, was told it worked and stayed enrolled is the failure this refusal exists for."},"profileId":{"type":"string","description":"Snapchat, and `profile_id` is accepted as an alias because that is the spelling our own error message tells a caller to send. A Snapchat Public Profile id, which every Snapchat creative requires — even a plain Snap Ad. USED ONLY WHEN THE CONNECTION HAS NO PROFILE OF ITS OWN: the connection’s profile always wins, so a request body cannot point one advertiser’s creatives at another’s profile. It exists because the Public Profile API sits on a different host behind a second OAuth scope behind an allowlist, so an owner may hold the id when our connect could not fetch one. With neither, the launch is refused before anything is created, as a 403 FORBIDDEN."},"ageMin":{"type":"integer","minimum":13,"maximum":35,"description":"Snapchat. Snapchat’s own floor is 13 and its min_age CEILING is 35 — the two bounds do NOT share a range, and any surface documenting both as 13-55 is wrong. Out of range is a 400 VALIDATION_ERROR rather than a silent clamp."},"ageMax":{"type":"integer","minimum":13,"maximum":55,"description":"Snapchat. 13 to 55, and OMIT IT for no upper age limit rather than sending the ceiling. An ageMax below ageMin is refused as a pair (both are legal numbers on their own), where it was previously raised to meet ageMin — so \"45 to 30\" became \"45 to 45\" and reached a fraction of the people the owner picked."},"gender":{"type":"string","enum":["MALE","FEMALE","OTHER"],"description":"Snapchat. Omit to target everyone."},"endDate":{"type":"string","description":"Snapchat. YYYY-MM-DD, when the ad squad stops. Meta’s equivalent is adset.endDate."},"adgroup":{"type":"object","additionalProperties":true,"description":"TikTok. Budget, locationIds, gender/ageGroups, pixelId on the conversions objective, promotionType."},"ad":{"type":"object","additionalProperties":true,"properties":{"productPageId":{"type":"string","description":"APPLE ONLY: the Custom Product Page this ad taps through to, from GET …/ads/lookups?network=apple_search_ads&type=product_page&adamId=… (Apple publishes no search over an app’s pages, so adamId is required there). REQUIRED on supplySource APPSTORE_TODAY_TAB whenever adGroup travels with the launch — the Today tab’s tap destination IS a Custom Product Page and Apple publishes no default, so that combination without this field is a 400 raised before anything is created, where it previously answered 201 with a campaign that could never serve. Optional on the other three placements, where it promotes a Custom Product Page instead of the placement’s own default: APPSTORE_SEARCH_TAB otherwise gets a default product page ad, and APPSTORE_SEARCH_RESULTS and APPSTORE_PRODUCT_PAGES_BROWSE render from the app’s App Store listing and owe no ad object at all. Read only when adGroup is present, because an ad hangs off an ad group; a body with no adGroup is the documented two-step and already answers \"created paused, with NO ad group\"."},"name":{"type":"string","description":"Apple only. The ad’s name; defaults to \"{campaignName} ad\"."}},"description":"Meta, TikTok, Google: the creative and its copy. TikTok’s leads objective puts the Instant Form id here as ad.pageId, and sends no ad.landingPageUrl. APPLE takes something different and much smaller — { productPageId?, name? }, the two properties named above — because an Apple ad points at a product page rather than carrying copy of its own."},"assetGroup":{"type":"object","additionalProperties":true,"description":"Google ?type=pmax. Replaces ad groups, ads and keywords entirely; its images must be uploaded first via POST …/ads/media?network=google. ALSO the body of ?type=asset_group, which adds another asset group to a campaign that already exists (with campaignId beside it) instead of creating one. An asset group is the unit that keeps creative together: Google mixes headlines with images inside a group and never across two, so a campaign selling to two audiences wants one group each. On that path businessName, logoAssets and landscapeLogoAssets are refused rather than ignored, because a brand-guidelines campaign already carries them."},"campaignId":{"type":"string","description":"Google ?type=asset_group only: the numeric id of the existing Performance Max campaign to add the asset group to. The campaign keeps running on its current asset groups; the new one is created PAUSED."},"keywords":{"type":"array","items":{"type":"object","additionalProperties":true},"description":"Google Search (?type= omitted): required, since an ad group with none has nothing to match a query against. Apple puts its keywords on adGroup instead."},"bidding":{"type":"object","additionalProperties":true,"description":"Google. { strategy, targetCpaCents?, targetRoas? }. ?type=app REQUIRES targetCpaCents. Performance Max takes a different vocabulary (uppercase MAXIMIZE_CONVERSIONS) from the other types’ REST scheme names."},"shopping":{"type":"object","additionalProperties":true,"description":"Google ?type=shopping. { merchantId, feedLabel?, campaignPriority? } and NO ad at all — Google renders every ad from the Merchant Center feed. GET /ads/catalogs?network=google lists the LINKED Merchant Center accounts, which is where merchantId comes from; POST the same path links one, and &resource=invitations answers a request sent from Merchant Center."},"catalog":{"type":"object","additionalProperties":true,"description":"TikTok product_sales. { catalogId, bcId?, productSpecificType?, productSetId?, skuIds?, itemGroupIds? }. catalogId and bcId come from GET /ads/catalogs?network=tiktok (a catalogue is Business Center property, so the pair travels together onto the ad group); productSetId from /ads/catalogs/{catalogId}?resource=sets. productSpecificType is ALL | PRODUCT_SET | CUSTOMIZED_PRODUCTS and is derived when omitted, and only the id list matching it is sent. skuIds are one per variant, itemGroupIds one per product — six sizes of one shirt share an item group — so the two are not interchangeable."},"audienceIds":{"type":"array","items":{"type":"string"},"description":"Target an audience AT CREATE, on the four networks that have one — Apple has no audience object at all. WHERE IT GOES DIFFERS: top level on Google and Snapchat, adset.audienceIds on Meta, adgroup.audienceIds on TikTok. Ids come from GET /ads/lookups?network=X&type=audience, and on Google they are user list RESOURCE NAMES (\"customers/123/userLists/456\") — Google refuses the bare id. NEVER SEND AN EMPTY ARRAY: omit the field. All four networks read an empty include list as a filter that matched nobody, which creates an ad set that reports as healthy and cannot deliver. Refused rather than dropped on Meta product=advantage_plus, Google ?type=app (no criteria of any kind) and Google ?type=pmax (which takes asset-group signals under audienceResourceNames instead — a second, non-interchangeable Google id space, \"customers/123/audiences/456\")."},"excludedAudienceIds":{"type":"array","items":{"type":"string"},"description":"The exclusion side, in the same place as audienceIds on each network. The SAME id on both sides is refused rather than sent: all four networks evaluate both and reach nobody in that audience, silently, with a plausible spec and no error at any layer. Google has no separate exclusion field — it is the same user_list criterion with negative:true — and Snapchat has none either, sending a second targeting.segments[] entry with operation EXCLUDE. Performance Max has no exclusion at all."}},"required":["campaignName"],"additionalProperties":true},"CampaignLaunchResponse":{"type":"object","description":"What a launch hands back. campaignId, network and status: \"PAUSED\" on every network; the rest is per-network. Full per-network payloads: https://affiliateo.com/docs/api.","properties":{"campaignId":{"type":"string"},"network":{"type":"string"},"status":{"type":"string","description":"Always \"PAUSED\". Nothing this endpoint creates starts spending."},"adGroupId":{"type":["string","null"],"description":"Apple: the ad group, when adGroup travelled with the launch."},"adId":{"type":["string","null"],"description":"Apple. The ad created beside the ad group, and NULL is not an omission: the placements Apple renders from the App Store listing (APPSTORE_SEARCH_RESULTS and APPSTORE_PRODUCT_PAGES_BROWSE) owe no ad object unless ad.productPageId asked for one, and a launch that sent no adGroup has nothing to hang an ad on."},"creativeId":{"type":["string","null"],"description":"Apple. The creative the ad renders. Named here rather than left to a follow-up read because Apple mints one creative per product page per ORGANISATION and publishes no creative delete — so this is a slot that cannot be pointed at again if the id is lost, and it is also what makes a retry after a failed ad create cheap."},"creativeState":{"type":["string","null"],"description":"Apple’s own review verdict, in Apple’s words. NULL ON A CREATIVE WE JUST CREATED — Apple’s create returns the id alone, so null here means \"not read\", never \"no state\". A REUSED creative carries Apple’s real verdict, which matters because an ad on a creative that has not cleared review is accepted and then delivers nothing."},"creativeStateReasons":{"type":"array","items":{"type":"string"},"description":"Apple’s reasons behind creativeState, empty when Apple gave none. Never our words."},"keywordsCreated":{"type":"integer","description":"Apple: how many Apple CONFIRMED, not how many were asked for."},"negativeKeywordsCreated":{"type":"integer","description":"Apple: same rule as keywordsCreated."},"keywordsError":{"type":"string","description":"Apple, present ONLY when the campaign and ad group landed and the keywords did not. A 201 beside it is deliberate: the campaign is real, correct and paused, and the ids to retry with are in this payload. Unlike the ad group and the ad, keywords are not a rollback point, because a campaign without them can still serve."},"note":{"type":"string","description":"What was built and what is still owed, in plain English."}},"required":["campaignId","network","status"],"additionalProperties":true},"AudienceCreateRequest":{"type":"object","description":"Create an audience. Which vocabulary applies is decided by ?network=. GOOGLE and SNAPCHAT take the shared { kind, name, … } below. META takes { kind: website|engagement|app_activity|lookalike, name, pageId?, appId?, originAudienceId?, country?, ratio?, retentionDays?, event?, urlContains? } and TIKTOK takes { kind: rule|lookalike, … } or ?type=saved for a saved audience, which is a different object entirely (a reusable targeting SET, not people). APPLE takes nothing: its API has no audience object. Customer-list uploads are refused on every network — a product decision, and the per-network hashing is built and tested, so reversing it is a decision plus a writer. Full per-network bodies: https://affiliateo.com/docs/api.","properties":{"kind":{"type":"string","enum":["website","engagement","app_activity","lookalike","customer_list","rule"],"description":"WHO the audience is. Not every network has every kind, and GET …/ads/audiences?catalog=1 is the machine-readable answer for which. google: website | lookalike. snapchat: website | app_activity | engagement | lookalike, all one object type (a SEGMENT), distinguished by source_type. meta: website | engagement | app_activity | lookalike. tiktok uses its own \"rule\" (with an audienceType picking the signal source) or \"lookalike\". customer_list is declared on all four and blocked on all four."},"name":{"type":"string","description":"Required everywhere."},"urlContains":{"type":"string","description":"website. REQUIRED ON GOOGLE and optional elsewhere: Google matches on variables the advertiser’s own tag fires and `url__` is the only one every account has, so it publishes no rule meaning \"everyone\". Snapchat has no URL condition at all — narrow with `event` there instead."},"event":{"type":"string","description":"One event the source saw (\"PURCHASE\"), instead of everything. REQUIRED ON SNAPCHAT for website and app_activity: it has no \"everyone the pixel saw\" form. Refused on Google, whose rule has no event field. On Meta an app audience spells it in the PIXEL’s event names, because that is how Meta aggregates standard app events for audience rules."},"sourceIds":{"type":"array","items":{"type":"string"},"description":"What the audience is built FROM, and it means a different thing per kind and network: pixel ids, a Meta Page id (engagement), a Meta app id or a Snap App UUID (app_activity), TikTok ad group ids. Empty means the connection’s own. Refused on Google, whose rule has no per-source selector."},"retentionDays":{"type":"integer","description":"How far back the source is searched. Meta clamps to its published 180-day ceiling; TikTok publishes different maxima per audience type, so its own refusal is the authority rather than a number invented here."},"seedAudienceId":{"type":"string","description":"lookalike. The audience to be similar to — never itself a lookalike. On Google this is a user list; on Snapchat any segment on the same ad account."},"countries":{"type":"array","items":{"type":"string"},"description":"lookalike. Meta and Snapchat take exactly ONE; Google takes one or more country codes; TikTok takes its OWN lookalike location ids, which are not the ad-group location ids."},"similarity":{"type":"number","minimum":0,"maximum":1,"description":"lookalike, 0 (closest and smallest) to 1 (widest). OUR scale: Meta takes a population ratio 0.01–0.20, TikTok and Google take NARROW|BALANCED|BROAD, Snapchat SIMILARITY|BALANCE|REACH, and none can express another’s. Omitted leaves each network’s own balanced default, so existing callers keep getting what they got."},"networkOptions":{"type":"object","additionalProperties":true,"description":"Per-network extras the shared contract deliberately does not promote to fields — TikTok’s parameterFilters and audienceSubType, Snapchat’s engagementType/engagementSpecs and multi-event `events`, Google’s operator/prepopulate. Unvalidated by the contract and validated by that network’s own resolver."}},"required":["name"],"additionalProperties":true},"WebhookRegisterRequest":{"type":"object","required":["url"],"properties":{"url":{"type":"string","format":"uri"},"events":{"type":"array","items":{"type":"string","enum":["affiliate.created","affiliate.verified","conversion.created","conversion.reversed"]}},"business_slug":{"type":"string"}}}}}}