Skip to content
HomeHome
DE
WhatsAppMailPhone
← All articles
Finding ads clicks in the Umami API when type=utm_source answers with 400
Performance & SEO

Finding ads clicks in the Umami API when type=utm_source answers with 400

Photo: lukechesser / Unsplash

How to break Google Ads traffic down to campaign and keyword in a cookieless Umami, why the obvious metrics call fails, which time zone the API returns and when filtered views show wrong session durations.

Eric MengeAuthorEric MengeOwner & web developer at EMIT Solution
Published
Reading timeca. 8 min

In short

  • The Umami API documentation lists no UTM value for the type parameter. A call with type=utm_source falls past SESSION_COLUMNS, EVENT_COLUMNS and the channel check inside the metrics route and ends up at badRequest(), so HTTP 400. In the source of v3.2.0 the types do exist, in camelCase, as utmSource, utmMedium, utmCampaign, utmContent and utmTerm inside EVENT_COLUMNS.
  • The route that works across versions is type=query. The endpoint returns the full query string per call including gclid, and you break it down yourself with parse_qs. Anyone who swallows the 400 in a try/except silently gets empty UTM lists and concludes that ads traffic cannot be analysed.
  • Of the seven website stats endpoints only /pageviews and /events/series accept a timezone parameter, and among the session endpoints only /sessions/weekly does. Google Ads reports in the account time zone, while the billing pages use PST exclusively. With three time axes in play a click otherwise lands in the wrong hour and therefore on the wrong keyword.
  • Referrer and UTM filters apply at pageview level. Since only the entry view carries the referrer, filtered overviews showed 0 seconds duration and 100 per cent bounce in my own queries. Behaviour belongs to /sessions and /metrics/expanded, not to the filtered overview.

The campaign is running, the target URLs carry utm_campaign and utm_term={keyword}, and the question could hardly be simpler. Which keyword brought in the session in which somebody clicked the contact button. In a cookieless, self-hosted Umami that ought to be a single API call.

It is not. The obvious call ends in HTTP 400, the timestamps do not line up with the Google Ads report, and the filtered overview claims that every ads visitor left again after zero seconds. All three points have a clean explanation, and none of the three appears in the API documentation.

Umami forms a visit from a hash of session ID and an hourly rotating salt, and the session itself from a hash of website ID, IP address, user agent and a second salt whose rotation is configurable and defaults to monthly. That is the whole of the session logic, and it works without any stored identifier in the browser.

For attribution that has one hard consequence. There is no visitor profile to which a campaign could be attached after the fact. Everything needed for the assignment has to sit in the event itself. And that is exactly where it sits. The collector in src/app/api/send/route.ts reads the ads parameters individually out of the submitted URL, via currentUrl.searchParams.get('utm_source'), ...get('gclid') and so on. There is no generic mapping of arbitrary query parameters, only this fixed list.

Network cabinet with patch panels and colourful network cables in dim light Photo: heyquilia / Unsplash

So the entry view of a session carries the campaign data and the follow-up views do not. That single property also explains trap 3 further down.

Trap 1, the metric type the docs do not know

The metrics endpoint is GET /api/websites/:websiteId/metrics with the required parameters startAt, endAt (Unix timestamp in milliseconds) and type. The response is an array of objects shaped {x: value, y: count}.

Optionally filters, limit (default 500) and offset (default 0) come on top. The default of 500 is reached quickly with type=query, because every combination of parameters produces its own row. A campaign with many keywords and auto-tagging enabled effectively produces one row per click, thanks to the individual gclid on each. Anyone analysing a longer period has to paginate, otherwise the long tail of the list goes missing exactly where the rarer keywords sit.

The official documentation lists precisely these values for type: path, entry, exit, title, query, referrer, channel, domain, country, region, city, browser, os, device, language, screen, event, hostname, tag, distinctId. There is no utm_source among them. The same documentation does list UTM parameters as a valid filter dimension. That asymmetry is documented without being explained anywhere.

The mechanism behind the 400 sits in the route itself.

// src/app/api/websites/[websiteId]/metrics/route.ts, v3.2.0
if (SESSION_COLUMNS.includes(type)) {
  const data = await getSessionMetrics(websiteId, { type, limit, offset }, filters);
  return json(data);
}

