ConstraintsFind the constraint in the paid funnel

Find the constraint in the paid funnel

Builds the full funnel per campaign (or ad set / ad) over the requested window and returns, for each row, the step whose fix would unlock the most conversions. The funnel is impressions → clicks → sessions → [leads] → conversions. Each step is compared against the pooled rate across every row in the account (not a global benchmark), and the constraint is the step with the highest recoverable_conversions — the extra conversions that closing the gap to that pooled rate would produce, after carrying the row's own downstream rates. A campaign already at or above the pooled rate on every step returns constraint: null: nothing to fix, scale it instead. Supply lead_event to split the on-site step into landing (visit → lead) and sales (lead → conversion). Without it those collapse into a single site step, because sessions → conversions genuinely cannot distinguish a page problem from a closing problem. Conversions are credited to sessions — their own when it resolved to a campaign, otherwise the person's nearest paid touch within attribution_window_days. This is effectively last-PAID-touch: an organic-session purchase by a person with a paid touch in-window is credited to paid. Pass attribution_window_days=0 for strict own-session-only credit. Always read tracking.match_rate before trusting the rest. It is the share of ad-click sessions whose UTMs resolved to a synced Meta row; a low value means the clicks → sessions step is measuring UTM hygiene rather than landing-page quality.

curl -X GET "https://mythic-analytics.gulp.workers.dev/client/v1/constraints?location_id=example_string&from=2024-12-25&to=2024-12-25&days=28&group_by=campaign&attribution=session&conversion_event=transaction_completed&lead_event=example_string&min_impressions=1000&campaign=example_string&account_id=act_1234567890&platform=meta&revenue_path=%24.value&table=meta_ads_ads_insights_action_type&attribution_window_days=30&include=audit%2Cevents&timezone=America%2FLos_Angeles&resolve_identities=true&benchmark_ad=3.14&benchmark_tracking=3.14&benchmark_landing=3.14&benchmark_sales=3.14&benchmark_site=3.14" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_API_TOKEN"
{
  "success": true,
  "data": {
    "window": {
      "from": "2024-12-25",
      "to": "2024-12-25",
      "timezone": "example_string",
      "timezone_source": "request",
      "ads_synced_at": "2024-12-25T10:00:00Z",
      "last_day_complete": true,
      "note": "example_string"
    },
    "grain": "campaign",
    "campaign": "example_string",
    "attribution": "session",
    "conversion_event": "example_string",
    "lead_event": "example_string",
    "revenue_measured": true,
    "steps": [
      {
        "step": "ad",
        "from": "example_string",
        "to": "example_string"
      }
    ],
    "benchmarks": {},
    "benchmark_sources": {},
    "pooled_rates": {},
    "pooled_rates_all_rows": {},
    "benchmark_pool_rows": 42,
    "stored_benchmarks": {
      "default": {},
      "default_notes": {},
      "campaigns": [
        {
          "campaign": "example_string",
          "benchmarks": {},
          "notes": {},
          "matched_rows": 42
        }
      ],
      "not_applicable_steps": [
        "example_string"
      ],
      "not_applicable_reason": "example_string"
    },
    "stored_benchmarks_unavailable": "example_string",
    "tracking": {
      "matched_sessions": 42,
      "unmatched_sessions": 42,
      "match_rate": 3.14,
      "joined_by_id": 123,
      "joined_by_url_param": 42,
      "grain_resolvable": true,
      "joined_by_name": 42,
      "click_to_session_rate": 3.14,
      "unique_clicks": 42,
      "click_to_unique_click_rate": 3.14,
      "meta_reported": {
        "landing_page_views": 25,
        "leads": 42,
        "sessions_per_landing_page_view": 3.14,
        "our_lead_sessions": 42,
        "our_leads_per_meta_lead": 3.14,
        "our_converting_sessions": 42,
        "our_conversions_per_meta_lead": 3.14
      },
      "redirect": {
        "used": true,
        "unavailable": "example_string",
        "hits": 42,
        "unique_clicks": 42,
        "rows_with_redirect": 42,
        "click_to_redirect_rate": 3.14,
        "redirect_to_visit_rate": 3.14,
        "by_placement": {}
      },
      "unexpanded_macro": {
        "sessions_all_sources": 42,
        "sessions_in_unmatched": 42,
        "sessions": 42,
        "meta_click_sessions": 42,
        "sample": "example_string",
        "message": "example_string"
      }
    },
    "conversion_attribution": {
      "window_days": 42,
      "identity": "distinct_id",
      "total_conversions_in_window": 42,
      "credited_via_session": 42,
      "credited_via_person": 42,
      "uncredited": 42,
      "credited_coverage": 3.14,
      "identity_resolution": {
        "enabled": true,
        "conversions_resolved_to_person": 42,
        "conversions_resolved_rate": 3.14,
        "distinct_converting_persons": 42
      }
    },
    "diagnostic": {
      "code": "no_campaign_data",
      "message": "example_string"
    },
    "audit": {},
    "event_inventory": {},
    "auto_selected": {
      "conversion_event": "transaction_completed",
      "lead_event": "input_captured",
      "revenue_path": "$.meta_amount",
      "chosen_from": 20
    },
    "warning": {
      "code": "low_conversion_coverage",
      "message": "example_string"
    },
    "account_id": "example_string",
    "diagnostics_scope": "location",
    "account_totals": {
      "rows": 42,
      "impressions": 42,
      "clicks": 42,
      "spend": 3.14,
      "meta_landing_page_views": 25,
      "meta_leads": 42,
      "sessions": 42,
      "unique_clicks": 42,
      "lead_sessions": 42,
      "converting_sessions": 42,
      "conversions": 42,
      "revenue": 3.14,
      "cost_per_conversion": 3.14,
      "roas": 3.14
    },
    "excluded_low_volume": 42,
    "min_impressions": 42,
    "rows": [
      {
        "campaign_id": "example_string",
        "campaign_name": "John Doe",
        "account_id": "example_string",
        "impressions": 42,
        "clicks": 42,
        "spend": 3.14,
        "meta_landing_page_views": 25,
        "meta_leads": 42,
        "sessions": 42,
        "unique_clicks": 42,
        "lead_sessions": 42,
        "converting_sessions": 42,
        "conversions": 42,
        "revenue": 3.14,
        "top_landing_page": "example_string",
        "redirect": {
          "hits": 42,
          "unique_clicks": 42,
          "click_to_redirect_rate": 3.14,
          "redirect_to_visit_rate": 3.14,
          "by_placement": {}
        },
        "placements": [
          {
            "placement": "example_string",
            "spend": 3.14,
            "impressions": 42,
            "clicks": 42,
            "landing_page_views": 25,
            "leads": 42,
            "ctr": 3.14,
            "cost_per_click": 3.14,
            "cost_per_lead": 3.14,
            "redirect_unique_clicks": 42,
            "click_to_redirect_rate": 3.14
          }
        ],
        "cpa": 3.14,
        "roas": 3.14,
        "funnel": {},
        "benchmarks": {},
        "benchmark_sources": {},
        "creative": {
          "is_representative": true,
          "headline": "example_string",
          "body": "example_string",
          "description": "example_string",
          "call_to_action": "example_string",
          "destination_url": "example_string",
          "effective_destination_url": "example_string",
          "display_url": "example_string",
          "url_tags": "example_string",
          "image_url": "example_string",
          "video_id": "example_string",
          "format": "image",
          "asset_count": 10,
          "assets": [
            {
              "headline": "example_string",
              "body": "example_string",
              "image_url": "example_string",
              "link": "example_string"
            }
          ],
          "unavailable": "example_string",
          "reason_code": "creative_not_synced"
        },
        "converting_persons": 42,
        "person_ids": [
          "example_string"
        ],
        "measurable": {},
        "status": "constrained",
        "constraint": {
          "step": "ad",
          "diagnosis": "example_string",
          "rate": 3.14,
          "benchmark_rate": 3.14,
          "benchmark_source": "override",
          "benchmark_note": "example_string",
          "confidence": "low",
          "confidence_reason": "example_string",
          "rate_vs_benchmark": 3.14,
          "recoverable_conversions": 3.14,
          "sub_step": "delivery",
          "sub_step_diagnosis": "example_string"
        }
      }
    ],
    "placements": {},
    "summary": {
      "primary_constraint": {
        "step": "example_string",
        "diagnosis": "example_string",
        "recoverable_conversions": 3.14,
        "entities": 42,
        "spend": 3.14
      },
      "by_step": [
        {}
      ]
    }
  }
}
GET
/client/v1/constraints
GET
Base URLstring

