Browse help topics

API & MCP

Adwave API v1

Use the Adwave REST API to manage businesses, campaigns, ads, wallet funding, performance reports, and agency clients.

Connect to the workspace you intend to manage
  1. 01Your integration

    Use a script, service, or AI assistant.

  2. 02Authenticate

    Use a workspace API key or supported OAuth connection.

  3. 03Adwave

    Work with that workspace’s businesses, campaigns, and wallet.

Launching, creating paid ads, and changing budgets can spend wallet funds. Check costs before approving those actions.

On this page

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 belong to the organization that was active when you created them. Switch workspaces in the sidebar before creating a key for another organization. To check its scope, call GET /api/v1/businesses and review the businesses returned.

Standard limits are 120 reads and 30 writes per minute, shared by keys belonging to the same user. Some operations have lower limits. A 429 response means you should wait before retrying. Errors return JSON with an error field. A 402 from launch includes nextTool: "create_wallet_checkout". The same operations are available to agents through MCP.

Example: from URL to live campaign

Add a business, wait for analysis, then create and launch a campaign. The examples below show abbreviated response shapes; replace sample IDs with the IDs your requests return.

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 + buyer profiles:
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. amountCents is what the wallet is
# credited; card checkout adds the card processing fee on top, ACH does not.
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": "YYYY-MM-DD" }
# If ads are still rendering, the response can instead contain:
# { "launched": true, "chargedDate": null, "awaitingCreatives": true }
# The first media charge waits until ads are ready.

From then on: GET …/report for performance, PATCH for budget changes, and POST …/lifecycle to pause, resume, or end. Add ?dryRun=1 to POST …/launch to see every launch check, requirement, and wallet coverage as pass/fail without charging anything, and use POST /campaigns/bulk-lifecycle to pause, resume, or end many campaigns of one business in one call.

Endpoints

The reference below lists 107 REST methods by resource. Request and response shapes use abbreviated notation: optional fields have ?, alternatives use |, and ... means additional fields. These shapes describe the contract; they are not all executable JSON.

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, or create it manually with a name, category, description, and offerings. A website starts analysis; poll until status is ready. A manual business is ready immediately. Agency parent keys must use a client workspace.

body (website): { "url": "https://example.com" }
body (manual): { "name": "Acme Coffee", "category": "Coffee shop", "description": "A neighborhood coffee shop", "offerings": ["Coffee", "Pastries"] }
201 { "business": { "id", "status": "pending"|"ready" } }

GET /api/v1/businesses/{id}

Business detail: the extracted profile, restrictedContent (system locks, attestation, political disclosure), googleListing (read-only Google Business Profile status, matched locations, hours, photo/review presence, and Maps asset status; no tokens or email), and every buyer profile on the business profile with its id, Profile reach (targetingSeeds: interest and demographic catalog keys), and buyer profile-scoped demographic pins/exclusions. Pass buyer profile ids to campaigns as icpIds (only selected buyer profiles are valid targets). After interest targeting is planned, that Reach is copied into the campaign's own flight targeting; buyer profile seeds stay as written on the Creative profile.

{ "business": { "id", "url", "name", "status", "error": string | null, "profile": {...}, "googleListing": { "status", "accountSelected", "locations": [{ "title", "matchStatus", "hoursSummary", "hasPhoto", "reviewQuoteCount", "placeIdPresent" }], "locationAssetsStatus" }, "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" }] } }

Buyer profiles

POST /api/v1/personas

Create a buyer profile 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 buyer profile: 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 buyer profile + statics + social + TV). Pass campaignId for a campaign-scoped quote of the recommended creative plan gaps: statics only for interest lines that need generation for targeted buyer profiles, 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 buyer profile; includeVideos only applies then. A deferred quote is not a completed zero-cost quote. Wait for planning to finish before treating it as a price; changes to the plan can change later quotes.

