Article Information

Category: Marketing

Published: May 28, 2026

Author: Chris de Gruijter

Reading Time: 20 min

Tags

Meta AdsPaid AdvertisingCampaign StrategyConversion TrackingCLIFunnelReporting
Two people planning a Meta Ads campaign funnel and strategy on a whiteboard

How I Plan a Meta Ads Campaign End to End: From First Principles to Programmatic Build

Published: May 28, 2026

Most write-ups about Meta Ads either show you which buttons to click or sell you a "winning formula" that conveniently ignores how messy real campaigns are. This is neither. It's a behind-the-scenes account of how I actually planned and built a paid-advertising campaign for a client — a B2B company that sells modular business software to SMEs — from the first principles all the way down to building the campaign shells programmatically from my terminal.

I want to focus on the thinking and the discipline rather than the steps, because that's the part nobody writes down. Why a video-first awareness funnel instead of going straight for leads? Why keep a meticulous audit log of every live change? Why audit the landing page before a single euro is spent? And why, when a tool let me set a bid strategy, I chose not to. If you run your own campaigns, I hope you take away something about rigour and process — not a template to copy.

1. Conceptualising: start from the data, not the idea

The temptation at the start of any campaign is to jump straight to the creative idea — the clever hook, the punchy headline. I do the opposite. Before I let myself have an opinion, I read the historical performance data. This client had been running Meta Ads on and off for the better part of a year, so I had real numbers to anchor on rather than guesses.

The history told a clear story. Across roughly €2,400 of spend, the account had produced 87 leads at an average cost per lead of about €28. But the average hid the signal. The best-performing ad set — a pixel-based set targeting a wholesale audience — pulled leads at €18.72 with the lowest cost per click and the lowest CPM in the account. The worst performers (a retail interest set, a couple of entrepreneurship video sets on cold audiences) burned money and produced almost nothing. Two findings mattered most:

  • Pixel beat instant forms. Website-based pixel ad sets averaged ~€24 CPL versus ~€35 for Facebook-hosted instant forms — and gave far better funnel visibility and attribution.
  • Video was efficient at attention, terrible at direct conversion. Cost per view sat at €0.16–0.18, but those video sets converted poorly on cold audiences. The role of video was awareness, not the bottom of the funnel.

That second point is the whole concept in one sentence. The client had a strong 60-second brand video in production — a story built around a tailoring metaphor (off-the-rack software never fits; bespoke is expensive and clunky; modular software fits exactly). The data said: lead with that video to build attention cheaply, then convert the warmed-up audience later. So the concept wrote itself from the numbers. A video-first funnel, with one clear proposition — "software that fits" — carried across every stage.

This is the part I want to stress: the creative angle was a decision the data supported, not a flash of inspiration I then rationalised. A client meeting confirmed the direction and the budget constraints, but the spine of the plan came from looking honestly at what had already worked and what had already failed.

Your previous campaigns already ran the experiment. The least you can do is read the results before you spend more money repeating them.

— Chris de Gruijter

2. Strategising: designing the funnel

With the concept set, the strategy is mostly about sequencing and constraints. A funnel is not three campaigns running at once — it is a sequence where each phase earns the right to start the next. I designed four phases, knowing full well I would only launch the first one for now.

  1. Phase 1 — Awareness (video views). The 60-second video on cold audiences, optimised for full video views (ThruPlay), not conversions. The goal here is cheap, qualified attention and a growing pool of video viewers I can retarget later.
  2. Phase 2 — Conversion (cold). Static image ads pointing at the landing page, optimised for the lead event. This starts only once Phase 1 is out of its learning period and the landing page and tracking are validated.
  3. Phase 3 — Retargeting. A 15-second cut-down of the video served to people who watched the original or visited the landing page. Warm audiences, lower friction.
  4. Phase 4 — Instant forms (tested later). A Facebook-hosted lead form for warm audiences only, where the extra friction of a landing page is unnecessary.

Audience segmentation: an ownership gate, not a job-title filter