Target server for requests. Edit to use your own host.

Bearer Token
Bearer Tokenstring
Required

Agency key (ak_) or location secret key (sk_). Scoped keys (mcp_) are accepted too and need constraints:read or constraints:write. See Using an mcp_ key over HTTP.

Agency key (ak_) or location secret key (sk_). Scoped keys (mcp_) are accepted too and need constraints:read or constraints:write. See Using an mcp_ key over HTTP.
query
location_idstring

Location to scope to. Required for ak_ keys (or send the X-Location-Id header); ignored for sk_.

query
fromstring

Window start (YYYY-MM-DD). Defaults to 28 days before to.

Format: date
query
tostring

Window end (YYYY-MM-DD), inclusive. Defaults to yesterday.

Format: date
query
daysinteger

Window length in days, inclusive of both ends — the same shorthand /client/v1/data takes. days=90 ending yesterday is the usual call. Ignored when from is given explicitly. Out-of-range values are rejected, not clamped.

Min: 1 • Max: 365
query
group_bystring

Grain of the analysis. ad is where creative lives, but it matches through utm_content, which carries a usable ad identifier on far fewer accounts than utm_id does — expect a lower match_rate.

Options: campaign, adset, ad
query
attributionstring

session uses the conversion's own session's campaign when it has one, else the person's MOST RECENT paid touch in the window — effectively last-paid-touch, closest to what the ad platform itself would claim. first_touch uses the person's EARLIEST paid touch: the campaign that first acquired them, however long ago.

