OverviewIntroduction

Incrementality API

Run Meta geo-holdout incrementality tests and derive your own per-channel coefficient — the multiplier that converts platform-reported ROAS into true incremental ROAS, maintained always-on as a precision-weighted running median.

Overview

The Incrementality API lets an agency run geo-holdout tests on Meta and derive its own incrementality coefficient per channel — the iROAS ÷ platform_ROAS multiplier that converts Meta's reported ROAS into true incremental ROAS.

You define a test (which regions get ads, which are held out, and the before/after windows), we compute the lift entirely from data already in your BigQuery dataset, and the coefficient is served back as a precision-weighted running median over every test you've run — a "progressive truth" that keeps improving as you accumulate experiments.

All three inputs come from data you already have — no new connector, no manual data entry:

  • Revenue by region — your resolved conversion events (which carry geo_region).
  • Ad spend + platform-reported purchase value — your Airbyte-synced Meta Ads insights.

Base URL

All endpoints are mounted under the /client/v1/incrementality prefix.

https://mythic-analytics.gulp.workers.dev/client/v1/incrementality

Authentication

Same key system as the rest of the /client/v1 surface: an agency key (ak_, read + write, bind a location with ?location_id= or the X-Location-Id header) or a secret key (sk_, read-only viewer, auto-scoped to its location). Creating and computing experiments requires an ak_ key; reads accept either.

Quickstart — from "should we?" to a coefficient

Check eligibility

Is this client even a viable geo-testing candidate?

# easiest: let the API find and assess every conversion event for you
curl "$BASE/eligibility?auto=true" -H "Authorization: Bearer $AK"

# or assess one revenue source explicitly:
curl "$BASE/eligibility?daily_budget=500" -H "Authorization: Bearer $AK"
# lead-gen client? assess a geo'd browser conversion instead:
curl "$BASE/eligibility?event_name=form_submitted&measure=count" -H "Authorization: Bearer $AK"

With auto=true the API inventories the client's top conversion-ish events, detects which carry revenue (and at which JSON path), runs all five checks per event, and returns the best candidate plus every event's verdict with a suggested_measure/suggested_json_path you can paste straight into an experiment definition.

Five checks: geo coverage (share of revenue that carries a region — requires IP/geo enrichment), history (≥28 days of data), market count (≥6 regions clearing both floors: min_revenue_share 2% of regional revenue and min_active_days, max(7, a quarter of the window) — the same floors GET /markets applies, so the two endpoints cannot disagree), matchability (the largest markets have at least one other market that moves with them — big but uncorrelated regions are not test cells), and detectability — the daily budget the client's revenue noise floor implies for a conclusive 30-day test (daily_budget_for_30d_test). If that number dwarfs the client's realistic spend, a geo test can never conclude for them — better to know before running one.

The default revenue source is the purchase event at $.value — clients whose conversions are named differently must pass event_name (and json_path for where the value lives, e.g. $.meta_amount). A path that extracts nothing does not error; it returns zeros, and because geo_coverage is revenue-weighted it also drives coverage to 0. The check now separates that case (root_cause.check: "revenue_value") from a genuine geo gap.

The activity floor is not a formality. A region that takes all its volume in a single day clears a revenue-share floor easily, and two such regions correlate near-perfectly with each other because they spike on the same day in a field of zeros. On one live account, bot traffic from two foreign regions (45 events on one day, 42 across two) carried the entire matchability check at 0.9542 and produced eligible: true with zero failing checks — for a client whose real markets number two, correlate at 0.27, and whose only possible pairing was then rejected at design time. Both floors apply, and market_count reports both so you can see which one a client missed.

When exactly one check fails, root_cause names it and carries a remedy — including the most useful verdict this endpoint produces, a detectability-only failure: everything else passes and the answer is a number. Required budget scales with 1/sqrt(days), so roughly 4x the days lets you halve the daily budget.

Pick matched markets

Propose your test states and let the API rank control candidates by how closely their daily revenue tracks the test set:

curl "$BASE/markets?test_regions=California,Texas" -H "Authorization: Bearer $AK"

Take the top 2–3 candidates as your controls. Candidates are ranked usable first, then by match_score — volume parity × day coverage × correlation. Correlation alone does not predict whether a synthetic control will fit: measured on a live account, the correlation leaders (8.7% of test volume, active 14/51 days) fit at −0.7145, while two markets ranked below them on correlation but near volume parity and active 36–42 days fit at +0.3127. Correlation stays in the score as a factor — a market that doesn't co-move isn't a control — but parity decides the order. ranked_by on the response states this. Older behaviour ranked on correlation — correlation alone is not a safe ranking, because a region with a single conversion can score arbitrarily high against a spike in a field of zeros, and following such a top-3 produces an experiment that cannot compute.

