THE FIELD NOTES

Someone learned the hard way.

You don’t have to. 57 incidents, the lessons they left, and the recipes that account for them.

57 field notes

Money & writes / 01

spend_cap: dollars in, cents out

$198,000 of unintended headroom

The first write set a $200,000 limit instead of $2,000 because the value read back was in cents.

Check units on every money field; re-read after writing.
Money & writes / 02

spend_cap=0 doesn't remove the cap

The API returned success; the limit stayed.

Remove with spend_cap_action=delete; verify with a GET.
Money & writes / 03

Money-routing fields: exact match or error

$839 on the wrong pixel

An unknown funnel fell back to the account's default pixel.

No defaults for pixel, page, link, event, language, account.
Money & writes / 04

Store operator choices in the launch record

A deploy mid-launch resumed with an empty config; every ad set got another brand's page.

Persist choices with the job, not in dialog state.
Money & writes / 05

Don't change start_time and status in one flow

start_time update failed (ad set counts as started at creation), activation succeeded — delivery started early.

Set start_time at creation; activate as a separate verified step.
Meta API / 06

One edit per object per 30s; ~4 budget changes per hour

A second POST within 30s hit error #613; frequent budget edits got blocked for an hour.

Batch changes per object; wait 31s; cap changes per hour.
Meta API / 07

Pixel, event and goal can't change on a published ad set

Edits were rejected.

Recreate the ad set instead.
Money & writes / 08

Ask Meta 'already launched?' and 'next number?'

A sync moved the counter backwards — duplicate campaign numbers, twice.

Read across all accounts, including excluded ones.
Money & writes / 09

A pack is all or nothing

Auto-created packs shipped with 24 of 25 ads.

Never launch part of a pack.
Money & writes / 10

Reused code inherits its defaults

A 'create ad' helper forced PAUSED; refilled creatives never ran.

Check effective_status after creating.
Autopilot / 11

'Couldn't read' is not 'zero'

An API error read as an empty list wiped the autopilot's history.

Postpone decisions on unreadable objects; keep the loop going.
Autopilot / 12

Judge 'is it live' by effective_status

An ACTIVE ad set inside a paused campaign triggered 15 false 'not delivering' alarms a day.

Use effective_status, not status.
Autopilot / 13

New geo exclusions only hit new ad sets

~$30k at 46% ROAS

Old ad sets kept buying in excluded countries.

Retrofit old ad sets explicitly.
Autopilot / 14

Judge a country inside the campaign's language

Country averages were wrong in 6 of 18 cases.

Slice by country × language.
Autopilot / 15

No kills in the first 2 days

Day-1 verdicts under ~$150/day can't tell good from bad.

Kill only after 2 spend days or $1,000.
Autopilot / 16

Judge losses on closed cohorts

Killed creatives that ended at 106–111% ROAS

About half of 'expensive trial' flags were false.

ROAS on closed cohorts from $300 closed spend; before that, CPT vs breakeven.
Autopilot / 17

Use a recent window for kills

Lifetime ROAS killed a recovered creative (53% lifetime vs 65.5% last 14 days).

Kill on ~14 closed days.
Autopilot / 18

Under a cap, slow new tests — don't cut winners

Simulation: 65.1% ROAS throttling tests vs 60.2% cutting working ad sets.

Throttle intake first.
Autopilot / 19

Scale by marginal return, not average

Small raises returned ~40%, large ones 58–66%.

Evaluate the increment.
Autopilot / 20

Test share-of-budget thresholds at every budget size

A 'reduce' branch never fired: $300 floor vs $150 base budget.

Test rules on small and large budgets.
Autopilot / 21

Dry-run rule changes on production

One change grew a queue from 22 to 242.

Count before/after and replay history before merging.
Autopilot / 22

Mass failure is usually tracking, not creatives

When most ad sets suddenly show zero purchases, the pixel is broken.

If ≥ 3 zero-purchase kills are > 50% of live ad sets — kill nothing, alert.
Data / 23

Start every analysis by checking the date

An analysis labeled one date was run three days later.

Run date first; use CURRENT_DATE() in SQL.
Data / 24

Join on IDs, never names

A renamed campaign kept its old UTM name: 0 trials, falsely low CPA.

Join by ad_id / campaign_id.
Data / 25

Count unique converters, not purchase events

Renewals inflated counts ~3×.

First real charge per profile.
Data / 26

Compare cohorts at the same age

Slicing by calendar date flipped the sign of a conclusion.

Same age, same campaign × country cell, one measure.
Data / 27

Watch for silent truncation

A BI API returned exactly 2,000 rows with no warning.