{ "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 cpmTargetCents and cpmBand set buying CPM in USD cents, including estimated audience fees and excluding platform/agency markup. The target must fit the limits. Read cpmRevision after creation before later buying edits. 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), metaStoryBeats (omitted stamps off; only true is on; not a spend path), driveTo (tactic + confirmed locationIds; omit on visits to use every confirmed store), 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 buyer profile. 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 buyer profile. icpIds narrows which buyer profiles the campaign selects (generation does not change that selection; an id that is not a selected buyer profile is 422 invalid_icp_ids, never silently dropped; at most 12); omit for the lean default (1 buyer profile 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. For a business without a website, supply landingUrl. generalAudience: true selects the General audience profile and cannot be combined with icpIds.

body: { "businessId", "name", "objective": "awareness|traffic|leads|calls|visits|bookings|sales", "dailyBudgetCents": 3000..1000000, "cpmTargetCents"?: 1..1000000|null, "cpmBand"?: { "minCents": 0..1000000, "maxCents": 0..1000000 }|null, "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, "metaStoryBeats"?: false, "icpIds"?: [...], "generalAudience"?: true, "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, "audioEnabled"?: false, "mail"?: { "enabled": true, "pieceCap"?: 200..5000 }, "driveTo"?: { "tactic"?: "proximity"|"conquest", "locationIds"?: ["uuid"] } }
201 { "campaign": { "id", "status": "draft" }, "campaignAdsEnqueued": false, "generationDeferred": true }

GET /api/v1/campaigns/{id}

Campaign detail: setup, target buying CPM, optional CPM limits, saved/applying revision, targeting, Autopilot mode, spend legs (Prospect, Follow, Boost, Win-back), playbook, and status.

{ "campaign": { "id", "name", "objective", "status", "dailyBudgetCents", "startDate", "endDate", "landingUrl", "states", "geo", "driveTo", "platforms", "inventorySurfaces", "cpmTargetCents", "cpmBand", "cpmSync", "cpmRevision", "autopilotMode", "followSpend", "prospectSpend", "winback", "metaStoryBeats", "playbook", "audioEnabled", "mail": { "enabled", "pieceCap" }, "audienceBoost", "holdoutStates", "measureLift", "icpIds", "budgetMix", "offer", "offerExpiresAt", "instructions", "createdAt" } }

POST /api/v1/campaigns/{id}/launch

Launch a draft. Add ?dryRun=1 to see what launching would do without doing it: 200 with canLaunch, pass/fail rows for every pre-check (status, archive state, restricted content, agency license), every launch requirement, and wallet coverage (day-one requiredCents vs availableCents); reads only, charges nothing. Without dryRun: 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 } | ?dryRun=1: { "dryRun": true, "launched": false, "status", "canLaunch", "checks": [{ "check", "ok", "reason"? }], "gates": [{ "gate", "ok" }], "wallet": { "ok", "requiredCents", "availableCents", "chargesOn", "overdue" } }

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 pushes the schedule; draft/error updates the setup value; JSON null clears; 422 invalid_end_date when before today on a live flight, not after startDate, or over 365 days), startDate and objective (draft/error only), and/or landingUrl (live/paused refreshes serving ads; draft/error just updates the setup value; JSON null on a draft falls back to the business website). Every field is validated before any is written: an invalid field returns 422 with nothing applied. Geo (full v2 object, including optional exclude on national/states/radius/dmas) or states: draft/error updates setup; live/paused enqueues a geo push. driveTo (tactic + confirmed locationIds) is strategy-only: live/paused does not enqueue campaign.update_geo. Platforms: draft/error updates setup; live/paused enqueues campaign.update_platforms (add/remove/pause legs; remaining seats must still clear budget floors). followSpend, prospectSpend, winback, and metaStoryBeats (draft/error setup or live/paused toggles; Meta story is not a spend path). playbook (follow-journey | open-mix) and mail (Neighborhood mail: enabled + pieceCap 200..5000) are draft/error only. audioEnabled: live/paused uses the same Audio toggle as the UI; any value while Audio is not live returns 422 audio_unavailable. A Follow-only social mix with no pool source (Mobile web, Mobile apps, Google Search, or an imported customer list) is 422 follow_needs_pool. 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 buyer profiles this campaign targets, at most 12 (422 persona_cap); an id that is not a selected buyer profile is 422 invalid_icp_ids, never silently dropped; 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. Buying price: send cpmTargetCents (1..1000000 or null), cpmBand ({minCents,maxCents}, 0..1000000, minimum less than maximum, or null), and expectedCpmRevision. Draft/error buying fields may be combined atomically with setup fields, except icpIds and Autopilot mode/reason, which require separate requests. Live/paused buying changes require a separate buying-only PATCH. Target must fit the limits. Omitted fields remain stored. 409 on stale revision. Draft saves apply to setup; live saves remain pending until confirmation from the delivery provider. Optional Idempotency-Key header. Send at least one field.