Options: session, first_touch
query
conversion_eventstring

Event name counted as the conversion. Discover this client's real event names via GET /client/v1/data/events/breakdown?dimension=event_name. Pass `auto` to let the endpoint choose: it runs the eligibility probe first, takes its suggested pairing, and reports the choice under auto_selected so it is visible rather than magic. That replaces a round trip you would otherwise make to /constraints/events purely to learn which event to ask about. An explicit lead_event still wins over the suggestion. If nothing in the window can serve as a conversion you get a 422 no_usable_conversion_event rather than an analysis of a dead funnel.

query
lead_eventstring

Optional mid-funnel event. Supplying it splits the on-site step into landing and sales. Must differ from conversion_event.

query
min_impressionsinteger

Rows below this many impressions are excluded from the analysis but still counted in the pooled benchmarks — dropping them from the pool would inflate the bar the surviving rows are judged against.

query
campaignstring

Drill into one campaign, matched by campaign id or name (trimmed, case-insensitive). With group_by=adset or ad it scopes the rows to that campaign's children. Benchmarks stay the account-wide pooled rates, so the campaign is still judged against the whole account — that comparison is the point of the drilldown. If nothing matches, the response carries a campaign_not_found diagnostic instead of a silent empty list.

query
account_idstring

Return only one Meta ad account's rows: the numeric id, with or without the act_ prefix Ads Manager shows (act_1234567890 and 1234567890 are the same filter). Anything else is a 400. The filter runs after attribution. Conversions are credited across every ad account in the location first, then the rows are filtered, so a person who clicked an ad in account A and later one in account B is credited once, to whichever touch the attribution model picks, and never to both accounts. Benchmarks, pooled_rates, tracking, conversion_attribution and any diagnostic stay location-wide; the response says so with diagnostics_scope: location and adds account_totals. Combine with campaign to narrow further. If the account has no rows in the window the response carries an account_not_found diagnostic.

Pattern: ^(act_)?[0-9]{1,20}$
query
platformstring

Ad platform. Only meta is supported; google answers 501 platform_not_supported until a Google Ads adapter exists.

Options: meta, google
query
revenue_pathstring