Paginate and assert counts.
Data / 28

A skipped source is 'incomplete', not zero

A transient error showed 'FB 0 · −100%'.

Retry or mark output incomplete.
Infrastructure / 29

Judge health by results by deadline

42 incidents in 3 weeks; the bot reported none.

Watchdog checks that each day's output exists.
Infrastructure / 30

Prove a change by running it

A pipe into tail hid failing tests; a deploy gate always exited 0.

Check exit codes and the deployed state.
Meta API / 31

Image hashes don't cross accounts

A foreign hash was accepted and rendered as a placeholder.

Always re-upload media into the target account.
Meta API / 32

Setting any placement field disables Advantage+

Sending publisher_platforms turned Advantage+ placements off.

Omit publisher_platforms, *_positions and device_platforms.
Meta API / 33

Don't send standard_enhancements

Error 3858504 appeared non-deterministically.

Opt out of features one by one.
Meta API / 34

DLO needs a labeled link per language

A single generic link was accepted but clicks didn't route by locale.

Verify /previews?locale= for each language.
Meta API / 35

Copy attribution exactly from the donor

1-day click and 7-day click are different universes.

Never default attribution silently.
Meta API / 36

Code 17 needs minutes, not seconds

After a burst of writes, /ads and /adsets edges stayed blocked 30+ minutes.

Wait 20–30 min; direct GET by ID still works.
Infrastructure / 37

Never let an agent delete without asking

An agent deleted a 'broken' campaign; the owner wanted to choose pause, fix or delete.

Offer pause / fix / delete; delete only on explicit yes.
Infrastructure / 38

Alert only on persistent failures

One-off rate limits flooded the channel and operators stopped reading.

Dedupe, alert on transitions, cap alerts per day.
Money & writes / 39

'Already launched' = the ad exists, even at $0

Packs were nearly relaunched three times because the check looked at spend.

Check ad existence across all accounts.
Autopilot / 40

Hold on anomalies before acting

A 5× price spike from late data almost triggered a scale.

Spike > 5× median, spend with zero trials, or stale data → hold.
Data / 41

Backtests use only data known on the decision date

Future leakage inflated the effect ~5×.

One point per entity, one horizon, correct / wrong / unknown.
Infrastructure / 42

Broad except blocks hide failures

A missing import was swallowed; account-block alerts never fired once.

Catch specific errors; log and mark incomplete.
Meta API / 43

Upload originals, not CDN previews

Previews were compressed and cropped.

Use local original files.
Autopilot / 44

Change a threshold everywhere at once

A threshold had copies in the bot, BI cards and scripts that drifted apart.

Find all copies and consumers; change in one PR.
Data / 45

Every report prints its denominator

A backtest silently evaluated only 31% of decisions.

Show totals and a 'not evaluated' bucket.
Meta API / 46

If > 50% exists, fill gaps — don't rebuild

A rebuild cost ~100 calls and a rate limit; filling gaps took ~29.

Resume from state.
Meta API / 47

Resolve locales via search, don't guess

Guessed locale ids targeted the wrong languages.

Use /search?type=adlocale.
Data / 48

A naming change is a schema migration

Czech CS vs CZ needed 13 code edits and 2 BI cards; DE vs GER shrank a pool to 3 of 46.

Grep all readers, diff parsers on 90 days of names, add tests.
Meta API / 49

Meta sometimes returns non-JSON

response.json() threw before the error check.

Parse defensively.
Meta API / 50

Creatives only in original sizes

Auto-cropped variants underperformed and broke naming.

Never auto-crop; upload originals.
Infrastructure / 51

Explain decisions in plain words

Operators ignored decisions they couldn't read (codes like f= m=).

One fact per sentence; context, not codes.
Data / 52

Name patterns need word boundaries

A number pattern matched Meta's '(2)' duplicate suffix.

Anchor patterns; refuse unknown suffixes.
Money & writes / 53

Read before writing, read again after

A platform silently ignored four fields while returning success.

Report the re-read state, not the API's 'success'.
Money & writes / 54

Exhausted credit lines stop spend silently

Accounts stopped spending with no error.

Monitor runway daily.
Data / 55

Accounts live in different time zones

Meta accounts in New York time vs another channel 7 hours ahead.

Never assume a shared day boundary.
Autopilot / 56

Weekends distort spend

Sunday overspend and Saturday underspend triggered brakes.

Normalize by weekday factors.
Data / 57

Yesterday's spend: trust the API if warehouse differs > 5%

Warehouse spend lagged a day while trials were on time.

Spend from API, trials from warehouse.