body: { "cpmTargetCents"?: 1..1000000|null, "cpmBand"?: { "minCents": 0..1000000, "maxCents": 0..1000000 }|null, "expectedCpmRevision"?: "required whenever buying price fields are present", "name"?: "2..80 chars", "dailyBudgetCents"?: 3000..1000000, "endDate"?: "YYYY-MM-DD"|null, "startDate"?: "YYYY-MM-DD (draft/error)", "objective"?: "awareness|traffic|leads|calls|visits|bookings|sales (draft/error)", "landingUrl"?: "https://..."|null, "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, "metaStoryBeats"?: true, "playbook"?: "follow-journey"|"open-mix", "audioEnabled"?: false, "mail"?: { "enabled": true, "pieceCap"?: 200..5000 }, "icpIds"?: ["uuid", ...], "audienceBoost"?: true, "measureLift"?: true, "autopilotMode"?: "auto"|"manual", "autopilotReason"?: "…", "driveTo"?: { "tactic"?: "proximity"|"conquest", "locationIds"?: ["uuid"] } }
Buying draft/error: { "ok": true, "applied": { "cpmTargetCents", "cpmBand", "cpmSync", ...acceptedSetupFields } }; buying live/paused: { "ok": true, "cpmTargetCents", "cpmBand", "cpmSync" }; other updates: { "updated": true, "name"?, "dailyBudgetCents"?, "placementsResplit"?, "endDate"?, "startDate"?, "objective"?, "landingUrl"?, "versionedAdCount"?, "geo"?, "states"?, "platforms"?, "audienceBoost"?, "measureLift"?, "icpIds"?, "followSpend"?, "prospectSpend"?, "winback"?, "metaStoryBeats"?, "playbook"?, "audioEnabled"?, "mail"?, "autopilotMode"?, "driveTo"? }

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 buyer profile shares daily media. Send the same cells GET campaign returns (platform, seat, buyer profile, share). Shares are integers that must sum to 10000. Omit cells when enabling to snapshot the current split (live/paused) or generate a floor-first draft mix (held seats at 0%). 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 checks (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/bulk-lifecycle

Pause, resume, or end up to 100 campaigns of one business in one call. Each campaign follows the single lifecycle rules (only live pauses, only paused resumes, ending is permanent) and is applied by the platform workers asynchronously; each requested campaign gets a Change History entry right away. Every id must belong to that business in your workspace, or nothing is requested: 404 campaign_not_found lists the ids. 404 business_not_found for a business outside your workspace. 202 when at least one campaign was requested; 409 invalid_status with the same per-campaign results when none was.

body: { "businessId": "uuid", "action": "pause|resume|end", "campaignIds": ["uuid", ...] }
202 { "requested": 2, "action", "batchId", "results": [{ "campaignId", "status", "outcome": "requested|invalid_status|campaign_archived|business_archived" }] }

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 in each group, AND between groups; 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 channel-specific 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); rather than a measured zero. 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", "impressionSource", "clickSource", "clickableImpressions", "ctrNumerator", "ctrDenominator", "ctrReason", "deliveryFreshness", "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-buyer-profile 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", "sessionsCrossDayNote", "timeseries": [{ "date", "impressions", "clicks", "sessions", "conversions", "clickableImpressions" }], "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 in-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}/landing-pages

Landing pages on the campaign: slug, publish status, and content kind (waveform_pending, waveform_source, or historical blocks). Waveform writes the page; this list does not return HTML.

{ "landingPages": [{ "id", "slug", "status", "contentKind" }] }

GET /api/v1/landing-pages/{id}

One landing page and its variants, including the latest Waveform attempt state. Does not return page HTML or a Waveform generation id.

{ "landingPage": { "id", "campaignId", "slug", "status", "contentKind", "variants": [{ "id", "key", "role", "status", "contentKind", "error", "attempt" }] } }

POST /api/v1/landing-pages/{id}/variants/{variantId}/retry

Enqueue a new Waveform generation for one variant. Body must be empty; the server mints the attempt. Callers never send a Waveform generationId. 409 landing_pages_disabled when hosted pages are off.

body: {}
{ "enqueued": true, "variantId" }

GET /api/v1/campaigns/{id}/ads

The campaign's Ads set (snapshot of ads selected to deliver) with per-ad lifetime performance: status, buyer profile, 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: save the desired pause or resume state on every platform it serves on. A pending: true response means Google Search is still confirming its update; serving is the requested state, not proof of delivery. 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, "pending"?: true }

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). Profile reach lives on each buyer profile 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 } | 503 { "error": "not_configured" }

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. Webhook fields include purchase/won/call names plus optional gclid, vendor + vendorEventId, durationSeconds, occurredAt, and campaignId. 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", "callExample" }, "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", "description", "createdAt", "reasonCode", "reason" }], "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", "method", "requestedCents", "creditedCents", "processingFeeCents", "grossCents" }

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

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. Guided Search copy and keyword recommendations may return pending: true: the edit is saved and waiting for Google review or confirmation, so accepted does not mean the replacement is already serving.

{ "ok": true, "status": "accepted" | "dismissed", "pending"?: true }

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, configured mail recipients per class, wallet balance, campaignPolicy (stored + inheritance-resolved defaults/locks), and the effective (inheritance-resolved) rate split. Prefer PATCH operatingMode to change the pair. defaultCreativeSource controls new campaigns: byo starts with creative generation frozen, autopilot keeps normal creation, and null restores the normal default. Existing campaigns are unchanged.