For a B2B product sold to SME owners, the targeting problem is reaching decision-makers without paying to show ads to employees, students and consumers. The obvious move — job-title targeting (CEO, Director, Owner) — is a trap in this market. Most owners of small businesses simply never fill in a job title on Facebook, and the title taxonomy skews English. Realistic coverage is maybe 10–20% of the actual population. That is not an audience; it is a sliver.

So I built sector-based saved audiences instead, each with an interest-and-behaviour OR-layer (the sector signals) and an AND-layer that acts as the ownership gate — an "entrepreneurship" interest combined with the "small business owners" behaviour. That AND-layer does the decision-maker filtering that a job-title audience would have done, but on signals with far higher coverage. Two launch audiences came out of this, each around 750,000–950,000 people in-market, both validated in the interface before anything went live.

I also deliberately left Advantage+ audiences switched off inside those sets. The whole point of the segmentation is to steer toward an owner profile; letting Meta optimise outside those parameters would dilute exactly the thing I was trying to control. Advantage+ still earns a place — but as a separate control group, so I can benchmark the manual method against full automation rather than blending the two and learning nothing.

A request came in to add a dedicated "senior management" audience. I pushed back, and documented why: it would overlap almost entirely with the ownership gate already baked into the existing sets, splitting budget across the same people without adding reach. Saying no to a reasonable-sounding request — and writing down the reasoning — is part of the job.

Budget and KPIs: planning around a hard cap

The budget was a hard €750/month — €25/day. That constraint shapes everything, and pretending otherwise is how campaigns quietly fail. So I phased the spend deliberately: start the awareness phase at €16/day split evenly across the two launch audiences, add the Advantage+ control group at €21/day in week two, then pause awareness and move the full €25/day to conversion once the funnel had warmed up.

The most important honesty in the whole plan is about Meta's learning phase. The textbook threshold is 50 conversions per ad set per week to exit learning. At €25/day and a realistic CPL, you get maybe 10 leads a week. That means the conversion campaign will run in "learning limited" more or less permanently — and that is not a sign of a broken setup, it is simply the reality at this budget. If you don't tell a client this up front, every weekly report becomes a panic about a status label that was never achievable.

So I set expectations against the trend, not the label: 15–25 leads/month at €30–50 CPL in the first six weeks, improving toward €22–30 as the campaign optimises. The signal to watch is a falling CPL trend over four weeks, not whether a badge says "learning complete". Setting a realistic number you can beat is far better than promising a number you can't.

3. Planning the build: the unglamorous discipline

This is the phase that separates campaigns that can be analysed from campaigns that can only be guessed at. None of it is exciting. All of it matters.

Creative briefing

I wrote a full brief for the designer rather than handing over a vague "make some ads". The video was the centrepiece for awareness; the conversion phase needed static formats. For the statics I specified two messaging angles (a cost-saving angle and a modular/control angle) and three variant types per angle — an actor still pulled from the video, a purely typographic text-card, and a product-UI dashboard shot. Three formats each (square, portrait, story).

That sounds like a lot of assets, and the brief explains why it is worth it: more variants do not raise the media budget — they extend creative lifespan and delay fatigue. The only cost is the designer's time, once. Skimping on variants just means the budget gets spent on burnt-out creatives faster. The brief also pinned down compliance details that are easy to forget until an ad gets rejected — for example, qualifying a large savings figure with "up to" rather than stating it as a promise, because Meta's review flags unqualified financial claims in this category.

A strict naming convention

Every campaign, ad set and ad follows a fixed pattern. It looks bureaucratic until the first time you read a report and instantly know what every row is without opening anything.

Campaign:  [CHANNEL]-[BRAND]-[OBJECTIVE]-[CAMPAIGN]-[PHASE]
Ad set:    [OBJECTIVE]-[AUDIENCE]-[GENDER]-[AGE]
Ad:        [ANGLE]-[FORMAT]-[FILETYPE]-[CONTENT]-[VERSION]

Example
Campaign:  FBN-XX-VVW-Project-Cold
Ad set:    VVW-AVT_TradeAudience-U-2555
Ad:        CostSaving-LNK-VID-VID60s-v1

