Waverunner API v1
Program the same always-on product your team uses in the app. REST API v1: JSON in, JSON out, bearer API key. Agents can use the same operations over MCP, including email signup and wallet checkout.
Authentication
Create an API key in Settings → API keys (shown once; no required prefix) and send it as a bearer token. MCP signup_poll also returns a key once after email verification. See the MCP server.
curl https://waverunner.adwave.com/api/v1/campaigns \ -H "Authorization: Bearer YOUR_API_KEY"
Keys are scoped to the organization that was active when you created them (switch workspaces in the sidebar first if you need a key for another organization). There is no required key prefix. Not sure which organization a key is bound to? GET /api/v1/businesses lists that organization's businesses. An unexpected or empty list means the key belongs to a different workspace than you intended. Reads are limited to 120 requests/minute, writes to 30/minute; 429 means slow down. Errors are JSON: { "error": "..." }. A 402 from launch includes nextTool: "create_wallet_checkout". Building with AI agents? The same operations are available over the MCP server.
Example: from URL to live campaign
The whole flow is five calls. Add a business and let the analysis run:
curl -X POST https://waverunner.adwave.com/api/v1/businesses \
-H "Authorization: Bearer YOUR_API_KEY" -H "Content-Type: application/json" \
-d '{ "url": "acme-coffee.com" }'
# → 201 { "business": { "id": "018f3a2b-...", "status": "pending" } }
# Poll until "ready" (a minute or two), then review profile + personas:
curl https://waverunner.adwave.com/api/v1/businesses/018f3a2b-... \
-H "Authorization: Bearer YOUR_API_KEY"Create a draft campaign. Autopilot builds a recommended creative plan after interest targeting is planned (response has generationDeferred: true), reusing ready creatives and generating only the gaps. Quote those gaps with campaignId once lines exist, check the wallet, and launch:
curl -X POST https://waverunner.adwave.com/api/v1/campaigns \
-H "Authorization: Bearer YOUR_API_KEY" -H "Content-Type: application/json" \
-d '{
"businessId": "018f3a2b-...",
"name": "Summer Sale",
"objective": "sales",
"dailyBudgetCents": 5000,
"offer": "Free shipping over $40"
}'
# → 201 { "campaign": { "id": "018f3c91-...", "status": "draft" },
# "campaignAdsEnqueued": false, "generationDeferred": true }
# After interest lines are planned, quote statics × lines + videos:
curl "https://waverunner.adwave.com/api/v1/creatives/quote?businessId=018f3a2b-...&campaignId=018f3c91-..." \
-H "Authorization: Bearer YOUR_API_KEY"
# → { "quote": { "staticCount", "interestLineCount", ... }, "campaignId": "..." }
curl https://waverunner.adwave.com/api/v1/wallet \
-H "Authorization: Bearer YOUR_API_KEY"
# → { "wallet": { "balanceCents": 25000, "autoRefillEnabled": false } }
# If launch returns 402, fund the wallet then retry. Open url in a browser.
# Never send card numbers to this API.
curl -X POST https://waverunner.adwave.com/api/v1/wallet/checkout \
-H "Authorization: Bearer YOUR_API_KEY" -H "Content-Type: application/json" \
-d '{ "amountCents": 5000, "method": "card" }'
# → { "url": "https://checkout.stripe.com/...", "sessionId": "cs_..." }
curl -X POST https://waverunner.adwave.com/api/v1/campaigns/018f3c91-.../launch \
-H "Authorization: Bearer YOUR_API_KEY"
# → { "launched": true, "chargedDate": "2026-07-06" }From then on: GET …/report for performance, PATCH for budget changes, and POST …/lifecycle to pause, resume, or end.
Endpoints
Grouped by resource. Every live v1 method is listed; the catalog is pinned to the route files so this page cannot drift.
- Businesses
- Personas
- Creatives
- Campaigns
- Audiences
- Tracking
- Wallet
- Ads
- Catalog Video
- Integrations
- Recommendations
- Organization
- Agency
Businesses
GET/api/v1/businesses
List businesses in your organization.
{ "businesses": [{ "id", "url", "name", "status", "createdAt" }] }POST/api/v1/businesses
Add a business by website URL. Kicks off analysis (scrape → profile → personas); poll the list until status is "ready". Agency parent keys cannot add a website (use a client workspace).
body: { "url": "https://example.com" }201 { "business": { "id", "status": "pending" } }GET/api/v1/businesses/{id}
Business detail: the extracted profile, restrictedContent (system locks, attestation, political disclosure), and every persona on the business profile with its id, Persona Reach (`targetingSeeds`: interest and demographic catalog keys), and persona-scoped demographic pins/exclusions. Pass persona ids to campaigns as icpIds (only selected personas are valid targets). After interest targeting is planned, that Reach is copied into the campaign's own flight targeting; persona seeds stay as written on the Creative profile.
{ "business": { "id", "url", "name", "status", "profile": {...}, "restrictedContent": { "systemLocks", "activeLocks", "userCategories", "confirmedLockIds", "attestedAt?", "paidForBy?", ... } | null, "personas": [{ "id", "name", "description", "selected", "targetingSeeds": { "status", "interestKeys", "demographicKeys", ... } | null, "demographicPins": string[], "demographicExclusions": string[], ... }] } }PATCH/api/v1/businesses/{id}/restricted-content
Update tenant-writable restricted-content fields: confirm system locks, attest categories (or none), and political disclosure (paidForBy, politicalScope, usBasedFundingAttested). System locks cannot be cleared (409 restricted_content_locked). Restrictive upgrades are refused while a campaign is launching, live, or paused (409 restricted_content_busy).
body: { "confirmLocks"?: true, "attest"?: true, "userCategories"?: string[], "confirmedLockIds"?: string[], "paidForBy"?: string | null, "politicalScope"?: "federal"|"state_local"|"issue"|null, "usBasedFundingAttested"?: boolean | null }{ "ok": true, "restrictedContent": { ... } }POST/api/v1/businesses/{id}/archive
Archive a business that has no live or launching campaigns. Hidden from the default list; can be unarchived.
{ "ok": true, "status": "archived" }POST/api/v1/businesses/{id}/unarchive
Restore an archived business to active use.
{ "ok": true, "status": "ready" }GET/api/v1/businesses/{id}/ltv
Customer lifetime value for one business over the trailing 365 days: identified customers, total revenue, average LTV, quartile thresholds, and acquisition-month payback cohorts (customers, revenue accumulated to date, revenue per customer).
{ "ltv": { "customers", "totalRevenueCents", "avgLtvCents", "p25Cents", "p50Cents", "p75Cents", "cohorts": [{ "month", "customers", "revenueCents", "revenuePerCustomerCents" }] } }Personas
POST/api/v1/personas
Create a persona from free-text notes for a business (enqueues enrichment).
body: { "businessId", "notes": "..." }202 { "personaId", "enqueued": true }PATCH/api/v1/personas/{id}
Partial update of a persona: name, description, demographics, motivations, pain points, channels (optional, machine-managed), selected flag, and/or demographicPins / demographicExclusions (catalog segment keys; pins capped at 3, must be eligible demographic filters).
body: { "name"?, "description"?, "selected"?, "demographicPins"?: string[], "demographicExclusions"?: string[], ... }{ "persona": { "id", "name", "selected", "demographicPins", "demographicExclusions", ... } }Creatives
GET/api/v1/creatives?businessId={id}&cursor={c}&limit={n}
List creatives for the business with their static variants and video ads (businessId optional; generated in campaign setup or on prior flights). Paginated: limit default 20, max 100. Distinct from a campaign's Ads set at GET /api/v1/campaigns/{id}/ads.
{ "creatives": [{ "id", "businessId", "status", "variants": [...], "videos": [...] }], "nextCursor" }GET/api/v1/creatives/quote?businessId={id}&scope={launch|all}&includeVideos={true|false}&icpId={uuid}&campaignId={uuid}
Pre-flight price for a business's ad generation batch: the per-unit breakdown, the resolved billing mode (waived = recorded free, charged = wallet debit), and remaining wallet funds. Defaults to the Launch pack (scope=launch: one persona + statics + social + TV). Pass campaignId for a campaign-scoped quote of the recommended creative plan gaps: statics only for interest lines that need generation across targeted personas, plus any missing video units (response includes interestLineCount; scope recommended_gaps). When interest lines are still planning, the campaign quote returns totalCents 0 with deferred: true (charge runs after interest targeting is planned). Without campaignId, library generate uses ICP-wide statics. Pass scope=all for every selected persona; includeVideos only applies then. The same planner prices the quote and the run, so the number is exact.
{ "quote": { "totalCents", "staticCount", "videoCount", "interestLineCount"?, "units": [...] }, "billing": { "mode" }, "allowanceCents", "scope", "campaignId"? }POST/api/v1/creatives/generate
Charge and enqueue a library ad generation batch for a ready business (wallet; starter credit applies). Quote first with GET /creatives/quote. 402 when funds are short.
body: { "businessId", "scope"?: "launch"|"all", "includeVideos"?: boolean, "icpId"?: "uuid", "instructions"?: "..." }202 { "creativeId", "enqueued": true }Campaigns
GET/api/v1/campaigns
List campaigns.
{ "campaigns": [{ "id", "name", "objective", "status", "dailyBudgetCents", "createdAt" }] }POST/api/v1/campaigns
Create a draft campaign. Optional geo (national | states | radius | zips | dmas | cities; preferred; national/states/radius/dmas may include exclude lists of states, dmas, cities, zips), states (US back-compat), platforms (named ids e.g. google_ads, meta, reddit, google_search; a present array is authoritative and does not re-add open inventory), inventorySurfaces (mobile-web, mobile-apps, tv), playbook (follow-journey | open-mix), followSpend (omitted stamps on; pass false to opt out), prospectSpend (omitted stamps on), winback (omitted stamps on), landingUrl, audienceBoost (needs a customer list), and measureLift for geo control. Omitted geo defaults from the business service area (or full US); omitted platforms to all enabled channels. After interest targeting is planned, Autopilot builds a recommended creative plan: reuse ready library creatives where they fit, and generate only the gaps for every targeted persona. Create does not charge generation in the create transaction: response has generationDeferred: true when ads were requested (default campaignAds: true). Pass campaignAds: false for a reuse-only plan with no generation charge. Autopilot launch starts ads if none were requested yet; BYO, Catalog Video, and prepaid still need a servable ad per targeted persona. icpIds narrows which personas the campaign selects (generation does not change that selection); omit for the lean default (1 persona under $20/day, else 2). startDate defaults to today; omit endDate to run open-ended (dated flights max 365 days). 409 business_archived when the business is archived. 422 agency_parent when the key is on the agency workspace (create on a client). After launch, each channel moves from Preparing, to Starting, to Live.
body: { "businessId", "name", "objective": "awareness|traffic|leads|calls|visits|bookings|sales", "dailyBudgetCents": 3000..1000000, "startDate"?: "YYYY-MM-DD", "endDate"?: "YYYY-MM-DD", "landingUrl"?: "https://...", "geo"?: { "version": 2, "mode": "national", "exclude": { "states": ["NJ"], "dmaCodes": ["501"], "zips": ["75201"] } }, "states"?: ["CA", ...], "platforms"?: [...], "inventorySurfaces"?: ["mobile-web"|"mobile-apps"|"tv"], "playbook"?: "follow-journey"|"open-mix", "followSpend"?: true, "prospectSpend"?: true, "winback"?: true, "icpIds"?: [...], "offer"?: "20% off first order", "offerExpiresAt"?: "2026-12-31T23:59:59Z (ISO datetime; requires offer; new creative stops including the offer after this instant)", "instructions"?: "Playful tone, show real people", "campaignAds"?: true, "measureLift"?: false, "audienceBoost"?: false }201 { "campaign": { "id", "status": "draft" }, "campaignAdsEnqueued": false, "generationDeferred": true }GET/api/v1/campaigns/{id}
Campaign detail: setup, targeting, Autopilot mode, spend legs (Prospect, Follow, Boost, Win-back), playbook, and status.
{ "campaign": { "id", "name", "objective", "status", "dailyBudgetCents", "startDate", "endDate", "landingUrl", "states", "geo", "platforms", "inventorySurfaces", "autopilotMode", "followSpend", "prospectSpend", "winback", "playbook", "beeswaxAudioEnabled", "audienceBoost", "holdoutStates", "measureLift", "icpIds", "budgetMix", "offer", "offerExpiresAt", "instructions", "createdAt" } }POST/api/v1/campaigns/{id}/launch
Launch a draft. When ads are already ready, charges the first day and distributes. Autopilot starts ads at launch if none were requested yet, then commits with awaitingCreatives (chargedDate null) and charges only when creatives are ready. Same deferred commit when a plan or sample is already rendering. 402 when the wallet can't cover day one (body includes nextTool create_wallet_checkout); 409 when there is still nothing to serve (BYO, Catalog Video, or a prepaid full-set with no finished ad; Autopilot also 409s if ads cannot start or generation already failed).
{ "launched": true, "chargedDate": "YYYY-MM-DD"|null, "awaitingCreatives"?: true }POST/api/v1/campaigns/{id}/cancel-launch
Cancel a launch that is still waiting on creatives (no day charge yet). Returns the campaign to draft. 409 when the campaign is not awaiting creatives or already charged.
{ "cancelled": true }PATCH/api/v1/campaigns/{id}
Edit a campaign: daily budget (draft/live/paused; no proration), name (any status except mid-launch; display-only), flight endDate (live/paused; JSON null clears), and/or landingUrl (live/paused refreshes serving ads; draft/error just updates the setup value). Geo (full v2 object, including optional exclude on national/states/radius/dmas) or states: draft/error updates setup; live/paused enqueues a geo push. Platforms: draft/error updates setup; live/paused enqueues campaign.update_platforms (add/remove/pause legs; remaining seats must still clear budget floors). followSpend, prospectSpend, and winback (draft/error setup or live/paused toggles). playbook (follow-journey | open-mix) is draft/error only. beeswaxAudioEnabled: live/paused uses the same Audio toggle as the UI; any value while Audio is not live returns 422 audio_unavailable. audienceBoost (stays off without a seeded customer list; use POST .../audience-boost for a loud failure instead), measureLift (recomputes the geo-holdout; GET echoes the same name when holdoutStates is non-empty), icpIds (draft/error: which personas this campaign targets; same core as the campaign setup UI). Autopilot mode: autopilotMode auto|manual (campaign-wide; manual = Guided; returning to auto requires autopilotReason). 409 once launching for some fields; 409 campaign_archived when the campaign is archived (restore it first); 409 guided_unavailable when campaign Guided control is not enabled. Optional Idempotency-Key header. Send at least one field.
body: { "name"?: "2..80 chars", "dailyBudgetCents"?: 3000..1000000, "endDate"?: "YYYY-MM-DD"|null, "landingUrl"?: "https://...", "geo"?: { "version": 2, "mode": "radius", "locations": [{ "businessLocationId": "uuid", "label": "HQ", "latitude": 30.27, "longitude": -97.74, "radiusMiles": 25 }], "exclude": { "zips": ["75201"] } }, "states"?: ["CA", ...], "platforms"?: [...], "inventorySurfaces"?: ["mobile-web"|"mobile-apps"|"tv"], "followSpend"?: true, "prospectSpend"?: true, "winback"?: true, "playbook"?: "follow-journey"|"open-mix", "beeswaxAudioEnabled"?: false, "icpIds"?: ["uuid", ...], "audienceBoost"?: true, "measureLift"?: true, "autopilotMode"?: "auto"|"manual", "autopilotReason"?: "…" }{ "updated": true, "name"?, "dailyBudgetCents"?, "placementsResplit"?, "endDate"?, "landingUrl"?, "versionedAdCount"?, "geo"?, "states"?, "platforms"?, "audienceBoost"?, "measureLift"?, "icpIds"?, "followSpend"?, "prospectSpend"?, "winback"?, "playbook"?, "beeswaxAudioEnabled"?, "autopilotMode"? }POST/api/v1/campaigns/{id}/audience-boost
Turn Audience boost on or off (draft, live, or paused). Boost only ever targets audiences seeded from this business's own imported customer list. Turning it on without an imported customer list returns 422 seed_required, it never silently no-ops. 409 campaign_archived when the campaign is archived. Optional Idempotency-Key header.
body: { "enabled": true|false }{ "updated": true, "enabled": true|false }PUT/api/v1/campaigns/{id}/budget-mix
Turn Your split on or off, or edit how each channel and persona shares daily media. Send the same cells GET campaign returns (platform, seat, persona, share). Shares are integers that must sum to 10000. Omit cells when enabling to snapshot the current split (live/paused) or even-split a draft. Returning to Autopilot requires reason (max 500). Autopilot will not move these dollars until the split is returned. 422 mix_floor names cells under a platform minimum. Optional Idempotency-Key header.
body: { "enabled": true|false, "cells"?: [{ "platform", "role", "icpId": "uuid"|null, "shareBps": 0..10000 }], "reason"?: "…" }{ "updated": true, "budgetMix": { "version": 1, "enabled", "cells" } }POST/api/v1/campaigns/{id}/retry
Retry a stalled campaign. `{"action":"launch"}` resets a failed campaign to draft and re-runs the launch gate (Autopilot starts ads if none were requested; may defer charge while ads render); 402 when the wallet can't cover day one, 409 no_servable_ads when there is still nothing to serve (BYO, Catalog Video, or a prepaid full-set with no finished ad; Autopilot also 409s if ads cannot start or generation already failed). `{"action":"failed_placements"}` re-runs distribution for a LIVE campaign's failed placements only (live ones are skipped); 409 when the campaign isn't live.
body: { "action": "launch"|"failed_placements" }{ "launched": true, "chargedDate": "YYYY-MM-DD"|null, "awaitingCreatives"?: true } | { "requeued": true }POST/api/v1/campaigns/{id}/lifecycle
Pause, resume, or end a campaign. Pausing stops future daily charges (today's stands, no proration); resuming re-charges today idempotently; ending is permanent. 202: the transition is applied by the platform workers asynchronously. 409 campaign_archived when the campaign is archived (restore it first).
body: { "action": "pause|resume|end" }202 { "requested": true, "action", "status" }POST/api/v1/campaigns/{id}/archive
Archive a campaign that is not launching or live (pause or end it first; 409 active otherwise). Hidden from the default list; launch and resume are blocked until it is restored. Ads, spend, and results are kept as a record.
{ "ok": true, "status": "archived" }POST/api/v1/campaigns/{id}/unarchive
Restore an archived campaign. Lists, launch, and resume come back; the status itself is unchanged.
{ "ok": true, "restored": true }DELETE/api/v1/campaigns/{id}
Permanently delete a draft campaign that never ran (no launch, no charges). Generated ads stay in the business Ad Library. 409 has_run once the campaign has run: archive it instead.
{ "ok": true, "deleted": true }GET/api/v1/campaigns/{id}/report
Performance report: conversions (click and view-through), sessions (ad visits from this campaign's ads), net spend, CPA, ROAS, plus per-placement and per-channel breakdowns. Funnel campaigns emit separate Mobile web, Mobile video, and TV rows (from line-item delivery); legacy non-funnel stays one open-inventory row labeled Mobile web and TV. Includes campaign-level and per-platform CPM breakout: `cpmCents` (all-in delivered spend per 1,000 views), `audienceFeeCpmCents` (typical catalog list-rate estimate for attached interest/demographic audiences: OR within each group, AND across; not a separate wallet charge), and `mediaCpmCents` (all-in minus list-rate, floored at 0). Null CPM means spend is not reported yet or there are no views. Per-platform rows also include honest channel fields: `clickableImpressions` / `viewOnlyImpressions` (CTR uses clickable only; null on pure TV/streaming), `reach` (null when the channel has no first-party impression pixel), `clickConversions` / `viewConversions`, `assistedConversions` (distinct union of same-campaign prior touches; reporting-only, not billed), `assistedViaClick` / `assistedViaHouseholdView` / `assistedViaPersonView` (path counts that may overlap; do not sum as the headline), `assistIdentityCoverage` (campaign-level; null when no visitor-tagged conversions), `conversionShare`, `sessions`, `cpaCents`, `roas`, `roles`, optional `seedImpact` (opaque follow-on outcome metrics; omit if null), and optional `deviceMix` (observed devices for that surface, not separate sold channels). `includesMobileVideo` is deprecated and no longer set (mobile video delivery reports as its own row). Omit inapplicable metrics in UIs rather than showing measured zeros. `platform` is an opaque channel id; render `platformLabel` when showing data to people. If the website's tracking tag isn't installed yet, `trackingInstalled` is false and `visitsSource` is "clicks": sessions mirror clicks and conversions can't be measured until the tag goes in. `visitsSource` "blended" means the campaign also ran before the tag was installed: `preTagClickVisits` clicks from those earlier days stay counted in sessions, and tagged sessions own the count from the install date on. `roas` is null whenever return can't be measured (no spend, no tag, or `valuesConfigured` false: no conversion has ever carried a value and no conversion rule has a per-conversion value); never a fake 0. `revenueIncludesEstimates` is true when revenue includes per-conversion values the user set on rules (render such ROAS as estimated).
{ "trackingInstalled", "visitsSource", "conversions", "sessions", "preTagClickVisits", "spendCents", "cpaCents", "valuesConfigured", "roas", "revenueIncludesEstimates", "cpmCents", "audienceFeeCpmCents", "mediaCpmCents", "assistIdentityCoverage", "byName": [...], "byPlacement": [{ "platform", "platformLabel", ... }], "byPlatform": [{ "platform", "platformLabel", "clickableImpressions", "viewOnlyImpressions", "reach", "ctr", "assistedConversions", "assistedViaClick", "assistedViaHouseholdView", "assistedViaPersonView", "conversionShare", "seedImpact", "deviceMix?", "cpmCents", ... }] }GET/api/v1/campaigns/{id}/analytics?days={1..90}
Deep breakdowns for optimization: daily funnel timeseries (impressions → clicks → ad visits via `sessions` → conversions), observed device/region mix (not separate ad channels), publishers where ads appeared (`deliverySources`: TV channels, web/apps, Google networks, Meta platforms, Reddit communities) for the selected days window, per-persona and per-targeting-segment performance, and the per-day money story (charged vs delivered vs credited). Default window 30 days. `sessions` here are ad visits from this campaign's ads (same meaning as the report).
{ "analytics": { "days", "trackingInstalled", "visitsSource", "timeseries": [{ "date", "impressions", "clicks", "sessions", "conversions" }], "deviceMix": [...], "topRegions": [...], "deliverySources": { "tvChannels": [...], "webApps": [...], "googleNetworks": [...], "metaPlatforms": [...], "redditCommunities": [...] }, "byIcp": [...], "segments": [...], "spendSeries": [{ "date", "chargedCents", "deliveredCents", "creditedCents" }] } }GET/api/v1/campaigns/{id}/lift
Lift scorecard: causal and directional truth checks, each with a 95% confidence interval and a significance flag: geo-holdout lift (conversions where ads ran vs deliberately dark control states), cross-channel overlap (households that saw the open-inventory ad and clicked another channel vs saw-only), per-channel within-pool lift (`channels`: TV via first-party surface resolution, follow platforms via click evidence, vs a shared pool-only baseline; always directional), organic halo, and Autopilot effectiveness (acted campaigns vs observe-only comparison, with CI). `holdoutInconclusive` flags when the holdout CI still includes no lift (directional only; holdout states are not reshuffled mid-flight). Null sections mean the check isn't available for this campaign; `significant: false` means directional, not proven.
{ "lift": { "holdout": { "lift", "ciLow", "ciHigh", "pValue", "significant", "matured", "coverageSufficient", ... } | null, "overlap": { "lift", "ciLow", "ciHigh", "significant", ... }, "channels": { "exposedHouseholds", "baselineHouseholds", "baselineConverted", "minCohortHouseholds", "rows": [{ "channel", "touchKind", "touchedHouseholds", "touchedConverted", "lift", "ciLow", "ciHigh", "pValue", "significant" }] }, "halo": { "lift", "ciLow", "ciHigh", "significant", ... } | null, "optimizer": { "date", "byKind": [...], "overall": { "lift", "ciLow", "ciHigh", "significant", ... } | null }, "holdoutInconclusive": { "source": "live|audit", "auditedAt" } | null } }GET/api/v1/campaigns/{id}/customers
Customer acquisition for a campaign (trailing 90 days). `customers` is new vs returning among attributed conversions (first-ever vs repeat buyers, cost per NEW customer). `introduced` is first-touch-new identities with no prior identity-graph presence before this campaign's first ad exposure (incremental by construction), plus introduced conversions and cost per introduced customer. Unmatched / unidentified conversions sit outside both splits.
{ "customers": { "identifiedConversions", "unidentifiedConversions", "newConversions", "newRevenueCents", "returningConversions", "returningRevenueCents", "spendCents", "costPerNewCustomerCents", "newShare" }, "introduced": { "introducedIdentities", "introducedConversions", "introducedRevenueCents", "priorPresenceConversions", "unmatchedConversions", "spendCents", "costPerIntroducedCustomerCents", "introducedShare" } }GET/api/v1/campaigns/{id}/ads
The campaign's Ads set (snapshot of ads selected to deliver) with per-ad lifetime performance: status, persona, funnel stage, creative label and preview, impressions, clicks, spend, first-party conversions, revenue. Distinct from the business creative library.
{ "ads": [{ "campaignAdId", "kind", "status", "icpName", "label", "impressions", "clicks", "spendCents", "conversions", "revenueCents", ... }] }PATCH/api/v1/campaigns/{id}/ads/{adId}
Per-ad serve switch on Ads: pause or resume one ad across every platform it serves on. On live or paused campaigns, Autopilot manages serving ads: include reasonCode (and optional note) so Autopilot can yield; without it returns 400 override_required. Resume with resume_optimizer_pause for Autopilot pauses and resume_user_pause for manual pauses (mismatched dedicated codes return 400 resume_reason_mismatch; other remains an escape hatch). Refuses to pause the last serving ad on this interest segment line (409 last_serving_ad) and refuses on archived campaigns (409 campaign_archived).
body: { "serve": true|false, "reasonCode"?: "poor_quality"|"off_message"|"resume_optimizer_pause"|"resume_user_pause"|"other"|..., "note"?: "..." }{ "updated": true, "serving": false }Audiences
GET/api/v1/audiences
First-party segments with live member counts (Website audiences), platform audiences (campaign reach, synced lists, lookalikes), connected customer sources, plus Customize targeting guides (interest and demographic catalog picks Autopilot always includes on the next launch). Persona Reach lives on each persona via get_business (`targetingSeeds`).
{ "segments": [...], "platformAudiences": [...], "customerSources": [...], "marketplaceSegments": [{ "businessId", "segmentKey", "name", ... }], "demographicFilters": [{ "businessId", "segmentKey", "name", "kind", "eligible", ... }] }POST/api/v1/audiences/segments
Create a custom first-party segment with a rule definition.
body: { "businessId", "name", "rule": {...} }201 { "segment": { "id", "name", ... } }PATCH/api/v1/audiences/segments/{id}
Activate or pause a segment (`active`: true|false).
body: { "active": true|false }{ "segment": { "id", "active", ... } }DELETE/api/v1/audiences/segments/{id}
Delete a custom (non-system) segment.
{ "ok": true }POST/api/v1/audiences/segments/{id}/refresh
Enqueue a re-evaluation of segment membership.
{ "ok": true, "enqueued": true }POST/api/v1/audiences/customize
Add or remove a Customize targeting guide. kind: "interest"|"demographic"; action: "add"|"remove".
body: { "businessId", "kind": "interest"|"demographic", "action": "add"|"remove", "segmentKey" }{ "ok": true }POST/api/v1/audiences/suggestions/{id}
Accept or dismiss an audience discovery suggestion. Accept creates a discovered segment with the proposed rule.
body: { "action": "accept"|"dismiss" }{ "ok": true, "status": "accepted"|"dismissed" }PATCH/api/v1/audiences/consent
Turn audience sync on or off for the organization (on by default; required for Meta/TikTok/Reddit reach and customer-list uploads).
body: { "enabled": true|false }{ "ok": true, "audienceSyncEnabled": true }POST/api/v1/audiences/csv
Import a customer CSV for a business (seeds Audience boost). Rate limited. Hashed at rest; raw emails are never returned.
body: { "businessId", "csvText": "email,phone\n..." }202 { "ok": true, "imported": number, "skipped": number }Tracking
GET/api/v1/tracking
Tracking setup for one website: the site tag snippet, server-side conversion webhook (POST JSON to conversionWebhook.url; the token is in the path, no bearer), conversion rules, and settings. Tags are per business: pass ?businessId= to pick a site (defaults to the oldest; other businesses are listed in the response).
{ "tracking": { "business", "snippet", "conversionWebhook": { "url", "fields", "example" }, "rules": [...], "settings": {...} }, "otherBusinesses": [] }POST/api/v1/tracking/rules
Create a conversion rule for a business. Intent-page URL patterns require acknowledgeIntentPage: true.
body: { "businessId", "name", "pattern", "trigger"?, "valueCents"?, "acknowledgeIntentPage"? }201 { "rule": { "id", "name", "pattern", "trigger", ... } }PATCH/api/v1/tracking/rules/{id}
Update a conversion rule's per-conversion value (cents).
body: { "valueCents": number|null }{ "rule": { "id", "valueCents", ... } }DELETE/api/v1/tracking/rules/{id}
Delete a conversion rule.
{ "ok": true }PATCH/api/v1/tracking/settings
Update tracking settings for a business (allowed origins, lead detection, etc.). Disabling recommended settings requires acknowledgeImpact: true.
body: { "businessId", "settings": {...}, "acknowledgeImpact"? }{ "settings": {...} }PATCH/api/v1/tracking/form-values
Set or clear the per-conversion value estimate for a named Form conversion (lead, signup, or subscribe). Creates a value-only carrier when needed; clearing with null removes the estimate. Applies to new conversions only.
body: { "businessId", "name": "lead"|"signup"|"subscribe", "valueCents": number|null }{ "ok": true, "businessId", "name", "valueCents" }POST/api/v1/tracking/suggestions/{id}
Accept or dismiss a conversion discovery suggestion. `{"action":"accept"}` creates or links a rule; `{"action":"dismiss"}` clears the card.
body: { "action": "accept"|"dismiss", "acknowledgeIntentPage"? }{ "ok": true, "status": "accepted"|"dismissed" }POST/api/v1/tracking/test
Send a test conversion event for a business (verifies the tag/webhook path).
body: { "businessId" }{ "ok": true }Wallet
GET/api/v1/wallet
Prepaid wallet balance (cents) and auto-refill state. Check before launching. Fund with POST /api/v1/wallet/checkout (or MCP create_wallet_checkout).
{ "wallet": { "balanceCents", "autoRefillEnabled" } }GET/api/v1/wallet/ledger?cursor={c}&limit={n}
Paginated wallet ledger (newest first). limit default 25, max 100.
{ "entries": [{ "id", "kind", "amountCents", "createdAt", ... }], "nextCursor" }POST/api/v1/wallet/checkout
Create a Stripe Checkout session to fund the prepaid wallet. Returns a hosted url; never send card data. amountCents is an integer between the same floor and ceiling as wallet top-up in the app ($30 to $50,000). method is card or ach.
body: { "amountCents", "method"?: "card"|"ach", "autoRefill"?: boolean }{ "url", "sessionId" }POST/api/v1/wallet/portal
Create a Stripe Customer Portal session so a human can manage saved payment methods. Returns a hosted url. 422 portal_not_configured if the billing portal is not enabled yet; use POST /api/v1/wallet/checkout instead.
{ "url" }Ads
GET/api/v1/ads/{kind}/{id}/revisions
Media revision history for one static, video, or audio creative (prior deliverable URLs and provenance). Distinct from campaign_ads serving version lineage on GET /campaigns/{id}/ads.
{ "entityKind", "entityId", "currentRevisionId", "revisions": [{ "id", "revision", "source", "createdAt", "imageUrl", "assetUrl", "bumperUrl", "mobileAssetUrl", "wavemakerCompositionId", ... }] }POST/api/v1/ads/{kind}/{id}/revisions/{revisionId}/restore
Promote a prior media revision to current (free; no creation charge). Live/paused campaign ads require rationale. Replaces DSP creatives when the ad is externalized. Returns 409 revision_conflict on concurrent tip change, 410 asset_missing when prior bytes are gone.
body: { "rationale"?: "string (1..500, required when entity backs live/paused campaign ads)", "expectedCurrentRevisionId"?: "uuid|null" }{ "ok": true, "currentRevisionId", "restoredRevisionId", "replacedCampaignAdIds": [] }GET/api/v1/ads/reviews
List ads waiting on manual approval for this organization. Empty when the org auto-approves. Approve with POST /api/v1/ads/reviews before those ads can serve. Optional push: PUT /api/v1/ads/reviews/webhook, or poll this endpoint.
{ "reviews": [{ "kind", "entityId", ... }] }POST/api/v1/ads/reviews
Approve one pending_review ad so it can serve. Pass kind (static, video, or audio) and entityId from GET /api/v1/ads/reviews. Does not launch a campaign; it only clears the approval hold on that ad.
body: { "kind": "static"|"video"|"audio", "entityId", "action": "approve" }{ "review": { ... } }GET/api/v1/ads/reviews/webhook
Show whether this organization has an ad-review webhook. Returns { configured, url }. Never returns the secret; PUT mints a new one.
{ "configured": false } | { "configured": true, "url" }PUT/api/v1/ads/reviews/webhook
Register an HTTPS URL (http on localhost only) to receive signed POSTs when ads need manual approval. Returns { url, secret } once. Store the secret; GET never shows it. Each call rotates the secret.
body: { "url" }{ "url", "secret" }DELETE/api/v1/ads/reviews/webhook
Stop sending ad-review webhooks. Clears the URL and secret.
{ "configured": false }Catalog Video
GET/api/v1/catalogs?businessId={id}
List Catalog Video product catalogs (optional businessId). Requires Catalog Video; otherwise returns 403.
{ "catalogs": [{ "id", "businessId", "name", "source", "status", "itemCount", ... }] }POST/api/v1/catalogs
Create a product catalog. source: "scrape"/"url" enqueue a sync; products appear once ingestion finishes (status starts "pending"). source: "csv" is parsed and inserted synchronously; status is "ready" immediately. Requires Catalog Video.
body: { "businessId", "source": "scrape|url", "sourceRef": "https://shop.example", "name"? }
or
{ "businessId", "source": "csv", "csvText": "sku,title,price,link,image_url\n...", "name"? }201 { "catalog": { "id", "status": "pending"|"ready", "itemCount" }, "importSummary"?: { ... } }GET/api/v1/catalogs/{id}/items?cursor={c}&limit={n}
List synced catalog products (SKU, title, price, image). Paginated: limit default 100, max 500; pass the response's nextCursor to fetch the next page.
{ "catalogId", "items": [{ "externalId", "title", "price", ... }], "nextCursor" }POST/api/v1/catalogs/{id}/items
Enqueue a catalog re-sync from its source.
{ "catalogId", "status": "syncing", "enqueued": true }POST/api/v1/catalog-video/from-url
Start the shop-URL → Catalog Video auto-pipeline for a business. Idempotent on (businessId, url). Requires Catalog Video.
body: { "businessId", "url", "topN"?: 1..10 }202 { "runId"?, "catalogId"?, ... }GET/api/v1/catalog-video/runs?businessId={id}&cursor={c}&limit={n}
List Catalog Video runs (optional businessId). Paginated: limit default 50, max 200; pass the response's nextCursor to fetch the next page.
{ "runs": [{ "id", "status", "completedCount", "totalCount", "error", "lastProgressAt", ... }], "nextCursor" }POST/api/v1/catalog-video/runs
Create a draft Catalog Video run linked to a catalog (optional template + selectedSkus). Requires Catalog Video.
body: { "catalogId", "templateId"?, "name"?, "selectedSkus"?, "aspectRatios"?, "defaultTier"?: "S|A|M|L|SWAP" }201 { "run": { "id", "status": "draft" } }GET/api/v1/catalog-video/runs/{id}
Catalog Video run detail with progress counters and status.
{ "run": { "id", "status", "completedCount", "totalCount", ... } }PATCH/api/v1/catalog-video/runs/{id}
Edit SKUs, aspect ratios, tier, sync cadence, refresh budget, new-item policy, or launch mode while draft/quoting/paused. Invalidates a prior quote.
body: { "selectedSkus"?, "aspectRatios"?, "defaultTier"?, ... }{ "run": { "id", "status", ... } }GET/api/v1/catalog-video/runs/{id}/quote?sample={true|false}&tranche={true|false}
Price a run from catalog SKUs at the default tier (S). sample=true quotes ~5 SKUs; tranche=true quotes the first chargeable chunk. Same planner as launch.
{ "quote": { "totalCents", "units": [...] }, "billing", "allowanceCents" }POST/api/v1/catalog-video/runs/{id}/launch
Charges the first tranche from the wallet (starter credit applies), inserts pending variants, sets status producing, enqueues production. Pass the quote's mode + quoteHash to guard against launching a stale quote (409 quote_mismatch on drift); omitting both auto-quotes once for mode=full. 402 when funds are short. Requires an approved template when the run is linked to one.
body: { "mode"?: "sample|full", "quoteHash"? }{ "launched": true, "status": "producing", "mode", "chargedUnits", "totalUnits" }POST/api/v1/catalog-video/runs/{id}/pause
Manually pause a producing/live Catalog Video run.
body: { "reason"? }{ "run": { "id", "status": "paused" } }POST/api/v1/catalog-video/runs/{id}/resume
Resume a paused Catalog Video run and re-arm remaining production work.
{ "run": { "id", "status": "producing"|"live" } }POST/api/v1/catalog-video/runs/{id}/archive
Archive a draft/failed/paused/live Catalog Video run. Terminal, non-refunding.
{ "run": { "id", "status": "archived" } }POST/api/v1/catalog-video/runs/{id}/retry
Clear the error on a failed Catalog Video run and resume it.
{ "run": { "id", "status": "producing" } }GET/api/v1/catalog-video/runs/{id}/manifest?cursor={c}&limit={n}
Per-variant CDN / VAST URLs from the local variant rows. Paginated: limit default 100, max 500; pass the response's nextCursor to fetch the next page.
{ "runId", "variants": [{ "sku", "status", "latestCdnUrl", ... }], "nextCursor" }GET/api/v1/catalog-video/runs/{id}/feeds
Platform supplemental feed URLs once published on the run.
{ "runId", "feeds": [{ "platform", "url" }] }GET/api/v1/catalog-video/runs/{id}/exports?format={json|google_video_link}
Export activation kit: JSON with Google video_link instructions + CSV rows, or raw CSV (format=google_video_link). Operator handoff: Shopping/PMax not automated.
{ "runId", "googleVideoLink": { "instructions", "supplementalFeedUrl", "csvRows" } }GET/api/v1/catalog-video/templates?businessId={id}
List Catalog Video templates (optional businessId). Requires Catalog Video.
{ "templates": [{ "id", "businessId", "catalogId", "status", "aspectRatio", "hookCount", ... }] }GET/api/v1/catalog-video/templates/{id}
Full detail for one template, including the pitched concept / probe entry.
{ "template": { "id", "status", "hookCount", "entry", "masterCompositionId", ... } }POST/api/v1/catalog-video/templates/pitch
Create a pitched template and enqueue the template pipeline (human-review start).
body: { "catalogId", "aspectRatio"?, "hookCount"?, "notes"? }201 { "template": { "id", "status": "pitched" } }POST/api/v1/catalog-video/templates/{id}/approve
Human approve a template: required before launching a run that uses it.
{ "template": { "id", "status": "approved" } }GET/api/v1/campaigns/{id}/catalog-video
List bindable Catalog Video runs for a campaign and its current binding. Requires Catalog Video.
{ "currentRunId", "runs": [...] }POST/api/v1/campaigns/{id}/catalog-video
Bind or clear a campaign's Catalog Video run for native serving (runId null clears). Requires Catalog Video.
body: { "runId": "uuid"|null }{ "ok": true, "catalogVideoRunId": "uuid"|null }Integrations
GET/api/v1/integrations?businessId={id}
List connected customer-data sources for the organization (optional businessId filter). Tokens are never returned. Use POST .../sync to re-pull.
{ "integrations": [{ "id", "businessId", "provider", "status", "lastSyncedAt", "error" }] }POST/api/v1/integrations/{id}/sync
Enqueue a sync for an already-connected integration. OAuth connect/disconnect stay in the web app. 409 when the source is disconnected.
202 { "enqueued": true }Recommendations
GET/api/v1/recommendations
Pending cross-campaign budget recommendations: moves suggested when automatic portfolio moves are off, each with both campaigns, the daily amount, and supporting evidence.
{ "recommendations": [{ "id", "businessId", "fromCampaignId", "fromCampaignName", "toCampaignId", "toCampaignName", "amountCents", "detail", "createdAt" }] }POST/api/v1/recommendations/{id}
Resolve one pending recommendation. `{"action":"apply"}` moves the daily budget through the same validated path as a manual budget edit (both campaign changes commit together or not at all, DSP pushes queued transactionally); `{"action":"dismiss"}` resolves it without moving money. Apply returns 409 when the campaigns' budgets changed since the move was computed.
{ "ok": true, "status": "accepted" | "dismissed" }Organization
PATCH/api/v1/organization
Update organization product settings. crossCampaignOptimization enables automatic portfolio budget moves.
body: { "crossCampaignOptimization": true|false }{ "organization": { "crossCampaignOptimization" } }GET/api/v1/export
Full organization data export as one JSON document (rate limited to 5/hour). Large payload; prefer specialized endpoints when you only need one resource.
{ "businesses": [...], "campaigns": [...], "ledgerEntries": [...], "creativeFeedback": [...], ... }Agency
GET/api/v1/agency/clients
List child client organizations for an agency API key. Scale license required. Refused for keys bound to a client org. experienceMode + billingMode are the stored pair (Managed = white_glove + license_passthrough; Self-serve = self_serve + platform_collect).
{ "clients": [{ "organizationId", "name", "slug", "experienceMode", "billingMode" }] }POST/api/v1/agency/clients
Create a child client organization under the agency.
body: { "name": "Client Cafe" }201 { "client": { "organizationId" } }GET/api/v1/agency/clients/{id}
Read one child client: stored experience/billing modes (Managed vs Self-serve pair), your per-client fee percent, monthly spend cap, digest mail routing, wallet balance, campaignPolicy (stored + inheritance-resolved defaults/locks), and the effective (inheritance-resolved) rate split. Prefer PATCH operatingMode to change the pair.
{ "client": { "organizationId", "name", "slug", "experienceMode", "billingMode", "partnerFeePercent", "monthlySpendCapCents", "clientDigestMail", "walletBalanceCents", "campaignPolicy": { "stored", "effective" }, "effective": { ... } } }PATCH/api/v1/agency/clients/{id}
Update one child client. Prefer operatingMode (managed | self_serve); that is the same packaged setter as the console. Legacy experienceMode + billingMode are accepted only as a legal Managed or Self-serve pair (422 illegal_combo otherwise). partnerFeePercent null inherits your agency default; monthlySpendCapCents null removes the cap; clientDigestMail true = client + agency, false = agency only, null = follow experience mode. campaignPolicy sets spend-leg and channel defaults/locks (null clears the child's stored policy). Only fields you pass change.
body: { "operatingMode"?: "managed"|"self_serve", "experienceMode"?, "billingMode"?, "partnerFeePercent"?: 0-99|null, "monthlySpendCapCents"?: cents|null, "clientDigestMail"?: true|false|null, "campaignPolicy"?: { "defaults"?, "locks"? }|null }{ "client": { ... } }POST/api/v1/agency/clients/{id}/offboard
Disable a child client: pause live campaigns and turn off that client's API keys. confirmName must match the client name. Does not delete history.
body: { "confirmName": "Client Cafe" }{ "already": false, "suspended": true }POST/api/v1/agency/clients/{id}/restore
Restore a disabled child client. confirmName must match the client name. Does not resume paused campaigns.
body: { "confirmName": "Client Cafe" }{ "already": false, "suspended": false }GET/api/v1/agency/clients/{id}/campaigns
List campaigns in a child client org. The id must be a child of the agency key's org.
{ "campaigns": [{ "id", "name", "objective", "status", "dailyBudgetCents", "createdAt" }] }GET/api/v1/agency/clients/{id}/campaigns/{campaignId}/report
Same performance report as GET /api/v1/campaigns/{id}/report, for a campaign that belongs to the named child client. Spend is pass-through media at cost.
{ "report": { ... } }GET/api/v1/agency/clients/{id}/campaigns/{campaignId}/analytics
Same analytics payload as GET /api/v1/campaigns/{id}/analytics, scoped to a child client's campaign.
{ "analytics": { ... } }GET/api/v1/agency/cost-report?from={YYYY-MM-DD}&to={YYYY-MM-DD}
CSV of pass-through media spend across child clients for agency finance. Scale operators only. Optional from/to filter on billed dates.
text/csv
Catalog Video
The Catalog Video flow is available under /api/v1/catalogs and /api/v1/catalog-video/*, and as snake_case tools on the MCP server (for example create_catalog, quote_catalog_video_run, launch_catalog_video_run). Launching accepts the quote's mode and quoteHash so a launch call can never silently drift from the quote you were shown. These endpoints require Catalog Video.
One call starts the whole from-URL pipeline: POST /api/v1/catalog-video/from-url with businessId, url, and optional topN (default 5). The run moves through syncing, product selection, persona generation, and pitching until it reaches awaiting_approval; poll GET /api/v1/catalog-video/runs/:id. Approving triggers a sample quote and advances the run to quoted (approving before the pitch finishes returns a clear error). If a run is already active for the business, the endpoint returns 409 active_run; a previously scraped catalog for the same URL is reused automatically.
Typical agent path: create_catalog → poll items ready → pitch_catalog_video_template → approve_catalog_video_template → create_catalog_video_run → quote_catalog_video_run (sample) → launch_catalog_video_run with mode + quoteHash → poll get_catalog_video_run → export_catalog_video_run. Optional segmentKeys (max 3) on create enables persona×SKU variants.
Ad reviews
If the organization requires manual approval, GET /api/v1/ads/reviews lists ads in pending_review. Approve with POST /api/v1/ads/reviews using kind, entityId, and action: "approve". That clears the hold so the ad can serve; it does not launch a campaign. The list is empty when the org auto-approves. MCP: list_ad_reviews / approve_ad.
To get a push instead of polling, PUT /api/v1/ads/reviews/webhook with { "url" }. HTTPS is required except on localhost. The response includes secret once; store it. GET returns configured and url only. Each PUT rotates the secret. DELETE clears both. MCP: get_ad_review_webhook / set_ad_review_webhook / clear_ad_review_webhook.
When ads need review we POST JSON { "type": "ad_review.pending", "campaignId", "campaignName", "pending" } with headers x-webhook-id, x-webhook-timestamp (unix seconds), and x-webhook-signature in the form v1,<hex>. Verify HMAC-SHA256 of id.timestamp.rawBody (three values joined by periods, using the exact JSON bytes we posted) with the secret, then compare the v1, hex with a timing-safe equality check. Reject deliveries more than 300 seconds off. A failing endpoint does not undo the review email; poll GET /api/v1/ads/reviews if a delivery is missed.
Conversion webhook
GET /api/v1/tracking returns a per-website conversionWebhook.url. POST JSON to that URL (the token is in the path; do not send a bearer key): name (required), optional value, currency, orderId, clickId, visitorId, email, phone. Email and phone are hashed, never stored raw. Same setup as MCP get_tracking_setup. Guided install copy lives on Tracking.
Errors and limits
Errors are JSON: { "error": "..." }. Common statuses: 401 missing or invalid key; 402 insufficient_funds (launch includes nextTool: "create_wallet_checkout"); 409 conflict (wrong status, nothing to serve, archived, byo_missing_serve_families); 422 invalid input, portal_not_configured, or agency illegal_combo; 429 rate limited (120 reads and 30 writes per minute per key). Optional Idempotency-Key on campaign edits and Audience boost.
Agency keys
Create the key while the agency workspace is active. Scale (or Enterprise) is required. That key can list, create, read, and update child clients, disable or restore a client, read each child's campaign report, and download a cost CSV of media spend at cost. Prefer operatingMode managed or self_serve on PATCH (same packaged setter as the console). A key created in a client workspace stays inside that client. There is no leads inbox on the API; use the campaign report's won count and revenue. Fund the wallet with POST /api/v1/wallet/checkout (hosted Stripe URL). Never send card numbers on the API. Disable pauses live campaigns and that client's keys; it does not delete history. Restore does not resume campaigns.
Billing model
Launched campaigns charge their daily budget from your prepaid wallet each day (US Eastern); pausing or ending stops future charges, and under-delivery reported by the ad platforms is credited back automatically. Ad creation through this API is metered per unit and charged to the same wallet (direct business signups receive $60 starter credit; agency workspaces skip it). In the product UI, campaign ads for businesses are included with the campaign. Price a batch up front with GET /api/v1/creatives/quote, and expect 402 with the quote attached when the wallet cannot cover it. Launch 402 points agents at wallet checkout. Failed renders refund their units automatically.
Catalog Video endpoints work when Catalog Video is on for the organization (the default). Launch charges the first production tranche the same way as other creation. Quote with /catalog-video/runs/{id}/quote first.