JSON path to the conversion's monetary value inside the event's properties. properties_json is the properties object, so the path is $.value, not $.properties.value. Pass none when the conversion event carries no value (the /constraints/events inventory lists each event's revenue_paths). Revenue is then not read at all: revenue is 0, roas is null and revenue_measured is false. Reading the properties is most of the scan on a small client (measured: 4.2 of 7.1 GB), so this makes the call cheaper and faster. conversion_event=auto does this by itself when the event it picks has no value path.

query
tablestring

Meta insights table in the client's dataset, if it differs from the platform default.

query
attribution_window_daysinteger

How long after a campaign touch a conversion may still be credited to it. A conversion with no session of its own (all CRM/server-side conversions) is credited to the person's nearest campaign touch inside this window; touches are looked up back to from minus the window, so early-window conversions still find the visit that earned them. Meta's own default is 7-day click — pass 7 for numbers comparable to Ads Manager. Pass 0 for strict own-session credit only (disables person crediting; the conservative reading).

Min: 0 • Max: 365
query
includestring

Comma-separated optional sections, all off by default: include=creative,audit,events,placements. creative adds each row's ad copy, destination and assets. Off by default because it is not free: measured on one client it scans 285.9 MB on every call at every grain, independent of the report window (the creatives table sits in a single partition that nothing prunes, and one JSON column is 139 MB of it). If you join creative yourself, leave it off. Row labels are unaffected — ad_name comes from ads-insights and top_landing_page from sessions. audit returns the tracking-hygiene report (the same body as /constraints/audit) under an audit key, and events returns the event inventory (/constraints/events) under event_inventory. These stay separate BigQuery jobs — one request cannot make them one scan — but they run in parallel with the analysis, so a caller that needed all three stops paying three serial round trips and pays the slowest one instead. placements splits every row by Meta placement (facebook, instagram, audience_network, messenger, threads) with spend, impressions, link clicks, Meta landing page views and Meta leads, and rolls the account up by placement under a top-level placements. Audience Network behaves unlike the rest (measured on one account: 71% of clicks, a 37% click rate, no leads), and summed into a row it reads as a healthy ad with a weak page. Needs the ads_insights_delivery_platform stream on the Meta connection; without it the section is {"unavailable": "stream_not_synced"}. A section that fails is reported as {"unavailable": "<reason>"} inside a 200; the analysis itself is never failed by it. An unknown name is a 400 rather than a silent omission.

query
timezonestring

IANA time zone the funnel's days are bucketed in, e.g. America/Los_Angeles. Meta reports in the ad account's time zone (its date_start is that account's local day), so our events are moved onto the same calendar; left to UTC, a Pacific account's evening clicks land on the next day on one side only (measured: a day of 13 clicks read as 22 in UTC). Default: the location's stored time zone, else UTC. The response states the zone used in window.timezone and where it came from in window.timezone_source. Also sets the default to (yesterday in that zone). An unknown name is a 400.

query
resolve_identitiesboolean

Identity stitching — resolve people through the central identity snapshots (merge tombstones collapsed) rather than the raw distinct_id. On by default, and you almost never want it off. A CRM/server-side conversion arrives with no session of its own, so credit can only reach a campaign through the person; the conversion's distinct_id is a CRM contact id that shares nothing with the browsing device's id until the snapshot maps both onto one person. Measured on one client over 30 days (273 conversions): the raw distinct_id could credit 10% of conversions to a paid touch, canonical persons credited 81%. Pass false (or 0) to opt out — useful only to reproduce a figure from before this was the default. conversion_attribution.identity_resolution reports how much of the resolution actually worked.

query
benchmark_adnumber

Target rate for the impressions -> clicks step, replacing the account's pooled rate. A rate between 0 and 1 — a 2% CTR target is 0.02. Values above 1 are rejected, not interpreted as percentages.

Format: double • Min: 0 • Max: 1
query
benchmark_trackingnumber

Target rate for the clicks -> sessions step. Rate between 0 and 1.

Format: double • Min: 0 • Max: 1
query
benchmark_landingnumber

Target rate for the sessions -> leads step. Rate between 0 and 1. Requires lead_event; without it this step does not exist and the request is rejected.

Format: double • Min: 0 • Max: 1
query
benchmark_salesnumber

Target rate for the leads -> conversions step. Rate between 0 and 1. Requires lead_event.

Format: double • Min: 0 • Max: 1
query
benchmark_sitenumber

Target rate for the combined sessions -> conversions step. Rate between 0 and 1. Only valid WITHOUT lead_event, which splits this step into landing and sales.

Format: double • Min: 0 • Max: 1
Request Preview
Response

Response will appear here after sending the request

Authentication

header
Authorizationstring
Required

Bearer token. Agency key (ak_) or location secret key (sk_). Scoped keys (mcp_) are accepted too and need constraints:read or constraints:write. See Using an mcp_ key over HTTP.

Query Parameters

location_idstring

Location to scope to. Required for ak_ keys (or send the X-Location-Id header); ignored for sk_.

fromstring

Window start (YYYY-MM-DD). Defaults to 28 days before to.

tostring

Window end (YYYY-MM-DD), inclusive. Defaults to yesterday.

daysinteger

Window length in days, inclusive of both ends — the same shorthand /client/v1/data takes. days=90 ending yesterday is the usual call. Ignored when from is given explicitly. Out-of-range values are rejected, not clamped.

group_bystring

Grain of the analysis. ad is where creative lives, but it matches through utm_content, which carries a usable ad identifier on far fewer accounts than utm_id does — expect a lower match_rate.

Allowed values:campaignadsetad
attributionstring

session uses the conversion's own session's campaign when it has one, else the person's MOST RECENT paid touch in the window — effectively last-paid-touch, closest to what the ad platform itself would claim. first_touch uses the person's EARLIEST paid touch: the campaign that first acquired them, however long ago.

Allowed values:sessionfirst_touch
conversion_eventstring

Event name counted as the conversion. Discover this client's real event names via GET /client/v1/data/events/breakdown?dimension=event_name. Pass auto to let the endpoint choose: it runs the eligibility probe first, takes its suggested pairing, and reports the choice under auto_selected so it is visible rather than magic. That replaces a round trip you would otherwise make to /constraints/events purely to learn which event to ask about. An explicit lead_event still wins over the suggestion. If nothing in the window can serve as a conversion you get a 422 no_usable_conversion_event rather than an analysis of a dead funnel.

lead_eventstring

Optional mid-funnel event. Supplying it splits the on-site step into landing and sales. Must differ from conversion_event.

min_impressionsinteger

Rows below this many impressions are excluded from the analysis but still counted in the pooled benchmarks — dropping them from the pool would inflate the bar the surviving rows are judged against.

campaignstring

Drill into one campaign, matched by campaign id or name (trimmed, case-insensitive). With group_by=adset or ad it scopes the rows to that campaign's children. Benchmarks stay the account-wide pooled rates, so the campaign is still judged against the whole account — that comparison is the point of the drilldown. If nothing matches, the response carries a campaign_not_found diagnostic instead of a silent empty list.

account_idstring

Return only one Meta ad account's rows: the numeric id, with or without the act_ prefix Ads Manager shows (act_1234567890 and 1234567890 are the same filter). Anything else is a 400. The filter runs after attribution. Conversions are credited across every ad account in the location first, then the rows are filtered, so a person who clicked an ad in account A and later one in account B is credited once, to whichever touch the attribution model picks, and never to both accounts. Benchmarks, pooled_rates, tracking, conversion_attribution and any diagnostic stay location-wide; the response says so with diagnostics_scope: location and adds account_totals. Combine with campaign to narrow further. If the account has no rows in the window the response carries an account_not_found diagnostic.

Example:
act_1234567890
platformstring

Ad platform. Only meta is supported; google answers 501 platform_not_supported until a Google Ads adapter exists.

Allowed values:metagoogle
revenue_pathstring

JSON path to the conversion's monetary value inside the event's properties. properties_json is the properties object, so the path is $.value, not $.properties.value.

Pass none when the conversion event carries no value (the /constraints/events inventory lists each event's revenue_paths). Revenue is then not read at all: revenue is 0, roas is null and revenue_measured is false. Reading the properties is most of the scan on a small client (measured: 4.2 of 7.1 GB), so this makes the call cheaper and faster. conversion_event=auto does this by itself when the event it picks has no value path.

tablestring

Meta insights table in the client's dataset, if it differs from the platform default.

attribution_window_daysinteger

How long after a campaign touch a conversion may still be credited to it. A conversion with no session of its own (all CRM/server-side conversions) is credited to the person's nearest campaign touch inside this window; touches are looked up back to from minus the window, so early-window conversions still find the visit that earned them. Meta's own default is 7-day click — pass 7 for numbers comparable to Ads Manager. Pass 0 for strict own-session credit only (disables person crediting; the conservative reading).

includestring

Comma-separated optional sections, all off by default: include=creative,audit,events,placements. creative adds each row's ad copy, destination and assets. Off by default because it is not free: measured on one client it scans 285.9 MB on every call at every grain, independent of the report window (the creatives table sits in a single partition that nothing prunes, and one JSON column is 139 MB of it). If you join creative yourself, leave it off. Row labels are unaffected — ad_name comes from ads-insights and top_landing_page from sessions. audit returns the tracking-hygiene report (the same body as /constraints/audit) under an audit key, and events returns the event inventory (/constraints/events) under event_inventory. These stay separate BigQuery jobs — one request cannot make them one scan — but they run in parallel with the analysis, so a caller that needed all three stops paying three serial round trips and pays the slowest one instead. placements splits every row by Meta placement (facebook, instagram, audience_network, messenger, threads) with spend, impressions, link clicks, Meta landing page views and Meta leads, and rolls the account up by placement under a top-level placements. Audience Network behaves unlike the rest (measured on one account: 71% of clicks, a 37% click rate, no leads), and summed into a row it reads as a healthy ad with a weak page. Needs the ads_insights_delivery_platform stream on the Meta connection; without it the section is {"unavailable": "stream_not_synced"}. A section that fails is reported as {"unavailable": "<reason>"} inside a 200; the analysis itself is never failed by it. An unknown name is a 400 rather than a silent omission.

Example:
audit,events
timezonestring

IANA time zone the funnel's days are bucketed in, e.g. America/Los_Angeles. Meta reports in the ad account's time zone (its date_start is that account's local day), so our events are moved onto the same calendar; left to UTC, a Pacific account's evening clicks land on the next day on one side only (measured: a day of 13 clicks read as 22 in UTC). Default: the location's stored time zone, else UTC. The response states the zone used in window.timezone and where it came from in window.timezone_source. Also sets the default to (yesterday in that zone). An unknown name is a 400.

Example:
America/Los_Angeles
resolve_identitiesboolean

Identity stitching — resolve people through the central identity snapshots (merge tombstones collapsed) rather than the raw distinct_id. On by default, and you almost never want it off. A CRM/server-side conversion arrives with no session of its own, so credit can only reach a campaign through the person; the conversion's distinct_id is a CRM contact id that shares nothing with the browsing device's id until the snapshot maps both onto one person. Measured on one client over 30 days (273 conversions): the raw distinct_id could credit 10% of conversions to a paid touch, canonical persons credited 81%. Pass false (or 0) to opt out — useful only to reproduce a figure from before this was the default. conversion_attribution.identity_resolution reports how much of the resolution actually worked.

benchmark_adnumber

Target rate for the impressions -> clicks step, replacing the account's pooled rate. A rate between 0 and 1 — a 2% CTR target is 0.02. Values above 1 are rejected, not interpreted as percentages.

benchmark_trackingnumber

Target rate for the clicks -> sessions step. Rate between 0 and 1.

benchmark_landingnumber

Target rate for the sessions -> leads step. Rate between 0 and 1. Requires lead_event; without it this step does not exist and the request is rejected.

benchmark_salesnumber

Target rate for the leads -> conversions step. Rate between 0 and 1. Requires lead_event.

benchmark_sitenumber

Target rate for the combined sessions -> conversions step. Rate between 0 and 1. Only valid WITHOUT lead_event, which splits this step into landing and sales.

Responses

successboolean
dataobject