{ "client": { "organizationId", "name", "slug", "experienceMode", "billingMode", "partnerFeePercent", "monthlySpendCapCents", "clientDigestMail", "mailRecipients": { "ops": [], "digest": [] }, "defaultCreativeSource": "autopilot"|"byo"|null, "walletBalanceCents", "campaignPolicy": { "stored", "effective" }, "effective": { ... } } }

PATCH /api/v1/agency/clients/{id}

Update one child client. Prefer operatingMode (managed | self_serve); that is the same setting 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; mailRecipients replaces the passed class's notification addresses (max 10; null or [] returns that class to the existing default recipient). dspBilling (Self-serve only) picks whose Meta, Google, TikTok, and Reddit accounts bill: platform_seat or client_dsp (422 managed_client_dsp on a Managed client). campaignPolicy sets spend-leg and channel defaults/locks (null clears the child's stored policy). defaultCreativeSource controls new campaigns: byo starts with creative generation frozen, autopilot keeps normal creation, and null restores the normal default. Existing campaigns are unchanged. Only fields you pass change.

body: { "operatingMode"?: "managed"|"self_serve", "experienceMode"?, "billingMode"?, "partnerFeePercent"?: 0-99|null, "monthlySpendCapCents"?: cents|null, "clientDigestMail"?: true|false|null, "mailRecipients"?: { "ops"?: string[]|null, "digest"?: string[]|null }, "defaultCreativeSource"?: "autopilot"|"byo"|null, "dspBilling"?: "platform_seat"|"client_dsp", "campaignPolicy"?: { "defaults"?, "locks"? }|null }
{ "client": { "defaultCreativeSource": "autopilot"|"byo"|null, ... } }

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/clients/{id}/api-keys

List API keys bound to one child client (never the secret). 404 client_not_found when the id is not your child.

{ "keys": [{ "id", "name", "start", "mintedByAgency", "createdAt" }] }

POST /api/v1/agency/clients/{id}/api-keys

Mint an API key that authenticates only as that child client, never as the agency. The secret is returned once. The key belongs to the operator who minted it and stops working if they leave the agency. 404 client_not_found when the id is not your child.

body: { "name": "Cafe dashboard" }
201 { "key": { "id", "name", "start", "organizationId", "secret" } }

DELETE /api/v1/agency/clients/{id}/api-keys

Disable one key bound to that child client. 404 key_not_found when the key is not that client's.

body: { "keyId": "..." }
{ "revoked": true, "keyId" }

GET /api/v1/agency/campaigns?status={status}&client={id}&limit={n}

Campaigns for every child client, newest first, each with its owning client (organizationId, name, disabled). Optional status (draft | launching | live | paused | ended | error), client, and limit (default 100, max 500). 404 client_not_found when client is not your child.

{ "campaigns": [{ "id", "name", "objective", "status", "dailyBudgetCents", "createdAt", "client": { "organizationId", "name", "disabled" } }] }

GET /api/v1/agency/cost-report?from={YYYY-MM-DD}&to={YYYY-MM-DD}

CSV of pass-through media spend for 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 to reject a launch when its quote no longer matches the current run. 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, buyer profile 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_templateapprove_catalog_video_templatecreate_catalog_video_runquote_catalog_video_run (sample) → launch_catalog_video_run with mode + quoteHash → poll get_catalog_video_runexport_catalog_video_run. Optional segmentKeys (max 3) on create enables buyer profile × 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. We keep only the hashes. 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, shared by keys belonging to the same user). 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 setting as the console); PATCH also takes partnerFeePercent, monthlySpendCapCents, clientDigestMail, dspBilling (Self-serve only), and campaignPolicy. An agency key can mint keys for a client with POST /api/v1/agency/clients/{id}/api-keys; those keys authenticate only as that client, never as the agency. List work across every client with GET /api/v1/agency/campaigns?status=live. A client id that is not yours is 404 client_not_found. 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

Live campaigns charge their daily budget from your prepaid wallet each active day in US Eastern time. Launch can return awaitingCreatives: true and chargedDate: null while ads render; the first media charge waits until they are ready. Pausing or ending stops future charges. Undelivered media is credited automatically.

Ad creation through this API is metered per unit. Direct business signups receive $60 starter credit; agency workspaces skip it. In the product UI, campaign ads for businesses are included. For API library generation, quote with GET /api/v1/creatives/quote before calling generate; insufficient funds returns 402. Campaign creation first saves a draft and defers generation until targeting is planned, so the draft can exist before a generation charge or a funding problem. A quote marked deferred: true is not a finished quote, even when totalCents is zero. Failed paid renders refund their units.

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.