Imported from isaachorowitz/multi-agent-setup (
growth-skills/ads/ads-tracking-playbook/SKILL.md). Install upstream withnpx skills add isaachorowitz/multi-agent-setup --skill ads-tracking-playbook. Copyright stays with the author.
A-to-Z Ad Tracking Playbook
Reproduce this setup for any SaaS app with subscription billing. Covers Google Ads, Meta Ads, and Google Analytics 4 end-to-end: account provisioning, programmatic access, pixels/tags, server-side conversions, audience creation, attribution, and validation.
Tested on ReplyMagic (this repo) where it works end-to-end. Adapt account IDs/secrets per project; everything else generalizes.
Table of contents
- What this gives you
- Architecture overview
- Prerequisites
- Google Ads — programmatic access
- Meta Ads — programmatic access
- Google Analytics 4 — server + client
- Pixel + gtag installation (client-side)
- Click-ID capture and cross-domain attribution
- Server-side conversion uploads
- Audiences and remarketing
- Domain verification + iOS attribution (AEM)
- Environment variables reference
- Deploy / secret management
- Validation checklist (run before going live)
- Future enhancements
1. What this gives you
When complete, every paid subscription fires three independent server-side conversion uploads, plus client-side event tracking on every key funnel step. The result:
- Meta Ads can optimize via Advantage+ and pixel/CAPI dedup-aware Purchase signal
- Google Ads can optimize Search/PMax with offline-uploaded purchases including real subscription value
- Google Analytics 4 records every conversion with proper client_id stitching, fueling cross-product reporting
- Retargeting works across both ad platforms with audiences fed by both URL rules (Google Ads native) and event rules (Meta + GA4)
- iOS attribution works after domain verification + AEM
- No double-counting: every Purchase has a stable
event_id/transaction_id/orderId= Stripe Checkout Session ID for client/server dedup
2. Architecture overview
AD CLICK
│
┌────────┴────────┐
▼ ▼
Google ad (gclid) Meta ad (fbclid)
│ │
└────────┬────────┘
▼
Landing page (replymagic.ai)
├─ Astro Layout fires:
│ • gtag('config', GA4)
│ • gtag('config', AW-GoogleAds)
│ • fbq('init', PIXEL)
│ • PageView (both)
├─ Inline script captures URL params:
│ • gclid / gbraid / wbraid → cookie .replymagic.ai/rm_gclid
│ • fbclid → cookie .replymagic.ai/rm_fbclid
│ • ttclid / msclkid → bonus
├─ Meta pixel sets _fbp, _fbc cookies
├─ GA4 gtag sets _ga cookie
├─ Google Ads gtag sets _gcl_aw cookie
│
▼
Cross-subdomain hop (no GCLID loss)
│
▼
App (app.replymagic.ai)
├─ Same gtag + pixel base load
├─ ViewContent / view_item on key pages
├─ Sign up → CompleteRegistration / sign_up
├─ Subscribe click:
│ • InitiateCheckout (Meta)
│ • begin_checkout (GA4)
│ • Read cookies → marketing-attribution.ts
│ • Pass marketing.* into createCheckoutSession tRPC mutation
│
▼
Stripe Checkout Session created
├─ session.metadata = { user_id, gclid, fbp, fbc, ga_client_id, client_ip, client_user_agent, ... }
├─ subscription_data.metadata = { user_id, gclid }
│
▼
User pays → Stripe webhook → checkout.session.completed
│
▼
Cloudflare Worker stripe.utils.ts handler:
├─ applyActiveSubscriptionQuota → grants Pro
├─ Extracts metadata from session + subscription
├─ reportPurchaseToAdPlatforms() runs 3 in parallel:
│ │
│ ├─→ Meta CAPI Purchase
│ │ POST graph.facebook.com/{ver}/{pixel_id}/events
│ │ body: {data: [{event_name: "Purchase",
│ │ event_id = session.id, // dedup
│ │ user_data: {em (hashed), fbp, fbc, ip, ua},
│ │ custom_data: {value, currency, order_id}}]}
│ │
│ ├─→ Google Ads offline click conversion
│ │ OAuth refresh → access token
│ │ POST googleads.googleapis.com/v20/customers/{id}:uploadClickConversions
│ │ body: {conversions: [{conversionAction,
│ │ conversionDateTime, conversionValue, currencyCode,
│ │ gclid, orderId = session.id,
│ │ userIdentifiers: [{hashedEmail}]}]}
│ │
│ └─→ GA4 Measurement Protocol
│ POST www.google-analytics.com/mp/collect?measurement_id&api_secret
│ body: {client_id (from _ga cookie or fallback),
│ events: [{name: "purchase",
│ params: {transaction_id = session.id,
│ value, currency, items: [...]}}]}
│
└─ Returns 200 to Stripe. All 3 uploads best-effort —
failures are logged and swallowed so Stripe doesn't retry.
3. Prerequisites
Before you start, you need:
- Business entity with verifiable docs (Meta requires Business Verification, Google requires basic identity)
- Marketing site at
apex.tld(e.g.myapp.com) - App at a subdomain (
app.myapp.com) — different subdomain matters for cross-domain linker; same domain is even simpler - Stripe subscription with
checkout.sessions.create({ ui_mode: "embedded_page" | "hosted" })and a webhook handler forcheckout.session.completed - Cloud build/host with environment variable management (Cloudflare Workers + Pages in our case; Vercel/AWS/etc. work the same way)
- A secrets store for production credentials (Infisical here; 1Password / Doppler / Cloudflare's own secret store work identically)
Estimated time end-to-end: 3-5 hours of human time over ~2 calendar days (Google verification has 1-day async wait; Meta Business Verification has 1-3 day async wait).
4. Google Ads — programmatic access
4.1 Create or identify the manager account (MCC)
Use one Manager Account (MCC) per legal entity, multiple ad accounts per product underneath.
If you already have one (e.g. an existing MCC XXX-XXX-XXXX / XXXXXXXXXX):
- Confirm currency + timezone — they're permanent
- Confirm it's actually a manager:
Account selector → check for the "MGR" badge
If not, create one at https://ads.google.com → top-right account selector → New Manager Account.
4.2 Create the per-product ad account under the MCC
From the MCC → Sub-account settings → + → Create new account. Pick:
- Currency: match what you'll bill in (e.g., USD)
- Time zone: where reporting "rolls over" daily (
America/New_Yorkis a fine default) - Both are permanent.
If the account already exists separately (because someone signed up directly), use Link existing account instead — sends an invite the target account owner must accept.
4.3 Apply for an API developer token
- Sign in to the MCC at https://ads.google.com
- Tools → API Center
- Fill the form (in-house use, internal automation, etc.)
- You get a token immediately in Test Access mode → click Apply for Basic Access
- Wait 1-2 business days for approval
The token is what authorizes any Google Ads API call. Save as GOOGLE_ADS_DEVELOPER_TOKEN.
4.4 Create a Google Cloud OAuth client
- https://console.cloud.google.com → new project (e.g.,
myapp-ads) - APIs & Services → Library → Google Ads API → Enable
- APIs & Services → OAuth consent screen:
- User type: External
- App name, support email
- Scopes: add
https://www.googleapis.com/auth/adwords - Test users: add the Google account that owns the MCC
- APIs & Services → Credentials → Create Credentials → OAuth client ID:
- Application type: Desktop app
- Save Client ID + Client Secret
Tip: leave the OAuth consent screen in Testing mode for in-house use. Refresh tokens technically expire after 7 days while in Testing — to make them permanent, click Publish App (no full brand verification needed; the
adwordsscope is sensitive but not restricted).
4.5 Generate a refresh token
Script at ./get-token.js (one-time local Node script):
import { google } from "googleapis";
import http from "node:http";
import open from "open";
const CLIENT_ID = "...";
const CLIENT_SECRET = "...";
const REDIRECT = "http://localhost:8765/oauth2callback";
const oauth2 = new google.auth.OAuth2(CLIENT_ID, CLIENT_SECRET, REDIRECT);
const url = oauth2.generateAuthUrl({
access_type: "offline",
prompt: "consent",
scope: ["https://www.googleapis.com/auth/adwords"],
});
const server = http.createServer(async (req, res) => {
const code = new URL(req.url, REDIRECT).searchParams.get("code");
const { tokens } = await oauth2.getToken(code);
console.log("REFRESH TOKEN:", tokens.refresh_token);
res.end("Done.");
server.close();
});
server.listen(8765, () => open(url));
Run with node get-token.js. Crucially: log in via the browser using the Google account that has access to the MCC. The refresh token inherits that user's permissions. Save as GOOGLE_ADS_REFRESH_TOKEN.
4.6 Verify access (smoke test)
import { GoogleAdsApi } from "google-ads-api";
const client = new GoogleAdsApi({ developer_token, client_id, client_secret });
const access = await client.listAccessibleCustomers(REFRESH_TOKEN);
// expect: array of customer resource names — includes your MCC
If you get DEVELOPER_TOKEN_NOT_APPROVED, you're still in Test Access — wait for Basic. If PERMISSION_DENIED, the OAuth user doesn't have MCC access.
4.7 Capture the IDs
GOOGLE_ADS_LOGIN_CUSTOMER_ID= MCC ID without dashesGOOGLE_ADS_CUSTOMER_ID= the per-product ad account ID without dashes
All API calls must include both as headers: login-customer-id and the URL path customers/{customer_id}.
4.8 Get the Google Tag (gtag) ID
In the per-product ad account: Tools → Conversions → Google Tag. Format: AW-NNNNNNNNNN. This is used for client-side conversion tracking, audience building, and GCLID auto-tagging. Save as PUBLIC_GOOGLE_ADS_TAG_ID.
4.9 Create a Purchase conversion action
Tools → Conversions → New conversion action:
- Source: Import → Other data sources or CRM → Track conversions from clicks
- Category: Purchase
- Value: Use different values for each conversion (we pass real subscription $)
- Count: Every (each purchase is unique)
- Click-through window: 30 days
- Attribution model: Data-driven
After creating, copy the resource name (format: customers/{cid}/conversionActions/{aid}). Save as GOOGLE_ADS_PURCHASE_CONVERSION_ACTION.
5. Meta Ads — programmatic access
5.1 Use ONE Business Portfolio for ALL ad accounts
Don't create a second portfolio per product. Multiple portfolios fragment pixels, audiences, billing, and reporting. The cap on new ad accounts is a per-portfolio limit that's solved by:
- Completing Business Verification (legal docs + domain in Business Settings)
- Submitting a Meta support case if still capped after verification
5.2 Create the Meta Developer App
https://developers.facebook.com/apps → Create App:
- Use case: Other → Type: Business
- App name: your product name (e.g.,
ReplyMagic) - Contact email
- Business Account: link it to the Business Portfolio ← critical
- After creation: Add Products → Marketing API → Set Up
Capture from Settings → Basic:
META_APP_IDMETA_APP_SECRET(click Show, requires password)
5.3 Create the per-product ad account
Business Settings → Accounts → Ad Accounts → + Add → Create new ad account. Currency + timezone are permanent.
Format: act_NNNNNNNNNNNNNNNN. Save as META_AD_ACCOUNT_ID (include the act_ prefix).
5.4 Create the pixel/dataset
You can do this via API once the System User exists:
curl -X POST "https://graph.facebook.com/v21.0/{AD_ACCOUNT_ID}/adspixels" \
-d "name=YourApp" \
-d "access_token=$META_SYSTEM_USER_TOKEN"
Or UI: Events Manager → Connect Data Sources → Web → Meta Pixel. Returns a 15-16 digit ID. Save as META_PIXEL_ID.
5.5 Create the System User (the API auth principal)
Business Settings → Users → System Users → Add:
- Name:
<Product> Automation - Role: Employee access (least privilege — Admin only if it needs to manage assets outside what's explicitly granted)
Then on the System User detail page, add assets BEFORE generating the token (the token inherits asset access):
- Apps → your Developer App → grant Develop App (Full control)
- Ad Accounts → your ad account → grant Manage ad account (Full control)
- Pixels/Datasets → your pixel → grant Manage Pixel
- Pages (if running ads with a Page) → grant Manage Page or at minimum Create content + Ads
5.6 Generate the System User access token
On the System User detail page → Generate New Token:
- Select the app
- Scopes:
ads_management,ads_read,business_management,read_insights - Token expiration: Never ✨ (only available for system user tokens)
Copy immediately — you can't view again, only regenerate. Save as META_SYSTEM_USER_TOKEN.
Verify with debug_token:
curl "https://graph.facebook.com/v21.0/debug_token?input_token=$TOKEN&access_token=$TOKEN"
# expect: type: "SYSTEM_USER", expires_at: 0, is_valid: true
5.7 Capture remaining Meta IDs
META_BUSINESS_ID= Business Portfolio ID (Business Settings → top-left dropdown)META_SYSTEM_USER_ID= numeric ID on the System User pageMETA_GRAPH_API_VERSION= pin to current stable (e.g.,v21.0), bump deliberately every ~12 months
6. Google Analytics 4 — server + client
6.1 Get the measurement ID
If GA4 is already installed (most projects have a G-XXXXXXXXXX from earlier), reuse it. Otherwise: GA4 → Admin → Data Streams → Add stream → Web → enter site URL. Copy the G-... measurement ID. Save as GA4_MEASUREMENT_ID and PUBLIC_GA_MEASUREMENT_ID.
6.2 Generate Measurement Protocol API secret
GA4 → Admin → Data Streams → click web stream → Measurement Protocol API secrets → Create:
- Nickname: e.g.,
<app>-server - Copy the secret (visible only once → store immediately)
Save as GA4_API_SECRET. This is what lets the Stripe webhook push purchase events server-side.
6.3 Link GA4 to Google Ads
Google Ads → Tools → Linked Accounts → Google Analytics (GA4) → click your property → Link. Confirm:
- Auto-tagging: On
- Site metrics import: On (engagement metrics flow to Ads reports)
- Audiences sync: On (GA4 audiences become Google Ads remarketing lists, ~24h delay)
7. Pixel + gtag installation (client-side)
7.1 Marketing site (Astro example)
In your root layout <head>:
---
const gaId = import.meta.env.PUBLIC_GA_MEASUREMENT_ID;
const adsId = import.meta.env.PUBLIC_GOOGLE_ADS_TAG_ID;
const pixelId = import.meta.env.PUBLIC_META_PIXEL_ID;
---
<script is:inline define:vars={{ gaId, adsId, pixelId }}>
// 1) Capture marketing click IDs to cookies on `.apex-domain.com`
(() => {
const url = new URL(location.href);
const captures = [
["gclid", "rm_gclid"], ["gbraid", "rm_gbraid"], ["wbraid", "rm_wbraid"],
["fbclid", "rm_fbclid"], ["ttclid", "rm_ttclid"], ["msclkid", "rm_msclkid"],
];
const host = location.hostname;
const cookieDomain = host.endsWith("yourapp.com") ? ".yourapp.com" : host;
for (const [param, key] of captures) {
const v = url.searchParams.get(param);
if (!v) continue;
try { localStorage.setItem(key, v); } catch {}
document.cookie = `${key}=${encodeURIComponent(v)}; path=/; domain=${cookieDomain}; max-age=7776000; samesite=lax; secure`;
}
})();
// 2) gtag + GA4 + Google Ads (with cross-domain linker)
window.dataLayer = window.dataLayer || [];
function gtag(){ window.dataLayer.push(arguments); }
window.gtag = gtag;
gtag('consent', 'default', { ad_storage:'denied', ad_user_data:'denied', ad_personalization:'denied', analytics_storage:'granted', wait_for_update: 500 });
const loadAnalytics = () => {
const s = document.createElement('script');
s.async = true;
s.src = `https://www.googletagmanager.com/gtag/js?id=${gaId}`;
document.head.appendChild(s);
gtag('js', new Date());
gtag('config', gaId, { allow_google_signals: true, cookie_flags: 'SameSite=None;Secure' });
gtag('config', adsId, {
allow_ad_personalization_signals: true,
cookie_flags: 'SameSite=None;Secure',
linker: { domains: ['app.yourapp.com'], accept_incoming: true },
});
};
// 3) Meta Pixel base
const loadPixel = () => {
!function(f,b,e,v,n,t,s){if(f.fbq)return;n=f.fbq=function(){n.callMethod?n.callMethod.apply(n,arguments):n.queue.push(arguments)};if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';n.queue=[];t=b.createElement(e);t.async=!0;t.src=v;s=b.getElementsByTagName(e)[0];s.parentNode.insertBefore(t,s)}(window,document,'script','https://connect.facebook.net/en_US/fbevents.js');
fbq('init', pixelId);
fbq('track', 'PageView');
};
if (document.readyState === 'complete') {
(window.requestIdleCallback || window.setTimeout)(() => { loadAnalytics(); loadPixel(); }, { timeout: 3500 });
} else {
window.addEventListener('load', () => setTimeout(() => { loadAnalytics(); loadPixel(); }, 2000), { once: true });
}
</script>
<noscript>
<img height="1" width="1" style="display:none"
src={`https://www.facebook.com/tr?id=${pixelId}&ev=PageView&noscript=1`} />
</noscript>
7.2 App subdomain (SPA index.html example)
Same idea but accept incoming linker:
gtag('config', gaId, { linker: { domains: ['yourapp.com'], accept_incoming: true } });
gtag('config', adsId, { linker: { domains: ['yourapp.com'], accept_incoming: true } });
This is what stitches a visitor across yourapp.com → app.yourapp.com so GCLID/GA client_id persist.
7.3 Fire standard events on key pages
// /pricing or /features
fbq('track', 'ViewContent', { content_name: 'Pricing', content_category: 'pricing' });
gtag('event', 'view_item', { send_to: GA4_ID, items: [{ item_id: 'pro', item_name: 'Pro' }] });
// Signup success (once per device via localStorage flag)
fbq('track', 'CompleteRegistration');
gtag('event', 'sign_up', { send_to: GA4_ID, method: 'email_otp' });
// Subscribe click (just before opening Stripe Checkout)
fbq('track', 'InitiateCheckout', { currency: 'USD', value: 29 });
gtag('event', 'begin_checkout', { send_to: GA4_ID, currency: 'USD', value: 29, items: [...] });
// Purchase: do NOT fire client-side. Server-side from Stripe webhook is canonical.
8. Click-ID capture and cross-domain attribution
The cookie-capture pattern in §7.1 stores click IDs on the apex domain so they survive subdomain navigation. The client then forwards them to your backend via Stripe Checkout Session metadata (not via signup, because cookies might not be present yet at signup but they ARE at subscribe time):
// Client-side helper
export function getMarketingAttribution() {
const out = {};
for (const [name, key] of [
["rm_gclid", "gclid"], ["rm_gbraid", "gbraid"], ["rm_wbraid", "wbraid"],
["rm_fbclid", "fbclid"],
]) {
const v = readCookie(name) || localStorage.getItem(name);
if (v) out[key] = v;
}
if (readCookie("_fbp")) out.fbp = readCookie("_fbp");
if (readCookie("_fbc")) out.fbc = readCookie("_fbc");
if (readCookie("_ga")) out.ga_client_id = readCookie("_ga");
return out;
}
// On "Subscribe" click:
const marketing = getMarketingAttribution();
trpc.billing.createCheckoutSession.mutate({ priceId, marketing });
In the tRPC procedure that creates the Stripe session:
const clientIp = req.headers.get("cf-connecting-ip") || ...;
const clientUserAgent = req.headers.get("user-agent");
await stripe.checkout.sessions.create({
customer: stripeCustomerId,
mode: "subscription",
line_items: [...],
metadata: {
user_id: userId,
...marketing, // gclid, fbp, fbc, ga_client_id, fbclid
client_ip: clientIp,
client_user_agent: clientUserAgent,
},
subscription_data: {
metadata: { user_id: userId, ...(marketing?.gclid ? { gclid: marketing.gclid } : {}) },
},
return_url: ...,
});
9. Server-side conversion uploads
The unified module is apps/app-api/src/lib/ad-conversions.ts. It exports reportPurchaseToAdPlatforms() which fires three uploads in parallel; failures are logged and never propagate (so the webhook still returns 200 to Stripe).
9.1 Meta CAPI Purchase
async function uploadMetaCapiPurchase(env, input, logger) {
if (!env.META_PIXEL_ID || !env.META_SYSTEM_USER_TOKEN) return;
const userData = {};
if (input.email) userData.em = [await sha256Hex(input.email)];
if (input.fbp) userData.fbp = input.fbp;
if (input.fbc) userData.fbc = input.fbc;
if (input.clientIp) userData.client_ip_address = input.clientIp;
if (input.clientUserAgent) userData.client_user_agent = input.clientUserAgent;
const body = {
data: [{
event_name: "Purchase",
event_time: Math.floor(Date.now() / 1000),
event_id: input.sessionId, // dedup
action_source: "website",
event_source_url: "https://app.yourapp.com/billing",
user_data: userData,
custom_data: {
currency: input.currency, value: input.value,
order_id: input.sessionId,
},
}],
};
await fetch(
`https://graph.facebook.com/v21.0/${env.META_PIXEL_ID}/events?access_token=${token}`,
{ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) },
);
}
9.2 Google Ads Offline Click Conversion
async function uploadGoogleAdsOfflineConversion(env, input, logger) {
if (!input.gclid) return; // no-op without click ID
if (!env.GOOGLE_ADS_PURCHASE_CONVERSION_ACTION) return;
// 1) OAuth refresh → access token
const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: env.GOOGLE_ADS_CLIENT_ID,
client_secret: env.GOOGLE_ADS_CLIENT_SECRET,
refresh_token: env.GOOGLE_ADS_REFRESH_TOKEN,
grant_type: "refresh_token",
}),
});
const { access_token } = await tokenRes.json();
// 2) Upload click conversion
const now = new Date();
const pad = (n) => String(n).padStart(2, "0");
const dt = `${now.getUTCFullYear()}-${pad(now.getUTCMonth()+1)}-${pad(now.getUTCDate())} ${pad(now.getUTCHours())}:${pad(now.getUTCMinutes())}:${pad(now.getUTCSeconds())}+00:00`;
await fetch(
`https://googleads.googleapis.com/v20/customers/${env.GOOGLE_ADS_CUSTOMER_ID}:uploadClickConversions`,
{
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${access_token}`,
"developer-token": env.GOOGLE_ADS_DEVELOPER_TOKEN,
"login-customer-id": env.GOOGLE_ADS_LOGIN_CUSTOMER_ID,
},
body: JSON.stringify({
conversions: [{
conversionAction: env.GOOGLE_ADS_PURCHASE_CONVERSION_ACTION,
conversionDateTime: dt,
conversionValue: input.value,
currencyCode: input.currency,
gclid: input.gclid,
orderId: input.sessionId,
...(input.email ? { userIdentifiers: [{ hashedEmail: await sha256Hex(input.email) }] } : {}),
}],
partialFailure: true,
}),
},
);
}
Watch for API version sunsets. v18/v19 sunset early 2026. Bump deliberately and watch deprecation warnings in response headers.
9.3 GA4 Measurement Protocol Purchase
async function sendGa4MeasurementProtocolPurchase(env, input, logger) {
if (!env.GA4_MEASUREMENT_ID || !env.GA4_API_SECRET) return;
const clientId = input.clientId // real _ga from cookie
|| (input.userId ? `srv.${input.userId}` : `srv.${input.sessionId}`);
const body = {
client_id: clientId,
...(input.userId ? { user_id: input.userId } : {}),
timestamp_micros: Date.now() * 1000,
events: [{
name: "purchase",
params: {
transaction_id: input.sessionId, // dedup
value: input.value, currency: input.currency,
items: [{ item_id: "subscription", item_name: "Pro", price: input.value, quantity: 1 }],
engagement_time_msec: 1,
},
}],
};
await fetch(
`https://www.google-analytics.com/mp/collect?measurement_id=${env.GA4_MEASUREMENT_ID}&api_secret=${env.GA4_API_SECRET}`,
{ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) },
);
}
9.4 Wire into Stripe webhook
In your checkout.session.completed handler:
const baseItem = subscription.items.data[0];
const value = (baseItem?.price.unit_amount ?? 0) / 100;
const currency = (baseItem?.price.currency ?? "usd").toUpperCase();
const email = session.customer_details?.email ?? session.customer_email ?? null;
const sessionMeta = (session.metadata ?? {});
const gclid = sessionMeta.gclid || subscription.metadata?.gclid || null;
const fbp = sessionMeta.fbp || null;
const fbc = sessionMeta.fbc || null;
const gaCookie = sessionMeta.ga_client_id || null;
const gaClientId = gaCookie?.split(".")[2] ?? null; // _ga format: GA1.1.<id>.<ts>
const userId = subscription.metadata?.user_id || sessionMeta.user_id;
await reportPurchaseToAdPlatforms(env, {
sessionId: session.id,
email, value, currency,
gclid, fbp, fbc,
clientId: gaClientId,
userId,
clientIp: sessionMeta.client_ip,
clientUserAgent: sessionMeta.client_user_agent,
}, logger);
10. Audiences and remarketing
10.1 Meta Custom Audiences (5 standard ones)
Programmatic creation requires Meta Custom Audience Terms of Service accepted once via UI (https://www.facebook.com/customaudiences/app/tos/?act={AD_ACCOUNT}). Then:
node ./create-meta-audiences.js
Audiences:
- Site visitors (30d) — PageView lookback
- Viewed pricing or features (14d) — PageView with URL filter
- Initiated checkout, no purchase (14d) — InitiateCheckout AND NOT Purchase
- Purchasers (180d) — exclusion — for filtering out existing customers
- Purchasers (180d) — lookalike seed — feed for Lookalike Audience
These auto-populate from your pixel events. Use in any campaign as inclusion/exclusion.
10.2 Google Ads native user lists (4 rule-based)
node ./create-google-ads-userlists.js
Lists:
- Site visitors (30d) — URL contains
replymagic.ai - Pricing page viewers (14d) — URL contains
/pricing - Features page viewers (14d) — URL contains
/features - App users (90d) — exclusion — URL contains
app.replymagic.ai
REST endpoint: POST /v20/customers/{id}/userLists:mutate with ruleBasedUserList.flexibleRuleUserList.
10.3 GA4 audiences (4 templated)
UI clicks (30 sec each): GA4 → Admin → Audiences → Suggestions:
- Non-purchasers — best Google Ads exclusion
- Purchasers — for exclusion
- 7-day inactive purchasers — churn re-engagement
- 7-day inactive users — general re-engagement
Auto-sync to Google Ads (24h delay) once GA4 link is active.
Note: GA4 Predictive Audiences (Likely 7-day purchasers, etc.) require ~1,000 positive + 1,000 negative training examples within 28 days. Revisit when you have spend data.
11. Domain verification + iOS attribution (AEM)
11.1 Meta domain verification
Required for the iOS attribution stack to function at all. Business Settings → Brand Safety → Domains → Add → enter apex domain → choose Meta-tag verification → copy the content="..." value → embed:
<meta name="facebook-domain-verification" content="..." />
In the <head> of every page. For Astro, conditionally render via env var with a hardcoded fallback (the value is public anyway):
const fbVerify = import.meta.env.PUBLIC_FACEBOOK_DOMAIN_VERIFICATION || "<hardcoded value>";
{fbVerify && <meta name="facebook-domain-verification" content={fbVerify} />}
Click Verify in Meta UI. Usually resolves in minutes.
11.2 Aggregated Event Measurement (AEM) — UPDATED June 2025
As of June 2025, Meta removed manual AEM configuration. The "Aggregated Event Measurement" tab is gone from Events Manager. There's no 8-event limit, no priority ranking, no manual setup. Meta now auto-aggregates ALL eligible events behind the scenes.
What this means for you:
- You do NOT need to rank events anymore. Skip that step if you see anywhere recommending it; the docs are out of date.
- All your standard events (Purchase, InitiateCheckout, CompleteRegistration, ViewContent, PageView, etc.) automatically count for iOS attribution.
- Value Optimization now considers the summed value of all eligible events.
What you DO still need:
- Domain verification (above) — still required as the link-ownership anchor.
- Pixel + CAPI correctly implemented — Meta's auto-aggregation runs on the events you send.
- Dedup via
event_id— same as ever, so pixel and CAPI Purchases don't double-count. - Consistent event names + schemas across pixel and CAPI.
- Quality monitoring via Events Manager → Diagnostics + Test Events.
Reference: Conversios blog "Meta Aggregated Event Measurement Explained 2025" (May 2025 update notice).
11.3 Google domain verification
Mostly automatic via auto-tagging + GA4 link. You only need to verify domains that you'll set as conversion sources, and that's handled when the Google Tag fires on them.
12. Environment variables reference
Server-side (Cloudflare Worker / Node backend)
| Var | Example | Purpose |
|---|---|---|
META_PIXEL_ID |
<your-meta-pixel-id> |
Pixel/dataset ID; used by CAPI URL |
META_SYSTEM_USER_TOKEN |
EAA... |
Long-lived token; CAPI auth |
META_CAPI_ACCESS_TOKEN |
(optional) | Override token if you want isolation; falls back to system user token |
META_APP_SECRET |
<your-meta-app-secret> |
For appsecret_proof HMAC signing (recommended) |
META_GRAPH_API_VERSION |
v21.0 |
Pin and bump deliberately |
GOOGLE_ADS_DEVELOPER_TOKEN |
<your-developer-token> |
Authorizes Google Ads API |
GOOGLE_ADS_CLIENT_ID |
74...apps.googleusercontent.com |
OAuth Desktop client |
GOOGLE_ADS_CLIENT_SECRET |
GOCSPX-... |
OAuth secret |
GOOGLE_ADS_REFRESH_TOKEN |
1//03... |
Long-lived refresh token |
GOOGLE_ADS_LOGIN_CUSTOMER_ID |
<your-mcc-id> |
MCC ID (no dashes) |
GOOGLE_ADS_CUSTOMER_ID |
<your-ad-account-id> |
Per-product ad account ID |
GOOGLE_ADS_PURCHASE_CONVERSION_ACTION |
customers/.../conversionActions/... |
Resource name of Purchase conversion |
GA4_MEASUREMENT_ID |
G-XXXXXXXXXX |
GA4 web stream ID |
GA4_API_SECRET |
<your-ga4-api-secret> |
Measurement Protocol secret |
Client-side (PUBLIC_ for Astro / VITE_ for Vite / NEXT_PUBLIC_ for Next.js)
| Var | Example | Where it's used |
|---|---|---|
PUBLIC_META_PIXEL_ID |
<your-meta-pixel-id> |
Astro Layout.astro |
PUBLIC_GOOGLE_ADS_TAG_ID |
AW-XXXXXXXXX |
Astro Layout.astro |
PUBLIC_GA_MEASUREMENT_ID |
G-XXXXXXXXXX |
Astro Layout.astro |
PUBLIC_FACEBOOK_DOMAIN_VERIFICATION |
<your-fb-domain-verification-token> |
Astro Layout.astro |
13. Deploy / secret management
13.1 Secrets store (Infisical)
Push to all environments at the right path:
# Server (Worker) — /apps/api in Infisical
infisical secrets set "META_PIXEL_ID=..." "META_SYSTEM_USER_TOKEN=..." ... --env=prod --path=/apps/api
# Public client (landing-page) — /apps/landing-page
infisical secrets set "PUBLIC_META_PIXEL_ID=..." ... --env=prod --path=/apps/landing-page
13.2 Sync to Cloudflare Worker on prod deploy
scripts/sync-cloudflare-worker-secrets.mjs reads Infisical and pushes via wrangler secret put. Allowlist must include all keys. Run:
pnpm run secrets:sync:cloudflare # defaults to env=prod, path=/apps/api
INFISICAL_ENV=staging pnpm run secrets:sync:cloudflare # if staging exists
13.3 Local dev
scripts/write-cloudflare-dev-vars.mjs writes .dev.vars from infisical run env. Called inline by pnpm dev in app-api package.json:
"dev": "infisical run --env=dev --path=/apps/api -- node ../../scripts/write-cloudflare-dev-vars.mjs .dev.vars && wrangler dev ..."
Keep the allowlist in write-cloudflare-dev-vars.mjs in sync with sync-cloudflare-worker-secrets.mjs.
13.4 Landing-page build env
Astro reads import.meta.env.PUBLIC_* at build time. For Cloudflare Pages, set them in the dashboard or via infisical run --env=prod --path=/apps/landing-page -- astro build. Hardcoded fallbacks in the layout file are a safety net for public values (pixel ID, gtag ID, FB domain verification) since they ship in HTML anyway.
14. Validation checklist (run before going live)
Run through this before your first real purchase. Each step takes < 1 min.
A. Confirm secrets in production
# Worker secrets — must show all META_*, GOOGLE_ADS_*, GA4_*
cd apps/app-api && pnpm exec wrangler secret list --env=""
Expected entries (at minimum):
- META_PIXEL_ID, META_SYSTEM_USER_TOKEN, META_APP_SECRET, META_GRAPH_API_VERSION
- GOOGLE_ADS_DEVELOPER_TOKEN + 5 other GOOGLE_ADS_*
- GA4_MEASUREMENT_ID, GA4_API_SECRET
B. Confirm pixel + tags load on production
curl -sL https://yourapp.com/ | grep -cE "fbevents.js|AW-|G-|facebook-domain-verification"
# expect: ≥ 4 hits
In a real browser tab:
- DevTools → Network → filter "fbevents" → confirm 200 response
- DevTools → Network → filter "googletagmanager" → confirm 200
- Application → Cookies → confirm
_fbp,_ga,_gcl_awpresent after page load
C. Confirm test events fire
Meta:
- Events Manager → Test events → enter your URL → click around → see PageView land
GA4:
- Realtime report → confirm
page_viewfrom your IP
D. Run a $1 / live purchase end-to-end
- Open
https://yourapp.com/pricing?gclid=test_validation_$(date +%s) - Click subscribe → enter real card → complete payment
- Watch within 30 sec:
- Meta Events Manager → Test Events →
Purchaseevent, Action Source: Server - GA4 → Realtime →
purchaseevent withtransaction_idmatching Stripe session
- Meta Events Manager → Test Events →
- Watch within 1 hour:
- Meta Events Manager → Overview → Purchase row with count
- GA4 → Reports → Monetization → Purchases → row with revenue
- Google Ads → Tools → Conversions (if
GOOGLE_ADS_PURCHASE_CONVERSION_ACTIONset) → Purchase row
E. Check webhook logs
cd apps/app-api && pnpm exec wrangler tail --env=""
# Look for these log lines after the purchase:
# "Meta CAPI Purchase uploaded" (events_received: 1)
# "Google Ads offline Purchase conversion uploaded"
# "GA4 MP purchase uploaded"
If any one fails:
Meta CAPI skipped (missing pixel id or token)→ secret not setGoogle Ads offline conversion skipped (no gclid)→ click ID wasn't captured/forwarded (test with?gclid=test_...in URL)Google Ads OAuth token exchange failed→ refresh token expired or wrong clientGA4 MP upload failed: 400→ measurement ID or api secret wrong
F. Refund the test purchase (optional)
Stripe Dashboard → Payments → find charge → Refund. The conversion event stays in Meta/GA4/Ads history (doesn't get reversed) — that's intentional, doesn't hurt anything.
14b. Lessons learned (from ReplyMagic live validation)
These are real gotchas that bit us during validation. Save yourself the time on the next app.
14b.1 Deploys must include the commit that wires conversions in
Symptom: Webhook fires (Pro granted) but no "Meta CAPI Purchase uploaded" log line appears.
Cause: Deploy ran from a code state before reportPurchaseToAdPlatforms() was wired into stripe.utils.ts.
Fix: After every code change to the webhook handler, redeploy app-api before testing. Verify the running version with wrangler versions list --env="" and compare the Created timestamp to your most recent commit on apps/app-api/src/webhook/router/stripe.utils.ts.
14b.2 Cloudflare log UI truncates by default
Symptom: Filtering by recent logs shows only 10 entries — start of webhook + end, nothing in the middle.
Cause: Cloudflare's Logs UI shows a paginated subset.
Fix: Filter by the specific request ID (e.g., 9faa33632bc0cb90) to see ALL log lines for that single request, even if 15-25.
14b.3 Meta "Test Events" tab is the wrong place to look
Symptom: Production Purchase event doesn't appear in Test Events tab.
Cause: That tab only shows events tagged with test_event_code: TEST_xxxxx. Production CAPI doesn't include that.
Fix: Look in Events Manager → Overview instead. Real production events appear there with 15-30 min lag. Action Source: Server is the marker that CAPI fired (vs Browser = pixel).
14b.4 Meta "0 Websites" is normal at first
Symptom: Overview shows "0 Websites — No websites found" even with PageView events firing. Cause: Meta UI lag. Domain registers ~15-30 min after first event. Fix: Wait. Don't add the website manually.
14b.5 Low fbc coverage warning is expected for test purchases
Symptom: "Try the parameter builder tool" banner appears in Events Manager.
Cause: Your test purchase came without ?fbclid=... in the URL, so _fbc cookie was never set, so the CAPI event has no fbc field.
Fix: Ignore. fbc coverage will improve organically once real Meta-driven traffic flows. Do NOT install the parameter-builder SDK — you have proper dedup via event_id already.
14b.6 Stripe metadata is the right transport for click IDs
Symptom: Lots of advice online says to capture click IDs into a DB column, signup flow, etc. Easier path: Set them as metadata on the Stripe Checkout Session at creation time. They roundtrip through the webhook for free, no schema changes needed.
metadata: {
user_id, gclid, fbp, fbc, ga_client_id, client_ip, client_user_agent
}
Stripe metadata caps each value at 500 chars; strip empty/oversized values before passing.
14b.7 GA4 client_id needs the 3rd segment of _ga
The _ga cookie value is GA1.1.<clientId>.<timestamp>. Extract index 2 (the clientId) before passing to Measurement Protocol; sending the raw cookie value is invalid. Fallback to srv.<userId> if cookie is missing — synthesizes a stable id so server-side sessions don't fragment into anon users.
14b.8 Google Ads API v18/v19 sunset early 2026
Use v20+ for all REST calls. Reference URL pattern:
https://googleads.googleapis.com/v20/customers/{customer_id}:uploadClickConversions
https://googleads.googleapis.com/v20/customers/{customer_id}/userLists:mutate
https://googleads.googleapis.com/v20/customers/{customer_id}/googleAds:searchStream
Probe supported versions with curl returning 200 vs 404:
for V in v20 v21 v22 v23; do
curl -s -o /dev/null -w "$V → %{http_code}\n" -X POST \
"https://googleads.googleapis.com/$V/customers/{id}/googleAds:search" \
-H "Authorization: Bearer $TOKEN" -H "developer-token: $DEV_TOKEN" \
-H "Content-Type: application/json" \
-d '{"query":"SELECT customer.id FROM customer LIMIT 1"}'
done
14b.9 Meta Custom Audience TOS must be accepted by a human, once per ad account
Symptom: (#2663) Terms of service has not been accepted when creating audiences via API.
Fix: Visit https://www.facebook.com/customaudiences/app/tos/?act={AD_ACCOUNT_ID}. One click. Then re-run script.
14b.10 GA4 audiences sync to Google Ads (24h delay) — saves you native list creation
Once GA4 ↔ Google Ads is linked, any audience built in GA4 auto-syncs as a Google Ads remarketing list. You can skip creating native Google Ads UserLists if you prefer to manage audiences only in GA4.
14c. Post-launch: doing the remaining "what's left" steps
After the first live purchase fires through end-to-end, here's how to complete the remaining configuration with current 2025-2026 best practices (researched via Exa).
14c.1 Meta AEM — actually nothing to do anymore (June 2025 update)
Skip this entirely. See §11.2 for why. Old playbooks tell you to rank 8 events; that UI is gone. Meta auto-aggregates everything you send.
14c.2 Switch Google Ads Purchase conversion source to "Import"
This unlocks server-side offline conversion uploads from your Stripe webhook.
Option A — Edit existing Purchase conversion action (if you already created one):
- Google Ads → top right wrench icon → Tools → Conversions
- Click your existing Purchase conversion
- Edit settings → expand Source → select Import → CRMs, files or other data sources → Track conversions from clicks → Save
- Note: the action's
typemust end up asUPLOAD_CLICKS. If the UI won't let you switch source, create a new one (Option B).
Option B — Create a fresh import-only conversion action (cleanest):
-
Google Ads → Goals icon (target) in left sidebar → Conversions → Summary → + New conversion action
-
On the "New conversion action" page, select Import
-
Select CRMs, files or other data sources → Track conversions from clicks
-
In the Data source step, select Skip this step (we upload via API, not Data Manager)
-
Configure:
- Category: Purchase
- Conversion name:
ReplyMagic Pro Subscription(or per-app) - Value: Use different values for each conversion (we send real $)
- Count: Every
- Click-through window: 30 days
- Attribution model: Data-driven
-
Save. Find the resource name via API:
SELECT customer.id, conversion_action.id, conversion_action.name, conversion_action.type, conversion_action.resource_name FROM conversion_action WHERE conversion_action.type = 'UPLOAD_CLICKS' AND conversion_action.status = 'ENABLED'(Run from your existing google-ads-api smoke script.) Resource name format:
customers/{cid}/conversionActions/{aid}. -
Store in Infisical + sync to Cloudflare Worker:
infisical secrets set "GOOGLE_ADS_PURCHASE_CONVERSION_ACTION=customers/.../conversionActions/..." \ --env=prod --path=/apps/api infisical secrets set "GOOGLE_ADS_PURCHASE_CONVERSION_ACTION=customers/.../conversionActions/..." \ --env=dev --path=/apps/api pnpm run secrets:sync:cloudflare pnpm --filter @reply-magic/app-api deploy -
Next paid subscription that came via a Google ad click (with gclid in cookie) will fire
Google Ads offline Purchase conversion uploadedin webhook logs and appear in Google Ads → Conversions within ~3 hours.
Upgrade path: Google now strongly recommends "Enhanced conversions for leads" (uses hashed email + GCLID together for better match rate). Our existing offline upload code already passes
userIdentifiers: [{ hashedEmail }], which is the same data shape. To officially "upgrade", you may need to also tick theAccept Customer Data TermsandEnhanced conversions for leads enabledcheckboxes on the conversion action settings page. See https://support.google.com/google-ads/answer/15479486
14c.3 First Meta campaign — Sales objective (not Advantage+ yet)
Important caveat for SaaS: Most "Advantage+ Shopping" how-tos assume e-commerce with 10+ products and 50+ weekly purchases. ReplyMagic-style SaaS at the cold-start phase doesn't meet that bar. Modern guidance (Stackmatix 2025):
"Avoid Advantage+ when your account has fewer than 50-100 weekly conversions, your audience is under 500K people, your product requires a long consideration cycle, or your pixel has limited history. In these cases, manual campaigns with defined audiences typically perform better and give you more control over where budget flows."
Better starting structure for your first $20-30/day SaaS campaign:
- Ads Manager → Create → Sales objective (not Advantage+ Shopping)
- Campaign budget: Daily budget $20-30 (commit to $600/month minimum to give the learning phase oxygen)
- Ad set level:
- Conversion location: Website
- Performance goal: Maximize number of conversions (don't impose a target CPA in week 1)
- Pixel: ReplyMagic
- Conversion event: Purchase (the canonical event since you're optimizing for paid subs)
- If your pixel has <30 purchases in last 7 days, switch event to InitiateCheckout for week 1-2 — same campaign, just better learning signal. Move back to Purchase once you have stable purchase volume.
- Audience: Broad. United States (or your priority country), 25-55, English. No detailed targeting beyond that.
- Exclude audience:
Purchasers (180d) — exclude from acquisition(the one we created via script) - Placements: Advantage+ Placements (let Meta optimize)
- Ad level: Upload 6-10 distinct creatives. Mix:
- Product demo screen recording (15-30s)
- Customer carousel
- Single image with bold headline
- UGC-style talking head
- Before/after split-screen
- Run for 7 days minimum without touching. Don't pause low-performing creatives; let Meta's algorithm reallocate.
- Day 8 review:
- If CPA on Purchase is under your LTV ceiling → scale budget 20-30%, keep going
- If CPA is too high but InitiateCheckout volume is healthy → conversion-rate problem at checkout, fix that before scaling
- If neither event fires → creative + offer problem; refresh hooks
Retargeting campaign (run in parallel):
- Same Sales objective, same Purchase event
- Audience inclusion:
Initiated checkout, no purchase (14d)+Viewed pricing or features (14d) - Audience exclusion:
Purchasers (180d) - Budget: $5-10/day
- Creative: testimonials + reminder copy ("Still thinking about it?")
Lookalike audiences: Wait until you have 100+ purchasers in the seed audience before creating LALs. Currently you have 1. Revisit in 2-3 months.
14c.4 First Google Ads campaign — Search FIRST, then Performance Max
For B2B SaaS, expert consensus (Aimers, Growleads, Stackmatix): Don't lead with Performance Max. PMax needs 30-50 monthly conversions across the account before its AI can optimize. New SaaS account = no conversion history = PMax spends without learning.
Better starting structure:
Step 1: Launch a focused Search campaign FIRST (week 1-4):
- Campaign: Search, Sales objective, $15-25/day
- Bidding: Maximize Conversions (no target CPA yet)
- Keywords: 5-10 high-intent terms — your product name + alternatives ("ReplyMagic", "AI Instagram comments", "Instagram auto reply tool")
- Ad copy: 3 RSAs (Responsive Search Ads), each with 15 headlines + 4 descriptions
- Sitelink extensions: pricing, features, demo
- Conversion: Purchase action (the one we just configured with Import source)
- Negative keywords: free, jobs, login, careers
- Geographic: your priority countries
This generates conversion data in your account in 2-4 weeks. Once you have 30+ conversions/month account-wide, PMax becomes viable.
Step 2: Launch Performance Max (week 4+):
- Ads → + New Campaign → Sales → Performance Max
- Conversion goal: Purchase
- Budget: 3-5× your target CPA daily ($300-500 if target CPA is ~$100). Less = learning phase wastes everything.
- Bidding: Maximize Conversions for first 14 days, then switch to Target CPA once stable.
- Asset groups (create 2-3, themed by use case, NOT one mega-group):
- "Instagram Creators — Auto-reply" — speak to solo creators
- "Agencies — Manage Client IG" — speak to agencies running multiple accounts
- Each group: 15 headlines, 5 descriptions, 5+ images per aspect ratio (1.91:1, 1:1, 4:5), at least 1 video (16:9, 9:16, 1:1)
- Audience signals (NOT targeting; just hints to the algorithm):
- Customer Match: upload current paying customer emails (CRM export)
- Remarketing: GA4
Site visitors (30d)audience - In-market: Business Software, Social Media Marketing
- Custom intent: search terms like "instagram comment automation", "AI social media replies"
- Final URL expansion: Off (initially — gives you more control over landing pages while you learn what works)
- Brand exclusions: add competitor brand names you don't want Google to scrape for
- Run 30 days minimum. Don't touch.
Step 3: After 60-90 days, audit. Look at:
- Asset performance reports (Google shows which headlines/images are "Best/Good/Low")
- Brand vs. non-brand split (Search will eat your branded traffic; you want it bidding on non-brand too)
- Whether PMax CPA is competitive vs. Search-only CPA — if not, redirect budget back to Search
14c.5 Add Enhanced Conversions (free uplift, ~30 min)
Once your offline upload is firing reliably:
- Google Ads → Tools → Conversions → click your Purchase conversion → Settings
- Scroll to Enhanced conversions → toggle ON → accept Customer Data Terms
- Method: API (since we upload server-side)
- Our code already sends
hashedEmailin theuserIdentifiersarray — Google will match these against signed-in Google accounts for cross-device attribution. - Match rate improves ~10-20% typically.
14c.6 Set up Google Ads remarketing TAG (optional but recommended)
You already have the Google Ads gtag firing on the marketing site. Confirm it's adding visitors to a remarketing list:
- Google Ads → Tools → Audience Manager → Your data sources
- Find "Google Ads tag" — should show last activity within 24 hours
- If empty, set up a Tag-driven remarketing list at: Audience Manager → Segments → + → "Website visitors" → All visitors in 30 days
- This complements the 4 rule-based user lists we created via API.
15. Future enhancements
Order roughly by ROI:
- GA4 Enhanced Conversions for Ads — pass hashed email/phone via gtag user_data so Google can do server-side identity matching. Big match-rate boost.
- Meta Conversions API Gateway (optional) — a hosted middleware between your server and Meta's Graph API. Adds reliability but we already have direct CAPI, so marginal.
- Server-side GA4 events for
sign_upandbegin_checkout— currently client-side only. Bulletproof against ad-blockers if mirrored server-side. - TikTok Ads + LinkedIn Ads — same pattern: capture click ID at landing, forward via Stripe metadata, server-side conversion API from webhook.
- Looker Studio dashboard — connect Meta + Google Ads + GA4 + Stripe → unified ROAS/CAC reporting.
- Cohort retention as conversion event — fire a
Subscription Active 30 Daysevent after a user's been billed for 30 consecutive days → optimize ad spend on actual retention, not first-payment. - Predicted LTV in offline uploads — pass
predicted_ltvinStartTrial/Subscribeevents so Smart Bidding optimizes for high-value customers, not just any customer.
Quick checklist for replicating to a new app
- Create new Cloud project + enable Google Ads API
- Create new OAuth Desktop client → generate refresh token (use MCC owner Google account)
- Create new ad account in same MCC (or link existing) — confirm currency + timezone
- Get gtag ID + create Purchase conversion action (import source)
- In Meta Developer apps → create Live app, link to Business Portfolio → grab App ID + Secret
- Create ad account in same Business Portfolio
- Create new System User in same portfolio → assign assets → generate Never token
- Create pixel (UI or API)
- Get GA4 measurement ID, generate MP API secret
- Verify domain in Meta (meta-tag method)
- Copy
ad-conversions.ts+ Layout.astro snippet + marketing-attribution.ts to new repo - Update env vars in secrets store + sync to deploy target
- Run audience creation scripts (Meta + Google Ads)
- Run validation checklist §14
- After first purchase: configure AEM in Meta
- After first purchase: switch Google Ads Purchase action source to "Import" + set conversion action env var
Estimated time per new app: 2-3 hours (assuming Business Verification + Google Ads basic access already completed for the legal entity).