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 Affiliatesendpoints 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, Meta Ads has the full surface including campaign creation, Apple Search Ads covers creation and management (campaigns, ad groups, keywords, bids, negative keywords), Google Adshas the full Search surface (creation, keywords, research, recommendations, conversion tracking) pending Google's approval of our API access, and TikTok Ads is read-only until TikTok approves write access (both pending surfaces are already built and documented below).

GET /api/v1 returns a machine-readable index of every endpoint: point an agent at it and it discovers the rest itself.

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 onlykeys 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

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

RATE_LIMIT_EXCEEDED429

Budget exhausted; retry after Retry-After seconds

UPSTREAM_ERROR502

The ad network rejected the operation; its message passes through

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 endpointin this reference — campaign creation, keywords, automated rules, audiences, webhooks — so nothing is out of an agent's reach (the one exception: creative uploads are multipart and stay REST-only).

Tools

list_businessesread

Every business the key can act on, ad-network connection status, and affiliate apps — the discovery call agents run first

get_appread

Commission rate/type, per-product rates, affiliate count, total paid out for one app

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_ad_statsread

Spend, clicks, installs, revenue, ROAS by campaign/adgroup/ad for any network and date range

list_ad_campaignsread

The live campaign tree with statuses, budgets, and bids (Meta, Apple, Google)

update_ad_entitywrite

Pause/resume, re-budget, re-bid, or archive (Meta, one-way) — the tool that starts real spend

sync_ad_statsread

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

get_ad_billingread

Ad account balance, lifetime spend, spend cap, payment method label (Meta)

call_apiread/write

Escape hatch to any endpoint in this reference: campaign creation, keywords, rules, audiences, webhooks — takes method + path + body

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 the affiliate-capable apps (source of the {appId} used by the 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"
    }]
  }]
}

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 Web App Affiliate, Mobile App Affiliate, and Web Traffic apps. Start with App info & commission to render the "earn X%" pitch screen, then create on tap. Call these from your server only: never ship the API key inside an app binary.

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 (they pay per qualified click from a budget).

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": "mobile_affiliate",
    "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
      }]
    }
  }
}

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 Web App Affiliate, Mobile App Affiliate, and Web Traffic apps (Web Traffic pays per qualified click, so its conversion money stays at zero).

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": "mobile_affiliate",
      "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. Store ref_codeagainst 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); nameseeds 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.

An affiliate who previously left your business is reactivated by this call (their original links and history come back). Someone you banned from the business is rejected with FORBIDDEN — 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 when the account is newly created

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",
    "user": {
      "username": "swiftpanda",
      "display_name": "Jane Doe"
    },
    "links": {
      "short": "https://affiliateo.com/r/aB3xY9k",
      "username": "https://affiliateo.com/r/acme-chat/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
}

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.renewalsis 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.

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",
    "user": {
      "username": "swiftpanda",
      "display_name": "Jane Doe"
    },
    "links": {
      "short": "https://affiliateo.com/r/aB3xY9k",
      "username": "https://affiliateo.com/r/acme-chat/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).

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

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_...'
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=payoutsto 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",
    "user": {
      "username": "swiftpanda",
      "display_name": "Jane Doe"
    },
    "links": {
      "short": "https://affiliateo.com/r/aB3xY9k",
      "username": "https://affiliateo.com/r/acme-chat/swiftpanda"
    },
    "stats": {
      "clicks": 482,
      "conversions": 17,
      "renewals": 6,
      "revenue_cents": 184300,
      "commission_cents": 36860
    }
  }],
  "has_more": false,
  "limit": 100
}

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 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_slugto 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 three

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",
    "conversion.created",
    "conversion.reversed"
  ]
}'
201
{
  "webhook": {
    "id": "a3b1c5d7-2e4f-4a6b-8c0d-1e2f3a4b5c6d",
    "url": "https://api.acme.com/affiliateo/webhook",
    "events": [
      "affiliate.created",
      "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)

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"
    }
  }
}
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).

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,
  "totals": {
    "spendMicros": 41250000,
    "impressions": 91203,
    "clicks": 2311,
    "installs": 402,
    "revenueMicros": 96500000,
    "roas": 2.34
  },
  "level_order": ["campaign", "adgroup", "ad"],
  "by_level": {
    "campaign": [{
      "id": "120210000000",
      "name": "Summer launch",
      "spendMicros": 41250000,
      "revenueMicros": 96500000,
      "roas": 2.34
    }]
  }
}

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.

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,
  "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. Everything is created PAUSED: nothing spends until the campaign is activated via the entity endpoint. Upload media first. The sales objective requires the conversion pixel. If any step fails, the freshly created campaign is rolled back, so a failed launch never leaves a half-built stack.

