OverviewIntroduction

Constraint Finder

One call that follows a paid campaign from impression to purchase and names the step it is actually stuck on — the ad, the tracking, the landing page, or the sale.

Overview

Every reporting tool tells you a campaign is underperforming. None of them tell you why. The Constraint Finder joins the three data sets you already have into one funnel and names the step that is holding each campaign back:

Data you already haveWhat it contributes
Airbyte-synced Meta Ads insightsimpressions, outbound link clicks, spend
Your Mythic pixelsessions, landing pages, UTMs and click IDs
Your conversion eventsleads, purchases, revenue

The result, per campaign (or ad set, or ad):

impressions → clicks → sessions → leads → conversions
            ad       tracking   landing    sales

A shortfall at each step means something different, and something different gets fixed:

  • ad — the creative or targeting isn't earning the click.
  • tracking — the click never became a tracked visit. Redirect chains, consent banners, slow pages, stripped UTMs.
  • landing — the page isn't converting the visit.
  • sales — leads arrive but don't close. Not a marketing problem.

Base URL

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

Authentication

The same key system as the rest of /client/v1: an agency key (ak_, bind a location with ?location_id= or the X-Location-Id header) or a location secret key (sk_, auto-scoped to its own location). Read-only — there is nothing to create.

The Constraint Finder is sold by tier. If your plan does not include it the whole surface answers 402 feature_not_in_plan — the key is valid, the tier is what is missing. GET /client/v1/agency/quota lists what your plan includes.

Quickstart

Ask what the account is stuck on

curl "$BASE/client/v1/constraints?location_id=$LOC" -H "Authorization: Bearer $AK"

# or over a longer window — days is inclusive of both ends, 1-365
curl "$BASE/client/v1/constraints?location_id=$LOC&days=90" -H "Authorization: Bearer $AK"

The summary.primary_constraint is the headline: the step constraining the most conversions account-wide. The window defaults to the last 28 days; set it with days, or with from/to when you need exact boundaries. /client/v1/constraints/events takes the same days, so keep the two in step when you compare them.

Find out which events you can use

curl "$BASE/client/v1/constraints/events?location_id=$LOC" -H "Authorization: Bearer $AK"

Returns every non-system event with its volume, session coverage, and which JSON paths carry a monetary value — plus a suggested pairing you can pass straight through. See Choosing your events.

Split the page problem from the sales problem

Without a lead_event, "sessions → conversions" is one opaque step. Supply your lead event and it splits in two:

curl "$BASE/client/v1/constraints?location_id=$LOC&lead_event=lead&conversion_event=transaction_completed" \
  -H "Authorization: Bearer $AK"

Check the tracking before you trust the funnel

"tracking": {
  "match_rate": 0.93,
  "joined_by_id": 5400,
  "joined_by_name": 180,
  "unmatched_sessions": 420
}

match_rate is the share of ad-click sessions whose UTMs resolved to a synced Meta row. Read it first — see When not to trust the answer.

Drill into the creative

group_by=ad moves the analysis to individual ads, where creative lives. Expect a lower match rate: ad-grain matching relies on utm_content, which carries a usable ad identifier on far fewer accounts than utm_id does.

How the constraint is chosen

Each step's rate is compared against the pooled rate across every row in your own account — not an industry benchmark. Your account sets its own bar, and the big spenders set most of it (rates are pooled, so a 12-impression campaign can't drag the benchmark as hard as a 2M-impression one).

The constraint is then the step with the highest recoverable_conversions: the extra conversions that closing the gap to the pooled rate would produce, after carrying that row's own downstream rates. That last clause is what makes the answer actionable — doubling the click-through rate into a page that converts nobody is worth nothing, and this reports it as worth nothing.

Ranking by recoverable conversions rather than by the worst rate is what makes results comparable across campaigns. A 2× shortfall on 1M impressions outranks a 5× shortfall on 1,000 — the second is a rounding error wearing an alarming percentage.

A campaign at or above the pooled rate on every step returns constraint: null with status: "healthy" — the explicit status is there so a null constraint is never misread as missing data. Nothing to fix; scale it instead.