One detail in there is a scar from experience: the Advantage+ label is written as "ADVPLUS", never "ADV+". A plus sign gets decoded as a space by some analytics tools when it lands in a URL parameter, which quietly corrupts your attribution. Conventions exist to survive contact with the tools downstream of them.

UTM tracking with dynamic placeholders

The UTM scheme uses Meta's dynamic placeholders so the campaign, ad set and ad names flow straight into analytics without manual tagging — and because the names follow the convention above, the analytics rows are already readable. The source placeholder resolves per impression to the network the ad actually ran on (Facebook, Instagram, Audience Network or Messenger), so you can split performance by platform without building separate campaigns for each.

utm_source={{site_source_name}}&utm_medium=cpc
&utm_campaign={{campaign.name}}
&utm_content={{adset.name}}
&utm_term={{ad.name}}

A small but real gotcha: there is no need for any "{lpurl}"-style placeholder here. That is a Google Ads concept. Meta appends the URL tags to the destination URL automatically. Carrying habits across platforms is a common way to break things.

Conversion tracking: pixel, server-side CAPI and custom events

Tracking was built in three layers. The Meta pixel loads through Google Tag Manager, consent-gated, and fires the standard page view. The meaningful events — a lead on form submission, a contact click on phone/WhatsApp/email, and a view-content on the landing page — fire from the application code rather than from GTM, and each one is mirrored server-side through the Conversions API.

The reason the events live in code rather than in GTM is deduplication. The browser pixel and the server-side CAPI event must share the same event ID so Meta counts them once, not twice. Coordinating a single ID is far simpler from a helper function than through dataLayer choreography. PII (email, phone, first name) is hashed with SHA-256 server-side before it ever leaves the server, and nothing fires before the user has given consent.

// One shared eventId for both channels = clean deduplication
const eventId = generateEventId(eventName)

// 1. Browser pixel (only if GTM has loaded it = consent given)
if ("undefined" !== 'undefined' && typeof window.fbq === 'function') {
  window.fbq('track', eventName, {}, { eventID: eventId })
}

// 2. Server-side CAPI — same eventId, PII hashed on the server
await fetch('/api/meta-event', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ eventName, eventId, userData, hasConsent }),
})

Before launch I validate this properly: the events go green in the Pixel Helper, both browser and server events arrive in Events Manager test events, and the match quality, deduplication rate, data freshness and event coverage all clear their thresholds. The awareness phase can technically start without all of this — video views optimise on ThruPlay, not conversions — but the conversion phase is hard-blocked on it. You cannot optimise toward a lead event the platform never reliably receives.

Auditing the landing page before spending a euro

No amount of clever targeting rescues a leaky landing page, so the page gets a CRO and readiness audit before launch. I checked the rendered page on desktop and at 360px mobile width, and scored it. It came out solid — but "solid" still surfaced a list of fixes worth making before paying for traffic:

  • Plugging conversion leaks — a clickable header logo and a row of module links were quietly offering paid visitors an exit out of the funnel.
  • Inline form, stripped navigation — keep the form on the page, drop the site nav. Full navigation on a paid landing page is just a menu of ways to leave.
  • Social proof above the fold — at least one customer reference visible in the hero, with real attribution rather than anonymous quote blocks that read like feature copy and are legally weak.
  • Copy orphans and form hierarchy — single words stranded on their own line on mobile, and placeholder text rendered larger than the labels above it. Small things that make a page feel unfinished.
  • Social/OG tags — the share title and URL pointed at the homepage rather than the campaign page, which would have made every shared link look generic.

The estimate was that landing-page-to-lead conversion could move from roughly 6.7% to 9–11% with these changes. That is the cheapest performance you will ever buy: you fix it once and every euro of traffic after that converts better. Auditing the page before launch, not after the first disappointing week, is the difference.

Keeping a campaign audit log

This is the discipline I would defend hardest. I keep a separate audit log — a plain changelog, newest entry on top — that records every live change to the campaign: what, when, by whom, by which method, the before-and-after, the reason, and whether it was verified. The strategy document stays the design; the audit log is the canonical record of what actually happened.

