> For the complete documentation index, see [llms.txt](https://docs.millimetric.ai/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.millimetric.ai/recipes/marketing-attribution.md).

# Marketing attribution dashboards

Most analytics tools give you one big "Facebook" bucket. Millimetric splits paid Facebook from organic Facebook automatically — and gives you the rule that fired so you can audit. This recipe shows the queries to build the dashboards every marketer asks for.

## What the classifier gives you

Every event row has:

```
source              "facebook" | "google" | "twitter" | "direct" | "internal" | ...
medium              "paid" | "organic" | "social" | "email" | "referral" | "direct"
campaign            utm_campaign, if any
source_confidence   "low" | "medium" | "high"
source_rule_id      which classifier rule fired
```

Full rule cascade in [Attribution](/core-concepts/attribution.md). Highlights:

* `gclid` → google/paid
* `fbclid` via `l.facebook.com` → facebook/paid (high)
* `fbclid` from elsewhere → facebook/paid (medium)
* `referrer = facebook.com` without `fbclid` → facebook/social
* `utm_source=facebook&utm_medium=cpc` → facebook/paid

## Channel breakdown — top of the dashboard

```bash
curl -G "https://api.millimetric.ai/v1/sources" \
  -H "Authorization: Bearer $RK_KEY" \
  --data-urlencode "from=2026-05-01T00:00:00Z" \
  --data-urlencode "to=2026-06-01T00:00:00Z" \
  --data-urlencode "breakdown=source_medium" | jq
```

→

```json
[
  { "source": "facebook", "medium": "paid",   "events": 6, "uniques": 6, "paid_share": 1.0 },
  { "source": "facebook", "medium": "social", "events": 3, "uniques": 3, "paid_share": 0.0 },
  { "source": "google",   "medium": "paid",   "events": 3, "uniques": 3, "paid_share": 1.0 },
  { "source": "google",   "medium": "organic","events": 8, "uniques": 8, "paid_share": 0.0 },
  { "source": "direct",   "medium": "direct", "events": 7, "uniques": 7, "paid_share": 0.0 }
]
```

## First-touch vs last-touch

The classifier evaluates *each event* independently — that's per-event truth. For "where did this customer come from", attribute by **first touch on the visitor**, not the last event.

### First-touch per visitor

```sql
SELECT
  argMin(source, timestamp)  AS first_source,
  argMin(medium, timestamp)  AS first_medium,
  count() AS visitors
FROM events
WHERE project_id = '...'
  AND timestamp > now() - INTERVAL 30 DAY
GROUP BY anonymous_id
GROUP BY first_source, first_medium
ORDER BY visitors DESC;
```

### Last-touch per visitor (just before conversion)

```sql
SELECT
  argMin(source, timestamp)        AS first_source,
  argMax(source, timestamp_signup) AS last_source_at_signup
FROM (
  SELECT
    anonymous_id,
    timestamp,
    source,
    if(event_name = 'signup', timestamp, NULL) AS timestamp_signup
  FROM events
  WHERE project_id = '...'
    AND timestamp > now() - INTERVAL 30 DAY
)
GROUP BY anonymous_id;
```

## Channel ROI — revenue per channel

Combines `completed_checkout` (revenue) with first-touch source (the channel that brought them in).

```sql
WITH attributed AS (
  SELECT
    e.anonymous_id,
    argMin(s.entry_source, s.started_at) AS first_source,
    argMin(s.entry_medium, s.started_at) AS first_medium
  FROM events e
  JOIN sessions s USING (project_id, anonymous_id)
  WHERE e.project_id = '...'
    AND e.event_name = 'completed_checkout'
    AND e.timestamp > now() - INTERVAL 30 DAY
  GROUP BY e.anonymous_id
)
SELECT
  a.first_source,
  a.first_medium,
  count(DISTINCT e.event_id) AS orders,
  sum(JSONExtractInt(e.properties, 'amount_cents')) / 100.0 AS revenue_usd
FROM events e
JOIN attributed a USING anonymous_id
WHERE e.project_id = '...'
  AND e.event_name = 'completed_checkout'
GROUP BY a.first_source, a.first_medium
ORDER BY revenue_usd DESC;
```

## Confidence-filtered attribution

When you want clean numbers (e.g. for board reporting), exclude low-confidence rows:

```sql
SELECT source, medium, count() AS events
FROM events
WHERE project_id = '...'
  AND source_confidence IN ('high', 'medium')   -- skip "low"
  AND timestamp > now() - INTERVAL 30 DAY
GROUP BY source, medium
ORDER BY events DESC;
```

Or *only* high-confidence:

```sql
WHERE source_confidence = 'high'
```

For `fbclid`-arrived events on non-Meta-redirect hosts, confidence is `medium` because `fbclid` can leak onto organic shares. Filter accordingly when revenue numbers matter.

## The Facebook social-vs-paid headline

This is the question the product was built to answer cleanly:

```sql
SELECT
  medium,
  count() AS events,
  uniq(anonymous_id) AS visitors,
  countIf(event_name = 'signup') AS signups,
  round(countIf(event_name = 'signup') * 100.0 / uniq(anonymous_id), 2) AS conv_pct
FROM events
WHERE project_id = '...'
  AND source = 'facebook'
  AND timestamp > now() - INTERVAL 30 DAY
GROUP BY medium;
```

→

```
medium  | events | visitors | signups | conv_pct
paid    | 4321   | 3998     | 412     | 10.30
social  | 1872   | 1801     | 67      | 3.72
```

Most analytics tools give you the union of those two rows. You can now act on the difference: paid Facebook converts 2.8× organic — fine, double down on ads, and don't optimize organic for conversion.

## Per-rule audit (for the paranoid)

Want to see exactly which classifier rule fired most often this week?

```sql
SELECT
  source_rule_id,
  source,
  medium,
  count() AS events
FROM events
WHERE project_id = '...'
  AND timestamp > now() - INTERVAL 7 DAY
GROUP BY source_rule_id, source, medium
ORDER BY events DESC
LIMIT 30;
```

This is the dashboard for "is the classifier behaving how I expect on real traffic". When `unknown/low` shows up at the top, one of your campaigns is missing UTMs.

## Building this in your own dashboard

Hit `/v1/sources` and `/v1/stats` from a server-side (Node, Python, Ruby) backend with an `rk_*` key, then render charts however you like. Sample minimal Next.js handler:

```ts
// app/api/dashboard/sources/route.ts
import { NextResponse } from "next/server";

export async function GET() {
  const url = new URL("https://api.millimetric.ai/v1/sources");
  url.searchParams.set("from", new Date(Date.now() - 30 * 86400 * 1000).toISOString());
  url.searchParams.set("to", new Date().toISOString());
  url.searchParams.set("breakdown", "source_medium");

  const res = await fetch(url, {
    headers: { Authorization: `Bearer ${process.env.AOA_RK!}` },
    next: { revalidate: 60 }
  });
  return NextResponse.json(await res.json());
}
```

Keep the `rk_*` key on the server. Never ship it to the browser.

## See also

* [Attribution](/core-concepts/attribution.md) — full classifier rules.
* [GET /v1/sources](/api-reference/sources.md) — the dashboard endpoint.
* [Sessions](/core-concepts/sessions.md) — entry-source per visit.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://docs.millimetric.ai/recipes/marketing-attribution.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