A candidate is usable: false when it holds under 5% of the test cell's volume (min_volume_ratio) or is active on too few days (min_active_days). Unusable candidates are still returned, each with an excluded_reason, so a missing region is explained rather than silently absent. usable_candidates counts how many cleared both floors — if it is under 2, this test set has no viable control and no amount of correlation will fix it.

Create the experiment — and get told how long to run it

Only regions and the test window are required — pre_window defaults to the same-length period immediately before the test, and revenue defaults to the purchase event. Include daily_budget and the response carries a power analysis (design.recommended_days): how many days the test must run to detect break-even incrementality at 95% confidence / 80% power, given this client's noise floor.

curl -X POST "$BASE/experiments" -H "Authorization: Bearer $AK" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Q3 Meta geo holdout",
    "definition": {
      "test_regions": ["California", "Texas"],
      "control_regions": ["Ohio", "Georgia"],
      "test_window": { "from": "2026-06-01", "to": "2026-06-30" },
      "daily_budget": 500
    }
  }'

If progress.underpowered is true, your window is shorter than design.recommended_days — widen it before launch.

Conduct it in Meta — from the launch plan

curl "$BASE/experiments/{id}/launch-plan" -H "Authorization: Bearer $AK"

The plan is the complete ad-side recipe: control regions resolved to Meta targeting region keys, the exact excluded_geo_locations change, apply/revert dates, a step-by-step checklist, and paste-ready Marketing API calls (read-targeting-first, merge, apply — plus a key-verification call). Mythic never touches the ad account; the plan removes every thinking step from doing it yourself in Ads Manager (Ad set → Locations → Exclude).

Check launchable before acting. It is false for two reasons, each listed in launch_blockers: a control region that can't be resolved to a targeting key (e.g. non-US regions — launching would exclude nothing and invalidate the test), or a stored design.ok: false. The second is the expensive one: the checklist asks you to edit every active ad set and hold it for the whole window, so an experiment whose control markets already failed the design fit would spend real budget on a verdict the API has already said it cannot produce. Re-pick controls from GET /markets and re-create instead.

Track progress

Every experiment read carries a derived progress object — poll it while the test runs:

"progress": {
  "phase": "running",
  "days_total": 30, "test_days": 30, "cooldown_days": 0,
  "days_elapsed": 12, "days_remaining": 18,
  "percent": 40, "recommended_days": 28, "underpowered": false
}

Phases: pendingrunning → (cooldown, when cooldown_days is set) → awaiting_computecomputed. Progress tracks the measured window — test days plus cooldown — so awaiting_compute only begins once lagged conversions have had their chance to land.

Read the verdict — check `inference_valid` first

Before iroas, coefficient, significant or p_value, check inference_valid. When it is false the estimator is telling you this experiment cannot support a verdict at all: significant and two_sided_significant come back null (not false — "we can't judge", not "no effect"), coefficient and its interval are null, estimate_uninformative is true, and inference_invalid_reason says which of two things went wrong.

The control does not fit (fit_r2 < 0). The synthetic control tracks the pre period worse than a flat mean, so it is not a counterfactual. confidence and p_value are computed from pre-period residual variance under that same broken fit and must not be interpreted.

The counterfactual is an extrapolation (counterfactual_ratio > 5). The control can fit the pre window well and still behave impossibly afterwards — measured on a live A/A placebo, a control fitted to match 12,687 exactly then predicted 296,566 against a test cell of 12,332, producing a confident iROAS of −19 with a tight interval on data with no effect in it. The bound is on the ratio to the test cell, not the control's own growth, so market-wide seasonality (every region grows over a holiday) does not trip it. There is deliberately no lower bound: a control much smaller than the test cell is what a large genuine win looks like.

inference_valid: true requires fit_r2 > 0, which means no worse than a flat mean — not good. A run at fit_r2 0.013 returns a full verdict and carries a poor_control_fit warning (threshold 0.3). Treat a significant result with that warning present as unproven until the controls are improved.

Compute — with a confidence level

After the measured window closes (test + cooldown):

curl -X POST "$BASE/experiments/{id}/compute" -H "Authorization: Bearer $AK"