The same endpoint also creates campaigns on the other networks: ?network=apple_search_ads (see Create campaigns), ?network=google (see Create campaigns, pending Google approval), and ?network=tiktok (see Launch campaigns, pending TikTok approval).

Body

campaignNamestring

Required. Campaign display name

objectivestring

'traffic' (default, link clicks) or 'sales' (pixel purchase optimization)

adset.dailyBudgetCentsnumber

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

adset.countriesstring[]

Required. ISO-2 country codes, e.g. ["US","CA"]

adset.ageMin / ageMaxnumber

18-65, defaults 18/65

adset.genderstring

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

adset.endDateYYYY-MM-DD

Optional end date

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

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.

ARCHIVEDis 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; POSTenables 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" }

Upload creative

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

Multipart upload of the ad image or video, forwarded to Meta. Returns the handle the launch endpoint needs. Caps: 10MB images, 50MB videos. Videos keep processing on Meta's side after upload; the launch endpoint waits for readiness itself.

Form fields

filefile

The image or video

kindstring

'image' or 'video'

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" }

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

GETlists the account's audiences with size bands (approximateLowerBound / approximateUpperBound), subtype, operationStatus, retentionDays, and timeUpdated. POST creates one of three kinds: website (pixel visitors; needs the pixel enabled; no filter = all visitors), engagement (everyone who engaged with a Facebook Page), 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.

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

Body (POST)

kindstring

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

namestring

Required. Audience display name

retentionDaysnumber

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

eventstring

website: optional pixel event filter, e.g. 'Purchase'

urlContainsstring

website: optional URL substring filter

pageIdstring

engagement: required. The Facebook Page whose engagers to collect

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.

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" }

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.

Query parameters

adIdstring

Required. The ad to preview

formatstring

MOBILE_FEED_STANDARD (default), DESKTOP_FEED_STANDARD, INSTAGRAM_STANDARD, INSTAGRAM_STORY, INSTAGRAM_REELS, FACEBOOK_STORY_MOBILE, FACEBOOK_REELS_MOBILE, RIGHT_COLUMN_STANDARD

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>"
  ]
}

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": 16800000
}
GET/api/v1/businesses/{slug}/ads/targeting-search

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

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 (activates on TikTok approval). 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

Read-only today: stats, live audience breakdowns, and on-demand sync. The full write side below (launch, budgets, identities, balances) is already built and returns 403 until TikTok approves the app's write scopes; it then activates with no code changes.

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_...'

Audience breakdowns

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

Who sees your ads: TikTok's live audience report bucketed by one dimension, sorted by spend. Defaults to the last 30 days; windows clamp to TikTok's 365-day per-query maximum.

Query parameters

dimensionstring

'age' (default) | 'gender' | 'country' | 'platform'

from / toYYYY-MM-DD

Window. Default: last 30 days

Get audience breakdown
curl "https://affiliateo.com/api/v1/businesses/acme/ads/breakdowns?network=tiktok&dimension=age" \
  --header 'Authorization: Bearer afk_...'
200
{
  "network": "tiktok",
  "dimension": "age",
  "from": "2026-06-10",
  "to": "2026-07-10",
  "buckets": [
    { "key": "25-34", "spendMicros": 18400000, "impressions": 41231, "clicks": 902, "conversions": 87 },
    { "key": "18-24", "spendMicros": 12100000, "impressions": 30012, "clicks": 611, "conversions": 45 }
  ]
}

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 (pending approval)

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

