API reference

Read ad stats and manage campaigns programmatically. Built for scripts and AI agents.

The Affiliateo API lets scripts and AI agents (Claude, Codex, n8n, anything that speaks HTTP) manage affiliates and ad campaigns for every business you own. The Affiliates endpoints power embedded affiliate signup: a "Become an affiliate" button in your own app that enrolls users by email and fetches their referral links and stats.

On the ads side, all five networks launch whole campaign stacks through one endpoint, always paused, rolled back rather than left half-built. What differs is the vocabulary, because the networks do not agree on what a campaign objective even is: Meta takes six objectives (plus Advantage+ on two of them), TikTok takes six of its own, Snapchat takes seven under the name goal, Google has no objective field at all and picks a campaign type instead (all six its API can create), and Apple Search Ads has none either — an Apple campaign is an app, one of four placements, and a bidding strategy. Each network's section below lists exactly what it serves and, just as importantly, what it does not and who owns that refusal. Google's endpoints are live but answer 403 FORBIDDEN until our Google Ads API access is approved.

Beyond launching: pause/resume, re-budget and re-bid everywhere, plus keywords and research (Apple, Google), automated rules (Meta, TikTok), custom audiences and lookalikes (Meta, TikTok, Google and Snapchat — and a campaign can carry the ids at create, which is the only moment that matters), product catalogs (TikTok and Snapchat, plus Google's Merchant Center link) and promoted apps (TikTok), custom conversions and pixels, server-side conversion health with a test event to prove it, ad previews and delivery estimates (Meta), and a field-write surface that reaches every catalogued field on every object of all five (see Full field writes).

GET /api/v1 returns a machine-readable index of every endpoint: point an agent at it and it discovers the rest itself. An OpenAPI 3.1 description lives at /openapi.json for generating typed clients (openapi-generator, openapi-typescript). For a wider map of the platform (docs, apps, API, MCP server) point it at llms.txt instead, or at llms-full.txt for the whole reference in a single fetch.

Base URL
https://affiliateo.com/api/v1

Authentication

Generate a key in any business dashboard under the API tab. Keys are account-level: one key works for every business you own. The key is shown once at creation, so store it securely and pass it on every request.

Read & write keys can launch campaigns, pause/resume, change budgets, and upload creatives. Read only keys can fetch stats and structure but never change anything: hand those to agents you don't want touching spend. Keys support optional expiry and instant revocation from the API tab.

Authenticated request
curl "https://affiliateo.com/api/v1/businesses" \
  --header 'Authorization: Bearer afk_...'

Errors & rate limits

Every error uses one envelope with a stable code you can branch on.

Each key gets 600 requests/minute by default; higher per-key limits are available for large integrations. Expensive endpoints add tighter budgets on top: launch 10/min, entity updates and pixel 30/min, uploads 10/min, sync 4/min. 429 responses include a Retry-After header. For updates, prefer webhooks over polling.

Error codes

UNAUTHORIZED401

Missing, malformed, expired, or revoked API key

FORBIDDEN403

Key is read-only, or pinned to a different business

ACCOUNT_INELIGIBLE403

The email’s account can’t be an affiliate (closed or platform-ineligible) — kept vague on purpose

BANNED_FROM_BUSINESS403

The email belongs to someone you banned from this business

NOT_FOUND404

Business slug does not exist or is not yours

VALIDATION_ERROR400

Bad parameter or request body; the message says which

NOT_CONNECTED409

The ad network is not connected with the required access

CONFLICT409

The request is well formed and your key is allowed, but the state of the ad account makes the write wrong to perform. Distinct from VALIDATION_ERROR (nothing to fix in the body), from FORBIDDEN (permission is fine) and above all from UPSTREAM_ERROR (no network was asked, so retrying will never succeed). Today it is raised in one place: PATCH …/ads/entities/{id}?network=google editing dailyBudgetCents on a campaign whose Google budget is shared

PAYMENT_REQUIRED402

Unpaid ads platform fees: top up the wallet to resume the ads endpoints

RATE_LIMIT_EXCEEDED429

Budget exhausted; retry after Retry-After seconds

UPSTREAM_ERROR502

The ad network rejected the operation; its message passes through

NOT_IMPLEMENTED501

The thing you asked for is REAL and is not built, or the network blocks it — and the message says which, plus who can change it. Never an empty result: an empty list means the network was asked and had none. This is what a declared-but-unbuilt lookup, breakdown or managed-resource operation answers

INTERNAL_ERROR500

Something broke on our side

Error envelope
{
  "error": {
    "code": "NOT_CONNECTED",
    "message": "Meta is not connected for this business"
  }
}

MCP server

Connect AI tools (Claude Code, Claude Desktop, Cursor, or any MCP client) straight to your account: add the server with your API key and the agent gets typed tools for the most common operations, with no HTTP code to write. The same key rules apply: a read only key makes the write tools fail with FORBIDDEN, every call counts against the key's rate limit, and everything shows up in the key's usage log.

The transport is Streamable HTTP (stateless, plain JSON responses), with the key passed as a header. The named tools cover the everyday operations; the call_api tool reaches every other endpoint in this reference — ad groups below a launched campaign, keywords, automated rules, audiences, catalogs, webhooks — so nothing is out of an agent's reach. That includes uploads, which used to be the exception: POST …/ads/media and the app-media fields all take a public https URL that we fetch server-side, so an agent never needs to send a file part.

Campaign creation is a named tool, not an escape-hatch call. launch_ad_campaign builds a whole stack on any of the five networks and is the only place the per-network objective vocabulary is written down for an agent — it is objective on Meta and TikTok (which do not accept the same set: Meta spells its conversion objective sales and TikTok spells it conversions), goal on Snapchat, the campaign type on Google, and a placement plus a bidding strategy on Apple. Meta, TikTok and Snapchat read an unrecognised value as traffic rather than refusing it, so a word borrowed from the wrong network launches the wrong campaign and answers 201.

Tools

list_businessesread

Every business the key can act on, ad-network connection status, and every app’s id/slug/type/status — the discovery call agents run first

get_appread

Commission rate/type, per-product rates, affiliate count, total paid out for one app — plus the full owner settings object for edit flows

get_app_summaryread

The whole program at a glance: totals + payout buckets across all affiliates, optional date window and per-day series

enroll_affiliatewrite

Embedded affiliate signup by email: find-or-create the account, enroll, return links + stats

get_affiliateread

One affiliate by email or ref_code: links, lifetime stats, payout buckets, optional click breakdown

list_affiliatesread

Cursor-paginated affiliate roster (emails never included)

list_affiliate_conversionsread

Conversion history with per-row ref_code: one affiliate’s, or the app-wide feed, optionally date-windowed

get_my_summaryread

The caller’s OWN affiliate program across all businesses they joined: totals, payout buckets, wallet balance, optional window + daily series

list_my_affiliationsread

Every app the caller is an affiliate of, across businesses: links, stats, commission; app+include_breakdown adds click sources (platforms) + countries

list_my_conversionsread

The caller’s own conversion history across all businesses, tagged with app + business, optionally by app and date

get_my_walletread

The caller’s account wallet: total balance + withdrawable, held, pending deposits, locked ad budget, disputed, and whether payouts are connected

list_my_transactionsread

The caller’s wallet ledger: every money movement (sale, deposit, withdrawal, refund, chargeback, transfer, commission, funding) with from/to, gross + fee, cursor-paginated

get_my_transactionread

One wallet transaction by id or short id: the normalized row plus, for the caller’s own deposits, the Stripe receipt + card/bank

get_business_affiliate_summaryread

Owner: the business’s storefront-affiliate program totals + payout buckets, optional date window

list_business_affiliatesread

Owner: the business’s storefront affiliates with links, visitors, sales, commission, payouts

list_business_affiliate_conversionsread

Owner: the business’s storefront-affiliate sales, tagged with product + affiliate ref_code

get_my_business_summaryread

The caller’s own storefront-affiliate program totals + payout buckets across businesses, optional date window

list_my_business_affiliationsread

The caller’s own storefront affiliations across businesses: links, visitors, sales, commission, payouts

list_my_business_conversionsread

The caller’s own storefront-affiliate sales across businesses, tagged with the business + product

get_ad_statsread

Spend, clicks, installs, revenue, ROAS by campaign/adgroup/ad for any network and date range. Flat daily totals: the day is the only segment, so anything dimensional is get_ad_breakdown

get_ad_breakdownread

Split one network’s spend and delivery by ONE dimension — age, gender, geo, device, platform, publisher, placement, hour, day of week, interest, language, network type, product, frequency, creative asset — over a date range, on all five networks. Call it with no dimension for the catalogue of what each network can answer. Live, never from stored stats, so bucket totals deliberately do not reconcile against get_ad_stats

get_ad_leadsread

The leads a TikTok Lead Generation campaign collected, and the Instant Forms themselves. No page_id lists the forms on the ad account, which is where an ad’s page_id comes from. Both LEAD reads require lead_source: INSTANT_FORM (with page_id) or DIRECT_MESSAGE (without one — leads collected in TikTok DMs, a second source entirely). There is no paging: TikTok ignores page and page_size here. resource="fields" returns the form’s questions plus its name and public URL. The "Lead Management" scope is granted

list_ad_lookupsread

The ids ad fields take, in one shape on all five networks: locations, interests, behaviours, languages, audiences, pixels, apps, catalogues, products, videos, images, creatives, conversion actions and more. This is where the values describe_ad_fields asks for come from — a field with a source names it as `lookup`. No type gives the catalogue

list_ad_campaignsread

The live campaign tree with statuses, budgets, and bids (Meta, Apple, Google, TikTok — manual and Smart+ in one list — and Snapchat). On Apple, ad-group and keyword detail is loaded for a WINDOW of 50 campaigns and the response’s truncation object says where it started and hands back the cursor for the next slice, so a big account is paged rather than cut off

preview_adread

Render an ad that already exists into something a human can look at. Meta returns embeddable HTML; Google returns a signed link and covers only Performance Max asset groups and YouTube ads, refusing responsive search and display; Snapchat previews the media file rather than the assembled ad; Apple needs none (the ad IS the App Store page) and TikTok’s endpoint is unconfirmed. A network that cannot answer returns its reason, not a blanket refusal. Nothing previews an ad that does not exist yet

launch_ad_campaignwrite

Build a whole campaign stack in one call on any of the five networks, always PAUSED and rolled back if a step fails. The tool that carries the objective vocabulary: it is `objective` on Meta and TikTok, `goal` on Snapchat, the campaign TYPE on Google and a placement plus a bidding strategy on Apple, and an unrecognised value reads as traffic rather than erroring

list_ad_audiencesread

The audience MANAGEMENT surface on Meta, TikTok, Google and Snapchat: size/coverage, status, and the create/rename/delete paths, with TikTok’s saved audiences (type: "saved") under their own key. Google builds a remarketing list (a rule over the page URL — urlContains is required, because Google has no rule meaning "everyone") and a lookalike segment (Demand Gen campaigns only). Snapchat builds four kinds of one object type — a pixel segment, an app segment, an ad-engagement segment and a lookalike — where the first two need at least one EVENT, because Snapchat has no "everyone the pixel saw" rule and no URL condition at all. Apple has no audience object of any kind. To FILL an audience_ids / excluded_audience_ids / saved_audience_id field, use list_ad_lookups

update_ad_entitywrite

Pause/resume, re-budget, set or clear a campaign spend cap, re-bid, or archive — the tool that starts real spend. Archiving is one-way on Meta, so it needs an explicit confirm_archive flag

describe_ad_fieldsread

Every field the network accepts per object type, with allowed values and whether each is settable at create, after create, or both. A field that takes an id names its source in `lookup`, which is a type for list_ad_lookups

write_ad_objectwrite

Create or edit one campaign/ad set/ad/creative using the network’s FULL field surface — targeting, bidding, placements, scheduling, Advantage+ creative. Unknown-to-us fields pass through via extra

sync_ad_statsread

Pull the freshest stats from the ad network right now (4/min)

get_ad_billingread

Ad account money state on three networks, which answer three different questions because they bill three different ways: Meta’s balance, lifetime spend, spend cap and payment method; Google’s billing setups, account budgets and (with invoice_year + invoice_month) invoices, since Google has no balance to report; and Snapchat’s funding sources and billing centres, which are organization-scoped and are the usual answer to "the campaigns look fine, why did the ads stop"

create_appwrite

Create an app of any type with the same validation as the dashboard wizard; media by https URL; returns next_step when the dashboard must finish (fund / connect provider)

update_appwrite

Edit an app’s settings (rates, requirements, visibility, media…) — content_type, provider, status, and budget stay locked

call_apiread/write

Escape hatch to any endpoint in this reference: ad groups below a launched campaign, keywords, rules, audiences, catalogs, webhooks — takes method + path + body. It is also the only way to reach the newest ads surfaces, which have no named tool: TikTok ad review and appeals, TikTok comment and blocked-word moderation, Google experiments and drafts, labels and campaign groups, change history, ad strength, Meta budget schedules, Apple impression share, the creative builder, and the creative-library read

MCP client config
{
  "mcpServers": {
    "affiliateo": {
      "url": "https://affiliateo.com/api/mcp",
      "headers": {
        "Authorization": "Bearer afk_your_api_key"
      }
    }
  }
}
Claude Code
claude mcp add --transport http affiliateo \
  https://affiliateo.com/api/mcp \
  --header "Authorization: Bearer afk_your_api_key"

List businesses

GET/api/v1/businesses

The discovery endpoint: every business the key can act on, with each ad network's connection status and every app's id, slug, type, and status (source of the {appId} used by the apps + affiliates endpoints). Call this first to find the slug used in all other paths.

List businesses
curl "https://affiliateo.com/api/v1/businesses" \
  --header 'Authorization: Bearer afk_...'
200
{
  "businesses": [{
    "slug": "acme",
    "name": "Acme Inc",
    "ad_connections": [{
      "network": "meta",
      "status": "connected",
      "account_name": "Acme Ads",
      "last_synced_at": "2026-07-10T02:31:07Z"
    }]
  }]
}

Apps (create & edit)

Create and edit the business's apps — the program types businesses run: clipping (pay creators per 1k views), affiliate (commission on sales from a website, a mobile app, or both), web_traffic (pay per qualified click), and collab (paid placements or content on creator channels). The API runs the exact same validation and clamps as the dashboard wizard, so it can't mint an app the wizard would refuse.

Affiliate is one type with two rails. Its content_type is affiliate_app — one name, sent and read back. It takes its rails purely from the providers you send, which is why sending it with neither provider is a 400. What the app can actually do is decided by the providers on it — provider plus website_url for the web rail, mobile_provider plus a store URL for the mobile rail — and either rail can be added later with a PATCH. See the Affiliate guide for how attribution and the commission ceilings differ between them.

Three things deliberately stay in the dashboard: funding (moves wallet money — clipping, web traffic, and collab apps are created pending_budget until the owner funds them, $100 minimum), ending an app, and connecting a payment provider (browser OAuth — web and mobile affiliate apps are created fully configured but pending the connection). When one of these is the next move, the create response carries a next_step object with the dashboard URL to finish there.

Budget maths on clipping, web traffic, and collab apps. Funding is free: every cent you fund lands in budget_cents. The 1% platform fee is charged per payout instead, out of that same budget, so a $100 payout to a creator costs the budget $101. That makes budget_cents gross: budget_cents minus spent_cents is the money still uncommitted (and the amount refunded in full if you end the app), while payable_capacity_cents is the largest single payout that remainder can still cover with its fee (derived on every read, never writable). Read it whenever the question is "how much can I still offer a creator". Nothing is ever charged on money that did not reach a creator.

List apps

GET/api/v1/businesses/{slug}/apps

Every app on the business — every type, any status — newest first. The id/slug source for the create/edit flows below; full settings live on the single-app GET.

List apps
curl "https://affiliateo.com/api/v1/businesses/acme/apps" \
  --header 'Authorization: Bearer afk_...'
200
{
  "apps": [{
    "id": "7d1e5a2c-9b3f-4c8d-a1e2-6f7a8b9c0d1e",
    "slug": "acme-chat",
    "name": "Acme Chat",
    "description": "Promote our chat app",
    "content_type": "affiliate_app",
    "category": "Tech & Software",
    "status": "active",
    "visibility": "public",
    "created_at": "2026-05-02T14:11:32Z"
  }]
}

Create an app

POST/api/v1/businesses/{slug}/apps

Create an app with the wizard's field names (see the samples; the GET /api/v1 endpoint index lists every field). name, content_type, and the per-type essentials are required: clipping needs platforms, a reward (cpm_cents per 1k views or per_platform_rewards), and at least one requirement; web traffic needs website_url + cpm_cents (per click); collab needs collab_kind (placement or content), ad_placements, and retention_days; affiliate needs a commission rate above zero plus at least one rail — provider with an https website_url for the web side, mobile_provider with at least one store URL for the mobile side, or both on the same app.

The same server-side rules as the dashboard apply: percent commission is capped at 90% (70% for a store-mediated mobile-only app, with a 1% floor), flat commission at $50, and the app name becomes the URL slug — globally unique, so a taken or reserved name is a hard 400 you resolve by renaming. On a dual-rail app the single rate is deliberately not refused at 90%: it is clamped per sale instead, to 70% when the sale went through Apple or Google and 90% otherwise. The web rail supports provider: "stripe" only via the API; Polar, Paddle, Whop, Shopify, and WooCommerce apps are created in the dashboard wizard (their connect steps only exist there).

Media: send JSON with thumbnail_url / media_urls (public https URLs — fetched server-side behind an SSRF guard and validated by content, PNG/JPEG for thumbnails; placement collabs require at least one creative) or multipart with thumbnail / collab_media_0..4 file parts. Creates are idempotent on the field fingerprint, so an accidental double-post returns the first result. Requires a read_write key; 10 creates/minute.

Create a clipping app
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/apps" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "name": "Acme Clips",
  "content_type": "clipping",
  "category": "Entertainment",
  "platforms": [
    "tiktok",
    "instagram",
    "youtube"
  ],
  "cpm_cents": 1000,
  "min_payout_cents": 100,
  "max_payout_cents": 50000,
  "requirements": [
    "Use our brand sounds",
    "No AI voiceovers"
  ],
  "submission_window_minutes": 60,
  "thumbnail_url": "https://acme.com/logo.png"
}'
201
{
  "app": {
    "id": "3f8e2a1b-7c6d-4e5f-9a0b-1c2d3e4f5a6b",
    "slug": "acme-clips",
    "name": "Acme Clips",
    "content_type": "clipping",
    "status": "pending_budget",
    "visibility": "public",
    "cpm_cents": 1000,
    "platforms": ["tiktok", "instagram", "youtube"],
    "…": "full settings object"
  },
  "next_step": {
    "action": "fund",
    "url": "https://affiliateo.com/acme/acme-clips",
    "message": "The app is created but inactive until the owner funds it in the dashboard (Fund app, $100 minimum)."
  }
}
Create a mobile affiliate app
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/apps" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "name": "Acme Chat",
  "content_type": "affiliate_app",
  "mobile_provider": "revenuecat",
  "ios_app_url": "https://apps.apple.com/app/id123456789",
  "affiliate_commission_rate": 30,
  "affiliate_commission_type": "percent"
}'
201 · pending connect
{
  "app": {
    "id": "7d1e5a2c-9b3f-4c8d-a1e2-6f7a8b9c0d1e",
    "slug": "acme-chat",
    "status": "pending_provider",
    "mobile_webhook_secret": "a1b2c3… (shown once)",
    "…": "full settings object"
  },
  "next_step": {
    "action": "connect_provider",
    "url": "https://affiliateo.com/acme/acme-chat/edit",
    "message": "The app is created but inactive until the owner connects the payment provider in the dashboard (browser login required)."
  }
}

Update an app

PATCH/api/v1/businesses/{slug}/apps/{appId}

Send only the fields to change — everything the dashboard edit dialog can touch: name (re-derives the slug, same global-uniqueness rule), description, category, requirements, FAQs, visibility (public / private / private_only / invite_only), commission rate/type and per-product rates, URLs, platforms, rewards, submission window, retention, placements, and media (thumbnail_url, remove_thumbnail, media_urls as the full desired set — kept CDN URLs stay, new https URLs are fetched, removed ones are deleted).

Commission changes notify the app's affiliates automatically and apply to new customers only — already-referred customers keep the rate stamped at their first conversion. Affiliates on a personal deal are unaffected by program-rate changes. Locked fields (content_type, status, budget_cents) return an explicit VALIDATION_ERROR instead of being silently ignored. Requires a read_write key.

Adding a rail. provider and mobile_provider are settable here when the app does not have one yet — that is how a website-only affiliate app grows a mobile rail (send mobile_provider plus a store URL), and how a mobile-only app grows a web rail (send provider plus an https website_url). Changing a provider that is already connected is still refused: disconnect it in the dashboard first, because the disconnect has to revoke the provider-side credentials too.

Path parameters

slugstring

Business slug (see List businesses)

appIdstring

App UUID or app slug

Raise the commission
curl -X PATCH "https://affiliateo.com/api/v1/businesses/acme/apps/acme-chat" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "affiliate_commission_rate": 40,
  "requirements": [
    "Post at least once a week"
  ],
  "visibility": "public"
}'
200
{
  "app": {
    "id": "7d1e5a2c-9b3f-4c8d-a1e2-6f7a8b9c0d1e",
    "slug": "acme-chat",
    "affiliate_commission_rate": 40,
    "…": "full settings object"
  }
}
400 · locked field
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "budget_cents cannot be changed via the API — funding happens in the dashboard (Fund app)"
  }
}

Affiliates

Embedded affiliate signup: add a "Become an affiliate" button to your own app or website. Your backend sends an email (and optional name); Affiliateo creates the account if needed, enrolls it as an affiliate of your app, and returns the referral links and stats in one response. Works for Affiliate and Web Traffic apps. Start with App info & commission to render the "earn X%" (or, for Web Traffic, "earn $X per visitor") pitch screen, then create on tap. Call these from your server only: never ship the API key inside an app binary.

Web Traffic apps pay per qualified click instead of commission, so their money is click-derived: the payouts buckets are paid = approved clicks × CPC and payable = unpaid qualified clicks × CPC (pending / refunded stay 0), and every affiliate also carries a click_payout object with the cpc_cents rate and the approved / payable click counts. Commission fields (stats.commission_cents, conversions) stay 0 for these apps.

App info & commission

GET/api/v1/businesses/{slug}/apps/{appId}

Everything you need to render the "join and earn X%" pitch screen before the user enrolls: the commission rate and type (percent or flat dollars), the affiliate count, total commission paid out, and the app status and application gates the create call will enforce. This is the same information the public app page shows prospective affiliates.

commission.products lists the app's visible catalog entries with their per-product rate, display price, billing interval, and an estimated_commission_cents (≈ payout per sale). Entries the owner hid are omitted, matching the public app card; a hidden product still earns its rate if bought. When the owner uses one rate for everything, has_custom_product_rates is false and every product carries the default rate. range is the min/max across visible rates when they differ.

For web_traffic apps commission is null and a click_payout object carries the per-click rate (cpc_cents), the budget, and the remaining budget instead: everything you need for an "earn $X per visitor" pitch screen. Exactly one of commission / click_payout is set per app. Its budget_remaining_cents is budget_cents minus spent_cents, which is gross: the 1% platform fee on each click payout comes out of that same budget, so about 4.8% of the remainder is already earmarked. Use settings.payable_capacity_cents for the amount that can still reach affiliates.

The response also carries a settings object with the full owner-facing field set (the key holder is always the business owner), including payable_capacity_cents for budget apps — read it before a PATCH so an edit changes only what you mean to change.

Path parameters

slugstring

Business slug (see List businesses)

appIdstring

App UUID or app slug

Get app info
curl "https://affiliateo.com/api/v1/businesses/acme/apps/acme-chat" \
  --header 'Authorization: Bearer afk_...'
200
{
  "app": {
    "id": "7d1e5a2c-9b3f-4c8d-a1e2-6f7a8b9c0d1e",
    "slug": "acme-chat",
    "name": "Acme Chat",
    "content_type": "affiliate_app",
    "status": "active",
    "require_application": false,
    "affiliates_count": 12,
    "total_paid_cents": 184300,
    "commission": {
      "type": "percent",
      "rate": 30,
      "range": { "min": 20, "max": 50 },
      "has_custom_product_rates": true,
      "products": [{
        "name": "Premium Monthly",
        "price_cents": 999,
        "currency": "usd",
        "interval": "month",
        "rate": 35,
        "rate_type": "percent",
        "estimated_commission_cents": 350
      }]
    }
  }
}
200 · web_traffic
{
  "app": {
    "id": "1c2d3e4f-5a6b-7c8d-9e0f-1a2b3c4d5e6f",
    "slug": "acme-traffic",
    "name": "Acme Traffic",
    "content_type": "web_traffic",
    "status": "active",
    "require_application": false,
    "affiliates_count": 34,
    "total_paid_cents": 91200,
    "commission": null,
    "click_payout": {
      "cpc_cents": 12,
      "budget_cents": 500000,
      "spent_cents": 91200,
      "budget_remaining_cents": 408800,
      "platform_filter_enabled": true,
      "allowed_platforms": ["instagram", "tiktok"],
      "include_unidentified_clicks": false
    }
  }
}

App summary

GET/api/v1/businesses/{slug}/apps/{appId}/summary

Your whole affiliate program in one call: affiliate counts, lifetime totals (clicks, sales, renewals, revenue, commission), and the payout buckets — pending, payable, paid, refunded — summed across every affiliate of the app. It's what the per-affiliate numbers from List affiliates add up to, without paging through them yourself. Works the same for Affiliate and Web Traffic apps (Web Traffic pays per qualified click, so its conversion money stays at zero), and on an Affiliate app the totals cover both rails together.

Add ?from=&to= for a window object with the same numbers scoped to those dates (UTC days, inclusive): clicks, initial sales, renewals, trials, refund count, and net revenue/commission. Money in the window is net — a refund shows as a negative in the window it happened, the Stripe convention. Add &include=daily for a per-day series of the same fields, ready for charting; days with no activity are omitted.

The owner's personal tracking link and organic (non-affiliate) sales are excluded everywhere, matching List affiliates and the webhooks — so this summary reconciles with the roster.

Path parameters

slugstring

Business slug (see List businesses)

appIdstring

App UUID or app slug

Query parameters

from / toYYYY-MM-DD

Inclusive window (UTC days), both together; adds the window object

includestring

daily: adds the per-day series (requires from/to, window up to 366 days)

App summary
curl "https://affiliateo.com/api/v1/businesses/acme/apps/acme-chat/summary?from=2026-06-01&to=2026-06-30&include=daily" \
  --header 'Authorization: Bearer afk_...'
200
{
  "summary": {
    "app": {
      "id": "7d1e5a2c-9b3f-4c8d-a1e2-6f7a8b9c0d1e",
      "slug": "acme-chat",
      "name": "Acme Chat",
      "content_type": "affiliate_app",
      "status": "active"
    },
    "affiliates": { "total": 12, "active": 11 },
    "totals": {
      "clicks": 5412,
      "conversions": 203,
      "renewals": 88,
      "revenue_cents": 291200,
      "commission_cents": 87360
    },
    "payouts": {
      "pending_cents": 41200,
      "payable_cents": 18700,
      "paid_cents": 26400,
      "refunded_cents": 1060,
      "payable_count": 9
    },
    "window": {
      "from": "2026-06-01",
      "to": "2026-06-30",
      "clicks": 1893,
      "conversions": 64,
      "renewals": 31,
      "trials": 12,
      "refunds": 2,
      "revenue_cents": 94800,
      "commission_cents": 28440,
      "payouts": {
        "pending_cents": 19100,
        "payable_cents": 9340,
        "paid_cents": 0,
        "refunded_cents": 620,
        "payable_count": 4
      }
    },
    "daily": [{
      "day": "2026-06-01",
      "clicks": 64,
      "conversions": 2,
      "renewals": 1,
      "trials": 0,
      "refunds": 0,
      "revenue_cents": 2997,
      "commission_cents": 899
    }]
  }
}

Create affiliate

POST/api/v1/businesses/{slug}/apps/{appId}/affiliates

The whole enrollment in one call: finds or creates the Affiliateo account for email, auto-joins your business, enrolls the user as an affiliate of the app, and returns their referral links plus starting stats. Safely repeatable: call it every time the user opens your affiliate screen, and an existing affiliate returns 200 with current links and stats instead of creating anything (account_created / affiliate_created tell you what actually happened).

The links.short URL is the canonical one (opaque code, survives renames): render QR codes from it. links.username is the readable variant. links.direct is your own domain with ?ref= appended (no redirect hop; the same parameter the short link hands off after its redirect, and ?via= / ?source= are read as aliases); it needs the tracking SDK or script live on the site to capture the click. For mobile apps it points at the app's landing page and requires the t.js snippet there (normal or instant-redirect mode) — or, fastest, your server can 302?ref= arrivals straight to the app's affiliateo.com/r/ link so the bounce happens before the page loads (see the mobile app docs); it's null when the app has no website or landing page URL.

links.coupon is the odd one out and the only member that is not a URL: it's the discount code the buyer types at your Stripe checkout (e.g. SWIFTPANDA), derived from the affiliate's username. It exists so someone on a podcast, a video or a livestream can be credited without anyone ever clicking a link. Don't render it as a link and don't encode it in a QR code — a bare code scans to nothing. It's null unless the app has coupon codes switched on and its Stripe account is still connected, so a merchant who disconnects Stripe stops advertising codes immediately (the codes themselves stay alive in Stripe on purpose — one already said on a podcast keeps working).

One thing you have to switch on yourself: your Checkout session needs allow_promotion_codes: true (or, on a Stripe Payment Link, the promotion-codes checkbox in the Stripe dashboard). Without it Stripe renders no "Have a promo code?" box, so the codes exist and no customer can ever type one in. We can neither set it for you nor detect that it's missing — the only symptom is that nothing is ever redeemed. It's the same setting that lets you run ordinary Stripe discounts, so most merchants already have it on. Store ref_code against your own user record so you can look the affiliate up later. The person can also sign into the Affiliateo dashboard any time with a magic link to the same email; it's the same account.

New accounts get an auto-generated username (the ref_code) unless you send one; name seeds the profile display name at creation only and never overwrites an existing profile. Apps with "require application approval" enabled reject this endpoint; those affiliates must apply in the dashboard.

Letting them pick their handle. Send an optional username and you can put a handle field next to the email field on your signup screen. It becomes the ref_code, so it is the readable half of every link that person hands out for the rest of their life on your app: affiliateo.com/r/acme-chat/janedoe instead of …/swiftpanda. Rules: 3–30 characters, lowercase letters, numbers, dots and underscores. We trim and lowercase for you, so JaneDoe arrives as janedoe.

It can never fail the signup. If the handle is taken, malformed, or on the reserved list, the account is still created and still enrolled — it just keeps the random one. That is deliberate: a cosmetic preference should not be able to break enrollment, so there is no error to catch here and no retry to write.

Because of that, you have to read the outcome rather than assume it. username_applied (boolean) and username_status come back on the response whenever you sent a username, and are absent when you didn't.

username_status

applieddone

They got the handle they asked for

takentell them

Someone else already holds it. This is the one to surface: “that handle is taken” is a message every signup form already knows how to show

invalidyour bug

Failed the character or length rules — usually means your own form isn’t validating before it posts

reservedtell them

On the impersonation blocklist (admin, stripe, affiliateo, our route names)

account_existsexpected

That email already had an Affiliateo account, so nothing changed. Not an error — see below

unavailableretry

The write failed for some other reason. Rare, and worth another try on their next visit

When it isn't applied, tell them which handle they actually got and that they can change it themselves at affiliateo.com — the first change is free, with no cooldown, and it rewrites their ref_code everywhere at once. Their links.short URL is unaffected by any of this, which is exactly why it's the one to print on a QR code.

account_exists is the case people forget. A username applies only when this call creates the account. Someone who already has an Affiliateo account may be an affiliate for several other businesses with links already in circulation, so a merchant they just met doesn't get to rename them. Since your form has no way of knowing which case it's in before it posts, expect this one and word it kindly.

A handle with no name also names them. If you send username without name — the natural shape of a form that asks for an email and a handle — the handle seeds the display name too, with dots and underscores read as spaces: jane.doe becomes Jane Doe, janedoe becomes Janedoe. That name is also what their auto-created business is called. Send a real name and it always wins; send neither and they get the random "Swift Panda" pair, as before. The point is that whatever they gave you is what names them, rather than two words they never saw.

A taken handle still names them — display names aren't unique, so someone else holding janedoe has no bearing on whether this person can be called Janedoe. They just get a random handle alongside it. A malformed one doesn't: garbage in the handle box must not become somebody's name.

Names are sanitised, not trusted. A name that impersonates the platform, staff, or a payment brand ("Affiliateo Support", "Stripe", "Admin") is replaced with a safe one, as are names made of emoji or stacked combining marks. It's the same rule the profile settings page enforces, and it never fails the call — you just get a different name than you sent. Nothing here is a reason to skip validating on your own form; it's the backstop, not the first line.

An affiliate who previously left your business is reactivated by this call (their original links and history come back). Someone you banned from your business is rejected with BANNED_FROM_BUSINESS; an account that is closed or banned platform-wide is rejected with a deliberately vague ACCOUNT_INELIGIBLE (so the API can't be used to probe someone's status on Affiliateo). Both are 403, and enrollment never un-bans.

Your own link: enroll your own email (the account that owns the business) and you get your personal tracking link instead — the same owner link the dashboard offers, for sharing your app yourself. It earns no commission and is excluded from List affiliates and App summary, but Get affiliate returns its clicks, stats, and breakdown like any other link, and QR codes render from its links.short the same way.

Path parameters

slugstring

Business slug (see List businesses)

appIdstring

App UUID or app slug (see the apps array on List businesses)

Body

emailstring

Required. The user’s email; the account is created for (or matched to) this address. Disposable email domains are rejected

namestring

Optional. Seeds the display name (and the auto-created business name) when the account is newly created. Takes priority over username for that

usernamestring

Optional. The handle in their referral link, 3–30 chars of a-z 0-9 . _ — applied only when this call creates the account, and never fails the call. With no name alongside it, also seeds the display name. See “Letting them pick their handle” above

Create affiliate
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/apps/acme-chat/affiliates" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "email": "jane@example.com",
  "name": "Jane Doe"
}'
201
{
  "affiliate": {
    "id": "9f2c7e1a-4b60-4f0e-9a75-0d1c2e3f4a5b",
    "status": "active",
    "ref_code": "swiftpanda",
    "short_code": "aB3xY9k",
    "joined_at": "2026-07-11T18:04:12Z",
    "verified": false,
    "payout": {
      "identity_verified": false,
      "payouts_enabled": false
    },
    "user": {
      "username": "swiftpanda",
      "display_name": "Jane Doe"
    },
    "links": {
      "short": "https://affiliateo.com/r/aB3xY9k",
      "username": "https://affiliateo.com/r/acme-chat/swiftpanda",
      "direct": "https://acmechat.com?ref=swiftpanda",
      "coupon": "SWIFTPANDA"
    },
    "stats": {
      "clicks": 0,
      "conversions": 0,
      "renewals": 0,
      "revenue_cents": 0,
      "commission_cents": 0
    },
    "payouts": {
      "pending_cents": 0,
      "payable_cents": 0,
      "paid_cents": 0,
      "refunded_cents": 0,
      "payable_count": 0
    }
  },
  "account_created": true,
  "affiliate_created": true
}
201 · sent username: janedoe, already taken
{
  "affiliate": {
    "ref_code": "swiftpanda",
    "links": {
      "username": "https://affiliateo.com/r/acme-chat/swiftpanda"
    }
  },
  "account_created": true,
  "affiliate_created": true,
  "username_applied": false,
  "username_status": "taken"
}

Get affiliate

GET/api/v1/businesses/{slug}/apps/{appId}/affiliates?email=…

One affiliate's links, lifetime stats, and payout state, looked up by ?email= or ?ref_code=. Use this to render the affiliate screen in your app (clicks, sales, earnings) with a read-only key. stats.conversions + stats.renewals is the "sales" number the dashboard shows; money fields are cents.

payouts is the earnings ledger, always included: pending_cents (earned, waiting for the monthly payable transition), payable_cents (ready to be paid out, net of refund clawbacks), paid_cents (already credited to the affiliate's wallet), and refunded_cents (reversed by refunds or chargebacks). These are the same numbers as the dashboard's payout table.

payout is the readiness hint for gating your buttons: show the Verify button until payout.identity_verified is true, and the Cash out button once payout.payouts_enabled is true (identity done AND their Stripe payout account has nothing outstanding that the cash-out page can't collect itself; a missing bank never counts, since adding the bank is the cash-out page's own first step). It rides every affiliate response, including lists, with no extra latency; the hosted verify / cash-out pages still re-check Stripe live, so a just-changed status can never pay out incorrectly.

Add &include=breakdown for where the clicks came from: top sources (referrer origins, null = direct) and countries. The breakdown covers the last 90 days of raw clicks; stats.clicks is the durable lifetime counter.

Query parameters

emailstring

Look up by the email used at enrollment

ref_codestring

Look up by referral code (the affiliate’s username)

includestring

breakdown: adds click sources + countries

from / toYYYY-MM-DD

Inclusive window; adds windowed_clicks (clicks within the range). Lifetime counters are unaffected

Get affiliate
curl "https://affiliateo.com/api/v1/businesses/acme/apps/acme-chat/affiliates?email=jane@example.com&include=breakdown" \
  --header 'Authorization: Bearer afk_...'
200
{
  "affiliate": {
    "id": "9f2c7e1a-4b60-4f0e-9a75-0d1c2e3f4a5b",
    "status": "active",
    "ref_code": "swiftpanda",
    "short_code": "aB3xY9k",
    "joined_at": "2026-07-11T18:04:12Z",
    "verified": true,
    "payout": {
      "identity_verified": true,
      "payouts_enabled": true
    },
    "user": {
      "username": "swiftpanda",
      "display_name": "Jane Doe"
    },
    "links": {
      "short": "https://affiliateo.com/r/aB3xY9k",
      "username": "https://affiliateo.com/r/acme-chat/swiftpanda",
      "direct": "https://acmechat.com?ref=swiftpanda",
      "coupon": "SWIFTPANDA"
    },
    "stats": {
      "clicks": 482,
      "conversions": 17,
      "renewals": 6,
      "revenue_cents": 184300,
      "commission_cents": 36860
    },
    "payouts": {
      "pending_cents": 4120,
      "payable_cents": 9400,
      "paid_cents": 22850,
      "refunded_cents": 490,
      "payable_count": 4
    },
    "breakdown": {
      "sources": [
        { "referer": "https://www.instagram.com", "count": 301 },
        { "referer": null, "count": 122 }
      ],
      "countries": [
        { "code": "US", "count": 268 },
        { "code": "GB", "count": 84 }
      ],
      "total": 482
    }
  }
}

List conversions

GET/api/v1/businesses/{slug}/apps/{appId}/affiliates/conversions

Conversion history, newest first: the API version of the dashboard's Transactions tab. Each row carries the type (subscription, one_time, renewal, trial, refund, chargeback), the gross sale amount, the commission earned, its payout status, and the earning affiliate's ref_code. With ?email= or ?ref_code= it's one affiliate's history — the "your recent sales" feed for your embedded affiliate screen. With neither, it's the app-wide feed: every affiliate-attributed sale across the program (the owner's tracking link and organic sales are excluded, matching App summary).

Refunds and chargebacks appear as separate rows with negative amounts, linked to the original sale via original_conversion_id. No customer data is included: these rows carry money and status only. ?from=&to= narrows either mode to a date window (UTC days, inclusive), and ?type= to particular conversion types.

Both filters run in the query, upstream of the cursor, so a filtered feed pages through matching rows only. That is what makes a Type filter on your own screen work: filtering the page you already hold can only search the rows on screen, so "Refunds" finds nothing for an affiliate whose single refund sits 300 rows down. An unknown ?type= value is a 400 rather than an empty page, so a typo cannot be read as "no refunds".

Query parameters

emailstring

Scope to one affiliate by the email used at enrollment

ref_codestring

Scope to one affiliate by referral code. Omit both for the app-wide feed

from / toYYYY-MM-DD

Inclusive date window (UTC days), both together

typestring

Comma-separated conversion types: any of subscription, one_time, renewal, trial, refund, chargeback. Omit for every type

limitnumber

Page size, 1–500. Default 100

starting_afterstring

Cursor: id of the last row of the previous page. Response carries has_more

List conversions (one affiliate)
curl "https://affiliateo.com/api/v1/businesses/acme/apps/acme-chat/affiliates/conversions?email=jane@example.com&limit=50" \
  --header 'Authorization: Bearer afk_...'
List conversions (app-wide, June)
curl "https://affiliateo.com/api/v1/businesses/acme/apps/acme-chat/affiliates/conversions?from=2026-06-01&to=2026-06-30" \
  --header 'Authorization: Bearer afk_...'
List conversions (reversals only)
curl "https://affiliateo.com/api/v1/businesses/acme/apps/acme-chat/affiliates/conversions?email=jane@example.com&type=refund,chargeback" \
  --header 'Authorization: Bearer afk_...'
200
{
  "ref_code": "swiftpanda",
  "conversions": [{
    "id": "c0a80121-7ac0-4e1c-9d5f-2b3c4d5e6f70",
    "conversion_type": "subscription",
    "amount_cents": 999,
    "commission_cents": 350,
    "status": "completed",
    "payout_status": "pending",
    "created_at": "2026-07-10T14:22:31Z",
    "original_conversion_id": null,
    "ref_code": "swiftpanda"
  }, {
    "id": "d1b90232-8bd1-4f2d-ae60-3c4d5e6f7a81",
    "conversion_type": "refund",
    "amount_cents": -999,
    "commission_cents": -350,
    "status": "completed",
    "payout_status": "payable",
    "created_at": "2026-07-11T09:02:10Z",
    "original_conversion_id": "c0a80121-7ac0-4e1c-9d5f-2b3c4d5e6f70",
    "ref_code": "swiftpanda"
  }],
  "has_more": false,
  "limit": 50
}

List affiliates

GET/api/v1/businesses/{slug}/apps/{appId}/affiliates

Every affiliate of the app with links and lifetime stats, newest first: the same roster the dashboard's affiliates table shows (the owner's own tracking link is excluded). Responses don't include emails; correlate rows with your own users via the ref_code you stored at enrollment. Add &include=payouts to attach each row's pending / payable / paid / refunded buckets. Pagination is cursor-based: pass the last row's id as starting_after while has_more is true.

Query parameters

limitnumber

Page size, 1–500. Default 100

starting_afterstring

Cursor: id of the last row of the previous page

includestring

payouts: adds the earnings buckets to every row

List affiliates
curl "https://affiliateo.com/api/v1/businesses/acme/apps/acme-chat/affiliates?limit=100" \
  --header 'Authorization: Bearer afk_...'
200
{
  "affiliates": [{
    "id": "9f2c7e1a-4b60-4f0e-9a75-0d1c2e3f4a5b",
    "status": "active",
    "ref_code": "swiftpanda",
    "short_code": "aB3xY9k",
    "joined_at": "2026-07-11T18:04:12Z",
    "verified": true,
    "payout": {
      "identity_verified": true,
      "payouts_enabled": true
    },
    "user": {
      "username": "swiftpanda",
      "display_name": "Jane Doe"
    },
    "links": {
      "short": "https://affiliateo.com/r/aB3xY9k",
      "username": "https://affiliateo.com/r/acme-chat/swiftpanda",
      "direct": "https://acmechat.com?ref=swiftpanda",
      "coupon": "SWIFTPANDA"
    },
    "stats": {
      "clicks": 482,
      "conversions": 17,
      "renewals": 6,
      "revenue_cents": 184300,
      "commission_cents": 36860
    }
  }],
  "has_more": false,
  "limit": 100
}

Get an affiliate paid-ready

POST/api/v1/businesses/{slug}/apps/{appId}/affiliates/verification-link

Add a "Verify" button next to your affiliate screen. This mints a short-lived hosted link (valid for expires_in seconds, reusable within that window so a page refresh resumes the flow); open it in the system browser from your site or app and the affiliate finishes everything needed to receive payouts on our domain: creating their Stripe payout account and passing identity verification (ID photo + selfie). The identity step needs camera access, so prefer the system browser over an embedded webview. Whichever steps they have already done are skipped, and an affiliate who is already paid-ready bounces straight back.

The link only works in the affiliate's hands. When it opens, we email a 6-digit code to the affiliate's address and the page stays locked until they enter it, so a minted link is useless to anyone who doesn't own that inbox (links minted self-serve via /api/v1/me/verification-link skip the code, since the affiliate's own key already proves who they are). On top of that, the link can only complete onboarding: it fills in whatever Stripe still needs and can never read or overwrite details an account already has.

When they finish we redirect the browser to your return_url with ?affiliateo_status=complete appended. If something can only be finished on the Affiliateo dashboard (a company-registered account, or an extra document Stripe requests), the page says so and the return link carries ?affiliateo_status=pending instead; complete is only ever sent when nothing is left to do. Use an https:// page for a website, or a mobile deep link (a Universal Link / App Link, or a custom scheme like myapp://verified) so the phone reopens your app. Confirm the result server-side with the affiliate.verified webhook or the verified field on the affiliate endpoints; a redirect alone is not proof.

Privacy: the sensitive data (SSN / tax ID, ID document, selfie) is entered only on our hosted page and goes straight to Stripe and our identity provider. It never passes through your servers, and Affiliateo stores only whether verification passed, never the documents.

The same gates as Create affiliate apply before a link is minted (ACCOUNT_INELIGIBLE / BANNED_FROM_BUSINESS, both 403). The link is short-lived (expires_in seconds); if it expires (even mid-flow), the affiliate is sent back to your return_url with ?affiliateo_status=expired, so mint a fresh link on arrival and reopen it — expiry heals itself without the affiliate retyping anything. v1 is individuals only — company / business affiliates onboard from the Affiliateo dashboard. Affiliates can also mint their own link with their own key via POST /api/v1/me/verification-link.

Path parameters

slugstring

Business slug (see List businesses)

appIdstring

App UUID or app slug

Body

emailstring

Required. The affiliate’s email (find-or-create + enroll, same as Create affiliate)

return_urlstring

Required. Where to send them when done: an https:// URL or a mobile deep link. Dangerous schemes are rejected

namestring

Optional. Seeds the display name when the account is newly created

langstring

Optional. A BCP-47 language tag such as "fr" or "pt-BR" (regional tags fall back to the base language). The hosted portal renders in this language when supported, otherwise English.

Mint a verification link
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/apps/acme-chat/affiliates/verification-link" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "email": "jane@example.com",
  "return_url": "https://acme.com/affiliates?done=1"
}'
200
{
  "url": "https://affiliateo.com/verify/onboard/eyJhbGciOi...",
  "expires_in": 3600,
  "account_created": false,
  "affiliate_created": false,
  "verified": false
}

Let an affiliate cash out

POST/api/v1/businesses/{slug}/apps/{appId}/affiliates/withdrawal-link

Add a "Withdraw earnings" button next to your affiliate screen. This mints a short-lived hosted link where the affiliate cashes out their Affiliateo wallet balance to a bank, entirely on our domain: they add or pick a bank account and withdraw, then get redirected back to your return_url with ?affiliateo_status=complete (=pending if they leave without withdrawing, =expired if the link had already expired — mint a fresh one and reopen it).

The link only works in the affiliate's hands. When it opens we email a 6-digit code to the affiliate's address and the page stays locked until they enter it (or they are already signed in as that same affiliate), so a minted link can't be used by anyone else (including you) to move their money. If the account has two-factor authentication enabled, the emailed code is not accepted at all: the page asks them to sign in to Affiliateo instead, which runs their normal 2FA challenge, then returns them to the same page unlocked. Only that person, behind the gate, adds their own bank and confirms the amount.

What they can withdraw is their account-wide wallet balance, not this app's payable. Affiliateo gives each person one wallet; once a commission is paid into it, it is simply their balance. The page shows THIS app's pending / payable / paid as context, but the cashable number is the whole wallet (minus the 7-day settlement hold), with a $20 minimum, exactly like the dashboard. Standard bank transfers always; eligible accounts (a debit-card-style payout method in a supported country) also get an Instant option that arrives within minutes for a higher fee.

This never creates or enrolls anyone (the email must already be an affiliate of this app, or you get NOT_FOUND). The affiliate does not have to be paid-ready first: one who still needs verification is walked through the get-paid-ready flow right on the same page, then continues straight into the withdrawal, so you can offer a single Cash out button and let the page sort out the rest. (Gating your buttons on the payout readiness object is still nicer UX when you want separate Verify and Cash out actions.) Requires a read-write key.

Path parameters

slugstring

Business slug (see List businesses)

appIdstring

App UUID or app slug

Body

emailstring

Required. The affiliate’s email. Must already be an affiliate of this app

return_urlstring

Required. Where to send them when done: an https:// URL or a mobile deep link. Dangerous schemes are rejected

langstring

Optional. A BCP-47 language tag such as "fr" or "pt-BR" (regional tags fall back to the base language). The hosted portal renders in this language when supported, otherwise English.

Mint a withdrawal link
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/apps/acme-chat/affiliates/withdrawal-link" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "email": "jane@example.com",
  "return_url": "https://acme.com/affiliates?cashout=1"
}'
200
{
  "url": "https://affiliateo.com/verify/withdraw/eyJhbGciOi...",
  "expires_in": 3600
}

Comp an affiliate into your paid app

POST/api/v1/businesses/{slug}/apps/{appId}/affiliates/free-access

Give an affiliate your paid app for free, and take it back whenever you want. This is the same switch as the Access column in the Affiliates tab: it grants a promotional entitlement in your own RevenueCat project, so they see the paid experience without paying and the receipt stays in your account. RevenueCat apps only — everywhere else this answers not_revenuecat and free_access is null on the affiliate payload.

Two different emails, and mixing them up is the one real trap. email (or ref_code) says who the affiliate is, the same way it does on every other endpoint here. revenuecat_email says which RevenueCat customer receives the grant — the address that person uses inside your app, which is very often not the one they signed up to Affiliateo with. Leave it out and we use their Affiliateo address.

We match on email and nothing else. Never on who referred whom: the people attributed to an affiliate are the customers they referred, so treating one of those as the affiliate would hand a year of paid features to a stranger. That means your app has to tell RevenueCat each person's email (Purchases.setEmail, or the $email attribute) or there is nothing to match, and affiliates who used Sign in with Apple and chose Hide My Email cannot be resolved at all.

When no customer carries that address the call still succeeds with pending: true. That is a waiting state, not a failure — the switch is genuinely on and the grant lands as soon as a matching customer appears. Send revenuecat_email or entitlement_id on their own, with no enabled, to record them without touching RevenueCat; that is how you set the right address first instead of granting to the wrong customer and correcting it. Changing either while a grant is live moves it, revoking the old one first.

enabled: false always works, including on a paused or wound-down app, or one whose RevenueCat connection has broken. Turning it on is refused for your own tracking link, for affiliates who are not active, and for apps that are ending. Owner only: unlike the ads endpoints, a team member's key cannot reach this, because it spends your own revenue. Read the current state from free_access on any affiliate payload (enabled, pending, entitlement_id, revenuecat_email, granted_at, expires_at).

Switch free access on
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/apps/acme-chat/affiliates/free-access" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "email": "jane@example.com",
  "enabled": true,
  "revenuecat_email": "jane@work.com"
}'
200
{
  "enabled": true
}
200 — waiting
{
  "enabled": true,
  "pending": true
}

Business affiliates

A second, separate program: people promoting your business's products (your storefront), not a specific app. These endpoints are for the business owner to read those affiliates, their links, visitor counts, sales, commission, and payouts. One caveat versus app affiliates: business-link clicks are only counted, not logged individually, so there is no sources/countries breakdown here, just a lifetime visitor total per affiliate.

List business affiliates

GET/api/v1/businesses/{slug}/affiliates

Every storefront affiliate of your business, newest first, each with their referral links (the canonical short and the storefront link with the ref tag), lifetime stats, and commission. stats.visitors is the lifetime click count. Add &include=payouts for each row's pending / payable / paid / refunded buckets. ?ref_code= returns a single affiliate. Cursor-paginated; emails are never included (correlate via ref_code).

Query parameters

ref_codestring

Return a single affiliate by referral code

includestring

payouts: adds the earnings buckets to every row

limitnumber

Page size, 1–500. Default 100

starting_afterstring

Cursor: id of the last row of the previous page

List business affiliates
curl "https://affiliateo.com/api/v1/businesses/acme/affiliates?include=payouts" \
  --header 'Authorization: Bearer afk_...'
One by ref_code
curl "https://affiliateo.com/api/v1/businesses/acme/affiliates?ref_code=swiftpanda" \
  --header 'Authorization: Bearer afk_...'
200
{
  "affiliates": [{
    "id": "b1f2...",
    "status": "active",
    "ref_code": "swiftpanda",
    "short_code": "aB3xY9k",
    "joined_at": "2026-06-02T18:04:12Z",
    "commission": { "type": "percentage", "rate": 20 },
    "links": {
      "short": "https://affiliateo.com/r/aB3xY9k",
      "storefront": "https://affiliateo.com/acme?ref=swiftpanda"
    },
    "business": { "slug": "acme", "name": "Acme Inc" },
    "user": { "username": "swiftpanda", "display_name": "Jane Doe" },
    "stats": { "visitors": 229, "conversions": 12, "revenue_cents": 48000, "commission_cents": 9600 },
    "payouts": { "pending_cents": 1200, "payable_cents": 800, "paid_cents": 7600, "refunded_cents": 0, "payable_count": 2 }
  }],
  "has_more": false,
  "limit": 100
}

Business affiliate conversions

GET/api/v1/businesses/{slug}/affiliates/conversions

Every sale your storefront affiliates drove, newest first: the type, the product (and price option), gross amount, commission, and payout status, each tagged with the earning affiliate's ref_code. ?ref_code= scopes to one affiliate; ?from=&to= to a date window. Refunds and chargebacks are separate rows with negative amounts.

Query parameters

ref_codestring

Scope to one affiliate by referral code

from / toYYYY-MM-DD

Inclusive date window (UTC days), both together

typestring

Comma-separated conversion types: any of subscription, one_time, renewal, trial, refund, chargeback. Filtered upstream of the cursor

limitnumber

Page size, 1–500. Default 100

starting_afterstring

Cursor: id of the last row of the previous page

Business affiliate sales
curl "https://affiliateo.com/api/v1/businesses/acme/affiliates/conversions?from=2026-06-01&to=2026-06-30" \
  --header 'Authorization: Bearer afk_...'
200
{
  "conversions": [{
    "id": "c0a8...",
    "conversion_type": "subscription",
    "amount_cents": 10000,
    "commission_cents": 1000,
    "status": "confirmed",
    "payout_status": "payable",
    "created_at": "2026-06-10T14:22:31Z",
    "product_name": "Pro plan",
    "pricing_option_label": "Monthly",
    "original_conversion_id": null,
    "ref_code": "swiftpanda"
  }],
  "has_more": false,
  "limit": 100
}

Business affiliate summary

GET/api/v1/businesses/{slug}/affiliates/summary

Your storefront-affiliate program in one call: affiliate counts, lifetime totals (visitors, sales, revenue, commission, money net of refunds), and payout buckets across every affiliate. ?from=&to= adds a window with the net sales numbers plus link clicks for that period (click history exists from July 2026 onward). There's no per-day series, because business clicks are counted per day, not logged per event.

Query parameters

from / toYYYY-MM-DD

Inclusive window (UTC days), both together; adds the window object

Business affiliate summary
curl "https://affiliateo.com/api/v1/businesses/acme/affiliates/summary?from=2026-06-01&to=2026-06-30" \
  --header 'Authorization: Bearer afk_...'
200
{
  "summary": {
    "business": { "slug": "acme", "name": "Acme Inc" },
    "affiliates": { "total": 104, "active": 100 },
    "totals": { "visitors": 229, "conversions": 12, "revenue_cents": 48000, "commission_cents": 9600 },
    "payouts": { "pending_cents": 1200, "payable_cents": 800, "paid_cents": 7600, "refunded_cents": 0, "payable_count": 2 },
    "window": {
      "from": "2026-06-01", "to": "2026-06-30",
      "clicks": 210,
      "conversions": 4, "renewals": 1, "trials": 0, "refunds": 0,
      "revenue_cents": 16000, "commission_cents": 3200,
      "payouts": { "pending_cents": 800, "payable_cents": 400, "paid_cents": 0, "refunded_cents": 0, "payable_count": 1 }
    }
  }
}

Your affiliate data

The affiliate's own view. The Affiliates endpoints above are for a business owner reading their app's affiliates; these /me endpoints are for any Affiliateo user pulling their OWN data across every business they've joined as an affiliate: their memberships, links, stats, earnings, and conversion history. Mint a key in the dashboard API tab (a read-only key is enough), then call these with no business slug in the path. Scope is app-affiliate earnings (the referral-link program); cashing out happens on the dashboard or through the hosted withdrawal link (see Cash out yourself).

My summary

GET/api/v1/me/summary

Your whole affiliate life in one call: how many apps you're an affiliate of, your lifetime totals (clicks, sales, renewals, revenue, commission), your earnings buckets (pending, payable, paid, refunded), and your account wallet balance, summed across every business you've joined. These are the same numbers your dashboard shows for the app-affiliate program.

Add ?from=&to= for a window object scoped to those dates (UTC days, inclusive), and &include=daily for a per-day series ready to chart. Money in the window is net — a refund shows as a negative in the window it happened. wallet.balance_cents is your account-wide withdrawable balance (every earning source, not just this program); cashing out happens on the dashboard or through the hosted withdrawal link.

Query parameters

from / toYYYY-MM-DD

Inclusive window (UTC days), both together; adds the window object

includestring

daily: adds the per-day series (requires from/to, window up to 366 days)

My summary
curl "https://affiliateo.com/api/v1/me/summary?from=2026-06-01&to=2026-06-30&include=daily" \
  --header 'Authorization: Bearer afk_...'
200
{
  "summary": {
    "user": { "username": "swiftpanda", "display_name": "Jane Doe" },
    "wallet": { "balance_cents": 48350 },
    "affiliations": { "total": 5, "active": 4 },
    "totals": {
      "clicks": 3120,
      "conversions": 96,
      "renewals": 41,
      "revenue_cents": 512400,
      "commission_cents": 118600
    },
    "payouts": {
      "pending_cents": 12400,
      "payable_cents": 8300,
      "paid_cents": 96800,
      "refunded_cents": 1100,
      "payable_count": 6
    },
    "window": {
      "from": "2026-06-01",
      "to": "2026-06-30",
      "clicks": 812,
      "conversions": 24,
      "renewals": 11,
      "trials": 5,
      "refunds": 1,
      "revenue_cents": 128400,
      "commission_cents": 29650,
      "payouts": { "pending_cents": 9200, "payable_cents": 4100, "paid_cents": 0, "refunded_cents": 300, "payable_count": 2 }
    },
    "daily": [{
      "day": "2026-06-01",
      "clicks": 31, "conversions": 1, "renewals": 0, "trials": 0, "refunds": 0,
      "revenue_cents": 4990, "commission_cents": 1497
    }]
  }
}

My affiliations

GET/api/v1/me/affiliations

Every app you're an affiliate of, across all your businesses, newest first, each with your referral links, lifetime stats, the app's current commission, and which business it belongs to. Add &include=payouts to attach each row's pending / payable / paid / refunded buckets. Pagination is cursor-based: pass the last row's id as starting_after while has_more is true.

Each app carries a rails object saying what it actually sells on — a website, a mobile app, or both — plus the connected provider on each side and that rail's percent ceiling. Read rails, not content_type: the affiliate type is always affiliate_app, and it says nothing about rails. An app can gain or lose either rail at any time without its type ever changing. The single commission.rate covers both rails and is clamped per sale to whichever ceiling applies (70% when Apple or Google take their cut, 90% otherwise).

If you also own a business and made a personal tracking link for your own app, it appears here with is_owner_link set to true (it earns nothing); filter it out client-side if you only want links where you're a third-party affiliate.

Add ?app= (the app UUID or slug) to zoom into a single affiliation. On that view, &include=breakdown adds breakdown — where your clicks came from: top sources (referrer origins, i.e. the platforms, with null = direct) and countries — and &from=&to= adds windowed_clicks (clicks in that date range). The breakdown covers the last 90 days of raw clicks; stats.clicks stays the durable lifetime counter. These scan raw clicks, so they're only available with ?app=, not on the full list. Everything you need for a QR code is already here: render it from links.short, the canonical link (the dashboard makes its QR the same way).

Query parameters

appstring

Zoom into one affiliation (app UUID or slug). Required for breakdown and from/to

includestring

payouts: earnings buckets on each row. breakdown (with app): click sources + countries

from / toYYYY-MM-DD

With app: inclusive window; adds windowed_clicks

limitnumber

Page size, 1–500. Default 100 (full list only)

starting_afterstring

Cursor: id of the last row of the previous page (full list only)

My affiliations
curl "https://affiliateo.com/api/v1/me/affiliations?include=payouts" \
  --header 'Authorization: Bearer afk_...'
200
{
  "affiliations": [{
    "id": "9f2c7e1a-4b60-4f0e-9a75-0d1c2e3f4a5b",
    "status": "active",
    "is_owner_link": false,
    "ref_code": "swiftpanda",
    "short_code": "aB3xY9k",
    "joined_at": "2026-05-02T18:04:12Z",
    "app": {
      "id": "7d1e5a2c-9b3f-4c8d-a1e2-6f7a8b9c0d1e",
      "slug": "acme-chat",
      "name": "Acme Chat",
      "content_type": "affiliate_app",
      "status": "active",
      "commission": { "type": "percent", "rate": 30 },
      "rails": {
        "web": true,
        "mobile": true,
        "web_provider": "stripe",
        "mobile_provider": "revenuecat",
        "max_commission_percent": { "web": 90, "mobile": 70 }
      }
    },
    "business": { "slug": "acme", "name": "Acme Inc" },
    "links": {
      "short": "https://affiliateo.com/r/aB3xY9k",
      "username": "https://affiliateo.com/r/acme-chat/swiftpanda",
      "direct": null,
      "coupon": null
    },
    "stats": { "clicks": 482, "conversions": 17, "renewals": 6, "revenue_cents": 184300, "commission_cents": 36860 },
    "payouts": { "pending_cents": 4120, "payable_cents": 9400, "paid_cents": 22850, "refunded_cents": 490, "payable_count": 4 }
  }],
  "has_more": false,
  "limit": 100
}
One affiliation + breakdown
curl "https://affiliateo.com/api/v1/me/affiliations?app=acme-chat&include=breakdown" \
  --header 'Authorization: Bearer afk_...'
200 (single app + breakdown)
{
  "affiliations": [{
    "id": "9f2c7e1a-4b60-4f0e-9a75-0d1c2e3f4a5b",
    "ref_code": "swiftpanda",
    "short_code": "aB3xY9k",
    "links": {
      "short": "https://affiliateo.com/r/aB3xY9k",
      "username": "https://affiliateo.com/r/acme-chat/swiftpanda",
      "direct": null,
      "coupon": null
    },
    "stats": { "clicks": 482, "conversions": 17, "renewals": 6, "revenue_cents": 184300, "commission_cents": 36860 },
    "breakdown": {
      "sources": [
        { "referer": "https://www.instagram.com", "count": 301 },
        { "referer": "https://www.tiktok.com", "count": 59 },
        { "referer": null, "count": 122 }
      ],
      "countries": [
        { "code": "US", "count": 268 },
        { "code": "GB", "count": 84 },
        { "code": "Other", "count": 130 }
      ],
      "total": 482
    }
  }],
  "has_more": false,
  "limit": 1
}

My conversions

GET/api/v1/me/conversions

Your sales feed across every business you're an affiliate of: the API version of your dashboard Transactions tab (app-affiliate rows). Each row carries the type (subscription, one_time, renewal, trial, refund, chargeback), gross amount, commission, payout status, and the app + business it came from. Refunds and chargebacks are separate rows with negative amounts.

payable_at is when the commission becomes (or became) available for the business owner to pay out, set by the payment processor that carried the sale: 14 days after the sale on Stripe, Shopify, WooCommerce, Whop and Polar, or the 1st of the next month for Paddle and App Store or Play Store sales. It is null once the commission has been paid, and on rows that never pay out (organic sales, trials, reversals).

?app= narrows to a single app (UUID or slug); ?from=&to= narrows to a date window (UTC days, inclusive). Cursor-paginated like My affiliations. No customer data is included.

Query parameters

appstring

Narrow to one app (UUID or slug you’re an affiliate of)

from / toYYYY-MM-DD

Inclusive date window (UTC days), both together

typestring

Comma-separated conversion types: any of subscription, one_time, renewal, trial, refund, chargeback. Filtered upstream of the cursor

limitnumber

Page size, 1–500. Default 100

starting_afterstring

Cursor: id of the last row of the previous page

My conversions
curl "https://affiliateo.com/api/v1/me/conversions?from=2026-06-01&to=2026-06-30" \
  --header 'Authorization: Bearer afk_...'
200
{
  "conversions": [{
    "id": "c0a80121-7ac0-4e1c-9d5f-2b3c4d5e6f70",
    "conversion_type": "subscription",
    "amount_cents": 999,
    "commission_cents": 350,
    "status": "completed",
    "payout_status": "pending",
    "created_at": "2026-06-10T14:22:31Z",
    "payable_at": "2026-06-24T14:22:31Z",
    "original_conversion_id": null,
    "app": { "id": "7d1e5a2c-9b3f-4c8d-a1e2-6f7a8b9c0d1e", "slug": "acme-chat", "name": "Acme Chat" },
    "business_slug": "acme"
  }],
  "has_more": false,
  "limit": 100
}

My wallet

GET/api/v1/me/wallet

Your one account wallet and the buckets behind the balance, all in cents. There is a single wallet per account, so this is the same money whether you're a business owner or an affiliate. balance_cents is the total; withdrawable_cents is what you can cash out right now (the balance minus anything still under the 7-day hold).

The rest explain the difference: held_cents is recent earnings still on hold, pending_deposits_cents is bank deposits still clearing (not yet in the balance), locked_app_cents is unspent ad budget you've committed, disputed_cents is commission tied up in open disputes, and in_transit_cents is withdrawals already on their way to your bank (the outgoing mirror of pending_deposits_cents, already out of the balance). payout_account_connected tells you whether a payout destination is set up, and payout is the same readiness hint the affiliate endpoints carry (identity_verified + payouts_enabled) for gating Verify / Cash out buttons. This endpoint is read-only; moving money happens on the dashboard or through the hosted withdrawal link (see Cash out yourself).

My wallet
curl "https://affiliateo.com/api/v1/me/wallet" \
  --header 'Authorization: Bearer afk_...'
200
{
  "wallet": {
    "id": "3b9a1c2d-4e5f-6071-8293-a4b5c6d7e8f9",
    "currency": "usd",
    "balance_cents": 48350,
    "withdrawable_cents": 41200,
    "held_cents": 7150,
    "pending_deposits_cents": 0,
    "locked_app_cents": 25000,
    "disputed_cents": 0,
    "in_transit_cents": 0,
    "payout_account_connected": true,
    "payout": {
      "identity_verified": true,
      "payouts_enabled": true
    }
  }
}

My transactions

GET/api/v1/me/transactions

Your account wallet ledger, newest first: every money movement on your wallet, the API version of the dashboard Transactions tab. Each row has a type, a signed amount_cents (direction is credit for money in, debit for money out), gross_amount_cents and fee_cents where a fee applies, and a counterparty — the other side of the movement (the buyer on a sale, the business on a commission, the app on ad funding, the other person on a transfer).

Types you'll see: sale, deposit, payout (a withdrawal), refund, chargeback, transfer_in / transfer_out, affiliate_commission, and campaign_fund / campaign_refund / campaign_payout (ad budget). ?type= filters to a comma-separated set. On payout rows the status is the honest withdrawal state, in_transit (on its way to the bank), paid (arrived), or failed (reversed), and the payout object carries the bank, the estimated_arrival, and the completed_at once it lands. Cursor-paginated: pass the last row's id as starting_after while has_more is true. Pending bank deposits aren't in the ledger yet — they show on /me/wallet as pending_deposits_cents until they settle.

GET /api/v1/me/transactions/{id} returns one transaction by its id or short_id (e.g. pay_…). For your own deposits it also returns the Stripe receipt_url and the card or bank used; for commission rows it adds product_name and the paying business. A buyer's receipt or card on a sale is never exposed.

Query parameters

typestring

Comma-separated transaction types to include (e.g. sale,refund)

limitnumber

Page size, 1–500. Default 100

starting_afterstring

Cursor: id of the last row of the previous page

My transactions
curl "https://affiliateo.com/api/v1/me/transactions?type=sale,affiliate_commission" \
  --header 'Authorization: Bearer afk_...'
200
{
  "transactions": [{
    "id": "8f0c1d2e-3a4b-5c6d-7e8f-9a0b1c2d3e4f",
    "short_id": "pay_9Kq2mX",
    "type": "affiliate_commission",
    "status": "available",
    "direction": "credit",
    "amount_cents": 350,
    "gross_amount_cents": 999,
    "fee_cents": null,
    "balance_after_cents": 48350,
    "description": "Commission from Acme Inc",
    "created_at": "2026-06-10T14:22:31Z",
    "counterparty": { "type": "business", "id": "a1b2c3d4-...", "name": "Acme Inc", "avatar_url": null },
    "app": null,
    "payment_method": null,
    "payout": null
  }],
  "has_more": false,
  "limit": 100
}
One transaction
curl "https://affiliateo.com/api/v1/me/transactions/pay_7Tn4bW" \
  --header 'Authorization: Bearer afk_...'
200 (detail)
{
  "transaction": {
    "id": "1c2d3e4f-5061-7283-94a5-b6c7d8e9f0a1",
    "short_id": "pay_7Tn4bW",
    "type": "deposit",
    "status": "available",
    "direction": "credit",
    "amount_cents": 50000,
    "gross_amount_cents": 50000,
    "fee_cents": 145,
    "counterparty": null,
    "payment_method": { "type": "card", "brand": "visa", "last4": "4242" }
  },
  "receipt_url": "https://pay.stripe.com/receipts/...",
  "payment_method": { "type": "card", "brand": "visa", "last4": "4242", "billing_name": "Jane Doe", "billing_country": "US" }
}

My business summary

GET/api/v1/me/business-summary

The storefront side of your summary: your business-affiliate life in one call, across every business you promote, affiliation counts, lifetime totals (visitors, sales, revenue, commission, money net of refunds), and payout buckets. ?from=&to= adds a window with the net revenue, commission, and link clicks for that period (click history exists from July 2026 onward), the direct answer to "what did I earn from storefront sales this month". This is the storefront counterpart to /me/summary; your wallet balance lives there (it's one account-wide wallet). No per-day series here, since business clicks are counted per day, not logged per event.

Query parameters

from / toYYYY-MM-DD

Inclusive window (UTC days), both together; adds the window object

My business summary
curl "https://affiliateo.com/api/v1/me/business-summary?from=2026-06-01&to=2026-06-30" \
  --header 'Authorization: Bearer afk_...'
200
{
  "summary": {
    "affiliations": { "total": 3, "active": 3 },
    "totals": { "visitors": 640, "conversions": 22, "revenue_cents": 88000, "commission_cents": 17600 },
    "payouts": { "pending_cents": 2400, "payable_cents": 1600, "paid_cents": 13200, "refunded_cents": 0, "payable_count": 3 },
    "window": {
      "from": "2026-06-01", "to": "2026-06-30",
      "clicks": 340,
      "conversions": 6, "renewals": 2, "trials": 0, "refunds": 0,
      "revenue_cents": 24000, "commission_cents": 4800,
      "payouts": { "pending_cents": 1200, "payable_cents": 600, "paid_cents": 0, "refunded_cents": 0, "payable_count": 1 }
    }
  }
}

My business affiliations

GET/api/v1/me/business-affiliations

The storefront (business/product) side of your affiliate life: every business you're promoting as a storefront affiliate, with your referral links, commission, and lifetime stats. This is separate from /me/affiliations (which is the app program). ?business= (UUID or slug) narrows to one; &include=payouts adds each row's earnings buckets. Cursor-paginated. Render QR codes from links.short.

Query parameters

businessstring

Narrow to one business (UUID or slug)

includestring

payouts: adds the earnings buckets to every row

limitnumber

Page size, 1–500. Default 100

starting_afterstring

Cursor: id of the last row of the previous page

My business affiliations
curl "https://affiliateo.com/api/v1/me/business-affiliations?include=payouts" \
  --header 'Authorization: Bearer afk_...'
200
{
  "affiliations": [{
    "id": "b1f2...",
    "status": "active",
    "ref_code": "swiftpanda",
    "short_code": "aB3xY9k",
    "joined_at": "2026-06-02T18:04:12Z",
    "commission": { "type": "percentage", "rate": 20 },
    "links": {
      "short": "https://affiliateo.com/r/aB3xY9k",
      "storefront": "https://affiliateo.com/acme?ref=swiftpanda"
    },
    "business": { "slug": "acme", "name": "Acme Inc" },
    "stats": { "visitors": 229, "conversions": 12, "revenue_cents": 48000, "commission_cents": 9600 },
    "payouts": { "pending_cents": 1200, "payable_cents": 800, "paid_cents": 7600, "refunded_cents": 0, "payable_count": 2 }
  }],
  "has_more": false,
  "limit": 100
}

My business conversions

GET/api/v1/me/business-conversions

Your storefront-affiliate sales across every business you promote: date, type, the product, amount, commission, and payout status, each tagged with the business it came from. ?business= narrows to one; ?from=&to= to a date window. Refunds and chargebacks are separate negative rows. No customer data is included.

Query parameters

businessstring

Narrow to one business (UUID or slug)

from / toYYYY-MM-DD

Inclusive date window (UTC days), both together

typestring

Comma-separated conversion types: any of subscription, one_time, renewal, trial, refund, chargeback. Filtered upstream of the cursor

limitnumber

Page size, 1–500. Default 100

starting_afterstring

Cursor: id of the last row of the previous page

My business conversions
curl "https://affiliateo.com/api/v1/me/business-conversions?from=2026-06-01&to=2026-06-30" \
  --header 'Authorization: Bearer afk_...'
200
{
  "conversions": [{
    "id": "c0a8...",
    "conversion_type": "subscription",
    "amount_cents": 10000,
    "commission_cents": 1000,
    "status": "confirmed",
    "payout_status": "payable",
    "created_at": "2026-06-10T14:22:31Z",
    "product_name": "Pro plan",
    "pricing_option_label": "Monthly",
    "original_conversion_id": null,
    "business_slug": "acme",
    "business_name": "Acme Inc"
  }],
  "has_more": false,
  "limit": 100
}

Get yourself paid-ready

POST/api/v1/me/verification-link

The self-serve twin of the app-affiliate Get an affiliate paid-ready endpoint: mint a link for yourself with your own key. It opens the same hosted flow (Stripe payout account + identity verification, whichever you still need) and redirects to your return_url when done. No emailed code here: your own key already proves who you are, so the page opens directly. Requires a read-write key.

Body

return_urlstring

Required. Where to return when done: an https:// URL or a mobile deep link

langstring

Optional. A BCP-47 language tag such as "fr" or "pt-BR" (regional tags fall back to the base language). The hosted portal renders in this language when supported, otherwise English.

Mint my verification link
curl -X POST "https://affiliateo.com/api/v1/me/verification-link" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "return_url": "https://myapp.com/wallet?done=1"
}'
200
{
  "url": "https://affiliateo.com/verify/onboard/eyJhbGciOi...",
  "expires_in": 3600,
  "verified": false
}

Cash out yourself

POST/api/v1/me/withdrawal-link

The self-serve twin of Let an affiliate cash out: mint a withdrawal link for yourself with your own key. It opens the same hosted flow (add or pick a bank, then withdraw your account wallet balance, $20 minimum) and redirects to your return_url when done. No emailed code and no sign-in here: your own key already proves who you are, so the page opens directly. withdrawable_cents in the response is your own available balance right now, so you can decide whether to even show the button. Requires a read-write key.

Body

return_urlstring

Required. Where to return when done: an https:// URL or a mobile deep link

langstring

Optional. A BCP-47 language tag such as "fr" or "pt-BR" (regional tags fall back to the base language). The hosted portal renders in this language when supported, otherwise English.

Mint my withdrawal link
curl -X POST "https://affiliateo.com/api/v1/me/withdrawal-link" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "return_url": "https://myapp.com/wallet?cashout=1"
}'
200
{
  "url": "https://affiliateo.com/verify/withdraw/eyJhbGciOi...",
  "expires_in": 3600,
  "withdrawable_cents": 4215
}

Webhooks

Get pushed instead of polling: register an HTTPS receiver and Affiliateo POSTs a signed event to it whenever an affiliate joins one of your apps, passes identity verification, or a tracked sale (or refund) lands. Deliveries go out within about two minutes of the event and are retried with backoff for roughly three days if your receiver is down. Webhooks are optional; every event is also visible through the GET endpoints above.

Manage endpoints

POST/api/v1/webhooks

POST registers a receiver. The secret (afwh_…) appears exactly once in the 201 response: store it server-side and use it to verify every delivery's signature. GET lists your endpoints (never with secrets), PATCH /api/v1/webhooks/{webhookId} updates the url or events or flips status, and DELETE removes one. Up to 10 endpoints per account.

By default an endpoint receives events for every business you own; pass business_slug to pin it to one. An endpoint that fails continuously for days is auto-disabled (you'll see status: "disabled" and a disabled_reason); fix your receiver and PATCH it back to "active", which resets the failure counter.

Body (POST / PATCH)

urlstring

HTTPS receiver URL. Private/internal hosts are rejected

eventsstring[]

Which events to receive. Default: all four

business_slugstring

POST only. Pin the endpoint to one business

statusstring

PATCH only. active | disabled

Register a webhook
curl -X POST "https://affiliateo.com/api/v1/webhooks" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "url": "https://api.acme.com/affiliateo/webhook",
  "events": [
    "affiliate.created",
    "affiliate.verified",
    "conversion.created",
    "conversion.reversed"
  ]
}'
201
{
  "webhook": {
    "id": "a3b1c5d7-2e4f-4a6b-8c0d-1e2f3a4b5c6d",
    "url": "https://api.acme.com/affiliateo/webhook",
    "events": [
      "affiliate.created",
      "affiliate.verified",
      "conversion.created",
      "conversion.reversed"
    ],
    "business_slug": null,
    "status": "active",
    "created_at": "2026-07-11T22:41:03Z"
  },
  "secret": "afwh_4f8a...e2d1"
}
List webhooks
curl "https://affiliateo.com/api/v1/webhooks" \
  --header 'Authorization: Bearer afk_...'
Delete a webhook
curl -X DELETE "https://affiliateo.com/api/v1/webhooks/a3b1c5d7-2e4f-4a6b-8c0d-1e2f3a4b5c6d" \
  --header 'Authorization: Bearer afk_...'

Events & signatures

Every delivery is a POST with a JSON envelope: a deterministic id (the same event never has two ids, so you can deduplicate on it), a type, the event time, and the data object.

Verify the signature before trusting a delivery. The Affiliateo-Signature header carries a unix timestamp and an HMAC-SHA256 of "{t}.{body}" keyed with your afwh_ secret. Recompute it over the raw request body, compare with a constant-time comparison, and reject stale timestamps (older than ~5 minutes) to block replays. Respond with any 2xx quickly; do slow work after acknowledging.

Event types

affiliate.createdevent

Someone became an affiliate of one of your apps (embedded signup, your site, or a referral link)

affiliate.verifiedevent

An affiliate passed identity verification. Fires once per affiliate per app: on the verification itself, or at enrollment when they were already verified. Deduplicate on the event id

conversion.createdevent

A tracked sale, renewal, or trial landed for one of your affiliates

conversion.reversedevent

A refund or chargeback reversed a tracked sale (negative amounts, linked via original_conversion_id)

Event envelope
POST https://api.acme.com/affiliateo/webhook
Affiliateo-Signature: t=1783202463,v1=5257a869e7...
Affiliateo-Event: conversion.created

{
  "id": "evt_conv_c0a80121-7ac0-4e1c-9d5f-2b3c4d5e6f70",
  "type": "conversion.created",
  "created": "2026-07-11T22:41:03Z",
  "data": {
    "conversion": {
      "id": "c0a80121-7ac0-4e1c-9d5f-2b3c4d5e6f70",
      "conversion_type": "subscription",
      "amount_cents": 999,
      "commission_cents": 350,
      "currency": "usd",
      "product_name": "Premium Monthly",
      "billing_interval": "month",
      "original_conversion_id": null,
      "created_at": "2026-07-11T22:40:12Z"
    },
    "affiliate": {
      "id": "9f2c7e1a-4b60-4f0e-9a75-0d1c2e3f4a5b",
      "ref_code": "swiftpanda"
    },
    "app": {
      "id": "7d1e5a2c-9b3f-4c8d-a1e2-6f7a8b9c0d1e",
      "slug": "acme-chat"
    }
  }
}
affiliate.verified event
{
  "id": "evt_affverified_9f2c7e1a-4b60-4f0e-9a75-0d1c2e3f4a5b",
  "type": "affiliate.verified",
  "created": "2026-07-11T22:41:03Z",
  "data": {
    "affiliate": {
      "id": "9f2c7e1a-4b60-4f0e-9a75-0d1c2e3f4a5b",
      "ref_code": "swiftpanda",
      "verified": true
    },
    "app": {
      "id": "7d1e5a2c-9b3f-4c8d-a1e2-6f7a8b9c0d1e",
      "slug": "acme-chat",
      "name": "Acme Chat"
    },
    "verified_at": "2026-07-11T22:41:03Z"
  }
}
Verify (Node.js)
import crypto from 'crypto'

function verifyWebhook(req, secret) {
  const header = req.headers['affiliateo-signature'] // t=...,v1=...
  const t = header.match(/t=(\d+)/)?.[1]
  const v1 = header.match(/v1=([a-f0-9]+)/)?.[1]
  if (!t || !v1) return false
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${t}.${req.rawBody}`)
    .digest('hex')
  return crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected))
}

Meta Ads

Full management: read performance, inspect the live campaign tree, launch new campaigns, pause/resume, re-budget, upload creatives, enable conversion tracking, manage spend caps and automated rules, build custom audiences, and estimate reach before spending. Requires the business's Meta connection to have ad-management access (connected via the Meta Ads app in the dashboard).

Ad stats & ROAS

GET/api/v1/businesses/{slug}/ads?network=meta

Aggregated spend, impressions, clicks, installs, attributed revenue, and ROAS, grouped by level. roas is revenue ÷ spend and is null until revenue attribution is ready. Money fields are micros (1,000,000 = 1 currency unit).

Two revenue numbers come back side by side. revenueMicros / roas are verified: computed from sales Affiliateo itself recorded and tied to an ad click. reportedConversions / reportedRevenueMicros / reported_roas are what the network claims from its own pixel or conversion tracking (self-reported, usually generous; null or 0 when the network reported nothing). Apple Ads reports no revenue, so its reported fields stay empty.

auctionShare answers how much of the auction you won and why you missed the rest, on totals and on every row. limitedBy is the field to act on: "rank" means your bid or ad quality was too low to enter the auction (raise the bid), "budget" means you ran out of money that day (raise the budget). The two have opposite fixes, so guessing between them is how a campaign gets changed in the wrong direction. All ratios are 0–1. eligibleImpressions is the estimated number you could have had.

Google only. Meta, TikTok and Snapchat publish no equivalent and never will: an auction-share number needs a fixed slate of competitors bidding on the same query, which is a search-results idea, not a feed one. Apple Search Ads has one, but as a separate asynchronous report at /api/v1/businesses/{slug}/ads/impression-share. On every other network auctionShare is null, and null never means zero.

Two honesty flags worth reading before you trust a number. approximate: true means at least one day was censored by Google, which reports anything under 10% as exactly 0.0999 and anything over 90% as 0.9001, in the API and in its own dashboard alike, with no way to get the real figure. When the floor is involved your true share is lower and your true loss higher than shown. And auctionShare is null for the most recent day or two even on Google, because impression share is estimated on a 1–2 day lag; that is a pending estimate, not a campaign that reached nobody.

auctionShare fields (Google)

impressionSharenumber

Share of eligible impressions you served, 0–1

lostToRanknumber | null

Missed because the bid or ad quality was too low. Fix: bid more

lostToBudgetnumber | null

Missed because the daily budget ran out. Fix: budget more. Campaign level only, because Google does not report it for ad groups or keywords

topImpressionSharenumber | null

Share of eligible top-of-page impressions. Approximated: Google publishes no eligible-top denominator

absoluteTopImpressionSharenumber | null

Same, for the very first ad slot

clickSharenumber | null

Share of eligible clicks won. Has its own denominator, so it can appear on a day impression share does not

eligibleImpressionsnumber

Estimated impressions you could have had across the window

daysnumber

Days that carried an estimate. Lower than the window when Google has not caught up

approximateboolean

True when any day hit Google’s "< 10%" / "> 90%" markers

limitedBy"rank" | "budget" | null

The bigger of the two losses. null when neither was reported or they are equal

Query parameters

networkstring

meta (default) | tiktok | apple_search_ads | google

startYYYY-MM-DD

Window start. Default: 29 days before end (a 30-day window)

endYYYY-MM-DD

Window end. Default: today (UTC)

Get ad stats
curl "https://affiliateo.com/api/v1/businesses/acme/ads?network=meta&start=2026-06-11&end=2026-07-10" \
  --header 'Authorization: Bearer afk_...'
200
{
  "network": "meta",
  "range": { "start": "2026-06-11", "end": "2026-07-10" },
  "currency": "USD",
  "revenue_ready": true,
  "reported_ready": true,
  "totals": {
    "spendMicros": 41250000,
    "impressions": 91203,
    "clicks": 2311,
    "installs": 402,
    "revenueMicros": 96500000,
    "roas": 2.34,
    "reportedConversions": 44,
    "reportedRevenueMicros": 131200000,
    "reported_roas": 3.18,
    "auctionShare": null
  },
  "level_order": ["campaign", "adgroup", "ad"],
  "by_level": {
    "campaign": [{
      "id": "120210000000",
      "name": "Summer launch",
      "spendMicros": 41250000,
      "revenueMicros": 96500000,
      "roas": 2.34,
      "reportedConversions": 44,
      "reportedRevenueMicros": 131200000,
      "reported_roas": 3.18,
      "auctionShare": null
    }]
  }
}
200 with auctionShare on Google
{
  "network": "google",
  "totals": {
    "spendMicros": 47320000,
    "impressions": 1336,
    "clicks": 33,
    "auctionShare": {
      "impressionShare": 0.1084,
      "lostToRank": 0.5219,
      "lostToBudget": 0.3697,
      "topImpressionShare": 0.0999,
      "absoluteTopImpressionShare": 0.0999,
      "clickShare": null,
      "eligibleImpressions": 12324,
      "days": 5,
      "approximate": true,
      "limitedBy": "rank"
    }
  }
}

Campaign tree

GET/api/v1/businesses/{slug}/ads/campaigns

The live campaign → ad set → ad tree straight from Meta, as a flat list. Each entity carries kind, status (ACTIVE/PAUSED), effectiveStatus (includes review states), dailyBudgetCents, parentId, and campaignId. Rebuild the tree from the parent links if you need it nested.

Always check tree_error before trusting the list. The tree is built top-down: campaigns first, then a second pass for everything underneath them. If that second pass fails, the response is still a 200 carrying the campaigns it already had, with tree_error: true. Treating that as a complete list is how an agent concludes an ad set was deleted when it was only unread, and then recreates it. tree_error_reason carries the network’s own message when it gave one, so the failure can be logged or retried on rather than guessed at.

Applies to meta, apple_search_ads, tiktok and snapchat. Google is the exception: its tree arrives in a single call, so it either returns in full or fails with a 502 carrying Google’s message, and it has no tree_error field to check.

Get campaign tree
curl "https://affiliateo.com/api/v1/businesses/acme/ads/campaigns" \
  --header 'Authorization: Bearer afk_...'
200
{
  "ad_account_id": "act_1234567890",
  "currency": "USD",
  "pixel_id": "987654321",
  "tree_error": false,
  "tree_error_reason": null,
  "entities": [
    {
      "id": "120210000000",
      "kind": "campaign",
      "name": "Summer launch",
      "status": "ACTIVE",
      "effectiveStatus": "ACTIVE",
      "dailyBudgetCents": null,
      "parentId": null,
      "campaignId": "120210000000"
    },
    {
      "id": "120210000001",
      "kind": "adset",
      "name": "Summer launch ad set",
      "status": "ACTIVE",
      "dailyBudgetCents": 2500,
      "parentId": "120210000000",
      "campaignId": "120210000000"
    }
  ]
}

Launch a campaign

POST/api/v1/businesses/{slug}/ads/campaigns

Creates the full stack (campaign, ad set, creative, ad) in one call, with the same validation as the in-app wizard. Requires a read & write key. The campaign is created PAUSED on every network, so nothing spends until you activate it via the entity endpoint — that one switch is the gate. Below it the launchers differ: Google's Search stack creates its ad group, ad and keywords ENABLED on purpose, because a keyword that arrives paused inside its own ad group is a silent no-op you would have to undo by hand before the campaign could do anything. Upload media first. The sales objective requires the conversion pixel — and it is the only one that does: an app campaign optimises against an app id rather than a pixel, so it launches fine on a connection that has never had one. If any step fails, the freshly created campaign is rolled back, so a failed launch never leaves a half-built stack.

Campaigns created through this endpoint (any network) carry the 1% ads platform fee on their spend, metered from the business wallet as spend syncs. So does any campaign you manage through Affiliateo: a successful write on it or anything under it — through this API, the dashboard or an MCP agent, a pause included — starts metering that campaign from that day forward. Creating a child object counts the same way: adding an ad set, ad group, ad, keyword or criterion inside a campaign you 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 into the campaign's. It is never retroactive, and connecting an account to read stats costs nothing. Unpaid fees (including estimates on the last days of spend the wallet can't cover) pause the ads endpoints with PAYMENT_REQUIRED (402) past a grace allowance; past the account's campaign-pause limit (starts at $50 and rises to $1,000 with clean payment history), the metered campaigns are paused on the networks themselves and can only be resumed once the balance is back to zero (top-ups collect automatically and unlock instantly). A campaign you have never created or written to through Affiliateo is never metered and never touched.

Accounts that signed up through a partner link pay no platform fee for their first 7 days, and ad spend is included. The trial is priced per spend DAY, not by when the meter runs: a day inside the window is written free and stays free even though fees settle a couple of days behind, so a sync or a cron catching up after the trial ended never bills it. See Partner program.

Two writes are refused while any billed fee is outstanding, however small and well below that grace allowance: 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. This endpoint is unaffected: it creates every campaign paused on all five networks, so it could not have started spend anyway. The refusal is there for field writes, which can set a status at create.

The same endpoint also creates campaigns on the other four networks: ?network=apple_search_ads (see Create campaigns), ?network=google (see Create campaigns, pending Google approval), ?network=tiktok (see Launch campaigns), and ?network=snapchat (see Launch campaigns). The objective field is not the same on any two of them, and three read an unrecognised value as traffic rather than refusing it, so follow the link for the network you are launching on rather than reusing this body's vocabulary.

Meta is the only network whose placement choice this endpoint collects and puts on the wire, which is a narrower claim than the one that used to be made here and is the only one that survives reading the other four. adset.publisherPlatforms is carried end to end. It is not that the others have no placement field: Snapchat declares placement_v2 on the ad squad and Google declares network_settings on the campaign, both of them mutable through field writes. The launch body simply does not ask for either, so a placement decision on those two is made after the campaign exists rather than inside it.

Three of this body's fields moved from API-only to also-in-the-dashboard this phase. specialAdCategoryCountry is a row of country chips that appears once a regulated category is declared, contextualMultiAds is a control on the creative step (default UNSET, which is not OPT_OUT), and the fanOut.extraAds half of the creative fan-out is the launcher's creative test. fanOut.extraAdGroups did not move and is still API-only.

Body

campaignNamestring

Required. Campaign display name

objectivestring

'traffic' (default, link clicks) | 'sales' (pixel purchase optimization) | 'awareness' (reach) | 'engagement' (post engagement) | 'video_views' (ThruPlay — 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' (installs — needs adset.app). Meta’s sixth objective, OUTCOME_LEADS, is not offered, and the reason splits in two: the INSTANT FORM half is permission-blocked (pages_manage_ads to create one, leads_retrieval to read the submissions back, and App Review declined both), so we could create that campaign and never hand a single lead back to you. The other half is ours. A lead ad that sends people to YOUR OWN WEBSITE needs no permission Meta has withheld and is simply NOT BUILT YET, which is a different fact with a different remedy. Google, TikTok and Snapchat run lead campaigns today; Apple Search Ads has no lead form anywhere in its API

productstring

'manual' (default — budget on the ad set, audience exactly as configured) or 'advantage_plus' — the budget moves onto the campaign and is shared across ad sets, and Meta picks the audience and the placements. Only on the 'sales' and 'app_promotion' objectives, since those are the two Meta derives an Advantage+ state for; anything else is refused rather than downgraded into a standard campaign wearing the badge

specialAdCategoriesstring[]

Regulated-advertising declaration, e.g. ["HOUSING"]. One or more of NONE, EMPLOYMENT, HOUSING, CREDIT, ISSUES_ELECTIONS_POLITICS, ONLINE_GAMBLING_AND_GAMING, FINANCIAL_PRODUCTS_SERVICES. Meta accepts this only at create — a campaign launched without it cannot be corrected afterwards

specialAdCategoryCountrystring[]

Two-letter codes saying which jurisdictions the regulated declaration is made for, e.g. ["US"]. WHY IT IS NOT OPTIONAL IN PRACTICE: Meta fills this in from the advertiser’s TAX country when it is absent, and a tax country is not a delivery country — an Irish company running housing ads in the United States had its compliance record naming Ireland, on a campaign that created, delivered and reported without one error. So a launch declaring a category other than NONE while targeting TWO OR MORE countries is now a 400 unless it sends this. One targeted country still derives itself and is unchanged. Sending it with no regulated category is also a 400, because it is a jurisdiction for a statement nobody made

contextualMultiAdsstring

Optional. 'OPT_IN' or 'OPT_OUT': whether this creative may appear inside Meta’s multi-advertiser ad units. UNSET IS NOT OPT_OUT — Meta enrols any creative created without an enroll_status, which is what every creative this product has ever made was, so opting out is something you have to say. Create-only: Meta takes enroll_status on the creative and a creative’s content cannot be edited afterwards

adset.publisherPlatformsstring[]

Optional. Where the ad set may run: facebook, instagram, threads, messenger, audience_network. Send NO list for automatic placements, which is every platform Meta has. An unknown value among known ones is dropped, but a list where NOTHING is recognised is a 400 rather than a silent widening — collapsing it to undefined would have bought every placement for a caller who asked for one. audience_network alone is refused, which is Meta’s own rule. Refused entirely alongside product 'advantage_plus', where the ABSENCE of this field is what makes the campaign Advantage+

fanOutobject

{ extraAds?, extraAdGroups? } — more of the same launch in one call. extraAds adds ads to the launch’s single ad group, each with its own media and optional primary text and headline that inherit the base ad’s when blank; extraAdGroups adds whole AD SETS, each with its own name, budget, age, gender, audiences and ads, inheriting from adset for anything it does not name. THE TWO HALVES DIFFER IN WHERE YOU CAN REACH THEM: extraAds now has a dashboard screen too (the launcher’s creative test, capped at four extra ads there and uncapped here, and every field except the media inherits from the base ad). extraAdGroups is API-only and is described that way on purpose — a second budget and a second audience is a different question, and the launch dialog does not ask it anywhere

adset.dailyBudgetCentsnumber

Required, min 100 (= 1.00 in the account currency). On an Advantage+ launch this becomes the CAMPAIGN budget — same field, one level up

adset.countriesstring[]

Required. ISO-2 country codes, e.g. ["US","CA"]. Still a hard boundary on Advantage+

adset.ageMin / ageMaxnumber

Optional, 18-65. Omit both and no age bound is sent at all — Meta’s own default range is wider than 18-65, so an explicit 65 maximum excludes everyone above it. Refused alongside product "advantage_plus", which hands the audience to Meta

adset.genderstring

'all' (default) | 'men' | 'women'. Refused alongside product 'advantage_plus'

adset.endDateYYYY-MM-DD

Optional end date. It means the END OF THAT DAY ON THE AD ACCOUNT’S OWN CLOCK, not on UTC — an ad set in Los Angeles ending on the 30th stops at 23:59:59 Pacific, which is 07:59 UTC on the 1st. Meta only tells us which clock that is if we ask, so a launch naming an end date makes ONE extra Graph read and a launch without one makes none. Survives Advantage+ — Meta automates who sees the ad, not for how long

adset.appobject

Required by objective 'app_promotion'. { applicationId, objectStoreUrl } — the numeric Meta app id and the store listing it belongs to. Meta requires both and checks that the listing matches the app

adset.audienceIdsstring[]

Optional. Custom Audiences to target, from GET …/ads/lookups?network=meta&type=audience. NEVER SEND AN EMPTY ARRAY — omit the field: Meta reads custom_audiences: [] as a filter that matched nobody, which is an ad set created successfully, reported as healthy, that cannot deliver. Refused alongside product "advantage_plus", which hands the audience to Meta

adset.excludedAudienceIdsstring[]

Optional. The exclusion side, same source and same empty-array rule. The SAME id on both sides is refused rather than sent: Meta evaluates both and reaches nobody in that audience, silently

ad.pageIdstring

Required. Facebook Page the ad publishes as

ad.primaryTextstring

Required. Primary ad copy

ad.headline / descriptionstring

Optional

ad.destinationUrlstring

Required. https:// landing URL

ad.ctastring

LEARN_MORE (default), SIGN_UP, GET_STARTED, SUBSCRIBE, SHOP_NOW, DOWNLOAD, CONTACT_US

ad.mediaobject

{ type: 'image', imageHash } or { type: 'video', videoId } from Upload creative. A video may also name its COVER FRAME with thumbnailId, whose candidates come from GET …/ads/media?video={videoId} — omit it and Meta picks, exactly as before

ad.media (boost)object

{ type: 'existing_post', objectStoryId: '{page_id}_{post_id}' } BOOSTS A POST YOU ALREADY PUBLISHED instead of building a new ad — and then primaryText, headline, destinationUrl, cta and the upload must all be omitted, because the post already carries them 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, which App Review has not granted; a post belonging to a different Page is well-formed and is refused before anything is created. Not available under product 'advantage_plus' (it manages its own ads) or objective 'video_views' (nothing we can read says whether the post is a video)

adset.billingEventstring

Optional. What Meta charges for. Defaults to IMPRESSIONS, which is what every ad set this product has ever created was billed on; THRUPLAY is the one worth knowing about on a video ad set

adset.destinationTypestring

Optional. Meta's conversion location — where the click lands. NEVER SENT before this, so Meta inferred one, which it does well for an ordinary website ad and unreliably for anything else. Meta's full 23-value enum is accepted; the three messaging values (MESSENGER, WHATSAPP, INSTAGRAM_DIRECT) are passed through and will be refused BY META, because a click-to-message ad additionally needs pages_manage_ads and pages_read_engagement, neither granted, and Instagram Direct needs instagram_basic on top

Launch a campaign
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/campaigns" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "campaignName": "Summer launch",
  "objective": "traffic",
  "adset": {
    "dailyBudgetCents": 2500,
    "countries": [
      "US"
    ]
  },
  "ad": {
    "pageId": "1234567890",
    "primaryText": "Meet the fastest way to ...",
    "destinationUrl": "https://acme.com",
    "media": {
      "type": "image",
      "imageHash": "abc123..."
    }
  }
}'
201
{
  "campaignId": "120210000000",
  "adsetId": "120210000001",
  "adId": "120210000002",
  "status": "PAUSED",
  "note": "Created paused. PATCH the campaign with {\"kind\":\"campaign\",\"status\":\"ACTIVE\"} to start spending."
}

Pause, resume, budgets & archive

PATCH/api/v1/businesses/{slug}/ads/entities/{id}

Updates one entity at any level. This is the endpoint that starts real spend (flipping PAUSED → ACTIVE), so it requires a read & write key.

ARCHIVED is Meta's "delete": the entity stops delivering, disappears from the campaign tree, and keeps all its reporting and revenue history. It is one-way: Meta never allows archived objects back to ACTIVE or PAUSED. Pause instead unless you're sure.

Campaigns also take a lifetime spend cap: { "spendCapCents": 15000 } caps the campaign's total spend (minimum 10000 = 100.00), { "spendCapCents": null } removes the cap.

Body

kindstring

Required. 'campaign' | 'adset' | 'ad'

statusstring

'ACTIVE', 'PAUSED', or 'ARCHIVED' (one-way)

dailyBudgetCentsnumber

Min 100. Not valid for kind=ad (ads have no budget of their own)

spendCapCentsnumber | null

Campaigns only. Lifetime spend cap in cents, min 10000 (= 100.00); null removes it

Activate a campaign
curl -X PATCH "https://affiliateo.com/api/v1/businesses/acme/ads/entities/120210000000" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "kind": "campaign",
  "status": "ACTIVE"
}'
200
{
  "ok": true,
  "network": "meta",
  "id": "120210000000",
  "kind": "campaign",
  "applied": { "status": "ACTIVE" }
}

Facebook Pages

GET/api/v1/businesses/{slug}/ads/pages

The Facebook Pages the connected Meta login manages. A launch needs one of these ids as ad.pageId, the identity the ad publishes as.

List Facebook Pages
curl "https://affiliateo.com/api/v1/businesses/acme/ads/pages" \
  --header 'Authorization: Bearer afk_...'
200
{ "pages": [{ "id": "1234567890", "name": "Acme" }] }

Conversion pixel

POST/api/v1/businesses/{slug}/ads/pixel

GET returns the current pixel; POST enables conversion tracking by adopting the ad account's existing pixel or creating one (Meta allows exactly one per account). Idempotent; requires a read & write key. Enabling it unlocks the sales objective, and recorded sales start flowing to Meta server-side via the Conversions API automatically.

Get pixel status
curl "https://affiliateo.com/api/v1/businesses/acme/ads/pixel" \
  --header 'Authorization: Bearer afk_...'
Enable conversion tracking
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/pixel" \
  --header 'Authorization: Bearer afk_...'
200
{ "ok": true, "pixel_id": "987654321" }

Sync stats

POST/api/v1/businesses/{slug}/ads/sync?network=meta

Pulls the freshest insights from Meta into Affiliateo right now (stats otherwise refresh nightly). Read keys may call it: it refreshes data, it doesn't spend.

Sync Meta stats
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/sync?network=meta" \
  --header 'Authorization: Bearer afk_...'
200
{ "ok": true, "network": "meta", "inserted": 412 }

Billing & spend cap

GET/api/v1/businesses/{slug}/ads/billing?network=meta

The ad account's money state: balance, lifetime spend, spend cap, prepay flag, and the payment method label. Read-only by design: no ads API (Meta's included, any vendor's) can add funds or manage payment methods. That always happens in Meta Ads Manager. spendCapCents is null when no cap is set.

PATCH manages the one money control that does exist, the account spend cap. Requires a read & write key; responds with ok plus the fresh billing fields.

Body (PATCH), one of

spendCapCentsnumber | null

Set the cap (counts spend from now on, not historically). Send null to remove it

resetSpentboolean

true zeroes the cap's spend counter

Get billing state
curl "https://affiliateo.com/api/v1/businesses/acme/ads/billing?network=meta" \
  --header 'Authorization: Bearer afk_...'
Set an account spend cap
curl -X PATCH "https://affiliateo.com/api/v1/businesses/acme/ads/billing?network=meta" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "spendCapCents": 50000
}'
200
{
  "network": "meta",
  "ad_account_id": "act_1234567890",
  "currency": "USD",
  "balanceCents": 12050,
  "amountSpentCents": 384200,
  "spendCapCents": null,
  "minDailyBudgetCents": 100,
  "isPrepay": false,
  "fundingSource": "Visa *1234",
  "fundingType": "CREDIT_CARD",
  "note": "Read-only: funds and payment methods can only be managed in Meta Ads Manager (no API exists, for anyone)."
}

Automated rules

POST/api/v1/businesses/{slug}/ads/rules

Standing automation that runs on Meta's side, on Meta's schedule: rules keep working long after the agent session that created them is gone. The example pauses any ad set whose cost per purchase blows past 40.00 over the last 3 days (money values in filters are cents).

GET lists the account's rules (id, name, status, evaluationSpec, executionSpec, scheduleSpec, createdTime). PATCH …/rules/{ruleId} renames, re-specs, or flips status between ENABLED and DISABLED and returns { ok, ruleId }; DELETE …/rules/{ruleId} removes the rule and returns { ok }. Writes require a read & write key.

Body (POST)

namestring

Required. Rule display name

evaluationSpec.evaluationTypestring

'SCHEDULE' (evaluated on a schedule) or 'TRIGGER' (evaluated when metrics change)

evaluationSpec.filtersarray

Required. Meta filter objects; an entity_type filter is required. Money values are cents

executionSpec.executionTypestring

PAUSE, UNPAUSE, NOTIFICATION, CHANGE_BUDGET, CHANGE_CAMPAIGN_BUDGET, CHANGE_BID, REBALANCE_BUDGET, ROTATE

scheduleSpecobject

Optional custom schedule. SCHEDULE rules default to a DAILY check when omitted

List rules
curl "https://affiliateo.com/api/v1/businesses/acme/ads/rules?network=meta" \
  --header 'Authorization: Bearer afk_...'
Create a rule
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/rules" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "name": "Pause expensive ad sets",
  "evaluationSpec": {
    "evaluationType": "SCHEDULE",
    "filters": [
      {
        "field": "entity_type",
        "value": "ADSET",
        "operator": "EQUAL"
      },
      {
        "field": "time_preset",
        "value": "LAST_3_DAYS",
        "operator": "EQUAL"
      },
      {
        "field": "cost_per_website_purchase",
        "value": 4000,
        "operator": "GREATER_THAN"
      }
    ]
  },
  "executionSpec": {
    "executionType": "PAUSE"
  }
}'
Disable a rule
curl -X PATCH "https://affiliateo.com/api/v1/businesses/acme/ads/rules/978100000000" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "status": "DISABLED"
}'
Delete a rule
curl -X DELETE "https://affiliateo.com/api/v1/businesses/acme/ads/rules/978100000000" \
  --header 'Authorization: Bearer afk_...'
201
{ "ruleId": "978100000000", "network": "meta", "status": "ENABLED" }

Custom audiences

POST/api/v1/businesses/{slug}/ads/audiences

GET lists the account's audiences with size bands (approximateLowerBound / approximateUpperBound), subtype, operationStatus, retentionDays, and timeUpdated. POST creates one of four kinds: website (pixel visitors; needs the pixel enabled; no filter = all visitors), engagement (everyone who engaged with a Facebook Page), app_activity (everyone a mobile app's SDK saw — the appId is a Meta app id, not an App Store or Play id, and those two namespaces are not interchangeable), or lookalike (people similar to a seed audience).

Customer-list uploads (hashed emails/phones) are deliberately not offered: rule-based audiences cover the same jobs without customer PII flowing through us. One-time gate: the ad account owner must accept Meta's Custom Audience terms once; the error message links straight to the accept page.

Three other networks answer this same path — TikTok with its own vocabulary, Google and Snapchat with a shared one. See Audiences, across the networks for the cross-network view, the machine-readable capability catalogue, and — the part worth reading before you build one — how to point a campaign at it at create.

DELETE …/audiences/{audienceId} returns { ok }. Meta refuses the delete while live ad sets still target the audience.

Body (POST)

kindstring

Required. 'website' | 'engagement' | 'app_activity' | 'lookalike'

namestring

Required. Audience display name

retentionDaysnumber

website/engagement/app_activity: how long people stay in the audience. Max 180

eventstring

website/app_activity: optional event filter, e.g. 'Purchase'. On app_activity this is the PIXEL's event name, not the SDK's — Meta aggregates standard app events under those for audience rules

urlContainsstring

website: optional URL substring filter

pageIdstring

engagement: required. The Facebook Page whose engagers to collect

appIdstring

app_activity: required. A META app id from GET …/ads/lookups?network=meta&type=app — not an App Store or Play id, which is a different namespace Meta accepts and then matches against nobody

originAudienceIdstring

lookalike: required. The seed audience id

countrystring

lookalike: required. ISO-2 country the lookalike lives in

rationumber

lookalike: 0.01-0.20 (top 1% to 20% most similar people)

List audiences
curl "https://affiliateo.com/api/v1/businesses/acme/ads/audiences?network=meta" \
  --header 'Authorization: Bearer afk_...'
Create a website audience
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/audiences" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "kind": "website",
  "name": "Purchasers 30d",
  "retentionDays": 30,
  "event": "Purchase"
}'
Create a lookalike
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/audiences" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "kind": "lookalike",
  "name": "Lookalike US 5%",
  "originAudienceId": "238100000000",
  "country": "US",
  "ratio": 0.05
}'
Delete an audience
curl -X DELETE "https://affiliateo.com/api/v1/businesses/acme/ads/audiences/238100000000" \
  --header 'Authorization: Bearer afk_...'
201
{ "audienceId": "238100000000", "kind": "website", "network": "meta" }

Custom conversions

POST/api/v1/businesses/{slug}/ads/custom-conversions

URL-rule conversions on top of the pixel: "a pageview of /thank-you counts as a registration worth 49". GET lists them (id, name, customEventType, rule, defaultConversionValue, creationTime); DELETE …/custom-conversions/{conversionId} removes one and returns { ok }. Needs the conversion pixel enabled; Meta caps an account at 100 custom conversions. Writes require a read & write key.

The same path serves ?network=tiktok, but the words mean different things there: a Meta custom conversion is an object on the ad account, a TikTok one is an event rule living on a specific pixel, so every TikTok call needs a pixelId and the two create bodies share nothing. See TikTok custom conversions before reusing a body across networks.

Body (POST)

namestring

Required

urlContainsstring[]

Required. 1-10 URL substrings that count as this conversion

eventTypestring

Optional Meta event type, e.g. 'COMPLETE_REGISTRATION', 'LEAD', 'ADD_TO_CART'

defaultValuenumber

Optional value assigned to each conversion, in account currency

List custom conversions
curl "https://affiliateo.com/api/v1/businesses/acme/ads/custom-conversions?network=meta" \
  --header 'Authorization: Bearer afk_...'
Create a custom conversion
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/custom-conversions" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "name": "Signup complete",
  "urlContains": [
    "/thank-you"
  ],
  "eventType": "COMPLETE_REGISTRATION",
  "defaultValue": 49
}'
Delete a custom conversion
curl -X DELETE "https://affiliateo.com/api/v1/businesses/acme/ads/custom-conversions/120210000777" \
  --header 'Authorization: Bearer afk_...'
201
{ "conversionId": "120210000777", "network": "meta" }

Scheduled budget boosts

GET/api/v1/businesses/{slug}/ads/budget-schedules

Raise a campaign's or ad set's budget for a window — Meta's high demand periods. This is not the campaign field budget_schedule_specs, and the difference is the whole reason the endpoint exists: that field is create-only, so a boost could be declared in the same breath as the campaign and never afterwards. This works on campaigns that already exist.

Three facts from Meta's own reference make every schedule a one-way write. A schedule cannot be deleted, cannot be updated, and does not recur — so "every Friday" is twelve permanent objects, not one recurring one. That is why there is no polite PATCH or DELETE here answering 501: the API should not suggest a repair that does not exist, so those verbs return 405 with an Allow header. It is also why a window that has already ended is refused rather than posted. To undo one, lower the budget or pause during that window.

Meta only. The other four answer 501 with the reason: Google models scheduled budgets on campaign budget resources, TikTok and Snapchat publish no equivalent, and Apple has no budget schedule of any kind.

Body (POST)

parentIdstring

Required. A campaign id or an ad set id — Meta hangs the schedule off either

budgetValuenumber

Required. With ABSOLUTE, an amount in cents that we scale to the ad account currency for you; with MULTIPLIER, a multiple of the existing budget, so 2 is twice

budgetValueTypestring

Required. 'ABSOLUTE' or 'MULTIPLIER'. They travel as one object precisely because only the first is money and only the first is scaled

timeStart / timeEndnumber

Required. Unix seconds. A window that has already ended is refused, since it is the one check that can prevent an unremovable mistake

Query parameters (GET)

parentIdstring

Required. The campaign or ad set whose schedules you want

networkstring

meta (default). The other four answer 501 with their own reason

List schedules on a campaign
curl "https://affiliateo.com/api/v1/businesses/acme/ads/budget-schedules?parentId=120210000001" \
  --header 'Authorization: Bearer afk_...'
Double the budget for Black Friday
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/budget-schedules" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "parentId": "120210000001",
  "budgetValue": 2,
  "budgetValueType": "MULTIPLIER",
  "timeStart": 1764028800,
  "timeEnd": 1764288000
}'
201
{
  "id": "120210000777",
  "parentId": "120210000001",
  "note": "Meta publishes no update and no delete for a budget schedule. This object is permanent."
}

Delivery estimate

GET/api/v1/businesses/{slug}/ads/delivery-estimate

How many people a targeting spec reaches, before spending anything: the sanity check an agent runs between picking targeting and launching. Takes the same simple targeting fields the launch takes and echoes back the Meta targeting spec it built.

Query parameters

countriesstring

Required. Comma-separated ISO-2 codes, e.g. US,CA

ageMin / ageMaxnumber

18-65, defaults 18/65

genderstring

'all' (default) | 'men' | 'women'

interestIdsstring

Optional comma-separated interest ids from targeting search

optimizationGoalstring

LINK_CLICKS (default), REACH, IMPRESSIONS, LANDING_PAGE_VIEWS, OFFSITE_CONVERSIONS, LEAD_GENERATION

Estimate audience size
curl "https://affiliateo.com/api/v1/businesses/acme/ads/delivery-estimate?countries=US,CA&ageMin=18&ageMax=65&gender=all&interestIds=6003139266461&optimizationGoal=LINK_CLICKS" \
  --header 'Authorization: Bearer afk_...'
200
{
  "network": "meta",
  "optimizationGoal": "LINK_CLICKS",
  "targeting": {
    "geo_locations": { "countries": ["US", "CA"] },
    "age_min": 18,
    "age_max": 65,
    "flexible_spec": [{ "interests": [{ "id": "6003139266461" }] }]
  },
  "estimateReady": true,
  "audienceLowerBound": 21400000,
  "audienceUpperBound": 25200000,
  "dailyActive": null
}
GET/api/v1/businesses/{slug}/ads/targeting-search

One lookup endpoint for every id that targeting specs are built from, dispatched by network + type.

Prefer id lookups for anything new. This endpoint answers in a different shape per networkid here, adamId there, locationId somewhere else — so a caller wanting one picker has to write four readers. It covers eight lookups on four of them; …/ads/lookups covers those same eight in one shape plus everything the other registries declare, Snapchat included. This one stays because the campaign launcher calls it and its responses are what existing integrations parse; nothing about it has changed.

Meta (default): type=interest returns interest ids with audience-size bands; type=location returns location keys ({ key, name, type, countryCode, countryName, region }), and it's the key that goes into targeting specs. Apple Search Ads: type=app (min 3 characters) returns the adamId campaign creation needs; type=geo with country + entity returns pipe-format geo ids ({ id: "US|CA|Cupertino", entity, displayName }) for ad group targeting. TikTok: type=region returns { locationId, name, level } for the launch endpoint. Google Ads: type=geo and type=language, documented under Google Ads targeting search.

Query parameters

networkstring

meta (default) | apple_search_ads | tiktok | google

typestring

Meta: interest | location. Apple: app | geo. TikTok: region. Google: geo | language

qstring

Required. Search text, min 2 characters (3 for Apple)

countrystring

Apple geo only: ISO-2 country, default US

entitystring

Apple geo only: 'AdminArea' or 'Locality' (default)

Search Meta interests
curl "https://affiliateo.com/api/v1/businesses/acme/ads/targeting-search?type=interest&q=fitness" \
  --header 'Authorization: Bearer afk_...'
Search App Store apps
curl "https://affiliateo.com/api/v1/businesses/acme/ads/targeting-search?network=apple_search_ads&type=app&q=habit" \
  --header 'Authorization: Bearer afk_...'
Search TikTok regions
curl "https://affiliateo.com/api/v1/businesses/acme/ads/targeting-search?network=tiktok&type=region&q=united" \
  --header 'Authorization: Bearer afk_...'
200 (type=interest)
{
  "results": [
    {
      "id": "6003139266461",
      "name": "Physical fitness",
      "path": ["Interests", "Fitness and wellness"],
      "audienceLowerBound": 41000000,
      "audienceUpperBound": 48000000
    }
  ]
}
200 (type=app)
{
  "results": [
    { "adamId": 1234567890, "appName": "Habit Tracker", "developerName": "Acme Inc" }
  ]
}

TikTok Ads

Full read and write: stats, the live campaign tree, on-demand sync, and the whole write side below (launch, budgets, identities, pixels, balances). Dimensional reporting moved out of this section — TikTok was the only network that could answer a breakdown when it was written here, and all five can now, so it lives under Breakdowns with the id lookups. GET …/ads/campaigns?network=tiktok lists manual and Upgraded Smart+ campaigns merged into one flat entity list, the same shape Meta and Apple return. Smart+ needs no allowlist or extra permission of any kind — TikTok confirmed general availability on 2026-08-03 — and the full campaign, ad group and ad stack is VERIFIED LIVE as of 2026-08-04. Its ad create refuses a fixed call to action on most campaigns; the answer is a dynamic CTA portfolio id, which the launch path builds for you and sends inside ad_configuration. Setting call_to_action_idyourself through …/ads/objects is nested for you too — at the top level TikTok discards it and then refuses the ad for having no call to action.

Read this before you call anything below the balances. Audiences, product catalogs, promoted apps, automated rules and custom conversions (including the pixel-event family, which is the same feature on the pixel path) each ride a TikTok permission that TikTok has now granted, verified against a live account on 2026-08-02: Audience Management, DPA Catalog Management, App Management, Automated Rules respectively. TikTok custom conversions never needed a new one: they ride Pixel Management, granted 2026-07-15. Each section repeats which one it needs. 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 when consent is given, so a business that connected TikTok before the approval has to reconnect once before those calls succeed. The 502 UPSTREAM_ERROR in that case carries TikTok's own message with the missing permission named in it, and reconnecting is the fix rather than retrying. Everything else on this page (campaigns, budgets, identities, balances, pixels and the pixel rename) works today, as do TikTok's id lookups and breakdowns.

Ad stats

GET/api/v1/businesses/{slug}/ads?network=tiktok

Same parameters and response shape as Meta's Ad stats & ROAS, with network=tiktok.

Get TikTok stats
curl "https://affiliateo.com/api/v1/businesses/acme/ads?network=tiktok" \
  --header 'Authorization: Bearer afk_...'

Sync stats

POST/api/v1/businesses/{slug}/ads/sync?network=tiktok

On-demand refresh of TikTok reporting.

Sync TikTok stats
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/sync?network=tiktok" \
  --header 'Authorization: Bearer afk_...'

Launch campaigns

POST/api/v1/businesses/{slug}/ads/campaigns?network=tiktok

As on every network here, everything is created PAUSED: an agent can build a full stack, but starting spend is an explicit PATCH on the entity.

Creates the full campaign → ad group → ad stack in one call. Media rides POST …/ads/media?network=tiktok (returns { image_id } or { video_id, cover_url }); pixels ride GET …/ads/pixel?network=tiktok (returns { pixels: [{ pixel_id, pixel_name }] } and works today, the pixel scopes are already live). Manage what you launched via PATCH …/ads/entities/{id}?network=tiktok with { kind, status, dailyBudgetCents } (status ACTIVE or PAUSED); TikTok applies ad-group daily budget changes at 00:00 account time (their rule).

Two launch paths, one endpoint, and smart_plus is the one that runs first. This paragraph described the reverse order until 6 August 2026 and was wrong from the day the order flipped: the launch tries the Upgraded Smart+ stack first and keeps the classic manual stack as the STANDBY. Manual is attempted only when Smart+'s CREATE endpoint refuses, which is deliberately narrow — a bad budget or a rejected identity surfaces immediately from the path you meant to use rather than being retried and reported twice. After 31 December 2026 manual creation stops answering at all, so the standby removes itself without anyone editing anything. The response says which family answered in path (smart_plus or manual), and that matters: the two families are hard-partitioned, so a later edit has to send the matching kind — which is why the note quotes it back at you. A fallback that happened is reported in fell_back_from. Smart+ is gated on no approval — its campaigns and ad groups create on an ordinary account — and it now finishes: the call-to-action refusal that used to stop it two thirds of the way is answered by a dynamic CTA portfolio id sent inside ad_configuration, which the launch builds for you, and the whole campaign → ad group → ad stack is verified live as of 4 August 2026.

Two body fields make a launch fall back to manual, and after the sunset they are a refusal. TikTok publishes no Smart+ field for adgroup.subplacements or ad.showcaseProducts, so a request naming either is refused on the Smart+ path before the first create and built by the manual family instead — which does carry both. Refusing rather than dropping them is the whole point on the sub-placement one: naming no sub-placements runs on every TikTok surface, so silently discarding the list does not lose a preference, it buys the surfaces you just excluded. Once manual creation stops on 31 December 2026 that message is the whole answer, and it is a true one: drop the two fields to launch on Smart+. Inventing a Smart+ field would be worse than saying no, because TikTok answers a field it does not recognise with silence rather than an error — the same way it discarded call_to_action_id and music_id before both were nested correctly.

One goal has no Smart+ form at all, and it is engagement. Smart+ publishes three campaign objectives — app promotion, web conversions and lead generation — so a Spark Ads campaign is the single TikTok goal with nothing to fall back to when manual creation stops on 31 December 2026. The other five all have a Smart+ shape, catalogue selling included.

kind takes campaign, adgroup or ad, and also smart_plus_campaign, smart_plus_adgroup and smart_plus_ad. Smart+ is a separate endpoint family, and nothing in an id says which family owns it — so with a bare name we ask TikTok, which costs one extra call. Send the smart_plus_ name when you already know and we skip the lookup. Everything else about the request is identical.

Body

campaignNamestring

Required. Campaign display name

objectivestring

'traffic' (default), 'conversions' (needs adgroup.pixelId, optionally adgroup.optimizationEvent), 'engagement' (Spark Ads: needs ad.itemId — the post URL, or the number at the end of it — and uploads no creative at all, since the post supplies the video, the caption and the account name; TikTok stops accepting new ENGAGEMENT campaigns at the end of 2026 and Smart+ has no successor for it), 'leads' (Instant Form, needs ad.pageId), 'app_promotion' (needs appId + appPlatform) or 'product_sales' (catalogue ads: needs catalog.catalogId, uploads no creative at all). 'awareness' and 'video_views' are REFUSED here by name rather than read as traffic, which is the one exception to the unrecognised-means-traffic rule on this endpoint: TikTok served both until 4 August 2026, stops creating REACH and VIDEO_VIEWS campaigns at the end of 2026, and its Smart+ successor publishes neither — so an integration still sending the old word gets a sentence rather than the wrong campaign. Meta, Google and Snapchat still serve both goals. Each one fixes the optimisation goal and the billing event; TikTok refuses mismatched pairs rather than correcting them. product_sales builds TikTok's SMART+ catalogue campaign (a WEB_CONVERSIONS campaign with catalog_enabled, whose ad is a third creative shape, CATALOG_CAROUSEL, carrying no media and no music), and falls back to the manual catalogue campaign if Smart+ refuses. GMV Max is the one product-sales format still listed and greyed rather than hidden, so you can tell an owner why instead of retrying: it needs a linked TikTok Shop and a seller account — a merchant relationship nobody here can create, on a different product source (STORE, not the CATALOG rail these endpoints feed)

appIdstring

App promotion only. TikTok's own app id from GET …/ads/apps?network=tiktok — not an App Store id, not a package name

appPlatformstring

App promotion only. 'ios' or 'android': the ad group promotes APP_IOS or APP_ANDROID and the app id alone does not say which. Send adgroup.promotionType instead to name one of TikTok's other locations (MINI_APP and the rest)

adgroup.dailyBudgetCentsnumber

Required. TikTok enforces a floor around 2000 (= 20.00/day)

adgroup.locationIdsstring[]

Required. Ids from targeting search type=region

adgroup.gender / ageGroupsmixed

'all' (default) | 'men' | 'women'; age buckets like ["AGE_25_34"]

adgroup.bidCentsnumber

Optional manual bid

adgroup.audienceIdsstring[]

Optional. Custom audiences to target, from GET …/ads/lookups?network=tiktok&type=audience. NEVER SEND AN EMPTY ARRAY — omit the field: TikTok reads an empty include list as a filter that matched nobody. On the Smart+ standby these are nested under targeting_spec and the targeting optimisation mode is switched to MANUAL for you, because under TikTok’s default AUTOMATIC it answers 200 and drops them

adgroup.excludedAudienceIdsstring[]

Optional. The exclusion side, same source and same empty-array rule. The same id on both sides is refused rather than sent: TikTok evaluates both and reaches nobody in that audience, silently

ad.identityType / identityIdstring

Required. 'TT_USER', 'AUTH_CODE' or 'BC_AUTH_TT' (Spark identities). TikTok-placement ads require a Spark identity since Jan 2026 — so CUSTOMIZED_USER is refused when the ad group can reach TikTok placement, and ACCEPTED when it cannot (a Pangle and/or global-app-bundle ad group, where a Custom User identity is the correct choice). Automatic placements count as reaching TikTok

adgroup.placementsstring[]

Optional. PLACEMENT_TIKTOK, PLACEMENT_PANGLE, PLACEMENT_GLOBAL_APP_BUNDLE. Omitted means ['PLACEMENT_TIKTOK'], which is what every launch sent before this existed — automatic is opt-in via adgroup.automaticPlacements, because automatic lets TikTok spend on Pangle. Frozen at creation

adgroup.subplacementsstring[]

Optional. Which surfaces INSIDE TikTok: IN_FEED, SEARCH_FEED, TIKTOK_LITE, LEMON8. TikTok requires IN_FEED among them, and we refuse rather than adding it for you — asking for SEARCH_FEED alone means search only

ad.formatstring

'SINGLE_VIDEO', 'SINGLE_IMAGE' or 'CAROUSEL_ADS'. A carousel takes 1 to 35 imageIds plus ad.musicId, which TikTok requires. Ignored on product_sales, where the ad is CATALOG_CAROUSEL and there is nothing to upload

ad.musicIdstring

CAROUSEL_ADS: TikTok refuses a carousel without music. Ids come from GET …/ads/lookups?network=tiktok&type=music, which lists tracks LEGAL ON A CAROUSEL — a track from TikTok's unscoped library is accepted by the lookup and then discarded by the ad create, which then reports the music as missing. Optional on a Smart+ launch: omit it and one is picked off that same list for you. Required on the manual rail

ad.showcaseProductsobject[]

Optional, any format. { catalogId, itemGroupId } or { storeId } — the product shelf under the ad, TikTok's showcase_products. This is what TikTok markets as a Collection ad: a FIELD on an ordinary creative rather than a sixth ad_format

ad.videoIdstring

SINGLE_VIDEO: required, from the media upload

ad.imageIdsstring[]

Exactly one entry on the two single formats: the video cover (SINGLE_VIDEO, same aspect ratio) or the image itself (SINGLE_IMAGE). CAROUSEL_ADS takes 1 to 35, in swipe order — earlier releases silently discarded every image after the first

adgroup.automaticPlacementsboolean

Optional. Let TikTok choose, which INCLUDES Pangle — so it is opt-in rather than the default, and naming no placement at all still resolves to PLACEMENT_TIKTOK, exactly what every launch before this bought

ad.text / cta / displayNamestring

Ad copy, call to action (LEARN_MORE default), advertiser display name. WHAT HAPPENS TO cta IS NOT ALWAYS "it is sent": on the campaign and identity combinations where TikTok refuses a fixed call_to_action_list, the launch builds a DYNAMIC CTA PORTFOLIO out of TikTok’s own /creative/cta/recommend/ answer and TikTok picks between the candidates at delivery. Your choice is moved to the FRONT of that portfolio rather than forced, and it is never rewritten or added to: every entry needs TikTok’s own asset ids, so a button TikTok did not recommend for that objective and promotion type cannot be expressed on that path at all, and the list is left exactly as TikTok ordered it

ad.landingPageUrlstring

Required on traffic and conversions, which are the only two TikTok objectives whose destination is a URL: the https:// page the ad opens. The other four have a destination that is not one — a lead ad opens the Instant Form, an app ad the store listing, a SPARK ad the post itself, and a catalogue ad each product’s own link out of the feed — so it is not asked for and not sent

ad.itemIdstring

Engagement (Spark Ads) only, required. The post the ad promotes, as its URL (https://www.tiktok.com/@brand/video/7412…) or as the number at the end of it — both are accepted and the number is extracted for you. It goes on the wire as tiktok_item_id. CONFIRM IT 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. A Spark ad uploads NOTHING — send no format, videoId, imageIds, text or displayName; the post supplies the video, the caption and the account name, and a display name here would be a second answer to who is publishing

ad.pageIdstring

Leads only, required. The id of the TikTok Instant Form the ad opens. List them with GET …/ads/leads?network=tiktok, which reads the ad account’s own forms (TikTok’s /page/get/, no extra permission). That is the only listing TikTok publishes: /page/library/get/ returns Business Center LIBRARIES, not forms, and there is no endpoint for the pages inside one — so a form reachable only through a BC library has to be read out of Ads Manager. The leads that form collects come back from the same endpoint with &pageId=, so a lead campaign built here no longer ends in TikTok’s Leads Center

catalog.catalogIdstring

Product sales only, required. The catalogue the ads are rendered from, from GET …/ads/catalogs?network=tiktok. Nothing else identifies what a catalogue ad advertises: there is no creative to upload

catalog.bcIdstring

Product sales. The catalog_authorized_bc_id that travels with the catalogue onto the ad group — catalogues are Business Center property, not ad account property, so the catalogue list returns the pair together

catalog.productSpecificTypestring

Product sales. 'ALL' (the whole catalogue), 'PRODUCT_SET' or 'CUSTOMIZED_PRODUCTS'. Derived when omitted: naming a set means PRODUCT_SET, naming skus or item groups means CUSTOMIZED_PRODUCTS, naming nothing means ALL

catalog.productSetIdstring

Product sales, required with PRODUCT_SET. From GET …/ads/catalogs/{catalogId}?resource=sets. TikTok has no create-a-product-set API, so sets are built in Business Center and their ids read back

catalog.skuIds / itemGroupIdsstring[]

Product sales, with CUSTOMIZED_PRODUCTS. Skus are one per variant and item groups one per product (six sizes of one shirt share the group), so the two are not interchangeable. Only the list matching productSpecificType is sent

Launch a TikTok campaign
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/campaigns?network=tiktok" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "campaignName": "Summer push",
  "objective": "traffic",
  "adgroup": {
    "dailyBudgetCents": 2000,
    "locationIds": [
      "6252001"
    ],
    "gender": "all",
    "ageGroups": [
      "AGE_25_34"
    ],
    "bidCents": 50
  },
  "ad": {
    "identityType": "TT_USER",
    "identityId": "7210000000000000000",
    "format": "SINGLE_VIDEO",
    "videoId": "v10033g50000abc",
    "imageIds": [
      "ad-site-i-abc123"
    ],
    "text": "Try it free",
    "cta": "LEARN_MORE",
    "landingPageUrl": "https://example.com",
    "displayName": "Example"
  }
}'
Pause / re-budget an ad group
curl -X PATCH "https://affiliateo.com/api/v1/businesses/acme/ads/entities/1780000000000000000?network=tiktok" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "kind": "adgroup",
  "status": "PAUSED",
  "dailyBudgetCents": 3000
}'
Upload a video
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/media?network=tiktok" \
  --header 'Authorization: Bearer afk_...' \
  -F kind=video -F file=@ad.mp4
List pixels (live today)
curl "https://affiliateo.com/api/v1/businesses/acme/ads/pixel?network=tiktok" \
  --header 'Authorization: Bearer afk_...'
201
{
  "campaignId": "1779000000000000000",
  "adGroupId": "1780000000000000000",
  "adId": "1781000000000000000",
  "network": "tiktok",
  "status": "PAUSED",
  "path": "smart_plus",
  "note": "Created paused. PATCH the campaign with {\"kind\":\"smart_plus_campaign\",\"status\":\"ACTIVE\"} to start spending."
}

Identities

GET/api/v1/businesses/{slug}/ads/identities?network=tiktok

Who a TikTok ad publishes as. GET lists the identities the launch endpoint's identityId accepts. POST creates a Custom User identity and returns 201 { identityId, identityType: "CUSTOMIZED_USER", note }: since Jan 2026 a custom identity works only on Pangle and the global app bundle, so an ad that can reach TikTok placement needs a Spark identity (TT_USER / AUTH_CODE / BC_AUTH_TT) from the list instead. The launch endpoint enforces that rule against the placements you chose rather than blanket-refusing custom identities — a Pangle-only ad group takes one, and that is what it is for.

List identities
curl "https://affiliateo.com/api/v1/businesses/acme/ads/identities?network=tiktok" \
  --header 'Authorization: Bearer afk_...'
Create a custom identity
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/identities?network=tiktok" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "displayName": "My Brand"
}'
200
{
  "network": "tiktok",
  "identities": [
    { "identityId": "7210000000000000000", "identityType": "TT_USER", "displayName": "@acme" }
  ]
}

Balances

GET/api/v1/businesses/{slug}/ads/balance?network=tiktok

Ad-account balances (cash, grants, frozen funds, in cents) grouped by Business Center. Read-only: funds enter TikTok only through their UI or an invoicing credit line. No ads API anywhere (TikTok's, Meta's, anyone's) can charge a payment method.

Get account balances
curl "https://affiliateo.com/api/v1/businesses/acme/ads/balance?network=tiktok" \
  --header 'Authorization: Bearer afk_...'
200
{
  "network": "tiktok",
  "businessCenters": [
    {
      "bcId": "7000000000000000000",
      "bcName": "Acme BC",
      "accounts": [
        {
          "advertiserId": "7100000000000000000",
          "advertiserName": "Acme Ads",
          "currency": "USD",
          "accountBalanceCents": 152000,
          "validBalanceCents": 150000,
          "frozenBalanceCents": 2000,
          "cashBalanceCents": 100000,
          "grantBalanceCents": 52000,
          "budgetMode": "BUDGET_MODE_INFINITE"
        }
      ]
    }
  ],
  "note": "Read-only. Funds enter TikTok via their UI or an invoicing credit line."
}

Custom and saved audiences

POST/api/v1/businesses/{slug}/ads/audiences?network=tiktok

Needs TikTok's Audience Management permission, which TikTok has granted (verified live 2026-08-02). Same path as Meta's Custom audiences, with ?network=tiktok.

GET lists the advertiser's custom and lookalike audiences under audiences (audienceId, name, audienceType, coverNum, isValid, isExpiring, expiredTime, plus raw with TikTok's untouched row); &ids=900,901 narrows to specific ones (TikTok caps that list at 100). POST creates kind=rule (everyone one of your own signal sources saw) or kind=lookalike (people similar to a seed audience). PATCH …/audiences/{audienceId}?network=tiktok takes { name } to rename or { refresh: true } to recalculate a lookalike, one or the other per call, never both. DELETE …/audiences/{audienceId}?network=tiktok removes one. Writes require a read & write key.

Saved audiences are a different object, not another kind of audience. A saved audience is a whole reusable targeting set (locations, ages, languages, interests, plus which custom audiences to include and exclude) and it is what an ad group's saved_audience_id points at. Add ?type=saved on any verb: the list answers under savedAudiences rather than audiences, so the two id spaces never arrive mixed together, and the discriminator is required on the delete because the ids look identical. TikTok publishes no saved-audience update at all, so PATCH ?type=saved is refused rather than pretending: create a new one and delete the old.

The two numbers both named after retention are not the same number. lookbackDays decides who is IN the audience (how far back TikTok looks for the action); expireInDays decides when the AUDIENCE ITSELF dies. Swap them and TikTok accepts the call and builds a different audience, with no error anywhere.

Customer-list uploads (hashed emails and phone numbers) are deliberately not offered on TikTok either, the same decision as Meta: rule-based audiences cover the same jobs without buyer PII flowing through us, and a purchaser audience plus a lookalike seeded from it needs no upload at all.

Body (POST, kind=rule)

kindstring

Required. 'rule' or 'lookalike'

namestring

Required. Max 128 characters

audienceTypestring

Required. PIXEL, APP, ENGAGEMENT, ENGAGEMENT_ORGANIC_VIDEO, ENGAGEMENT_LIVE_VIDEO, LEAD_GENERATION, BUSINESS_ACCOUNT, TIKTOK_SHOP, OFFLINE

lookbackDaysnumber

Required. How far back TikTok looks for the action. Video engagement types accept only 7, 14 or 30

eventSourceIdsstring[]

The sources to collect from, and what they are depends on audienceType: pixel ids for PIXEL, app ids for APP, ad group ids for ENGAGEMENT (omit for all). Required for PIXEL

eventstring

Optional. Narrows to one action the source saw. Omit and the audience is everyone it saw in the window

parameterFiltersarray

[{ field, operator, values }] AND-ed onto event, so event is required with them. field: URL, CONTENT_TYPE, PRICE, VALUE, CONTENT_ID, CONTENT_CATEGORY

expireInDaysnumber

Optional. When the audience itself expires. Not the lookback

isAutoRefresh / audienceSubTypemixed

Optional. Keep the audience refreshing; NORMAL (default) or REACH_FREQUENCY

identityType / identityIdstring

Video and account engagement types only: TT_USER or BC_AUTH_TT plus the identity, and identityAuthorizedBcId for BC_AUTH_TT

Body (POST, kind=lookalike)

namestring

Required

sourceAudienceIdstring

Required. The seed audience

audienceSizestring

NARROW | BALANCED | BROAD (TikTok's equivalent of Meta's ratio)

locationIdsstring[]

Where the lookalike lives. Ids from targeting search type=region

mobileOs / placements / includeSourcemixed

ALL (default) | ANDROID | IOS; placement list; whether the seed's own people are included

Body (POST, ?type=saved)

namestring

Required. Max 512 characters here, not the 128 of a custom audience

locationIdsstring[]

Required. A saved audience with no location is refused before the call

audienceIds / excludedAudienceIdsstring[]

The custom audiences this targeting set includes and excludes. These are the ad-group fields audience_ids and excluded_audience_ids

ageGroups / gender / languagesmixed

Age buckets; GENDER_FEMALE | GENDER_MALE | GENDER_UNLIMITED; language codes

interestCategoryIds / interestKeywordIdsstring[]

Interest targeting

operatingSystems / minAndroidVersion / minIosVersionmixed

One operating system at a time (two are refused)

networkTypes / carrierIds / deviceModelIds / devicePriceRangesmixed

Device targeting. devicePriceRanges is TikTok's own price-band enum, not money

Query parameters

networkstring

Required for TikTok: 'tiktok' (default is meta)

typestring

'custom' (default) | 'saved'. An unrecognised value is refused, never read as custom

idsstring

GET only: comma-separated audience ids, max 100 (TikTok’s own cap)

accountstring

Which ad account (advertiserId also accepted). An advertiser that is not on the connection is a 409, never a silent fall back to the first

List custom audiences
curl "https://affiliateo.com/api/v1/businesses/acme/ads/audiences?network=tiktok" \
  --header 'Authorization: Bearer afk_...'
List saved audiences
curl "https://affiliateo.com/api/v1/businesses/acme/ads/audiences?network=tiktok&type=saved" \
  --header 'Authorization: Bearer afk_...'
Create a rule audience
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/audiences?network=tiktok" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "kind": "rule",
  "name": "Purchasers 30d",
  "audienceType": "PIXEL",
  "lookbackDays": 30,
  "eventSourceIds": [
    "7180000000000000000"
  ],
  "expireInDays": 180
}'
Create a lookalike
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/audiences?network=tiktok" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "kind": "lookalike",
  "name": "Lookalike US balanced",
  "sourceAudienceId": "900000000000001",
  "audienceSize": "BALANCED",
  "locationIds": [
    "6252001"
  ]
}'
Rename an audience
curl -X PATCH "https://affiliateo.com/api/v1/businesses/acme/ads/audiences/900000000000001?network=tiktok" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "name": "Purchasers 30d (EU)"
}'
Delete a saved audience
curl -X DELETE "https://affiliateo.com/api/v1/businesses/acme/ads/audiences/910000000000002?network=tiktok&type=saved" \
  --header 'Authorization: Bearer afk_...'
201
{ "audienceId": "900000000000001", "kind": "rule", "network": "tiktok", "type": "custom" }

Product catalogs

GET/api/v1/businesses/{slug}/ads/catalogs

Needs TikTok's DPA Catalog Management permission, which TikTok has granted. Catalogs are what dynamic product ads serve from, and they are where the four id fields the write surface declares actually come from: catalog_id, product_set_id, sku_ids and item_group_ids.

A catalog is Business Center scoped, not ad-account scoped. TikTok requires bc_id on every catalog endpoint and advertiser_id appears exactly once in that whole surface, as an optional filter on the upload log. So this is the one ads route where ?account= is the wrong axis, and it is refused with a 400 rather than ignored: pass ?bcId= instead. Omit it entirely and we use the only Business Center the TikTok login can see; if it can see several we refuse and list them, never "the first one".

GET …/ads/catalogs lists them (catalogId, bcId, name, catalogType, regionCode, currency, productCount); ?catalogId= narrows to one, ?resource=countries lists the region codes this Business Center may create for, and ?resource=currencies lists location/currency pairs (the one catalog read that needs no Business Center at all). POST creates a catalog. currency and regionCode can never be changed afterwards: TikTok's update takes a name and nothing else, so a typo means delete and rebuild, which is why both are shape-checked before the call.

One catalog and everything inside it lives at …/ads/catalogs/{catalogId}, on a ?resource= discriminator because a catalog's parts are not independently addressable on TikTok's side either: sets (where a real product_set_id comes from), products with &productSetId= (where sku_ids and item_group_ids come from), upload-log with &feedLogId=, the feed upload (POST ?resource=products), the rename (PATCH), a set rename or re-rule (PATCH ?resource=sets) and three deletes (the catalog, ?resource=sets&productSetIds=, ?resource=products&skuIds=). A product delete with no ids at all is refused here rather than sent.

Two asymmetries worth knowing before you plan around them. A product feed is a URL TikTok fetches, not a file we send, so the upload answers 202 with a feedLogId and means "queued": the per-row verdicts only appear in the upload log. And TikTok has no create for product sets at all (their API has get, update and delete only), so a set is made once in Business Center and read back here. Writes require a read & write key.

The same path serves two other networks, on two other scopes — which is the thing to know before reaching for ?bcId=, because scope is what differs here and not the operations. ?network=snapchat lists and creates catalogs on the organization, which comes from the connection, so there is no scope parameter to pass at all; its create is { name, vertical? } with vertical defaulting to COMMERCE, and currency / regionCode are refused by name rather than dropped, because a Snapchat catalog has neither — currency belongs to each product feed — and silence would teach you your catalog is scoped to a market it is not. Rename and delete work; product feeds do not. ?network=google is not a catalog at all but the Merchant Center link, which hangs off the ad account, so ?account= is the right selector there: GET lists linked accounts (where shopping.merchantId comes from), POST { merchantCenterId } links one when you are admin on both, &resource=invitations is where a link started from the Merchant Center side waits to be accepted, and DELETE with &productLink= unlinks without touching the catalog itself. Meta answers 501: catalog_management was not granted by App Review. Product sets exist on TikTok only — Snapchat publishes no endpoint that lists one anywhere in its Marketing API, which is precisely why it can list catalogs and still not launch a product-sales campaign.

Query parameters

bcIdstring

Which Business Center. Optional when the login can see exactly one. ?account= / ?advertiserId= is refused here, not ignored

resourcestring

Collection: 'catalogs' (default) | 'countries' | 'currencies'. One catalog: default (the catalog + its counts) | 'sets' | 'products' | 'upload-log'

catalogIdstring

On the collection GET: confirm one id is real before writing it onto an ad group

productSetIdstring

Required with ?resource=products on a read; also narrows ?resource=sets to one set

returnProductCountboolean

?resource=sets: forwarded explicitly, so false stays false

feedLogIdstring

Required with ?resource=upload-log. The id the feed upload returned

Body (POST, create a catalog)

namestring

Required

catalogTypestring

Required. TikTok owns this vocabulary (e.g. ECOMMERCE, TRAVEL_ENTERTAINMENT) and rejects an unknown one by name

currencystring

Required. Three-letter ISO code; tells TikTok how to read the feed’s price column. Cannot be changed later

regionCodestring

Required. Two-letter country code. Cannot be changed later

businessPlatform / channel / creativeAssetTypestring

Optional, passed through

Body (POST ?resource=products, feed upload)

fileUrlstring

Required. A public https URL TikTok fetches itself. Checked against the outbound guard first, so an internal address fails here rather than as an opaque TikTok code later

updateModestring

INCREMENTAL (default) or a full replace, TikTok's vocabulary

feedIdstring

Optional. Attach the upload to an existing feed

List catalogs
curl "https://affiliateo.com/api/v1/businesses/acme/ads/catalogs" \
  --header 'Authorization: Bearer afk_...'
Create a catalog
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/catalogs" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "name": "Main store",
  "catalogType": "ECOMMERCE",
  "currency": "USD",
  "regionCode": "US"
}'
Upload a product feed
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/catalogs/730000000000001?resource=products" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "fileUrl": "https://example.com/feed.csv",
  "updateMode": "INCREMENTAL"
}'
List product sets
curl "https://affiliateo.com/api/v1/businesses/acme/ads/catalogs/730000000000001?resource=sets" \
  --header 'Authorization: Bearer afk_...'
Products in a set
curl "https://affiliateo.com/api/v1/businesses/acme/ads/catalogs/730000000000001?resource=products&productSetId=740000000000002" \
  --header 'Authorization: Bearer afk_...'
200 (?resource=products)
{
  "bc_id": "7000000000000000000",
  "catalogId": "730000000000001",
  "productSetId": "740000000000002",
  "products": [
    { "skuId": "SKU-1188", "itemGroupId": "TEE-BLACK", "title": "Black tee", "raw": { … } }
  ],
  "note": "skuId feeds the ad-level sku_ids field; itemGroupId feeds item_group_ids."
}

Promoted apps

GET/api/v1/businesses/{slug}/ads/apps?network=tiktok

Needs TikTok's App Management permission, which TikTok has granted. This is the read that makes the app_id field usable: it is declared at campaign, smart_plus_campaign and smart_plus_adgroup level and there was previously no way anywhere to obtain a value for it, so app-promotion campaigns could not be built at all.

These are not Affiliateo apps. /api/v1/businesses/{slug}/apps is your affiliate programs; this path is the mobile apps a TikTok advertiser has registered for promotion. Different namespace, different ids, one unfortunate word. Responses call the id tiktokAppId for that reason, and ?appId= is accepted as an alias so a guess still gets an answer.

GET lists them; ?tiktokAppId= alone returns one app's detail; &resource=optimization-events&optimizationGoal=… returns the events THIS app may legally bid on for THAT goal, which is what an ad group's optimization_event takes. optimizationGoal is required precisely because the legal set depends on it, and availableOnly=false and isSkan=false are meaningful requests that survive as false rather than being coerced away. &resource=retargeting-events answers the same question for an APP_RETARGETING campaign, which TikTok serves from a different endpoint with a different answer.

Read-only on purpose. TikTok's app API has six operations and none of them is a delete, so a mistaken registration is permanent on the advertiser; and the tracking_url fields on a registration are third-party measurement postbacks carrying device identifiers on every click and impression, which is a product decision rather than an API convenience. Register apps in TikTok Ads Manager; this route then makes them discoverable.

Query parameters

networkstring

'tiktok' (default here). Apple promotes one app per org via its adamId on the campaign; Meta and Google app promotion is not wired

tiktokAppIdstring

The promoted app. Required for either events read. ?appId= is accepted as an alias

resourcestring

'apps' (default) | 'optimization-events' | 'retargeting-events'

optimizationGoalstring

Required with ?resource=optimization-events: CLICK, INSTALL, IN_APP_EVENT, VALUE …

placementsstring

Optional comma-separated list. Omitted, TikTok is asked with PLACEMENT_TYPE_AUTOMATIC; passed, with PLACEMENT_TYPE_NORMAL, where the answer can differ

objective / appPromotionType / isSkan / availableOnlymixed

Optional filters passed through. The two booleans keep an explicit false

accountstring

Which ad account (advertiserId also accepted). TikTok requires it on all six app endpoints, so the wrong one answers about a different account’s apps

List promoted apps
curl "https://affiliateo.com/api/v1/businesses/acme/ads/apps?network=tiktok" \
  --header 'Authorization: Bearer afk_...'
Legal optimisation events
curl "https://affiliateo.com/api/v1/businesses/acme/ads/apps?network=tiktok&tiktokAppId=7200000000000000000&resource=optimization-events&optimizationGoal=INSTALL" \
  --header 'Authorization: Bearer afk_...'
Retargeting events
curl "https://affiliateo.com/api/v1/businesses/acme/ads/apps?network=tiktok&tiktokAppId=7200000000000000000&resource=retargeting-events" \
  --header 'Authorization: Bearer afk_...'
200
{
  "network": "tiktok",
  "advertiser_id": "7100000000000000000",
  "apps": [
    {
      "tiktokAppId": "7200000000000000000",
      "appName": "Habit Tracker",
      "platform": "IOS",
      "downloadUrl": "https://apps.apple.com/app/id123456789",
      "packageName": "com.acme.habit",
      "raw": { … }
    }
  ],
  "note": "tiktokAppId is the value the app_id field takes at campaign, smart_plus_campaign and smart_plus_adgroup level (POST …/ads/objects). …"
}

Automated rules

POST/api/v1/businesses/{slug}/ads/rules?network=tiktok

Needs TikTok's Automated Rules permission, which TikTok has granted. Like Meta's, a TikTok rule runs on TikTok's side on TikTok's schedule, so it keeps working long after the agent that created it is gone. Unlike Meta's, it is a genuinely different object rather than a dialect: conditions, actions, applyObjects, notification and ruleExecInfo, all required. The vocabularies inside them (metric names, action types, dimensions, schedules) are typed as plain strings by TikTok with no published enum, so we validate the STRUCTURE and let TikTok validate the words: an invented allowlist here would refuse values TikTok accepts while looking like verified validation.

Money comes in two spellings and you pick one. Our boundary is minor units, TikTok reads rule thresholds in major units, so valuesCents / valueCents / limitCents are cents and we convert, while values / value / limit are dollars and pass through untouched. Sending both spellings of the same number is refused rather than guessed: guessing is how a rule meant to fire at $40 fires at $4000, or never.

PATCH is a FULL REPLACE on TikTok and a partial on Meta, on the same verb and the same path. TikTok requires every create field on an update, so a Meta-shaped { "name": "x" } aimed at TikTok would return 200 having stripped the rule of its conditions, actions and schedule. We refuse a body missing any required part, name the missing parts, and echo replaced: true on success. Read the rule with GET …/ads/rules/{ruleId}?network=tiktok, merge, then send the whole thing.

TikTok has no rule delete and no on/off switch. Their automated rules API is seven endpoints and none of them removes or disables a rule, so DELETE …/rules/{ruleId}?network=tiktok answers 400 saying so and names the two things that do work: PATCH a narrower applyObjects list (a full replace, so the new list becomes the whole coverage), or remove it in TikTok Ads Manager. Meta's delete is unaffected.

Two more TikTok-only pieces. POST …/ads/rules/{ruleId} binds an EXISTING rule to more campaigns, ad groups or ads: it is its own operation rather than a create-time convenience, because folding it into the create turns one call into two writes whose half-failure looks to the caller like nothing happened, and there is no delete to undo the orphan with. And GET …/ads/rules?view=results is the audit trail of what the rules actually did, which unattended automation needs; &detail=1 chains the per-result detail for the first 20 rows and reports details_truncated rather than truncating silently. ?view=results&network=meta is refused instead of answering [], because an empty audit trail reads as "your rule never fired". Writes require a read & write key.

Body (POST)

namestring

Required

conditionsarray

Required. [{ subjectType, calculationType?, matchType?, rangeType, values | valuesCents }]. Money in exactly one spelling

actionsarray

Required. [{ subjectType, actionType, valueType?, value?: { value | valueCents, limit | limitCents, useLimit? }, frequencyInfo? }]

applyObjectsarray

Required. [{ dimension, preConditionType?, dimensionIds, bindType? }]. An entry with no dimensionIds means EVERY object at that level in the account and is refused unless confirmAccountWide is true

notificationobject

Required. { notificationType, emailSetting? { emailExecTime, muteOption, noResultNotification, notificationPeriod } }

ruleExecInfoobject

Required. { execTimeType, execTime?, timePeriodInfo? }

tzonestring

Optional timezone for the schedule

confirmAccountWideboolean

Required opt-in for an account-wide applyObjects entry. A pause rule at that scope can stop a whole account

Query parameters

networkstring

'meta' (default) | 'tiktok'

ruleIdsstring

GET: comma-separated ids for an exact read instead of the list

viewstring

GET: 'results' for the audit feed (TikTok only). &detail=1 adds per-result detail, capped at 25 rows

status / action / dataDimension / ruleInfo / timemixed

TikTok's five documented list filters, passed through unexamined

accountstring

Which ad account. A rule created against the wrong one is automation nobody is watching

List rules
curl "https://affiliateo.com/api/v1/businesses/acme/ads/rules?network=tiktok" \
  --header 'Authorization: Bearer afk_...'
Create a rule
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/rules?network=tiktok" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "name": "Pause expensive ad groups",
  "conditions": [
    {
      "subjectType": "COST_PER_CONVERSION",
      "rangeType": "GREATER_THAN",
      "valuesCents": [
        4000
      ]
    }
  ],
  "actions": [
    {
      "subjectType": "ADGROUP",
      "actionType": "PAUSE"
    }
  ],
  "applyObjects": [
    {
      "dimension": "ADGROUP",
      "dimensionIds": [
        "1780000000000000000"
      ]
    }
  ],
  "notification": {
    "notificationType": "EMAIL"
  },
  "ruleExecInfo": {
    "execTimeType": "HOURLY"
  }
}'
Bind a rule to more objects
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/rules/1790000000000000000?network=tiktok" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "binds": [
    {
      "dimension": "ADGROUP",
      "bindType": "ADD",
      "dimensionIds": [
        "1780000000000000001"
      ]
    }
  ]
}'
What the rules actually did
curl "https://affiliateo.com/api/v1/businesses/acme/ads/rules?network=tiktok&view=results&detail=1" \
  --header 'Authorization: Bearer afk_...'
201
{
  "ruleId": "1790000000000000000",
  "network": "tiktok",
  "advertiser_id": "7100000000000000000",
  "applyObjects": [ … ],
  "note": "…"
}

Custom conversions

POST/api/v1/businesses/{slug}/ads/custom-conversions?network=tiktok

Works today. These endpoints ride Pixel Management, granted 2026-07-15, not the similarly named Custom Conversion Managementpermission in TikTok's portal, which guards none of them.

"Custom conversion" names a different object on each network, which is the single thing most likely to be got wrong here. On Meta it is a first-class object on the AD ACCOUNT that watches pixel traffic and fires when a URL matches, independent of any one pixel, capped at 100 per account. What this endpoint manages on TikTok is an event rule living ON a pixel, which is why every TikTok call here takes a pixelId (from GET …/ads/pixel?network=tiktok) and why deleting one changes that pixel. Every TikTok endpoint here sits under /pixel/ and TikTok guards them withCreate and Update Pixels. The two create bodies therefore share nothing: reusing Meta's body here is a 400, not a partial success.

This page used to say TikTok has no custom conversion object at all. It does. Corrected 5 August 2026 after a live call: TikTok publishes a separate first-class family at /custom_conversion/ — list, get, create, update and delete — keyed by custom_conversion_id and scoped by an event SOURCE that can be an app as well as a pixel. This endpoint does not wrap it; a pixel event rule is a real and useful object, but it is a different object, and the two were conflated. That family is READABLE at 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 deliberately not wrapped anywhere: all three endpoints exist, no request body for them has ever been observed, and TikTok answers an unrecognised field with silence rather than an error — so a create typed from a doc read can succeed while dropping the rule that makes the conversion mean anything.

The event vocabulary is the other trap. TikTok runs two of them: the Events API speaks Purchase, AddToCart, Lead, while pixel event rules and the ad-group optimisation goal speak SHOPPING, FORM. eventType here is the second one, because the only reason to make a custom conversion is for an ad group to bid on it. An Events API name is refused up front with a 400 naming the right one: TikTok's own response to an unrecognised name is to file it quietly as a custom event, return success, and leave a conversion no ad group can ever optimise toward.

GET …?network=tiktok&pixelId= lists one pixel's events (one pixel per call). &view=stats with pixelIds plus a startDate/endDate window answers the debugging question, since a conversion that was created successfully and never fires looks exactly like one that works until the campaign underdelivers. PATCH …/custom-conversions/{id} is a true partial (name, currency, value only; the event type and its rules are delete-and-recreate) and is TikTok-only, because Meta publishes no update we wrap. DELETE returns { ok, note } and the note is the warning: TikTok is not documented to refuse deleting an event a live ad group still optimises toward, so the call that reads as safe on Meta can strip a running ad group of its signal and still answer 200. valueCents is minor units at our boundary on both networks; TikTok takes major units on the wire and we convert both ways.

The same five operations are also reachable under the pixel path as ?resource=events. Neither is a wrapper around the other: this path groups the feature with Meta's equivalent, that one groups it with the pixel it physically belongs to. Use whichever reads better in your code. Writes require a read & write key.

Body (POST)

pixelIdstring

Required. The pixel the rule lives on. Ids from GET …/ads/pixel?network=tiktok

eventTypestring

Required. The OPTIMISATION vocabulary (SHOPPING, FORM …), never the Events API one (Purchase, AddToCart …), which is refused

eventNamestring

Optional display name

eventCode / statisticTypestring

Optional. TikTok’s own event code and how it counts (per session, per event …)

currency / valueCentsmixed

Optional conversion value. valueCents is MINOR units here; TikTok is sent major units

rulesarray

[{ variable, operator, value, trigger }] and all four are required per rule. What has to be true for the event to count

Query parameters

networkstring

'meta' (default) | 'tiktok'

pixelIdstring

Required on TikTok reads. One id for the events list; ?view=stats accepts a comma-separated list

viewstring

'stats' for firing counts (TikTok only). Needs startDate and endDate as YYYY-MM-DD

accountstring

Which ad account. A TikTok pixel belongs to one advertiser, so the wrong one answers about somebody else’s funnel

List a pixel’s events
curl "https://affiliateo.com/api/v1/businesses/acme/ads/custom-conversions?network=tiktok&pixelId=55000000000" \
  --header 'Authorization: Bearer afk_...'
Create a conversion
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/custom-conversions?network=tiktok" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "pixelId": "55000000000",
  "eventType": "SHOPPING",
  "eventName": "Checkout complete",
  "currency": "USD",
  "valueCents": 4900,
  "rules": [
    {
      "variable": "page_url",
      "operator": "CONTAINS",
      "value": "/thank-you",
      "trigger": "PAGE_VIEW"
    }
  ]
}'
Edit a conversion
curl -X PATCH "https://affiliateo.com/api/v1/businesses/acme/ads/custom-conversions/66000000000?network=tiktok" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "eventName": "Checkout complete v2",
  "valueCents": 5900
}'
Is it actually firing?
curl "https://affiliateo.com/api/v1/businesses/acme/ads/custom-conversions?network=tiktok&view=stats&pixelId=55000000000&startDate=2026-07-01&endDate=2026-07-28" \
  --header 'Authorization: Bearer afk_...'
201
{
  "conversionId": "66000000000",
  "network": "tiktok",
  "advertiser_id": "7100000000000000000",
  "pixel_id": "55000000000",
  "conversion": { "eventId": "66000000000", "eventName": "Checkout complete", "eventType": "SHOPPING", "currency": "USD", "valueCents": 4900, "rules": [ … ] }
}

Pixels and pixel events

GET/api/v1/businesses/{slug}/ads/pixel?network=tiktok

GET …/ads/pixel?network=tiktok lists the advertiser's pixels, which is what the conversions objective and the custom-conversion calls take as pixelId. This read has worked since TikTok granted Pixel Management, and so does the new PATCH …/ads/pixel?network=tiktok with { pixelId, pixelName }, which renames a pixel: it rides that same granted permission, so it is the one thing in this batch that is live now.

POST …/ads/pixel?network=tiktok with { pixelName } creates the pixel itself, and returns its pixel_code. pixelName is required rather than defaulted from the business, because TikTok offers no way to DELETE a pixel — only to rename one — so an accidental empty POST would leave a permanent object on the advertiser. The response carries action_required: "install_pixel_code" for the same reason Google's WEBPAGE conversion carries install_tag: a fresh pixel tracks nothing until its code is on the site AND an event exists for a campaign to bid on, which are two more steps this call did not take.

?resource=events is TikTok's custom conversions on the path they physically belong to (there is no separate object, and every TikTok endpoint for them lives under /pixel/): GET with &pixelId= lists them, POST creates one, PATCH edits one, DELETE with &eventId= removes one, and ?resource=stats with &pixelIds=&startDate=&endDate= reports whether they are firing. Those five ride Pixel Management and work today, and they are the same operations documented at TikTok custom conversions (including the SHOPPING-not-Purchase trap and the minor-units rule). Writes require a read & write key.

Query parameters

networkstring

'meta' (default) | 'tiktok' | 'google'

resourcestring

TikTok: omit for the pixel itself, 'events' for its conversion rules, 'stats' for firing counts

pixelId / pixelIdsstring

One pixel for ?resource=events; a comma-separated list for ?resource=stats. Both spellings are accepted here, and pixelId is the one that also works on …/ads/custom-conversions

eventIdstring

Required on DELETE ?resource=events. Deletes exactly one event

accountstring

Which ad account (Meta and TikTok). Google is deliberately absent: its conversion action lives on the account Google nominates, not the one you are looking at

List pixels
curl "https://affiliateo.com/api/v1/businesses/acme/ads/pixel?network=tiktok" \
  --header 'Authorization: Bearer afk_...'
Rename a pixel (works today)
curl -X PATCH "https://affiliateo.com/api/v1/businesses/acme/ads/pixel?network=tiktok" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "pixelId": "55000000000",
  "pixelName": "Storefront pixel"
}'
The pixel’s events
curl "https://affiliateo.com/api/v1/businesses/acme/ads/pixel?network=tiktok&resource=events&pixelId=55000000000" \
  --header 'Authorization: Bearer afk_...'
Delete an event
curl -X DELETE "https://affiliateo.com/api/v1/businesses/acme/ads/pixel?network=tiktok&resource=events&eventId=66000000000" \
  --header 'Authorization: Bearer afk_...'
200
{
  "network": "tiktok",
  "pixels": [
    { "pixel_id": "55000000000", "pixel_name": "Storefront pixel" }
  ]
}

Instant Forms and the leads they collect

GET/api/v1/businesses/{slug}/ads/leads

The other half of a lead campaign. Everything else in this API can create a lead campaign; this is what reads the leads back out, so an owner who ran one here does not have to open TikTok Ads Manager to see what it caught.

Three reads behind one endpoint. With no pageId it lists the Instant Forms on the ad account, which is where ad.pageId on a TikTok launch comes from. resource=leads returns the leads themselves, and &resource=fields returns the form's questions as plain strings, plus the form's own name and public URL.

Both lead reads need leadSource, and it has exactly two values. INSTANT_FORM reads one form and also needs a pageId; DIRECT_MESSAGE reads leads collected in TikTok DMs and takes no pageId at all. That second source is easy to miss and it means an account with zero Instant Forms can still have leads. TikTok requires the parameter and refuses either read without it.

There is no paging, because TikTok implements none. This endpoint used to publish page, pageSize and hasMore. TikTok ignores page and page size on the lead read and publishes no list endpoint to walk, so those knobs described a pagination that does not exist and have been withdrawn. One call is the whole answer; startTime and endTime still filter.

Read the fields for the questions, not for the labels. This page used to say leads render as custom_question_1: yes without them. They do not: every answer already arrives keyed by the same field name the questions list returns. What the fields read genuinely adds is the form's name and URL, and the full roster of questions including the ones a given lead left blank.

The Lead Management scope is granted — verified against the live API on 5 August 2026, where this page previously said our app did not hold it and that both reads answered 403. The DIRECT_MESSAGE response shapes were verified with it. INSTANT_FORM was not, because the connected account has no Instant Form to read, so that branch is still unproven.

Query parameters

networkstring

'tiktok'. The only network here: Meta's lead forms need two App Review scopes we were refused, and Snapchat issues lead-form access by hand per advertiser

leadSourcestring

'INSTANT_FORM' | 'DIRECT_MESSAGE'. Required on the fields and leads reads. TikTok's own pair — it refuses anything else

pageIdstring

The Instant Form. Required when leadSource is INSTANT_FORM; omit it to list the forms instead

resourcestring

'leads' (default when pageId is given) | 'fields' | 'forms' (default when it is not)

startTime / endTimestring

Leads only, optional. TikTok’s own spelling, not the startDate/endDate the reporting endpoints take

accountstring

Which TikTok advertiser, when the connection holds more than one

Which forms exist
curl "https://affiliateo.com/api/v1/businesses/acme/ads/leads?network=tiktok" \
  --header 'Authorization: Bearer afk_...'
What one form asks
curl "https://affiliateo.com/api/v1/businesses/acme/ads/leads?network=tiktok&leadSource=INSTANT_FORM&pageId=1798...&resource=fields" \
  --header 'Authorization: Bearer afk_...'
Leads from a form
curl "https://affiliateo.com/api/v1/businesses/acme/ads/leads?network=tiktok&leadSource=INSTANT_FORM&pageId=1798..." \
  --header 'Authorization: Bearer afk_...'
Leads from TikTok DMs
curl "https://affiliateo.com/api/v1/businesses/acme/ads/leads?network=tiktok&resource=leads&leadSource=DIRECT_MESSAGE" \
  --header 'Authorization: Bearer afk_...'
200 (forms)
{
  "network": "tiktok",
  "advertiser_id": "7009...",
  "forms": [
    { "pageId": "1798000000000001", "title": "Book a demo", "status": "PUBLISHED" }
  ]
}
200 (leads)
{
  "network": "tiktok",
  "lead_source": "DIRECT_MESSAGE",
  "leads": [
    {
      "leadId": "7670393988348182805",
      "createdAt": "2026-08-05 04:04:49",
      "campaignId": "1872247700914193",
      "adgroupId": "1872353427997697",
      "adId": "1872353428021761",
      "values": { "first_name": "Ada", "email": "ada@example.com" }
    }
  ]
}
400 (no leadSource)
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "leadSource is required and must be one of: DIRECT_MESSAGE, INSTANT_FORM…"
  }
}

Ad review and appeals

GET/api/v1/businesses/{slug}/ads/ad-review

Why an ad is not delivering, in words rather than a status code. Every other surface in this API reports secondary_status — a string like AD_STATUS_AUDIT_DENY that says an ad was refused and nothing about why. This returns TikTok's policy reasons, and the frames of the video it points at, which is the only place in the API that says which part of a creative was the problem.

TikTok-only, and for one of its two endpoint families. TikTok publishes review and appeal endpoints under /smart_plus/ and none at all for manual ads. That is now the better-covered half rather than the worse one: the launch tries Smart+ first and keeps manual as the standby, so a campaign built here normally has reasons to read. A manual ad still reports a status with no reason attached. &family=manual answers with that status plus the reason it is thin, rather than refusing: “TikTok publishes nothing to build” is not the same as “nobody built it”, and a manual ad is appealed in Ads Manager.

The reasons and the violation frames are requested by default. TikTok defaults both to off, which returns a review status and nothing else — the same answer you already had. Pass includeRejectInfo=false to opt out.

&materialIds= reads the review of the individual creatives inside a Smart+ ad. Worth knowing about: one Smart+ ad can hold up to fifty creatives and TikTok reviews each one, so an ad can be delivering perfectly well on four of them while the fifth is refused and invisible in the ad-level answer.

Also in the dashboard. Ads tab → TikTok. This is the one of the six that is not a card, and the endpoint is the reason: both reads need an ad id and there is no list behind them, so a standalone panel would have to open by asking for an id from memory. Switch the campaign table to its ad level (TikTok labels it Video) and the shield on a row opens that ad's review — status, policy reasons, violation frames and the appeal — already knowing the id.

Query parameters

adIdsstring

Comma-separated ad ids

materialIdsstring

Comma-separated ad material ids. Reads per-creative review instead of per-ad

familystring

smart_plus (default) or manual

includeRejectInfoboolean

Default true. TikTok defaults it to false

includeViolationFrameboolean

Default true

langstring

TikTok's locale for the reason text

networkstring

tiktok

Body (POST)

actionstring

'appeal' (default) or 'material_status'

adIdstring

Required. The Smart+ ad

reasonstring

appeal: what the advertiser wants to say. A human reviewer reads it

attachmentsstring[]

appeal: supporting material. Passed to TikTok unchanged — its SDK publishes no description of what an entry is

materialIdsstring[]

material_status: which creatives to change

operationStatusstring

material_status: TikTok's own value. Turning one creative off leaves the rest running, which pausing the whole ad does not

Why was this refused
curl "https://affiliateo.com/api/v1/businesses/acme/ads/ad-review?network=tiktok&adIds=1872247700914193" \
  --header 'Authorization: Bearer afk_...'
200
{
  "network": "tiktok",
  "family": "smart_plus",
  "ads": [
    {
      "adId": "1872247700914193",
      "reviewStatus": "AUDIT_DENY",
      "rejectReasons": ["Prohibited product", "Misleading claim"],
      "violationFrames": ["https://…/frame-3s.jpg"],
      "raw": { }
    }
  ]
}

Comment moderation

GET/api/v1/businesses/{slug}/ads/comments

Read, hide, reply to and delete the comments on your TikTok ads. Before this the only comment control available was the ad group's comment_disabled switch — which turns comments off entirely, and costs the engagement TikTok's ranking rewards. Turning comments off to avoid moderating them was the only workable policy; it no longer has to be.

The vocabularies are validated here, and only one is still yours to supply. This block used to say the opposite — that searchField, commentType, commentStatus and the status-update operation were bare strings with no published enum, so they had to be passed through untouched. A live call disproved it: handed a bad value, TikTok's own error 40002 lists the legal set for each of them, and both values that paragraph offered as examples were the wrong ones. searchField takes ADGROUP_ID and nothing else, so it is filled in for you rather than demanded, and operation takes the STATE (HIDDEN / PUBLIC) rather than the verb HIDE. The survivor is commentType on the replies read, which TikTok has still not enumerated.

Also in the dashboard. Ads tab → TikTok, below the campaign table. A triage screen rather than a table, and it owns its own date window because this endpoint requires one: TikTok caps the span at 30 days and refuses anything older than 365. It lists one ad group at a time for the same reason the endpoint does — TikTok publishes no account-wide comment list — and hides, replies and deletes from the same rows.

&commentId=&commentType= reads the replies under one comment, which is a separate endpoint at TikTok rather than a filter. Replying and deleting both need five ids — the ad, the comment, the identity (two fields) and the organic post the comment sits on — because a comment on a Spark ad lives on the creator's post rather than on an ad object.

Query parameters

startTime / endTimestring

Required, and DATE-ONLY (YYYY-MM-DD) — a datetime is refused. The window is capped at 30 days and cannot start more than 365 days ago; both are checked here, so an out-of-range window is a 400 rather than a 502 that looks like a TikTok outage

searchValuestring

Required: the ad group id whose comments to read

searchFieldstring

Optional, and there is only one legal value — 'ADGROUP_ID'. Filled in for you; anything else is refused

commentTypesstring

Optional comma-separated filter: ALL | COMMENT | REPLY

commentStatusesstring

Optional comma-separated filter: ALL | HIDDEN | PUBLIC

sortField / sortTypestring

CREATE_TIME | LIKES | REPLIES | SMART, and ASC | DESC

commentId / commentTypestring

Reads one thread instead of the list. commentType is the one comment vocabulary TikTok has not enumerated for us, so it is still yours to supply

page / pageSizenumber

Defaults 1 and 50 (TikTok’s own default is 10). pageSize is capped at TikTok’s ceiling of 100

networkstring

tiktok

Body (POST)

actionstring

'status' (default, hide or unhide) or 'reply'

commentIdsstring[]

status: the batch to change

operationstring

status: HIDDEN or PUBLIC — the STATE you want, not a verb, so there is no 'HIDE'. action:'hide' fills in HIDDEN for you

adId / commentId / commentType / identityId / identityType / itemIdstring

reply: all six are required by TikTok

textstring

reply: what to say. Public, permanent, and posted under the identity you name

Read the comments on an ad group
curl "https://affiliateo.com/api/v1/businesses/acme/ads/comments?network=tiktok&startTime=2026-08-01&endTime=2026-08-03&searchValue=1872247700914193" \
  --header 'Authorization: Bearer afk_...'
200
{
  "network": "tiktok",
  "comments": [
    { "commentId": "c-1", "text": "does this ship to Canada?", "username": "…", "status": "PUBLIC", "replyCount": 0 }
  ],
  "page": 1,
  "totalPages": 3
}

Blocked words

GET/api/v1/businesses/{slug}/ads/blocked-words

Words that keep a comment from appearing at all. Account level, which is the point: the comment switch is per ad group, so keeping one word out of your comments previously meant turning comments off on every ad group you run. A blocked word is set once and applies to everything the ad account serves, before a moderator ever sees it.

?check=word,word asks TikTok whether words are already blocked rather than filtering the list here — TikTok normalises words its own way, so it is the only thing that can answer correctly.

Renaming is one call rather than a delete plus an add, and deliberately so: doing it as two calls leaves a window in which neither word is blocked, and a comment that arrives in that window is published and stays published.

Query parameters

checkstring

Comma-separated words. Asks TikTok whether each is already blocked

wordsstring

DELETE only: comma-separated words to unblock

page / pageSizenumber

Defaults 1 and 100

networkstring

tiktok

Body (POST)

actionstring

'add' (default) or 'rename'

wordsstring[]

add: the words to block

oldWord / newWordstring

rename: replace one with the other, in a single call

The account block list
curl "https://affiliateo.com/api/v1/businesses/acme/ads/blocked-words?network=tiktok" \
  --header 'Authorization: Bearer afk_...'
200
{
  "network": "tiktok",
  "words": [{ "word": "refund", "raw": { } }],
  "page": 1,
  "totalPages": 1
}

Apple Search Ads

Create campaigns and ad groups, read performance, inspect the live campaign → ad group → keyword tree plus the ads and creatives underneath it, pause/resume, change budgets and bids, manage targeting and negative keywords, split delivery by age, gender, country, region, city or device, discover keywords from what people actually searched, and pull Apple's bid recommendations.

The v5 sunset, and why no migration date is published

Every endpoint in this section runs on Apple Ads Campaign Management API v5, and v5 stops answering on 2027-01-26. That is Apple's date, not ours. It is now surfaced in the product rather than only in code: the Apple connection settings dialog shows a countdown inside the last 180 days, and once the date has passed it says plainly that every Apple call is failing. No migration date is published here on purpose. Apple's successor, the Platform API, exists as a preview announcement with no production reference: the base path is unconfirmed, the full field table is unpublished, the auth model is undescribed and the report shapes are not given. Naming a date we cannot keep would be worse than naming none.

Ad stats

GET/api/v1/businesses/{slug}/ads?network=apple_search_ads

Same parameters and response shape as Meta's Ad stats & ROAS, with network=apple_search_ads. Apple additionally reports keyword and search-term levels in by_level, with revenue attribution drilled down to ad group and keyword.

Get Apple stats
curl "https://affiliateo.com/api/v1/businesses/acme/ads?network=apple_search_ads" \
  --header 'Authorization: Bearer afk_...'

Campaign tree

GET/api/v1/businesses/{slug}/ads/campaigns?network=apple_search_ads

The live campaign → ad group → keyword tree straight from Apple, flat like the Meta tree. Ad groups carry their default bid, keywords their own bid and matchType.

ads and creatives close the two levels that were write-only. Both could be created through POST …/ads/objects and neither could be read back anywhere, which on this network matters more than it sounds: a creative is minted once per product page per organisation and Apple publishes no update endpoint for one, so a lost id was a slot permanently occupied by something nobody could name. An ad carries servingStateReasons, which is usually the only place that says why an ACTIVE ad is spending nothing; a creative carries the productPageId it was built on and the state that decides whether an ad on it can ever deliver. Creatives are organisation-wide, so the list does not change with ?account=beyond which organisation is being read.

They sit beside entities rather than inside it because the entity PATCH handles campaigns, ad groups and keywords — an ad in that list would render a control that throws. Trim with ?include=ads,creatives, or ?include=none for the tree alone and the payload this endpoint returned before. null means "not asked for"; an empty array means Apple was asked and had none, and reads_error appears only when it could not be asked.

A big Apple account is now paged rather than cut off. Ad-group and keyword detail is loaded for a window of 50 campaigns — it used to stop at the first fifty with nothing behind them, so an advertiser with 51 campaigns could not see the 51st at all. The window moves: truncation reports detail_offset, detail_loaded, campaigns_before_window and next_detail_offset, and you pass that last one back as ?detailOffset=. The window size is deliberately unchanged, because this tree loads on every dashboard render and raising the default would have made every page slower for everyone to help the few with big accounts.

Two of the four narrowing parameters are applied by Apple rather than by us, so a 500-campaign organisation costs what a small one costs: ?campaignIds= becomes an id IN condition and reads exactly those, and ?servingStatus= becomes a servingStatus condition — worth knowing that this is Apple's delivery state and not the same question as ENABLED or PAUSED, since a campaign can be enabled and not serving.

Query parameters

includestring

'ads,creatives' to trim, or 'none' for the tree alone

campaignIdsstring

Comma-separated. Loads detail for exactly these, as an Apple-side condition rather than a filter here

servingStatusstring

'RUNNING' or 'NOT_RUNNING'. Apple's delivery state

detailOffsetnumber

Where the 50-campaign detail window starts. Use truncation.next_detail_offset from the previous call

keywordPagesnumber

How many 1,000-keyword pages to read per campaign. Defaults to 2, i.e. the first 2,000

Get campaign tree
curl "https://affiliateo.com/api/v1/businesses/acme/ads/campaigns?network=apple_search_ads" \
  --header 'Authorization: Bearer afk_...'
200
{
  "network": "apple_search_ads",
  "org_id": "1234567",
  "currency": "USD",
  "tree_error": false,
  "tree_error_reason": null,
  "entities": [
    { "id": "900000001", "kind": "campaign", "name": "US brand terms", "status": "ACTIVE", "dailyBudgetCents": 5000, "parentId": null, "campaignId": "900000001" },
    { "id": "900000002", "kind": "adgroup", "name": "Exact match", "status": "ACTIVE", "bidCents": 250, "parentId": "900000001", "campaignId": "900000001" },
    { "id": "900000003", "kind": "keyword", "name": "affiliate tracker", "status": "ACTIVE", "bidCents": 300, "matchType": "EXACT", "parentId": "900000002", "campaignId": "900000001" }
  ],
  "ads": [
    { "id": "900000010", "campaignId": "900000001", "adGroupId": "900000002", "name": "Spring CPP ad", "status": "ACTIVE",
      "servingStatus": "NOT_RUNNING", "servingStateReasons": ["CREATIVE_PENDING_REVIEW"], "creativeId": "42", "creativeType": "CUSTOM_PRODUCT_PAGE" }
  ],
  "creatives": [
    { "id": "42", "adamId": "123456789", "name": "Spring CPP", "type": "CUSTOM_PRODUCT_PAGE", "productPageId": "7f1c…", "state": "VALID", "stateReasons": [] }
  ]
}

Create campaigns

POST/api/v1/businesses/{slug}/ads/campaigns?network=apple_search_ads

Creates an Apple Search Ads campaign, PAUSED like everything else the API creates. Requires a read & write key. Find the app's adamId via targeting search type=app.

Apple has no objective field. A campaign is an app, a placement (supplySource) and a bidding strategy (biddingStrategy), and those two are the whole of what there is to choose. All four placements are creatable: search results (the default), the Search tab, the Today tab, and the “You Might Also Like” strip on other apps' product pages. Search results is the only placement with keywords, negative keywords or Search Match — there is no query to match anywhere else — and it is also the only one MAX_CONVERSIONS runs in.

Awareness, engagement, video views, leads and catalogue sales are not features we have yet to build on Apple: none of them exists anywhere in the Apple Search Ads API. The ad is assembled from your App Store listing, and the only actions it can measure are a tap and an install.

Send adGroup and the ad group, its targeting and both keyword lists are all created inside the new campaign in this one call — a campaign without an ad group can never serve, so that is what the in-app launcher always sends. Targeting keywords are created ACTIVE: the paused campaign and paused ad group are what gate delivery, and a keyword that arrives paused inside them is a silent no-op you would have to undo by hand. If the campaign and ad group are created and the keywords are not, the answer is still 201 — the campaign is complete and correct — carrying keywordsError plus the ids to retry against.

The ad is built here too, on the placements that owe one, and the sentence saying this endpoint builds no Apple creative at launch is no longer true. Apple assembles the ad from your App Store listing on APPSTORE_SEARCH_RESULTS and APPSTORE_PRODUCT_PAGES_BROWSE, so those two owe no ad object and come back with adId and creativeId of null — which is correct rather than an omission. The other two do owe one: the Search tab gets a default product page ad, and the Today tab gets a Custom Product Page ad, because a custom page is the Today tab's tap destination and Apple publishes no default for it. Naming ad.productPageId promotes that page on any of the four, which makes it a choice rather than a placement rule. Every campaign this endpoint built on those two placements before this landed was structurally unable to serve, under a 201 telling the owner to switch it on.

Two new refusals, both before anything exists or with the campaign rolled back. A APPSTORE_TODAY_TAB launch carrying an adGroup and no ad.productPageId is a 400 VALIDATION_ERROR raised before the campaign is created, naming the lookup that lists your pages. It used to answer 201 and leave behind a campaign that could never serve, squatting the name you wanted. And a failed AD create now rolls the campaign back and answers 502 when Apple refused, or 400 when our own check did — the split matters, because calling our refusal an upstream error would send you to look at Apple. The rollback cannot undo the creative: Apple publishes no creative delete, which is harmless and slightly useful, since the creative is org-scoped and the retry finds it instead of colliding with it. A body with no adGroup at all is untouched; that is the documented two-step contract and it already says the campaign cannot serve yet.

Also in the dashboard. Ads tab → Apple → Launch. ad.productPageId stopped being API-only this phase: the launcher REQUIRES it on the Today tab and OFFERS it on the other three placements, picked from the product_page lookup rather than typed, since the ids are UUIDs and Apple publishes no search over them.

Body

namestring

Required. Campaign display name

adamIdnumber

Required. The App Store app id, from targeting search type=app

countriesOrRegionsstring[]

Required. ISO-2 storefronts, e.g. ["US"]

dailyBudgetCentsnumber

Required daily budget

supplySourcestring

APPSTORE_SEARCH_RESULTS (default), APPSTORE_SEARCH_TAB, APPSTORE_TODAY_TAB, APPSTORE_PRODUCT_PAGES_BROWSE

biddingStrategystring

'MANUAL_CPT' (default) or 'MAX_CONVERSIONS' (Apple bids automatically toward targetCpaCents; search results only; requires the automated ad group next)

targetCpaCentsnumber

MAX_CONVERSIONS only: required target cost per conversion

adGroupobject

{ name, defaultBidCents, searchMatch?, targeting?, keywords?, negativeKeywords? } — creates the ad group inside the new campaign in the same call. Without an ad group a campaign can never serve. searchMatch defaults to true on a search-results campaign, because an ad group with neither it nor keywords matches nothing; send false to opt out and supply keywords instead. MAX_CONVERSIONS campaigns get Apple’s automated ad group, where Search Match is always on and defaultBidCents is ignored

adGroup.keywordsarray

[{ text, matchType: 'EXACT' | 'BROAD' }] — Apple has NO phrase match, and PHRASE is refused rather than quietly bought as broad. Created ACTIVE in the new ad group. Refused off the search-results placement (no query to match), and refused under MAX_CONVERSIONS, where Apple picks the keywords itself

adGroup.negativeKeywordsarray

The same shape, and the searches this campaign must NOT match. Worth sending at launch rather than afterwards: an ad group with Search Match on starts matching queries the moment the campaign goes live. These are the ONE keyword-shaped thing Apple accepts on an automated (MAX_CONVERSIONS) ad group, which is Apple’s own documented exception

adGroup.targetingobject

Apple’s full targetingDimensions: { ageMin, ageMax, genders, deviceClasses, daypartHours, adminAreas, localities, appDownloaders, appCategories }. appDownloaders: { mode: "new_users", adamId } is how an install campaign stops paying for taps from people who already have the app. appCategories: { include, exclude } exists ONLY on the APPSTORE_PRODUCT_PAGES_BROWSE placement and is REFUSED rather than dropped on the other three — an advertiser who asked to appear only on competitors’ pages and had it silently discarded would be running the campaign they were trying to avoid. 100 is Apple’s one documented value ("the same category as my app"); every other id is an App Store category id Apple publishes as a document rather than through its API, which is why that lookup is declared and unbuilt. This whole 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

adobject

{ productPageId?, name? } — the ad the placement serves, created inside the new campaign alongside the ad group. REQUIRED on APPSTORE_TODAY_TAB whenever adGroup travels with the launch, and optional on the other three. Sending productPageId on ANY placement promotes that Custom Product Page instead of the default one. name defaults to "{campaignName} ad"

ad.productPageIdstring

The Custom Product Page UUID (8-4-4-4-12 hex, e.g. 45812c9b-c296-43d3-c6a0-c5a02f74bf6e), from GET …/ads/lookups?network=apple_search_ads&type=product_page&adamId={adamId}, or from the older …/ads/targeting-search?type=product_pages&q={adamId}. Both LIST the app’s pages rather than searching them, because Apple publishes no search over them

adGroup.cpaGoalCentsnumber

Optional, and NOT the campaign’s targetCpaCents: targetCpa instructs Apple’s automated bidding, while this changes no bid at all and only gives reporting a benchmark to measure against. Search-results campaigns only, never on a MAX_CONVERSIONS ad group (which is the one goal that would want a cost target, and the one Apple refuses the field on), and Apple is sunsetting it

Response (201): the ad fields

adId / creativeIdstring | null

The ad and the creative behind it. Null on APPSTORE_SEARCH_RESULTS and APPSTORE_PRODUCT_PAGES_BROWSE when no ad.productPageId was sent, because Apple renders those from the App Store listing and they owe no ad object. Named in the response rather than left to a follow-up read: a creative is minted once per product page per organisation and has no update endpoint, so an id that never reached the caller is a slot nobody can point at again

creativeStatestring | null

Apple’s own review verdict on the creative, and NULL ON ONE WE JUST CREATED — Apple’s create returns only the id, so null here means "not read" rather than "no state". A REUSED creative (the find-before-create found an existing one for that product page) carries Apple’s real verdict, which is the one worth reading: an ad on a creative that has not cleared review is accepted and then delivers nothing

creativeStateReasonsstring[]

Apple’s reasons, empty when it gave none. When it gives any, the note repeats them and says to settle them before switching the campaign on

Create an Apple campaign
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/campaigns?network=apple_search_ads" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "name": "US search",
  "adamId": 1234567890,
  "countriesOrRegions": [
    "US"
  ],
  "dailyBudgetCents": 5000,
  "supplySource": "APPSTORE_SEARCH_RESULTS",
  "biddingStrategy": "MANUAL_CPT",
  "adGroup": {
    "name": "Brand",
    "defaultBidCents": 150,
    "searchMatch": false,
    "keywords": [
      {
        "text": "habit tracker",
        "matchType": "EXACT"
      },
      {
        "text": "habit app",
        "matchType": "BROAD"
      }
    ],
    "negativeKeywords": [
      {
        "text": "free",
        "matchType": "BROAD"
      }
    ]
  }
}'
201
{
  "campaignId": "900000010",
  "adGroupId": "900000020",
  "adId": null,
  "creativeId": null,
  "creativeState": null,
  "creativeStateReasons": [],
  "network": "apple_search_ads",
  "supplySource": "APPSTORE_SEARCH_RESULTS",
  "status": "PAUSED",
  "keywordsCreated": 2,
  "negativeKeywordsCreated": 1,
  "note": "Created paused. PATCH the campaign ACTIVE to start spending."
}
A Today tab campaign, which owes an ad
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/campaigns?network=apple_search_ads" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "name": "Today tab",
  "adamId": 1234567890,
  "countriesOrRegions": [
    "US"
  ],
  "dailyBudgetCents": 5000,
  "supplySource": "APPSTORE_TODAY_TAB",
  "adGroup": {
    "name": "Today",
    "defaultBidCents": 400
  },
  "ad": {
    "productPageId": "8f2c1a30-0000-4a1b-9c3d-1111aaaa2222",
    "name": "Spring page"
  }
}'

Create ad groups

POST/api/v1/businesses/{slug}/ads/adgroups?network=apple_search_ads

Creates an ad group inside an existing campaign, PAUSED. automated: true creates the automated ad group a MAX_CONVERSIONS campaign requires (Apple owns keywords and bids there, so defaultBidCents is ignored). Requires a read & write key.

Geo ids for adminAreas / localities come from targeting search type=geo, and geo targeting only works on single-country campaigns. daypartHours are hours-of-week, 0-167 counted from Sunday 00:00.

Body

campaignIdstring

Required. The owning campaign

namestring

Required. Ad group display name

defaultBidCentsnumber

Required (ignored with automated: true). Default bid keywords inherit

searchMatchboolean

Let Apple auto-match the ad to relevant searches

automatedboolean

Create the automated ad group a MAX_CONVERSIONS campaign requires

targeting.ageMin / ageMaxnumber

Optional age band

targeting.gendersstring[]

["M"], ["F"], or both

targeting.deviceClassesstring[]

IPHONE, IPAD

targeting.daypartHoursnumber[]

Hours-of-week, 0-167 from Sunday 00:00

targeting.adminAreas / localitiesstring[]

Pipe geo ids from targeting search type=geo (single-country campaigns only)

targeting.appDownloadersobject

{ mode: 'downloaders' | 'new_users', adamId }: target by download history of the campaign's app

Create a targeted ad group
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/adgroups?network=apple_search_ads" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "campaignId": "900000010",
  "name": "Broad",
  "defaultBidCents": 150,
  "searchMatch": true,
  "targeting": {
    "ageMin": 18,
    "ageMax": 45,
    "genders": [
      "M",
      "F"
    ],
    "deviceClasses": [
      "IPHONE"
    ],
    "daypartHours": [
      9,
      10,
      11
    ],
    "adminAreas": [
      "US|CA"
    ],
    "appDownloaders": {
      "mode": "new_users",
      "adamId": "1234567890"
    }
  }
}'
Create the automated ad group
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/adgroups?network=apple_search_ads" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "campaignId": "900000010",
  "name": "Automated",
  "automated": true
}'
201
{
  "adGroupId": "900000020",
  "campaignId": "900000010",
  "network": "apple_search_ads",
  "status": "PAUSED",
  "note": "Created paused. Add keywords (POST …/ads/keywords), then PATCH the ad group ACTIVE."
}

Pause, budgets & bids

PATCH/api/v1/businesses/{slug}/ads/entities/{id}?network=apple_search_ads

Updates one entity: campaign status/budget, ad group status/default bid, or keyword status/bid. Requires a read & write key.

Body

kindstring

Required. 'campaign' | 'adgroup' | 'keyword'

campaignIdstring

Required. The owning campaign id

adGroupIdstring

Required for keyword updates

statusstring

'ACTIVE' or 'PAUSED'

dailyBudgetCentsnumber

Campaigns only, min 100

bidCentsnumber

Ad groups (default bid) and keywords (keyword bid), min 1

Raise a keyword bid
curl -X PATCH "https://affiliateo.com/api/v1/businesses/acme/ads/entities/900000003?network=apple_search_ads" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "kind": "keyword",
  "campaignId": "900000001",
  "adGroupId": "900000002",
  "bidCents": 350
}'
200
{ "ok": true, "network": "apple_search_ads", "id": "900000003", "kind": "keyword" }

Targeting keywords

POST/api/v1/businesses/{slug}/ads/keywords

Bulk keyword management for an ad group, up to 1,000 keywords per call. Omit bidCents on a keyword to inherit the ad group's default bid. The live keyword list (with bids and statuses) rides the campaign tree. Writes require a read & write key.

Keywords are created PAUSED, like every other create on this API, so adding a hundred keywords never starts a hundred auctions on its own. Each returned keyword carries the status it actually has; PATCH them ACTIVE when the ad group is ready to bid on them.

text and matchType are immutable on Apple's side: to change them, delete the keyword and recreate it. PATCH and DELETE return { ok }.

Body (POST / PATCH / DELETE)

campaignIdstring

Required

adGroupIdstring

Required

keywordsarray

POST only: [{ text, matchType: 'EXACT' | 'BROAD', bidCents? }]

updatesarray

PATCH only: [{ id, status?, bidCents? }]

idsstring[]

DELETE only: keyword ids to remove

Add keywords
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/keywords" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "campaignId": "900000010",
  "adGroupId": "900000020",
  "keywords": [
    {
      "text": "habit tracker",
      "matchType": "EXACT",
      "bidCents": 150
    }
  ]
}'
Pause a keyword + lower its bid
curl -X PATCH "https://affiliateo.com/api/v1/businesses/acme/ads/keywords" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "campaignId": "900000010",
  "adGroupId": "900000020",
  "updates": [
    {
      "id": "900000789",
      "status": "PAUSED",
      "bidCents": 120
    }
  ]
}'
Delete keywords
curl -X DELETE "https://affiliateo.com/api/v1/businesses/acme/ads/keywords" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "campaignId": "900000010",
  "adGroupId": "900000020",
  "ids": [
    "900000789"
  ]
}'
201
{
  "ok": true,
  "keywords": [
    { "id": "900000789", "text": "habit tracker", "matchType": "EXACT", "status": "PAUSED" }
  ]
}

Negative keywords

POST/api/v1/businesses/{slug}/ads/negative-keywords

GET lists, POST adds, PATCH pauses or reactivates, DELETE removes. adGroupId picks the scope: omit it for campaign-wide (blocks the term in every ad group), pass it to target one ad group. Writes require a read & write key. Up to 1,000 keywords per call. status is the only field Apple allows updating on an existing negative keyword (text/matchType = delete + recreate); PATCH returns { ok }.

Body (POST / PATCH / DELETE)

campaignIdstring

Required

adGroupIdstring

Optional scope. Omit = campaign-wide

keywordsarray

POST only: [{ text, matchType: 'EXACT' | 'BROAD' }]

updatesarray

PATCH only: [{ id, status: 'ACTIVE' | 'PAUSED' }]

idsstring[]

DELETE only: negative keyword ids to remove

List negative keywords
curl "https://affiliateo.com/api/v1/businesses/acme/ads/negative-keywords?campaignId=900000001" \
  --header 'Authorization: Bearer afk_...'
Add negative keywords
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/negative-keywords" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "campaignId": "900000001",
  "keywords": [
    {
      "text": "free",
      "matchType": "BROAD"
    }
  ]
}'
Pause negative keywords
curl -X PATCH "https://affiliateo.com/api/v1/businesses/acme/ads/negative-keywords" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "campaignId": "900000001",
  "updates": [
    {
      "id": "900000099",
      "status": "PAUSED"
    }
  ]
}'
Remove negative keywords
curl -X DELETE "https://affiliateo.com/api/v1/businesses/acme/ads/negative-keywords" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "campaignId": "900000001",
  "ids": [
    "900000099"
  ]
}'
200
{
  "negatives": [
    { "id": "900000099", "campaignId": "900000001", "adGroupId": null, "text": "free", "matchType": "BROAD", "status": "ACTIVE" }
  ]
}

Bid recommendations

GET/api/v1/businesses/{slug}/ads/keyword-recommendations

Apple's suggested bid per targeting keyword, derived from 30-day keyword reports. suggestedBidCents is null in MAX_CONVERSIONS campaigns (Apple bids automatically there). Works on read-only Apple connections.

This is a bid for a keyword you already have, not a new keyword. Apple publishes no keyword-suggestion endpoint of any kind, so keyword discovery on Apple is its search-term report: GET …/ads/lookups?network=apple_search_ads&type=keyword_idea&campaignId=… returns the phrases people actually typed, with meta.source saying whether Search Match found one and nothing targets it yet (AUTO — a keyword to add, or a negative to block) or one of your own keywords already bought it (TARGETED). Apple only reports terms with at least ten impressions.

Also in the dashboard. Ads tab → Apple, below the campaign table, as Keyword ideas — one panel carrying both halves of this endpoint. Apple's half is an inventory, so there is nothing to type: pick a campaign (and an ad group, to reach the narrower report) and the answer is the campaign. Google's half of the same panel is the seeded search below.

Query parameters

campaignIdstring

Required. The campaign whose keywords to score

adGroupIdstring

Optional, and usually the one you want. Apple publishes this report at TWO levels and this reaches the narrower endpoint rather than filtering here — the campaign-level report returns every keyword in every ad group, so re-bidding one ad group on a forty-ad-group campaign otherwise pays for forty times the rows

Get bid recommendations
curl "https://affiliateo.com/api/v1/businesses/acme/ads/keyword-recommendations?campaignId=900000010" \
  --header 'Authorization: Bearer afk_...'
200
{
  "campaignId": "900000010",
  "recommendations": [
    {
      "keywordId": "900000789",
      "text": "habit tracker",
      "matchType": "EXACT",
      "adGroupId": "900000020",
      "suggestedBidCents": 180,
      "currency": "USD"
    }
  ]
}

Custom Product Page ads

POST/api/v1/businesses/{slug}/ads/creatives?network=apple_search_ads

Apple's whole creative surface is one format. Nothing is uploaded — Apple renders the ad from an App Store product page — but it is still two objects in a fixed order: an organisation-scoped creative at /creatives, then an ad under a campaign and an ad group pointing at it.

The creative is found before it is created, and that is the trap this closes. Apple allows one creative per product page per organisation, so the second campaign promoting the same page gets a duplicate refusal that does not carry the id it needs. This looks first and reuses, reporting creative_reused so a caller is never surprised by an id it did not create. It is also what makes a failed ad create cheap to retry: Apple publishes no creative update and no creative delete, so an object that landed is found rather than orphaned, and a refusal after the creative exists names its id either way.

creative_state comes back on every answer because it is the failure that costs nothing and looks like success: an ad on a creative that has not cleared review is accepted and then never delivers. Send creativeId instead of creative to point at one that already exists — the creative lookup (…/ads/lookups?network=apple_search_ads&type=creative) lists them, and its meta.state is what separates a usable creative from one that is accepted and silent.

Also in the dashboard. Ads tab → Apple, below the campaign table, in the creative builder it shares with Snapchat's four formats. A builder rather than a table, because this endpoint's POST is the whole capability — there is no creative list on it. It never opens by itself and it warns before it is used, for the reason above: neither network publishes a delete, so anything it makes is permanent. The campaign and ad group are picked from the tree rather than typed when the tree is there.

Body

formatstring

'product_page'. Apple has exactly one

campaignId / adGroupIdstring

Required. Apple hangs an ad off both

creative.adamIdnumber

The app. From ?type=app on the lookup surface

creative.typestring

'CUSTOM_PRODUCT_PAGE' or 'DEFAULT_PRODUCT_PAGE'

creative.productPageIdstring

The Custom Product Page UUID, from …/ads/targeting-search?network=apple_search_ads&type=product_pages&q={adamId} — which takes the Adam ID rather than a search term

creativeIdstring

Alternative to creative: point the ad at a creative that already exists

Advertise a Custom Product Page
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/creatives?network=apple_search_ads" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "format": "product_page",
  "campaignId": "900000010",
  "adGroupId": "900000020",
  "creative": {
    "name": "Runners CPP",
    "adamId": 1234567890,
    "type": "CUSTOM_PRODUCT_PAGE",
    "productPageId": "8f2c1a30-0000-4a1b-9c3d-1111aaaa2222"
  }
}'
201
{
  "network": "apple_search_ads",
  "creative_id": "5500001",
  "creative_reused": true,
  "creative_state": "VALID",
  "ad_id": "6600001"
}

Impression share

POST/api/v1/businesses/{slug}/ads/impression-share

How much of the search results you got, and who was ahead of you. The metrics are lowImpressionShare, highImpressionShare, rank (ONE through GREATER_THAN_FIVE) and searchPopularity, dimensioned by app, country and search term. It is the only Apple report that is not about your own campaigns, which is why it takes an app and a country rather than a campaign id.

Apple-only and asynchronous, which is why it is not a dimension on Breakdowns: every other report this product pulls is a synchronous call that answers with rows, and this one answers with an id and a state (QUEUEDPENDING COMPLETED | FAILED). Apple allows ten of these per organisation per 24 hours.

Known Apple-side fault, and it is theirs rather than ours. Since 2026-03-16 Apple's gateway has answered these GETs with a 403 HTML page for accounts whose token and role work everywhere else (their own developer forum thread 820073, unanswered), so a report can be queued and never read. This endpoint says so in plain words instead of surfacing a bare 403 and sending somebody off to re-check their role, their org id and their token, none of which is the problem. The POST is unaffected.

Also in the dashboard. Ads tab → Apple, below the campaign table. On screen although Apple is refusing half of it, which is the deliberate part rather than an oversight: the queue half works, and where Apple answers its 403 the panel prints Apple's own refusal as a statement rather than showing a blank card or a red error. An owner who can read “Apple is refusing this report” stops debugging their own account; one looking at an empty box does not.

Google's impression share is four metrics on an ordinary search query — ask Breakdowns — and Meta, TikTok and Snapchat publish none.

Body (POST)

namestring

Required, 50 characters or fewer

granularitystring

'DAILY' or 'WEEKLY'

dateRangestring

'LAST_WEEK' | 'LAST_2_WEEKS' | 'LAST_4_WEEKS'. Mutually exclusive with startTime/endTime, and WEEKLY takes ONLY this form — refused here with the field to use, since Apple's own error names neither

startTime / endTimeYYYY-MM-DD

The absolute alternative to dateRange

adamIds / countriesOrRegionsarray

Optional narrowing

Query parameters (GET)

reportIdstring

One report. Without it, the last 20

limit / offsetnumber

limit up to 50

Queue a report
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/impression-share" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "name": "Last 4 weeks, US",
  "granularity": "WEEKLY",
  "dateRange": "LAST_4_WEEKS",
  "countriesOrRegions": [
    "US"
  ]
}'
Poll it
curl "https://affiliateo.com/api/v1/businesses/acme/ads/impression-share?reportId=770001" \
  --header 'Authorization: Bearer afk_...'
200
{
  "reportId": "770001",
  "state": "COMPLETED",
  "downloadUri": "https://…",
  "note": "downloadUri expires 90 seconds after this response; the report itself stays available for two days, so a missed download is another GET rather than another POST."
}

Sync stats

POST/api/v1/businesses/{slug}/ads/sync?network=apple_search_ads

On-demand refresh of Apple Search Ads reporting.

Sync Apple stats
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/sync?network=apple_search_ads" \
  --header 'Authorization: Bearer afk_...'

Google Ads

All six campaign types Google's API can create, since the campaign TYPE is what Google has instead of an objective: Search, Performance Max, Display, Demand Gen, App and Shopping, each built in one atomic call and each with a dry run. There is deliberately no Video — Google's API can only read Video campaigns, never create them. A Search campaign can also collect leads on Google itself — send leadForm and the launch authors the LEAD_FORM asset and attaches it in the same atomic call, which is the one place Google differs from every other network here: TikTok and Snapchat name a form that already exists, and Google has nothing to name until the launch makes it. Plus: read performance, inspect the live campaign tree, pause/resume, re-budget, re-bid, manage negative keywords, run keyword research, apply Google's own optimizer recommendations, and enable conversion tracking. The endpoints are live in the API but return 403 FORBIDDEN with a pending message until our Google Ads API access (developer token) is approved; everything below then activates automatically with no code changes. Everything created is PAUSED: starting spend is an explicit PATCH. Money is always cents in this API (dailyBudgetCents, cpcBidCents, targetCpaCents); Google natively speaks micros and we convert both ways.

Ad stats

GET/api/v1/businesses/{slug}/ads?network=google

Same parameters and response shape as Meta's Ad stats & ROAS, with network=google. Google reports campaign, ad group, keyword, and ad levels in by_level.

Get Google stats
curl "https://affiliateo.com/api/v1/businesses/acme/ads?network=google" \
  --header 'Authorization: Bearer afk_...'

Campaign tree

GET/api/v1/businesses/{slug}/ads/campaigns?network=google

The live campaign → ad group → ad/keyword tree straight from Google, nested (unlike the flat Meta and Apple lists): each campaign carries its adGroups, each ad group its ads and keywords. Campaigns carry advertisingChannelType, biddingStrategyType, dailyBudgetCents, and budgetResourceName; ad groups their default cpcBidCents; keywords their own bid and matchType.

dailyBudgetCents on a Google campaign is not necessarily that campaign's own number, which is the one thing about this tree that has no equivalent on the other four networks. A campaign_budget is its own resource and an advertiser can point any number of campaigns at one, so two fields say whether the number is shared. budgetCampaignCount is Google's reference_count: 1 is this campaign alone and anything above 1 is how many campaigns spend from that budget. budgetExplicitlyShared is the budget's type, which Google documents as flippable false to true and never back, so it can be true while exactly one campaign uses it — which is why it is not the answer on its own. Both are null when Google returned no budget row, and null is "unknown" rather than "private". Everything this product creates sends explicitly_shared: false; an account connected from Google Ads may hold shared budgets we did not build. See the entity endpoint for what happens when you try to edit one.

The tree also carries what used to be write-only. assetGroups is Performance Max's replacement for the ad group — a PMax campaign has no ad groups, ads or keywords at all, so before this it came back as a campaign with no contents, indistinguishable from a launch that half failed. Each asset group carries its assets (with the text or image label, not just an id), its signals (search themes and audiences), Google's adStrength grade, and — when you pass from and to — its metrics. assets and criteria at campaign and ad-group level are the sitelinks, callouts, locations, languages, schedules and exclusions that could be written and never read. advertisingChannelType is what tells an empty PMax campaign (that is how PMax is shaped) from an empty Search campaign (that is a problem).

Everything is patched via the composite id Google composes, and it is what these responses return: adGroupId~childId for an ad or keyword, parentId~criterionId for a criterion, and parentId~assetId~FIELD_TYPE for an asset link — the field type is part of a link's identity, which is what lets one image serve as both MARKETING_IMAGE and SQUARE_MARKETING_IMAGE. Pass ?customerId= to pin one of the connection's ad accounts; it defaults to the first.

Query parameters

customerIdstring

Optional. One of the connection's ad account ids. Default: the first account

includestring

Comma-separated: asset_groups, assets, criteria. Default: all three. Use include=none for the four levels alone — the old payload, and the old latency

from / toYYYY-MM-DD

Optional. Adds per-asset-group performance. The structure is read without a window, so "did my launch land" never depends on picking one

Get campaign tree
curl "https://affiliateo.com/api/v1/businesses/acme/ads/campaigns?network=google" \
  --header 'Authorization: Bearer afk_...'
200
{
  "business": "acme",
  "network": "google",
  "customer_id": "1234567890",
  "currency": "USD",
  "campaigns": [
    {
      "id": "20000000001",
      "name": "Brand search",
      "status": "PAUSED",
      "advertisingChannelType": "SEARCH",
      "biddingStrategyType": "MAXIMIZE_CLICKS",
      "dailyBudgetCents": 5000,
      "budgetResourceName": "customers/1234567890/campaignBudgets/9000000001",
      "budgetExplicitlyShared": false,
      "budgetCampaignCount": 1,
      "adGroups": [
        {
          "id": "30000000001",
          "name": "Exact match",
          "status": "ENABLED",
          "cpcBidCents": 150,
          "ads": [
            {
              "id": "40000000001",
              "status": "ENABLED",
              "type": "RESPONSIVE_SEARCH_AD",
              "finalUrl": "https://acme.com"
            }
          ],
          "keywords": [
            {
              "criterionId": "50000000001",
              "text": "habit tracker",
              "matchType": "EXACT",
              "status": "ENABLED",
              "cpcBidCents": 175
            }
          ],
          "assets": [],
          "criteria": []
        }
      ],
      "assetGroups": [],
      "assets": [
        { "id": "20000000001~5101~SITELINK", "assetId": "5101", "fieldType": "SITELINK",
          "status": "ENABLED", "assetType": "SITELINK", "label": "Our pricing" }
      ],
      "criteria": [
        { "id": "20000000001~101", "criterionId": "101", "type": "LOCATION",
          "status": "ENABLED", "negative": false,
          "value": "geoTargetConstants/2840", "constantId": "2840", "bidModifier": null }
      ]
    }
  ]
}

Create campaigns

POST/api/v1/businesses/{slug}/ads/campaigns?network=google

Creates budget → campaign (PAUSED) → geo/language criteria → ad group → responsive search ad → keywords in one atomic operation: it either all succeeds or nothing is created. Requires a read & write key. Append &validate=1 for a full server-side dry run that creates nothing and returns { validated: true }.

Geo ids come from targeting search type=geo, language ids from type=language (1000 = English; omit languageIds to target all languages).

Google has no objective field. The campaign type is what Google has instead, so &type= is where an objective goes — and every one of the six types its API can create is creatable here, each as its own atomic mutate with its own body. Omitting type (or sending anything unrecognised) builds the Search stack the table below describes, which is what every caller who has never sent the parameter has always got.

Campaign types (?type=), which is what Google has instead of an objective

(omitted)search

Budget → campaign → geo/language → ad group → responsive search ad → keywords. The body documented below. The only type with keywords, and the only one that takes leadForm — send it and two more operations join the same mutate, a LEAD_FORM asset and the campaign_asset that links it, which is how Google collects leads (see Lead forms below).

pmaxperformance max

Budget → campaign → geo/language → asset group → assets → asset links → signals. No ad groups, ads or keywords: an asset group replaces all three. Takes assetGroup instead of adGroup/ad/keywords, and its images must be uploaded first via POST …/ads/media?network=google, which hands back the asset resource names the body takes. Google enforces its asset minimums server-side, so &validate=1 is the only honest way to check a build before committing.

displaydisplay network

A responsive display ad across the Google Display Network. ad: { finalUrl, headlines (1-5), longHeadlines (the first is used), descriptions (1-5), businessName, marketingImageAssets, squareMarketingImageAssets, logoAssets }. Note the limits: 5 headlines, not the search ad’s 15.

demand_gendemand gen

An ad in the feeds people scroll — YouTube (including Shorts), Discover and Gmail. THREE layouts, picked with ad.demandGenFormat: multi_asset (the default; the same body as display minus longHeadlines, with logoAssets required rather than optional), carousel (a swipeable card list, ad.carouselCards, where array position is swipe position), or video_responsive (ad.videoAssets — YouTube asset resource names, since Google takes no video bytes).

appapp campaign

Installs across Search, Google Play, YouTube and the Display Network. app: { appId (a Play package name or an App Store id), appStore? } plus ad: { headlines (2-5), descriptions (2-5) } and bidding.targetCpaCents, which is REQUIRED — Google bids an App campaign with target_cpa and that strategy IS its target. No landing page and no audience to pick: Google targets from your app listing.

shoppingshopping

Product listings rendered from your Google Merchant Center feed. shopping: { merchantId, feedLabel?, campaignPriority? } and NO ad at all — nothing to write and nothing to upload. An "All products" listing group is created with the ad group, without which the campaign could never bid. The Merchant Center account has to be LINKED to this Google Ads account first, and that link is reachable: GET …/ads/catalogs?network=google lists the linked accounts, POST the same path with { merchantCenterId } creates the link when the connected person is an admin on both, and &resource=invitations is where a link started from the Merchant Center side waits to be accepted.

(no video)not creatable

Deliberately absent, and it is Google’s refusal rather than a gap on our side: their Video campaigns guide states the API "only supports fetching and reporting on existing Video campaigns and their criteria. You cannot create new Video campaigns or update existing ones using the Google Ads API." Demand Gen is Google’s own suggested alternative and does run on YouTube, but the ad we build there is an image ad.

Body

campaignNamestring

Required. Campaign display name, max 255 chars, no line breaks

dailyBudgetCentsnumber

Required, min 100 (= 1.00 in the account currency)

bidding.strategystring

'maximize_clicks' (default) | 'maximize_conversions' | 'maximize_conversion_value' | 'manual_cpc'

bidding.targetCpaCentsnumber

maximize_conversions only: optional target cost per conversion

bidding.targetRoasnumber

maximize_conversion_value only: optional target return on ad spend, e.g. 3.5 = 350%

geoTargetIdsstring[]

Required, at least 1. Ids from targeting search type=geo, e.g. ["2840"] (United States)

languageIdsstring[]

Optional. Ids from targeting search type=language; empty = all languages

audienceIdsstring[]

Optional, TOP LEVEL (Meta and TikTok carry theirs one object down). User list RESOURCE NAMES — "customers/123/userLists/456", from GET …/ads/lookups?network=google&type=audience — because Google refuses the bare numeric id in a user_list criterion. They ride the SAME atomic mutate as the rest of the launch, appended last, so an untargetable launch creates nothing at all. Never send an empty array; omit the field. REFUSED on ?type=app (a Google App campaign has no criteria of any kind) and on ?type=pmax, which takes audienceResourceNames instead — asset-group SIGNALS, a second Google id space ("customers/123/audiences/456") that seeds Google rather than restricting it

excludedAudienceIdsstring[]

Optional. Google has no separate exclusion field: it is the same user_list criterion with negative:true. The same list on both sides is refused rather than sent — Google creates both criteria, the exclusion wins, and the ad group reaches nobody in it

adGroup.namestring

Optional ad group display name

adGroup.cpcBidCentsnumber

manual_cpc only: the ad group default bid

ad.finalUrlstring

Required. https:// landing URL

ad.headlinesstring[] | object[]

3-15 headlines, max 30 chars each. An entry may be { text, pinnedField } to PIN it to a slot — HEADLINE_1, HEADLINE_2 or HEADLINE_3. Only those five pins (with DESCRIPTION_1 and DESCRIPTION_2) are offered out of Google’s 39-member shared enum, a headline pinned to a description slot is refused because Google ACCEPTS it and the asset then never serves, and pinning every headline is refused because it leaves Google nothing to test. An unpinned ad builds the identical body it always did

ad.descriptionsstring[] | object[]

2-4 descriptions, max 90 chars each. Same { text, pinnedField } form, pinned to DESCRIPTION_1 or DESCRIPTION_2

ad.demandGenFormatstring

?type=demand_gen only: 'multi_asset' (the default, byte-identical to what shipped before), 'carousel' or 'video_responsive'. Sending it on any other campaign type is refused rather than ignored — a body carrying it is a caller who believes they are building a carousel

ad.carouselCardsobject[]

demandGenFormat 'carousel': 2-10 cards, each { headline, marketingImageAsset, squareMarketingImageAsset?, portraitMarketingImageAsset?, callToActionText? }. ARRAY POSITION IS SWIPE POSITION — never sorted or deduplicated. The cards are created inside the SAME atomic mutate as the ad that references them, because a separate assets call can half-succeed and strand orphan assets nothing can find again

ad.videoAssetsstring[]

demandGenFormat 'video_responsive': YouTube video ASSET resource names ("customers/123/assets/999"), which come from POST …/ads/media?network=google with { youtube_url }. Google publishes no video upload that takes bytes, so a link is the only way in

ad.path1 / path2string

Optional display-path segments, max 15 chars each; path2 requires path1

keywordsarray

Required, at least 1: [{ text (max 80 chars / 10 words), matchType: 'EXACT' | 'PHRASE' | 'BROAD', cpcBidCents? }]

negativeKeywordsarray

Optional: [{ text, matchType }] blocked at the campaign level from day one

leadFormobject

Optional, Search only. Collect leads on Google instead of sending people to a page: the launch AUTHORS a LEAD_FORM asset and attaches it to the campaign, inside the same atomic mutate. Full field table below under Lead forms. Omitted, the launch builds byte-for-byte the campaign this endpoint has always built

containsEuPoliticalAdvertisingstring

EU political-advertising declaration (TTPA): 'CONTAINS_EU_POLITICAL_ADVERTISING' or 'DOES_NOT_CONTAIN_EU_POLITICAL_ADVERTISING' (the default). Google requires a value on every new campaign, and a declared campaign no longer serves in the EU at all. Accepted on every ?type= — Search, pmax, display, demand_gen, app and shopping all carry it — and editable afterwards on the campaign entity

Create a Google campaign
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/campaigns?network=google" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "campaignName": "Brand search",
  "dailyBudgetCents": 5000,
  "bidding": {
    "strategy": "maximize_clicks"
  },
  "geoTargetIds": [
    "2840"
  ],
  "languageIds": [
    "1000"
  ],
  "adGroup": {
    "name": "Exact match"
  },
  "ad": {
    "finalUrl": "https://acme.com",
    "headlines": [
      "Track Every Habit",
      "Build Better Routines",
      "Start Free Today"
    ],
    "descriptions": [
      "The habit tracker your goals deserve.",
      "Set goals, track streaks, stay accountable."
    ],
    "path1": "habits"
  },
  "keywords": [
    {
      "text": "habit tracker",
      "matchType": "EXACT"
    },
    {
      "text": "habit tracking app",
      "matchType": "PHRASE"
    }
  ],
  "negativeKeywords": [
    {
      "text": "free",
      "matchType": "BROAD"
    }
  ]
}'
Collect leads instead (Search + a form Google creates)
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/campaigns?network=google" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "campaignName": "Quote requests",
  "dailyBudgetCents": 2500,
  "bidding": {
    "strategy": "maximize_conversions"
  },
  "geoTargetIds": [
    "2840"
  ],
  "ad": {
    "finalUrl": "https://acme.com/quote",
    "headlines": [
      "Get A Quote Today",
      "Free Written Quote",
      "Reply In One Day"
    ],
    "descriptions": [
      "Tell us what you need and we will come back to you.",
      "No obligation, no sales call unless you ask for one."
    ]
  },
  "keywords": [
    {
      "text": "habit tracker for teams",
      "matchType": "PHRASE"
    }
  ],
  "leadForm": {
    "headline": "Get a quote in one day",
    "description": "Tell us what you need and we will come back to you.",
    "businessName": "Acme",
    "cta": "GET_QUOTE",
    "ctaDescription": "A written quote, no obligation.",
    "privacyPolicyUrl": "https://acme.com/privacy",
    "questions": [
      "FULL_NAME",
      "EMAIL",
      "COMPANY_NAME"
    ]
  }
}'
201
{
  "campaignId": "20000000001",
  "adGroupId": "30000000001",
  "adId": "40000000001",
  "network": "google",
  "status": "PAUSED",
  "note": "Created paused. PATCH the campaign with {\"kind\":\"campaign\",\"status\":\"ENABLED\"} to start spending."
}

Lead forms (Google authors the form)

Every other network in this API collects leads by pointing at a form that already exists: TikTok takes ad.pageId, Snapchat takes leadFormId. Google has nothing to point at — its lead form is an Asset, and the launch creates it. Send leadForm on the Search body (?type= omitted) and the same atomic mutate gains two operations: a LEAD_FORM asset built from the fields below, and the campaign_asset that links it to the campaign it just made. Either both exist or neither does.

No pixel and no conversion action are needed. The form is submitted on Google, and Google reports the submission itself — which is also why a lead campaign has no landing page to tag and reports Google's numbers rather than revenue. Leads are downloaded from Google Ads or delivered to a webhook you configure there; nothing in this API reads them back.

Search only, and on any other ?type= it is IGNORED rather than refused. Each campaign type has its own body parser and only the Search one reads leadForm, so a Performance Max or Demand Gen launch carrying one answers 201 and creates a campaign with no form attached. Demand Gen would take the identical two operations and is deliberately not wired yet — one unproven launch shape per change.

leadForm — the six Google marks Required, then the five it does not

headlinestring

Required. The title at the top of the form

descriptionstring

Required. What the form is for, shown under the headline

businessNamestring

Required. The business the form collects for

ctastring

Required, the button that OPENS the form: 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. Fourteen of Google’s sixteen — UNSPECIFIED and UNKNOWN are not choices

ctaDescriptionstring

Required. The line beside the button saying what someone gets for filling it in

privacyPolicyUrlstring

Required. A page describing how the data is handled. Google refuses the form without one

questionsstring[]

Required, at least one — a form that asks nothing collects nothing. Upper-case Google input types: FULL_NAME, FIRST_NAME, LAST_NAME, EMAIL, PHONE_NUMBER, POSTAL_CODE, STREET_ADDRESS, CITY, REGION, COUNTRY, COMPANY_NAME, JOB_TITLE, WORK_EMAIL, WORK_PHONE are the common ones and Google publishes about a hundred more (vertical questions like VEHICLE_PURCHASE_TIMELINE, plus forty-eight age gates). ANY enum-shaped name is accepted and Google is the authority on the rest, rather than a copied allowlist that goes stale. Duplicates are dropped and ORDER IS PRESERVED — see below

postSubmitHeadline / postSubmitDescriptionstring

Optional thank-you screen copy. Omitted rather than sent empty: an empty headline renders as nothing, which is not the same as Google’s own default

postSubmitCtastring

Optional, what the thank-you button does: VISIT_SITE, DOWNLOAD, LEARN_MORE, SHOP_NOW

desiredIntentstring

Optional: 'LOW_INTENT' (more leads, potentially lower quality) or 'HIGH_INTENT' (more qualified). Omitted when unset rather than defaulted — either choice buys a different set of leads than you asked for

backgroundImageAssetstring

Optional asset resource name ("customers/123/assets/456") from POST …/ads/media?network=google. Google requires exactly 1200x628

The question order is the form, and it is the one thing you cannot fix later. Google renders the questions in the order given, and its own field documentation says the list can be updated by reordering questions but not by adding or removing them. So a form that asks the wrong thing is replaced with a new one, not edited. The same question twice would be unfixable for the same reason, so duplicates are dropped here before Google ever sees them.

Per-field character limits are deliberately not enforced. Google publishes them in its Help Centre rather than in the API, so a limit invented here would refuse a form Google would have accepted, for a reason nobody could look up. Google's own refusal names the field and the length. What is checked is the shape of the two things a typo makes unrecoverable: an unknown call to action, and a privacy-policy URL that is not a URL.

Pause, budgets & bids

PATCH/api/v1/businesses/{slug}/ads/entities/{id}?network=google

Updates one entity at any level. This is the endpoint that starts real spend (flipping PAUSED → ENABLED, Google's word for active), so it requires a read & write key.

Campaign and ad group ids are numeric. Ad and keyword ids are the composite adGroupId~childId form, and you build it — the campaign tree does not hand it to you. Its ads and keywords carry the plain id / criterionId, nested inside the ad group they belong to, so join the ad group's id to the child's with a ~. An ad group holding ad 50000000001 under id 30000000001 is patched as 30000000001~50000000001. The plain id on its own is rejected: Google addresses an ad or a criterion by its parent, so half the id is not an id.

A shared campaign budget answers 409 CONFLICT, and nothing is sent to Google. A campaign_budget is its own resource and several campaigns can spend from one, so editing dailyBudgetCents through a campaign that shares its budget would rewrite the number every one of them spends against, with no symptom anywhere. The refusal names how many campaigns are on that budget and what to do instead: change the shared budget in Google Ads, or give this campaign a budget of its own first. It also refuses when Google does not report the sharing state at all, because "we could not read it" is not "it is private" and a write there is a coin flip on somebody's money. This used to answer 502 UPSTREAM_ERROR, which is the code an agent retries and this is the one refusal retrying will never get past — 409 CONFLICT says the refusal is ours and the account's state is what has to change. Read budgetCampaignCount off the campaign tree to know before you write. Ad group and keyword bids are unaffected: they are not budgets.

Also in the dashboard. Ads tab → Google. The daily-budget cell on a campaign whose budget is shared renders READ ONLY, showing that same sentence verbatim rather than a disabled box with no explanation. So the claim that the dashboard lets an owner edit any campaign's daily budget is false for Google, in exactly the same case and for exactly the same reason as the 409 here.

Body

kindstring

Required. 'campaign' | 'adgroup' | 'ad' | 'keyword'

statusstring

'ENABLED' or 'PAUSED'

dailyBudgetCentsnumber

Campaigns only. Answers 409 CONFLICT, before Google is asked, when the campaign’s budget is shared with other campaigns or when Google did not report the sharing state

cpcBidCentsnumber

Ad groups (default bid) and keywords (keyword bid)

Start spending (activate)
curl -X PATCH "https://affiliateo.com/api/v1/businesses/acme/ads/entities/20000000001?network=google" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "kind": "campaign",
  "status": "ENABLED"
}'
Raise a keyword bid
curl -X PATCH "https://affiliateo.com/api/v1/businesses/acme/ads/entities/30000000001~50000000001?network=google" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "kind": "keyword",
  "cpcBidCents": 200
}'
200
{ "ok": true, "network": "google", "id": "20000000001", "kind": "campaign" }

Negative keywords

POST/api/v1/businesses/{slug}/ads/negative-keywords?network=google

Campaign-level negative keywords: the term is blocked in every ad group of the campaign. GET lists them, POST adds, DELETE removes by composite campaignId~criterionId ids. There is no PATCH for Google (add and remove only; Apple keeps PATCH). Writes require a read & write key.

Body (POST / DELETE)

campaignIdstring

Required (also the required GET query parameter)

keywordsarray

POST only: [{ text, matchType: 'EXACT' | 'PHRASE' | 'BROAD' }]

idsstring[]

DELETE only: composite campaignId~criterionId ids from GET

List negative keywords
curl "https://affiliateo.com/api/v1/businesses/acme/ads/negative-keywords?network=google&campaignId=20000000001" \
  --header 'Authorization: Bearer afk_...'
Add negative keywords
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/negative-keywords?network=google" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "campaignId": "20000000001",
  "keywords": [
    {
      "text": "free",
      "matchType": "BROAD"
    }
  ]
}'
Remove negative keywords
curl -X DELETE "https://affiliateo.com/api/v1/businesses/acme/ads/negative-keywords?network=google" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "ids": [
    "20000000001~60000000001"
  ]
}'
200
{
  "negatives": [
    { "criterionId": "60000000001", "campaignId": "20000000001", "text": "free", "matchType": "BROAD" }
  ]
}

Keyword ideas

GET/api/v1/businesses/{slug}/ads/keyword-recommendations?network=google

Keyword research from Google's Keyword Planner: search volume, competition, and the top-of-page bid range for keywords related to your seeds. Seed it with q (comma-separated keywords), pageUrl (a landing page to mine), or both. The bid range is what feeding these into keywords[].cpcBidCents should orient on.

Google throttles keyword planning to about 1 request per second per account, so space out repeated calls.

Also in the dashboard. Ads tab → Google, below the campaign table, in the same Keyword ideas panel that carries Apple's bid recommendations. Google's half is a seeded search rather than an inventory, so the panel shows nothing until a seed is typed — which is a question waiting to be asked, not an empty state, and it says so. It needs no campaign, so it still works on a page whose campaign tree failed to load.

Query parameters

qstring

Comma-separated seed keywords (q and/or pageUrl required)

pageUrlstring

A https:// page to extract ideas from (q and/or pageUrl required)

geostring

Optional comma-separated geo target ids, e.g. 2840,21167. NUMERIC ids only, and a name is refused rather than dropped: ?geo=texas used to be filtered away and answer WORLDWIDE volumes and bid ranges that read as Texas ones, and those bid figures are what a caller pastes into cpcBidCents. Look ids up with …/ads/targeting-search?network=google&type=geo&q=texas

languagestring

Optional language id, e.g. 1000 (English)

Ideas from seed keywords
curl "https://affiliateo.com/api/v1/businesses/acme/ads/keyword-recommendations?network=google&q=habit%20tracker,goal%20app" \
  --header 'Authorization: Bearer afk_...'
Ideas from a landing page
curl "https://affiliateo.com/api/v1/businesses/acme/ads/keyword-recommendations?network=google&pageUrl=https://acme.com&geo=2840&language=1000" \
  --header 'Authorization: Bearer afk_...'
200
{
  "ideas": [
    {
      "text": "habit tracker app",
      "avgMonthlySearches": 27100,
      "competition": "HIGH",
      "lowTopOfPageBidCents": 92,
      "highTopOfPageBidCents": 341
    }
  ]
}
GET/api/v1/businesses/{slug}/ads/targeting-search?network=google

The Google flavor of the shared targeting-search endpoint. type=geo returns geo target constants (countries, states, cities) whose id goes into geoTargetIds; type=language returns language constants whose id goes into languageIds.

Query parameters

typestring

'geo' or 'language'

qstring

Required. Search text

countrystring

geo only: optional ISO-2 filter, e.g. US

Search geo targets
curl "https://affiliateo.com/api/v1/businesses/acme/ads/targeting-search?network=google&type=geo&q=texas&country=US" \
  --header 'Authorization: Bearer afk_...'
Search languages
curl "https://affiliateo.com/api/v1/businesses/acme/ads/targeting-search?network=google&type=language&q=eng" \
  --header 'Authorization: Bearer afk_...'
200 (type=geo)
{
  "results": [
    {
      "id": "21167",
      "name": "Texas",
      "canonicalName": "Texas,United States",
      "countryCode": "US",
      "targetType": "State",
      "reach": 24800000
    }
  ]
}
200 (type=language)
{
  "results": [
    { "id": "1000", "code": "en", "name": "English" }
  ]
}

Recommendations

POST/api/v1/businesses/{slug}/ads/recommendations?network=google

Google's own optimizer suggestions: budget raises, bidding upgrades, new keywords, responsive-search-ad improvements, 70+ types in all. This is the Google counterpart to Meta's automated rules (Google has no rules API): instead of writing your own automation, you review what Google's optimizer already computed and apply or dismiss it.

GET lists the account's open recommendations. POST with action: "apply" applies one with Google's default parameters; action: "dismiss" dismisses it. Writes require a read & write key.

Body (POST)

actionstring

Required. 'apply' or 'dismiss'

resourceNamestring

Required. The recommendation resourceName from GET

List recommendations
curl "https://affiliateo.com/api/v1/businesses/acme/ads/recommendations?network=google" \
  --header 'Authorization: Bearer afk_...'
Apply a recommendation
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/recommendations?network=google" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "action": "apply",
  "resourceName": "customers/1234567890/recommendations/abc123"
}'
200
{
  "recommendations": [
    {
      "resourceName": "customers/1234567890/recommendations/abc123",
      "type": "CAMPAIGN_BUDGET",
      "campaignId": "20000000001",
      "dismissed": false
    }
  ]
}

Conversion tracking

POST/api/v1/businesses/{slug}/ads/pixel?network=google

Google's "pixel" is a ConversionAction fed by server-side click uploads: our tracking snippet already captures the gclid on the advertiser's site, and Affiliateo reports each sale to Google automatically, live at sale time plus a nightly sweep, deduped by order id. Google refuses uploads for clicks younger than 6 hours; the nightly sweep absorbs those.

Two kinds of conversion, and the difference decides everything. type: "UPLOAD_CLICKS" (the default) is the one described above: we upload each sale from our server, keyed to the gclid our snippet captured. type: "WEBPAGE" is fed by a gtag on your own site instead, which is what you want for a signup, a lead or a booking — events that have no sale to upload. Pass { name?, type?, category? } to POST to choose; an empty body still creates the historical "Affiliateo purchases" action, so nothing built against this endpoint changes.

A WEBPAGE response carries action_required: "install_tag" and an install block with the finished code — a baseTag for every page and an eventSnippet for the moment the conversion happens. They are separate on purpose: put the event on every page and every visit counts as a conversion. Nothing is tracked until that tag is live, which is why the flag exists rather than only a sentence.

GET returns conversions, every conversion action with its own id, type, enabled and tag values. A connection can hold several, so there is no single "is tracking on". Read it before acting: "never set up" and "deliberately switched off" need different fixes and POSTing again only solves the first. POST is idempotent — the same name and type adopts the existing action rather than making a second one — and resolves cross-account conversion tracking through a manager account automatically. New actions warm up for about 6 hours before accepting uploads. Requires a read & write key.

PATCH { "conversionActionId": "…", "enabled": false } switches one conversion, by flipping its status to HIDDEN on Google. That is the only lever that works for a tag-reported conversion: our own flags gate our uploader, and a gtag on your site is not something we can reach. HIDDEN rather than removed, because removal is permanent and takes the conversion history Smart Bidding trained on with it.

Counted and chased are two different things. Beside enabled there is primaryForGoal: enabled decides whether Google records the conversion, primaryForGoal decides whether Google bids toward it. Send either on its own — only what you send is written, so changing one never overwrites the other.

Primary is account-wide, and that is the part worth pausing on. It governs every campaign on the account, not just this conversion. Create a second primary and your existing sales campaigns start optimising for it too. So create the ones you want to watch with { "primaryForGoal": false }, and keep one primary for the thing you actually want more of. The create response returns bidding_toward and warns when an account ends up chasing several at once. Google has exactly these two states; there is no third.

PATCH { "enabled": … } with no id keeps its old meaning: the connection-wide switch for the server-side sales uploader. Off stops new uploads (sales already reported stay reported); on turns them back on and the nightly sweep re-sends the last 7 days, so a pause loses nothing. Turning on mirrors to Google first and answers 502 if Google refuses, so the switch never reads as on while nothing is uploading. Google only: a Meta pixel belongs to the ad account and other tools use it, and TikTok pixels are created and retired in the dashboard.

Refunds are retracted. When a sale reverses, we send Google a conversion adjustment of type RETRACTION keyed on the same order id, so it stops counting that sale and Smart Bidding stops training on it. Google refuses to adjust a conversion less than about 24 hours old, so a same-day refund is picked up by the nightly sweep rather than immediately — the sweep re-reads reversed sales every night for exactly this reason.

This is Google only, and that is not an oversight on our side. Meta's Conversions API and TikTok's Events API are append-only event streams: there is no call that withdraws a purchase they have already counted, so there is nothing for us to send. On those two networks a refund moves our own verified revenue and ROAS, while the network's self-reported conversions and claimed ROAS keep the sale. That gap is one of the reasons every ad row shows both numbers side by side.

Get conversion tracking status
curl "https://affiliateo.com/api/v1/businesses/acme/ads/pixel?network=google" \
  --header 'Authorization: Bearer afk_...'
Enable conversion tracking
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/pixel?network=google" \
  --header 'Authorization: Bearer afk_...'
Track signups instead of sales
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/pixel?network=google" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "name": "Newsletter signup",
  "type": "WEBPAGE",
  "category": "SIGNUP"
}'
Switch ONE conversion off
curl -X PATCH "https://affiliateo.com/api/v1/businesses/acme/ads/pixel?network=google" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "conversionActionId": "987654321",
  "enabled": false
}'
Watch it, but stop bidding toward it
curl -X PATCH "https://affiliateo.com/api/v1/businesses/acme/ads/pixel?network=google" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "conversionActionId": "112233445",
  "primaryForGoal": false
}'
200
{
  "conversions": [
    { "id": "987654321", "name": "Affiliateo purchases", "type": "UPLOAD_CLICKS",
      "category": "PURCHASE", "enabled": true, "primaryForGoal": true,
      "tag": null, "isUploadTarget": true },
    { "id": "112233445", "name": "Newsletter signup", "type": "WEBPAGE",
      "category": "SIGNUP", "enabled": true, "primaryForGoal": false,
      "isUploadTarget": false,
      "tag": { "conversionId": "AW-1234567890", "label": "AbC-D_efGhIjK" } }
  ],
  "conversion_customer_id": "1234567890",
  "conversion_tracking_status": "CONVERSION_TRACKING_MANAGED_BY_SELF",
  "uploads_enabled": true
}
201 (WEBPAGE)
{
  "ok": true,
  "action_required": "install_tag",
  "conversion_type": "WEBPAGE",
  "tag": { "conversionId": "AW-1234567890", "label": "AbC-D_efGhIjK" },
  "install": {
    "baseTag": "<script async src=\"…/gtag/js?id=AW-1234567890\"></script>…",
    "eventSnippet": "<script>gtag('event','conversion',{'send_to':'AW-…/AbC-…'});</script>",
    "where": "Put baseTag in <head> on every page…"
  },
  "note": "Created, but NOTHING IS TRACKED YET…"
}
GET/api/v1/businesses/{slug}/ads/ad-strength?network=google

Google's own grade for every ad and every Performance Max asset group on the account, plus the action items it attaches to a low one. This is the answer where a preview is impossible: Google refuses to preview responsive search and responsive display ads, which is most of what an advertiser runs, so on a Search campaign this is the only read that says anything about creative quality. See Ad previews for the two shapes Google will render.

PENDING and NO_ADS are in Google's enum and are not gradesPENDING means Google has not finished assessing and NO_ADS means the ad group is empty — so they come back as themselves. Rendering either as "Poor" would be a false statement about somebody's campaign.

Both lists are read, and that matters rather than being thorough for its own sake: a Performance Max campaign has no ad rows at all, so reading only the first list returns an empty result that a reader takes for "no ads". Google only — no other network publishes an equivalent grade.

Query parameters

networkstring

google. Required in practice: the other four have nothing comparable

campaignIdstring

Optional. Scope both lists to one campaign

accountstring

Which Google customer id, when several are connected

Grade every ad on the account
curl "https://affiliateo.com/api/v1/businesses/acme/ads/ad-strength?network=google" \
  --header 'Authorization: Bearer afk_...'
200
{
  "network": "google",
  "ads": [
    {
      "adGroupId": "170000001",
      "adId": "690000001",
      "adStrength": "AVERAGE",
      "actionItems": ["Add more headlines", "Make your headlines more unique"]
    }
  ],
  "assetGroups": [
    { "assetGroupId": "220000001", "adStrength": "EXCELLENT", "actionItems": [] }
  ]
}

Experiments and drafts

POST/api/v1/businesses/{slug}/ads/experiments

Copy a campaign, change it, split the traffic, and either keep the change or throw it away. Google is the only one of the five networks with an experiment API — Meta's split tests need an App Review scope this app has not been granted, TikTok and Snapchat publish nothing comparable, and Apple has none at all.

Nothing spends at create. The experiment and both its arms are built in SETUP, and the split starts only at { action: "schedule" } — the same nothing-until-you-flip-it contract every launch path here keeps. Two actions cannot be undone: promote copies the treatment onto the original campaign and stops the treatment serving, and graduate turns the trial campaign into a standalone one (which is why it requires a budget: a trial campaign shares the original's and a standalone one cannot). Every response carries permanent: true on exactly those, so a client can gate them without hard-coding a list.

Read asyncErrors. Scheduling and promoting return long-running operations that report acceptance, not completion, and Google documents the real errors behind a separate list. Both readers run on the detailGET, so a failed promotion cannot look like a successful one.

DELETE is allowed here and refused for campaigns, ad groups and ads, for a reason rather than by inconsistency: an experiment and a draft are copies. Removing one leaves the original campaign serving, loses no stats, and takes no bidding history with it.

Also in the dashboard. Ads tab → Google, below the campaign table. A state machine rather than a table, because that is what this endpoint is: the actions offered on a row are the ones its current status allows, and the two permanent ones are confirmed before they run. asyncErrors is not a column — it exists only on the single-item read — so it is fetched when a row is opened, and the list never claims health it has not checked. It needs nothing from the campaign tree; it fetches the campaigns it needs itself.

Body (POST)

actionstring

'create' | 'schedule' | 'promote' | 'graduate' | 'end'. Drafts use the same verbs under kind: 'draft'

name / baseCampaignIdstring

create: what to call it and which campaign to copy

treatmentTrafficPercentnumber

create: ONE number, because the two arms cannot then fail to total 100

campaignBudgetMappingsobject[]

graduate: required, since a trial campaign shares the original’s budget and a standalone one needs its own

kindstring

'draft' to act on a campaign draft instead of an experiment

validateOnlyboolean

Rehearse the call and change nothing

Query parameters (GET / DELETE)

experimentIdstring

GET: adds the arms and asyncErrors. DELETE: which experiment to throw away

kindstring

'draft' lists or acts on drafts; &draftId=baseCampaignId~draftId adds that draft’s asyncErrors

networkstring

google

Create an experiment (nothing splits yet)
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/experiments" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "action": "create",
  "name": "Broad match test",
  "baseCampaignId": "210000001",
  "treatmentTrafficPercent": 50
}'
Start splitting traffic
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/experiments" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "action": "schedule",
  "experimentId": "450000001"
}'
200
{
  "experimentId": "450000001",
  "status": "GRADUATED",
  "permanent": true,
  "note": "Promoting copies the treatment onto the original campaign and stops the treatment serving. There is no undo."
}

Labels and campaign groups

POST/api/v1/businesses/{slug}/ads/labels

Two answers to one question — "which campaigns belong together" — and they are genuinely different objects. A label is many-to-many and goes on campaigns, ad groups and ads (Google keeps three separate label services, one per level). A campaign group is a folder a campaign is in at most one of.

Removing a label is safe in a way removing a campaign is not: a label carries no history, so a delete detaches it everywhere and nothing stops serving. Google only — Meta has no label object, TikTok and Snapchat publish none, and Apple's nearest equivalent is the campaign name.

Body (POST)

namestring

Create a label. backgroundColor and description are optional

actionstring

'attach' | 'detach' with level ('campaign' | 'adgroup' | 'ad'), objectId and labelId. An ad’s objectId is the composite adGroupId~adId

kindstring

'campaign_group' switches to groups: { kind, name } creates, { kind, action: 'rename', groupId, name } renames, { kind, action: 'assign', campaignId, groupId } files a campaign — groupId null takes it back out

Query parameters

kindstring

GET/DELETE: omit for labels, 'campaign_group' for groups

labelId / groupIdstring

DELETE: which one to remove

networkstring

google

Create a label
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/labels" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "name": "Client: Acme",
  "backgroundColor": "#2563eb"
}'
Put a campaign in a group
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/labels" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "kind": "campaign_group",
  "action": "assign",
  "campaignId": "210000001",
  "groupId": "330000001"
}'
200
{ "ok": true, "labelId": "880000001" }

Change history

GET/api/v1/businesses/{slug}/ads/change-history?network=google

Who changed what, recently. Every row carries clientType, which is how a change made through this product (GOOGLE_ADS_API) is told apart from one made in Ads Manager, in Google Ads Editor, by a script, or by Google's own recommendation automation.

Thirty days, and it is not an audit log. Google keeps no more, and answers a request for day 31 with an empty list rather than an error — which reads as "nobody touched it". So this endpoint refuses an out-of-range window and names the earliest day that is reachable, rather than passing a silence through as an answer.

Query parameters

startDate / endDateYYYY-MM-DD

The window. Refused rather than emptied when it reaches past Google’s 30-day retention

campaignIdstring

Optional. Scope to one campaign

limitnumber

Up to 10000

networkstring

google

Who changed this account
curl "https://affiliateo.com/api/v1/businesses/acme/ads/change-history?network=google&startDate=2026-07-10&endDate=2026-08-03" \
  --header 'Authorization: Bearer afk_...'
200
{
  "network": "google",
  "changes": [
    {
      "changeDateTime": "2026-07-28 09:14:22",
      "clientType": "GOOGLE_ADS_API",
      "changeResourceType": "CAMPAIGN",
      "resourceName": "customers/1234567890/campaigns/210000001",
      "changedFields": ["campaign.status"]
    }
  ]
}

Sync stats

POST/api/v1/businesses/{slug}/ads/sync?network=google

On-demand refresh of Google Ads reporting. Synced levels: campaign, ad group, keyword, and ad.

Sync Google stats
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/sync?network=google" \
  --header 'Authorization: Bearer afk_...'

Snapchat Ads

Snapchat launches a full campaign → ad squad → creative → ad stack in one call, across seven goals — the widest of the five, now TIED with Google rather than ahead of it: Google reached the same seven by a different route when its Search campaign learned to collect leads. The two miss different goals — Snapchat has no product sales, Google no video views. Everything is created PAUSED, and if any step of the stack fails the campaign it already made is deleted rather than left half-built. Snapchat ids are UUIDs at every level, unlike the numeric ids Meta, TikTok and Apple use, and the middle level is an ad squad: adsquad is the write-level name on field writes, while stats and the entity endpoints call the same thing adgroup. Money is cents at this boundary; Snapchat speaks micros natively and we convert both ways.

Four formats the launch body cannot express — deep link, reminder, story and collection — have their own path: Creative formats. Two things that used to be true here and are not any more: a video over 32MB is no longer refused (the upload chunks automatically above Snapchat's simple-upload ceiling, on the one network whose ads are full-screen vertical video), and a call to action Snapchat does not recognise is refused by name rather than quietly becoming "More". Snapchat's creative library is readable too, which matters more here than anywhere else: a media id came back exactly once, so an upload response lost to a retry was an asset that existed and could never be attached to anything.

Launch campaigns

POST/api/v1/businesses/{slug}/ads/campaigns?network=snapchat

Requires a read & write key. Upload the Snap first with POST …/ads/media?network=snapchat and pass the id back as media.snapchatMediaId.

The field is goal, not objective — and an unrecognised value is read as traffic rather than refused, which is deliberate (callers who predate the goal list never sent one) and is exactly why it is worth getting right: sending Meta's word here launches a traffic campaign and answers 201.

Snapchat's own objective enum has only five values because objective_v2 steers nothing — it is the ad squad's optimisation goal that decides what Snapchat actually buys — so awareness, engagement and video views all sit under one shared AWARENESS_AND_ENGAGEMENT objective with different optimisations. The three are kept apart anyway, because objective_v2 decides which optimisation goals Ads Manager offers the squad afterwards, so a campaign the owner asked for has to BE one when they open it.

The goal also picks the CREATIVE, which is what decides the shape of the rest of the body. The five web goals take destinationUrl; leads takes leadFormId instead; app_promotion takes an app object instead. Those two creatives have no URL field at all, so neither can carry our tracking tags and neither reports ROAS — Snapchat has no url_tags / final_url_suffix to append them to. Spend and Snapchat's own reported conversions still arrive normally, and the 1% platform fee is unaffected: it meters campaign rows and reads no utm tag.

A Public Profile is a precondition, and its refusal is a 403 FORBIDDEN rather than a 502. Every Snapchat creative requires profile_properties.profile_id, including a plain link ad, so a connection with no Public Profile cannot build one. That used to answer 502 UPSTREAM_ERROR, which is a lie about who refused: Snapchat was never asked. The message is unchanged and only the code moved, so anything branching on the 502 needs updating. Send profileId (or profile_id) in the body to supply one yourself — it is used only when the connection has none of its own, and the connection's profile always wins.

The two age bounds have different ceilings, and neither is clamped any more. ageMin is 13 to 35 and ageMax is 13 to 55, which is Snapchat's own asymmetry rather than ours. Both used to run through a min/max against one 13-55 pair, so an owner asking for 18 to 65 was quietly sold 18 to 55 with nothing said anywhere. All three refusals are 400 VALIDATION_ERROR now: a bound outside its range, and an ageMax below ageMin, which targets nobody. Omitting ageMax means NO upper limit rather than 55, and omitting ageMin takes Snapchat's 13.

Also in the dashboard. Ads tab → Snapchat → Launch. ageMin, ageMax, gender and endDate stopped being API-only this phase: the launcher collects all four, on the network best known for a young audience, where it previously could not narrow by age at all.

goal: what the campaign optimises for, and which creative it builds

trafficdefault

TRAFFIC objective, SWIPES optimisation. Takes destinationUrl.

conversionsweb

SALES objective, optimising against the Snap Pixel on the connection. Takes destinationUrl.

awarenessweb

AWARENESS_AND_ENGAGEMENT, IMPRESSIONS. There is no REACH member in Snapchat’s optimisation-goal enum at all, so impressions is the honest answer rather than a substitute for one. Takes destinationUrl.

engagementweb

AWARENESS_AND_ENGAGEMENT, SWIPES — the same optimisation as traffic under a different objective_v2, and that is stated rather than hidden. Snapchat’s other engagement goals are STORY_OPENS (a composite creative assembled from up to 20 child creatives plus a separate preview) and USES (Lenses and Filters, which cannot be created through the API at all), so swipes is the engagement action a link ad actually has. Takes destinationUrl.

video_viewsweb

AWARENESS_AND_ENGAGEMENT, VIDEO_VIEWS. Needs a VIDEO media because it bids on plays, and an image is refused before anything is created. Deliberately not VIDEO_VIEWS_15_SEC, which only counts a view at fifteen seconds while a top snap is commonly six. Takes destinationUrl.

leadslead form

LEADS objective. Takes leadFormId INSTEAD of destinationUrl — a form built in Snapchat Ads Manager. Nothing lists them, and the submissions stay in Snapchat’s Leads Center.

app_promotionapp install

APP_INSTALLS. Takes app: { name, iosAppId or androidAppUrl, iconMediaId } INSTEAD of destinationUrl. Both store ids are also written to the CAMPAIGN’s measurement_spec, which is what makes install attribution work.

(no product sales)not built

The one goal not offered, and it is unbuilt rather than blocked. The field list is NOT the problem — Snapchat’s dynamic creative takes exactly two ids, dynamic_template_id and a product_set_id that must match the ad squad’s. Both are unobtainable: Snapchat publishes no endpoint that lists a product set and none that lists or creates a dynamic template, so both would have to be copied out of Ads Manager. Catalogues themselves list fine (GET …/ads/catalogs?network=snapchat), which is exactly why building only that half would leave a campaign whose ad can never follow.

Body

campaignNamestring

Required. Campaign display name

goalstring

'traffic' (default) | 'conversions' | 'awareness' | 'engagement' | 'video_views' | 'leads' | 'app_promotion'. Anything unrecognised is read as traffic

dailyBudgetCentsnumber

Required, min 500 (= 5.00). That is the AD SQUAD floor, which is where a launch puts the money; holding it to the campaign’s higher floor would refuse budgets Snapchat accepts

countriesstring[]

Required. ISO-2 country codes

destinationUrlstring

Required on the five WEB goals, https only (Snapchat rejects plain http). Not taken by leads or app_promotion, whose creatives have no URL field

audienceIdsstring[]

Optional, TOP LEVEL. Segment ids from GET …/ads/lookups?network=snapchat&type=audience, landing in the ad squad’s targeting.segments[]. Never send an empty array; omit the field, because Snapchat reads an empty include list as a filter that matched nobody

excludedAudienceIdsstring[]

Optional. Snapchat has no excluded-audience FIELD: targeting.segments[] is a list of entries, each with an operation of INCLUDE or EXCLUDE, so both sides land in the same key as two entries. The same id on both sides is refused rather than sent — Snapchat evaluates both and reaches nobody in that segment

leadFormIdstring

Required by goal leads (leadGenerationFormId is accepted too). From Snapchat Ads Manager

appobject

Required by goal app_promotion: { name, iosAppId? | androidAppUrl?, iconMediaId }. androidAppUrl is the Play PACKAGE name despite the field name

media.snapchatMediaIdstring

Required. The uploaded Snap

media.snapchatIconMediaIdstring

The square app icon for goal app_promotion — an install creative is two uploads, not one, because Snapchat carries the icon next to the Snap

businessName / headlinestring

Required. The brand name shown on the ad next to the profile, and the headline

callToActionstring

The button, and it no longer changes under you. A value outside the goal’s shortlist used to become MORE with nothing said, so a typo, another network’s word and a real Snapchat verb picked for the wrong format all produced one ad reading "More". Now: the cross-network words the shared launcher sends (LEARN_MORE, GET_STARTED, SUBSCRIBE, CONTACT_US) are TRANSLATED to the value they already resolved to, so every launch built before this is byte-identical; a real Snapchat verb off our shortlist is SENT AS ASKED, because those menus are our product shortlists rather than a claim about the API (Snapchat documents 23 of 38 for a web view and never enumerates them); and a value Snapchat has never heard of is REFUSED by name, with the menu

bidStrategystring

'AUTO_BID' (default) | 'LOWEST_COST_WITH_MAX_BID' | 'TARGET_COST'. bidCents is only read alongside this, so a body that never mentions bidding builds the ad squad it always did

bidCentsnumber

Required by the two manual bid strategies (1 to 50000) and ignored without one — Snapchat refuses those strategies with no amount

ageMin / ageMaxnumber

Optional. ageMin is 13-35 and ageMax is 13-55 — DIFFERENT ceilings, which is Snapchat’s asymmetry, not ours. Out of range is a 400 rather than a clamp, and so is ageMax below ageMin. Omit ageMax for no upper limit; omit ageMin for Snapchat’s 13

gender / endDatemixed

Optional narrowing, same shape as the other networks

profileIdstring

Optional (profile_id accepted too). A Snapchat Public Profile id, from Snapchat Business Manager. Read ONLY when the connection carries no profile of its own — the connection’s profile always wins, because createSnapchatObject stamps it last. With neither, the launch is a 403 FORBIDDEN before Snapchat is asked, since every creative requires profile_properties.profile_id

frequencyCapobject

{ count, days } — how often one person may see the ad, capped at Snapchat’s own 30-day ceiling. Refused before the campaign is created on the goals Snapchat does not allow it on, rather than at ad squad create, which is inside the rollback window

Launch a Snapchat campaign
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/campaigns?network=snapchat" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "campaignName": "Spring launch",
  "goal": "conversions",
  "dailyBudgetCents": 5000,
  "countries": [
    "US",
    "CA"
  ],
  "destinationUrl": "https://acme.com/app",
  "businessName": "Acme",
  "headline": "Track every habit",
  "callToAction": "MORE",
  "bidStrategy": "AUTO_BID",
  "media": {
    "snapchatMediaId": "8a1b2c3d-4e5f-6789-abcd-ef0123456789"
  }
}'
201
{
  "campaignId": "0a1b2c3d-4e5f-6789-abcd-ef0123456789",
  "adSquadId": "1b2c3d4e-5f67-89ab-cdef-0123456789ab",
  "creativeId": "2c3d4e5f-6789-abcd-ef01-23456789abcd",
  "adId": "3d4e5f67-89ab-cdef-0123-456789abcdef",
  "network": "snapchat",
  "goal": "conversions",
  "status": "PAUSED",
  "note": "Created paused. PATCH the campaign ACTIVE to start spending."
}

Creative formats

POST/api/v1/businesses/{slug}/ads/creatives?network=snapchat

The four Snapchat formats this product could not build, taken as a format plus the inputs you have rather than as a field bag. Field writes could always POST these — every property object is a writable JSON field — and that is exactly why almost nobody could: using it meant already knowing that a Story ad is four objects and a collection is three, that a collection must send call_to_action null and shows its interaction zone's headline instead, and that fallback_type carries an underscore.

DEEP_LINK is the one an app advertiser actually wants: an app-install ad sends everybody to the store including the people who already have the app, while a deep link opens the app at the thing advertised and falls back to the store or a web page only for people who do not. REMINDER fixes the call to action at REMIND_ME. STORY builds the preview tile shown in the Stories feed (its own 3:5 image and a 55-character headline, not the creative's 34) and then the composite holding 1–20 slides in an order that is frozen after create — there is no reorder, insert or remove, so a repeated slide is refused rather than deduplicated, because deduplicating would silently change the running order and accepting would show the same slide twice, unfixably. COLLECTION creates every tile (a creative element each), then the interaction zone that orders them and carries the button, then the creative.

The collection path is not atomic and nothing here pretends otherwise. Snapchat has no transaction across these objects and documents no delete for elements or zones, so a part-way failure leaves what landed. GET the same path with ?resource=interaction_zones to see what that is: the zones already on the account, each with the ids of its tiles. Reusing one is not done on this endpoint. The collection branch here takes no interactionZoneId — it always builds a fresh zone from the tiles you send — so posting again to "retry" makes a second set of undeletable tiles beside the first. A zone is a reusable object, and the way to point a new creative at one is a field write: POST …/ads/objects at the Snapchat creative level, whose collection_properties carries interaction_zone_id. That is what makes a second collection ad cheap.

Every response carries ad_type beside creative_id, because this network's sharpest edge is that it spells every format twice, in two different enums: a WEB_VIEW creative goes on a REMOTE_WEBPAGE ad and a COMPOSITE goes on a STORY ad, and Snapchat refuses a mismatched pair at ad create, after the creative already exists.

Apple is the second network on this path — see Custom Product Page ads. Meta, Google and TikTok reach their creative shapes through the launch endpoint and field writes, and answer 501 here. Lenses and Filters stay unreachable on every surface: a lens is built in Lens Studio and published through a surface with no Marketing API create.

Also in the dashboard. Ads tab → Snapchat, below the campaign table, in the builder Apple's Custom Product Page ads share. It asks for a format first and then only the inputs that format needs, which is the same reason this route exists rather than field writes. It never opens by itself and it warns first, because Snapchat documents no delete for creative elements, interaction zones or creatives. After a part-way failure it lists the zones already on the account, so you can see what was left behind — deliberately as a list and not as a picker, because this POST cannot reuse one. Reuse is the field write above; creating the collection again makes the tiles a second time.

Body

formatstring

'deep_link' | 'reminder' | 'story' | 'collection'

name / headlinestring

Required. The creative headline caps at 34 characters; a Story PREVIEW tile has its own 55

topSnapMediaIdstring

The media this creative shows, from Upload creative or from the media library read

deepLinkUri / iosAppId / androidAppUrlstring

deep_link: where it opens and which app it is

fallbackType / webViewFallbackUrlstring

deep_link: where people who do not have the app land

slidesobject[]

story: 1–20 child creatives in an order FROZEN after create. A repeated slide is refused

previewMediaId / previewHeadlinestring

story: the tile shown in the Stories feed, its own image and its own 55-character headline

tilesobject[]

collection: each becomes a creative element, then the interaction zone that orders them. Required — this endpoint always builds a NEW zone, and there is no interactionZoneId input to reuse an existing one (point a creative at one with a field write on collection_properties instead)

zoneHeadlinestring

collection: the button label on the interaction zone. A collection sends call_to_action null and shows this instead

Query parameters

networkstring

'snapchat' or 'apple_search_ads'. The other three answer 501

resourcestring

GET: 'interaction_zones' lists the zones already on the account, each with its element ids and headline. There is deliberately no listing for creative ELEMENTS — Snapchat documents their create and no read of any kind, and the zone carries their ids anyway

A deep-link ad
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/creatives?network=snapchat" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "format": "deep_link",
  "name": "Open the workout screen",
  "headline": "Jump straight in",
  "topSnapMediaId": "8a1b2c3d-4e5f-6789-abcd-ef0123456789",
  "deepLinkUri": "myapp://workouts/42",
  "iosAppId": "1234567890",
  "fallbackType": "WEB_VIEW",
  "webViewFallbackUrl": "https://example.com/workouts"
}'
201
{
  "network": "snapchat",
  "creative_id": "2c3d4e5f-6789-abcd-ef01-23456789abcd",
  "ad_type": "APP_INSTALL",
  "note": "Pair this creative id with that ad_type. Snapchat refuses a mismatched pair at AD create, after the creative already exists."
}

Two steps in the ads lifecycle cannot be an API call no matter how the API is designed: approving access on the ad network (that consent screen belongs to Meta, Google, TikTok, Apple and Snapchat) and entering a card. These endpoints mint a short-lived page that handles each one, then sends the visitor back to your return_url with an ?affiliateo_status of complete, pending or expired. Same pattern as the affiliate verification and cash-out links, and as Stripe Connect Account Links.

A link carries intent, not a login. Whoever opens it still signs in as the account holder, so a link that leaks is not a way in.

There is a third option these docs do not otherwise mention: embedded elements. Instead of redirecting to a hosted page, you mount the same flows as components inside your own app, styled to your brand. Referral link, QR, per-product rates, recent sales, this app's pending/payable/paid, cash out, withdrawal history, identity.

It is not either/or with this API. Build the screens you want control over from the endpoints here, 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. The numbers match either way: the balance element renders the same pending/payable/paid that GET /affiliates?email= returns, so a hand-built screen and a mounted one cannot disagree.

Which to use, per screen. Every element is its own page load, an iframe on the web and a WebView in an app, so a tab stacking four pays for 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 the referral link, QR, stats, per-product rates, recent sales and this app's pending/payable/paid from GET /affiliates?email=, GET /apps/{appId} and GET /affiliates/conversions, and mounting elements only for cash out, withdrawal history and identity: the three that collect bank details, open the account-wide wallet, or run ID capture.

Lists make the point best. The embedded activity and payouts elements show the 25 most recent and neither paginate nor filter, so an affiliate with a year of sales cannot reach the 26th and cannot ask to see just their refunds, while GET /affiliates/conversions is cursor-paginated and filters by date (?from=&to=) and by type (?type=refund,chargeback) in the query, upstream of the cursor, so a filtered feed pages through matching rows only. That is also why the filter belongs on our side rather than in your own code: filtering the page you already hold can only search what is on screen, and finds nothing for someone whose single refund sits 300 rows down. The trade going the other way is copy: this API returns data, never labels, and lang is a mint parameter for elements only. But an API-built screen does not mean writing your own translations: the exact strings our elements use are published at /locales/{lang}.json in all 16 languages, so you can lift them. Copy the values 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, in particular Paid, which means already in their Affiliateo balance and not yet in their bank.

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 appears only inside the gated withdraw and payouts components, and on their own /me/wallet with their own key. Your screens show what you owe them, which is a different number on purpose.

POST/api/v1/businesses/{slug}/ads/connect-link?network=meta

This is step zero. Every other ads endpoint answers NOT_CONNECTED until a network is connected, and connecting means an OAuth consent in a browser. Mint a link, hand it to the business owner, and they land back in your app when it is done.

already_connected is reported rather than refused: reconnecting is legitimate when a grant was revoked or an ad account changed. Unpaid platform fees do not block this endpoint either, since refusing to let someone connect over a billing state would be a catch-22.

Query

networkstring

meta (default), google, tiktok, apple_search_ads, snapchat

Body

return_urlstring

Required. https:// or a mobile deep link. Where we send them afterwards

langstring

Optional. Locale for the hosted page

Mint a connect link
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/connect-link?network=meta" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "return_url": "https://yourapp.com/settings/ads"
}'
200
{
  "url": "https://affiliateo.com/verify/connect/eyJhbGciOi...",
  "expires_in": 3600,
  "network": "meta",
  "network_label": "Meta Ads",
  "already_connected": false
}
POST/api/v1/me/topup-link

The answer to a 402 PAYMENT_REQUIRED. Unpaid 1% ad platform fees past the grace allowance pause every /ads/* endpoint, and topping up the Affiliateo balance is what clears it. Paused campaigns resume on the next sync once the balance covers what is owed.

amount_cents is only shown on the hosted page as the amount owed; the account holder still enters and confirms the payment themselves. The link is for the caller's own account, so it is minted with their own key, exactly like /me/withdrawal-link.

Body

return_urlstring

Required. https:// or a mobile deep link

amount_centsnumber

Optional, shown as the amount owed, 100 to 1000000

langstring

Optional. Locale for the hosted page

Mint a top-up link
curl -X POST "https://affiliateo.com/api/v1/me/topup-link" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "return_url": "https://yourapp.com/settings/billing",
  "amount_cents": 5000
}'
200
{
  "url": "https://affiliateo.com/verify/topup/eyJhbGciOi...",
  "expires_in": 3600,
  "balance_cents": 1240
}

Discovery and reporting

Two endpoints that answer for all five networks in one shape, so they sit here rather than inside any one network's section. The first says what an id-shaped field can be filled with; the second says where the money actually went. Both are catalogue-driven: leave the selector off and you get the catalogue of what exists, per network, and whether it can be answered today.

Neither one ever answers "none" with an empty list. An empty array cannot be told apart from "the network genuinely has none", and the two need completely different reactions — so a concept the network does not have is a 400 naming the ones it does, a concept it has that we have not built (or that the network refuses us access to) is a 501 saying which, a network that is not connected is a 409, and a network that was asked and refused is a 502 carrying its own words. An empty result therefore means exactly one thing.

Id lookups

GET/api/v1/businesses/{slug}/ads/lookups

The field catalogue tells you a campaign takes location_ids or an ad takes video_id. This tells you which ones exist. Every lookup on every network answers the same three keys: id is always the value that goes on the wire, label is always what a human picks from, and meta carries whatever else the network said and is never needed to interpret id. One reader serves all five, which is the whole point — the older targeting search answers in a different shape per network and covers eight lookups on four of them; this is a strict superset.

The same concept is a different id space on every network, so always pass the network you are going to write to. A Meta interest id in a TikTok body is well-formed, wrong, and nothing upstream will say so — which is why type without network is refused rather than defaulted. Two shapes worth knowing before you paste: on Google, id is the full resource name (customers/123/assets/456) for assets, audiences and conversion actions, because Google refuses the bare id; and on Google and Apple a keyword has no id at all, so id is the keyword text.

Drop type to get the catalogue: every lookup each network has, its help, its parameters, and a status of available, not_built or blocked. blocked is the one worth reading — it means building it would not help, and the message names the permission or scope the network has not granted us (Meta's catalogues, product sets, Instagram identity and lead forms; Snapchat's Public Profile and lead forms). A handful of entries are not_built because the network publishes no endpoint at all that enumerates them — placements on Meta, TikTok and Snapchat, TikTok's cards, images and inventory categories — and in every one of those cases the field help says where the value does come from instead, rather than implying a picker. TikTok's music is not one of them: it lists, and a carousel needs a track, so reach for it rather than hunting an id in Ads Manager.

Two entries stopped being refusals this phase, and one of them was a false denial. network=snapchat&type=custom_conversion is BUILT: it takes a required eventSourceId and an optional eventSourceType (PIXEL by default, or APP), because Snapchat lists custom conversions per event source rather than per account. There is no account-wide list, so a call with no parent is a 400 rather than an empty page. It used to answer 501 saying Snapchat published nothing, which was wrong: the page was fetched on 2026-08-06 and it publishes six paths. It is not verified by a live call — the connected Snapchat account has no pixel to ask with — so the reader refuses rather than reporting an empty list when a row will not parse, which makes the first real call a bug report instead of a false "you have none". And network=google&type=shared_set is new: an account-scoped, queryless list of shared negative lists, whose meta.type separates NEGATIVE_KEYWORDS from NEGATIVE_PLACEMENTS. Read that field. One account holds both kinds in the same id space, and attaching a placement list where a keyword list was meant excludes websites instead of queries, silently, and keeps spending. Whether Google lets that type be selected is unverified and it is selected anyway: if Google refuses the field this lookup 400s with Google's own message, which is better than labelling a list by guessing.

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" without a per-network branch. A field with no lookup and an id-shaped help is one the network gives us no way to list.

Query parameters

networkstring

Required with type. meta | google | tiktok | apple_search_ads | snapchat. Omit BOTH for the whole catalogue

typestring

What to look up, or one of that network's aliases (region on TikTok, location on Meta). Omit for the catalogue

qstring

Search text, min 2 characters — except on a lookup the catalogue marks queryless, which is an account inventory with nothing to search or one that takes a parent id instead

limitnumber

1–200, default 50

cursorstring

The nextCursor from the previous page. Opaque, and a cursor this endpoint did not issue is refused rather than read as offset 0

accountstring

Which ad account / org / advertiser / customer to search. A lookup runs against a specific account’s access

(per-lookup params)string

Some lookups declare their own and refuse without them, because no global list exists: TikTok’s ISPs need locationIds, its app events need appId + optimizationGoal, Snapchat’s regions and metros need country, its interests take taxonomy, and Apple’s product pages need adamId. The catalogue lists each one

Search TikTok locations
curl "https://affiliateo.com/api/v1/businesses/acme/ads/lookups?network=tiktok&type=geo&q=united" \
  --header 'Authorization: Bearer afk_...'
200
{
  "network": "tiktok",
  "type": "geo",
  "query": "united",
  "results": [
    { "id": "6252001", "label": "United States", "meta": { "level": "COUNTRY" } }
  ],
  "page": { "limit": 50, "returned": 1, "nextCursor": "o50", "total": 137 }
}
What can each network look up?
curl "https://affiliateo.com/api/v1/businesses/acme/ads/lookups" \
  --header 'Authorization: Bearer afk_...'
Apple product pages for one app
curl "https://affiliateo.com/api/v1/businesses/acme/ads/lookups?network=apple_search_ads&type=product_page&adamId=987654321" \
  --header 'Authorization: Bearer afk_...'

Managed resources, in one shape

GET/api/v1/businesses/{slug}/ads/resources

Seventeen list-shaped objects the ad networks keep beside a campaign — audiences, custom conversions, blocked words, identities, keywords, negative keywords, shared negative-keyword lists and their campaign attachments, labels, campaign groups, budget schedules, automated rules, recommendations, change history, ad strength, registered apps and balances — behind one contract instead of seventeen dialects. Every one of them answers the same four verbs: GET lists, POST creates, PATCH ?id= edits, DELETE ?id= or ?ids=a,b removes. A POST carrying action runs a row verb that is not CRUD instead — attach, detach, rename, assign, apply, dismiss, bind.

Drop resource to get the catalogue. It asks no network anything, needs no connection, and answers the same for every business: every resource on every network, its columns with the network's own wire spelling, its form fields, its parent, its row actions, and a status per operation of available, not_built or blocked. blocked is the one worth reading — it means building it would not help, and it carries the network's own reason plus sourcedFrom. A null sourcedFrom means nobody has ever called the network to confirm the claim, which is honestly what most of them are today: they come from SDK and reference reads. If you can call it and it works, the catalogue entry is what has to change.

Four different "no"s, and none of them is an empty list. 400 means the request itself is malformed: an unknown resource, a missing or unknown network, a required parent left out, a limit or cursor we did not issue. 501 covers both kinds of "not here" — declared and blocked, or declared and not built yet — and says which, in words. A blocked one carries the network's own reason, who can change it (the network, a scope nobody has granted us, or a decision of ours), whether anyone has ever called the network to confirm it, and the networks whose same verb is open, so the next call is right instead of another guess. 409 means the network is not connected. 502 means the network was asked and refused, in its own words. So rows: [] means exactly one thing: the network was asked and it has none.

All seventeen answer today, and several answer partly. resource=audience carries all four verbs on Meta, Google, TikTok and Snapchat. resource=custom_conversion carries all four on Meta, list only on TikTok, and list, create and delete on Snapchat — that last row read "Snapchat publishes no endpoint that lists an account's custom conversions" until the page was actually fetched on 2026-08-06 and turned out to publish six. Snapchat lists them per event source rather than per account, so eventSourceId is required and eventSourceType picks whether a pixel or a mobile app is asked; there is no update, and that one is a real blocker, because none of the six paths is a PUT or a PATCH. resource=identity carries list, create and delete on TikTok; resource=registered_app list and edit; resource=ad_balance list; resource=blocked_word list, create and delete plus the rename row action — not a PATCH, because TikTok's /blockedword/update/ replaces one word with another rather than editing fields, and doing it as a delete plus an add leaves a window in which neither word is blocked. That PATCH is a declared blocker rather than a gap, so it answers 501 naming the rename call to make instead — it is not coming later.

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. resource=rule carries all four verbs on Meta and list, create, update plus the bind row action on TikTok. On Meta the update is a real partial, so a name-only or status-only edit is safe, and DISABLED is the reversible off switch that the delete is not — but an empty list on Meta is not proof that nothing is running: on 2026-08-05 the adrules_library edge answered {"data":[]} for an account whose adrules_history, in the same run on the same token, listed hourly evaluations of an ENABLED rule on that same account. That one was Meta-authored, so the library edge appears to carry rules an advertiser wrote rather than ones Meta's recommendation system installed — but only that it was absent has been established, never why. On TikTok there is no delete and no on/off field, which is now a call rather than a reading: /optimizer/rule/delete/, /remove/, /unbind/, /batch_unbind/ and /status/update/ each answered 404 with no envelope, while POST-only endpoints that certainly exist answered 405 and the list answered 200, all on one token in one run. To stop a TikTok rule, narrow its applyObjects — the update is a full replace, so a body missing any required part is refused naming the missing parts rather than quietly wiping the rule — or remove it in TikTok Ads Manager. Money thresholds are in major units unless you send the valuesCents / valueCents / limitCents spelling, and an applyObjects entry with no dimensionIds covers every object at that level in the account, which is why it needs confirmAccountWide.

Keywords are two objects, and one of them is a first read. resource=keyword on Apple Search Ads carries all four verbs against a required campaignId, and its list is new: …/ads/keywords exports POST, PATCH and DELETE and no GET, so that door could add a keyword, re-bid it, pause it and remove it and could not show you a single one. The find behind this one has been running on every dashboard render the whole time. Four things before you write one. A new keyword is PAUSED unless you send status: "ACTIVE" — Apple creates one ACTIVE when the body says nothing, which is the one create in this product that used to start spending the moment it landed. Omitting bidCents inherits the ad group's default bid, which is Apple's own fallback, and 0 is refused as a bid of nothing rather than taken to mean inherit. The read is campaign-wide and every write is per ad group: the find is /campaigns/{id}/adgroups/targetingkeywords/find and reports each row's adGroupId, while create, update and delete are per-ad-group bulk paths — so adGroupId is required at create and read back off the row afterwards, and an id that is not really in this campaign is refused as a 400.Past pages × 1,000 rows the list is a prefix (two pages by default, up to ten): total then carries Apple's own count while the rows stop short of it, and pages — how deep to read Apple — is deliberately a different number from limit, which pages what came back. text, matchType and the ad group are frozen at create, so correcting any of them is delete-and-recreate with a new id; status and the bid are all an edit can carry. On Google, resource=keyword answers list and edit: the read is the GAQL query over ad_group_criterion that the dashboard already runs on every load, so this surface maps those same rows rather than asking a second query that could disagree about which keywords are live, and an edit carries status and the bid and nothing else, because Google answers [IMMUTABLE_FIELD] on the text and the match type. Its create and delete are declared and not built rather than blocked, and the difference is deliberate: Google plainly does both — a create is POST …/ads/objects at level ad_group_criterion, which works today — so a blocker there would be a false denial. A Google keyword has no id of its own at create: the text plus the match type is the criterion, and the id handed back is the composite adGroupId~criterionId. Note the level differs from the negatives below: a Google keyword hangs off an ad group, a Google negative keyword off a campaign.

Negative keywords are one resource in two shapes, which is the point of one contract rather than seventeen dialects. resource=negative_keyword needs a campaignId on both, because neither network keeps an account-wide list of them. On Apple Search Ads all four verbs answer — but an edit may carry status only, because Apple freezes the keyword text and the match type at create, so correcting either one is delete-and-recreate. adGroupId at create picks campaign-wide versus one ad group, and it is then read back on edit and delete rather than asked for again: Apple keeps the two scopes behind different paths and the id alone does not say which, so the row is looked up before it is acted on. 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 — so changing a keyword is remove-and-add. The two match-type vocabularies are not narrowed into each other: Google takes EXACT, PHRASE and BROAD, Apple only EXACT and 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 belonging to 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.

One brand-safety list, attached to many campaigns. Everything under resource=negative_keyword is a term written onto one campaign, so a list of words you never want to appear against had to be retyped campaign by campaign and adding the next term meant remembering every campaign it belongs on. The copies drift apart quietly, in the direction of showing ads where you said not to. On Google, resource=shared_set is that list itself, and it carries list and create plus three row actions that reach the terms in a list: read them, add some, remove one. Its DELETE is a policy blocker rather than a gap — removing a list stops every campaign attached to it excluding every term it held, all at once, with nothing on screen changing to say so — and removing one term is the safe verb that does exist. Editing a list is declared and not built: nobody has read whether Google publishes a rename, and guessing either way here would be a claim we cannot support. resource=campaign_shared_set is the attachment, and it is its own key rather than a field on either side, because that is how Google models it and because it is what makes "stop using this list here" possible: it carries list, create and delete, which are attach and detach under their real names. Detaching is not deleting — the list keeps its terms and every other campaign keeps its exclusions — which is exactly why this delete is offered while the one above is not. Its id is the composite campaignId~sharedSetId, read off what Google returned rather than composed, and its campaignId parent is optional, unlike every other Google parent here: Google publishes both reads, so leaving it out answers "which campaigns are on which lists" rather than refusing.

A Meta budget schedule is the one one-way door on this endpoint. resource=budget_schedule takes a parentId that 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 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 no connected account has a real schedule to address.

On Google, five more. resource=label and resource=campaign_group carry all four verbs, plus attach and detach on a label and assign on a group — the last of which writes the campaign rather than the group, because a campaign's group membership is a field on the campaign and Google publishes no link resource for it. Read the label delete twice before pressing it: 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, which is why that operation ships a confirm sentence the panel puts in front of the button. resource=recommendation, resource=change_history and resource=ad_strength are list only, with apply and dismiss as row actions on a recommendation. Those three refuse writes because Google does, not because nobody finished them: recommendations:mutate and changeEvents:mutate are not served at all — each answers 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 of its own.

Google ids come in two shapes on this surface, and that is Google's doing. A label or campaign-group row carries the plain number, because that is what this endpoint's own DELETE ?id=, PATCH ?id= and row actions take; Google's full customers/{cid}/labels/{id} is composed for you from the account you are already scoped to, and rides along in its own column. A recommendation row carries the whole resource name instead, because recommendations:apply and :dismiss take nothing else. In both cases the id is exactly what goes back on the wire — which is the rule, even though the wire differs per resource. An ad-level label attach wants Google's composite adGroupId~adId, not the ad id alone, and is refused here rather than three hops later.

A verb missing from that list is missing for a reason worth reading. TikTok's custom-conversion create, edit and delete all exist — each named its own required field when called, rather than answering 404 — and no request body for them has ever been observed, because both advertisers on the account this was verified against have no pixel and no app 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. They are therefore left unbuilt rather than blocked: a blocker would claim TikTok cannot do something TikTok plainly can. That trio, Google's keyword create and delete, and Google's shared_set update are the declared-and-unblocked operations left anywhere on this endpoint with nothing behind them — three subjects and six triples in all, and every other 501 you can reach here carries a network's own reason. Each is the same shape of gap: Google plainly creates and removes keywords, and nobody has read whether SharedSetService publishes a rename, so a blocker in any of them would be a denial we cannot support. coverage on the catalogue response is the honest count, derived from the resolver table rather than typed.

Every resource has an older per-resource route, named in alsoAt — but that is a signpost, not a promise of parity. Where both doors serve the same object, there is one implementation behind them: audiences here go through the same four writers and the same shared create vocabulary as POST …/ads/audiences, and the older routes keep their exact shapes and their error codes. This paragraph used to add "nothing is implemented behind this dispatcher that is not already implemented behind those", and five capabilities live here and nowhere else: the Google label edit (…/ads/labels takes create, attach and detach, and no rename), the TikTok identity delete (…/ads/identities is GET and POST only), the TikTok registered_app edit (…/ads/apps is GET only), the TikTok custom_conversion list (…/ads/custom-conversions?network=tiktok serves pixel event rules, which is a different object), and the account-scoped audience CRUD the dashboard panel drives. Read alsoAt as "the related older door" and check its own verbs before assuming it can stand in for this one.

Write bodies are checked against the catalogue before they reach a network. An unknown key is refused by name, an enum outside its declared values is refused with the values listed, and mutability is enforced per verb — a create_only field on a PATCH comes back as "fixed once it exists" rather than as the network's own code three hops later. On audiences the editable set is deliberately name, description and TikTok's lookalike refresh and nothing else: every other field decides who is in the audience, and rewriting one silently rebuilds the membership of a list that live ad sets are targeting right now.

Query parameters

resourcestring

audience | custom_conversion | blocked_word | identity | keyword | negative_keyword | shared_set | campaign_shared_set | label | campaign_group | budget_schedule | rule | recommendation | change_history | ad_strength | registered_app | ad_balance. All seventeen, because this is the list a generated client turns into an enum: one missing value is a resource that client can never address. Omit for the catalogue

networkstring

Required with resource. meta | google | tiktok | apple_search_ads | snapchat. No default on purpose — five of the older routes each pick their own, and one defaults against its own family

accountstring

Which ad account. Refused rather than ignored when it is not one this business has enabled, on every verb including the writes

(parent)string

Some resources hang off another object and say so in the catalogue: eventSourceId for TikTok custom conversions (a pixel or an app — TikTok scopes them by event SOURCE, not by pixel alone), campaignId for negative keywords and ad strength, parentId for a Meta budget schedule. A missing required parent is refused BEFORE the connection loads, so you get a 400 about the id rather than a 409 about reconnecting

qstring

Filters the list where the network publishes no search of its own. An empty q is no filter, never "matched nothing"

limitnumber

1–200, default 50

cursorstring

The nextCursor from the previous page. The same opaque grammar the id lookups use, and a cursor this endpoint did not issue is refused rather than read as offset 0

id / idsstring

DELETE only. ids is comma-separated; one id and ten take the same path, because Google negatives and TikTok blocked words are bulk on the wire

What can each network manage?
curl "https://affiliateo.com/api/v1/businesses/acme/ads/resources" \
  --header 'Authorization: Bearer afk_...'
One list
curl "https://affiliateo.com/api/v1/businesses/acme/ads/resources?resource=audience&network=google" \
  --header 'Authorization: Bearer afk_...'
Create one
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/resources?resource=audience&network=google" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "kind": "website",
  "name": "Checkout visitors 30d",
  "urlContains": "/checkout",
  "retentionDays": 30
}'
A row action
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/resources?resource=blocked_word&network=tiktok" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "action": "rename",
  "id": "refund",
  "newWord": "chargeback"
}'
200 (catalogue)
{
  "resources": [
    { "network": "google", "resource": "audience", "label": "Remarketing and Customer Match lists",
      "networkType": "user_list", "status": "available", "scope": "ad_account",
      "columns": [{ "key": "name", "label": "Name", "kind": "text", "wire": "user_list.name", "primary": true }],
      "operations": { "list": { "status": "available" }, "delete": { "status": "available" } },
      "actions": [],
      "alsoAt": "GET …/ads/audiences?network=google" },
    { "network": "google", "resource": "label", "label": "Labels",
      "networkType": "label", "status": "available",
      "operations": { "list": { "status": "available" }, "update": { "status": "available" } },
      "actions": [{ "name": "attach", "label": "Attach", "permanent": false, "status": "available" }],
      "alsoAt": "GET …/ads/labels?network=google" },
    { "network": "tiktok", "resource": "custom_conversion", "label": "Custom conversions",
      "networkType": "custom_conversion", "status": "available",
      "operations": { "list": { "status": "available" }, "create": { "status": "not_built" } },
      "actions": [],
      "alsoAt": "GET …/ads/custom-conversions?network=tiktok" },
    { "network": "meta", "resource": "blocked_word", "label": "Blocked words",
      "networkType": null, "status": "blocked",
      "operations": { "list": { "status": "blocked", "blocked": {
        "by": "permission",
        "reason": "Blocked words are TikTok-only. Meta’s equivalent is a Page moderation setting behind pages_manage_engagement…",
        "sourcedFrom": null } } },
      "alsoAt": "GET …/ads/blocked-words?network=tiktok" }
  ],
  "coverage": { "declared": 340, "built": 71, "blocked": 263 }
}

Audiences, across the networks

POST/api/v1/businesses/{slug}/ads/audiences

The same path serves four networks, and this block is the cross-network view of it — each network's own create body is in its own section (Meta, TikTok). 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 point a campaign at. That is a statement, not a gap waiting to be filled.

?catalog=1 (with no ?network=) answers the whole vocabulary without asking any network anything, so it needs no connection: every audience kind on every network, that network's own wire name for it, whether a writer exists today, and where one does not, which kind of blocker it is — the network has no such object, a permission was not granted to us, we decided against it, or nobody has confirmed the wire shape. Those send you to four different people, so they are four different answers rather than one "unsupported". The capabilities array beside it is derived from which writers exist, which is why it is the thing to trust over any prose on this page.

Google and Snapchat take the shared vocabulary { kind, name, … } — while Meta and TikTok keep their own older bodies behind the same path. Two required fields have no equivalent anywhere else and are worth knowing before the first 400: Google's urlContains is required, because Google matches on variables the advertiser's own tag fires and publishes no rule meaning "everyone"; and Snapchat needs at least one event on a pixel or app segment, because it has no "everyone the pixel saw" form and no URL condition at all.

Unusable audiences stay in the list. Every network keeps audiences it will not target — too small, still processing, expired, closed — and each row carries notTargetableReason saying which. Hiding them would read as "somebody deleted my audience". Google's id is the full resource name (customers/123/userLists/456), because Google refuses the bare id in every slot that takes one; the PATCH and DELETE paths take the numeric half, and accept the full form too.

An audience nothing can target is worth nothing, so a launch carries them: send the ids to 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 through the field surface is not the same thing — a campaign that spends its first day untargeted has already spent it.

Never send an empty array; omit the key. 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, for the mirror reason: all four evaluate both and reach nobody in that audience, silently, with a plausible spec and no error at any layer. Three products refuse audiences by name rather than dropping them — Meta product: "advantage_plus" hands the audience to Meta, Google ?type=app has no criteria of any kind, and Google ?type=pmax takes asset-group signals under audienceResourceNames instead, a second Google id space (customers/123/audiences/456) that seeds Google rather than restricting it and has no exclusion.

Customer-list uploads are refused on all four, and that is a product decision rather than 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 build a purchaser audience and a lookalike seeded from it.

Sharing is TikTok's alone, at …/ads/audiences/{audienceId}/shareGET reads who currently has it, POST { advertiserIds } lends it, and DELETE with &advertiserId= withdraws one loan, one account per call because the networks cancel one share at a time. It exists because an advertiser running several ad accounts otherwise rebuilds the same purchaser audience once per account, and each copy only starts accumulating members from its own creation date. It is not a customer-list operation: nothing about any person moves anywhere, an audience that already exists inside the network becomes targetable by a second account of the same advertiser, the owner keeps ownership, and the loan is withdrawable. Every other network answers 501 with its own reason, and capability.share on ?catalog=1 is the machine-readable form of the same answer.

PATCH …/audiences/{audienceId} renames, and name and description are all there is 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. TikTok alone adds { refresh: true }, which re-runs a lookalike over its current seed; the other three refuse it by name, because none publishes a way to force one.

Body (POST — the shared vocabulary, Google and Snapchat)

kindstring

Required. google: 'website' | 'lookalike'. snapchat: 'website' | 'app_activity' | 'engagement' | 'lookalike', all one object type (a SEGMENT) distinguished by its source. ?catalog=1 is the machine-readable list

namestring

Required everywhere

urlContainsstring

website. REQUIRED on Google and unavailable on Snapchat, which has no URL condition at all

eventstring

One event the source saw ('PURCHASE'). REQUIRED on Snapchat's website and app_activity; refused on Google, whose rule has no event field. networkOptions.events takes several

sourceIdsstring[]

Which pixels / apps / ad groups. Empty means the connection’s own. Refused on Google, whose rule has no per-source selector

retentionDaysnumber

How far back the source is searched. Each network’s own ceiling applies and its own refusal is the authority — no number is invented here

seedAudienceIdstring

lookalike: required. Never itself a lookalike

countriesstring[]

lookalike. Snapchat takes exactly ONE ISO-2 code; Google takes one or more

similaritynumber

lookalike, 0 (closest, smallest) to 1 (widest). OUR scale — each network maps it onto its own, and omitting it leaves that network’s balanced default

networkOptionsobject

Per-network extras the shared contract deliberately does not promote to fields (Snapchat’s engagementType / engagementSpecs / events, Google’s operator and prepopulate). Validated by that network’s resolver, not by the contract

Query parameters

catalogstring

Any value: answer the cross-network vocabulary and capabilities instead of calling a network. Needs no connection, and is the same answer for every business

networkstring

meta (default) | tiktok | google | snapchat. apple_search_ads answers 501 with the reason

accountstring

Which ad account / advertiser / customer. An audience belongs to ONE of them on every network, so a lookalike seeded from another account’s audience is an upstream error rather than a smaller right answer

typestring

TikTok only: 'custom' (default) or 'saved'. A saved audience is a different OBJECT — a reusable targeting set — and answers under its own key

idsstring

TikTok only: comma-separated ids to narrow to, max 100 (TikTok’s own cap)

What every network can do
curl "https://affiliateo.com/api/v1/businesses/acme/ads/audiences?catalog=1" \
  --header 'Authorization: Bearer afk_...'
Google remarketing list
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/audiences?network=google" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "kind": "website",
  "name": "Checkout visitors 30d",
  "urlContains": "/checkout",
  "retentionDays": 30
}'
Snapchat pixel segment
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/audiences?network=snapchat" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "kind": "website",
  "name": "Purchasers 30d",
  "event": "PURCHASE",
  "retentionDays": 30
}'
Target it at launch
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/campaigns?network=snapchat" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "campaignName": "Retarget",
  "goal": "conversions",
  "audienceIds": [
    "0b7c…"
  ],
  "destinationUrl": "https://example.com"
}'
200 (?catalog=1)
{
  "kinds": [
    { "network": "google", "kind": "website", "networkType": "rule_based_user_list",
      "label": "Remarketing list", "creatable": true, "help": "…" },
    { "network": "google", "kind": "lookalike", "networkType": "lookalike_user_list",
      "label": "Lookalike segment", "creatable": true, "help": "…" },
    { "network": "google", "kind": "customer_list", "creatable": false,
      "blockedBy": "policy", "blockedReason": "…" }
  ],
  "capabilities": [
    { "network": "meta", "list": true, "create": ["website","engagement","app_activity","lookalike"],
      "update": true, "delete": true, "share": false,
      "attachAtLaunch": true, "attachNotBuilt": null }
  ]
}

Upload creative

POST/api/v1/businesses/{slug}/ads/media

Upload the ad image or video and get back the handle the launch endpoint needs. Two ways in: multipart (a file part plus kind), or JSON { kind, url } and we fetch it server-side — that second form is what agents and MCP use, since a JSON body cannot carry a file part. The upload serves four networks — Meta, Google, TikTok and Snapchat — and each hands back its own handle: Meta an image_hash or video_id, TikTok an image_id or video_id, Snapchat a media_id, Google a full asset resource name. ?network=apple_search_ads is a 400 naming the four, and that is not a gap: an Apple ad is an App Store product page or a Custom Product Page, both built on Apple's side, so there are no creative bytes to post at an Apple ad account at all. The GET below is a different question and takes all five, refusing with a 501 and the reason where a library read is impossible rather than with a 400 — the two halves of this path carry two different network lists.

Google video is a YouTube link, not a file. Send { "youtube_url": "https://youtu.be/…" } (or youtube_video_id, or a video_url pointing at YouTube) and you get back { asset, asset_id, youtube_video_id }. That is not a shortcut invented here: Google Ads publishes no video upload that takes bytes, and a video asset is what Demand Gen video ads and Performance Max asset groups both need.

Caps are 10MB for images and 50MB for video, and both are ours rather than any network's — the file is buffered once in memory here. Snapchat's used to be the tighter of the two and no longer is: a video over 32MB is now split across a chunked upload automatically, so an ordinary 1080×1920 vertical video — the shape Snapchat itself asks for — uploads instead of being refused with advice to compress a file that was never the problem. Videos keep processing on the network's side after upload; the launch endpoint waits for readiness itself.

GET reads the library back, which is the other half of a surface that used to be write-only. An upload hands back its id exactly once, and an image id is a hash that cannot be guessed or reconstructed — so an agent that lost the response had mislaid an asset that still existed in the ad account. Meta lists images and videos; Snapchat lists both from one library (a Snap creative points at the same field whether the bytes are a still or a video, and status is load-bearing: media that never reached READY produces an ad that silently does not serve); TikTok lists videos only and answers 501 with the reason for images, because it publishes an ids-in, detail-out endpoint and no search that produces them — so keep your TikTok image ids. Google is declared and unbuilt.

Add ?video= and the answer is a different one: the candidate cover frames Meta cut out of that one video, each { id, url, isNetworkPick, width, height }. Send the id back as ad.media.thumbnailId on a launch to choose which frame stands in for the video, on the one creative decision that most decides whether a muted feed stops. Omit it and Meta's own pick is used, as it always was — that is what isNetworkPick marks. Meta only, and an empty list means "not yet" rather than "none": the frames do not exist until Meta has finished processing the upload.

Form fields (multipart) or JSON body

filefile

Multipart: the image or video

kindstring

'image' or 'video'

url / image_url / video_urlstring

JSON: a public https URL we fetch server-side, SSRF-guarded and magic-byte sniffed

youtube_url / youtube_video_idstring

Google: the only way to register a video, since Google Ads takes no bytes

Query parameters (GET)

networkstring

meta (default), snapchat, or tiktok (videos only). google and apple_search_ads are accepted and answer 501 with the reason — Google is declared and unbuilt, Apple has no creative library because its ad is an App Store listing — so an unknown name is a 400 and a real network that cannot answer is not

kindstring

'image' or 'video'. Omitted returns both

videostring

Meta: a video id. Returns that video’s candidate cover frames instead of the library

accountstring

Creative is stored PER AD ACCOUNT on every network, so an id from one account is refused by an ad in another

Upload an image
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/media" \
  --header 'Authorization: Bearer afk_...' \
  -F kind=image -F file=@creative.jpg
200
{ "image_hash": "abc123..." }

// or, for kind=video:
{ "video_id": "120210000099" }

Ad previews

GET/api/v1/businesses/{slug}/ads/previews

Meta's rendered preview of an existing ad as embeddable iframe HTML: what the ad actually looks like in a given placement, without opening Ads Manager. The iframes stay valid for 24 hours (Meta rule); fetch fresh ones after that.

Google answers too, and it is narrower. Google previews Performance Max asset groups (?assetGroupId=) and certain YouTube video and audio ads (?adId= as the compositeadGroupId~adId). It refuses responsive search and responsive display ads outright, so a Search campaign has no preview anywhere on Google — use /ads/ad-strength for those instead. And what comes back is a signed link with its own expiry, never HTML: Google sets X-Frame-Options: deny on these URLs, so putting one in an iframe renders a blank box.

Snapchat answers, and it answers a narrower question than the other two. It previews the media file rather than the assembled ad: pass the media id as ?adId= and you get a signed link with its own expiry. So on Snapchat this settles "is that the right video" and cannot settle "what will the ad look like", because the headline, brand name and call to action live on the creative rather than on the file. A creative-level preview almost certainly exists on Snapchat's side, and it is deliberately not called: its own Creatives reference documents create, update, patch and a phone-numbers read, and no preview of any kind, so the corroborated path stays named and unconfirmed rather than guessed at.

TikTok and Apple Search Ads answer 501 with their own reason, and the two reasons are different rather than one refusal wearing two names. An Apple ad is an App Store product page, so there is nothing a preview would add and nothing to build. TikTok publishes an endpoint whose description is blank, whose body has no field descriptions, and which never says which id space it takes — so it is named and not called, because a guessed id would mint a link to the wrong creative rather than fail. 501 rather than 400 on purpose: asking Google or Apple for a preview is not a caller error.

Nothing here previews an ad that does not exist yet. Every published endpoint on every one of the five networks renders a saved object, so "let me see it before I launch it" is not something this endpoint can be made to do.

Query parameters

adIdstring

The object to render. Meta, TikTok and Apple: a plain id. Google: the composite adGroupId~adId, and only a YouTube ad answers. Snapchat: a MEDIA id (a UUID), not an ad id

assetGroupIdstring

Google only: a Performance Max asset group, which is not an ad and has no ad id. Send this or adId, not both

formatstring

Meta placement: MOBILE_FEED_STANDARD (default), DESKTOP_FEED_STANDARD, INSTAGRAM_STANDARD, INSTAGRAM_STORY, INSTAGRAM_REELS, FACEBOOK_STORY_MOBILE, FACEBOOK_REELS_MOBILE, RIGHT_COLUMN_STANDARD. Google previewType: UI_PREVIEW (asset group) or YOUTUBE_LIVE_PREVIEW (ad)

networkstring

meta (default), google or snapchat. tiktok and apple_search_ads answer 501 with the reason

accountstring

Which ad account. Meta renders through the account in the path, so an ad id from another account comes back as a permissions error rather than a preview

Preview an ad
curl "https://affiliateo.com/api/v1/businesses/acme/ads/previews?adId=120210000002&format=MOBILE_FEED_STANDARD" \
  --header 'Authorization: Bearer afk_...'
200
{
  "adId": "120210000002",
  "format": "MOBILE_FEED_STANDARD",
  "previews": [
    "<iframe src=\"https://www.facebook.com/ads/api/preview_iframe.php?d=…\" width=\"320\" height=\"550\"></iframe>"
  ]
}

Breakdowns

GET/api/v1/businesses/{slug}/ads/breakdowns

Who sees your ads: one dimension, bucketed and sorted by spend, fetched live.key is the network's own value and label is the human string — the same id/label split the id lookups use, because the label is lossy (every network folds several unknowns into one word). Defaults to the last 30 days; windows clamp to the network's own per-query ceiling and window says which one and whether it clamped. accountId names the ad account the report covers — one connection can hold several.

Drop dimension to get the catalogue: every breakdown each of the five networks has, its spelling on that network's wire, and whether it can be answered today. Four different "no" answers, deliberately kept apart: 400 this network has no such breakdown (the ones that do are named),501 it exists and cannot be answered today (nobody has built it, the network has refused us the access it needs, or you asked for one campaign on a network that only splits the whole account), 409 not connected, 502 the network refused. An empty buckets array therefore means exactly one thing — the network was asked and you had no delivery.

One campaign instead of the whole ad account. Add campaignId and the split covers that campaign alone. Leave it off and you get the ad account, which is what this endpoint has always answered and is byte-identical to what it answered before. scope on the response says which you got, account or campaign, and a scoped report echoes campaignId back, so a caller can tell from the payload alone that the filter was honoured rather than dropped somewhere between here and the network. Accepted on meta, google, tiktok and snapchat.

Apple Search Ads refuses it with 501 NOT_IMPLEMENTED, and that refusal is the point rather than a gap. Apple's split here is the org-wide campaign report, so answering a campaign id with the account's numbers would be a wrong number wearing a right label, and unlike an error it has no symptom: nobody reports it, they just decide on it. The code path exists and is guarded, and campaign level is not declared until one live Apple call has proved it. The catalogue takes campaignId too: on a network that cannot narrow, every dimension comes back with status of blocked and a blocked reason, and the response gains a scope object naming the levels each network supports.

A malformed id is a 400 VALIDATION_ERROR raised before any network is called: letters, digits, hyphens and underscores, 1 to 128 characters. The id is the network's own, and Google is the one worth naming, because it prints a resource name rather than an id. From customers/8249011858/campaigns/456 send the last segment, 456, not the whole string. Whether the id exists and belongs to this advertiser is checked against what the network sends back, which is the only check that survives a network ignoring the filter.

spendMicros is USD micros (currency says so), not the account's own currency: buckets are converted so they add up with every other spend figure on the ads surface. TikTok's audience report carries no date dimension, so the whole window is priced at its end date. A row we cannot value — an exotic currency with no rate — is left out rather than counted at zero, which would quietly understate a bucket. conversions comes backnull, never 0, on a dimension the network refuses conversion metrics for.

Bucket totals do not add up to the daily totals from Ad stats, and they are not meant to: networks suppress buckets below a privacy threshold, add an "unknown" bucket the flat rows do not have, and restate dimensional history on their own schedule. Ad stats stays the number to reconcile against.

Query parameters

networkstring

Required with dimension. meta | google | tiktok | apple_search_ads | snapchat

dimensionstring

Canonical name (age, gender, country, region, city, metro, device, platform, publisher, placement, hour_of_day, day_of_week, interest, product, asset, frequency…) or the network's own spelling. Omit for the catalogue. Meta serves every dimension it declares; Google serves all but ad_format and asset; TikTok serves age, gender, country, platform, network_type, language, interest and publisher — the eight members of its required audience-dimension array, while its region and metro are additional_dimension members and answer 501; Snapchat serves age, gender, country, region, metro, platform, interest and device_make — eight of the nine it declares, and device_make joined them on 2026-08-06 when Snapchat's measurement page was actually fetched and `make` turned out to be listed there under Device. Only device_model is left, and it really is Ads Manager only: the same table lists `make` and stops, so `model` has a pivot name and no report_dimension value, and it answers 501. Do not treat the two as one case, which is the mistake that kept `make` refused; Apple serves all six it declares — age, gender, country, region, city and device — each of them its campaign-level report grouped by one groupBy value

campaignIdstring

Optional. Narrows the split to ONE campaign; omit it for the whole ad account, which is the unchanged default. meta, google, tiktok and snapchat accept it, and apple_search_ads answers 501 NOT_IMPLEMENTED rather than serving account-wide numbers under a campaign id. Letters, digits, hyphens and underscores, 1 to 128 characters, checked before any network call. On Google send the LAST SEGMENT of the resource name it prints: "customers/8249011858/campaigns/456" means 456

from / toYYYY-MM-DD

Window. Default: last 30 days

accountstring

Which ad account, when several are connected

assetTypestring

Required by Meta’s asset dimension: ad_format | body | call_to_action | description | image | link_url | title | video. Meta ships eight separate creative-asset breakdowns rather than one with a type

timeZonestring

Meta’s hour_of_day only: advertiser (default, when you were billed) or audience (when they were awake). Two different questions, two different Meta breakdowns

On Google, the geo dimensions key on the geo target constant id, which is the same id the geo lookup hands out and the same one a campaign location criterion takes — so a country, region, city or metro bucket that is wasting budget joins straight back onto the criterion that bought it. The label is the canonical name ("Springfield,Illinois,United States"), resolved in one follow-up query; if that query fails the label falls back to the id and the numbers still stand. age and gender come from their own Google resources rather than from segments, so they only cover ad groups that carry age or gender criteria at all — a Performance Max or App campaign reports nothing there, which is not the same as reaching nobody. Every Google response says which caveat applies in coverage.note.

On Meta, conversions is the purchase count — the same priority-picked action type the Reported column uses. Meta publishes no single conversion metric: /insights returns an actions array with one entry per action type, and a total across it would add page likes to purchases. Every Meta response says so in coverage.note. Where Meta refuses conversion metrics beside a breakdown at all, every bucket carries null and the note says that instead.

On Meta's device breakdown, an empty result is TWO different answers and Meta does not say which. Its own note, effective 6 August 2026: this breakdown may be unavailable for some ad accounts, a synchronous request that returns no results can be fixed by the account administrator opting in through Ads Manager or by an asynchronous report job, and the same applies to any breakdown combination that includes it. So no buckets can mean no delivery OR an account that never opted in, nothing in the response distinguishes them, and the coverage.note on a device response now carries that sentence rather than leaving the ambiguity unstated. Nothing about the buckets themselves changed.

On Snapchat, buckets deliberately do not reconcile against Ad stats. Snapchat suppresses delivery insights for any day with fewer than 30 impressions, so a low-spend campaign contributes nothing to a bucket while still appearing in the daily totals — that is privacy suppression on Snapchat's side, not missing spend, and every Snapchat response says so in coverage.note. Its region, metro, interest and device_make dimensions carry no conversion metrics at all — Snapchat names exactly those four in its own sentence about it — and it refuses the request rather than returning an empty column, so those buckets report conversions: null. And the window here is far wider than the 30 days the Snapchat stats sync uses — the famous 31-day limit belongs to daily granularity, which a breakdown does not need.

On Apple, clicks is taps and conversions is total installs. Apple has neither a click nor a generic conversion: it bills every placement on taps and counts totalInstalls, the tap-through plus view-through figure its own dashboard calls Total Installs and the one Ad stats already stores. Every Apple response says so in coverage.note. Its window caps at 89 days, which is Apple's limit and not ours — no Apple report may start more than 90 days back, so a dimension nobody captured inside that window is gone for everyone. Geo buckets carry Apple's own strings: a region or city key is not the pipe-delimited geo id an ad group's targeting takes, so look that up rather than pasting a bucket key into targetingDimensions. Apple's impression share report is deliberately absent: it is a separate asynchronous endpoint family, and its retrieval half has answered 403 from Apple's own gateway since 2026-03-16, so a report can be queued and never read.

Spend by age (TikTok)
curl "https://affiliateo.com/api/v1/businesses/acme/ads/breakdowns?network=tiktok&dimension=age" \
  --header 'Authorization: Bearer afk_...'
Spend by placement (Meta)
curl "https://affiliateo.com/api/v1/businesses/acme/ads/breakdowns?network=meta&dimension=placement" \
  --header 'Authorization: Bearer afk_...'
Spend by device (Google)
curl "https://affiliateo.com/api/v1/businesses/acme/ads/breakdowns?network=google&dimension=device" \
  --header 'Authorization: Bearer afk_...'
One campaign, not the account (Meta)
curl "https://affiliateo.com/api/v1/businesses/acme/ads/breakdowns?network=meta&dimension=country&campaignId=120210000000000000" \
  --header 'Authorization: Bearer afk_...'
200
{
  "network": "tiktok",
  "dimension": "age",
  "from": "2026-06-10",
  "to": "2026-07-10",
  "accountId": "7100000000000000000",
  "scope": "account",
  "currency": "USD",
  "buckets": [
    { "key": "AGE_25_34", "label": "25-34", "spendMicros": 18400000, "impressions": 41231, "clicks": 902, "conversions": 87 },
    { "key": "AGE_18_24", "label": "18-24", "spendMicros": 12100000, "impressions": 30012, "clicks": 611, "conversions": 45 }
  ],
  "window": { "maxDays": 365, "clamped": false },
  "coverage": { "source": "live" }
}
What can each network break down?
curl "https://affiliateo.com/api/v1/businesses/acme/ads/breakdowns" \
  --header 'Authorization: Bearer afk_...'

Conversion reporting health

GET/api/v1/businesses/{slug}/ads/conversions

Every other endpoint here answers a question about campaigns. This one answers the question that has no other way to be asked: whether the sales this product records are actually reaching the ad networks it reports them to.

Read the difference between reporting and evidence. reporting: "on" means the setup looks healthy on our side — a pixel is configured, a credential exists, nothing is blocked. It is not a claim 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, not only ours. Treating the first as the second is the unfalsifiable green tick this endpoint exists to remove.

POST fires one real test event, which is how you prove it yourself. Meta and TikTok both have a test stream keyed by a code from their own dashboard: an event carrying it shows up in a live feed within seconds and is not counted as a conversion, so you can watch one of ours make the round trip 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 on the allowlist to make one. A cheerful success there would be exactly the thing this endpoint is for removing.

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. Supplying it also clears any standing auth block. Both need a read & write key.

Query parameters (GET)

networkstring

'meta' | 'tiktok' | 'snapchat' | 'google'. Omit for all four

Body (POST / PATCH)

networkstring

Required on both. Also accepted as a query parameter

testEventCodestring

POST. The code from the network’s own test-events tab. Without it the event is a real one

urlstring

POST. The page URL to attribute the test event to

matchWithoutClickIdboolean

PATCH. Report click-id-less sales that carry a hashed email. Refused for Google

capiTokenstring | null

PATCH, Snapchat only. null removes it. Storing one also clears a standing auth block

clearBlockboolean

PATCH, Snapchat only. Clears the auth block without changing the token

Is any of it working
curl "https://affiliateo.com/api/v1/businesses/acme/ads/conversions" \
  --header 'Authorization: Bearer afk_...'
Fire one test event
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/conversions" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "network": "meta",
  "testEventCode": "TEST12345"
}'
Store the Snapchat token
curl -X PATCH "https://affiliateo.com/api/v1/businesses/acme/ads/conversions" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "network": "snapchat",
  "capiToken": "eyJ…"
}'
200
{
  "networks": [
    {
      "network": "meta",
      "connected": true,
      "destination": "dataset 1234567890",
      "reporting": "on",
      "blocked_reason": null,
      "last_event_at": "2026-08-04T18:22:10.000Z",
      "evidence": "network_reported",
      "test_event": { "supported": true },
      "match_without_click_id": false,
      "notes": []
    }
  ],
  "note": "reporting=on means the setup looks healthy on our side. It is NOT confirmation that events reached the network…"
}

Full field writes

The campaign endpoints above build a whole stack from a short, curated body. These three are the other half: write ONE object at ONE level using everything the platform accepts — targeting, bidding, placements, scheduling, creative enhancements. Works the same way on all five networks.

fields is checked against the catalogue before anything is sent, so a misspelled name, an invalid enum value, or a field the platform freezes after creation fails here with a sentence you can act on instead of an upstream error code. extra is the escape hatch: anything the platform accepts but we have not catalogued yet goes through untouched, so our coverage never caps theirs.

Field catalogue

GET/api/v1/businesses/{slug}/ads/objects?network=meta

Every field we know for that network, so you (or an agent) can discover what is settable without reading these docs. Published for all five networks.

settable_at_create and settable_after_create come from how the platform treats the field: most are both, a frozen-at-creation field is create only, and a few Meta fields can only be changed afterwards. mutability is the same fact as one word.

min, max, requires and item_values are the rules we check before your request reaches the network, so you can check them first and get a readable answer instead of a platform error code. item_values means the field is a LIST whose members must each be one of those values.

money: true means the field takes an amount in hundredths of a major unit of the ad account’s currency — 5000 is $50.00 — whatever the currency. Do not apply a per-currency offset yourself: Meta gives eleven currencies an offset of 1, and we apply it at the wire, so scaling it again sends a budget a hundred times too large. Fields WITHOUT this flag are in the network’s own unit and say so in their help (Snapchat is micro at 1e6, Google is micros).

lookup names the id-lookup that hands out a value for that field, so an id-shaped field is never a dead end. Follow it to GET …/ads/lookups?network=&type=. owned_params lists the tracking keys we own inside a tracking-tag field: yours are kept, ours win on collision. ui_hidden is a rendering hint only — the field is still fully writable here.

writes_supported reflects whether a write would actually execute right now, not just whether we support the network. Google is the one network still behind a runtime gate, needing its API credentials present; when they are missing the flag is false rather than optimistic.

Query

networkstring

meta (default), google, tiktok, apple_search_ads, snapchat

List Meta fields
curl "https://affiliateo.com/api/v1/businesses/acme/ads/objects?network=meta" \
  --header 'Authorization: Bearer afk_...'
200
{
  "network": "meta",
  "platform": "Meta",
  "levels": ["campaign", "adset", "ad", "creative"],
  "writes_supported": true,
  "fields": [
    {
      "name": "optimization_goal",
      "level": "adset",
      "type": "enum",
      "values": ["OFFSITE_CONVERSIONS", "LINK_CLICKS", "..."],
      "settable_at_create": true,
      "settable_after_create": false,
      "label": "Optimisation goal",
      "help": "What Meta optimises delivery for.",
      "mutability": "create_only"
    },
    {
      "name": "daily_budget",
      "level": "adset",
      "type": "number",
      "settable_at_create": true,
      "settable_after_create": true,
      "label": "Daily budget",
      "mutability": "mutable",
      "min": 100,
      "money": true
    },
    {
      "name": "location_ids",
      "level": "adgroup",
      "type": "json",
      "settable_at_create": true,
      "settable_after_create": true,
      "label": "Locations",
      "mutability": "mutable",
      "lookup": { "type": "geo", "network": "tiktok", "multiple": true }
    }
  ]
}

Create one object

POST/api/v1/businesses/{slug}/ads/objects?network=meta

Needs a read_write key. Anything that can serve is created paused on every network, so a create never starts spending on its own — set the status yourself when you want it live. One exception, on Google: the two criterion levels (ad_group_criterion — keywords, audiences, demographics — and campaign_criterion — locations, languages, schedules, negatives) are created ENABLED, because Google enables a new criterion when the create says nothing and a keyword that arrives paused inside a live ad group is a silent no-op. Send fields.status = "PAUSED" to hold one back. Either way the response note tells you which happened for the object you just wrote. Campaign creates draw on the same 10/min budget as the campaign endpoints; everything below campaign level gets 30/min.

Creating a campaign here tags it for the 1% platform fee, the same as launching one through the dashboard — and so does editing one: a successful write at any level tags the campaign above it, from that day forward and never retroactively. That includes creating an ad set, ad group, ad, keyword or criterion inside a campaign you built on the network itself — the campaign above it is what gets metered, from that day. Child objects are not tagged separately because their spend already rolls up into the campaign.

One refusal to know about: while your ads platform fees are unpaid, a campaign create that starts ACTIVE answers 402 PAYMENT_REQUIRED, the same rule that stops a metered campaign being resumed with a balance outstanding. Creating it paused is never blocked — a paused campaign spends nothing, so it owes nothing — and switching it on works as soon as the balance is zero.

Query

networkstring

meta (default), google, tiktok, apple_search_ads, snapchat

accountstring

Which ad account, org, advertiser or customer to write to, when several are connected

campaignIdstring

Parent campaign. Apple and TikTok need it below campaign level

adGroupIdstring

Parent ad group. Apple needs it for keywords and ads; TikTok for ads

Body

levelstring

Which object to create — see the table below

fieldsobject

The platform’s own field names to values

extraobject

Fields we have not catalogued, forwarded verbatim

Create an ad set
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/objects?network=meta" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "level": "adset",
  "fields": {
    "name": "Spring — US broad",
    "campaign_id": "120210000000000",
    "optimization_goal": "OFFSITE_CONVERSIONS",
    "billing_event": "IMPRESSIONS",
    "daily_budget": 5000,
    "bid_strategy": "COST_CAP",
    "bid_amount": 250,
    "targeting": {
      "geo_locations": {
        "countries": [
          "US"
        ]
      },
      "age_min": 25
    }
  },
  "extra": {
    "some_new_meta_field": "value"
  }
}'
201
{
  "id": "120210000000001",
  "level": "adset",
  "network": "meta",
  "account": "123456789",
  "note": "Created paused. Set it active when you are ready for it to spend."
}

Edit one object

PATCH/api/v1/businesses/{slug}/ads/objects?network=meta&id={objectId}

Same body as create. Send only what you are changing; a field the platform freezes after creation is rejected by name, with the reason.

Ids are numeric everywhere except on Google, where an object's id is composed out of its parents and the composition is not uniform. Four shapes exist: 123 for campaigns, budgets, ad groups, assets, asset groups and conversion actions; 123~456 for ads and every criterion level; 123~456~HEADLINE for the campaign, ad-group and asset-group asset links, where the field type is part of the link's identity (that is what lets one image serve as two field types at once); and 456~HEADLINE for customer_asset, the account-level link, which has no parent id because the customer is already in the resource name. In every case it is exactly the id the create call handed back — send it verbatim rather than rebuilding it.

Query

idstring

Required. The object to edit

networkstring

meta (default), google, tiktok, apple_search_ads, snapchat

accountstring

Which ad account, org, advertiser or customer

campaignIdstring

Parent campaign, where the network needs it in the path

adGroupIdstring

Parent ad group, where the network needs it in the path

Body

levelstring

Which object type this id is

fieldsobject

Only the fields you are changing

extraobject

Fields we have not catalogued, forwarded verbatim

confirm_full_replaceboolean

TikTok ad groups only — see below

Raise a budget and pause
curl -X PATCH "https://affiliateo.com/api/v1/businesses/acme/ads/objects?network=meta&id=120210000000001" \
  --header 'Authorization: Bearer afk_...' \
  --header 'Content-Type: application/json' \
  --data '{
  "level": "adset",
  "fields": {
    "daily_budget": 8000,
    "status": "PAUSED"
  }
}'
200
{ "ok": true, "id": "120210000000001", "level": "adset", "network": "meta" }

Detach a link

DELETE/api/v1/businesses/{slug}/ads/objects?network=google&level={level}&id={objectId}

This is the only way to retire an asset. Setting status to REMOVED does not work and never did: Google answers [INVALID_ENUM_VALUE], "Enum value 'REMOVED' cannot be used", because removal is a separate operation rather than a value of the status field. Until this endpoint existed the only route was Google's own web console, which rather defeats the point of an API.

Link-shaped levels only. campaign_asset, ad_group_asset, asset_group_asset, customer_asset, asset_group_signal, asset_group_listing_group_filter, ad_group_criterion and campaign_criterion. Detaching one is reversible by re-linking and destroys no history, which is the same bar the labels endpoint keeps. The spend-carrying levels are refused and point you at PATCH status, which is the reversible version of the same intent.

asset is refused for a harder reason than policy: Google publishes no asset delete at all, so there is no request to send. An asset stops serving when its last link goes, which is exactly what this removes.

Google only today. The other four answer 400 saying it is not built yet rather than claiming their platform cannot do it, because the difference matters to anyone deciding whether to wait for us or go elsewhere.

One caveat before you reach for PATCH status: PAUSED as a substitute: it works on ordinary links, but Google refuses it on LOGO and BUSINESS_NAME when the campaign has brand guidelines enabled, since it requires one of each to exist. Both behaviours were confirmed against a live account; the v25 protos describe status as plainly mutable and mention neither.

Query

levelstring

Required. Which object type this id is

idstring

Required. The link to detach

networkstring

google — the other four answer 400 for now

accountstring

Which customer, when the connection holds several

Retire a logo
curl -X DELETE "https://affiliateo.com/api/v1/businesses/acme/ads/objects?network=google&level=campaign_asset&id=24125351257~405592817440~LOGO" \
  --header 'Authorization: Bearer afk_...'
200
{ "ok": true, "id": "24125351257~405592817440~LOGO", "level": "campaign_asset", "network": "google" }

Levels and per-network behaviour

The networks disagree about what an object is called, where the parent id goes, and what an update means. These are the differences worth knowing before you write.

Levels

metalevels

campaign, adset, ad, creative

googlelevels

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

tiktoklevels

campaign, adgroup, ad, smart_plus_campaign, smart_plus_adgroup, smart_plus_ad

apple_search_adslevels

campaign, adgroup, keyword, negative_keyword, ad, creative

snapchatlevels

campaign, adsquad, ad, creative

Google assets are how images and video reach the network. Upload an image with POST /ads/media?network=google (you get back an asset resource name) or register a YouTube video by creating an asset with youtube_video_asset. An asset is never deleted — you remove the LINK, which is what the asset_group_asset, campaign_asset, ad_group_asset and customer_asset levels are for. A link's identity includes its field_type, so the same image can serve as both a landscape and a square image, and changing a field type means removing the link and making a new one.

Performance Max uses asset_group in place of ad groups — a PMax campaign has no adgroup or ad rows at all. Google validates the asset minimums when the group is created, so an asset group and its assets have to arrive in one request; POST /ads/campaigns?network=google&type=pmax does that for you. Video is optional: without one, Google generates video from your other assets.

A second asset group goes on a campaign that already exists through POST /ads/campaigns?network=google&type=asset_group with { campaignId, assetGroup }. Reach for it when one campaign sells to two audiences: an asset group is the unit that keeps creative together, because Google mixes headlines with images freely inside a group and never across two. Put both audiences in one group and it will pair a picture written for one with a headline written for the other. For the same reason as above this cannot be done a row at a time, so level: "asset_group" on /ads/objects is refused with a pointer here rather than forwarded to Google, which answers an empty group with five enum names and no remedy. Editing an existing group there still works. businessName, logoAssets and landscapeLogoAssets are refused on this path too: with brand guidelines on they belong to the campaign, which already carries them. The new group arrives paused, so the running campaign is untouched until you enable it.

Google criteria are the two levels that make a live campaign editable rather than frozen at launch. ad_group_criterion holds keywords, audiences and demographics on one ad group; campaign_criterion holds locations, languages, ad schedules and campaign-wide negatives. Without them a campaign's targeting could only ever be set in the launch call, so this is how you add a keyword or a country to something already running.

Google bidding is editable on a live campaign, which it was not before 10 August 2026. Send the scheme as an object: { "target_spend": { "cpcBidCeilingMicros": "3000000" } } puts a $3 ceiling on cost per click under Maximise Clicks, and {} selects a scheme and lets its settings default. The body shape never changed. What was broken was the update mask: Google refuses a mask that names a message carrying subfields, and ours named the parent, so every bidding edit came back as a 502 that read like an outage rather than a request built wrong. The mask is now assembled from the leaf fields, so a caller already sending the right shape simply starts working.

TikTok Smart+ is a separate endpoint family, not a mode of the manual levels — the manual endpoints cannot write Smart+ objects and Smart+ endpoints cannot write manual ones. After 31 December 2026 TikTok stops accepting new manual campaigns on /campaign/create/; existing campaigns keep running and stay editable. Smart+ writes need no allowlist and no extra permission — they reuse the same ones manual campaigns already have, and TikTok confirmed general availability on 2026-08-03 — but only campaigns and ad groups are proven. Smart+ ad creation used to refuse every call to action; TikTok’s answer is that some campaign and identity combinations need a dynamic CTA portfolio id (call_to_action_id, built from /creative/cta/recommend/ then /creative/portfolio/create/) instead of a fixed value, and the launch path now escalates to one. Note Smart+ ads take their creatives in creative_list, not the manual creatives wrapper.

Two manual fields have no Smart+ level to live at, and neither is declared at any smart_plus_* level here because TikTok publishes neither: tiktok_subplacements on the ad group and showcase_products on the creative. That is why the launch refuses a Smart+ attempt naming either and hands the stack to the manual family instead. Inventing a field name would not fail loudly: TikTok answers an unrecognised field with silence, which is how call_to_action_id and music_id were each discarded for a while before they were nested where TikTok actually reads them.

Meta carries the parent inside fields (campaign_id, adset_id), so the query parents are ignored. A creative can only be renamed after creation — Meta does not let you edit the content, so make a new one and point the ad at it. Ad sets get extra checks: the capped bid strategies need a bid amount, and minimum-ROAS bidding rejects one.

Google also carries the parent inside fields (campaign, ad_group) as full resource names. Pick exactly one of the 17 bidding strategies. Editing an ad's headlines routes to Google's separate ad resource automatically, which is why ad ids are composite.

TikTok needs ?campaignId= for ad groups and ?adGroupId= for ads. Two things differ from everyone else: pausing goes to a separate endpoint (we call it for you, so just set operation_status), and an ad-group update is a full replacement — TikTok resets anything you leave out, including targeting, schedule, bid and budget. So a partial ad-group edit is refused; send the complete ad group with "confirm_full_replace": true. Ad edits are protected automatically.

Apple builds parents into the path, so ?campaignId= and ?adGroupId= are required below campaign level (a negative keyword with no ad group is campaign-wide, which is deliberate, not a mistake). Creatives have no update endpoint at all. Keyword text is frozen once created — change the bid or status instead. Changing a campaign's countries clears the geo targeting on its ad groups, because Apple requires it.

Errors

VALIDATION_ERROR400

Unknown network or level, unknown field name, bad enum value, a frozen field on edit, or nothing to change

FORBIDDEN403

Read-only key, a read-only ad connection, or write scopes still pending

NOT_CONNECTED409

That network is not connected for this business

RATE_LIMIT_EXCEEDED429

30/min for writes, 10/min for campaign creates

UPSTREAM_ERROR502

The network refused the write; its own message is passed through

Need a key? Open your business dashboard → API tab → Generate API key.