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 + 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": "2026-07-06" }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, gate, and the wallet 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
Grouped by resource. Every live v1 method is listed; the catalog is pinned to the route files so this page cannot drift.
- Businesses
- Buyer profiles
- Creatives
- Campaigns
- Audiences
- Tracking
- Wallet
- Ads
- 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 → buyer profiles); 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), 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", "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). A change to the categories in force re-plans the business's launching, live and paused campaigns so the networks get the new compliance settings.
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" }] } }GET/api/v1/businesses/{id}/website-insights
Read this business's Search Console and Google Analytics connections and reports. Includes selected property, source date window, timezone, freshness, sampling and truncation. Website key events are not campaign-attributed conversions. No tokens are returned.
{ "connections": [{ "id", "businessId", "provider", "status", "revision", "selection", "pendingAttemptId", "lastSyncedAt", "availability", "snapshot", "error" }] }POST/api/v1/businesses/{id}/website-insights/connect
Get a browser Google consent URL for this business. The initiating user must finish sign-in in the same workspace, approve read-only access, and explicitly select a property.
body: { "provider": "google_search_console" | "google_analytics" }{ "connectUrl": "https://…" }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, demographicPins / demographicExclusions (demographic catalog keys; pins capped at 3) and/or interestPins (interest catalog keys for this buyer profile, any inventory including TV; capped at 4). Pins need a data fee of at most $3 per 1,000.
body: { "name"?, "description"?, "selected"?, "demographicPins"?: string[], "demographicExclusions"?: string[], "interestPins"?: string[], ... }{ "persona": { "id", "name", "selected", "demographicPins", "demographicExclusions", "interestPins", ... } }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 across 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. 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). 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/{id}/budget-policy
Read the campaign's budget: dailyBudgetCents (the daily amount, or the Monthly daily maximum), monthlySpendTargetCents (Monthly), flightBudgetCents (total budget) and any separately funded external allocation. A Monthly budget is paced into each day's amount automatically. Amounts are integer cents.
{ basis, dailyBudgetCents, monthlySpendTargetCents, flightBudgetCents, budgetPolicy: { digitalDailyMaximumCents, external }, pricingRevision, setupRevision }PUT/api/v1/campaigns/{id}/budget-policy
Choose Daily or Monthly. Monthly limits Adwave charges including fees; an external allocation is paid separately and defaults to 50%. On a running campaign today's amount is recomputed from what already ran, and today's hold only goes up if the wallet covers it. Saves are last-write-wins; expectedRevision and pricingRevision are accepted and ignored. 404 not_found, 409 campaign_ended, 422 invalid.
body: { mode: daily|monthly, dailyBudgetCents, monthlySpendTargetCents: number|null, external: { totalDailyMaximumCents, externalShareBps? }|null }{ updated: true, policy, notices? } or { updated: false, reason, message }PATCH/api/v1/campaigns/{id}/budget-policy
Retired. One-day budget amounts were removed: a monthly budget is paced into each day's amount. This path answers 410.
410 { "error": "retired" }POST/api/v1/campaigns/{id}/budget-policy
Preview the next eligible Eastern day's digital amount and Adwave charge for proposed Daily or Monthly settings. Nothing is saved, charged or sent to a network. Limited to 30 requests per minute.
body: { mode: daily|monthly, dailyBudgetCents, monthlySpendTargetCents: number|null, external: { totalDailyMaximumCents, externalShareBps? }|null }{ available: true, date, basis, digitalBudgetCents, adwaveChargeCents, externalAllocation, acceptedSettings, budgetRevision } or 404 { available: false, reason: not_found }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 | countries; preferred; ads run in the US and Canada only, Canada is {"mode":"countries","countries":["CA"]}, a radius centre must be in the US, and any other area is 422; 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), except that a business whose locations are outside the US and Canada is 422 location_outside_service_area (with a message) until a geo is sent; 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). An agency client's campaign is never generation-deferred: quote its ads and accept the price first. Pass campaignAds: false for a reuse-only plan with no generation charge. Autopilot launch starts ads if none were requested yet; BYO 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.
body: { "businessId", "name", "objective": "awareness|traffic|leads|calls|visits|bookings|sales", "dailyBudgetCents"?: 3000..1000000, "flightBudgetCents"?: "total budget over explicit startDate..endDate; omit dailyBudgetCents", "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"?: [...], "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", "dailyBudgetCents", "flightBudgetCents" }, "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", "driveTo", "platforms", "inventorySurfaces", "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, agency license), every launch gate, and the wallet (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: error insufficient_funds, balanceCents is the raw wallet balance, availableCents is what launching can spend after today's holds, plus nextTool create_wallet_checkout); 409 when there is still nothing to serve (BYO 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). 422 end_date_required (with a message) when the change would leave a total budget without an end date: a total budget is spread from the start date to the end date; use a daily or monthly budget for ongoing spend. 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, "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"] } }{ "updated": true, "name"?, "dailyBudgetCents"?, "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. Or send channelShares (one row per channel, open web and TV by surface, summing to 10000) to set each channel's share while the buyer-profile proportions beneath it are kept. 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 }], "channelShares"?: [{ "platform", "surface"?, "shareBps": 0..10000 }], "reason"?: "…" }{ "updated": true, "budgetMix": { "version": 1, "enabled", "cells" } }POST/api/v1/campaigns/{id}/retry
Retry a stalled campaign. `{"action":"launch"}` relaunches a failed campaign through the same launch a draft uses (Autopilot starts ads if none were requested; the launch can wait while ads render); 402 when the wallet can't cover today, 409 no_servable_ads when there is still nothing to serve (BYO or a prepaid full-set with no finished ad; Autopilot also 409s if ads cannot start or generation already failed). `{"action":"failed_placements"}` retries a LIVE campaign's failed placements now (live ones are untouched); 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" }GET/api/v1/campaigns/{id}/external-fulfillment
Retired. External fulfillment notices were removed; this path answers 410.
410 { "error": "retired" }POST/api/v1/campaigns/{id}/external-fulfillment
Retired. External fulfillment notices were removed; this path answers 410.
410 { "error": "retired" }PUT/api/v1/campaigns/{id}/external-fulfillment
Retired. External fulfillment notices were removed; this path answers 410.
410 { "error": "retired" }DELETE/api/v1/campaigns/{id}/external-fulfillment
Retired. External fulfillment notices were removed; this path answers 410.
410 { "error": "retired" }GET/api/v1/campaigns/{id}/catalog-video
Retired. Catalog Video was removed; this path answers 410.
410 { "error": "retired" }POST/api/v1/campaigns/{id}/catalog-video
Retired. Catalog Video was removed; this path answers 410.
410 { "error": "retired" }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
Optional window: `period` = `all` (default), `30d`, `previous_month`, `7d`, `yesterday` or `today`, or `custom` with `from` and `to` (inclusive US Eastern dates, up to 366 days, through today; dates alone mean custom). A bad or conflicting window is a 422 naming the problem (`invalid_period`, `invalid_range`, `conflicting_range`, `range_too_long`, `future_range`). 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}
Window: `period` (same values and 422s as the report, including `custom` with `from`/`to`; `all` reads the last 30 days) wins over `days`. 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", "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}/landing-pages
Landing pages on the campaign: slug, publish status, and content kind (blocks, or unknown when the saved content does not parse). This list does not return HTML.
{ "landingPages": [{ "id", "slug", "status", "contentKind" }] }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, 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: 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), to resume an ad whose creative was rejected (409 creative_rejected; allow the creative again first) 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). 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 in cents: the balance, what live campaigns have set aside today (heldCents) and what is still available (availableCents), plus auto-refill state. Each morning a live campaign sets aside the day's amount. The next morning, once delivery is reported, we charge what ran, never more than the amount set aside. A day when nothing can serve is not charged. Check availableCents before launching. Fund with POST /api/v1/wallet/checkout (or MCP create_wallet_checkout).
{ "wallet": { "balanceCents", "heldCents", "availableCents", "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 }Integrations
GET/api/v1/integrations/{id}/properties
List accessible properties. Optional attemptId selects a pending grant, pageToken continues discovery, and Analytics propertyId lists web streams. Requires connection-management permission.
{ "provider", "revision", "properties": [{ "id", "label" }], "nextPageToken" } or { "provider", "revision", "property", "streams": [{ "id", "label", "siteUrl" }], "nextPageToken" }PUT/api/v1/integrations/{id}/properties
Select a freshly validated Search Console property or Analytics property/web stream for this business and queue its first report. 409 if the connection revision changed; 403 for inaccessible or unrelated properties.
body: { "expectedRevision": 0, "propertyId", "streamId"?: "123", "attemptId"?: "uuid" }202 { "ok": true, "enqueued": true }DELETE/api/v1/integrations/{id}
Disconnect only a Search Console or Google Analytics source. Clears local credentials and reports; leaves the Google property, tracking and other business connections unchanged. Requires the current revision.
body: { "expectedRevision": 1 }{ "ok": true }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
GET/api/v1/assistant/capabilities
Read-only map of tenant Assistant tools and their current dispositions. No spend.
{ capabilities }GET/api/v1/assistant/workflows
List this viewer's private Assistant conversations. Does not create a business or spend.
{ workflows }POST/api/v1/assistant/workflows
Start a private Assistant conversation. Does not create a business or spend.
body: { goal? }{ workflowId }GET/api/v1/assistant/workflows/{id}
Read one private Assistant conversation you own.
{ id, state, goal, reviewUrl }PATCH/api/v1/assistant/workflows/{id}/answers
Save private scratch answers. Does not create a business or spend.
body: { key, value }{ saved, workflowId }POST/api/v1/assistant/workflows/{id}/artifacts
Replace a private artifact payload. No secrets.
body: { kind, payload }{ saved, workflowId }POST/api/v1/catalog-video/from-url
Retired. Catalog Video was removed; this path answers 410.
410 { "error": "retired" }GET/api/v1/catalog-video/runs
Retired. Catalog Video was removed; this path answers 410.
410 { "error": "retired" }POST/api/v1/catalog-video/runs
Retired. Catalog Video was removed; this path answers 410.
410 { "error": "retired" }GET/api/v1/catalog-video/runs/{id}
Retired. Catalog Video was removed; this path answers 410.
410 { "error": "retired" }PATCH/api/v1/catalog-video/runs/{id}
Retired. Catalog Video was removed; this path answers 410.
410 { "error": "retired" }POST/api/v1/catalog-video/runs/{id}/archive
Retired. Catalog Video was removed; this path answers 410.
410 { "error": "retired" }GET/api/v1/catalog-video/runs/{id}/exports
Retired. Catalog Video was removed; this path answers 410.
410 { "error": "retired" }GET/api/v1/catalog-video/runs/{id}/feeds
Retired. Catalog Video was removed; this path answers 410.
410 { "error": "retired" }POST/api/v1/catalog-video/runs/{id}/launch
Retired. Catalog Video was removed; this path answers 410.
410 { "error": "retired" }GET/api/v1/catalog-video/runs/{id}/manifest
Retired. Catalog Video was removed; this path answers 410.
410 { "error": "retired" }POST/api/v1/catalog-video/runs/{id}/pause
Retired. Catalog Video was removed; this path answers 410.
410 { "error": "retired" }GET/api/v1/catalog-video/runs/{id}/quote
Retired. Catalog Video was removed; this path answers 410.
410 { "error": "retired" }POST/api/v1/catalog-video/runs/{id}/resume
Retired. Catalog Video was removed; this path answers 410.
410 { "error": "retired" }POST/api/v1/catalog-video/runs/{id}/retry
Retired. Catalog Video was removed; this path answers 410.
410 { "error": "retired" }GET/api/v1/catalog-video/templates
Retired. Catalog Video was removed; this path answers 410.
410 { "error": "retired" }POST/api/v1/catalog-video/templates/pitch
Retired. Catalog Video was removed; this path answers 410.
410 { "error": "retired" }GET/api/v1/catalog-video/templates/{id}
Retired. Catalog Video was removed; this path answers 410.
410 { "error": "retired" }POST/api/v1/catalog-video/templates/{id}/approve
Retired. Catalog Video was removed; this path answers 410.
410 { "error": "retired" }GET/api/v1/catalogs
Retired. Catalog Video was removed; this path answers 410.
410 { "error": "retired" }POST/api/v1/catalogs
Retired. Catalog Video was removed; this path answers 410.
410 { "error": "retired" }GET/api/v1/catalogs/{id}/items
Retired. Catalog Video was removed; this path answers 410.
410 { "error": "retired" }POST/api/v1/catalogs/{id}/items
Retired. Catalog Video was removed; this path answers 410.
410 { "error": "retired" }GET/api/v1/landing-pages/{id}
One landing page and its variants, including each variant's latest generation attempt state. Does not return page HTML or a generation id.
{ "landingPage": { "id", "campaignId", "slug", "status", "contentKind", "variants": [{ "id", "key", "role", "status", "contentKind", "error", "attempt" }] } }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/team
List staff and pending invitations. Agency administrator access required. Query: limit (1–100), offset.
{ members, invitations, nextOffset }POST/api/v1/agency/team/invitations
Invite staff with fixed permissions. Selected scope requires at least one enabled client. Preserve the idempotency key when retrying.
body: { email, role: administrator|account_manager|analyst|billing, scope: all_clients|selected_clients, clientIds, operationId }{ invitationId, deliveryStatus: pending|sent|failed, replayed }PATCH/api/v1/agency/team/{userId}
Change role and client assignments. A stale revision returns 409 with currentRevision and a reload message. Ownership uses the existing ownership flow.
body: { role, scope, clientIds, expectedRevision, operationId }{ revision }; errors: { code, message, fieldErrors, retryable, operationId, currentRevision? }DELETE/api/v1/agency/team/{userId}
Revoke agency access. Does not delete the user account.
body: { operationId }{ ok: true, result }DELETE/api/v1/agency/team/invitations/{id}
Cancel a pending invitation. It can no longer grant agency access.
body: { operationId }{ ok: true, result }GET/api/v1/agency/clients
List child client organizations for an agency API key, newest first; limit (default 50, max 100) and cursor (page.nextCursor). 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" }], "page": { "hasMore", "nextCursor" } }POST/api/v1/agency/clients
Create a child client under the agency. Send a UUID Idempotency-Key header and reuse the same key and body after an uncertain result; a changed body with the same key returns 409 idempotency_conflict. Without a key, creation succeeds with idempotencyWarning but cannot be safely retried. externalId is a case-sensitive, trimmed identity unique to this agency; the same identity and matching name/mode returns the existing client (409 external_id_conflict otherwise). An erased identity cannot be reused. 409 license_limit when the agency license has no client slots left. Never retry creation automatically with a new key.
body: { "name": "Client Cafe", "externalId"?: "crm-123", "operatingMode"?: "managed"|"self_serve" }201 { "client": { "organizationId" } }; 200 replay { "client": { "organizationId" }, "replayed": true } or existing { "client": { "organizationId" }, "existing": true }; omitted key adds "idempotencyWarning": "Send Idempotency-Key to safely retry creation"GET/api/v1/agency/provisioning-requests/{key}
Read a client creation result by its original Idempotency-Key UUID. Agency administrators only. Unknown means no completed result is available yet; retry the original body with the same key if needed. A completed result reports whether the client is available, disabled, or erased. This read does not create a client.
{ "state": "unknown", "requestKey": "uuid" } or { "state": "complete", "requestKey": "uuid", "client": { "organizationId": "uuid" }, "availability": "available"|"disabled"|"erased" }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 and target, minimum daily budget, digest mail routing and configured mail recipients, external id, wallet balance, campaignPolicy (stored + inheritance-resolved defaults/locks), and the effective (inheritance-resolved) rate split. Prefer PATCH operatingMode to change the pair.
{ "client": { "organizationId", "externalId", "externalIdRevision", "clientCpmDisplayOverride", "clientCpmDisplayRevision", "name", "slug", "experienceMode", "billingMode", "partnerFeePercent", "monthlySpendCapCents", "monthlySpendTargetCents", "minDailyBudgetCents", "effectiveMinDailyBudgetCents", "monthlyTargetScope", "clientDigestMail", "mailRecipients": { "ops", "digest" }, "dspBilling", "defaultCreativeSource", "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. 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). Only fields you pass change.
body: { "operatingMode"?: "managed"|"self_serve", "experienceMode"?, "billingMode"?, "partnerFeePercent"?: 0-99|null, "monthlySpendCapCents"?: cents|null, "clientDigestMail"?: true|false|null, "dspBilling"?: "platform_seat"|"client_dsp", "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, newest first; limit (default 50, max 100) and cursor (page.nextCursor). The id must be a child of the agency key's org.
{ "campaigns": [{ "id", "name", "objective", "status", "dailyBudgetCents", "createdAt" }], "page": { "hasMore", "nextCursor" } }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 across every child client, newest first, each with its owning client (organizationId, name, disabled). Optional status (draft | launching | live | paused | ended | error), client, limit (default 50, max 500) and cursor (page.nextCursor; 400 invalid_cursor when tampered or reused with other filters, 409 pagination_scope_changed when your client access changed). 404 client_not_found when client is not your child.
{ "campaigns": [{ "id", "name", "objective", "status", "dailyBudgetCents", "createdAt", "client": { "organizationId", "name", "disabled" } }], "page": { "hasMore", "nextCursor" } }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
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); 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
Each morning (US Eastern) a launched campaign sets aside its daily budget from your prepaid wallet, and the next morning it is charged for what ran, never more than the amount set aside; pausing or ending stops future charges. Ad creation through this API is metered per unit and charged to the same wallet. In the product UI, for businesses: Your first campaign comes with free starter ads. Making all your ads now, filling gaps and Ad Library extras use published unit prices, and you see the price before you accept. 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.