Developer Handover Pack
Everything needed to build the automation from scratch: current state, full agent specs, data flow, and the build stack.
Developer Handover Pack
Paid Ad Campaign Management
[YourCompany.com] · Marketing Department · Prepared by FullSpec · [Today's Date]
This document is the single source of truth for the FullSpec build team delivering the Paid Ad Campaign Management automation. It covers the current-state process map, full agent specifications with IO contracts, the end-to-end data flow, and the recommended build stack. The FullSpec team uses this pack to configure, connect, and test every component. Your team's responsibility is limited to granting API access, confirming budget threshold values, and validating the parallel-run output before go-live.
01Process snapshot
02Agent specifications
Connects to the Google Ads API and the Meta Marketing API on a daily schedule each morning. Authenticates via OAuth 2.0 for Google and a system user access token for Meta. Pulls spend, clicks, impressions, and conversions for all active campaigns on both platforms and merges the results into a single structured data object keyed by campaign ID and date. Writes each campaign row to the correct position in the Google Sheets master log using the Sheets API. No human intervention is required at any point in this agent's execution. If either API call fails, the agent retries up to three times before logging the error and halting without a partial write to avoid corrupt data in the master log.
// Input
trigger_time: ISO8601 timestamp from scheduler
google_ads_customer_id: string (MCC or individual account ID)
meta_ads_account_id: string (act_XXXXXXXXXX format)
sheet_id: string (Google Sheets document ID)
sheet_tab: string (e.g. 'Daily Log')
date_range: { start: 'YYYY-MM-DD', end: 'YYYY-MM-DD' }
// Output
campaigns[]: {
campaign_id: string,
platform: 'google' | 'meta',
campaign_name: string,
date: 'YYYY-MM-DD',
spend_usd: float,
clicks: integer,
impressions: integer,
conversions: integer,
cpc_usd: float,
ctr_pct: float,
roas: float | null
}
write_status: 'success' | 'partial' | 'failed'
rows_written: integer
error_log[]: { step: string, message: string, timestamp: ISO8601 }- Google Ads API requires a developer token approved at Standard Access level. Confirm this is granted before build starts. Test Access level does not support production data volumes.
- Meta API requires a system user token created under the Business Manager, not a personal user token. Personal tokens expire and will break the daily schedule silently.
- The Google Sheets master log must have a defined header row with column names matching the output field names exactly (campaign_id, platform, campaign_name, date, spend_usd, clicks, impressions, conversions, cpc_usd, ctr_pct, roas). Restructuring the sheet is part of the Access and Config Setup stage.
- Dedupe logic: before writing, the agent checks whether a row with the same campaign_id and date already exists. If it does, it overwrites rather than appending a duplicate. This handles manual reruns and partial failures cleanly.
- Google Ads GAQL query must filter on campaign.status = 'ENABLED' to avoid pulling paused campaigns and inflating row counts. Confirm whether paused campaigns should also be logged for audit purposes.
- Meta API pagination must be handled explicitly. Accounts with more than 25 active campaigns will paginate and the agent must follow the next_page cursor until exhausted.
- Retry policy: three attempts with exponential backoff (30 s, 90 s, 270 s) on any 429 or 5xx response before the agent marks the run as failed and writes to the error log table.
- The Sheets write must use a batch update (batchUpdate endpoint), not sequential single-row writes, to stay within Sheets API quota limits for accounts with 20+ campaigns.
Fires immediately after the Ad Data Sync Agent confirms a successful write to the Google Sheets master log. Reads the freshly written rows, loads the budget and KPI threshold configuration from a dedicated config tab in the same sheet, and compares actual spend and conversion performance against those thresholds for each campaign. Scores each campaign as within range, approaching cap, or over cap. For any campaign flagged as anomalous, it composes and sends a structured Slack message to the configured marketing channel. It then writes conversion attribution data, matched by UTM parameters, to the corresponding HubSpot contact records via the HubSpot Contacts API. Finally, the Looker Studio dashboard refreshes automatically because it is connected directly to the Google Sheet as its data source and requires no separate API call from this agent.
// Input
sheet_id: string (same document as Ad Data Sync Agent)
data_tab: string (e.g. 'Daily Log')
config_tab: string (e.g. 'Thresholds')
slack_channel_id: string (e.g. 'C0XXXXXXXXX')
hubspot_portal_id: string
campaigns[]: { campaign_id, platform, date, spend_usd, conversions, cpc_usd, roas }
// Threshold config row structure (read from config_tab)
threshold_row: {
campaign_id: string,
monthly_budget_usd: float,
daily_spend_cap_usd: float,
max_cpc_usd: float,
min_roas: float | null,
alert_pct: integer (e.g. 80 = alert at 80% of budget consumed)
}
// Output
anomaly_flags[]: {
campaign_id: string,
campaign_name: string,
flag_type: 'budget_cap' | 'cpc_spike' | 'roas_drop' | 'zero_spend',
current_value: float,
threshold_value: float,
recommended_action: string
}
slack_messages_sent: integer
hubspot_records_updated: integer
hubspot_errors[]: { contact_id: string, error: string }
// On approval (manager acts on Slack alert)
manager_action: 'pause_campaign' | 'adjust_budget' | 'no_action'
actioned_by: string (Slack user display name, logged to sheet)
action_timestamp: ISO8601- The threshold configuration table in Google Sheets is the single source of truth for all alert logic. Column headers must be exactly: campaign_id, monthly_budget_usd, daily_spend_cap_usd, max_cpc_usd, min_roas, alert_pct. The build team will create this tab during the Access and Config Setup stage.
- HubSpot attribution match relies on UTM parameters (utm_source, utm_medium, utm_campaign) being consistently applied to all ad destination URLs. If UTMs are absent or inconsistent, the matching query will return zero results and the step will log a warning rather than failing the run. A UTM audit is required before go-live.
- HubSpot API scope required: crm.objects.contacts.write and crm.objects.deals.read. Confirm the HubSpot account tier supports private app tokens (Starter tier and above). Free tier does not support private apps.
- Slack bot must be installed in the workspace with the chat:write scope and invited to the target marketing channel before build. The channel ID (not name) must be stored in the credential store to avoid breakage if the channel is renamed.
- Anomaly scoring logic: a campaign is flagged if spend_usd exceeds (monthly_budget_usd multiplied by alert_pct divided by 100), or if cpc_usd exceeds max_cpc_usd, or if roas falls below min_roas (where min_roas is not null). Zero-spend on a campaign with impressions greater than zero is also flagged as a potential tracking issue.
- Looker Studio refresh is passive, it reads from the Google Sheet on each dashboard open. No API call is needed from this agent. Confirm the Looker Studio report is connected to the correct sheet tab and using the correct date dimension field.
- Dedupe on HubSpot writes: identify the contact by email address extracted from the UTM-tagged conversion event. If no matching contact is found, log the unmatched conversion to a separate sheet tab named 'Unmatched Conversions' rather than creating a new HubSpot record silently.
- Slack alert message must include: campaign name, platform, flag type, current value, threshold value, and recommended action. Include a direct link to the campaign in the ad platform where possible to reduce the manager's time to act.
03End-to-end data flow
// ─────────────────────────────────────────────────────────────────
// TRIGGER
// ────────────���────────────────────────────────────────────────────
scheduler.fire({ time: '09:00', date: 'YYYY-MM-DD' })
OR threshold_monitor.breach({
campaign_id: string,
metric: 'spend_pct' | 'cpc_usd' | 'roas',
current_value: float,
threshold_value: float
})
// ─────────────────────────────────────────────────────────────────
// AGENT 1: Ad Data Sync Agent
// ─────────────────────────────────────────────────────────────────
// Step 1 — Google Ads API query
google_ads.query({
customer_id: google_ads_customer_id,
query: "SELECT campaign.id, campaign.name, metrics.cost_micros,
metrics.clicks, metrics.impressions, metrics.conversions,
metrics.average_cpc, metrics.ctr
FROM campaign
WHERE segments.date = '{yesterday}'
AND campaign.status = 'ENABLED'"
})
-> google_raw[]: {
campaign_id, campaign_name, cost_micros,
clicks, impressions, conversions, average_cpc, ctr
}
// Step 2 — Normalise Google fields
google_normalised[]: {
campaign_id: string,
platform: 'google',
campaign_name: string,
date: 'YYYY-MM-DD',
spend_usd: cost_micros / 1_000_000,
clicks: integer,
impressions: integer,
conversions: integer,
cpc_usd: average_cpc / 1_000_000,
ctr_pct: ctr * 100,
roas: null // Google Ads ROAS requires separate revenue signal if not configured
}
// Step 3 — Meta Marketing API query (runs in parallel with Step 1)
meta_ads.insights({
account_id: meta_ads_account_id,
fields: ['campaign_id', 'campaign_name', 'spend', 'clicks',
'impressions', 'actions', 'cpc', 'ctr', 'purchase_roas'],
date_preset: 'yesterday',
level: 'campaign'
})
-> meta_raw[]: {
campaign_id, campaign_name, spend, clicks,
impressions, actions[], cpc, ctr, purchase_roas[]
}
// Step 4 — Normalise Meta fields
meta_normalised[]: {
campaign_id: string,
platform: 'meta',
campaign_name: string,
date: 'YYYY-MM-DD',
spend_usd: parseFloat(spend),
clicks: integer,
impressions: integer,
conversions: actions.find(a => a.action_type === 'lead').value | 0,
cpc_usd: parseFloat(cpc),
ctr_pct: parseFloat(ctr),
roas: purchase_roas[0].value | null
}
// Step 5 — Merge and dedupe
merged_campaigns[]: [...google_normalised, ...meta_normalised]
// Dedupe check: sheet lookup by (campaign_id + date) before write
// If row exists: overwrite. If not: append.
// Step 6 — Write to Google Sheets master log
sheets.batchUpdate({
sheet_id: sheet_id,
tab: 'Daily Log',
rows: merged_campaigns[] // one row per campaign per date
})
-> write_status: 'success' | 'partial' | 'failed'
-> rows_written: integer
// ─────────────────────────────────────────────────────────────────
// AGENT HANDOFF: Ad Data Sync Agent -> Campaign Analysis and Alert Agent
// Condition: write_status === 'success'
// Payload: { sheet_id, data_tab, config_tab, campaigns: merged_campaigns[] }
// ─────────────────────────────────────────────────────────────────
// ─────────────────────────────────────────────────────────────────
// AGENT 2: Campaign Analysis and Alert Agent
// ─────────────────────────────────────────────────────────────────
// Step 7 — Load threshold config
sheets.read({ sheet_id: sheet_id, tab: 'Thresholds' })
-> thresholds[]: {
campaign_id, monthly_budget_usd, daily_spend_cap_usd,
max_cpc_usd, min_roas, alert_pct
}
// Step 8 — Score each campaign
for each campaign in merged_campaigns[]:
t = thresholds.find(t => t.campaign_id === campaign.campaign_id)
flags = []
if campaign.spend_usd > t.daily_spend_cap_usd:
flags.push({ flag_type: 'budget_cap', current: spend_usd, threshold: daily_spend_cap_usd })
if campaign.cpc_usd > t.max_cpc_usd:
flags.push({ flag_type: 'cpc_spike', current: cpc_usd, threshold: max_cpc_usd })
if t.min_roas != null AND campaign.roas < t.min_roas:
flags.push({ flag_type: 'roas_drop', current: roas, threshold: min_roas })
if campaign.impressions > 0 AND campaign.conversions === 0 AND campaign.spend_usd > 50:
flags.push({ flag_type: 'zero_conversions' })
// Step 9 — Send Slack alerts for flagged campaigns
for each flagged campaign:
slack.chat.postMessage({
channel: slack_channel_id,
text: ':warning: Campaign Alert: {campaign_name} ({platform})',
blocks: [
{ flag_type, current_value, threshold_value,
recommended_action, platform_deep_link }
]
})
-> slack_messages_sent: integer
// Step 10 — HubSpot attribution write
// Source: conversion events matched by utm_campaign === campaign_name
for each campaign with conversions > 0:
hubspot.contacts.search({
filter: { property: 'hs_analytics_last_url', value contains utm_campaign }
})
-> matching_contacts[]: { contact_id, email, hs_analytics_last_url }
for each matching_contact:
hubspot.contacts.update({
contact_id: contact_id,
properties: {
hs_latest_source: 'PAID_SEARCH' | 'PAID_SOCIAL',
hs_latest_source_data_1: campaign_name,
hs_latest_source_data_2: platform,
last_attribution_date: date
}
})
// Unmatched conversions -> append to 'Unmatched Conversions' sheet tab
-> hubspot_records_updated: integer
-> hubspot_errors[]: { contact_id, error }
// Step 11 — Looker Studio refresh (passive)
// Looker Studio reads directly from 'Daily Log' tab on each dashboard load.
// No API call required. Data is live as soon as the Sheets write completes.
// ─────────────────────────────────────────────────────────────────
// TERMINAL STATE
// ─────────────────────────────────────────────────────────────────
run_summary: {
run_date: 'YYYY-MM-DD',
campaigns_synced: integer,
rows_written: integer,
anomalies_flagged: integer,
slack_messages_sent: integer,
hubspot_records_updated: integer,
errors: error_log[]
}
// run_summary written to 'Run Log' sheet tab and to Supabase error_log table04Recommended build stack
More documents for this process
Every document generated for Paid Ad Campaign Management.