Rows are returned ordered by opportunity, which is deliberately not spend order.

One call instead of three

The three read endpoints answer different questions, and a panel that needs all of them used to pay three serial round trips. include= folds the other two into the analysis response and runs them in parallel, so the wall clock is the slowest one rather than the sum:

# funnel + tracking audit + event inventory, one request, one round trip
curl "$BASE/client/v1/constraints?location_id=$LOC&include=audit,events&conversion_event=auto" \
  -H "Authorization: Bearer $AK"

audit lands under audit, the inventory under event_inventory. They stay separate BigQuery jobs — one request can't make them one scan — but you stop waiting for them in sequence.

conversion_event=auto closes the last serial hop: instead of calling /constraints/events to learn which event to ask about and then calling the analysis, the endpoint runs the probe itself and reports what it chose under auto_selected. An explicit lead_event still wins.

A section that fails comes back as {"unavailable": "<reason>"} inside a 200 — the analysis is never failed by it. That matters for the audit specifically: "the audit did not run" must not be readable as "this account has no tracking problems". An unknown include name is a 400 rather than a silent omission, so a typo can't quietly return less than you asked for.

What the ad actually says

Creative is off by default — request it with include=creative. It is the most expensive thing this endpoint can do: measured on one client it scans 285.9 MB on every call at every grain, independent of the report window, because the creatives table sits in a single partition that nothing prunes and one JSON column accounts for 139 MB of it. If you already join creative yourself, leave it off. Row labels are unaffected either way — ad_name comes from ads-insights and top_landing_page from the sessions side, never from creatives.

An ad_name is a naming convention, not content — "Lead C - SE-2 - Markus Video Adscripts 1.16.26 - Copy" tells you nothing about the promise the visitor saw. Every row carries the real creative, so the comparison that follows a landing constraint (ad copy vs page copy) happens in one response:

"creative": {
  "is_representative": false,
  "headline": "I Ran the Numbers 47 Times. The Answer Made Me Sick",
  "body": "I ran the math 47 different ways.

Max out the 401(k). Index funds…",
  "call_to_action": "LEARN_MORE",
  "destination_url": "https://go2.example.com/rockwealth-os-webinar-registration",
  "effective_destination_url": "https://go2.example.com/rockwealth-os-webinar-registration?utm_source=facebook&utm_content={{adset.name}}&ad_id={{ad.id}}",
  "url_tags": "utm_source=facebook&utm_content={{adset.name}}&ad_id={{ad.id}}",
  "image_url": "https://scontent.xx.fbcdn.net/…",
  "format": "image",
  "asset_count": 1
}

At group_by=ad the creative is exact. At coarser grains it's the most-delivered ad's creative with is_representative: true — a stand-in, never presented as the whole row.

destination_url deliberately has no UTMs on it. Meta stores the link and its tracking parameters separately and appends url_tags at click time, so the bare link looks untagged even on a correctly tagged ad. Compare effective_destination_url against your landing page; read url_tags to see whether an ad is tagged at all — it's the one field that answers that without needing a single session to have arrived.

Carousels return their cards in order under assets, with format: "carousel" and asset_count set.

When there's no creative, you get a reason

The object is always present. When the copy can't be read it carries unavailable and a reason_code rather than going blank:

"creative": {
  "unavailable": "The Meta ad_creatives streams are not synced for this ad account…",
  "reason_code": "creative_not_synced"
}

creative_not_synced is by far the most common, and it's per ad account, not per ad — measured on one client, two of four Meta ad accounts synced creatives (100% of their ads resolved) and two synced none (0%), which is why coverage sat at ~76% of impressions rather than 100%. It's a fixable Airbyte stream setting, so if you see it, ask for those streams to be enabled rather than working around it.

Who converted, and why stitching is on by default

A CRM or server-side conversion never carries a session of its own — it arrives over Pub/Sub with a contact id, long after the browsing happened. Credit can therefore only reach a campaign through the person, and the conversion's distinct_id shares nothing with the browsing device's id until identity resolution maps both onto one person.

