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&revenue_path=%24.value&table=meta_ads_ads_insights_action_type&attribution_window_days=30&include=audit%2Cevents&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"
import requests
import json
url = "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&revenue_path=%24.value&table=meta_ads_ads_insights_action_type&attribution_window_days=30&include=audit%2Cevents&resolve_identities=true&benchmark_ad=3.14&benchmark_tracking=3.14&benchmark_landing=3.14&benchmark_sales=3.14&benchmark_site=3.14"
headers = {
"Content-Type": "application/json",
"Authorization": "Bearer YOUR_API_TOKEN"
}
response = requests.get(url, headers=headers)
print(response.json())
const response = await fetch("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&revenue_path=%24.value&table=meta_ads_ads_insights_action_type&attribution_window_days=30&include=audit%2Cevents&resolve_identities=true&benchmark_ad=3.14&benchmark_tracking=3.14&benchmark_landing=3.14&benchmark_sales=3.14&benchmark_site=3.14", {
method: "GET",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer YOUR_API_TOKEN"
}
});
const data = await response.json();
console.log(data);
package main
import (
"fmt"
"net/http"
)
func main() {
req, err := http.NewRequest("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&revenue_path=%24.value&table=meta_ads_ads_insights_action_type&attribution_window_days=30&include=audit%2Cevents&resolve_identities=true&benchmark_ad=3.14&benchmark_tracking=3.14&benchmark_landing=3.14&benchmark_sales=3.14&benchmark_site=3.14", nil)
if err != nil {
panic(err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer YOUR_API_TOKEN")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
fmt.Println("Response Status:", resp.Status)
}
require 'net/http'
require 'json'
uri = URI('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&revenue_path=%24.value&table=meta_ads_ads_insights_action_type&attribution_window_days=30&include=audit%2Cevents&resolve_identities=true&benchmark_ad=3.14&benchmark_tracking=3.14&benchmark_landing=3.14&benchmark_sales=3.14&benchmark_site=3.14')
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
request = Net::HTTP::Get.new(uri)
request['Content-Type'] = 'application/json'
request['Authorization'] = 'Bearer YOUR_API_TOKEN'
response = http.request(request)
puts response.body
{
"success": true,
"data": {
"window": {
"from": "2024-12-25",
"to": "2024-12-25"
},
"grain": "campaign",
"campaign": "example_string",
"attribution": "session",
"conversion_event": "example_string",
"lead_event": "example_string",
"steps": [
{
"step": "ad",
"from": "example_string",
"to": "example_string"
}
],
"benchmarks": {},
"benchmark_sources": {},
"pooled_rates": {},
"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,
"unexpanded_macro": {
"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"
},
"excluded_low_volume": 42,
"min_impressions": 42,
"rows": [
{
"campaign_id": "example_string",
"campaign_name": "John Doe",
"impressions": 42,
"clicks": 42,
"spend": 3.14,
"sessions": 42,
"lead_sessions": 42,
"converting_sessions": 42,
"conversions": 42,
"revenue": 3.14,
"top_landing_page": "example_string",
"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
}
}
],
"summary": {
"primary_constraint": {
"step": "example_string",
"diagnosis": "example_string",
"recoverable_conversions": 3.14,
"entities": 42,
"spend": 3.14
},
"by_step": [
{}
]
}
}
}
{
"error": "Bad Request",
"message": "The request contains invalid parameters or malformed data",
"code": 400,
"details": [
{
"field": "email",
"message": "Invalid email format"
}
]
}
{
"error": "Unauthorized",
"message": "Authentication required. Please provide a valid API token",
"code": 401
}
{
"error": "Unprocessable Entity",
"message": "The request was well-formed but contains semantic errors",
"code": 422,
"details": [
{
"field": "password",
"message": "Password must be at least 8 characters long"
}
]
}
{
"error": "Error",
"message": "No Meta ads-insights table in this client's dataset, or the client has no BigQuery event dataset yet.
",
"code": 424
}
{
"error": "Too Many Requests",
"message": "Rate limit exceeded. Please try again later",
"code": 429,
"retryAfter": 3600
}
/client/v1/constraints
Target server for requests. Edit to use your own host.
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.
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.Location to scope to. Required for ak_ keys (or send the X-Location-Id header); ignored for sk_.
Window start (YYYY-MM-DD). Defaults to 28 days before to.
Window end (YYYY-MM-DD), inclusive. Defaults to yesterday.
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.
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.
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.
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.
Optional mid-funnel event. Supplying it splits the on-site step into landing and sales. Must differ from conversion_event.
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.
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.
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.
Meta insights table in the client's dataset, if it differs from the platform default.
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).
Comma-separated optional sections, all off by default: include=creative,audit,events.
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.
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.
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.
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.
Target rate for the clicks -> sessions step. Rate between 0 and 1.
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.
Target rate for the leads -> conversions step. Rate between 0 and 1. Requires lead_event.
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.
Request Preview
Response
Response will appear here after sending the request
Authentication
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 to scope to. Required for ak_ keys (or send the X-Location-Id header); ignored for sk_.
Window start (YYYY-MM-DD). Defaults to 28 days before to.
Window end (YYYY-MM-DD), inclusive. Defaults to yesterday.
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.
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.
campaignadsetadsession 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.
sessionfirst_touchEvent 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.
Optional mid-funnel event. Supplying it splits the on-site step into landing and sales. Must differ from conversion_event.
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.
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.
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.
Meta insights table in the client's dataset, if it differs from the platform default.
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).
Comma-separated optional sections, all off by default: include=creative,audit,events.
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.
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.
audit,eventsIdentity 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.
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.
Target rate for the clicks -\> sessions step. Rate between 0 and 1.
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.
Target rate for the leads -\> conversions step. Rate between 0 and 1. Requires lead_event.
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.