if (EVENT_COLUMNS.includes(type)) {
  if (type === 'event') {
    filters.eventType = EVENT_TYPE.customEvent;
    return json(await getEventMetrics(websiteId, { type, limit, offset }, filters));
  } else {
    return json(await getPageviewMetrics(websiteId, { type, limit, offset }, filters));
  }
}

if (type === 'channel') {
  return json(await getChannelMetrics(websiteId, filters));
}

return badRequest();

An unknown type string falls silently through to badRequest(). And utm_source is an unknown type string, because in src/lib/constants.ts of v3.2.0 the entries are named differently.

export const EVENT_COLUMNS = [
  'path', 'fullPath', 'entry', 'exit', 'referrer', 'domain', 'title',
  'query', 'event', 'tag', 'hostname',
  'utmSource', 'utmMedium', 'utmCampaign', 'utmContent', 'utmTerm',
];

export const FILTER_COLUMNS = {
  query: 'url_query',
  utmSource: 'utm_source',
  utmMedium: 'utm_medium',
  utmCampaign: 'utm_campaign',
  utmContent: 'utm_content',
  utmTerm: 'utm_term',
  // ...
};

So the metric types do exist, but in camelCase, and the API documentation does not list them. The obvious snake_case spelling hits no entry at all. Whether type=utmSource actually returns 200 on your own instance depends on the version and deserves one check before you build an analysis script on top of it.

The route that works regardless is type=query. The endpoint returns the complete query string per call, including gclid, and you break it down yourself.

from urllib.parse import parse_qs