That is why identity stitching is on by default. Measured on one client over 30 days (273 conversions):

Raw distinct_idStitched
Conversions with their own session00
Reaching any pixel session23%91%
Creditable to a paid campaign touch10%81%

Because the funnel resolves people, every row tells you who, not just how many:

{
  "campaign_name": "WOS Webinar — Interests",
  "conversions": 12,
  "converting_persons": 9,
  "person_ids": ["01hq2…", "01hq7…", "01hqa…"]
}

Those are canonical person ids (merge tombstones already collapsed), so they go straight into GET /client/v1/data/persons/{id} — a constraint verdict drills through to the actual profiles and their full touch history. converting_persons is the true distinct count; person_ids is a capped sample.

Check conversion_attribution.identity_resolution before trusting a low attribution number:

"identity_resolution": {
  "enabled": true,
  "conversions_resolved_to_person": 265,
  "conversions_resolved_rate": 0.97,
  "distinct_converting_persons": 241
}

A low conversions_resolved_rate is the ceiling on everything else — it usually means the identity snapshot export has stalled, not that your campaigns stopped working.

resolve_identities=false reverts to raw distinct_id matching. It exists to reproduce a pre-2026-07-31 figure; on a CRM-fed client it will cut credited conversions by roughly 8×.

When a grain can't be measured

Ad-level attribution needs a per-ad key in the visit. The endpoint reads three, in order: the grain's UTM id, the grain's id in the landing URL's query string (ad_id=, adset_id=, campaign_id= — where Meta's {{ad.id}} macro usually lands), then the name.

If none of those arrive, the sessions still happened — we just can't attribute them to an ad. That is unknown, not zero, and the response says so rather than inventing a verdict:

"status": "unmeasurable",
"measurable": { "ad": true, "tracking": false, "landing": false, "sales": false },
"constraint": null

When every row with clicks is blind, the response carries a grain_unresolvable diagnostic naming the missing key, and tracking.grain_resolvable is false. Use group_by=campaign, which resolves from utm_id on almost every account, and /client/v1/constraints/audit to see which tags are missing.

A step marked measurable: false is never reported as the constraint. This matters because the failure it replaces was actively misleading: at ad grain the funnel used to return step: "tracking", rate: 0 — telling you your click tracking was broken when the clicks had arrived perfectly well and only the ad-level key was absent. Render unmeasurable steps as "can't measure", never as 0%.

Drilling into one campaign

Every row already carries its own constraint and status, so the default response is the constraint-per-campaign list. To isolate a single campaign, pass campaign= with its id or name (case-insensitive):

# just this campaign
curl "$BASE/client/v1/constraints?location_id=$LOC&campaign=120210000000000000" -H "Authorization: Bearer $AK"

# that campaign's ads, each with its own constraint
curl "$BASE/client/v1/constraints?location_id=$LOC&group_by=ad&campaign=Summer%20Sale" -H "Authorization: Bearer $AK"

The filter is applied after the pooled benchmarks are computed, so the campaign is still judged against the whole account — that comparison is the point of the drilldown. If nothing matches, you get a campaign_not_found diagnostic instead of a silent empty list.

Setting your own benchmarks

The pooled rate answers "which campaign is worst relative to this account". It can't tell you the whole account is underperforming — if every campaign converts badly, the pooled rate is badly too, and nothing looks constrained.

So any step's benchmark can be replaced with a target of your own. Pass benchmark_<step> as a rate between 0 and 1:

# judge CTR against a 2% target and lead rate against 10%,
# leave tracking and sales on the account's own pooled rates
curl "$BASE/client/v1/constraints?location_id=$LOC&lead_event=lead\
&benchmark_ad=0.02&benchmark_landing=0.10" -H "Authorization: Bearer $AK"

Valid step names are ad, tracking, landing, sales, and site — matching the funnel shape you asked for. A benchmark for a step your funnel doesn't have is a 400 rather than a silent no-op, since a target that quietly did nothing is worse than an error.

