Campaigns — API Routes
Related: Data Model | Workflow & Permissions | API structure
All campaign routes are registered under the tenant prefix: /tenant/v1/campaigns.
The existing DM portal routes at /dm/v1/campaigns cover the backlink-outreach campaign read/send flow and should be aligned to the generalised model as part of this work.
Route Index
| Method | Route | Section | Notes |
|---|---|---|---|
GET | /campaigns/import | Flow A | Discover platform campaigns live |
POST | /campaigns/import | Flow A | Import selected campaigns + trigger sync |
POST | /campaigns/:id/link-external | Flow A | Link existing campaign to platform |
GET | /campaigns | CRUD | List all campaigns (paginated, filterable) |
POST | /campaigns | CRUD | Create campaign (draft status) |
GET | /campaigns/:id | CRUD | Full campaign detail |
PATCH | /campaigns/:id | CRUD | Update editable fields |
DELETE | /campaigns/:id | CRUD | Soft-delete (archive) |
POST | /campaigns/:id/generate-brief | Workflow | Enqueue campaign-brief-writer |
POST | /campaigns/:id/generate-audience | Workflow | Enqueue audience-analyst |
PATCH | /campaigns/:id/status | Workflow | Advance / reverse lifecycle status |
POST | /campaigns/:id/approve | Workflow | Client approval (reviewer role only) |
GET | /campaigns/:id/content | Content | List activities linked to campaign |
POST | /campaigns/:id/content | Content | Create activity linked to campaign |
GET | /campaigns/:id/metrics | Metrics | Get CampaignMetrics rows |
POST | /campaigns/:id/metrics/sync | Metrics | Trigger manual metrics pull |
GET | /campaigns/:id/metrics/insights | Metrics | Latest AI insights |
POST | /campaigns/:id/keywords/sync | Keywords | Sync ad groups + keywords from Google |
GET | /campaigns/:id/keywords | Keywords | List CampaignKeyword records |
PATCH | /campaigns/:id/keywords/:keywordId | Keywords | Update bid or status |
GET | /campaigns/:id/ad-groups | Keywords | List AdGroup records |
POST | /campaigns/:id/search-terms/sync | Search Terms | Pull from search_term_view GAQL |
GET | /campaigns/:id/search-terms | Search Terms | List with classification join |
POST | /campaigns/:id/search-terms/classify | Search Terms | Enqueue search-term-classifier |
PATCH | /campaigns/:id/search-terms/:reportId/classification | Search Terms | DM accepts / overrides AI decision |
POST | /campaigns/:id/search-terms/push-negatives | Search Terms | Push dm_reviewed negatives to Google |
GET | /campaigns/:id/negative-keywords | Negatives | List negative keywords |
POST | /campaigns/:id/negative-keywords | Negatives | Manually add negative |
DELETE | /campaigns/:id/negative-keywords/:id | Negatives | Remove negative from Google + DB |
GET | /campaigns/:id/audience | Audience | List segments |
POST | /campaigns/:id/audience | Audience | Add segment |
PATCH | /campaigns/:id/audience/:segmentId | Audience | Update segment |
DELETE | /campaigns/:id/audience/:segmentId | Audience | Remove segment |
GET | /campaigns/:id/sequences | Email Sequences | List drip sequences |
POST | /campaigns/:id/sequences | Email Sequences | Create sequence |
PATCH | /campaigns/:id/sequences/:seqId/steps/:stepId | Email Sequences | Edit step content |
POST | /campaigns/:id/sequences/:seqId/activate | Email Sequences | Push to email platform |
POST | /campaigns/:id/review-sequence | Review Drip | Enqueue review-campaign-writer |
POST | /campaigns/:id/review-sequence/send | Review Drip | Dispatch next drip step |
GET | /campaigns/:id/optimizations | Optimizations | List recommendations |
POST | /campaigns/:id/optimizations/trigger | Optimizations | Manual scan trigger |
PATCH | /campaigns/:id/optimizations/:recommendationId | Optimizations | Update recommendation status |
POST | /campaigns/:id/optimizations/:recommendationId/apply | Optimizations | Push approved change to platform |
Flow A — Import Campaigns from Platform
Allows the DM team to discover campaigns already running on a connected ad account (Google Ads, Meta Ads, LinkedIn Ads) and import them into Leadmetrics so their metrics can be tracked.
GET /tenant/v1/campaigns/import
Fetches the list of campaigns currently available on the connected platform. Calls the platform API live — does not read from the DB.
Query params:
| Param | Type | Notes |
|---|---|---|
channel | string | Required. google_ads | meta_ads | linkedin_ads |
connectedChannelId | string | Required. The tenant’s specific connected channel record |
Response:
{
campaigns: {
externalCampaignId: string; // platform's own ID
name: string;
status: string; // as reported by the platform (enabled, paused, etc.)
budget?: number;
startDate?: string;
endDate?: string;
alreadyImported: boolean; // true if a CampaignExternalMapping already exists
}[];
}For Google Ads: calls getCampaignsAsLookup() from packages/providers/google/src/google-ads.ts (already implemented).
For Meta Ads: calls the Meta Ads campaigns endpoint via packages/providers/meta/src/facebook.ts.
POST /tenant/v1/campaigns/import
Imports one or more selected platform campaigns into Leadmetrics. For each selected campaign:
- Creates a
Campaignrecord (type: paid_ads,source: imported,status: activeorpausedto mirror platform status) - Creates a
CampaignExternalMappingrecord linking the Leadmetrics campaign to the external campaign ID - Immediately triggers a metrics sync (enqueues the relevant insights worker)
Body:
{
connectedChannelId: string;
platform: "google_ads" | "meta_ads" | "linkedin_ads";
campaigns: {
externalCampaignId: string;
name: string;
budget?: number;
startDate?: string;
endDate?: string;
}[];
deliverablePeriodId?: string; // optional — link all imported campaigns to a period
}Response: Array of created Campaign records with their CampaignExternalMapping.
POST /tenant/v1/campaigns/:id/link-external
Links an existing Leadmetrics campaign (created via the wizard) to an external platform campaign. Used when a DM creates a campaign in Leadmetrics first and then launches it on the platform.
Body:
{
connectedChannelId: string;
platform: "google_ads" | "meta_ads" | "linkedin_ads";
externalCampaignId: string;
externalCampaignName: string;
}Creates a CampaignExternalMapping record. If a mapping already exists for this platform on this campaign, returns a 409.
Flow B — Metrics Sync
Pulls per-campaign metrics from the platform using the externalCampaignId stored in CampaignExternalMapping and writes structured rows to CampaignMetrics.
This is a significant change from the current behaviour where google-ads-insights and meta-ads-insights workers write a single narrative text blob to ChannelInsight for the whole ad account. For campaigns, they are extended to also write structured per-campaign metric rows.
Sync trigger options:
| Trigger | How |
|---|---|
| On import | Automatically after POST /import |
| Manual | POST /:id/metrics/sync (see Campaign Metrics section below) |
| Scheduled | Billing server / cron job syncs all active paid_ads campaigns daily |
| On connect | When a channel first connects, if imported campaigns exist, a sync is enqueued |
Per-campaign sync flow:
CampaignExternalMapping.externalCampaignId
│
▼
Platform API call scoped to that campaign ID
└─ Google: GAQL filtered by campaign.id
└─ Meta: GET /act_{adAccountId}/insights?filtering=[{field:"campaign.id",operator:"IN",value:[externalId]}]
│
▼
Structured metrics written to CampaignMetrics rows
(one row per day per channel)
│
▼
CampaignExternalMapping.lastSyncedAt updatedThe existing ChannelInsight AI narrative (account-level) continues to work unchanged alongside this. They serve different purposes: ChannelInsight is a broad account health summary; CampaignMetrics is granular per-campaign data for the Performance tab.
Campaign CRUD
GET /tenant/v1/campaigns
List all campaigns for the tenant.
Query params:
| Param | Type | Notes |
|---|---|---|
type | CampaignType? | Filter by campaign type |
channel | string? | Filter by channel (e.g. google_ads) |
status | CampaignStatus? | Filter by workflow status |
goal | CampaignGoal? | Filter by campaign goal |
deliverablePeriodId | string? | Filter campaigns linked to a period |
page | number? | Pagination |
limit | number? | Pagination |
Response: Paginated list of campaigns with type, channel badges, status, goal, date range, and the primary performance metric for that campaign type (CTR for paid ads, open rate for email, etc.).
POST /tenant/v1/campaigns
Create a new campaign.
Body:
{
name: string;
type: CampaignType;
channel: string[];
goal: CampaignGoal;
budget?: number;
budgetCurrency?: string;
startDate?: string;
endDate?: string;
deliverablePeriodId?: string; // optional link to DeliverablePeriod
}Creates the campaign in draft status. Does not trigger any agent automatically.
GET /tenant/v1/campaigns/:id
Get full campaign detail including:
- Campaign fields + brief
- Linked
CampaignAudience[] CampaignMetricssummary (latest snapshot per channel)- Linked
Activity[](viaActivity.campaignId) CampaignSequence[](email/review campaigns only)
PATCH /tenant/v1/campaigns/:id
Update editable campaign fields: name, budget, budgetCurrency, startDate, endDate, brief, autoPilotEnabled, deliverablePeriodId.
Status transitions use a dedicated route (see below) — cannot be set directly via PATCH.
DELETE /tenant/v1/campaigns/:id
Soft delete — sets status to archived. Does not hard-delete any records.
Campaign Workflow
POST /tenant/v1/campaigns/:id/generate-brief
Enqueues the campaign-brief-writer agent. Transitions campaign to dm_review once brief is ready.
Body:
{
targetAudienceDescription: string;
}POST /tenant/v1/campaigns/:id/generate-audience
Enqueues the audience-analyst agent. Results stored as CampaignAudience records.
PATCH /tenant/v1/campaigns/:id/status
Advance or reverse the campaign workflow status. Role-gated — see Workflow & Permissions.
Body:
{
status: CampaignStatus;
note?: string; // optional reason for rejection / change
}POST /tenant/v1/campaigns/:id/approve
Client-facing approval endpoint. Only callable when status === "client_review" and the caller has reviewer role. Transitions to client_approved.
Body:
{
approved: boolean;
note?: string;
}Campaign Content
GET /tenant/v1/campaigns/:id/content
List all Activity records where Activity.campaignId === id. Returns the activity type, status, output payload summary, and associated content entity (BlogPost, SocialPost, etc.).
POST /tenant/v1/campaigns/:id/content
Create a new Activity linked to this campaign. Triggers the appropriate agent worker based on the activity’s deliverableType.
Body:
{
deliverableType: string; // google_ads_copy | meta_ads_copy | email_newsletter | social_post | etc.
inputPayload: object; // agent-specific input
}Campaign Metrics
GET /tenant/v1/campaigns/:id/metrics
Return CampaignMetrics[] for the campaign, optionally filtered by channel and date range.
Query params: channel?, from?, to?, granularity? (day | week | month)
POST /tenant/v1/campaigns/:id/metrics/sync
Trigger a manual pull of metrics from the connected channel APIs for this campaign. Reads CampaignExternalMapping to find the externalCampaignId and enqueues the relevant insights worker scoped to that campaign.
Returns 400 if no CampaignExternalMapping exists for the campaign (i.e. the campaign has not been linked to a platform campaign yet).
GET /tenant/v1/campaigns/:id/metrics/insights
Return the latest AI-generated optimisation recommendations from google-ads-insights / meta-ads-insights / ads-analyst for this campaign.
Keywords (Google Ads — paid_ads type only)
These routes are only applicable to campaigns with platform: google_ads in their CampaignExternalMapping.
POST /tenant/v1/campaigns/:id/keywords/sync
Sync ad groups, keywords, and existing negative keywords from Google Ads into the DB (AdGroup, CampaignKeyword, NegativeKeyword tables). Uses the externalCampaignId from CampaignExternalMapping.
Updates CampaignExternalMapping.lastSyncedAt on completion.
GET /tenant/v1/campaigns/:id/keywords
List all CampaignKeyword records for this campaign.
Query params: adGroupId?, status? (enabled | paused | removed), matchType?, page?, limit?
Response: Keywords with match type, status, bid, quality score, and last-sync metrics (impressions, clicks, CTR, avg CPC, conversions).
PATCH /tenant/v1/campaigns/:id/keywords/:keywordId
Update a keyword’s bid or status. Pushes the change to Google Ads API immediately (no approval workflow — bid adjustments are a DM-only action).
Body:
{
bidMicros?: number;
status?: "enabled" | "paused";
}GET /tenant/v1/campaigns/:id/ad-groups
List all AdGroup records for this campaign with name, status, and default bid.
Search Terms (Google Ads — paid_ads type only)
POST /tenant/v1/campaigns/:id/search-terms/sync
Pull search term data from search_term_view via GAQL for the specified date range and store as SearchTermReport rows. Deduplicates by (campaignId, searchTerm, syncDate).
Body:
{
from: string; // ISO date
to: string; // ISO date
}GET /tenant/v1/campaigns/:id/search-terms
List SearchTermReport rows joined with their SearchTermClassification (if classified).
Query params: classified? (boolean — filter to classified or unclassified), classification? (add_as_keyword | add_as_negative | watch | irrelevant), page?, limit?
POST /tenant/v1/campaigns/:id/search-terms/classify
Trigger the search-term-classifier agent on all unclassified SearchTermReport rows for this campaign. Creates SearchTermClassification records with status: pending.
PATCH /tenant/v1/campaigns/:id/search-terms/:reportId/classification
DM reviews and accepts or overrides the AI classification for a single search term.
Body:
{
dmDecision: "add_as_keyword" | "add_as_negative" | "watch" | "irrelevant";
}Sets SearchTermClassification.dmDecision and status: dm_reviewed.
POST /tenant/v1/campaigns/:id/search-terms/push-negatives
Push all dm_reviewed search terms classified as add_as_negative to Google Ads as negative keywords. For each:
- Creates a
NegativeKeywordrecord (source: search_term_classification,status: pending_push) - Calls
addNegativeKeyword()on the Google Ads provider - Stores
externalCriterionIdreturned from the API - Updates
NegativeKeyword.status→activeandSearchTermClassification.status→pushed
Body:
{
matchType: "broad" | "phrase" | "exact"; // match type to apply to all pushed negatives
}Negative Keywords (Google Ads — paid_ads type only)
GET /tenant/v1/campaigns/:id/negative-keywords
List all NegativeKeyword records for this campaign, including source (manual or search_term_classification) and status.
Query params: adGroupId? (null = campaign-level), status?
POST /tenant/v1/campaigns/:id/negative-keywords
Manually add a negative keyword to the campaign. Creates a NegativeKeyword record and immediately pushes to Google Ads API.
Body:
{
keyword: string;
matchType: "broad" | "phrase" | "exact";
adGroupId?: string; // null = campaign-level negative
}DELETE /tenant/v1/campaigns/:id/negative-keywords/:negativeKeywordId
Remove a negative keyword. Calls removeNegativeKeyword() on the Google Ads provider using externalCriterionId, then sets NegativeKeyword.status → removed.
GET /tenant/v1/campaigns/:id/audience
List all CampaignAudience segments for this campaign.
POST /tenant/v1/campaigns/:id/audience
Add an audience segment manually or confirm an AI-suggested segment.
Body:
{
name: string;
segmentType: "crm_segment" | "platform_audience" | "custom_list";
filters?: object;
estimatedSize?: number;
platformAudienceId?: string;
}PATCH /tenant/v1/campaigns/:id/audience/:segmentId
Update a segment’s filters, name, or platform audience ID.
DELETE /tenant/v1/campaigns/:id/audience/:segmentId
Remove an audience segment from the campaign.
Email Sequences (email_marketing type)
GET /tenant/v1/campaigns/:id/sequences
List all CampaignSequence records for this campaign.
POST /tenant/v1/campaigns/:id/sequences
Create an email drip sequence.
Body:
{
name: string;
triggerType: "on_subscribe" | "on_date" | "manual";
}PATCH /tenant/v1/campaigns/:id/sequences/:seqId/steps/:stepId
Edit the content of a sequence step (subject, body, delay).
POST /tenant/v1/campaigns/:id/sequences/:seqId/activate
Activate a sequence — pushes the first step to Mailchimp / Klaviyo and schedules subsequent steps.
Review Drip (review_generation type)
POST /tenant/v1/campaigns/:id/review-sequence
Trigger the review-campaign-writer agent to generate the drip sequence. Result stored in ReviewCampaign.sequence.
Body:
{
reviewPlatform: string;
reviewUrl: string;
tone: "friendly" | "professional" | "casual";
stepCount?: number;
channel: "email" | "sms";
}POST /tenant/v1/campaigns/:id/review-sequence/send
Dispatch the next pending step of the review drip to the contact list.
Optimization Recommendations
GET /tenant/v1/campaigns/:id/optimizations
List all CampaignOptimizationRecommendation records for the campaign, ordered by priority then createdAt desc. Optionally filter by status or platform.
Query params: ?status=pending&platform=meta_ads
Response: Array of recommendations with title, rationale, suggestedAction, estimatedImpact, priority, status.
POST /tenant/v1/campaigns/:id/optimizations/trigger
Manually trigger an optimization scan for this campaign (instead of waiting for the weekly cron). Enqueues meta-ads-optimizer and/or linkedin-ads-optimizer based on which platforms the campaign is running on.
Only admin and member roles can call this.
PATCH /tenant/v1/campaigns/:id/optimizations/:recommendationId
Update the status of a single recommendation. Role-gated actions:
Body { status } | Who can call |
|---|---|
skipped | admin, member (DM) |
dm_approved | admin, member (DM) |
client_approved | admin, reviewer (client) |
client_rejected → back to dm_reviewing | admin, reviewer (client) |
Optionally include { note: string } to populate dmNote or clientNote.
POST /tenant/v1/campaigns/:id/optimizations/:recommendationId/apply
Push an approved recommendation to the platform. Only callable when status = client_approved (or dm_approved for budget/bid changes under threshold). Executes the suggestedAction payload against the relevant provider (Meta Marketing API or LinkedIn Ads API), then sets status → applied and stamps appliedAt.
Only admin role can call this.
Files
| File | Action |
|---|---|
apps/api/src/routers/tenant/campaigns.ts | Create — all routes above including Flow A import, keywords, search terms, negative keywords, optimizations |
apps/api/src/routers/index.ts | Register new router under /tenant/v1 |
apps/api/src/routers/dm/campaigns.ts | Update — align existing DM routes to generalised model |
packages/agents/src/workers/insights/google-ads-insights.worker.ts | Extend — write structured rows to CampaignMetrics per externalCampaignId |
packages/agents/src/workers/insights/meta-ads-insights.worker.ts | Extend — same as above for Meta |
packages/agents/src/workers/search-term-classifier.worker.ts | Create — new agent worker |
packages/agents/src/workers/meta-ads-optimizer.worker.ts | Create — new agent worker |
packages/agents/src/workers/linkedin-ads-optimizer.worker.ts | Create — new agent worker |
packages/agents/src/workers/campaign-optimizer-runner.worker.ts | Create — threshold check runner (no AI), enqueues optimizer workers |
packages/providers/google/src/google-ads.ts | Extend — add getAdGroups, getKeywords, getSearchTerms, getNegativeKeywords, addNegativeKeyword, removeNegativeKeyword methods |
packages/providers/meta/src/facebook.ts | Extend — add getAdSets, getAds, updateAdSetBudget, updateAdSetStatus, updateAdStatus, updateAdSetTargeting methods |
packages/providers/linkedin/src/linkedin-ads.ts | Create — new provider for LinkedIn Ads API |
Provider Extensions
New methods needed in the provider packages to support the routes above.
packages/providers/google/src/google-ads.ts
| Method | GAQL / Endpoint | Purpose |
|---|---|---|
getAdGroups(campaignId) | SELECT ad_group.id, ad_group.name, ad_group.status, ad_group.cpc_bid_micros FROM ad_group WHERE campaign.id = :id | Sync ad groups |
getKeywords(campaignId) | SELECT ad_group_criterion.criterion_id, ad_group_criterion.keyword.text, ad_group_criterion.keyword.match_type, ad_group_criterion.status, ad_group_criterion.quality_info.quality_score, metrics.* FROM ad_group_criterion WHERE campaign.id = :id AND ad_group_criterion.type = KEYWORD | Sync keywords with metrics |
getSearchTerms(campaignId, dateRange) | SELECT search_term_view.search_term, search_term_view.status, segments.keyword.ad_group_criterion, metrics.* FROM search_term_view WHERE campaign.id = :id | Sync search terms |
getNegativeKeywords(campaignId) | SELECT campaign_criterion.criterion_id, campaign_criterion.keyword.text, campaign_criterion.keyword.match_type FROM campaign_criterion WHERE campaign.id = :id AND campaign_criterion.type = KEYWORD AND campaign_criterion.negative = true | Sync existing negatives |
addNegativeKeyword(campaignId, keyword, matchType) | POST /customers/:id/campaignCriteria:mutate | Push new negative |
removeNegativeKeyword(campaignId, criterionId) | POST /customers/:id/campaignCriteria:mutate with remove operation | Remove a negative |
packages/providers/meta/src/facebook.ts
| Method | Endpoint | Purpose |
|---|---|---|
getAdSets(adAccountId, externalCampaignId, token) | GET /act_{id}/adsets?fields=id,name,status,daily_budget,lifetime_budget,targeting,destination_type&filtering=[campaign_id=X] | Sync ad sets |
getAds(adAccountId, externalCampaignId, token) | GET /act_{id}/ads?fields=id,name,status,creative,insights{impressions,clicks,spend,reach,frequency,ctr,cpc,actions}&filtering=[campaign_id=X] | Sync ads with metrics |
getAdInsights(adId, token, dateRange) | GET /{adId}/insights?fields=impressions,clicks,spend,reach,frequency,ctr,cpc,actions,cost_per_action_type | Per-ad metrics |
updateAdSetBudget(adSetId, dailyBudget, token) | POST /{adSetId}?daily_budget={amount} | Push budget change |
updateAdSetStatus(adSetId, status, token) | POST /{adSetId}?status={ACTIVE|PAUSED} | Pause/resume ad set |
updateAdStatus(adId, status, token) | POST /{adId}?status={ACTIVE|PAUSED} | Pause/resume individual ad |
updateAdSetTargeting(adSetId, targeting, token) | POST /{adSetId}?targeting={json} | Push audience exclusions/expansions |
packages/providers/linkedin/src/linkedin-ads.ts (new file)
| Method | Endpoint | Purpose |
|---|---|---|
getCampaigns(adAccountId, token) | GET /adCampaigns?q=search&search.account.values[0]=urn:li:sponsoredAccount:{id} | List campaigns for import (Flow A) |
getAds(campaignId, token) | GET /adCreatives?q=search&search.campaign.values[0]=urn:li:sponsoredCampaign:{id} | Sync ad creatives |
getAdAnalytics(campaignId, token, dateRange) | GET /adAnalytics?q=analytics&pivot=CREATIVE&campaigns[0]=urn:li:sponsoredCampaign:{id} | Per-ad metrics |
getDemographicBreakdown(campaignId, token, pivot, dateRange) | GET /adAnalytics?q=analytics&pivot={JOB_FUNCTION|SENIORITY|INDUSTRY|COMPANY_SIZE}&campaigns[0]=... | Demographic performance breakdown |
updateAdStatus(adId, status, token) | POST /adCreatives/{id} with status field | Pause/resume ad |
updateCampaignBid(campaignId, bidAmount, token) | POST /adCampaigns/{id} with unitCost field | Update CPC bid |
getLeadGenFormStats(formId, token) | GET /leadGenForms/{id}/statistics | Lead Gen Form submission rate |