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.
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.
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
UNAUTHORIZED401Missing, malformed, expired, or revoked API key
FORBIDDEN403Key is read-only, or pinned to a different business
NOT_FOUND404Business slug does not exist or is not yours
VALIDATION_ERROR400Bad parameter or request body; the message says which
NOT_CONNECTED409The ad network is not connected with the required access
RATE_LIMIT_EXCEEDED429Budget exhausted; retry after Retry-After seconds
UPSTREAM_ERROR502The ad network rejected the operation; its message passes through
INTERNAL_ERROR500Something broke on our side
{
"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_businessesreadEvery business the key can act on, ad-network connection status, and affiliate apps — the discovery call agents run first
get_appreadCommission rate/type, per-product rates, affiliate count, total paid out for one app
get_app_summaryreadThe whole program at a glance: totals + payout buckets across all affiliates, optional date window and per-day series
enroll_affiliatewriteEmbedded affiliate signup by email: find-or-create the account, enroll, return links + stats
get_affiliatereadOne affiliate by email or ref_code: links, lifetime stats, payout buckets, optional click breakdown
list_affiliatesreadCursor-paginated affiliate roster (emails never included)
list_affiliate_conversionsreadConversion history with per-row ref_code: one affiliate’s, or the app-wide feed, optionally date-windowed
get_ad_statsreadSpend, clicks, installs, revenue, ROAS by campaign/adgroup/ad for any network and date range
list_ad_campaignsreadThe live campaign tree with statuses, budgets, and bids (Meta, Apple, Google)
update_ad_entitywritePause/resume, re-budget, re-bid, or archive (Meta, one-way) — the tool that starts real spend
sync_ad_statsreadPull the freshest stats from the ad network right now (4/min)
get_ad_billingreadAd account balance, lifetime spend, spend cap, payment method label (Meta)
call_apiread/writeEscape hatch to any endpoint in this reference: campaign creation, keywords, rules, audiences, webhooks — takes method + path + body
{
"mcpServers": {
"affiliateo": {
"url": "https://affiliateo.com/api/mcp",
"headers": {
"Authorization": "Bearer afk_your_api_key"
}
}
}
}claude mcp add --transport http affiliateo \ https://affiliateo.com/api/mcp \ --header "Authorization: Bearer afk_your_api_key"
List businesses
/api/v1/businessesThe 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.
curl "https://affiliateo.com/api/v1/businesses" \ --header 'Authorization: Bearer afk_...'
{
"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
/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
slugstringBusiness slug (see List businesses)
appIdstringApp UUID or app slug
curl "https://affiliateo.com/api/v1/businesses/acme/apps/acme-chat" \ --header 'Authorization: Bearer afk_...'
{
"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
/api/v1/businesses/{slug}/apps/{appId}/summaryYour 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
slugstringBusiness slug (see List businesses)
appIdstringApp UUID or app slug
Query parameters
from / toYYYY-MM-DDInclusive window (UTC days), both together; adds the window object
includestringdaily: adds the per-day series (requires from/to, window up to 366 days)
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_...'
{
"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
/api/v1/businesses/{slug}/apps/{appId}/affiliatesThe 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
slugstringBusiness slug (see List businesses)
appIdstringApp UUID or app slug (see the apps array on List businesses)
Body
emailstringRequired. The user’s email; the account is created for (or matched to) this address. Disposable email domains are rejected
namestringOptional. Seeds the display name when the account is newly created
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"
}'{
"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
/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
emailstringLook up by the email used at enrollment
ref_codestringLook up by referral code (the affiliate’s username)
includestringbreakdown: adds click sources + countries
from / toYYYY-MM-DDInclusive window; adds windowed_clicks (clicks within the range). Lifetime counters are unaffected
curl "https://affiliateo.com/api/v1/businesses/acme/apps/acme-chat/affiliates?email=jane@example.com&include=breakdown" \ --header 'Authorization: Bearer afk_...'
{
"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
/api/v1/businesses/{slug}/apps/{appId}/affiliates/conversionsConversion 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
emailstringScope to one affiliate by the email used at enrollment
ref_codestringScope to one affiliate by referral code. Omit both for the app-wide feed
from / toYYYY-MM-DDInclusive date window (UTC days), both together
limitnumberPage size, 1–500. Default 100
starting_afterstringCursor: id of the last row of the previous page. Response carries has_more
curl "https://affiliateo.com/api/v1/businesses/acme/apps/acme-chat/affiliates/conversions?email=jane@example.com&limit=50" \ --header 'Authorization: Bearer afk_...'
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_...'
{
"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
/api/v1/businesses/{slug}/apps/{appId}/affiliatesEvery 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
limitnumberPage size, 1–500. Default 100
starting_afterstringCursor: id of the last row of the previous page
includestringpayouts: adds the earnings buckets to every row
curl "https://affiliateo.com/api/v1/businesses/acme/apps/acme-chat/affiliates?limit=100" \ --header 'Authorization: Bearer afk_...'
{
"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
/api/v1/webhooksPOST 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)
urlstringHTTPS receiver URL. Private/internal hosts are rejected
eventsstring[]Which events to receive. Default: all three
business_slugstringPOST only. Pin the endpoint to one business
statusstringPATCH only. active | disabled
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"
]
}'{
"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"
}curl "https://affiliateo.com/api/v1/webhooks" \ --header 'Authorization: Bearer afk_...'
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.createdeventSomeone became an affiliate of one of your apps (embedded signup, your site, or a referral link)
conversion.createdeventA tracked sale, renewal, or trial landed for one of your affiliates
conversion.reversedeventA refund or chargeback reversed a tracked sale (negative amounts, linked via original_conversion_id)
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"
}
}
}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
/api/v1/businesses/{slug}/ads?network=metaAggregated 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
networkstringmeta (default) | tiktok | apple_search_ads | google
startYYYY-MM-DDWindow start. Default: 29 days before end (a 30-day window)
endYYYY-MM-DDWindow end. Default: today (UTC)
curl "https://affiliateo.com/api/v1/businesses/acme/ads?network=meta&start=2026-06-11&end=2026-07-10" \ --header 'Authorization: Bearer afk_...'
{
"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
/api/v1/businesses/{slug}/ads/campaignsThe 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.
curl "https://affiliateo.com/api/v1/businesses/acme/ads/campaigns" \ --header 'Authorization: Bearer afk_...'
{
"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
/api/v1/businesses/{slug}/ads/campaignsCreates 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
campaignNamestringRequired. Campaign display name
objectivestring'traffic' (default, link clicks) or 'sales' (pixel purchase optimization)
adset.dailyBudgetCentsnumberRequired, min 100 (= 1.00 in the account currency)
adset.countriesstring[]Required. ISO-2 country codes, e.g. ["US","CA"]
adset.ageMin / ageMaxnumber18-65, defaults 18/65
adset.genderstring'all' (default) | 'men' | 'women'
adset.endDateYYYY-MM-DDOptional end date
ad.pageIdstringRequired. Facebook Page the ad publishes as
ad.primaryTextstringRequired. Primary ad copy
ad.headline / descriptionstringOptional
ad.destinationUrlstringRequired. https:// landing URL
ad.ctastringLEARN_MORE (default), SIGN_UP, GET_STARTED, SUBSCRIBE, SHOP_NOW, DOWNLOAD, CONTACT_US
ad.mediaobject{ type: 'image', imageHash } or { type: 'video', videoId } from Upload creative
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..."
}
}
}'{
"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
/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
kindstringRequired. 'campaign' | 'adset' | 'ad'
statusstring'ACTIVE', 'PAUSED', or 'ARCHIVED' (one-way)
dailyBudgetCentsnumberMin 100. Not valid for kind=ad (ads have no budget of their own)
spendCapCentsnumber | nullCampaigns only. Lifetime spend cap in cents, min 10000 (= 100.00); null removes it
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"
}'{
"ok": true,
"network": "meta",
"id": "120210000000",
"kind": "campaign",
"applied": { "status": "ACTIVE" }
}Facebook Pages
/api/v1/businesses/{slug}/ads/pagesThe Facebook Pages the connected Meta login manages. A launch needs one of these ids as ad.pageId, the identity the ad publishes as.
curl "https://affiliateo.com/api/v1/businesses/acme/ads/pages" \ --header 'Authorization: Bearer afk_...'
{ "pages": [{ "id": "1234567890", "name": "Acme" }] }Conversion pixel
/api/v1/businesses/{slug}/ads/pixelGET 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.
curl "https://affiliateo.com/api/v1/businesses/acme/ads/pixel" \ --header 'Authorization: Bearer afk_...'
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/pixel" \ --header 'Authorization: Bearer afk_...'
{ "ok": true, "pixel_id": "987654321" }Upload creative
/api/v1/businesses/{slug}/ads/mediaMultipart 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
filefileThe image or video
kindstring'image' or 'video'
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/media" \ --header 'Authorization: Bearer afk_...' \ -F kind=image -F file=@creative.jpg
{ "image_hash": "abc123..." }
// or, for kind=video:
{ "video_id": "120210000099" }Sync stats
/api/v1/businesses/{slug}/ads/sync?network=metaPulls 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.
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/sync?network=meta" \ --header 'Authorization: Bearer afk_...'
{ "ok": true, "network": "meta", "inserted": 412 }Billing & spend cap
/api/v1/businesses/{slug}/ads/billing?network=metaThe 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 | nullSet the cap (counts spend from now on, not historically). Send null to remove it
resetSpentbooleantrue zeroes the cap's spend counter
curl "https://affiliateo.com/api/v1/businesses/acme/ads/billing?network=meta" \ --header 'Authorization: Bearer afk_...'
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
}'{
"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
/api/v1/businesses/{slug}/ads/rulesStanding 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)
namestringRequired. Rule display name
evaluationSpec.evaluationTypestring'SCHEDULE' (evaluated on a schedule) or 'TRIGGER' (evaluated when metrics change)
evaluationSpec.filtersarrayRequired. Meta filter objects; an entity_type filter is required. Money values are cents
executionSpec.executionTypestringPAUSE, UNPAUSE, NOTIFICATION, CHANGE_BUDGET, CHANGE_CAMPAIGN_BUDGET, CHANGE_BID, REBALANCE_BUDGET, ROTATE
scheduleSpecobjectOptional custom schedule. SCHEDULE rules default to a DAILY check when omitted
curl "https://affiliateo.com/api/v1/businesses/acme/ads/rules?network=meta" \ --header 'Authorization: Bearer afk_...'
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"
}
}'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"
}'curl -X DELETE "https://affiliateo.com/api/v1/businesses/acme/ads/rules/978100000000" \ --header 'Authorization: Bearer afk_...'
{ "ruleId": "978100000000", "network": "meta", "status": "ENABLED" }Custom audiences
/api/v1/businesses/{slug}/ads/audiencesGETlists 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)
kindstringRequired. 'website' | 'engagement' | 'lookalike'
namestringRequired. Audience display name
retentionDaysnumberwebsite/engagement: how long people stay in the audience. Max 180
eventstringwebsite: optional pixel event filter, e.g. 'Purchase'
urlContainsstringwebsite: optional URL substring filter
pageIdstringengagement: required. The Facebook Page whose engagers to collect
originAudienceIdstringlookalike: required. The seed audience id
countrystringlookalike: required. ISO-2 country the lookalike lives in
rationumberlookalike: 0.01-0.20 (top 1% to 20% most similar people)
curl "https://affiliateo.com/api/v1/businesses/acme/ads/audiences?network=meta" \ --header 'Authorization: Bearer afk_...'
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"
}'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
}'curl -X DELETE "https://affiliateo.com/api/v1/businesses/acme/ads/audiences/238100000000" \ --header 'Authorization: Bearer afk_...'
{ "audienceId": "238100000000", "kind": "website", "network": "meta" }Custom conversions
/api/v1/businesses/{slug}/ads/custom-conversionsURL-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)
namestringRequired
urlContainsstring[]Required. 1-10 URL substrings that count as this conversion
eventTypestringOptional Meta event type, e.g. 'COMPLETE_REGISTRATION', 'LEAD', 'ADD_TO_CART'
defaultValuenumberOptional value assigned to each conversion, in account currency
curl "https://affiliateo.com/api/v1/businesses/acme/ads/custom-conversions?network=meta" \ --header 'Authorization: Bearer afk_...'
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
}'curl -X DELETE "https://affiliateo.com/api/v1/businesses/acme/ads/custom-conversions/120210000777" \ --header 'Authorization: Bearer afk_...'
{ "conversionId": "120210000777", "network": "meta" }Ad previews
/api/v1/businesses/{slug}/ads/previewsMeta'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
adIdstringRequired. The ad to preview
formatstringMOBILE_FEED_STANDARD (default), DESKTOP_FEED_STANDARD, INSTAGRAM_STANDARD, INSTAGRAM_STORY, INSTAGRAM_REELS, FACEBOOK_STORY_MOBILE, FACEBOOK_REELS_MOBILE, RIGHT_COLUMN_STANDARD
curl "https://affiliateo.com/api/v1/businesses/acme/ads/previews?adId=120210000002&format=MOBILE_FEED_STANDARD" \ --header 'Authorization: Bearer afk_...'
{
"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
/api/v1/businesses/{slug}/ads/delivery-estimateHow 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
countriesstringRequired. Comma-separated ISO-2 codes, e.g. US,CA
ageMin / ageMaxnumber18-65, defaults 18/65
genderstring'all' (default) | 'men' | 'women'
interestIdsstringOptional comma-separated interest ids from targeting search
optimizationGoalstringLINK_CLICKS (default), REACH, IMPRESSIONS, LANDING_PAGE_VIEWS, OFFSITE_CONVERSIONS, LEAD_GENERATION
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_...'
{
"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
}Targeting search
/api/v1/businesses/{slug}/ads/targeting-searchOne 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
networkstringmeta (default) | apple_search_ads | tiktok | google
typestringMeta: interest | location. Apple: app | geo. TikTok: region. Google: geo | language
qstringRequired. Search text, min 2 characters (3 for Apple)
countrystringApple geo only: ISO-2 country, default US
entitystringApple geo only: 'AdminArea' or 'Locality' (default)
curl "https://affiliateo.com/api/v1/businesses/acme/ads/targeting-search?type=interest&q=fitness" \ --header 'Authorization: Bearer afk_...'
curl "https://affiliateo.com/api/v1/businesses/acme/ads/targeting-search?network=apple_search_ads&type=app&q=habit" \ --header 'Authorization: Bearer afk_...'
curl "https://affiliateo.com/api/v1/businesses/acme/ads/targeting-search?network=tiktok&type=region&q=united" \ --header 'Authorization: Bearer afk_...'
{
"results": [
{
"id": "6003139266461",
"name": "Physical fitness",
"path": ["Interests", "Fitness and wellness"],
"audienceLowerBound": 41000000,
"audienceUpperBound": 48000000
}
]
}{
"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
/api/v1/businesses/{slug}/ads?network=tiktokSame parameters and response shape as Meta's Ad stats & ROAS, with network=tiktok.
curl "https://affiliateo.com/api/v1/businesses/acme/ads?network=tiktok" \ --header 'Authorization: Bearer afk_...'
Audience breakdowns
/api/v1/businesses/{slug}/ads/breakdowns?network=tiktokWho 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-DDWindow. Default: last 30 days
curl "https://affiliateo.com/api/v1/businesses/acme/ads/breakdowns?network=tiktok&dimension=age" \ --header 'Authorization: Bearer afk_...'
{
"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
/api/v1/businesses/{slug}/ads/sync?network=tiktokOn-demand refresh of TikTok reporting.
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/sync?network=tiktok" \ --header 'Authorization: Bearer afk_...'
Launch campaigns (pending approval)
/api/v1/businesses/{slug}/ads/campaigns?network=tiktokThe 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
campaignNamestringRequired. Campaign display name
objectivestring'traffic' (default) or 'conversions' (needs adgroup.pixelId, optionally adgroup.optimizationEvent)
adgroup.dailyBudgetCentsnumberRequired. 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.bidCentsnumberOptional manual bid
ad.identityType / identityIdstringRequired. '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.videoIdstringSINGLE_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 / displayNamestringAd copy, call to action (LEARN_MORE default), advertiser display name
ad.landingPageUrlstringRequired. https:// landing URL
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"
}
}'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
}'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
curl "https://affiliateo.com/api/v1/businesses/acme/ads/pixel?network=tiktok" \ --header 'Authorization: Bearer afk_...'
{
"campaignId": "1779000000000000000",
"adGroupId": "1780000000000000000",
"adId": "1781000000000000000",
"network": "tiktok",
"status": "PAUSED",
"note": "Created paused. PATCH the campaign ACTIVE to start spending."
}Identities (pending approval)
/api/v1/businesses/{slug}/ads/identities?network=tiktokWho 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.
curl "https://affiliateo.com/api/v1/businesses/acme/ads/identities?network=tiktok" \ --header 'Authorization: Bearer afk_...'
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"
}'{
"network": "tiktok",
"identities": [
{ "identityId": "7210000000000000000", "identityType": "TT_USER", "displayName": "@acme" }
]
}Balances (pending approval)
/api/v1/businesses/{slug}/ads/balance?network=tiktokAd-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.
curl "https://affiliateo.com/api/v1/businesses/acme/ads/balance?network=tiktok" \ --header 'Authorization: Bearer afk_...'
{
"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
/api/v1/businesses/{slug}/ads?network=apple_search_adsSame 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.
curl "https://affiliateo.com/api/v1/businesses/acme/ads?network=apple_search_ads" \ --header 'Authorization: Bearer afk_...'
Campaign tree
/api/v1/businesses/{slug}/ads/campaigns?network=apple_search_adsThe 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.
curl "https://affiliateo.com/api/v1/businesses/acme/ads/campaigns?network=apple_search_ads" \ --header 'Authorization: Bearer afk_...'
{
"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
/api/v1/businesses/{slug}/ads/campaigns?network=apple_search_adsCreates 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
namestringRequired. Campaign display name
adamIdnumberRequired. The App Store app id, from targeting search type=app
countriesOrRegionsstring[]Required. ISO-2 storefronts, e.g. ["US"]
dailyBudgetCentsnumberRequired daily budget
supplySourcestringAPPSTORE_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)
targetCpaCentsnumberMAX_CONVERSIONS only: required target cost per conversion
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"
}'{
"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
/api/v1/businesses/{slug}/ads/adgroups?network=apple_search_adsCreates 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
campaignIdstringRequired. The owning campaign
namestringRequired. Ad group display name
defaultBidCentsnumberRequired (ignored with automated: true). Default bid keywords inherit
searchMatchbooleanLet Apple auto-match the ad to relevant searches
automatedbooleanCreate the automated ad group a MAX_CONVERSIONS campaign requires
targeting.ageMin / ageMaxnumberOptional 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
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"
}
}
}'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
}'{
"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
/api/v1/businesses/{slug}/ads/entities/{id}?network=apple_search_adsUpdates one entity: campaign status/budget, ad group status/default bid, or keyword status/bid. Requires a read & write key.
Body
kindstringRequired. 'campaign' | 'adgroup' | 'keyword'
campaignIdstringRequired. The owning campaign id
adGroupIdstringRequired for keyword updates
statusstring'ACTIVE' or 'PAUSED'
dailyBudgetCentsnumberCampaigns only, min 100
bidCentsnumberAd groups (default bid) and keywords (keyword bid), min 1
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
}'{ "ok": true, "network": "apple_search_ads", "id": "900000003", "kind": "keyword" }Targeting keywords
/api/v1/businesses/{slug}/ads/keywordsBulk 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)
campaignIdstringRequired
adGroupIdstringRequired
keywordsarrayPOST only: [{ text, matchType: 'EXACT' | 'BROAD', bidCents? }]
updatesarrayPATCH only: [{ id, status?, bidCents? }]
idsstring[]DELETE only: keyword ids to remove
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
}
]
}'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
}
]
}'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"
]
}'{
"ok": true,
"keywords": [
{ "id": "900000789", "text": "habit tracker", "matchType": "EXACT" }
]
}Negative keywords
/api/v1/businesses/{slug}/ads/negative-keywordsGET 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)
campaignIdstringRequired
adGroupIdstringOptional scope. Omit = campaign-wide
keywordsarrayPOST only: [{ text, matchType: 'EXACT' | 'BROAD' }]
updatesarrayPATCH only: [{ id, status: 'ACTIVE' | 'PAUSED' }]
idsstring[]DELETE only: negative keyword ids to remove
curl "https://affiliateo.com/api/v1/businesses/acme/ads/negative-keywords?campaignId=900000001" \ --header 'Authorization: Bearer afk_...'
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"
}
]
}'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"
}
]
}'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"
]
}'{
"negatives": [
{ "id": "900000099", "campaignId": "900000001", "adGroupId": null, "text": "free", "matchType": "BROAD", "status": "ACTIVE" }
]
}Bid recommendations
/api/v1/businesses/{slug}/ads/keyword-recommendationsApple'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
campaignIdstringRequired. The campaign whose keywords to score
curl "https://affiliateo.com/api/v1/businesses/acme/ads/keyword-recommendations?campaignId=900000010" \ --header 'Authorization: Bearer afk_...'
{
"campaignId": "900000010",
"recommendations": [
{
"keywordId": "900000789",
"text": "habit tracker",
"matchType": "EXACT",
"adGroupId": "900000020",
"suggestedBidCents": 180,
"currency": "USD"
}
]
}Sync stats
/api/v1/businesses/{slug}/ads/sync?network=apple_search_adsOn-demand refresh of Apple Search Ads reporting.
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
/api/v1/businesses/{slug}/ads?network=googleSame 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.
curl "https://affiliateo.com/api/v1/businesses/acme/ads?network=google" \ --header 'Authorization: Bearer afk_...'
Campaign tree
/api/v1/businesses/{slug}/ads/campaigns?network=googleThe 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
customerIdstringOptional. One of the connection's ad account ids. Default: the first account
curl "https://affiliateo.com/api/v1/businesses/acme/ads/campaigns?network=google" \ --header 'Authorization: Bearer afk_...'
{
"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
/api/v1/businesses/{slug}/ads/campaigns?network=googleCreates 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
campaignNamestringRequired. Campaign display name, max 255 chars, no line breaks
dailyBudgetCentsnumberRequired, min 100 (= 1.00 in the account currency)
bidding.strategystring'maximize_clicks' (default) | 'maximize_conversions' | 'maximize_conversion_value' | 'manual_cpc'
bidding.targetCpaCentsnumbermaximize_conversions only: optional target cost per conversion
bidding.targetRoasnumbermaximize_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.namestringOptional ad group display name
adGroup.cpcBidCentsnumbermanual_cpc only: the ad group default bid
ad.finalUrlstringRequired. https:// landing URL
ad.headlinesstring[]3-15 headlines, max 30 chars each
ad.descriptionsstring[]2-4 descriptions, max 90 chars each
ad.path1 / path2stringOptional display-path segments, max 15 chars each; path2 requires path1
keywordsarrayRequired, at least 1: [{ text (max 80 chars / 10 words), matchType: 'EXACT' | 'PHRASE' | 'BROAD', cpcBidCents? }]
negativeKeywordsarrayOptional: [{ text, matchType }] blocked at the campaign level from day one
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"
}
]
}'{
"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
/api/v1/businesses/{slug}/ads/entities/{id}?network=googleUpdates 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
kindstringRequired. 'campaign' | 'adgroup' | 'ad' | 'keyword'
statusstring'ENABLED' or 'PAUSED'
dailyBudgetCentsnumberCampaigns only
cpcBidCentsnumberAd groups (default bid) and keywords (keyword bid)
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"
}'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
}'{ "ok": true, "network": "google", "id": "20000000001", "kind": "campaign" }Negative keywords
/api/v1/businesses/{slug}/ads/negative-keywords?network=googleCampaign-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)
campaignIdstringRequired (also the required GET query parameter)
keywordsarrayPOST only: [{ text, matchType: 'EXACT' | 'PHRASE' | 'BROAD' }]
idsstring[]DELETE only: composite campaignId~criterionId ids from GET
curl "https://affiliateo.com/api/v1/businesses/acme/ads/negative-keywords?network=google&campaignId=20000000001" \ --header 'Authorization: Bearer afk_...'
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"
}
]
}'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"
]
}'{
"negatives": [
{ "criterionId": "60000000001", "campaignId": "20000000001", "text": "free", "matchType": "BROAD" }
]
}Keyword ideas
/api/v1/businesses/{slug}/ads/keyword-recommendations?network=googleKeyword 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
qstringComma-separated seed keywords (q and/or pageUrl required)
pageUrlstringA https:// page to extract ideas from (q and/or pageUrl required)
geostringOptional comma-separated geo target ids, e.g. 2840,21167
languagestringOptional language id, e.g. 1000 (English)
curl "https://affiliateo.com/api/v1/businesses/acme/ads/keyword-recommendations?network=google&q=habit%20tracker,goal%20app" \ --header 'Authorization: Bearer afk_...'
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_...'
{
"ideas": [
{
"text": "habit tracker app",
"avgMonthlySearches": 27100,
"competition": "HIGH",
"lowTopOfPageBidCents": 92,
"highTopOfPageBidCents": 341
}
]
}Targeting search
/api/v1/businesses/{slug}/ads/targeting-search?network=googleThe 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'
qstringRequired. Search text
countrystringgeo only: optional ISO-2 filter, e.g. US
curl "https://affiliateo.com/api/v1/businesses/acme/ads/targeting-search?network=google&type=geo&q=texas&country=US" \ --header 'Authorization: Bearer afk_...'
curl "https://affiliateo.com/api/v1/businesses/acme/ads/targeting-search?network=google&type=language&q=eng" \ --header 'Authorization: Bearer afk_...'
{
"results": [
{
"id": "21167",
"name": "Texas",
"canonicalName": "Texas,United States",
"countryCode": "US",
"targetType": "State",
"reach": 24800000
}
]
}{
"results": [
{ "id": "1000", "code": "en", "name": "English" }
]
}Recommendations
/api/v1/businesses/{slug}/ads/recommendations?network=googleGoogle'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)
actionstringRequired. 'apply' or 'dismiss'
resourceNamestringRequired. The recommendation resourceName from GET
curl "https://affiliateo.com/api/v1/businesses/acme/ads/recommendations?network=google" \ --header 'Authorization: Bearer afk_...'
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"
}'{
"recommendations": [
{
"resourceName": "customers/1234567890/recommendations/abc123",
"type": "CAMPAIGN_BUDGET",
"campaignId": "20000000001",
"dismissed": false
}
]
}Conversion tracking
/api/v1/businesses/{slug}/ads/pixel?network=googleGoogle'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).
curl "https://affiliateo.com/api/v1/businesses/acme/ads/pixel?network=google" \ --header 'Authorization: Bearer afk_...'
curl -X POST "https://affiliateo.com/api/v1/businesses/acme/ads/pixel?network=google" \ --header 'Authorization: Bearer afk_...'
{
"conversion_action": "customers/1234567890/conversionActions/987654321",
"conversion_customer_id": "1234567890",
"conversion_tracking_status": "CONVERSION_TRACKING_MANAGED_BY_SELF"
}Sync stats
/api/v1/businesses/{slug}/ads/sync?network=googleOn-demand refresh of Google Ads reporting. Synced levels: campaign, ad group, keyword, and ad.
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.