# Track trial-to-paid conversion by acquisition source

> Build the trial-to-paid funnel split by acquisition source. Surface the channel that converts 8x better than the rest.

## Agent adaptation contract

- Canonical human page: https://agentry.sh/workflows/trial-to-paid-conversion
- Execution mode: on_demand
- Immutable automation template: none
- Applies to: b2c-saas, b2b-saas, devtools-api
- Required example events: signup_completed, trial_started, subscription_started
- Required Agentry resources: none declared
- Do not use when:
  - Do not use until the example events are mapped to observed project signals, the current onboarding state is verified, and live event/property reads prove the required data is present.
  - Do not recommend this for products without a real revenue lifecycle or an equivalent paid-account signal.
  - Do not present illustrative custom analysis as a built-in query blueprint or runnable Agentry product surface.
- Ask before using:
  - Which observed events map to signup_completed, trial_started, subscription_started? Is the current onboarding state verified, and do live event/property reads show non-synthetic traffic for them?
  - Which live properties provide signup_completed.utm_source, subscription_started.plan, and which stable user or account identifier joins the signals?

This is an adaptable workflow example, not an API recipe. Map event and property names to the project's saved signal map, require status: "verified" from GET /v1/projects/:project_id/onboarding, and confirm the required signals through live event/property metadata and rows. Fetch current OpenAPI or query-blueprint details before making calls. Do not infer unattended authority from this page.

## 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

**You:**
> Build the trial-to-paid funnel split by acquisition source. Which channel converts trials best?

**Agent:**
> Let me check your event vocabulary first.

```text
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"
```
```text
{
  "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": []
}
```

**Agent:**
> 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.

```text
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"
```
```text
{
  "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 }
  ]
}
```

**Agent:**
> 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.

**Agent:**
> 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:

```ts
// 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:

```ts
// 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 %."*
