Weekly paid social / ads performance review

In one September week our daily ad spend went from a quarter of the budget's daily rate to 4.6 times it, with nothing broken. Here is the guard I wrote afterwards, the ranking code it runs every three hours, the cents-versus-dollars bug that briefly multiplied our account cap by 100, and the weekly
Chart: daily ad spend rising from 0.3x to 4.6x the budget's daily rate in one week
Open source · MIT
Agentic Company
Agents crawl, summarize, draft assets, and prepare publish/sync. You approve anything public, paid, or irreversible.
View on GitHub →

On September 3 our Meta account was spending about a quarter of the daily rate our monthly budget would allow. Seven days later it was spending 4.6 times that rate. No campaign was misconfigured. Nothing was hacked. Three reasonable launches happened in one week, and nobody added them up.

This is the write-up of what I built after that week: a guard that holds the monthly total in code, the ranking it uses to decide which ad sets live, the two bugs it shipped with, and the weekly read-out that replaced our old “let’s look at the ads” review. I will use ratios rather than dollar amounts; the shape is what transfers.

The week, in ratios

Take the monthly cap divided by 30.4 as the “daily rate”. Here is actual daily spend as a multiple of that rate:

DaySpend ÷ daily rateWhat happened
Sep 1–30.23–0.41×Baseline: a few evergreen ad sets
Sep 41.9×Seven product campaigns launched, each with a modest daily budget
Sep 52.8×All seven exit the first day’s ramp
Sep 62.5×A seniors-discount campaign launches
Sep 7–93.3–3.5×We switch WhatsApp numbers and launch “New Number” mirrors of the old ad sets, which keep running
Sep 104.6×Sum of active daily budgets: 3.9× the daily rate. By the 10th we had spent 75% of what we then set as the monthly cap

The mirror campaigns were the worst part: the same audiences, the same creatives, two destination numbers, competing with each other in the same auction. And the ads platform attributed zero purchases to any of it, partly because health-adjacent ad accounts get a restricted pixel and purchase events do not flow back. We had no feedback loop and no total.

The lesson I wrote down that night: the test is whether the month still fits, not whether one new campaign looks affordable. No human checks that on every launch. So code does.

The guard, in one paragraph

Every three hours a job reads month-to-date spend and every active daily-budget ad set. It computes a daily pool as (cap − spent) / days_left, capped at the flat daily rate so a quiet first week cannot be “saved up” into a blowout at month end. It splits the pool 30/30/40 between two metro areas and the rest of the country. Within each area it ranks ad sets, funds as many as fit at a minimum effective budget, shares the surplus by rank, and pauses the rest. At 98% of the cap it pauses everything; the last 2% absorbs auctions already won that report spend late.

Launching a campaign no longer adds spend. It enters the ranking at the next run and is funded only if it beats something already there.

The ranking is the whole product

The first version ranked by results per dollar and nothing else. It killed every new ad set on day one, because a new ad set has spent almost nothing and has zero results, so its ratio is zero. The version that runs now has three tiers:

def score(row, median_peer_ratio):
    spend = row.month_spend
    results = row.conversations + row.purchases * PURCHASE_WEIGHT  # 1 purchase = 10 chats
    if spend >= ZERO_RESULT_KILL and results == 0:
        return (2, 0.0, "zero_result")          # proved it does not work
    if spend < LEARNING_MIN_SPEND:
        return (1, median_peer_ratio, "learning")  # protected, scored like a median peer
    if results == 0:
        return (2, 0.0, "no_results")
    return (0, results / spend, "scored")       # proven earner

ranked = sorted(rows, key=lambda r: (score(r)[0], -score(r)[1]))

Two choices in there matter. A learning ad set gets the median ratio of its proven peers rather than zero or infinity, so it competes fairly but never outranks a proven one. And because the platform reports no purchases for us, the ranking runs on WhatsApp conversations, with a purchase (when one is ever reported) worth ten. That weight is a guess and is labeled as one in the config.

Fewer ad sets, funded properly

My instinct with a small pool was to keep everything alive at the platform’s minimum daily budget. That is the worst option: every ad set stays stuck in the learning phase forever and nothing gets enough delivery to prove itself. The guard funds floor(pool / 3) ad sets at a minimum of $3 a day and pauses the rest. Under a cap, spreading the money thin is what stops ads from delivering.

Policy runs before performance

After the number switch, every WhatsApp ad had to point at the new number. The guard removes any ad set pointing elsewhere before the budget maths, so a high-earning ad set on the retired number cannot win its way back in. It is also fail-closed: an ad set that does not pin a destination number at all is paused too, because an unpinned ad set silently falls back to whatever number the Facebook page is connected to. That fallback is how ad traffic kept reaching the old number for days after we thought we had switched.

