Why this matters
Most acquisition dashboards show traffic. The metric that actually matters is conversion-by-source. Reddit might be 1% of your traffic but 8% of your paid signups; LinkedIn might be 30% of traffic and 2% of paid. Spending $5k on more LinkedIn ads is then provably wrong.
Mixpanel has this view but you set it up once per source and re-build it every quarter. GA4 has UTMs but no funnel UI. Most teams have a Notion doc with stale numbers and a Slack message asking “wait, where do our actual paying customers come from?”
Agentry first checks whether the saved signal map, current verified onboarding state, and live event properties can actually support the question. Then HogQL groups your funnel by acquisition_source in one query; a public dashboard is only a follow-up if you explicitly ask for a browser-safe view.
What you get
- A table of acquisition sources × trial-start × paid-conversion × conversion rate
- The channel with the best conversion rate (not just the most volume)
- Optional browser-safe output when you explicitly ask for an embeddable weekly review
Walk through it
Build the trial-to-paid funnel split by acquisition source. Which channel converts trials best?
Let me check your event vocabulary first.
Endpoint: GET /v1/projects/:project_id/event-names
Exact shape: /v1/openapi.json?path=%2Fv1%2Fprojects%2F%7Bproject_id%7D%2Fevent-names&method=get
Use query parameters, not a JSON body.
Query/filter concept:
matching: "trial|signup|subscription|paid"
{
"server_emitted": [],
"analytics_events": [
{ "event": "trial_started", "count": 4280, "last_seen": 1783651200 },
{ "event": "signup_completed", "count": 4310, "last_seen": 1783651210 },
{ "event": "subscription_started", "count": 412, "last_seen": 1783651250 },
{ "event": "subscription_canceled", "count": 31, "last_seen": 1783651290 }
],
"wildcards": []
}
Good — trial_started and subscription_started are plausible edges. I
still need to verify that acquisition_source exists on the trial event and
that both events use the same stable identity before running the comparison.
Endpoint: POST /v1/projects/:project_id/analytics/query
Exact shape: /v1/openapi.json?path=%2Fv1%2Fprojects%2F%7Bproject_id%7D%2Fanalytics%2Fquery&method=post
Custom HogQL goes in the OpenAPI-defined `query` field.
Concept fields:
query: "WITH trials AS (SELECT distinct_id, properties.acquisition_source AS source, min(timestamp) AS trial_ts FROM events WHERE event = 'trial_started' AND timestamp >= now() - INTERVAL 90 DAY GROUP BY distinct_id, source), paid AS (SELECT distinct_id, min(timestamp) AS paid_ts FROM events WHERE event = 'subscription_started' AND timestamp >= now() - INTERVAL 90 DAY GROUP BY distinct_id) SELECT t.source AS source, count(DISTINCT t.distinct_id) AS trials_count, count(DISTINCT p.distinct_id) AS paid_count, round(count(DISTINCT p.distinct_id) * 100.0 / count(DISTINCT t.distinct_id), 1) AS conv_pct FROM trials t LEFT JOIN paid p ON t.distinct_id = p.distinct_id AND p.paid_ts > t.trial_ts AND p.paid_ts < t.trial_ts + INTERVAL 14 DAY GROUP BY source ORDER BY trials_count DESC"
{
"rows": [
{ "source": "google_organic", "trials_count": 1820, "paid_count": 142, "conv_pct": 7.8 },
{ "source": "linkedin_ads", "trials_count": 980, "paid_count": 41, "conv_pct": 4.2 },
{ "source": "direct", "trials_count": 720, "paid_count": 71, "conv_pct": 9.9 },
{ "source": "reddit", "trials_count": 142, "paid_count": 31, "conv_pct": 21.8 },
{ "source": "twitter", "trials_count": 312, "paid_count": 18, "conv_pct": 5.8 },
{ "source": "producthunt", "trials_count": 280, "paid_count": 88, "conv_pct": 31.4 }
]
}
In this illustrative result, Product Hunt and Reddit have higher observed trial-to-paid rates. The samples differ substantially, and conversion alone does not establish CAC, payback, incrementality, or channel profitability.
This by-source conversion query is custom HogQL, not a built-in public-query blueprint. I can keep it in a protected internal dashboard by running the analytics/query call server-side. If you want a browser-safe public URL, I should first add a real trial-to-paid query blueprint and then publish that blueprint.
Protected result ready. The next step is to review spend, cohort quality, retention, attribution, and uncertainty before changing any campaign.
The output
Trial → Paid by Acquisition Source (90d, 14-day conversion window)
SOURCE TRIALS PAID CONV% SAMPLE NOTE
producthunt 280 88 31.4% smaller cohort
reddit 142 31 21.8% small cohort
direct 720 71 9.9% attribution may mix sources
google_organic 1,820 142 7.8% largest observed cohort
twitter 312 18 5.8% smaller cohort
linkedin_ads 980 41 4.2% spend/payback not measured
Volume vs quality split:
Volume leaders: google_organic, linkedin_ads
Quality leaders: producthunt, reddit, direct
Review before action:
- add spend and payback before judging paid-channel economics
- compare later retention and revenue using the same identity/cohort rules
- validate attribution and sample stability before changing budgets
Public view:
Use Agentry's returned public_url after publishing a real query blueprint.
Setting it up
You need two events plus a UTM-style source on the trial event:
// On signup completion (server-side, after email verify):
await fetch(`https://api.agentry.sh/v1/analytics/`, {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.AGENTRY_SERVER_API_KEY}`,
"Content-Type": "application/json",
"User-Agent": "myapp/1.0", // REQUIRED — Cloudflare 403s default UAs
},
body: JSON.stringify({
event: "trial_started",
distinct_id: user.email,
properties: {
acquisition_source: user.utm_source ?? "direct", // ← the key field
acquisition_campaign: user.utm_campaign,
referrer: user.initial_referrer,
},
}),
});
// On Stripe webhook (subscription.created → trial → active OR direct paid):
await fetch(`https://api.agentry.sh/v1/analytics/`, {
method: "POST",
headers: { /* same */ "User-Agent": "myapp-stripe/1.0" },
body: JSON.stringify({
event: "subscription_started",
distinct_id: stripeCustomerEmail,
properties: { plan: subscription.items.data[0].plan.nickname, amount: subscription.items.data[0].plan.amount },
}),
});
To capture utm_source on signup, snapshot it from the landing page into a first-party cookie (or localStorage) and read it on signup:
// On any landing page hit:
const params = new URLSearchParams(window.location.search);
if (params.get("utm_source")) {
localStorage.setItem("acq_source", params.get("utm_source")!);
localStorage.setItem("acq_campaign", params.get("utm_campaign") ?? "");
}
// On signup form submission, read back from localStorage and POST with the trial_started event.
Variations
- “Same funnel but for the last 30 days only — has anything shifted post-launch?”
- “Compare trial-to-paid by GEO (country) — are non-US trials converting differently?”
- “For Product Hunt specifically, what’s the path-to-paid? Show the average # of sessions before paying.”
- “Build a separate dashboard for paid ad channels only (LinkedIn, Google ads, Facebook). Compare CPA × conversion %.”