The Meta API will happily give you the wrong number.
Reading the Marketing API is easy. Reading it correctly is not. The failures below all return a valid response with a plausible number in it — nothing errors, nothing warns you, and the number is wrong. Each one is a bug class we hit, diagnosed, and now guard against.
That work is most of what separates a dashboard you can act on from one you have to double-check.
A page view counted as a lead
The actions array carries offsite_content_view_add_meta_leads next to the real lead events. It is a ViewContent variant — a page view. Any integration that matches on “action type contains lead” picks it up and adds it in. A campaign with no real leads reports 134.
The ViewContent family is excluded before matching, then the real lead types are resolved by a fixed priority order. Same campaign, same raw payload: 0 leads, and the ad is correctly flagged as not converting.
A dead campaign looks like your best one. You scale it, and cost per lead stays fictional the whole way up. Nothing in the dashboard ever contradicts it, because the number came straight from Meta.
Custom conversions are invisible in the actions field
Meta lets every advertiser define their own conversion event and name it anything. Those events do not appear in actions — they are only in the conversions field. Requestactions alone on an account that measures a custom event and every ad returns zero results.
Each ad set’s promoted_object is read first to learn which event actually counts as the result for that ad set, then the conversions field is merged in. An account converting on a named custom event reads its real numbers.
Every automation sees a working account as dead. This is the failure that produced a false kill recommendation on an ad with 135 real conversions, read as 1. Zero-result rules are the most destructive thing to get wrong.
Alias action types double-counted
Meta’s lead types overlap. lead already rolls up on-Meta and pixel leads, andonsite_conversion.lead_grouped, leadgen.other and offsite_conversion.fb_pixel_lead restate the same conversions. Summing the array turns 40 leads into 78.
Coalesce by priority: take the first matching type, never sum. The same rule applies to the broader result family, where the largest single count is used instead of the total, for the same reason.
Cost per lead halves on paper — €25 shown as €13. Budget moves to the campaign with the most aliases rather than the most leads, and the client report will not reconcile against their CRM.
Stale status after a write
effective_status is a derived rollup: it folds in parent campaign state, delivery and review. It lags your own writes. Pause an ad and read it back and the API can still answerACTIVE for a while.
Toggles, active-state and anything that follows a write key off the entity’s own status. effective_status is used only where the question really is “is this delivering right now” — for example, before firing an alert about an ad.
Switches that flip themselves back a second after you click them. Alerts about an ad you paused ten minutes ago. Small in isolation, and the fastest way to stop trusting a tool.
Yesterday landing on the wrong day
The API takes dates as YYYY-MM-DD in the ad account’s timezone. The common bug is deriving them with toISOString() on a local date object, which converts to UTC first — so “yesterday” silently becomes two days ago, or today, depending on the hour.
User-facing dates are built from local calendar fields, never a UTC conversion. Scheduled jobs work in UTC deliberately and explicitly. Windows are anchored to the account timezone so they match what Meta reported.
Your reporting and Ads Manager disagree about the same day — €1,240 against €980 — and you cannot tell which one is wrong. Every day-over-day comparison built on top inherits the offset.
Only the first page of results
List endpoints return a page, typically 25 objects, plus a paging.next cursor. Readingdata and stopping is silent: the response is a valid 200 with a well-formed array. An account with 180 ads reports 25.
Cursors are followed to exhaustion on every list read, with a page ceiling that raises a loud error rather than quietly returning a short list. Totals are computed only from a complete fetch.
Spend is under-reported, so budget pacing is wrong. Worse, the ad that is actually burning the money is often not on page one, so it never gets reviewed at all.
Currencies summed as if they were one
Every ad account reports spend in its own currency, with no conversion. The currency is a field on the account object, not on the insights row — so nothing in the numbers themselves tells you they are incompatible.
Account currencies are resolved before any aggregation. Each account is formatted in its own currency, and a cross-account total is refused outright when the currencies differ rather than printed as a number that means nothing.
A portfolio total of 48,000 mixing EUR, USD and SEK, where the SEK account is inflated roughly tenfold. Your ranking of best and worst accounts becomes a ranking of exchange rates.
The cheap-lead mirage
cost_per_action_type gives you a clean comparison: €3.10 against €18.40. It is arithmetically correct and says nothing at all about whether either set of leads is worth anything.
Cost per lead is treated as a hypothesis. A winner call needs a volume floor and a significance check, not a lower number, and accounts converting through events the standard lead count cannot see are flagged rather than ranked.
Cheap leads are usually a broad audience filling a short form. Scaling the €3 campaign buys more of the same, while the €18 campaign that produced the actual pipeline gets cut for being expensive.
Anyone can read the API.
Getting the same answer twice is the work.
Every guard above is on by default, on every plan, including the free one.