The switch itself taught a second lesson. Pausing the five old ad sets alone would have left both metro areas with no ads at all, because the remaining new-number ad sets were national. The switch had to pause five and activate four in the same geographies in one change, so the 30/30/40 split survived.

Two bugs it shipped with

The first correction tripped the rate limit. The first enforcing run tried to change every active ad set at once and hit the platform’s per-account API limit (error code 17) halfway through. Now failures are printed per ad set, the next run retries, and once budgets settle a run only writes what changed. Every enforcing run writes a before/after JSON snapshot and prints the exact restore command, so a bad run is one line to undo.

The cap went up by a factor of 100. The account also carries a platform-side spend cap, and it is a lifetime counter, not a monthly one: left alone, the account simply stops once lifetime spend reaches it. A second job tops it up once a month to amount_spent + one month. The API returns that cap in cents and accepts it in dollars. The first version read the value and wrote it back, which multiplied the cap by 100. It was caught and reverted within a minute because the job re-reads after every write and fails on mismatch. Now every write goes through one conversion function, a test pins the unit, and a hard maximum aborts the run if the arithmetic ever grants more than a month.

What replaced the weekly review

Once the guard owned budgets, the weekly “review the ads” meeting had nothing left to decide about money. It became a daily read-only email at 08:30 that compares the last seven days with the seven before, per ad set and per ad, and turns the changes into ranked suggestions:

SignalThresholdSuggestion
Cost per conversation rising+25% week over weekCreative fatigue or the audience narrowed
Frequency≥ 2.5Refresh the creative or widen the audience
Spending with nothing back≥ $3, zero conversationsPause it
Weak hookCTR < 0.8% on ≥ 1,000 impressionsChange the first frame or headline
One region ≥ 2× anotherCost per conversationFix the weak region’s creative, not the split
Best performer≥ 5 conversationsCopy its angle into weaker ad sets

It never writes anything to the ads platform, and a test asserts it can never suggest raising the cap or changing the split. Ad-level advice is limited to ads in ad sets that are currently active, so it never tells you to pause something already paused, which the first draft did constantly.

What agents may and may not touch

Three AI agents work in this codebase. The cap and the split live in two constants pinned by a unit test whose failure message says, in effect, “revert your change; do not edit this test.” Agents may draft creatives and prepare ad sets. They may not launch with a budget of their own choosing, unpause what the guard paused, or disable the guard. “It needs more budget to work” and “just for this launch” are written into the rule as things that are not authorization.

Key takeaways

  1. Make the monthly total a constant, and pin it with a test. Two values in one config file. The test’s failure message tells whoever breaks it to revert. This is what makes the rule hold when agents, not just people, can edit the code.
  2. Protect learning ad sets with a median score, not zero. Rank in tiers (proven, learning, proved-bad) and give learners the median ratio of proven peers, or your guard will kill every new launch on day one.
  3. Fund fewer ad sets properly. floor(pool / minimum_effective_budget) survivors, surplus shared by rank, everything else paused. Many ad sets at the platform minimum all stay stuck in learning.
  4. Put policy checks before the budget maths, and fail closed. If you cannot prove where an ad sends people, it does not run, however well it performs.
  5. Re-read after every write to a money setting. Units differ between read and write more often than you would think. One conversion function, a verify-by-reading step, and a hard maximum.

If you want to copy it this week

You do not need the script to get most of the value. In a spreadsheet: put the monthly cap at the top, compute (cap − spent so far) / days left, list every running daily budget underneath, and sum them. If the sum is bigger than the pool, pause from the bottom of your cost-per-result ranking until it fits, but leave anything that has spent less than a few days’ budget alone. Do it every Monday and every time someone launches something. The script just does that every three hours and does not forget.

The recipe pattern is in the open Agentic Company playbook.

— Alex

Open source · MIT
Agentic Company
Agents crawl, summarize, draft assets, and prepare publish/sync. You approve anything public, paid, or irreversible.
View on GitHub →
Facebook
Twitter
LinkedIn
Pinterest

Posts

Android Apps App Wars

Google and Facebook own all of us Android users

Out of a sample of 1,700 Android devices from all over the world, the three most popular non-Google apps where owned by Facebook. As part of our work at Staytuned, we’ve been trying to understand how people use their phones and how we can help them to have a better mobile experience and user their phones more efficiently. Staytuned is a personal

Read More »