The whole TikTok write side is built and waiting on TikTok's app review: every write returns 403 FORBIDDENwith a clear message until TikTok approves the app's write scopes, then it activates with no code changes. As on every network here, everything is created PAUSED.

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).

Body

campaignNamestring

Required. Campaign display name

objectivestring

'traffic' (default) or 'conversions' (needs adgroup.pixelId, optionally adgroup.optimizationEvent)

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

ad.identityType / identityIdstring

Required. 'TT_USER' or 'AUTH_CODE' (Spark identity). TikTok-placement ads require a Spark identity since Jan 2026; CUSTOMIZED_USER is rejected by our validation for this flow

ad.formatstring

'SINGLE_VIDEO' or 'SINGLE_IMAGE'

ad.videoIdstring

SINGLE_VIDEO: required, from the media upload

ad.imageIdsstring[]

Exactly one entry: the video cover (SINGLE_VIDEO, same aspect ratio) or the image itself (SINGLE_IMAGE)

ad.text / cta / displayNamestring

Ad copy, call to action (LEARN_MORE default), advertiser display name

ad.landingPageUrlstring

Required. https:// landing URL

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",
  "note": "Created paused. PATCH the campaign ACTIVE to start spending."
}

Identities (pending approval)

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

Who a TikTok ad publishes as. GETlists the identities the launch endpoint's identityId accepts. POST creates a Custom User identity and returns 201 { identityId, identityType: "CUSTOMIZED_USER", note }: custom identities only work on Pangle placements since Jan 2026, so TikTok-placement ads need a Spark identity (TT_USER / AUTH_CODE) from the list instead.

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 (pending approval)

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."
}

Apple Search Ads

Create campaigns and ad groups, read performance, inspect the live campaign → ad group → keyword tree, pause/resume, change budgets and bids, manage targeting and negative keywords, and pull Apple's bid recommendations.

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.

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,
  "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" }
  ]
}

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 & writekey. Find the app's adamId via targeting search type=app.

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

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"
}'
201
{
  "campaignId": "900000010",
  "network": "apple_search_ads",
  "status": "PAUSED",
  "note": "Created paused. Add an ad group (POST …/ads/adgroups), then keywords (POST …/ads/keywords), then PATCH the campaign ACTIVE to start spending."
}

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 bidCentson 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.

text and matchTypeare 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" }
  ]
}

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.

Query parameters

campaignIdstring

Required. The campaign whose keywords to score

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"
    }
  ]
}

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

Full Search management: read performance, inspect the live campaign tree, create campaigns (budget, targeting, responsive search ad, keywords in one atomic call), 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 biddingStrategyType, dailyBudgetCents, and budgetResourceName; ad groups their default cpcBidCents; keywords their own bid and matchType.

Ads and keywords are patched via composite ids: adGroupId~childId (the ad group id, a tilde, then the ad id or keyword criterionId). 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

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",
      "biddingStrategyType": "MAXIMIZE_CLICKS",
      "dailyBudgetCents": 5000,
      "budgetResourceName": "customers/1234567890/campaignBudgets/9000000001",
      "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
            }
          ]
        }
      ]
    }
  ]
}

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).

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

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[]

3-15 headlines, max 30 chars each

ad.descriptionsstring[]

2-4 descriptions, max 90 chars each

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

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"
    }
  ]
}'
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."
}

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 from the campaign tree.

Body

kindstring

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

statusstring

'ENABLED' or 'PAUSED'

dailyBudgetCentsnumber

Campaigns only

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.

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

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 gclidon 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.

GET returns the current state. POSTenables it: adopts or creates the "Affiliateo purchases" conversion action on the correct account (cross-account conversion tracking through a manager account is handled automatically). New conversion actions warm up for about 6 hours before they accept uploads. Requires a read & write key.

Refunds are not yet pushed to Google (retraction support is planned).

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_...'
200
{
  "conversion_action": "customers/1234567890/conversionActions/987654321",
  "conversion_customer_id": "1234567890",
  "conversion_tracking_status": "CONVERSION_TRACKING_MANAGED_BY_SELF"
}

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_...'

Need a key? Open your business dashboard → API tab → Generate API key.