Benchmarks are rates, not percentages. A 2% CTR target is 0.02. Anything above 1 is rejected rather than interpreted, because 2 is ambiguous — read as 200% instead of 2% it would make every campaign in the account look ad-constrained, and you'd have no way to tell from the output.

Storing targets, per account and per campaign

Query params set a target for one request. /client/v1/constraints/benchmarks stores them as standing policy — including per campaign, because a cold-prospecting campaign and a retargeting campaign have no business being held to the same click-through rate. Stored targets are applied automatically; you don't pass anything on the analysis request.

# an account-wide floor
curl -X PUT "$BASE/client/v1/constraints/benchmarks?location_id=$LOC" \
  -H "Authorization: Bearer $AK" -H 'Content-Type: application/json' \
  -d '{"benchmarks":{"ad":0.015,"landing":0.08}}'

# ...and a higher bar for one campaign, with the reason on the record
curl -X PUT "$BASE/client/v1/constraints/benchmarks?location_id=$LOC" \
  -H "Authorization: Bearer $AK" -H 'Content-Type: application/json' \
  -d '{"campaign":"120210000000000000","benchmarks":{"ad":0.03},"note":"Q3 client contract"}'

curl "$BASE/client/v1/constraints/benchmarks?location_id=$LOC" -H "Authorization: Bearer $AK"   # what's on file
curl -X DELETE "$BASE/client/v1/constraints/benchmarks?location_id=$LOC&step=ad" -H "Authorization: Bearer $AK"

Writes need an agency key (ak_); sk_ keys can read. campaign matches the Meta campaign id or name, case-insensitively — the same rule as the campaign filter.

Which target wins

Four sources, highest first. Every response repeats this list under precedence, and every row reports which one it actually used.

SourceSet byScope
overridebenchmark_<step> on the requestThis call only
stored_campaignPUT with a campaignThat campaign (and its ad sets / ads)
stored_defaultPUT without a campaignEvery campaign with no target of its own
pooledNothing — measuredThe account's own rate across the window

Because targets can differ per campaign, each row carries its own benchmarks and benchmark_sources, and the winning step names its source right at the verdict:

{
  "campaign_name": "Cold — Broad",
  "benchmarks":        { "ad": 0.03, "tracking": 0.9, "site": 0.02 },
  "benchmark_sources": { "ad": "stored_campaign", "tracking": "pooled", "site": "stored_default" },
  "status": "constrained",
  "constraint": {
    "step": "ad", "rate": 0.004, "benchmark_rate": 0.03,
    "benchmark_source": "stored_campaign", "benchmark_note": "Q3 client contract",
    "rate_vs_benchmark": 0.133, "recoverable_conversions": 468
  }
}

The top-level benchmarks stays the account-level answer (override → stored default → pooled), so it never silently reflects one campaign's target. pooled_rates is always what the account actually does, even where a target replaced it — keep both in view, because a target far above the pooled rate makes every campaign report the same constraint, which tells you the target is aspirational rather than that every campaign has the same problem.

How a stored target tells you it's stale

Two things quietly rot: a target keyed to a renamed campaign, and a target for a step the current funnel shape doesn't have. Both are reported rather than dropped.

"stored_benchmarks": {
  "default": { "ad": 0.015 },
  "campaigns": [
    { "campaign": "120210000000000000", "benchmarks": { "ad": 0.03 }, "matched_rows": 1 },
    { "campaign": "Summer Sale 2024",   "benchmarks": { "ad": 0.05 }, "matched_rows": 0 }
  ],
  "not_applicable_steps": ["site"],
  "not_applicable_reason": "This funnel's steps are ad, tracking, landing, sales — stored targets for other steps sit out."
}

matched_rows: 0 means that target hit nothing this window. Prefer keying by campaign id: it survives a rename, a name does not.

PUT replaces a scope — the body is the complete set of targets for it, so a step you previously stored and now omit falls back to the next source. The response echoes the resulting state, so a replacement is never invisible. To clear one step, DELETE ...&step=ad.