def utm_breakdown(rows):
    """rows: [{'x': '?utm_source=google&utm_term=webdesign', 'y': 3}, ...]"""
    out = {}
    for row in rows:
        params = parse_qs(row['x'].lstrip('?'))
        for key in ('utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'gclid'):
            for value in params.get(key, []):
                out.setdefault(key, {})
                out[key][value] = out[key].get(value, 0) + row['y']
    return out

The real nastiness is not the status code, it is the usual way of dealing with it. Wrap the call in a try/except and return an empty list on error, and you no longer see an error, you see an empty result. And an empty result reads like “no ads traffic is arriving”.

One more detail for anyone who has upgraded an older instance. The Prisma migration 08_add_utm_clid adds eleven columns to website_event, five for UTM and six click IDs (fbclid, gclid, li_fat_id, msclkid, ttclid, twclid), all as VARCHAR(255). For historic data there is a separate script that has to be run manually, db/postgresql/data-migrations/convert-utm-clid-columns.sql, which backfills the new columns from url_query. If it never runs, the new columns stay empty for historic rows while url_query still holds the values. Querying via type=query covers both cases.

The tagging that has to be right first

Without clean parameters in the target URL the best endpoint is worth nothing. The field officially intended for this is the final URL suffix, which can be set at account, campaign, ad group, ad, keyword and dynamic ad target level. With parallel tracking the user lands directly on the final URL including the suffix parameters, while the tracking template is processed in the background.

utm_source=google&utm_medium=cpc&utm_campaign=webdesign-bundesweit&utm_term={keyword}

Two limitations on that, from Google Help. In search campaigns the ValueTrack parameter {keyword} returns the keyword from the account that matched the search query, whereas for keywordless formats such as AI Max for Search, DSA and Performance Max it returns an empty value. And auto-tagging additionally appends ?gclid=..., shown on the help page as www.example.com/?gclid=123xyz. Both then sit together in the query string.

Laptop screen showing the LinkedIn Campaign Manager with the ad accounts overview Photo: zulfugarkarimov / Unsplash

Umami can do something with either. The constant PAID_AD_PARAMS contains, among others, gclid=, fbclid=, msclkid=, ttclid=, li_fat_id=, twclid=, dclid=, utm_medium=cpc, utm_medium=paid and utm_source=google. The attribution report classifies paid traffic directly through the click ID columns, where a gclid yields “Google Ads”.

-- getAttribution.ts, counts sessions rather than views
select case
         when coalesce(gclid, '')     != '' then 'Google Ads'
         when coalesce(fbclid, '')    != '' then 'Facebook / Meta'
         when coalesce(msclkid, '')   != '' then 'Microsoft Ads'
         when coalesce(ttclid, '')    != '' then 'TikTok Ads'
         when coalesce(li_fat_id, '') != '' then 'LinkedIn Ads'
         when coalesce(twclid, '')    != '' then 'Twitter Ads (X)'
         else ''
       end as "name",
       count(distinct we.session_id) as "value"

How Umami resolves a contradiction between a gclid and a diverging utm_source is not documented. I would not rely on it and would rather stay consistent with manual tagging.

One tracker attribute can silently switch the whole analysis off, by the way.

<script defer
  src="https://analytics.example.com/script.js"
  data-website-id="..."
  data-exclude-search="true">
</script>

data-exclude-search has existed since v2.11.0 and prevents the URL search parameters from being collected. If you set it because it sounded like data minimisation, you are no longer collecting UTM data.

Trap 2, three clocks on the same click

Of the seven website stats endpoints only /pageviews and /events/series accept a timezone parameter. /active, /daterange, /metrics, /metrics/expanded and /stats have none, and among the session endpoints only /sessions/weekly has one. In my queries against my own instance the values arrive in UTC.

Google Ads, by contrast, reports in the account time zone. That is set when the account is created and is effectively fixed afterwards, changes are no longer supported in standard accounts, and the help explicitly notes that reports and statistics depend on that choice. The billing pages do not follow it, there all items are shown in PST exclusively.

So three time axes sit next to each other as soon as somebody brings clicks, sessions and cost together. An ads click at 18:29 account time shows up in the Umami response as 16:29. Without conversion it gets assigned to the wrong hour and therefore to the wrong keyword.

from datetime import datetime, timezone
from zoneinfo import ZoneInfo

ACCOUNT_TZ = ZoneInfo('Europe/Berlin')  # Google Ads account time zone

def to_local(ts: str) -> datetime:
    """'2026-07-16T16:29:00Z' -> 2026-07-16 18:29:00+02:00"""
    dt = datetime.fromisoformat(ts.replace('Z', '+00:00'))
    if dt.tzinfo is None:
        dt = dt.replace(tzinfo=timezone.utc)
    return dt.astimezone(ACCOUNT_TZ)

Trap 3, why filtered views show 100 per cent bounce

Put a referrer or UTM filter on the overview and it will regularly show 0 seconds on page, 100 per cent bounce and views equal to visits. That looks like catastrophic ads traffic and it is an artefact.

The filters apply at pageview level, and only the entry view carries the referrer or the campaign parameters. The follow-up views carry your own domain. What remains is exactly one event per session. The official metric definitions explain the rest. A bounce is defined as a visit with only one event, and total time is only calculated for visitors who view more than one page. A single-event visit therefore has 0 seconds duration by necessity and counts as a bounce.

For behaviour, then, other endpoints belong in the picture. /api/websites/{id}/metrics/expanded returns views, visitors, bounce and duration per dimension and sidesteps the artefact for page analyses. GET /api/websites/:websiteId/sessions/:sessionId/activity returns createdAt, urlPath, urlQuery, referrerDomain, eventId, eventType, eventName, visitId and hasData per event. So the complete query string is available at activity level too, which makes the click path of a specific ads session reconstructable.

Screen with an analytics dashboard, tiles showing CTR, cost per conversion and quality score Photo: dawson2406 / Unsplash

Whether the artefact still appears unchanged in v3.2.0 is open. The changelog names “Channel metrics queries” among the fixes, plus two corrections to how url_query is displayed in the pages report, and with the dedicated utm_* columns a filter could in theory hit all views of a session. The channel assignment bug from issue #3403, where getChannelMetrics() read from referrer_query while the UTM values ended up in website_event.url_query, is marked as fixed there. If you run an older version, do the test on your own instance before you believe a filtered figure.

The route that is left

Self-hosted, authentication runs through POST /api/auth/login with username and password, after which the token is sent along as Authorization: Bearer <token>.

TOKEN=$(curl -s -X POST https://analytics.example.com/api/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"readonly","password":"..."}' | jq -r .token)

curl -s -G https://analytics.example.com/api/websites/$WEBSITE_ID/metrics \
  -H "Authorization: Bearer $TOKEN" \
  --data-urlencode 'type=query' \
  --data-urlencode "startAt=$(date -d '30 days ago' +%s%3N)" \
  --data-urlencode "endAt=$(date +%s%3N)"

The sequence I have in place has four steps. Fetch the token, pull type=query over the desired period and break it down yourself, then load the sessions via /sessions and /sessions/{id}/activity, and only at the end bring all timestamps onto the account time zone. That order is not a matter of taste. Convert first and group by calendar days afterwards, and you shift the day boundary and get figures for the first and last day of every period that match no ads report. The startAt and endAt bounds of the query are UTC as well and want to be set accordingly. For recurring analyses a small CLI beats clicking around the dashboard, if only because the conversion then lives in exactly one place.

One operational detail that cost me time. An API user with the view-only role does not see websites owned by a team under /api/websites. They have to be fetched via GET /api/teams/{teamId}/websites.

Brass combination padlock on a white keyboard next to two gold chip cards Photo: towfiqu999999 / Unsplash

Two report endpoints can take work off your hands. POST /api/reports/utm counts views rather than sessions and returns five separate lists for source, medium, campaign, term and content, so no cross table of campaign and keyword. POST /api/reports/attribution counts count(distinct session_id) instead and knows the first-click and last-click models, with 20 entries per dimension. I read those details out of the source (getUTM.ts, getAttribution.ts) rather than testing them against a running instance, because the public API documentation does not describe the request bodies in full. For the question “which keyword brought in the session” the attribution report is the better fit, and for a quick ranking of campaigns the UTM report is enough.

What gets analysed in the end are the custom events. In my setup they are called kontakt_formular, kontakt_whatsapp and kontakt_anruf, so form, WhatsApp and phone. A kontakt_klick only means the contact section was opened, and it is not a contact. Confuse the two and you go looking for a bug in the form dispatch that does not exist. And times on page above 30 minutes I read as a background tab, not as a reader. Alongside the campaign data, the same instance collects the Core Web Vitals of real visitors via data-performance, which often answers the question about poor ads landing pages faster than any attribution debate.

If you run an Umami instance and your ads figures refuse to match the Google Ads account, I am happy to take a look. Usually it comes down to one of the three points above, and those are straightened out in an afternoon. Just get in touch through the contact form.

FAQ

Why does the Umami API return HTTP 400 for type=utm_source?+

Because the metrics route checks the given type against SESSION_COLUMNS, then against EVENT_COLUMNS, then against the special case channel, and otherwise returns badRequest(). The snake_case spelling utm_source appears in none of those lists. In v3.2.0 EVENT_COLUMNS holds the types in camelCase as utmSource, utmMedium, utmCampaign, utmContent and utmTerm, and the public API documentation does not list them at all.

How do I analyse Google Ads keywords in Umami?+

Through the final URL suffix in Google Ads with utm_term={keyword}, and then through the metrics endpoint with type=query, whose values you break down with parse_qs. According to Google Help the ValueTrack parameter {keyword} returns an empty value for keywordless formats such as Performance Max, DSA and AI Max for Search. For those campaigns only campaign and ad group level remain.

Which time zone does the Umami API return its data in?+

The endpoints /metrics, /metrics/expanded, /stats, /active, /daterange and /sessions have no timezone parameter, only /pageviews, /events/series and /sessions/weekly accept one. In my own queries against my own instance the times arrive in UTC. Anyone comparing them with a Google Ads report in account time has to convert manually, which in Central European Summer Time means two hours.

Why does Umami show 100 per cent bounce under a referrer filter?+

Umami defines a bounce as a visit with only one event, and by the metric definition total time is only calculated for visitors who view more than one page. A referrer filter reduces the visit to the entry view, because the follow-up views carry your own domain as the referrer. What remains is a single-event visit, and by definition that is a bounce with 0 seconds duration.

Want to know more?

In a free intro call we discuss how you can use these topics for your company. Not a sales pitch, but an honest assessment.

Book a free intro call