# Affiliateo, full reference > Affiliateo is an all-in-one platform where businesses launch affiliate programs for their web and mobile apps, sell products with built-in checkout, manage their paid ads (Meta, Google, TikTok, Apple Search Ads, Snapchat) with true click-to-revenue ROAS, and pay affiliates worldwide. Affiliates browse a marketplace of programs, promote with tracked links, and withdraw earnings globally. Stripe is the merchant of record; tracking SDKs exist for web, iOS (Swift), Android (Kotlin), Flutter, and React Native. This is the expanded companion to https://affiliateo.com/llms.txt. It exists so a model can answer questions about Affiliateo from one fetch. The endpoint list, error codes and ad object levels below are generated from the same constants the running API serves, so they cannot drift from the product. ## Core concepts **Business.** A workspace. An account can own up to 3. One account wallet spans all of them. **App.** What a business installs into a workspace to run something: Affiliate, Clipping, Web Traffic, Collab, Meta Ads, Google Ads, TikTok Ads, Apple Search Ads, Snapchat Ads, Calendar and more. An app's URL is `/{business}/{appSlug}` and slugs are globally unique. **Affiliate.** A person promoting an app or a storefront with a tracked link. These are two separate programs with two separate link formats and they are never merged: `?ref=` is an affiliate sale link, `?a=` is a partner signup link. **One Affiliate app covers a website, a mobile app, or both**, on a single app id with a single referral link that routes by device (iOS to the App Store, Android to the Play Store, everything else to the website). What the app can do is decided by which providers are connected, not by the app type, and a second rail can be added later by editing the app. The type is always `affiliate_app`, one name, sent and read back. Never infer rails from it. ## Attribution The difference between the two rails is real and worth stating plainly: - **Web is deterministic.** The `?ref=` code is stored first-party on the merchant's own domain and read back at checkout. - **Mobile is probabilistic.** The App Store strips every URL parameter, so a fresh install is matched to a click within the last 7 days by IP/subnet, OS, country and recency. Expect some mobile installs to go unattributed. **Ads attribution is first-party.** Ad clicks are stamped on visitors and locked onto conversions at sale time, so campaign ROAS reflects real attributed revenue rather than the network's own claim. ## Commissions and money Maximum commission is **70% on store-mediated sales** (Apple in-app purchases, Google Play, RevenueCat, Adapty, where the stores take up to 30% off the top) and **90% everywhere else** (Stripe and every web provider). Per-product rates are capped per catalogue when saved; the app default rate and per-affiliate deals are capped at the moment of the sale, where the rail is known. Commissions are **locked at conversion time**: a customer's original rate survives renewals. There is **one wallet per account**, not per business. Withdrawals need identity verification and have a $20 minimum. Refunds and chargebacks reverse commissions as separate negative rows. ### The budget-app platform fee Budget apps (**clipping, web traffic, collab**) carry their 1% platform fee at **payout time**, not at funding time. Funding is free: every cent funded becomes the app's budget. Each payout then costs the budget the creator's amount **plus 1% of it**, taken from the same budget and credited to Affiliateo in the same transaction. A $100 payout costs the budget $101, so plan a budget as intended payouts times 1.01. A payout the remaining budget cannot cover **together with its fee** is refused outright rather than part-paid, so pay attention to the numbers below before offering an amount. Nothing is ever charged on money that did not reach a creator: ending an app refunds the whole unspent remainder and keeps no fee. Because of that, `budget_cents` is **gross**. It holds the creator money and the fees still to come, and `budget_cents` minus `spent_cents` is the **uncommitted money**, not what a creator can be offered. The derived, read-only `payable_capacity_cents` is the largest single payout that remainder can still cover with its fee. Read it instead of subtracting. `payable_capacity_cents` sits next to `budget_cents`/`spent_cents` on `GET /api/v1/businesses/{slug}/apps/{appId}` (in its settings object) and on the public discover feed. The apps **list** is a thin id/name/type/status index and carries no money fields, so read the single-app endpoint for a budget app's numbers. Affiliate apps are unaffected: their 1% is per conversion and was always collected at payout. ### The partner program Every account has a personal invite link, in three interchangeable forms: `/i/{code}` (an opaque permanent code), `/i/{username}` (the same thing, readable, breaks on a username change), and `?a={username}` appended to **any** page URL, so a partner can link straight to a business, an app or a blog post instead of the homepage. A signup through any of them is stamped onto the new account **permanently and single-level**. From then on the referrer earns **50% of every platform fee Affiliateo collects** from that account: sales on Affiliateo, app sales, budget-app payouts and ad spend alike. At the standard 1% fee that is 0.5% of what the referred account sells. The cut always comes out of Affiliateo's own fee — never out of the seller's net, the creator's payout, or any affiliate's commission on the same sale. Attribution is remembered for 30 days from the click and applied once, at signup; the most recent partner link clicked wins; referring your own account does not count. ### The partner-link fee trial An account that signed up through a partner link pays **no platform fee at all for its first 7 days**, counted from signup rather than from its first sale. It covers every fee surface without exception: sales, subscriptions, one-time products, budget-app payouts and ad spend. **Nothing is deferred to the end of the trial.** A fee falling inside the window is not postponed, it is never charged. For ad spend specifically this matters, because ad fees settle roughly two days behind the spend they bill: the trial is priced **per spend DAY**, not by the clock at the moment the meter runs, so a day inside the window stays free even when a sync or the daily cron settles it after the trial has already ended. Because the referrer's share is half of what Affiliateo **actually collects**, a referred account earns its referrer nothing during those 7 days. The 50% begins the moment the trial ends and then runs for the life of the account. ## Paid ads Five networks, one shape: Meta, Google, TikTok, Apple Search Ads and Snapchat. For each, Affiliateo READS stats (spend, clicks, conversions, the network's own reported revenue), WRITES campaign management, and answers two cross-network questions identically on all five: **which ids a field can take** (`/ads/lookups`) and **where the money actually went** (`/ads/breakdowns`, split by age, geo, device, placement, hour and the rest). Both are covered below. Every ad row carries **two revenue views side by side**: verified revenue and ROAS from first-party tracked sales, and the network's own reported conversions and claimed ROAS. They disagree, and seeing both is the point. ### What each network can LAUNCH `POST /api/v1/businesses/{slug}/ads/campaigns` builds a whole stack — the campaign plus everything under it — in one call, on all five networks. Every campaign is created **PAUSED**, so a launch never starts spend; a stack that half-builds is rolled back rather than left orphaned. The MCP equivalent is the `launch_ad_campaign` tool. **The objective field is not the same on any two networks, and three of them read an unrecognised value as "traffic" rather than refusing it.** That is deliberate (a launch is not the place to start rejecting a field older callers never sent) and it is exactly why the legal set has to be read rather than guessed: - **Meta** — `objective`: traffic (default), sales, awareness, engagement, video_views (Meta has no video-views objective, so this is the engagement objective with a ThruPlay performance goal and the creative must be a video), app_promotion. `product` is manual (default) or advantage_plus, which moves the budget onto the campaign and hands Meta the audience and placements — offered on sales and app_promotion only, because those are the two states Meta derives, and refused rather than downgraded elsewhere. Three things on a Meta launch body are worth reading before you send one. **`adset.endDate` is the end of that day ON THE AD ACCOUNT'S OWN CLOCK, not UTC.** A launch naming one makes one extra Graph read to learn the account's timezone (a launch without one makes no extra call), and an end date that cannot be placed on a real clock is REFUSED rather than sent as `T23:59:59Z` — that hardcoded UTC is what a Los Angeles advertiser lost seven paid hours to and a Tokyo advertiser overspent nine hours past, with nothing erroring either way. **`specialAdCategoryCountry`** is a list of two-letter codes saying which countries a regulated declaration covers. One targeted country still derives itself, but a launch declaring a special ad category other than NONE while targeting TWO OR MORE countries is now a 400 unless it sends this: Meta would otherwise file the declaration under the country the account pays tax in, which is not where the ads run. Sending it with no regulated category declared is refused too. **`contextualMultiAds`** is `OPT_IN` or `OPT_OUT`, create-only, and UNSET IS NOT OPT_OUT — Meta enrols any creative made with no enrol status, so leaving the field out keeps Meta's default rather than declining it. All three are dashboard controls as well. One more refusal changed: an `adset.publisherPlatforms` list of which NONE is facebook / instagram / threads / messenger / audience_network is a 400 instead of a silent launch on every platform Meta has. Dropping SOME unknown values is unchanged. - **TikTok** — `objective`: traffic (default), conversions, engagement (SPARK ADS — see below), leads (an Instant Form id), app_promotion (a TikTok app id plus ios/android), product_sales (a CATALOGUE campaign: it needs `catalog.catalogId` and uploads no creative at all, because TikTok renders every impression from the product itself — the ad goes out as a catalogue carousel and format, videoId and imageIds are dropped rather than sent empty). Each fixes the optimisation goal AND the billing event, because TikTok refuses mismatched pairs instead of correcting them. Leads, engagement, app promotion and product sales send no landing page at all. **Engagement is Spark Ads, and it UPLOADS NOTHING.** The ad promotes a post that already exists on a TikTok account: send `ad.itemId` — the post URL, or the number at the end of it — and no format, videoId, imageIds, text or displayName, because the post supplies the video, the caption and the account name. Confirm the id first with `GET …/ads/lookups?network=tiktok&type=organic_post`, which takes the id plus the identity that owns it and proves TikTok will release the post for advertising; it CONFIRMS one post rather than listing them, because TikTok publishes no endpoint that enumerates an account's posts at all. It is also the one TikTok objective Smart+ cannot express (its three are app promotion, web conversions and lead generation), so unlike the other five it stops being creatable at the end of 2026 with nothing to migrate to. **A lead campaign's Instant Form no longer has to be typed by hand, and its leads can be read back.** `GET …/ads/leads?network=tiktok` with no pageId lists the Instant Forms on the ad account — no special permission, and it is where `ad.pageId` comes from. The two LEAD reads both require `leadSource`, which takes INSTANT_FORM (with pageId) or DIRECT_MESSAGE (without one — leads collected in TikTok DMs are a second source entirely, so an account with no forms can still have leads). There is NO paging: TikTok ignores page and pageSize here and publishes no list endpoint, so one call is the whole answer. `&resource=fields` returns the form's questions as plain strings plus its name and public URL — worth reading for the questions a lead left blank, though not to label the answers, which already arrive keyed by those same names. The "Lead Management" scope is GRANTED, verified live 2026-08-05. **awareness and video_views are REFUSED on TikTok, and refused BY NAME rather than read as traffic** — the only place in this API where an unrecognised objective is not silently downgraded. TikTok served both until 2026-08-04; it stops creating REACH and VIDEO_VIEWS campaigns at the end of 2026 and the Smart+ family that replaces manual creation publishes neither, so there is no successor to migrate to. Do not retry, do not substitute traffic: Meta, Google and Snapchat all still serve both goals. - **Snapchat** — the field is `goal`, **not** `objective`: traffic (default), conversions, awareness, engagement, video_views, leads, app_promotion. Seven of our eight, tied with Google, which reaches the same number by a different route: its campaign TYPE is its objective, and the only goal it refuses is video views, because the Google Ads API cannot create Video campaigns at all. The goal picks the CREATIVE, so leads takes a lead-form id and app promotion takes a store id plus an icon INSTEAD of a destination URL — and because neither of those creatives has a URL field, neither can carry tracking tags or report ROAS. **AGE TARGETING IS REFUSED RATHER THAN CLAMPED, AND THE TWO BOUNDS DO NOT SHARE A CEILING.** `ageMin` is a whole number from **13 to 35**; `ageMax` is from **13 to 55**; an `ageMax` below `ageMin` targets nobody. All three are `400 VALIDATION_ERROR`. Both bounds used to run through a single 13-55 clamp, so 65 silently became 55 and 12 became 13 and the ad squad went out targeting an audience nobody asked for while every surface reported the launch as configured — which means a caller working from the older "13 to 55 on both bounds" reading will now get a 400 where it used to get a 201. Leave `ageMax` out entirely for no upper limit. `ageMin`, `ageMax`, `gender` and `endDate` are dashboard controls as well. A launch may also carry **`profileId`** (alias `profile_id`), a Snapchat Public Profile id from Business Manager, used ONLY when the connection carries no profile of its own — the connection's profile always wins. A launch with no Public Profile anywhere answers **403 FORBIDDEN**, where it used to answer 502: the message is the same and the refusal is OURS rather than Snapchat's, so anything branching on the old status needs changing. - **Google** — there is no objective field. The campaign TYPE is Google's objective, chosen with `?type=`: omitted builds Search, or pmax, display, demand_gen, app, shopping. Each is one atomic mutate and `&validate=1` dry-runs it without creating anything. **Google collects LEADS by attaching a form to the Search campaign, and the form is CREATED in the same mutate rather than pointed at.** That is the one place Google differs from every other network on this endpoint: TikTok and Snapchat name a form that already exists, and Google has nothing to name until the launch makes it. Send `leadForm` on the Search body — `{ headline, description, businessName, cta, ctaDescription, privacyPolicyUrl, questions }`, all six required by Google itself, plus at least one question (FULL_NAME, EMAIL, PHONE_NUMBER and about a hundred more) — and the launch adds two operations to the same atomic call: a LEAD_FORM asset and the campaign_asset that links it. `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. The QUESTION ORDER is the form's layout, 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. No pixel is needed: the form is submitted on Google and Google reports the submission itself. - **Apple Search Ads** — no objective field either. A campaign is an app, a PLACEMENT (`supplySource`: search results by default, or the Search tab, the Today tab, or other apps' product pages) and a bidding strategy (`biddingStrategy`: MANUAL_CPT, or MAX_CONVERSIONS which bids toward installs and runs in search results only). Send `adGroup` or the campaign can never serve. An optional `ad: { productPageId, name? }` block promotes a **Custom Product Page** on any of the four placements, and it is **REQUIRED on `APPSTORE_TODAY_TAB`** whenever `adGroup` travels with the launch: a Today-tab launch with an ad group and no `ad.productPageId` is a 400 raised before anything is created, where it used to answer 201 and leave a campaign that could never serve. The 201 gains `adId`, `creativeId`, `creativeState` and `creativeStateReasons`. `adId` and `creativeId` are `null` on `APPSTORE_SEARCH_RESULTS` and `APPSTORE_PRODUCT_PAGES_BROWSE` **when no `ad.productPageId` was sent**, which is correct rather than an omission — those two render from the App Store listing and owe no ad object by default. Send `ad.productPageId` on either of them and an ad IS built and both ids come back, which is the same sentence as "on any of the four" read from the response end. `creativeState` is `null` on a creative we just made, because Apple's create returns only an id, and carries Apple's real verdict on a REUSED one. A failed AD create now rolls the campaign back and answers 502 (Apple refused) or 400 (our own check) instead of 201, and the rollback message calls it "the half-built campaign" rather than "the empty campaign", because a campaign whose ad failed still has an ad group in it. The campaign launcher asks for the product page too, picked from the `product_page` lookup, so this is not an API-only field. **What cannot be launched, and who owns each refusal** — worth telling an owner rather than retrying: - **Meta catalogue sales**: App Review did not grant `catalog_management`. Nothing on our side unlocks it. - **Meta leads, which is TWO refusals wearing one word, and only one of them is a wall.** The blocked half is Meta **Instant Forms**: they need `pages_manage_ads` to create and `leads_retrieval` to read the submissions back, App Review granted neither, and we could build that campaign and never read a single lead out of it. The other half is lead ads that send people to the **advertiser's own website**, which need no permission Meta has withheld and are simply **not built yet**. So do not tell an owner that nothing on our side unlocks the goal as a whole: half of it is a work item, and a false reason is how a work item stops existing. **Google, TikTok and Snapchat run lead campaigns today; Apple Search Ads does not** — it has no lead form anywhere in its API, which is the fourth kind of no again and not "the other networks". - **Google video**: Google's own guide says the Ads API can only fetch and report on Video campaigns, never create or update them. Demand Gen is Google's suggested alternative and does run on YouTube, but the ad we build there is an image ad, so it is offered under awareness and engagement rather than badged as video views. Google lead forms used to be listed here as a second gap and ARE NOT ONE ANY MORE: the launch authors the LEAD_FORM asset itself — see `leadForm` on the Search body above. - **TikTok Smart+**: needs no allowlist — TikTok confirmed general availability on 2026-08-03 — and the whole campaign/ad group/ad stack is verified live as of 2026-08-04. The Smart+ AD endpoint refuses a fixed call to action on most campaigns; the answer is a dynamic CTA portfolio id, sent inside `ad_configuration`, which the launcher builds. ONE TikTok goal has no Smart+ form at all, and it is ENGAGEMENT: Smart+ publishes three campaign objectives (APP_PROMOTION, WEB_CONVERSIONS, LEAD_GENERATION) and none of them is engagement, so Spark Ads are the single TikTok goal with nothing to migrate to when manual creation stops on 2026-12-31. The other five all have one, catalogue selling included — Smart+ sells from a catalogue as a WEB_CONVERSIONS campaign carrying `catalog_enabled` plus `catalog_type`, with `catalog_id` and `catalog_authorized_bc_id` on the ad group, and that body is BUILT and live-verified rather than the "third request shape nobody wrote" it was until 2026-08-04. What is genuinely left is that the launcher picks the TikTok family server-side rather than letting a caller choose it. - **GMV Max** is the one TikTok product still listed and greyed rather than hidden, so an owner can be told why instead of retrying. It is a third kind of blocker, neither "TikTok refuses it" nor "nobody wrote it": it advertises a TIKTOK SHOP, so it needs a Shop linked to the ad account and a seller account behind it — a merchant relationship nobody here can create — and it sells from a different product source (STORE, not the CATALOG rail our catalogue endpoints feed), so the catalogue campaign could not be widened into it. Both catalogue products beside it, manual Catalog ads and Smart+ Catalog Ads, are selectable. - **Snapchat product sales**: the field list is not the problem — the dynamic creative takes a dynamic_template_id and a product_set_id, and Snapchat publishes no endpoint for either, so the ad squad wants a product SET id that no endpoint of ours can list. - **Apple awareness, engagement, video views, leads and product sales**: none of these exists anywhere in Apple's API. A campaign advertises one app and bids on taps or installs. ### Audiences: building one, and pointing a campaign at it `GET/POST /api/v1/businesses/{slug}/ads/audiences` manages audiences on the **four networks that have them** — Meta, TikTok, Google and Snapchat. Apple has none at all: its ad groups target dimensions evaluated at serve time, and there is no audience object anywhere in its API to create, list or attach. Add `?catalog=1` (with no `?network=`) for the machine-readable vocabulary: every audience kind on every network, that network's own name for it, whether a writer exists today, and where one does not, whether the blocker is the network's own limit, a permission we were not granted, a product decision of ours, or a wire shape nobody has confirmed. That call asks no network anything, so it works without a connection. Five kinds, named by concept rather than by any one network's spelling — **website** (everyone a site pixel saw), **app_activity** (everyone an app's SDK saw), **engagement** (people who interacted with your own content or ads ON the network), **lookalike** (people the network judges similar to a seed), and **customer_list**. What each network can mint: - **Meta** — website, engagement (a Facebook Page), app_activity, lookalike. - **TikTok** — website (PIXEL), app_activity (APP), engagement (ad groups, or the profile-side video and live sources), lookalike. TikTok is also the only network that can **share** an audience to another ad account on the same connection, which is what stops an advertiser running several accounts from rebuilding the same purchaser audience once per account, each copy starting from its own date. Nothing about any person moves: an audience that already exists becomes targetable by a second account of the same advertiser, and it is withdrawable. - **Google** — website (a remarketing list) and lookalike (which serves on Demand Gen campaigns only and reads "Eligible - Limited" anywhere else). Its ids are **resource names** ("customers/123/userLists/456"); Google refuses the bare id in every slot that takes one. App and YouTube lists arrive because a Firebase, GA4 or YouTube property is LINKED, and no Ads API call creates one. - **Snapchat** — website, app_activity, engagement and lookalike, all one object type (a SEGMENT) distinguished by its source. The first two need at least one **event**: Snapchat has no "everyone the pixel saw" form and no URL condition at all, so a page-specific audience is a custom event there. **Customer-list uploads are refused on all four.** That is a product decision, not a technical gap — rule-based audiences do the same jobs off signals the pixel already sends, so no buyer PII has to flow through us to get a purchaser audience and a lookalike seeded from it. **An audience nothing can target is worth nothing**, so a launch carries them: send the ids on `POST /ads/campaigns` as `adset.audienceIds` on Meta, `adgroup.audienceIds` on TikTok, and top-level `audienceIds` on Google and Snapchat (each with an `excluded…` twin). Attaching afterwards is not the same thing — a campaign that spends its first day untargeted has already spent it. Three rules hold everywhere. **Never send an empty array**: omit the key, because all four networks read an empty include list as a filter that matched nobody, which is an ad set created successfully, reported as healthy, that cannot deliver. **The same id on both sides is refused** rather than forwarded, because all four evaluate both and reach nobody in that audience silently. And three products refuse audiences by name rather than dropping them: Meta `advantage_plus` hands the audience to Meta, Google `?type=app` has no criteria of any kind, and Google `?type=pmax` takes asset-group SIGNALS instead (`audienceResourceNames`, a second Google id space — "customers/123/audiences/456" — which seeds Google rather than restricting it and has no exclusion). TikTok's Smart+ does take them, nested under its targeting spec with the optimisation mode switched to manual, because under TikTok's default it answers 200 and drops them. Renaming is `PATCH …/ads/audiences/{audienceId}`, and **name and description are all there is**, on purpose and on every network. The rule, the retention window and the lookalike spec decide who is IN the audience, and rewriting one silently rebuilds the membership of an audience live ad sets are targeting right now, with nothing on screen changing to say so. Changing who is in an audience means creating the replacement. ### Everything a network keeps beside a campaign, behind one contract `GET /api/v1/businesses/{slug}/ads/resources` is **the one to try first** for anything that is not a campaign, an ad group or an ad. Seventeen list-shaped objects across the five networks answer here instead of seventeen dialects: 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. **Call it with no `?resource=` and it describes itself.** That is the catalogue: every resource on every network, that network's own wire spelling for each column, the fields a create form takes, the parent object an id hangs off, the row actions that are not CRUD, and a status per operation of `available`, `not_built` or `blocked`. So what can be done is **read rather than guessed**, and the catalogue asks no network anything, which means it works before a connection exists. Answering today, all seventeen and several of them partly: `?resource=audience` on Meta, Google, TikTok and Snapchat (list, create, `PATCH ?id=`, `DELETE ?id=`); `?resource=rule` on Meta (all four) and on TikTok (list, create, update, plus `POST {"action":"bind"}`; TikTok publishes no delete); `?resource=custom_conversion` on Meta (all four), on TikTok (LIST ONLY) and on Snapchat (list, create, delete, with the required parent `?eventSourceId=` and `&eventSourceType=PIXEL|APP`; no update, and that one is a real blocker because none of Snapchat's six paths is a PUT or a PATCH — the row itself said Snapchat "publishes no endpoint that lists an account's custom conversions" until the page was fetched on 2026-08-06 and turned out to publish six); `?resource=identity` on TikTok (list, create, delete); `?resource=registered_app` on TikTok (list, edit); `?resource=ad_balance` on TikTok (list); `?resource=keyword` on Apple Search Ads (all four, with the required parent `?campaignId=`) and on Google (list and edit, off the same GAQL query over `ad_group_criterion` the dashboard already runs; an edit carries status and the bid only, because Google answers [IMMUTABLE_FIELD] on the text and the match type); `?resource=negative_keyword` on Apple Search Ads (all four) and on Google (list, create, delete); `?resource=shared_set` on Google (list and create, plus three row actions that reach the TERMS in a list: `{"action":"keywords"}` reads them, `{"action":"addKeywords"}` adds and `{"action":"removeKeyword"}` removes one; NO DELETE, and that is a policy blocker rather than a gap, because removing a list stops every campaign attached to it excluding every term it held, all at once); `?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` and the `?campaignId=` parent is OPTIONAL, unlike every other Google parent here, because Google publishes both reads); `?resource=budget_schedule` on Meta (list and create only); `?resource=blocked_word` on TikTok (list, create, delete, plus `POST {"action":"rename"}`); `?resource=label` and `?resource=campaign_group` on Google (all four verbs, plus `{"action":"attach"}` / `{"action":"detach"}` on a label and `{"action":"assign"}` on a group); and `?resource=recommendation`, `?resource=change_history` and `?resource=ad_strength` on Google (LIST ONLY, with `{"action":"apply"}` and `{"action":"dismiss"}` on a recommendation). Those last three are read-shaped because Google made them so, not because nobody finished them: `recommendations:mutate` and `changeEvents:mutate` are not served at all — both answer an HTML document where a method Google does publish answers a JSON error envelope — and ad strength is an output-only FIELD on an ad rather than a resource. A GOOGLE LABEL DELETE IS THE ONE TO READ TWICE: removing a label detaches it from every campaign, ad group and ad carrying it, in one go, with no list of what it was on and no undo, so the catalogue ships a `confirm` sentence on that operation. The rename is one TikTok call rather than a delete followed by an add, because two calls leave a window in which the word is blocked by nobody — and a `PATCH` on a blocked word is therefore reported as BLOCKED rather than unbuilt, with the rename named as what to send instead. None of these is a second implementation: audiences go through the same four writers as `/ads/audiences`, Meta's custom conversions through the same writers as `/ads/custom-conversions`, identities, apps and balances through the same clients as `/ads/identities`, `/ads/apps` and `/ads/balance`, keywords, negative keywords and budget schedules through the same clients as `/ads/keywords`, `/ads/negative-keywords` and `/ads/budget-schedules`, and rules through the same clients as `/ads/rules`. **APPLE KEYWORDS: THE READ THAT DID NOT EXIST.** `?resource=keyword&network=apple_search_ads` with the required parent `?campaignId=` is the first way to LIST an Apple targeting keyword over the API. `/ads/keywords` exports `POST`, `PATCH` and `DELETE` and no `GET`, so until now that door could add a keyword, re-bid it, pause it and remove it and could not show you a single one. Four things are worth knowing before you write one. (1) **A NEW KEYWORD IS PAUSED.** Apple creates one ACTIVE when the body says nothing about status, which is the one create in this product that used to start spending the moment the request landed; send `"status": "ACTIVE"` and mean it. (2) **OMITTING `bidCents` INHERITS THE AD GROUP'S DEFAULT BID** — that is Apple's own fallback, and it is not the same as `0`, which is refused as a bid of nothing. (3) **THE READ IS CAMPAIGN-WIDE AND EVERY WRITE IS PER AD GROUP.** That is Apple's shape, not a choice: the find is `/campaigns/{id}/adgroups/targetingkeywords/find` and it reports each row's `adGroupId`, while create, update and delete all go to a per-ad-group `/bulk` path. So `adGroupId` is required at CREATE and is read back off the row on edit and delete rather than asked for again — and an id that is not really in this campaign is refused as a 400 rather than sent, because Apple ids are small integers unique only inside one organisation. (4) **THE LIST IS A PREFIX PAST `&pages=` × 1,000 ROWS** (two pages by default, up to ten). When it is, `total` carries Apple's own count and the rows stop short of it, so the two disagreeing is how you can tell. `&pages=` is how deep to read APPLE and `&limit=` is how much of what came back to show; they are deliberately two numbers. `text` and `matchType` are frozen at create and a keyword cannot move between ad groups, so correcting any of the three is delete-and-recreate with a new id; `status` and the bid are the only two things an edit can carry. Apple takes `EXACT` or `BROAD` and a `PHRASE` is refused rather than widened to BROAD, which would buy far more traffic than you asked for. **GOOGLE KEYWORDS NOW LIST AND EDIT, AND THIS PARAGRAPH USED TO SAY THE ROW WAS UNBUILT.** It was not a network limit even then — the GAQL read that fills it already ran, and its rows already rendered on the dashboard's Google Keyword tab; the missing piece was the adapter, and that is now wired. `?resource=keyword&network=google` takes the required parent `?adGroupId=` and answers rows off the same `ad_group_criterion` query the dashboard runs, with five columns: the text, the id, the match type, the status and the Max CPC. **The edit carries `status` and `cpc_bid_micros` and nothing else**, and the two it will not carry are Google's refusal rather than ours: Google answers `[IMMUTABLE_FIELD]` on `keyword.text` and `keyword.match_type`, so correcting either is delete-and-recreate and the replacement is a new criterion id. Sending anything else is refused by name rather than dropped, because the shared updater builds its update mask from whatever bag it is handed and a mask naming an immutable field would 502 the whole edit instead of telling you which key is the problem. `status` is `ENABLED` or `PAUSED` — Google's vocabulary, deliberately not normalised toward Apple's `ACTIVE`. **Create and delete are still declared and unbuilt**, and that is two of the six triples named at the end of this section. Ids are the composite `{adGroupId}~{criterionId}`, which is a **different id space** from a Google NEGATIVE keyword's `{campaignId}~{criterionId}`: a keyword hangs off an AD GROUP (`?adGroupId=`) and a negative keyword off a CAMPAIGN, because they are two different Google objects. An ad group that is not on this customer is a 400 naming the customer, never an empty list, since an empty list here means "asked and it has none". **AUTOMATED RULES ARE THE ONE RESOURCE HERE THAT KEEPS ACTING AFTER YOU STOP.** A rule runs on the NETWORK'S side on the network's own schedule: it pauses ad sets, moves budget and changes bids with nobody calling the API, which is exactly why it is worth listing and exactly why it is worth reading before you write one. Meta and TikTok express it as two genuinely different objects rather than two dialects, and the fields say so. On **Meta**, a rule is `evaluationSpec` (when it fires) plus `executionSpec` (what it does) plus an optional `scheduleSpec`, and Meta requires an `entity_type` filter inside the evaluation spec naming which objects to scan. The update is a real PARTIAL, so a name-only or status-only edit is safe. `status` is `ENABLED` or `DISABLED`, and DISABLED is the reversible way to stop a rule — the delete is not, and undoes nothing the rule has already done. **AN EMPTY LIST ON META IS NOT PROOF THAT NOTHING IS RUNNING.** Live on 2026-08-05, the `adrules_library` edge answered `{"data":[]}` for an account whose `adrules_history` in the same run listed hourly evaluations of an ENABLED rule on that same account. That rule was Meta-authored (`PRODUCT_SET_BOOSTING` / `AD_RECOMMENDATION_APPLY`), so the likely reading is that the library lists rules an advertiser wrote and not ones Meta's own recommendation system installed, but only THAT it is absent was established, never WHY. On **TikTok** three things differ and none is cosmetic. (1) **The update is a FULL REPLACE.** TikTok marks `name`, `conditions`, `actions`, `applyObjects`, `notification` and `ruleExecInfo` required on update as well as create, so a partial body is not a rename, it is a rule with no conditions — both doors refuse one with the missing parts named. Read the rule, change it, send the whole thing back. (2) **Money is MAJOR units.** A "spend greater than X" condition is read in the ad account's currency in major units, so send `valuesCents` / `valueCents` / `limitCents` and the conversion is done for you, or send `values` / `value` / `limit` and they pass through untouched; sending both spellings of one number is refused rather than guessed, because guessing is how a rule meant to fire at $40 fires at $4000 instead. (3) **An `applyObjects` entry with no `dimensionIds` covers EVERY object at that level in the ad account**, which is a real TikTok mode and, on a pause rule, one that can stop an entire account — so it stays reachable behind `"confirmAccountWide": true` and is never arrived at by leaving a field out. `POST {"action":"bind"}` attaches 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. **THERE IS NO TIKTOK DELETE AND NO OFF SWITCH,** and that is now from calls rather than from a reference: on 2026-08-05 `/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 and the list answered 200 on the same token in the same run. To stop a TikTok rule, replace its `applyObjects` with a narrower list, or remove it in TikTok Ads Manager. What a rule actually DID is a separate read and stays at the older door: `GET /ads/rules?network=tiktok&view=results`. **NEGATIVE KEYWORDS ARE ONE RESOURCE IN TWO SHAPES,** which is the point of one contract rather than seventeen dialects. Both need `&campaignId=`, because neither network keeps an account-wide list of them. On Apple all four verbs answer, but an EDIT may carry `status` only — Apple freezes the keyword text and the match type at create, so correcting either is delete-and-recreate — and `adGroupId` at create picks campaign-wide versus one ad group, which is then READ BACK on edit and delete rather than asked for again, because Apple keeps the two scopes behind different paths and the id alone does not say which. On Google there is no edit at all, and that is a BLOCKER rather than a gap. The precise reason matters because the loose one is false: Google DOES publish an update on a campaign criterion, and it answers `[IMMUTABLE_FIELD] Field 'keyword.text' cannot be modified by 'UPDATE' operation` — live-verified against a control, where an operation Google does not publish answers a protobuf parse error instead. The two match-type vocabularies are NOT narrowed into each other: Google takes EXACT | PHRASE | BROAD, Apple only EXACT | BROAD, and a PHRASE sent to Apple is refused rather than quietly filed as EXACT, which would block far less than you asked for. A Google id is the COMPOSITE `campaignId~criterionId`, and one from another campaign is refused as a `400` — Google would remove it without complaint, and Apple's ids are small org-scoped integers that usually exist in several campaigns at once. **A META BUDGET SCHEDULE IS THE ONE ONE-WAY DOOR on this endpoint.** `&parentId=` is a campaign or an ad set — Meta hangs a high demand period off either, never off the account — and `list` and `create` are all there is, because Meta publishes neither an update nor a delete for one. A boost over the wrong week is money that will be spent, and the only remedies are elsewhere: lower the budget, or pause for that window. `timeStart` and `timeEnd` are unix SECONDS, and a millisecond value is refused before it is sent, because as seconds it is a window fifty thousand years out that Meta accepts and nobody can delete. Both of those blockers carry `sourcedFrom: null`: a live attempt was made and settled nothing, because Meta answers "does not exist, cannot be loaded due to missing permissions, or does not support this operation" as ONE sentence, and there is no real schedule on any connected account to address. Creating one to find out would have left a permanent boost on a live ad account to test whether it is permanent. TikTok's custom-conversion WRITES are the one gap worth explaining, because they are unbuilt rather than blocked and the difference is deliberate. `/custom_conversion/create/`, `/update/` and `/delete/` all exist — each named its own required field when called, where a route that does not exist answers HTTP 404 with no envelope — and no request BODY for any of them has ever been observed: both advertisers on the account this was verified against have no pixel and no app, so there is no event source to define one on. TikTok answers an unrecognised field with SILENCE rather than an error, so a create typed from an SDK read can succeed while dropping the rule that makes the conversion mean anything. A blocker there would claim TikTok cannot do something it plainly can, so the honest answer is no resolver. Note also that `/ads/custom-conversions?network=tiktok` manages a DIFFERENT OBJECT — pixel event rules (`/pixel/event/`) — which is a real and useful object and is not this one. That trio, GOOGLE'S `keyword` CREATE AND DELETE and GOOGLE'S `shared_set` UPDATE are the declared-and-unblocked operations anywhere on this endpoint with no implementation behind them — three subjects and six triples in all, three here, two on the keyword row and one on the list: every other 501 you can reach here carries a network's own reason. The other two are the same shape of gap as this one. Google plainly creates and removes keywords (a create is POST …/ads/objects at level `ad_group_criterion`, which works today, and no remover for an ad group criterion has been written anywhere), and nobody has read whether `SharedSetService` publishes a rename, so a blocker in either place would be a denial we cannot support. **Four different NOs, and none of them is an empty list.** `400` the request is malformed (unknown resource, no network, a missing required parent, a limit or cursor we never issued). `501` declared but blocked, or declared and not built yet, and it says which — a blocked one carries the network's own reason, WHO can change it (the network's own limit, a permission nobody granted us, or a decision of ours), **whether anybody has ever CALLED the network to confirm it**, and the networks whose same verb is open. `409` not connected. `502` the network was asked and refused. So `rows: []` means exactly one thing: the network was asked and it has none. Retrying is never the answer to any of the four. **THE RETRY RULE FOR EVERY ADS WRITE, AND IT IS THE ONE AN AGENT GETS WRONG.** `502 UPSTREAM_ERROR` is the **only** ads failure worth retrying: it means we asked the network and the network refused, timed out or answered something we could not parse, so the same call a minute later can genuinely come out differently. **`409` with `error.code: "CONFLICT"` is OUR refusal, not the network's, and no network was asked at all** — the request was well formed and the caller was permitted, but the state of the account makes the write wrong to perform, so retrying it can never succeed and a loop just burns the rate limit. It is not `VALIDATION_ERROR` (there is nothing to fix in the body), not `FORBIDDEN` (permission is fine) and not `UPSTREAM_ERROR` (nothing upstream was called). The live case is `PATCH /api/v1/businesses/{slug}/ads/entities/{id}?network=google` editing `dailyBudgetCents` on a campaign whose Google `campaign_budget` is shared: the message says how many campaigns spend from that budget, nothing is sent to Google, and the fix is to change the shared budget in Google Ads or give the campaign a budget of its own first. That case used to answer `502`, which is precisely the code an agent loops on, and no amount of looping was ever going to move a budget we deliberately refused to touch. **Branch on `error.code`, never on the status alone**, because `409` also carries `NOT_CONNECTED` — a genuinely different answer with a genuinely different fix (send the owner a connect link). That "has anybody actually called it" field is not decoration, and most blockers still say **no**: they come from reading each network's SDK and reference rather than from a live call. This product has twice refused to build a feature because a comment written from reading said an endpoint did not exist, and both times the endpoint was real and the claim was load-bearing. So the doubt ships with the answer instead of being rounded off. A live sweep of TikTok's API on 2026-08-05 settled 31 of them and, in the process, found the catalogue wrong in BOTH directions on one resource at once: it said TikTok publishes no delete for an ad identity (it does, and it is wired now) and that an identity update was merely pending (that one really does answer HTTP 404). Neither entry had ever been called before that day. ### Ad object levels, per network These are the object types `POST/PATCH /ads/objects` can write. Call `GET /ads/objects?network=X` for the full field catalogue of any of them. - **meta**: campaign, adset, ad, creative Meta calls the middle level an ad set. The other three call it an ad group. - **google**: campaign, budget, adgroup, ad, conversion_action, asset, asset_group, asset_group_asset, asset_group_signal, asset_group_listing_group_filter, campaign_asset, ad_group_asset, customer_asset, ad_group_criterion, campaign_criterion The asset levels are how Performance Max is built: an asset group replaces ad groups, ads and keywords entirely. Assets are never deleted, you remove the LINK. The two criterion levels are why a LIVE campaign can gain keywords, audiences, locations and schedules rather than being frozen at launch. - **tiktok**: campaign, adgroup, ad, smart_plus_campaign, smart_plus_adgroup, smart_plus_ad smart_plus_* is a separate endpoint family, not a mode of the manual levels. It is the only campaign-CREATION path that works after 2026-12-31; editing, pausing and reporting on existing manual campaigns continue unchanged. Smart+ creation needs NO allowlist and no extra permission — TikTok confirmed general availability on 2026-08-03, and campaigns and ad groups create on an ordinary ad account. Smart+ AD creation used to be blocked by a call-to-action refusal; TikTok’s answer is that some campaign and identity combinations require a dynamic CTA portfolio id instead of a fixed value, and the launcher builds one and sends it inside `ad_configuration` — at the top level TikTok DISCARDS it with no error and then refuses the ad for having no call to action, so setting call_to_action_id through …/ads/objects is nested for you as well. The full stack is VERIFIED LIVE as of 2026-08-04. SMART+ IS WHAT RUNS BY DEFAULT, and the one goal Smart+ cannot express is ENGAGEMENT: SPC_OBJECTIVES is APP_PROMOTION, WEB_CONVERSIONS and LEAD_GENERATION, so Spark Ads are the single TikTok goal with no Smart+ successor. Catalogue selling DOES have one — a WEB_CONVERSIONS campaign carrying catalog_enabled + catalog_type, with catalog_id and catalog_authorized_bc_id on the ad group — built and live-verified 2026-08-04. POST /ads/campaigns?network=tiktok does not make you choose, and the ORDER IS SMART+ FIRST: TIKTOK_LAUNCH_ORDER is [smart_plus, manual], so the Smart+ stack is attempted and MANUAL IS THE STANDBY that catches a create-endpoint refusal — not the other way round. After 2026-12-31 manual creation stops answering, the standby goes away on its own and Smart+ is the whole launch. Two launch fields the Smart+ endpoints have nowhere to put are checked BEFORE the first create: adgroup.subplacements and ad.showcaseProducts. Naming either refuses the Smart+ attempt with nothing yet made, so the manual family finishes the launch today and the same request is refused outright after 2026-12-31 — TikTok answers a field it does not recognise with silence rather than an error, so inventing a Smart+ spelling would drop a sub-placement NARROWING and buy the surfaces the advertiser just excluded. The response reports which family answered in `path` (manual or smart_plus). Send that value as the matching `kind` on any later edit, because the two families cannot write each other’s objects. - **apple_search_ads**: campaign, adgroup, keyword, negative_keyword, ad, creative Keyword-level bidding, so keywords and negative keywords are first-class objects. - **snapchat**: campaign, adsquad, ad, creative Snapchat calls the middle level an ad SQUAD, and that is the write level name: `adsquad`, not `adgroup`. It lands in ad stats as adgroup and comes back as kind `adgroup` from the entity endpoints, the same three-vocabulary split Meta already has for "ad set". Snapchat ids are UUIDs at every level, unlike the numeric ids Meta, TikTok and Apple use. ### Finding the ids a field takes A field catalogue tells you an ad group takes `location_ids` and an ad takes `video_id`. `GET /api/v1/businesses/{slug}/ads/lookups` tells you **which ones exist**, and it answers the same way on every network: `{ id, label, meta }`, where `id` is always the value that goes on the wire and `label` is always what a human picks from. One reader serves all five, which is the point — the older `/ads/targeting-search` answers in a different shape per network and covers eight lookups on four of them; this is a strict superset of it. What can be looked up, as concepts: geo, interest, behavior, language, audience, page, pixel, app, custom_conversion, video, image, organic_post, placement, catalog, product_set, identity, lead_form, keyword_idea, saved_audience, shared_set, topic, asset, merchant_center, zip_code, interest_keyword, purchase_intent, content_exclusion, device_model, carrier, isp, app_event, product, business_center, music, card, product_page, creative, app_category, dynamic_template. That is the vocabulary, not a promise. A concept is declared wherever the network HAS it, including where the network publishes no endpoint that enumerates it (placements on Meta, TikTok and Snapchat; TikTok's music, cards and inventory categories) and where it has refused us the access (Meta's catalogues and lead forms, Snapchat's Public Profile). Those answer 501 naming which. The endpoint description further down carries the per-network list of what can be answered today, and calling `/ads/lookups` with no `type` returns it live. Three things to know before using one: - **The same concept is a different id space on every network.** A Meta interest id in a TikTok body is well-formed, wrong, and nothing upstream will say so. `type` without `network` is therefore refused rather than defaulted to one. - **Fields point at their own lookup.** `GET /ads/objects` returns `lookup: { type, network, multiple }` on every field that takes an id we can enumerate, so a form or an agent goes from "this field is required" to "here are its legal values" with no per-network branch. A field with no `lookup` and an id-shaped `help` is one the network gives us no way to list, and the help says where the value comes from instead. - **A "no" is never an empty list.** A concept the network does not have is a 400 naming the ones it does; one it has that is unbuilt, or that the network has refused us the access for, is a 501 saying which. An empty result therefore means exactly one thing. ### Dimensional reporting Ad stats are **flat daily totals**: the day is their only segment. So "which placement, device, age group or hour is wasting my budget" — the question a reporting surface exists to answer — cannot be asked of them. `GET /api/v1/businesses/{slug}/ads/breakdowns` is where it goes. All five networks hold the data and spell it four different ways (Meta `breakdowns`, Google `segments`, Snapchat `dimension` + `pivots`, Apple `groupBy`); one catalogue owns that mapping and publishes each network's own spelling beside ours, so a wrong mapping is findable rather than a silently different number. The dimensions, as concepts: `age` (Age), `gender` (Gender), `country` (Country), `platform` (Operating system), `region` (Region), `metro` (Metro (DMA)), `network_type` (Connection), `language` (Language), `interest` (Interest), `publisher` (Publisher), `device` (Device), `placement` (Placement), `hour_of_day` (Hour of day), `product` (Product), `frequency` (Frequency), `asset` (Creative asset), `city` (City), `day_of_week` (Day of week), `ad_format` (Ad format), `device_make` (Device make), `device_model` (Device model). Again the vocabulary, not a promise: no network has all of them, and a few are declared because the network documents the split without documenting any way to ask for it. Snapchat's device MODEL is now the only one of that kind left — its dimension table lists `make` under Device and stops there, so `model` has an Ads Manager pivot name and no `report_dimension` value. **`make` was refused on that same reasoning and the reasoning was wrong about it**: re-read on 2026-08-06, it is in Snapchat's own `report_dimension` table, and it is servable today. It is **delivery metrics only** — Snapchat names it in its own list of the four pivots that lose conversion metrics (`lifestyle_category`, `region`, `dma`, `make`) — so `conversions` is `null` on every bucket rather than `0`, and asking for conversion metrics beside it is a rejected request rather than a null column. A dimension the network does not have is a 400 naming the networks that do; one it has that is unbuilt is a 501. The endpoint description below carries the per-network available list. **Meta's `device` breakdown now carries an extra sentence in `note`, quoting Meta's own opt-in notice.** Nothing about the buckets changed; what the sentence is for is that an EMPTY result there is ambiguous — it can mean the split has no data, and it can mean the account has not opted in to it — so an empty device breakdown on Meta is not evidence that nobody saw the ad on a phone. **The whole ad account, or ONE campaign.** A breakdown covers the ad account unless you send `?campaignId=`, which narrows it to that campaign on Meta, Google, TikTok and Snapchat. Omitting it is byte-identical to what this endpoint always answered. Every report states which it is in `scope` (`account` or `campaign`) and echoes `campaignId` back when scoped, so a caller can tell from the payload alone that the filter was honoured rather than dropped between us and the network. **Apple Search Ads answers 501 NOT_IMPLEMENTED instead**, and the refusal is the feature: its split here is the org-wide campaign report, so serving those numbers under a campaign id would be a wrong number wearing a right label, and unlike an error it has no symptom. Nobody reports it, they decide on it. Campaign level is not declared for Apple until one live call has proved it, and the code path is already there and guarded. The catalogue call takes the same parameter: on a network that cannot narrow, EVERY dimension comes back with status `blocked` and the reason, and the response gains a `scope` object naming the levels each network supports. A malformed id is a 400 raised before any network is called (letters, digits, hyphens and underscores, 1 to 128 characters). Google prints a resource name rather than an id, and the value to send is the LAST SEGMENT of it: `customers/8249011858/campaigns/456` means `456`. - `key` is the **network's own value** and `label` is the human string. Join on `key`: the label side is lossy, because every network folds several unknowns into one word. - `spendMicros` is **USD micros** on all five, so buckets add up with every other spend figure here. A row that cannot be valued is dropped and counted, never zeroed. - `conversions` is `null`, never `0`, wherever the network refuses conversion metrics beside that split. - **One dimension per call.** All five restrict combinations, and a pair produces a row per combination against a shape that has one key. - **Bucket totals do not add up to ad stats totals, and are not meant to.** Networks suppress buckets under a privacy threshold, add an "unknown" bucket the flat rows have no equivalent of, and restate dimensional history on their own schedule. Ad stats stays the number to reconcile against, and nothing that decides an amount of money reads a bucket. Both surfaces are catalogue-driven: call either with no `type` / `dimension` and it lists what every network has and whether it can be answered today. The endpoint descriptions below carry the per-network available lists. ### Creative: what an ad can actually be The launch endpoint builds one ad of one shape. These are the levers beside it, and every one of them is optional — an untouched launch body builds exactly the ad it always built. - **Meta: boost a post you already published.** `ad.media = { type: "existing_post", objectStoryId: "{page_id}_{post_id}" }`, and then no text, headline, link, button or upload, because the post carries all five and Meta refuses `object_story_id` alongside `object_story_spec`. The boosted version **keeps the likes and comments the organic post earned**, which an ad rebuilt from the same image starts without. The id is typed rather than picked because listing a Page's posts needs `pages_read_engagement`, ungranted; a post belonging to a different Page is well-formed and is refused before anything is created. - **Meta: the video cover frame.** `ad.media.thumbnailId`, candidates from `GET …/ads/media?video={videoId}`. Omit it and Meta picks, as it always did. - **TikTok: carousel, placements, product showcase.** `ad.format: "CAROUSEL_ADS"` takes 1–35 images plus `ad.musicId`, which TikTok requires on every carousel. **You no longer have to supply one on a Smart+ launch**: omit it and a carousel-legal track is picked for you. Say which one and yours is used. (An earlier version of this file claimed TikTok publishes no endpoint listing music — it does, on v1.3, and the claim is what made image-only Smart+ launches impossible.) `adgroup.placements` / `subplacements` / `automaticPlacements` choose where it runs — TikTok, Pangle, the Global App Bundle, or automatic. **Naming none still buys exactly what every previous launch bought.** `ad.showcaseProducts` anchors products under an ad of any format, which is what TikTok markets as a Collection ad: a FIELD, not a sixth ad format. - **Google: three Demand Gen layouts and pinned search assets.** `ad.demandGenFormat` picks multi_asset (the default), carousel or video_responsive. Responsive search headlines and descriptions take `{ text, pinnedField }`; a headline pinned to a description slot is refused because Google ACCEPTS it and the asset then never serves. - **Snapchat: four formats through a format-shaped route** — deep_link, reminder, story, collection — rather than through the field surface, which could always POST them and is exactly why almost nobody could. Plus a frequency cap at launch. - **Apple: ad-group targeting at launch**, and Custom Product Page ads. The targeting block was accepted by the server for months and sent by nothing, so every Apple campaign this product launched went out reaching everyone in its countries. **Previews.** `GET …/ads/previews` renders an ad that already exists. Meta returns embeddable HTML. Google returns a signed link and covers Performance Max asset groups and YouTube ads only — it refuses responsive search and display ads, which is most of what an advertiser runs, so `GET …/ads/ad-strength` (Google's own grade plus its action items) is the answer there. Snapchat previews the **media file** rather than the assembled ad. Apple needs none: the ad IS the App Store product page. **No published endpoint on any of the five previews an ad that does not exist yet.** **Uploads.** 10MB images and 50MB video, and both ceilings are ours rather than any network's — the file is buffered once. Snapchat's used to be tighter and no longer is: above 32MB the upload is chunked automatically, so an ordinary 1080x1920 vertical video uploads instead of being refused. Google takes **no video bytes at all**: send a YouTube link and get back the asset resource name Demand Gen and Performance Max both need. `GET` on the same path reads the library back, which matters because an upload hands back its id exactly once. ### Operations: the reads that explain a campaign - **Why is this ad not delivering.** TikTok publishes ad review and appeal endpoints (under `/smart_plus/`, and reads on Smart+ objects never depended on Smart+ ad creation working), so a refused ad can be argued with rather than only observed — with the policy reason and the offending video frame, both of which TikTok defaults OFF and we ask for. There is no equivalent for TikTok's manual ads, which is stated rather than answered with a 501. Google's answer to the same question is ad strength. - **Who changed this account.** Google change history, thirty days, where every row's `clientType` tells a change made through this product from one made in Ads Manager, in Editor, by a script, or by Google's own automation. Google keeps no more than thirty days and answers day 31 with an EMPTY LIST, which reads as "nobody touched it" — so an out-of-range window is refused rather than passed through. - **A/B tests.** Google experiments and campaign drafts. Google is the only one of the five networks with an experiment API. Nothing splits or spends until you schedule it; promote and graduate are permanent and say so; the real errors live in `asyncErrors`, because scheduling and promoting report acceptance rather than completion. - **Comments.** TikTok comment moderation plus a blocked-word list held at ACCOUNT level, so keeping one word out no longer means switching comments off per ad group. - **Money that is not campaign money.** Billing reads on three networks, and they answer three different questions: Meta's balance and spend cap, Google's billing setups, budgets and invoices (no "balance", because Google has none), and Snapchat's organization-scoped funding sources — the usual explanation for delivery stopping while every campaign reports healthy. - **What should I bid, and what else should I be bidding on.** `GET …/ads/keyword-recommendations` is one path answering two different questions, because two networks have a keyword surface and they do not have the same one. **Apple** (the default) is an INVENTORY: a suggested bid for every targeting keyword already in a campaign, lifted out of a 30-day keyword report because v5 publishes no standalone endpoint for it. `suggestedBidCents` is `null` on MAX_CONVERSIONS campaigns, where Apple bids for you, and the whole read works on a read-only Apple connection since it is report data. `&adGroupId=` reaches Apple's narrower ad-group-scoped report rather than filtering the campaign-wide one, which on a forty-ad-group campaign is forty times fewer rows for the same answer. **Google** (`?network=google`) is a SEEDED SEARCH: KeywordPlanIdeaService invents ideas from `?q=` seeds and/or a `?pageUrl=` to mine, and answers with monthly volume, competition and top-of-page bid ranges. Google throttles planning to about one request a second per account. Two refusals here are deliberate and both were real defects: `?network=` is checked against the TWO networks that have this rather than tested for the string `google`, because `?network=meta` used to fall through and answer with Apple bid recommendations for a campaign id the caller believed was a Meta one; and `?geo=` takes numeric geo target constant ids only, with a NAME refused rather than dropped — `?geo=texas` was filtered away and returned WORLDWIDE volumes and a completely different bid range, reading as a Texas answer, and those bid figures are exactly what a caller pastes into `keywords[].cpcBidCents` on a launch. Meta, TikTok and Snapchat have no keyword surface, so they have no keyword recommendations either. - **Where you rank.** Apple's impression share report, its one asynchronous report. Known Apple-side fault: their gateway has answered the read half with a 403 since 2026-03-16, on accounts whose token works everywhere else, which the endpoint explains in words. - **Organising accounts.** Google labels (many-to-many, across campaigns, ad groups and ads) and campaign groups (a folder a campaign is in at most one of). - **Scheduled budget boosts.** Meta, on a campaign or ad set that ALREADY exists — the campaign field is create-only. Meta publishes no update, no delete and no recurrence, so every schedule is permanent and "every Friday" is twelve objects. **ELEVEN ADS SURFACES GAINED A DASHBOARD SCREEN across two phases**, which is worth saying because for months they had none: they answered perfectly over `/api/v1` and there was nowhere in the product a person could click to see them, so an owner had to be told to use an API to find out why their ads had stopped. The first five were **billing** (balance, spend cap, funding sources and invoices — owner-only, because a card number is not a staff member's to read), **rendered previews**, **Meta's audience-size estimate**, **conversion reporting health with its test event**, and the whole **managed-resource** surface as one generic table with the same four verbs on every row — which is seventeen list-shaped objects reaching a screen through one panel rather than seventeen hand-written ones. **The last six followed, and they are the ones that had no obvious shape as a table.** **TikTok ad review** hangs off the ad row itself rather than sitting in a card, because both of its reads need an ad id and there is no list behind them — so the policy reason, the offending frame and the appeal are reached from the row of the ad that was refused. **TikTok comments** is a triage screen that owns its own date window, since TikTok caps the span at about 30 days and lists comments one ad group at a time. **Creatives** is a builder (Snapchat's four formats, Apple's Custom Product Page ads) rather than a table, and it never opens by itself, because neither network publishes a creative LIST or a delete for what it makes. Its one GET (`?resource=interaction_zones`) lists Snapchat's reusable zones, so a half-built collection is recovered by reusing the tiles that landed instead of uploading four more — a recovery path, not a table of creatives. **Google experiments and drafts** is a state machine whose load-bearing field, `asyncErrors`, only exists on the single-item read, so the list never claims health. **Keyword ideas** carries both halves — Apple's bid recommendations for keywords that exist, Google's ideas for ones that do not. And **Apple's impression share** is on screen although Apple's own gateway has refused the retrieval half since 2026-03-16: an owner who can read "Apple is refusing this report" is better served than one looking at a blank card, so the refusal is rendered in Apple's words and the queue half still works. Each appears only where the network really answers, read off the same declared leaf the endpoint answers from rather than a second opinion: Apple gets no billing card and no previews card, because Apple publishes no billing API and its ad IS an App Store product page, and TikTok gets no previews card while its preview blocker is unverified. A network that gains one of these gains the screen with no edit on either side. The six late arrivals are gated on the ads capability rather than on ownership, matching the API exactly — a co-founder the owner switched ads access on for already calls all six through the session bridge, so an owner-only panel over a team-reachable endpoint would be the same divergence pointing the other way. **THOSE ELEVEN ARE THE PANELS THAT WERE ADDED, NOT THE WHOLE OF WHAT IS ON SCREEN**, and the distinction matters because this paragraph used to end by claiming everything else was API-only. It was not. The campaign tree, the launcher, dimensional reporting (`…/ads/breakdowns`), the lead reader (`…/ads/leads`) and the advanced field panel over `…/ads/objects` were all on the ad dashboard before any of the eleven, and the seventeen managed resources arrive through the generic table above. **Every capability named in this Operations section is reachable from the dashboard as well as from a key** — ad review, ad strength, change history, experiments and drafts, comments and blocked words, billing, impression share, labels and campaign groups, budget schedules — and the six above were the last of those endpoints the product could not do. **THE LAUNCHER GAINED FIVE CONTROLS THAT WERE API-ONLY, so five sentences that used to be true about it are not any more.** Apple's `ad.productPageId` (required on the Today tab, offered on the other three placements, picked from the `product_page` lookup); Meta's `specialAdCategoryCountry` (chips over the targeted countries, shown only once a regulated category is declared); Meta's `contextualMultiAds` on the creative step, defaulting to UNSET rather than to OPT_OUT; Snapchat's `ageMin`, `ageMax`, `gender` and `endDate`, so the wizard CAN narrow by age and set a stop date; and Meta's **creative fan-out**, which adds up to four more ads to the launch's single ad group, each with its own image or video and optional primary text and headline that inherit the base ad's when left blank. The fan-out writes `fanOut.extraAds`, the same key `POST …/ads/campaigns` already accepted, so this is one wire shape with two doors rather than a second implementation. **`fanOut.extraAdGroups`, the second-ad-group half, is still API-only** and should keep being described that way: a second ad group is a second budget and a second audience, which is a question the dialog does not ask anywhere. That is the honest shape of the parity claim — it holds capability by capability, and below that the wizard is not a field-for-field cover of the launch body. **THE DASHBOARD IS ALSO NARROWER THAN THE API IN ONE PLACE, ON PURPOSE.** A Google campaign whose `campaign_budget` is shared with other campaigns renders its daily-budget cell READ ONLY, showing the same refusal sentence the API answers, so any claim that the ads dashboard lets an owner edit any campaign's daily budget is false for Google. Give the campaign a budget of its own, or change the shared budget in Google Ads. The API answers `409 CONFLICT` for the same edit, and the tree it reads already carried `budgetExplicitlyShared` and `budgetCampaignCount` on every Google campaign node — `budgetCampaignCount > 1` is what says `dailyBudgetCents` is a number this campaign SHARES rather than one it owns. **THE APPLE SUNSET IS NOW IN THE PRODUCT, NOT ONLY IN THE CODE.** Apple Ads Campaign Management API v5 stops answering on **2027-01-26**. The Apple connection settings dialog shows a countdown inside the last 180 days and says plainly, once the date has passed, that every Apple call is failing. **No migration date is published anywhere**, and that is deliberate: the successor still carries a blocker and the flip has not happened. **ONE ADS CAPABILITY IS STILL API-ONLY AND IT IS NAMED HERE RATHER THAN LEFT FOR SOMEBODY TO DISCOVER:** the catalog WRITES. `POST …/ads/catalogs` (create a TikTok or Snapchat product catalog, or link a Google Merchant Center account), `DELETE …/ads/catalogs` and answering a Merchant Center link invitation have no dashboard control at all. The READ half is on screen — the campaign wizard lists catalogues and product sets through `…/ads/lookups` and writes the chosen id onto an ad group — and when the list comes back empty the wizard tells the owner, in the product, to create one from this API. That is a deliberate stopping point rather than a gap waiting to be filled: a catalog is a container for a product FEED, and nothing in Affiliateo uploads, validates or reads a feed. On Google that is the Merchant API, a separate product with its own OAuth scope and no connection here. A dashboard button that made an empty catalog nobody could then fill would be a worse answer than the sentence. The other splits are not ads reads at all and they are deliberate too: funding an app, ending one and connecting a payment provider stay dashboard actions, and the OAuth consent an ad network demands can only ever be a hosted link. ### Is the conversion reporting actually working `GET /api/v1/businesses/{slug}/ads/conversions` answers the one question no other endpoint here asks. Everything else reports on campaigns; this reports on whether the sales this product records are reaching the ad networks it reports them to. All four server-side networks answer (Meta, TikTok, Snapchat, Google), or name one with `?network=`. **Read `reporting` and `evidence` as different claims, because they are.** `reporting: "on"` means the setup looks healthy on our side — a pixel is configured, a credential exists, nothing is blocked. It is NOT a statement that events land. `evidence: "network_reported"` is the only field carrying an answer from the network itself, and where it is present it covers every source firing that pixel rather than only ours. Treating the first as the second is the unfalsifiable green tick this endpoint exists to remove. Also on every row: `connected`, `destination`, `blocked_reason`, `last_event_at`, `match_without_click_id` and `notes`. **`POST` fires one real test event, which is how a human proves it rather than trusting it.** Meta and TikTok both have a test stream keyed by a code from their own dashboard: `{ network, testEventCode, url? }` sends an event carrying that code, it appears in their live feed within seconds, and it is NOT counted as a conversion — so the round trip is watchable against a production pixel without inventing a sale. `network: "snapchat"` has no such stream but has something better for this question, an endpoint that validates the credential and ingests nothing, which is also the one call that settles whether Snapchat accepts the token at all. **Google answers no on purpose**: its conversion upload has no test facility (an upload IS the test) and the account is not allowlisted to make one, so a cheerful success there would be exactly the thing this endpoint removes. **`PATCH` carries two settings, both per-network and both off by default.** `matchWithoutClickId` reports sales that carry no click id but do carry a hashed buyer email; it stays off until switched on, because sending a network a purchase it never touched is a decision about customer data and belongs to the owner, and it is refused for Google, whose upload identifies the row BY the click id. `capiToken` is Snapchat-only: the long-lived token an Organization Admin mints by hand, which a multi-tenant OAuth flow can never obtain, and supplying it also clears any standing auth block. Both need a read & write key. ### The ads platform fee Campaigns **created OR MANAGED through Affiliateo** (dashboard, API, or an AI agent) carry a 1% platform fee on their spend, metered from the business wallet as spend syncs. MANAGED means any successful write on the campaign or on anything under it — budgets, bids, targeting, creative, and **a pause or a resume too**. There is no exempt edit: a paused campaign spends nothing and 1% of nothing is nothing, so the only thing an exemption would buy is an argument about which of ~390 editable fields across five networks counts as real management. A write starts that campaign's meter **from that day forward and never retroactively** — a campaign already metered keeps its original, earlier start date. CREATING a child object counts the same way: adding an ad set, ad group, ad, keyword or criterion inside a campaign built in the network's own manager meters **that campaign**, from that day. The child is never metered separately, because its spend already rolls up. An account inside its **partner-link fee trial** pays nothing here either, for the first 7 days after signup. The trial is applied per spend DAY rather than at the moment the meter runs, so spend from a day inside the window is written free and stays free even though ad fees settle about two days behind it. See "The partner-link fee trial" above. Connecting an ad account and only READING it is **free**: stats, campaign trees, audiences and every other GET cost nothing, and a campaign nobody has ever created or written to through Affiliateo is never metered and never touched. Unpaid fees pause the ads endpoints with `PAYMENT_REQUIRED` past a grace allowance. Past the account's campaign-pause limit (starts at $50, rises to $1,000 with clean payment history) the metered campaigns are paused on the networks until the balance is back to zero. The answer to a 402 is `POST /api/v1/me/topup-link`. While any billed fee is unpaid, two writes are refused with `PAYMENT_REQUIRED` however small the balance: switching a metered campaign back to ACTIVE, and CREATING a campaign that starts ACTIVE. Creating one **paused** is never blocked — it spends nothing, so it owes nothing — and it can be switched on the moment the balance reaches zero. ### What is gated Connecting a network is an OAuth consent and **cannot be an API call**. Mint a hosted link with `POST /ads/connect-link` and hand it to the business owner. Funding an app, ending it, and connecting a payment provider are likewise dashboard-only. ### TikTok audiences, catalogs, promoted apps, rules and custom conversions Five TikTok capabilities beyond campaign management. Each rides a TikTok permission, and TikTok has now **granted** every one of them — verified against a live account on 2026-08-02, where audiences, saved audiences, rules and apps all answered with real (empty) lists rather than refusals. Custom conversions never needed a new permission: they ride Pixel Management, granted 2026-07-15. Nothing is switched off at our end and there is no flag to set. **If one of these still refuses you**, the account is fine and the TOKEN is stale: OAuth permissions are fixed into an access token at consent, so a business that connected TikTok BEFORE the approval must reconnect once before those calls succeed. The refusal comes back as `UPSTREAM_ERROR` carrying TikTok's own message with the permission named in it, which is the signal to tell the owner to reconnect rather than to retry with different arguments. - **Audiences** (permission: Audience Management). Reach for this to retarget people a signal source already saw, or to expand from them. Two objects, not one: a CUSTOM audience is a set of people (rule-based, built from your own pixel, app, engagement, lead-gen, shop or offline signals, or a lookalike seeded from another audience), while a SAVED audience is a whole reusable targeting set (locations, ages, interests, plus which custom audiences to include and exclude) and is what an ad group's `saved_audience_id` points at. Saved audiences ride a `?type=saved` discriminator on every verb and answer under their own key, because the two id spaces are indistinguishable and a delete aimed at the wrong one is destructive. Customer-list uploads of hashed emails and phone numbers are deliberately not offered here, exactly as on Meta: rule-based audiences do the same jobs without buyer PII flowing through us. The one field pair to get right is retention: `lookbackDays` decides who is IN the audience, `expireInDays` decides when the audience itself dies, and swapping them succeeds upstream while building a different audience. - **Product catalogs** (permission: DPA Catalog Management). Reach for this when the ad should show products rather than one creative. A catalog holds products, a product set is a rule-defined slice of one, and those supply the `catalog_id`, `product_set_id`, `sku_ids` and `item_group_ids` fields the write surface declares. Catalogs are **Business Center scoped, not ad-account scoped**: they take `?bcId=` and the `?account=` selector every sibling route honours is refused rather than ignored. Products arrive by giving TikTok a feed URL it fetches itself, so a successful upload means queued, not imported, and the upload log holds the per-row verdicts. Two TikTok asymmetries: a catalog's currency and region can never be changed after creation, and product sets cannot be created through the API at all (make them in Business Center, then read the id back). A catalogue is not just an inventory here — it is what TikTok's `product_sales` objective launches out of, so this is the read that unblocks a catalogue campaign. **The same path serves two other networks on two other scopes**, which is the thing to know before reaching for `?bcId=`: `?network=snapchat` lists and creates catalogues on the ORGANIZATION (which comes from the connection, so there is no scope parameter at all, and `currency`/`regionCode` are refused by name because a Snapchat catalogue has neither — currency belongs to each product feed); `?network=google` is not a catalogue but the Merchant Center **LINK**, which hangs off the ad ACCOUNT, so `?account=` is the right selector there, and `&resource=invitations` is where a link started from the Merchant Center side sits waiting to be accepted. Meta answers 501: `catalog_management` was not granted. PRODUCT SETS EXIST ON TIKTOK ONLY — Snapchat publishes no endpoint that lists one anywhere in its Marketing API, which is exactly why it can list catalogues and still not launch a product-sales campaign. - **Promoted apps** (permission: App Management). Reach for this to build an app-install or app-retargeting campaign: it is the only source of the `app_id` field, and a second read returns the optimisation events one app may legally bid on for one goal, which is what an ad group's `optimization_event` takes. Read-only on purpose, because TikTok's app API has no delete and a registration's tracking URLs are third-party measurement postbacks. Note the word collision: a promoted app is a mobile app on TikTok, NOT an Affiliateo app (an affiliate program), and they live under different paths. - **Automated rules** (permission: Automated Rules). Reach for this to leave standing automation behind, since a rule runs on TikTok's schedule long after the agent that made it is gone. Same idea as Meta's rules, different object: conditions, actions, apply objects, notification and schedule are all required. Three things bite. Money thresholds come in a cents spelling and a dollars spelling and sending both is refused rather than guessed. An apply-objects entry with no ids means EVERY object at that level in the account, so it needs an explicit confirmation flag. And TikTok's update is a FULL REPLACE while Meta's is a partial, on the same verb, so a Meta-shaped patch aimed at TikTok would strip the rule and return success; read, merge, then send the whole rule. TikTok publishes no rule delete and no enable/disable at all, so narrowing the apply-objects list is how a rule is pulled back. A results feed is available to audit what the rules actually did. - **Custom conversions** (permission: Pixel Management, ALREADY GRANTED, so this one works today. TikTok's portal also lists a "Custom Conversion Management" permission; it guards none of these endpoints). Reach for this to teach TikTok to count something specific, then optimise toward it. **The word names a different object on each network**: on Meta it is an account-level object watching pixel traffic for a URL match, while what this endpoint manages on TikTok is an event rule living ON a pixel, so every TikTok call takes a pixel id and deleting one changes that pixel. Corrected 2026-08-05 after a live call: this used to say TikTok has no custom conversion object at all, and it does — a separate first-class family at `/custom_conversion/` keyed by custom_conversion_id and scoped by an event source that may be an APP or a PIXEL. This endpoint does not wrap that family; a pixel event rule is a real object but a different one. That family is READABLE through the managed-resource surface instead (`GET …/ads/resources?resource=custom_conversion&network=tiktok` with `eventSourceId` and `eventSourceType=PIXEL|APP`), and the same ids answer as an id LOOKUP at `GET …/ads/lookups?network=tiktok&type=custom_conversion&eventSourceId=…`, which is what fills the Smart+ ad group's `custom_conversion_id`. Its writes are wrapped nowhere on purpose: the endpoints exist, no request body for them has ever been observed, and TikTok answers an unrecognised field with silence rather than an error. TikTok also runs two event vocabularies and only one of them works here: the optimisation vocabulary (SHOPPING) rather than the Events API vocabulary (Purchase). An unrecognised name is not an error upstream, it is filed as a custom event and returned as success, leaving a conversion no ad group can bid on, so it is refused before the call. A firing-stats read answers whether the conversion ever actually fires, which is otherwise indistinguishable from working until the campaign underdelivers. The same operations appear under the pixel path as a sub-resource, since that is where they physically live on TikTok's side. One TikTok write nearby is NOT gated: renaming a pixel rides Pixel Management, which TikTok granted already, so it works today. ## The API Base URL `https://affiliateo.com`. Documentation at https://affiliateo.com/docs/api, OpenAPI 3.1 at https://affiliateo.com/openapi.json, machine-readable index at https://affiliateo.com/api/v1. ### Authentication `Authorization: Bearer afk_…` Generate keys in the dashboard: any business dashboard → API tab. Keys are account-level and cover every business you own. Keys come in read-only and read-write levels; every write endpoint says so in its description. ### Rate limits 600 requests / minute by default (per-key overrides available for large integrations) Expensive endpoints (launch, upload, sync) have tighter additional budgets. 429 responses carry Retry-After. ### Errors Envelope: `{ "error": { "code", "message" } }` Codes: `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` ### Endpoints #### Businesses and apps Read the account, then create and edit the apps on it. - `GET /api/v1/businesses` Businesses this key can act on, with ad-network connection status and every app’s id/slug/type/status (source of the {appId} below). - `GET /api/v1/businesses/{slug}/apps` Every app on the business (every type, any status): id, slug, name, type, status, visibility. Types: affiliate_app (commission on sales), clipping, web_traffic, collab. The id/slug source for create/edit flows. A thin index only: it carries no money fields, so read the single-app endpoint below for a budget app’s budget_cents/spent_cents/payable_capacity_cents. - `POST /api/v1/businesses/{slug}/apps` Create an app — same validation and clamps as the dashboard wizard (percent commission ≤90, ≤70 store-mediated mobile, flat ≤$50, 1% floor; the name becomes the globally unique URL slug). JSON body with the wizard’s field names, or multipart with thumbnail / collab_media_0..4 file parts; JSON callers pass media as public https URLs (thumbnail_url, media_urls — fetched server-side, SSRF-guarded, magic-byte validated; placement collabs require ≥1 creative). clipping/web_traffic/collab are born pending_budget (owner funds in the dashboard, $100 min; funding is free, because a budget app’s 1% platform fee is charged per payout out of the same budget instead — a $100 payout costs the budget $101, so size a budget as intended payouts times 1.05); affiliate apps (Stripe only via API on the web rail) are born pending the provider connection (browser OAuth) — the response’s next_step carries the dashboard URL to finish either. content_type for an affiliate program is "affiliate_app" (one name, sent and read back), and you set its rails with provider + website_url (web) and/or mobile_provider + a store URL (mobile); either can be added later, and sending neither is a 400. One app, both rails, one device-routed referral link — there is no separate “mobile affiliate” and “web affiliate” to create. coupon_codes_enabled + coupon_discount_percent are settable here too but only land once the STRIPE web rail is CONNECTED, which this endpoint cannot do (browser OAuth): the usual shape is create the app, have the owner connect in the dashboard via next_step, then PATCH the switch on. Mobile apps return their webhook secret once. Requires a read_write key. - `GET /api/v1/businesses/{slug}/apps/{appId}` App info for the embedded-signup pitch screen: commission rate/type (with per-product rates, prices, and ≈commission-per-sale estimates for the app’s visible products), affiliate count, total paid out, status, and whether joining requires an application. For web_traffic apps commission is null and a click_payout object carries the per-click rate (cpc_cents), budget, and remaining budget instead (exactly one of commission / click_payout is non-null). Also carries a full owner-facing settings object (the key holder is the owner) for read-modify-write edit flows. On budget apps (clipping, web_traffic, collab) that settings object carries payable_capacity_cents next to budget_cents/spent_cents: funding is free and the 1% platform fee is charged per payout out of the same budget, so budget_cents is GROSS and remaining budget alone overstates what a creator can be offered. payable_capacity_cents is the largest single payout the remainder still covers including its fee, and a payout the remainder cannot cover with its fee is refused outright rather than part-paid. Quote that number, not budget minus spent. - `PATCH /api/v1/businesses/{slug}/apps/{appId}` Edit an app’s settings — same fields and clamps as the dashboard edit dialog: name (re-derives the slug), description, category, requirements, faqs, visibility (public/private/private_only/invite_only), commission rate/type (notifies affiliates; new customers only), per-product rates, affiliate discount codes (coupon_codes_enabled + coupon_discount_percent — every affiliate gets a Stripe code made from their username, so a buyer who types it at checkout is credited to them with no link click; needs the Stripe web rail already connected AND allow_promotion_codes: true on the merchant’s own Checkout session, which we can neither set nor detect — without it the codes exist and no buyer can type one; turning it OFF hides codes from affiliates without deactivating them in Stripe, so a code already said on a podcast keeps working), URLs, platforms, rewards, submission window, retention, placements, plus media (thumbnail_url / remove_thumbnail / media_urls replace-all, or multipart file parts). Locked with explicit errors: content_type, provider, status, budget — funding, ending, and provider connects stay dashboard actions. {appId} = app UUID or slug. Requires a read_write key. - `GET /api/v1/businesses/{slug}/apps/{appId}/summary` The app’s affiliate program at a glance: affiliate counts, lifetime totals (clicks, conversions, renewals, revenue, commission) and payout buckets (pending/payable/paid/refunded) summed across every affiliate; for web_traffic apps these are per-click earnings (their conversion money stays zero). ?from=&to= (YYYY-MM-DD, UTC days, inclusive) adds the same numbers for that window (net money, Stripe convention); &include=daily adds a per-day series for charting (window ≤366 days). Owner tracking links and organic sales excluded, so it reconciles with the affiliates list. #### App affiliates People promoting one app. Embedded signup, their stats and conversions, and the hosted links that get them paid-ready, cashed out, or embedded in your own UI. - `POST /api/v1/businesses/{slug}/apps/{appId}/affiliates` Embedded affiliate signup (the "Become an affiliate" button): { email, name? } finds-or-creates the Affiliateo account, joins the business, enrolls the affiliate, and returns referral links (short + username + merchant-domain direct for apps with a website or landing page, plus the affiliate’s COUPON code when the app has Stripe discount codes switched on — that one is a code the buyer types at checkout, not a URL, so it is not QR-able), stats, a verified flag (identity check passed), and a payout readiness object ({ identity_verified, payouts_enabled }, webhook-fresh with no Stripe call: gate your Verify / Cash out buttons on it) in one payload (web_traffic apps also return per-click payout buckets + a click_payout object). Safely repeatable: an existing affiliate returns 200 with current links + stats, and one who previously left the business is reactivated. Users banned from the business are rejected with FORBIDDEN. {appId} = app UUID or app slug. Requires a read_write key. - `GET /api/v1/businesses/{slug}/apps/{appId}/affiliates?email=…` One affiliate by ?email= or ?ref_code=: links, lifetime stats (clicks, conversions, renewals, revenue, commission), payout buckets (pending/payable/paid/refunded cents), and the payout readiness object ({ identity_verified, payouts_enabled }) for gating Verify / Cash out buttons (also on every list row). For web_traffic the buckets are per-click (paid = approved clicks × CPC, payable = unpaid qualified clicks × CPC; pending/refunded always 0) and a click_payout object adds the CPC + approved/payable click counts. Add &include=breakdown for click sources + countries (last 90 days), or &from=YYYY-MM-DD&to=YYYY-MM-DD for windowed click counts. Without email/ref_code: lists the app’s affiliates, cursor-paginated (?limit=, ?starting_after={id}, has_more in the response; &include=payouts for per-row buckets). Every row carries personal_commission — the affiliate’s owner-negotiated deal ({ type, rate }) or null when they ride the program rates; deals apply to NEW customers only (already-referred customers keep their stamped rate). - `GET /api/v1/businesses/{slug}/apps/{appId}/affiliates/conversions` Conversion history, cursor-paginated (?limit=, ?starting_after={id}): date, type (subscription, one_time, renewal, trial, refund, chargeback), gross amount, commission, paid/unpaid status, and the earning affiliate’s ref_code per row. ?email= or ?ref_code= scopes to one affiliate; without either it’s the app-wide feed (owner links + organic sales excluded). ?from=&to= (YYYY-MM-DD) narrows either mode to a date window, and ?type= to one or more conversion types, comma-separated (?type=refund,chargeback for reversals only; ?type=subscription,one_time for first sales). Both filter in the query, upstream of the cursor, so a filtered feed pages through matching rows only. That is why a Type filter belongs here and not in your own code: filtering the page you already hold cannot find a refund sitting 300 rows down. Unknown type values are a 400, never a silently empty page. Refunds/chargebacks are separate negative rows. No customer PII. - `POST /api/v1/businesses/{slug}/apps/{appId}/affiliates/verification-link` Mint a short-lived hosted link that gets an affiliate PAID-READY (the "Verify" button): { email, return_url, name?, lang? } → { url, expires_in, verified }. The affiliate opens url on our domain, first confirms a 6-digit code we email them (proves the link is used by the inbox owner, not the minter; /me self-minted links skip the code), then creates their Stripe payout account and passes identity verification (only whichever step they still need: already-done steps are skipped, already-ready affiliates bounce straight back, and the link can only fill in what Stripe still asks for, never read or rewrite existing details), then is redirected to return_url (an https page, or a mobile deep link like myapp://verified) with ?affiliateo_status=complete appended, ?affiliateo_status=pending when a step can only finish on the Affiliateo dashboard, or ?affiliateo_status=expired when the link had already expired (mint a fresh one and retry). Sensitive data (SSN, ID photos, selfie) is collected ONLY on our domain and goes straight to Stripe/AWS, never through your servers, and we store only pass/fail. Same enrollment gates as the affiliates endpoint (banned → ACCOUNT_INELIGIBLE / BANNED_FROM_BUSINESS). Pair with the affiliate.verified webhook or the verified field on the affiliate endpoints. v1 is individuals only (companies onboard in the dashboard). Requires a read_write key. - `POST /api/v1/businesses/{slug}/apps/{appId}/affiliates/withdrawal-link` Mint a short-lived hosted "cash out" link for an EXISTING affiliate of this app: { email, return_url, lang? } → { url, expires_in }. The affiliate opens url on our domain, first confirms a 6-digit code we email them (proves the inbox owner is using it, not the minter; an active Affiliateo login as that same affiliate skips the code; accounts with 2FA enabled must sign in instead of using a code, so the emailed code alone never moves their money), then adds/picks a bank and withdraws their own wallet balance ($20 minimum; standard payout, or instant within minutes when the account is eligible) to it, then is redirected to return_url with ?affiliateo_status=complete (=pending if they left without withdrawing, =expired if the link had already expired: mint a fresh one and retry). The cashable amount is the affiliate’s account-wide withdrawable wallet balance (one wallet per account), not any single app’s payable; the link shows THIS app’s pending/payable/paid as context only. Never creates or enrolls anyone (the email must already be an affiliate here, else NOT_FOUND), and the affiliate must be paid-ready (identity verified) first. Requires a read_write key. - `POST /api/v1/businesses/{slug}/apps/{appId}/affiliates/free-access` Give an EXISTING affiliate of this app complimentary access to your paid app, or take it back: { email | ref_code, enabled?, entitlement_id?, revenuecat_email? } → { enabled, pending?, reason? }. RevenueCat apps ONLY (free_access is null on every other affiliate payload, and this returns not_revenuecat elsewhere) — it grants a promotional entitlement in YOUR OWN RevenueCat project, so the affiliate sees the paid app without paying and you keep the receipt. email/ref_code identifies WHO; revenuecat_email is a different thing and the one people get wrong: it overrides WHICH RevenueCat customer receives the grant, i.e. the address that person uses INSIDE your app, which is frequently not the address they signed up to Affiliateo with. Matching is BY EMAIL ONLY and never by who referred whom — the people attributed to an affiliate are the customers they REFERRED, so treating one of those as the affiliate would comp a stranger. Your app therefore has to send emails to RevenueCat (Purchases.setEmail / the $email attribute) or nothing can be matched; affiliates who used Sign in with Apple with Hide My Email cannot be resolved at all. When no customer carries the address the call still succeeds with pending: true — the switch is on and the grant lands as soon as one appears, so treat pending as "waiting", not as failure. Sending revenuecat_email or entitlement_id ALONE (no enabled) just records them with no RevenueCat call, which is how you set the right address before switching on rather than granting to the wrong customer and correcting it; changing either while a grant is live MOVES it (the old one is revoked first). enabled: false always works, even on a paused, wound-down or disconnected app. Refused for your own tracking link, for non-active affiliates, and for apps that are ending. OWNER ONLY — unlike the /ads endpoints a team member’s key cannot reach this, because it spends the owner’s own revenue. Read the current state from free_access on any affiliate payload (enabled, pending, entitlement_id, revenuecat_email, granted_at, expires_at). Requires a read_write key. - `POST /api/v1/businesses/{slug}/apps/{appId}/affiliates/embed-session` Mint a session for EMBEDDED ELEMENTS — drop-in UI that mounts inside your own page, so the affiliate never leaves it. NOT an either/or with this API: the two are designed to MIX. Build the screens you want control over from the REST endpoints and mount only the components you would rather not build (bank collection and ID capture being the usual two). A session carries ONLY the components you name, so asking for one is normal and correct — e.g. your own links + balance screens from GET /affiliates?email=, plus components: ["withdraw"] and nothing else. WHICH TO USE PER SCREEN: every element is its own page load (an iframe on web, a WebView on mobile), so a tab stacking four pays four before it paints and your app cannot cache any of them. Anything this API already returns is faster built yourself and can be cached so the screen paints on open. We recommend building link, qr, stats, products, activity and balance from GET /affiliates?email= + GET /apps/{appId} + GET /affiliates/conversions, and mounting elements only for withdraw, payouts and identity — the three that collect bank details, expose the account-wide wallet, or run ID capture. Lists are the sharpest case: the embedded activity and payouts elements show the 25 most recent with NO pagination and NO filtering, so an affiliate with a year of sales cannot reach the 26th and cannot ask for their refunds, while GET /affiliates/conversions is cursor-paginated (?limit=, ?starting_after=) and filterable by date (?from=&to=) and by type (?type=refund,chargeback), so the same screen can carry a Type filter that searches their whole history rather than the rows already on screen. Copy, and where to get it: this API returns DATA, never UI text (lang is a mint parameter for elements only; no read endpoint takes it or returns a label), so an API-built screen needs its own words but NOT its own translations. The exact strings the elements use are published at https://affiliateo.com/locales/{lang}.json in all 16 languages: lift embed.appEarnings.* for the buckets and their hint line, embed.activity.* for row types and empty states, embed.filter.preset.* for the date chips, embed.link.format* for the picker. Copy the values into your own catalogue at build time rather than fetching live, since those files are the elements runtime asset and not a versioned API. Keep the money words verbatim even if you reword the rest: Pending is earned but not yet released, Payable is released and ready to pay out, and Paid means already in their Affiliateo balance and not yet in their bank, so "Paid out" is the wording to avoid. Product NAMES are never translated on either path (they are whatever you typed), and the tab bar is yours: no endpoint describes navigation. The numbers agree either way, because the balance element renders the same pending/payable/paid that response already returns. The ONE figure you cannot build yourself is the affiliate’s wallet total (it spans every program they are in, so no owner-side endpoint returns it); it exists only inside the gated withdraw/payouts components and on their own /me/wallet with their own key: { email, components, allowed_origins, appearance?, lang? } → { client_secret, expires_in, components, requires_step_up, element_urls, allowed_origins, ignored_appearance_keys? }. Call from your BACKEND (the afk_ key must never ship in an app binary), hand client_secret to the frontend, and our JS SDK mounts each component as an iframe on your own page; or drop an element_urls entry straight into an