If the stored targets can't be read, the analysis still runs against pooled rates but the response carries stored_benchmarks_unavailable — your targets were not applied. Check for that field before trusting a verdict, because a benchmark that silently reverted to pooled is the one failure that misleads without looking wrong.

Choosing your events

GET /client/v1/constraints/events inventories every non-system event in the window and tells you what each can be used for:

{
  "suggested": {
    "conversion_event": "transaction_completed",
    "revenue_path": "$.value",
    "lead_event": "lead"
  },
  "events": [
    { "event_name": "lead", "total_events": 5000, "session_coverage": 0.98,
      "person_coverage": 1, "credited_via": "session",
      "revenue_paths": [], "usable": true, "suggested_role": "lead" },
    { "event_name": "transaction_completed", "total_events": 800, "session_coverage": 0,
      "person_coverage": 1, "credited_via": "person",
      "revenue_paths": ["$.meta_amount"], "usable": true, "suggested_role": "conversion",
      "warning": "No event of this type carries a session (typical of CRM/webhook events)…" },
    { "event_name": "ghl_rebill", "total_events": 3000, "session_coverage": 0,
      "person_coverage": 0, "credited_via": null,
      "usable": false, "warning": "These events carry neither a session nor a person…" }
  ]
}

Four fields do the work:

  • session_coverage — the share of these events that carry a session_id. Zero is normal for anything server-side and is not disqualifying on its own.
  • person_coverage — the share that carry a person. This is what decides usability on a CRM-fed client: a session-less conversion is credited to that person's nearest paid touch (see How conversions get credited).
  • revenue_paths — which JSON paths actually hold a number, so you know what to pass as revenue_path. An event with none can still be a lead_event; it just can't carry revenue.
  • usable — whether the event can be credited to a campaign at all. false only when neither a session nor a person is present. credited_via tells you which route it will take.

A session-less conversion is not an unusable one. CRM and webhook conversions (GoHighLevel over Pub/Sub, for instance) never touch the browser, so they carry no session — and on a CRM-fed client they are the only revenue events there are. They are credited through the person instead, so they remain usable. What that costs you is visible in the response: conversion_attribution.uncredited counts the conversions whose people never visited from an ad inside attribution_window_days, and on a real account that can be the majority. Read it before quoting a conversion count.

The genuinely unusable case is an event carrying neither a session nor a person: nothing can attach it to a campaign, and its warning says so.

A lead_event is held to a stricter standard than a conversion. The landing step is measured per session (sessions → lead_sessions), so a session-less event would report a landing rate of zero and blame your page for it. The suggestion never proposes one, even when it is the highest-volume event on the account.

/data/events/breakdown?dimension=event_name and /data/properties remain the general-purpose event and property catalogues. This endpoint exists because neither reports session coverage, which is the one thing this funnel depends on.

When not to trust the answer

A low tracking.match_rate invalidates everything downstream of it. If ad clicks aren't resolving to sessions, the clicks → sessions step is measuring UTM hygiene, not landing-page quality — and every step after it is computed from a fraction of the real traffic.

Sessions are matched to Meta rows two ways, and the response tells you which was used:

  • joined_by_id — matched on the Meta ID via utm_id (campaign) or utm_content (ad set / ad). Exact, and survives renames. This is what Meta's {{campaign.id}} dynamic parameter gives you.
  • joined_by_name — matched on the campaign name, which is what Meta's {{campaign.name}} gives you. Works, but breaks silently when a campaign is renamed mid-window: the renamed campaign's sessions stop matching, and its funnel collapses for reasons that have nothing to do with performance. Names arrive percent-encoded from the URL (GPM+%7C+Brand), so both sides are compared with escapes and punctuation removed — you do not need to decode anything yourself.

If joined_by_name dominates your response, adding utm_id={{campaign.id}} to your ad URLs is the single highest-value fix available — it makes every number here more reliable.

Unexpanded macros

The one tracking fault no matching can repair is a macro that was never substituted, sitting in the UTM as a literal:

"tracking": {
  "match_rate": 0.91,
  "unexpanded_macro": {
    "sessions": 189,
    "sample": "{campaignname}",
    "message": "189 session(s) carry an unexpanded ad-platform macro in the campaign UTM…"
  }
}

{campaignname} is not a macro Meta recognises, so it is passed through verbatim — the correct forms are {{campaign.name}} and {{campaign.id}}. {{campaign.name}} reaching the pixel unexpanded means the tag is set somewhere that does not support substitution. Either way those clicks carry no campaign and can never be attributed; the fix is in the ad's URL tags, not in a query parameter here.

unexpanded_macro is absent when there is nothing to report, so its presence alone is the signal. Sessions counted here are also excluded from the join outright — a literal like {campaignname} would otherwise be capable of matching a campaign genuinely named "Campaign Name".

Auditing your tracking

match_rate tells you a problem exists. GET /client/v1/constraints/audit tells you which campaigns to go fix, and why:

curl "$BASE/client/v1/constraints/audit?location_id=$LOC&days=90" -H "Authorization: Bearer $AK"
{
  "totals": {
    "campaigns": 20, "spend": 47506,
    "flagged": 10, "flagged_spend": 16165,
    "matched_sessions": 7285, "untracked_sessions": 645, "match_rate": 0.919
  },
  "campaigns": [
    { "campaign_name": "HBOT – Custom Conversion", "spend": 8884, "clicks": 3341, "sessions": 0,
      "verdict": "no_sessions", "issue": "3341 link clicks produced no tracked session at all.", "fix": "…" },
    { "campaign_name": "Metabolic Edge – lp_roas_form", "spend": 3307, "clicks": 969, "sessions": 2,
      "verdict": "low_tracking", "issue": "Only 2 of 969 link clicks became a tracked session (0%).", "fix": "…" }
  ],
  "untracked_sessions": {
    "total": 645,
    "by_cause": [
      { "cause": "no_utm", "sessions": 617, "top_landing_page": "/", "fix": "…" },
      { "cause": "unknown_campaign", "sessions": 24, "sample": "…lpv2260520copy", "fix": "…" },
      { "cause": "unexpanded_macro", "sessions": 4, "sample": "{campaignname}", "fix": "…" }
    ]
  }
}

flagged_spend is the number to lead with. It is the spend whose effect cannot be measured — in the example above, a third of the account.

Verdicts, worst first:

verdictmeaning
no_sessionsClicks produced no tracked visit at all.
low_trackingUnder 10% of link clicks arrived as a session.
name_onlyEvery session matched on campaign name. Works today, breaks the next time the campaign is renamed.
no_clicksNo delivery yet — nothing to track. Not a fault, and not counted in flagged.
okMatching on ID, sessions arriving.

no_sessions is not automatically a tagging bug. An on-platform objective — lead form, engagement, messaging — never sends anyone to your site, so zero sessions is the correct reading for it. The fix text names both possibilities rather than blaming your pixel, because telling them apart needs the campaign objective, which the ads-insights table does not carry. Check the objective before you go hunting for a missing pixel.

The untracked_sessions buckets are separated rather than summed because each calls for a different repair: no_utm means the ad has no URL tags at all, unexpanded_macro means the tag holds a literal the platform never substituted, and unknown_campaign means the UTM names something outside the synced rows — a deleted campaign, a hand-written UTM, or a different ad account than the one connected.

There is deliberately no volume floor here, unlike the analysis: a small campaign with broken tags is exactly what a hygiene report should surface.

How conversions get credited

A conversion does not have to happen in a browser session to count. Measured across the platform, every CRM conversion event carries no session_id at all — transaction_completed, order_completed, product_purchased and contact_created are all server-side, arriving over Pub/Sub without ever touching a browser. Counting conversions only inside their own session would make this endpoint blind on every CRM-fed client.

So each conversion is credited to a session instead:

  1. Its own session, when that session resolved to a campaign. The most precise case.
  2. Otherwise the person's nearest campaign touch within the attribution window — the identity graph is what makes this possible. Last touch by default; the first touch when attribution=first_touch.