Why bother? Because three weeks into a campaign, when a metric moves, the first question is always "what changed?" — and human memory is a terrible source of truth. An audit log turns that question into a lookup. It also makes a campaign handover-able: someone else can read it and know exactly the state of the account. And it forces honesty, because writing down a change and its reason makes you justify the change to yourself before you make it.

4. Execution: building it programmatically (and where that broke)

I built the campaign structure from the terminal using the official Meta Ads CLI — the first-party tool, not a third-party wrapper. I am strict about that distinction: unofficial wrappers carry a real risk of account penalisation, and they are never worth it. Authentication is environment-variable based; with credentials loaded, creating the campaign shell is a single command.

meta -o json ads campaign create \
  --name "FBN-XX-VVW-Project-Cold" \
  --objective outcome_awareness \
  --no-adset-budget-sharing \
  --status paused

Everything was created paused — campaign shell plus three ad-set shells. Nothing went live. And this is where reality met the plan, in two instructive ways.

Limitation one: the bid-strategy trap

The CLI created the campaign with a "lowest cost with bid cap" strategy by default. For an awareness campaign I wanted "highest volume" (lowest cost, no cap). The CLI has no flag to set the bid strategy — not on the campaign, not on the ad set. Worse, the first ad-set creation failed outright with an API error demanding a bid amount, because a bid cap requires one.

I had a choice. I could force a bid cap by passing a placeholder amount just to make the API call succeed — which would have left the campaign running the wrong strategy. Or I could stop. I added a token placeholder bid purely to create the paused shells, and wrote a loud note in the audit log that the bid strategy must be corrected in the interface before launch. The principle: do not force a wrong configuration just because the tool allows it. A tool succeeding is not the same as a campaign being correct.

Limitation two: targeting beyond country

The second limitation was bigger. The CLI can create campaigns, ad sets, ads and creatives, but ad-set targeting is limited to country. There are no flags for age, gender, saved audiences, Advantage+ or placements. Since the entire strategy rests on those carefully built saved audiences and the ownership gate inside them, a fully programmatic build was off the table for now. The honest options were: finish the targeting in the interface, or write a Graph API script to push the full targeting JSON.

The honest result: a hybrid build