Compute is gated: before the measured window closes it returns 409 window_not_complete. You can force a provisional read with { "allow_partial": true } — it is stamped result.partial: true and excluded from /coefficients until a full compute overwrites it.

The result answers "how sure are we?": confidence (one-sided confidence the lift is positive, e.g. 0.97), p_value, significant (one-sided 95% threshold), and 90% two-sided intervals on both iROAS (iroas_ci) and the coefficient (coefficient_ci) — chosen so significant: true always coincides with the interval excluding zero.

confidence is directional, not a quality score. It is P(lift > 0), so a low value means the lift is probably not positive — it does not mean the estimate is imprecise. Seeing confidence: 0.09 beside p_value: 0.91 is the same statement twice, not a contradiction. The response carries confidence_means spelling this out; render that string rather than the bare number if the reader is not the person who designed the test.

Compute refuses to store silently-wrong numbers. It errors instead of guessing when test/control regions match no revenue (422 no_revenue_in_regions / no_revenue_in_controls — check region names against GET /markets), when the pre window itself is empty (422 pre_window_empty — no revenue in any region there, so there is no baseline; the response carries earliest_data_date), when the Meta insights table is missing (422 spend_table_missing — sync an ads-insights action-type stream or set spend.table), when spend covers multiple account currencies (422 mixed_currencies — set spend.account_id), or when Meta spend data hasn't caught up with the window (409 stale_spend_data — wait for the next Airbyte sync).

Read the coefficient

curl "$BASE/coefficients?channel=meta" -H "Authorization: Bearer $AK"

Multiply Meta's reported ROAS by this coefficient to get incremental ROAS. Every additional test sharpens it — and GET /coefficients/history returns the full series: each test's coefficient plus the running weighted median at that point, so you can watch the progressive truth converge over time.

Coefficients pool per series_key — an estimand identity built from the channel, the revenue definition, and the spend scope/conversion. Two experiments only pool when they measure the same thing; a lead-gen test and a purchase test (or different campaign scopes) each get their own series.

Statistical model

Inference is weighted synthetic control difference-in-differences: a non-negative weighted blend of the control regions is fit on the pre period to reproduce the test regions' daily revenue (weights and fit quality are reported as control_weights / fit_r2), the daily residual test − blend gives the noise floor, and the mean-adjusted post-period residual sum is the incremental revenue. Its standard error comes from pre-period residual variance, inflated by a degrees-of-freedom correction for the fitted weights. Significance is one-sided at 95% ("did the channel drive anything?") and the reported intervals are 90% two-sided, so the verdict and the interval never disagree. The create-time power analysis solves the same model forward: recommended_days = ((z_α + z_power) · σ / (daily_budget × mde_iroas))², with mde_iroas defaulting to 1.0 — "detect it if the channel at least breaks even."

Every calendar day in the measured windows counts: a day with zero conversions is a zero, not a missing observation.

