Skip to Content
CampaignsCampaigns — API Routes

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

MethodRouteSectionNotes
GET/campaigns/importFlow ADiscover platform campaigns live
POST/campaigns/importFlow AImport selected campaigns + trigger sync
POST/campaigns/:id/link-externalFlow ALink existing campaign to platform
GET/campaignsCRUDList all campaigns (paginated, filterable)
POST/campaignsCRUDCreate campaign (draft status)
GET/campaigns/:idCRUDFull campaign detail
PATCH/campaigns/:idCRUDUpdate editable fields
DELETE/campaigns/:idCRUDSoft-delete (archive)
POST/campaigns/:id/generate-briefWorkflowEnqueue campaign-brief-writer
POST/campaigns/:id/generate-audienceWorkflowEnqueue audience-analyst
PATCH/campaigns/:id/statusWorkflowAdvance / reverse lifecycle status
POST/campaigns/:id/approveWorkflowClient approval (reviewer role only)
GET/campaigns/:id/contentContentList activities linked to campaign
POST/campaigns/:id/contentContentCreate activity linked to campaign
GET/campaigns/:id/metricsMetricsGet CampaignMetrics rows
POST/campaigns/:id/metrics/syncMetricsTrigger manual metrics pull
GET/campaigns/:id/metrics/insightsMetricsLatest AI insights
POST/campaigns/:id/keywords/syncKeywordsSync ad groups + keywords from Google
GET/campaigns/:id/keywordsKeywordsList CampaignKeyword records
PATCH/campaigns/:id/keywords/:keywordIdKeywordsUpdate bid or status
GET/campaigns/:id/ad-groupsKeywordsList AdGroup records
POST/campaigns/:id/search-terms/syncSearch TermsPull from search_term_view GAQL
GET/campaigns/:id/search-termsSearch TermsList with classification join
POST/campaigns/:id/search-terms/classifySearch TermsEnqueue search-term-classifier
PATCH/campaigns/:id/search-terms/:reportId/classificationSearch TermsDM accepts / overrides AI decision
POST/campaigns/:id/search-terms/push-negativesSearch TermsPush dm_reviewed negatives to Google
GET/campaigns/:id/negative-keywordsNegativesList negative keywords
POST/campaigns/:id/negative-keywordsNegativesManually add negative
DELETE/campaigns/:id/negative-keywords/:idNegativesRemove negative from Google + DB
GET/campaigns/:id/audienceAudienceList segments
POST/campaigns/:id/audienceAudienceAdd segment
PATCH/campaigns/:id/audience/:segmentIdAudienceUpdate segment
DELETE/campaigns/:id/audience/:segmentIdAudienceRemove segment
GET/campaigns/:id/sequencesEmail SequencesList drip sequences
POST/campaigns/:id/sequencesEmail SequencesCreate sequence
PATCH/campaigns/:id/sequences/:seqId/steps/:stepIdEmail SequencesEdit step content
POST/campaigns/:id/sequences/:seqId/activateEmail SequencesPush to email platform
POST/campaigns/:id/review-sequenceReview DripEnqueue review-campaign-writer
POST/campaigns/:id/review-sequence/sendReview DripDispatch next drip step
GET/campaigns/:id/optimizationsOptimizationsList recommendations
POST/campaigns/:id/optimizations/triggerOptimizationsManual scan trigger
PATCH/campaigns/:id/optimizations/:recommendationIdOptimizationsUpdate recommendation status
POST/campaigns/:id/optimizations/:recommendationId/applyOptimizationsPush 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:

ParamTypeNotes
channelstringRequired. google_ads | meta_ads | linkedin_ads
connectedChannelIdstringRequired. 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:

  1. Creates a Campaign record (type: paid_ads, source: imported, status: active or paused to mirror platform status)
  2. Creates a CampaignExternalMapping record linking the Leadmetrics campaign to the external campaign ID
  3. 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:

TriggerHow
On importAutomatically after POST /import
ManualPOST /:id/metrics/sync (see Campaign Metrics section below)
ScheduledBilling server / cron job syncs all active paid_ads campaigns daily
On connectWhen 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 updated

The 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:

ParamTypeNotes
typeCampaignType?Filter by campaign type
channelstring?Filter by channel (e.g. google_ads)
statusCampaignStatus?Filter by workflow status
goalCampaignGoal?Filter by campaign goal
deliverablePeriodIdstring?Filter campaigns linked to a period
pagenumber?Pagination
limitnumber?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[]
  • CampaignMetrics summary (latest snapshot per channel)
  • Linked Activity[] (via Activity.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:

  1. Creates a NegativeKeyword record (source: search_term_classification, status: pending_push)
  2. Calls addNegativeKeyword() on the Google Ads provider
  3. Stores externalCriterionId returned from the API
  4. Updates NegativeKeyword.statusactive and SearchTermClassification.statuspushed

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.statusremoved.


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
skippedadmin, member (DM)
dm_approvedadmin, member (DM)
client_approvedadmin, reviewer (client)
client_rejected → back to dm_reviewingadmin, 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

FileAction
apps/api/src/routers/tenant/campaigns.tsCreate — all routes above including Flow A import, keywords, search terms, negative keywords, optimizations
apps/api/src/routers/index.tsRegister new router under /tenant/v1
apps/api/src/routers/dm/campaigns.tsUpdate — align existing DM routes to generalised model
packages/agents/src/workers/insights/google-ads-insights.worker.tsExtend — write structured rows to CampaignMetrics per externalCampaignId
packages/agents/src/workers/insights/meta-ads-insights.worker.tsExtend — same as above for Meta
packages/agents/src/workers/search-term-classifier.worker.tsCreate — new agent worker
packages/agents/src/workers/meta-ads-optimizer.worker.tsCreate — new agent worker
packages/agents/src/workers/linkedin-ads-optimizer.worker.tsCreate — new agent worker
packages/agents/src/workers/campaign-optimizer-runner.worker.tsCreate — threshold check runner (no AI), enqueues optimizer workers
packages/providers/google/src/google-ads.tsExtend — add getAdGroups, getKeywords, getSearchTerms, getNegativeKeywords, addNegativeKeyword, removeNegativeKeyword methods
packages/providers/meta/src/facebook.tsExtend — add getAdSets, getAds, updateAdSetBudget, updateAdSetStatus, updateAdStatus, updateAdSetTargeting methods
packages/providers/linkedin/src/linkedin-ads.tsCreate — 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

MethodGAQL / EndpointPurpose
getAdGroups(campaignId)SELECT ad_group.id, ad_group.name, ad_group.status, ad_group.cpc_bid_micros FROM ad_group WHERE campaign.id = :idSync 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 = KEYWORDSync 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 = :idSync 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 = trueSync existing negatives
addNegativeKeyword(campaignId, keyword, matchType)POST /customers/:id/campaignCriteria:mutatePush new negative
removeNegativeKeyword(campaignId, criterionId)POST /customers/:id/campaignCriteria:mutate with remove operationRemove a negative

packages/providers/meta/src/facebook.ts

MethodEndpointPurpose
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_typePer-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)

MethodEndpointPurpose
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 fieldPause/resume ad
updateCampaignBid(campaignId, bidAmount, token)POST /adCampaigns/{id} with unitCost fieldUpdate CPC bid
getLeadGenFormStats(formId, token)GET /leadGenForms/{id}/statisticsLead Gen Form submission rate

© 2026 Leadmetrics — Internal use only