So the campaign was built as a hybrid. The CLI created the skeleton — campaign and ad sets, paused, named exactly to convention, verified by their returned IDs. The targeting, the bid strategy, the final budgets and the creatives get finished in Ads Manager (the video asset wasn't in the right vertical format yet either, which independently blocked uploading creatives via the CLI). A fully programmatic build is feasible later via the Graph API — I estimate half a day to a day of work — but it was not needed to start, and forcing it would have been effort spent proving a point rather than launching a campaign.

I am comfortable reporting that as the outcome because it is the truth, and because the audit log makes the seams explicit. A hybrid CLI-plus-interface build is not a failure of automation; it is automation used where it helps and stopped where it would force a wrong decision.

5. Owning the reporting layer: a dashboard built on the Graph API

A campaign you cannot measure on your own terms is a campaign you do not really control. Ads Manager is a capable interface, but it is someone else's interface — its columns, its date logic, its idea of what a "funnel" is, and its habit of changing the layout from under you. Once the same questions started coming up every week — what is the cost-per-lead trend, which audience is cheapest, are we hitting creative fatigue — I stopped answering them by clicking through Ads Manager and built my own reporting layer instead, straight on top of the Meta Graph API.

The tool is a small Streamlit dashboard in Python. It shells out to the same official Meta CLI I used for the build, requests insights as JSON, and reshapes them into the views I actually report on. Every number is pulled live from the API at request time — there is no spreadsheet to update, no manual export, no copy-paste step that can go stale or wrong.

Why build it instead of living in Ads Manager

  • I own the funnel definition. The dashboard renders the exact funnel from the strategy — impressions → link clicks → landing-page views → leads — with drop-off rates at each stage shown against the benchmarks I set, not a generic template.
  • I own the metrics that matter here. The split between instant-form leads and website/pixel leads, the CPL-over-time trend that tells the real story under a permanent learning-limited status, frequency plotted against CTR to catch fatigue early — these are the views this campaign needs, not the ones a default report happens to surface.
  • Benchmark comparison is built in. Every KPI is scored against a B2B lead-gen reference range (CPL, CTR, CPM, frequency, click-to-lead rate) and flagged green / amber / red, so "is this good?" has an answer on the page rather than in my head.
  • It is programmatic, so it composes. Because the data comes from the API, I can pull it on a schedule, diff date ranges, compare campaigns side by side, or feed the same numbers into anything else later. An interface you can only click is a dead end; an API you can query is a building block.

What the dashboard actually shows

It is organised into tabs, each answering one kind of question. A sidebar drives the whole thing — pick a campaign, set a date range, optionally select up to four other campaigns to compare against, and hit refresh.

  • Overview — the headline KPIs (spend, leads, CPL, impressions, reach, CTR, CPM, frequency) plus daily spend, daily leads, CPL-over-time, and a day-of-week breakdown.
  • Funnel — the four-stage funnel with drop-off rates against benchmark, and the instant-form vs website/pixel lead split.
  • Ad Sets — per-ad-set leads, CPL, spend and a spend-vs-leads bubble chart for spotting what to scale.
  • Audiences — performance grouped by the audience label parsed out of each ad set's targeting, so I can see which sector segment is cheapest.
  • Creatives — engagement signals (reactions, comments, saves, video views) and ad-level performance loaded on demand.
  • Compare — the selected campaigns side by side on the same KPIs.
  • Benchmarks — every metric scored against the B2B reference range, with plain-language recommendations.

Two engineering details make it pleasant to actually use day to day. The first is caching: API responses are cached with a sensible time-to-live (insight queries for an hour, the lighter list calls for a few minutes) so flipping between tabs is instant and I am not hammering the API or burning rate limit on every click. A single "Refresh Data" button clears the cache when I want genuinely live numbers. The second is an auth guard — if the access token is missing or has expired, the app says so clearly instead of failing with a stack trace.

# Shell out to the official Meta CLI, parse JSON, cache the result
@st.cache_data(ttl=3600, show_spinner=False)
def fetch_campaign_aggregate(campaign_id: str, since: str, until: str) -> dict:
    result = subprocess.run(
        ["meta", "--output", "json", "ads", "insights", "get",
         "--campaign-id", campaign_id, "--since", since, "--until", until,
         "--fields", "spend,impressions,clicks,ctr,cpm,frequency,actions"],
        capture_output=True, text=True, env=os.environ,
    )
    if result.returncode == 3:        # auth error — token expired
        st.error("Token expired — re-source the env file and restart.")
        st.stop()
    data = json.loads(result.stdout) if result.stdout.strip() else {}
    return data.get("data", [{}])[0]

A shareable snapshot that costs nothing to regenerate

The dashboard is for me. The stakeholder wants something they can open without running Python. So the Benchmarks tab has an export button that builds a self-contained static HTML report — the KPI table and the key charts baked into a single file, no server, no dependencies, no login. It is just a file you can email or drop in a shared folder, and it opens the same in any browser in five years as it does today.

The point that matters is that regenerating it is free and instant. Because it is generated from the live API rather than hand-assembled, producing this week's report is a button press, not an afternoon of pulling numbers into slides. That is the difference between reporting that happens reliably and reporting that quietly stops happening because it was too much effort — and it is the same principle as the audit log: make the disciplined thing the easy thing, and you will actually keep doing it.

Is a custom dashboard overkill for a single small campaign? On its own, maybe. But the reporting layer outlives any one campaign. It is reusable across every campaign on the account, it never silently changes its definitions on me, and it turns "let me check Ads Manager" into "the answer is already on the page". Owning your measurement is the same instinct as owning your tracking and keeping your own audit log — it is all the same refusal to let your understanding of the campaign depend on a tool you do not control.

What I want you to take from this

The mechanics of Meta Ads are learnable in a weekend. The rigour is what compounds. If I had to compress this into a handful of principles:

  • Let the data pick the concept. Read what already worked before you fall in love with an idea.
  • Sequence the funnel; don't stack it. Each phase earns the next. Don't run cold awareness and cold conversion against the same audiences at the same time.
  • Plan around the constraint. A hard budget cap changes what "success" means — set KPIs you can actually beat and explain the learning phase before it scares anyone.
  • Audit the landing page before you spend. It is the cheapest performance gain available.
  • Keep an audit log. "What changed?" should be a lookup, not a memory test.
  • Don't force a wrong configuration because a tool allows it. A green checkmark is not correctness.
  • Own your reporting layer. Build measurement on the API so your understanding of the campaign never depends on someone else's interface.

A campaign is not the ads. It is the thinking that decided which ads, to whom, in what order, measured how — and the honest record of what you actually did. Get that right and the buttons more or less click themselves.

Frequently Asked Questions

Why start with a video-views (awareness) campaign instead of going straight for leads?

Because the historical data showed video was cheap at capturing attention (cost per view of €0.16–0.18) but poor at converting cold audiences directly. Leading with video builds a warm pool of viewers at low cost, who can then be retargeted with conversion-focused ads. Going straight for cold conversions wastes budget on people who have never heard of the brand.

Why use sector-based saved audiences instead of job-title targeting for B2B?

Most small-business owners never fill in a job title on Facebook, and the title taxonomy skews English, so realistic coverage of titles like "Director" or "Owner" is only around 10–20% of the actual population. A sector-interest OR-layer combined with an "entrepreneurship" plus "small business owners" AND-layer acts as an ownership gate with far higher coverage, reaching decision-makers without the data-quality problem.

Why fire conversion events from application code rather than from Google Tag Manager?

Because the browser pixel and the server-side Conversions API event must share the same event ID for Meta to deduplicate them. Coordinating one shared ID is far simpler from a helper function in code than through GTM dataLayer choreography. Standard page views can stay in GTM; the meaningful conversion events belong in code.

What does it mean that a campaign runs in "learning limited" permanently?

Meta's learning phase wants roughly 50 conversions per ad set per week. At a small daily budget that volume is simply unreachable, so the ad set stays in a learning-limited state. This is the reality of the budget, not a sign of a broken setup. The right approach is to judge performance on a falling cost-per-lead trend over several weeks rather than on whether the learning phase formally completes.

Can you build a full Meta Ads campaign programmatically with the CLI?

Partially. The official Meta Ads CLI can create campaigns, ad sets, ads and creatives, but ad-set targeting is limited to country — there are no flags for age, gender, saved audiences, Advantage+ or placements, and it cannot set the bid strategy. For a strategy that relies on detailed audiences, you finish in Ads Manager or write a Graph API script. The CLI is excellent for creating named, paused shells quickly.

Why keep a campaign audit log?

Because the first question when a metric moves is always "what changed?", and memory is unreliable. A changelog recording what changed, when, how, why and whether it was verified turns that question into a lookup, makes the account handover-able, and forces you to justify each change to yourself before making it.

Why not force a bid cap just to get the campaign created?

Because a tool succeeding is not the same as a campaign being correct. Forcing a bid cap to satisfy the API would leave the campaign running the wrong bid strategy for an awareness objective. The disciplined move is to create paused shells with a placeholder, document the required correction in the audit log, and fix the strategy properly before launch.

Why build a custom dashboard on the Meta Graph API instead of using Ads Manager?

Ads Manager is someone else's interface — its columns, date logic and funnel definition are fixed, and it changes layout without warning. Building a small Streamlit dashboard on top of the Meta Graph API means owning the exact funnel and metrics the campaign needs (CPL trend, instant-form vs pixel lead split, frequency-vs-CTR fatigue checks), scoring every KPI against your own benchmarks, and pulling live data programmatically so it composes with everything else. The reporting layer is reusable across every campaign on the account.

How do you share campaign reports with a non-technical stakeholder?

The dashboard exports a self-contained static HTML report — the KPI table and key charts baked into a single file with no server, login or dependencies. Because it is generated from the live API rather than hand-assembled, regenerating this week's report is a button press rather than an afternoon of copying numbers into slides, which is what keeps reporting happening reliably.