Because the credit lands on a session, every funnel rate above is computed the same way whether the conversion came from the browser or your CRM.

# 7-day window, matching Meta's own default click attribution
curl "$BASE/client/v1/constraints?location_id=$LOC&attribution_window_days=7"

attribution_window_days defaults to 30 and accepts 0–365. Meta's own default is 7-day click, so pass 7 when you want numbers comparable to Ads Manager, and widen it for long sales cycles.

Touches are looked up back to from − attribution_window_days, so a conversion early in the report still finds the visit that earned it — without you having to widen the report window. Funnel denominators stay strictly inside [from, to] (a pre-window touch session never counts as a session), so step rates remain coherent with this window's spend, while the credited conversions and revenue still land on the campaign that earned them.

Be clear about what this model is: last paid touch. A purchase made in an organic session still gets credited to the buyer's paid touch within the window — every conversion that can be attributed to paid is. That is roughly the claim Meta makes for itself, which is what makes the comparison fair, but it is generous to paid media by construction. For strict own-session-only credit — the conservative reading, where an organic-session purchase stays with organic — pass attribution_window_days=0.

Every response shows exactly how the crediting went:

"conversion_attribution": {
  "window_days": 30,
  "identity": "distinct_id",
  "total_conversions_in_window": 190,
  "credited_via_session": 80,
  "credited_via_person": 110,
  "uncredited": 0
}

credited_via_person is usually the majority on a CRM-fed client — that is the identity graph doing the work. A high uncredited count means those people have no campaign touch within the window at all: they genuinely arrived another way, or the sales cycle is longer than attribution_window_days — raise it if so.

If nothing can be credited, the response carries a diagnostic instead of a silently empty analysis. That matters because zero conversions makes every pooled benchmark zero, which makes nothing "below benchmark", which would otherwise return constraint: null for every campaign — indistinguishable from a genuinely healthy account. The diagnostic names which case it is: no ad rows or campaign traffic in the window at all (no_campaign_data, e.g. a paused ad account), the conversion event does not exist (no_conversion_events), or it exists but nothing is creditable (no_conversions_attributed).

Canonical identities

By default people are keyed on distinct_id, which identify() already rewrites to the canonical id at ingest. Pass resolve_identities=true to resolve through the central identity snapshots instead, collapsing merge tombstones:

curl "$BASE/client/v1/constraints?location_id=$LOC&resolve_identities=true"

Measured worth about 0.2pp of extra credited conversions on a typical client, precisely because the merge already happened upstream at ingest. The gain scales with a client's merge rate — under 1% for almost every location, but 10.8% for one, where it is worth turning on. conversion_attribution.identity reports which mode was used.

Campaign attribution

Two models for deciding which campaign a touch belongs to, both computed from columns already on your events — so switching costs nothing:

  • session (default) — the conversion's own session's campaign when it has one; otherwise the person's most recent paid touch in the window. Effectively last-paid-touch (see the callout above), which is closest to what the ad platform itself would claim.
  • first_touch — the person's earliest paid touch instead: credits the campaign that first acquired the person. Use it when the buying cycle is long enough that the closing touch tells you nothing about what earned the customer.

Positional, decay, and data-driven models are deliberately absent: they change how credit is split between campaigns, not which step constrains a campaign — which is the question this endpoint answers. For true incremental lift rather than attributed credit, use the Incrementality API.

Requirements

  • A BigQuery event dataset for the location (otherwise 424).
  • A synced Meta ads-insights stream in that dataset. The platform default is meta_ads_ads_insights_action_type; pass ?table= if your connection writes elsewhere (otherwise 424).
  • Meta traffic tagged with UTMs. Untagged traffic isn't lost — it lands in unmatched_sessions and drags match_rate down, which is the honest signal.

Currency: spend is in the ad account's currency and revenue in whatever your conversion events carry — roas and cpa divide them without converting. For USD ad accounts with USD revenue this is a non-issue; for anything else, treat cross-currency ROAS as approximate.

Each call is a two-table BigQuery scan, so the rate limit is 20 requests per minute per location.