Validation, seasonality, and data quality

  • A/A placebo before launchGET /experiments/{id}/validate (while the test hasn't started) splits the pre period in half and runs the exact inference with the second half as a fake "post". True lift is zero by construction, so a placebo lift significant in either direction (aa_pass: false, |z| ≥ 1.96) means the market pairing is invalid — fix the regions before spending a dollar. market_match_quality is the synthetic-control fit R².
  • Drift monitoring while running — the same endpoint in a running test returns interim lift (labeled peeking: true — not a result), and drift_detected when outlier days appear inside the test window. (control_shift_z is reported but informational: control regions are held out, so their level shift vs pre is the intended treatment contrast — not drift.) Set definition.alert_webhook_url (https) and Mythic checks daily through the test and cooldown, POSTing an incrementality.drift_alert webhook only when the outlier set changes — alert state is persisted, so the same condition never alerts twice. Webhook requests carry X-Mythic-Event: incrementality.drift_alert and X-Mythic-Experiment-Id headers.
  • Seasonality — matched controls absorb shared seasonality by construction (both groups live through the same holidays and promos). What they don't absorb is guarded: non-whole-week windows bias the fit via day-of-week composition (windows_not_whole_weeks warning — size windows in multiples of 7), and region-specific drift is what the weighted blend minimizes.
  • Outliers — days whose residual is >3σ off the pre-period distribution are reported in outlier_days, never silently removed. Exclude them explicitly with definition.exclude_dates.
  • Conversion lagdefinition.cooldown_days extends the measured window past the test window so lagged conversions land inside the measurement; the launch plan keeps geo exclusions in place through the cooldown.

How the coefficient is computed

Two queries against your per-location BigQuery dataset — daily revenue-by-region, and the Meta spend row — with inference in between:

  1. Build daily revenue series for the test and control regions across the pre and test windows (every calendar day counts, zeros included).
  2. Fit the counterfactual: a non-negative weighted blend of the control regions that reproduces the test regions' pre-period daily revenue (weighted synthetic control).
  3. incremental_revenue = the mean-adjusted sum of post-period residuals test − blend.
  4. iROAS = incremental_revenue ÷ test_spend, where test spend comes from Meta.
  5. coefficient = iROAS ÷ platform_ROAS (equivalently incremental_revenue ÷ platform_reported_revenue).

A coefficient of 1.0 means Meta's reported ROAS is exactly right; 1.15 means Meta under-reports; 0.25 means three-quarters of that reported ROAS was not incremental.

v1 runs weighted synthetic control diff-in-differences in BigQuery + the API — no external stats service. For heavier rigor (augmented SCM, permutation p-values), export the geo-daily data and run GeoLift externally; the coefficient store still holds the results.

Defining an experiment

The definition object on an experiment carries the full test spec:

FieldDescription
test_regions[]Regions (US state names, matched exactly against geo_region_name/geo_region) where ads run. Trimmed + deduped at create.
control_regions[]Held-out regions used as the counterfactual. Must carry pre-period revenue — compute fails loudly (no_revenue_in_controls) rather than degrading into a before/after comparison.
pre_window(optional) { from, to } (YYYY-MM-DD) — the baseline period. Defaults to the same-length period immediately before test_window.
test_window{ from, to } — the period ads ran in the test regions.
revenueWhere revenue comes from (see below).
spendHow Meta spend is attributed to the test (see below).
cooldown_days(optional) Extend the measured window past test_window.to to capture lagged conversions. Note this is a measurement extension, not a washout: keep ads and exclusions exactly as they were through the cooldown. Compute waits for it.
exclude_dates(optional) Dates to drop from the analysis (use for reported outlier_days).
alert_webhook_url(optional, https) Daily drift alerts while the test runs (and through cooldown). Alerts fire only when the outlier set changes — no repeat alerts for the same condition. Unsigned blind POST with a fixed payload; use a dedicated, unguessable endpoint URL.

Count mode has its own coefficient. For high-ticket, low-volume advertisers, revenue-mode variance can be unfittable — where individual sales are several times the daily mean of a region, no synthetic control tracks the series. Counting conversions removes that variance (one live account: sigma_daily 3.28 in count mode vs 2,685 in revenue mode, so 7 days to conclude instead of 56–179).

In count mode the response carries conversion_coefficient — incremental conversions per Meta-reported conversion — with se_conversion_coefficient and platform_conversions. Same estimand on both sides. The revenue-denominated coefficient is always null there by construction, and /coefficients reports count series with kind: "conversion" so the two are never averaged together. Like coefficient, it is withheld (null) whenever inference_valid is false — a coefficient is only as good as the counterfactual behind it.

Meta must actually report the action you're counting. The denominator comes from Meta's actions, and the default reads omni_purchase with purchase as fallback. A lead-gen account reports neither — it carries lead, complete_registration, onsite_web_lead — so the coefficient comes back null until you set spend.purchase_action_type to a type the account reports. conversion_coefficient_unavailable_reason names which of the three causes applies: nothing reported at all, the geo_stream Type 1 restriction, or too little spend behind the reported conversions.

Coverage is measured in spend, not rows. Both coefficient denominators are withheld when under 5% of the window's spend sits on rows carrying the value or count — one stray valued row out of tens of thousands yields a real number that is pure noise. The reported shares are platform_value_spend_share and platform_conversion_spend_share, alongside the raw platform_spend_rows / platform_value_rows / platform_conversion_rows. Spend-weighting matters because ad-day rows with zero spend are common: on one live account 1,481 of 3,212 rows spent nothing, so a denominator covering 29.7% of spend presented as 3.0% of rows and was withheld as "no conversions reported".

A pass inside twice the floor adds low_platform_coverage to warnings, naming which denominator (value, conversion, or both). Treat it as a real caveat: the same account measured over a different window can drop under 5% and lose the coefficient entirely, with nothing but the share to explain why. One live account clears at 5.80% over 28 days and 5.04% over 90.

Revenue source (revenue)

Configurable per experiment, because revenue lives in different places for different clients:

  • Event JSON (default) — { "source": "event", "event_name": "purchase", "json_path": "$.value" }. Reads revenue from the tracked conversion event; region comes from the event's geo. properties_json IS the event's properties object, so the default path is $.value. Add "timezone": "America/New_York" to align the revenue day boundary with the ad account (default UTC).

    The default path is right for SDK capture() revenue and wrong for most CRM streams. GoHighLevel transaction_completed events, for example, carry the amount at $.meta_amount and are 0% populated at $.value.

    A path that extracts nothing does not error — every revenue figure simply becomes 0, which also drives revenue-weighted geo_coverage to 0. The eligibility check now separates the two cases: when matching events exist and carry a region but no number at the configured path, root_cause.check is revenue_value (fix the path) rather than geo_coverage (fix tracking). Run GET /incrementality/eligibility?auto=1 to see the path each event actually uses.

  • Lead-gen count mode{ "source": "event", "event_name": "form_submitted", "measure": "count" }. Counts matching events instead of summing a value, for conversion events that carry no monetary amount (form fills, lead captures). iroas then reads as incremental conversions per dollar; pair with "spend": { "purchase_action_type": "lead" } so the platform side reads Meta's lead metric. Count-mode and revenue-mode experiments pool in separate coefficient series.

    Count-mode results are self-describing so the units cannot be misread: measure: "count", lift_units: "conversions", iroas_units, and cost_per_incremental_conversion (the number most readers actually want). coefficient is always null in count mode — it is revenue-denominated by construction, so a conversion count cannot produce one. coefficient_unavailable_reason says so on the response. If you need the coefficient, you need measure: "revenue" with a populated json_path.

Revenue must be geo-splittable to be testable. Server-side conversion events (CRM/order webhooks) carry no region of their own — geo is stamped from the request at ingest, and those events never touch the browser.

geo_inherit (default true) solves this, and is why server-side revenue is testable at all: when a conversion carries no region, it inherits one from the person who converted — the modal geo_region across that same distinct_id's pixel events in the window. Modal rather than most-recent, because people travel and an arbitrary pick shifts the per-region revenue split the holdout is built from.

In practice this moves CRM revenue from 0% geo coverage to the same coverage as the client's pixel traffic. It only works for buyers who also browsed: a customer whose only pixel visits predate the lookback stays unmatched, so coverage tracks how well the pixel is deployed. Set geo_inherit: false to require a region on the conversion row itself.

If coverage is still too low, the eligibility check fails on geo_coverage and tells you so via root_cause rather than computing a meaningless zero. Fall back to the commerce-table source with a region column, or measure a geo'd browser conversion in lead mode.

spend.source: "geo_stream" can never produce a coefficient — this is a Meta platform rule, not a data gap.

Meta classifies region as a Type 1 breakdown, and its Insights API docs state it "will not return unsupported off-Meta metrics (for example, an actions metric with Type 1 breakdowns)." On-Meta metrics still come back faithfully — impressions, link clicks, and on-Meta conversions such as onsite_conversion.lead_grouped match the unbroken stream exactly. What does not come back is off-Meta (pixel/CAPI) conversions and their values, for every advertiser. No Airbyte field selection or custom stream alters this: the connector passes breakdowns straight to Meta, so a custom region stream requesting action_values syncs the nothing Meta returns.

Practical consequence: a lead-gen count-mode test CAN use geo_stream (set spend.purchase_action_type to your on-Meta lead action) — what you lose is values, and therefore ROAS and the coefficient, not conversions.

Since coefficient = iROAS ÷ platform_ROAS needs Meta's reported conversion value, a region-broken spend stream yields coefficient: null with coefficient_unavailable_reason explaining it. iROAS is unaffected — it is denominated in your own measured revenue.

To get a coefficient, scope spend by campaign: { "source": "campaigns", "campaign_ids": ["..."] }. A correctly launched geo test already excludes the control regions from those campaigns (see the launch plan), so for the test window those campaigns are the test cell — campaign-scoped spend and campaign-scoped platform revenue are region-consistent by construction, and the action-type stream carries both.

  • Commerce table{ "source": "table", "table": "transactions", "column": "amount", "region_col": "state", "date_col": "created_date" }. Uses a structured table; that table must carry its own region and date columns to be geo-splittable.

Which conversion event you pick changes the answer, and the wrong pick fails silently. A single order commonly emits several events carrying the same value — an order-level event, a per-charge transaction event, and a per-line-item event. They are alternative views of one sale, never additive. Summing across them multiplies revenue by roughly the number of event types you summed — up to 3x per order, and higher where one order spans several line items.

Prefer the per-charge transaction event as the revenue series. Order-level events miss subscription rebills entirely where the rebill is emitted by a reconciliation sweep with no matching order webhook. Line-item events are a decomposition of the order-level event, so they inherit the same gap.

How much you lose depends entirely on that client's subscription share, and the range is the full one. Measured across ten live accounts, order-level revenue ran anywhere from 0% to 100% of true revenue: for a pure one-time-purchase client it matched exactly, and for a pure subscription client it was zero. There is no useful average here — a fleet-wide figure is wrong for essentially every individual account, so measure the client in front of you rather than applying a rule of thumb.

This matters because spend is unaffected by the choice: pick a series that undercounts revenue and iROAS is understated by the same proportion, with no error anywhere. Use GET /eligibility?auto=true — it assesses every candidate event and reports coverage per event, which surfaces this before you commit to a definition.

Spend attribution (spend)

  • Geo stream (preferred) — { "source": "geo_stream", "region_col": "region" }. Uses the region-broken Meta insights stream (meta_ads_ads_insights_region), so test-region spend is read directly from Meta. Set "table" only if your geo-broken insights live in a custom table.

    The region stream is a prerequisite, and older connections do not have it. It is a default on Meta connections created by the platform today, but a connection created before the stream was added does not gain it retroactively — enabling a stream on a live connection is an explicit action, because it triggers a full backfill from the source's start_date. On a two-year history that first sync can take many hours.

    If spend.source is geo_stream and the table is absent you get 422 spend_table_missing. Enable ads_insights_region on the connection (or use source: "campaigns" meanwhile) and wait for the backfill to finish before trusting a result — a partially-synced region table yields spend that is real but incomplete, which is worse than a missing one because it still produces a number.

  • Campaigns{ "source": "campaigns", "campaign_ids": ["123"] } or { "source": "campaigns", "name_pattern": "GEOTEST" } (a literal substring — %/_ are not wildcards). Attributes spend to the test by which campaigns target the test regions, for accounts without the geo stream. Add "account_id": "act_…" when the dataset receives more than one Meta connection.

With no geo/campaign filter, spend falls back to whole-account — which is only meaningful because the holdout excludes control regions (account spend ≈ test spend). Always scope spend explicitly for partial holdouts or multi-account datasets.

Platform-reported conversion value is read from Meta's action_values using one canonical action typeomni_purchase, falling back to purchase (Meta reports the same conversion under several overlapping types, so they are never summed together). Lead-gen accounts can override with "purchase_action_type": "lead" (or any Meta action type) on the spend object. The default table is meta_ads_ads_insights_action_type, also synced by default on platform-created Meta connections.

The result

After POST /experiments/{id}/compute, the experiment's result holds the computed metrics: incremental_revenue, test_spend, platform_revenue, iroas, platform_roas, coefficient, the absolute standard errors (se_lift, se_iroas, se_coefficient), ci_width (relative 90% CI half-width, display only), series_key, spend_currency, spend_data_through, plus the raw test_pre/test_post/control_pre/control_post (note: the control totals are the weighted blend, scaled to match test) and computed_at.

GET /coefficients returns the inverse-variance weighted median coefficient per series_key across computed experiments — weight = 1/se_coefficient², so precision (not effect size) determines pull, and a huge noisy lift earns no extra say. Results computed mid-window (partial) or with a poor control fit (poor_control_fit warning) are excluded.

Response envelope

All endpoints return a JSON envelope.

successboolean
Required

Whether the request succeeded.

dataobject|array

Resource payload. Shape depends on the endpoint.

errorobject

Present on failed requests. Contains code, message, and optionally details.

Error handling

StatusMeaning
400Invalid body, or an uncomputable definition (bad regions, windows, enums, or identifiers); unsupported_channel for non-meta channels
401Key missing or invalid
402Incrementality is not included in this account's plan (feature_not_in_plan)
403Write attempted with a read-only secret (sk_) key, or HIPAA-restricted location
404Experiment not found for this location
409window_not_complete (compute before the measured window closes — see allow_partial) or stale_spend_data (Meta sync hasn't caught up)
422Uncomputable without guessing: insufficient_data, pre_window_empty, no_revenue_in_regions, no_revenue_in_controls, result_truncated, spend_table_missing, mixed_currencies
429Rate limit exceeded (120 requests per 10s per location)
500BigQuery not configured in this environment
502Compute query failed or the result could not be saved