Zum Inhalt springen
StartseiteStart
EN
WhatsAppMailPhone
← Alle Artikel
Ads-Klicks in der Umami-API finden, wenn type=utm_source mit 400 antwortet
Performance & SEO

Ads-Klicks in der Umami-API finden, wenn type=utm_source mit 400 antwortet

Foto: lukechesser / Unsplash

Wie sich Google-Ads-Traffic in einem cookielosen Umami bis auf Kampagne und Keyword auswerten lässt, warum die naheliegende Metrik-Abfrage scheitert, welche Zeitzone die API liefert und wann gefilterte Ansichten falsche Verweildauern zeigen.

Eric MengeAutorEric MengeInhaber & Webentwickler bei EMIT Solution
Veröffentlicht
Lesezeitca. 8 Min.

Kurz gesagt

  • Die API-Dokumentation von Umami listet für den Parameter type keinen UTM-Wert. Ein Aufruf mit type=utm_source läuft in der Metrics-Route an SESSION_COLUMNS, EVENT_COLUMNS und der channel-Prüfung vorbei und landet bei badRequest(), also HTTP 400. Im Quellcode von v3.2.0 stehen die Typen dagegen in camelCase als utmSource, utmMedium, utmCampaign, utmContent und utmTerm in EVENT_COLUMNS.
  • Der verlässliche Weg über alle Versionen hinweg ist type=query. Der Endpunkt liefert den kompletten Query-String je Aufruf inklusive gclid, aufgeschlüsselt wird selbst per parse_qs. Wer den 400er per try/except schluckt, bekommt stillschweigend leere UTM-Listen und hält Ads-Traffic für nicht auswertbar.
  • Von den sieben Website-Stats-Endpunkten akzeptieren nur /pageviews und /events/series einen timezone-Parameter, bei den Session-Endpunkten nur /sessions/weekly. Google Ads rechnet im Reporting in der Kontozeitzone, die Abrechnungsseiten dagegen ausschließlich in PST. Bei drei Zeitachsen landet ein Klick sonst in der falschen Stunde und damit am falschen Keyword.
  • Referrer- und UTM-Filter greifen auf Pageview-Ebene. Da nur der Einstiegs-View den Referrer trägt, zeigten gefilterte Übersichten in meinen Abfragen 0 Sekunden Dauer und 100 Prozent Bounce. Für Verhalten sind /sessions und /metrics/expanded zuständig, nicht die gefilterte Overview.

Die Kampagne läuft, die Ziel-URLs tragen utm_campaign und utm_term={keyword}, und die Frage ist denkbar einfach. Welches Keyword hat die Sitzung gebracht, in der jemand auf den Kontaktbutton geklickt hat. In einem cookielosen, selbst gehosteten Umami sollte das eine einzige API-Abfrage sein.

Ist es nicht. Der naheliegende Aufruf endet mit HTTP 400, die Zeitstempel passen nicht zum Google-Ads-Bericht, und die gefilterte Übersicht behauptet, jeder Ads-Besucher sei nach null Sekunden wieder weg. Alle drei Punkte haben eine saubere Erklärung, und alle drei stehen so nicht in der API-Dokumentation.

Umami bildet einen Visit über einen Hash aus Session-ID und einem stündlich rotierenden Salt, die Session selbst über einen Hash aus Website-ID, IP-Adresse, User-Agent und einem weiteren Salt, dessen Rotation sich konfigurieren lässt und ohne Konfiguration monatlich wechselt. Das ist die ganze Sitzungsbildung, und sie kommt ohne gespeicherte Kennung im Browser aus.

Für die Attribution folgt daraus eine harte Konsequenz. Es gibt kein Besucherprofil, an das sich eine Kampagne nachträglich anheften ließe. Alles, was zur Zuordnung gebraucht wird, muss im Event selbst stehen. Genau da steht es auch. Der Collector in src/app/api/send/route.ts liest die Ads-Parameter einzeln aus der übergebenen URL, per currentUrl.searchParams.get('utm_source'), ...get('gclid') und so weiter. Ein generisches Mapping beliebiger Query-Parameter gibt es nicht, nur diese feste Liste.

Netzwerkschrank mit Patchfeldern und bunten Netzwerkkabeln im Halbdunkel Foto: heyquilia / Unsplash

Der Einstiegs-View einer Sitzung trägt also die Kampagnendaten, die Folge-Views tragen sie nicht. Diese eine Eigenschaft erklärt später auch Falle 3.

Falle 1, der Metriktyp, den die Doku nicht kennt

Der Metrics-Endpunkt ist GET /api/websites/:websiteId/metrics mit den Pflichtparametern startAt, endAt (Unix-Timestamp in Millisekunden) und type. Die Antwort ist ein Array aus Objekten der Form {x: Wert, y: Anzahl}.

Optional kommen filters, limit (Default 500) und offset (Default 0) dazu. Der Default von 500 ist bei type=query schnell erreicht, weil jede Kombination aus Parametern eine eigene Zeile ergibt. Eine Kampagne mit vielen Keywords und aktivem Auto-Tagging produziert durch den jeweils eigenen gclid faktisch eine Zeile pro Klick. Wer über einen längeren Zeitraum auswertet, muss also blättern, sonst fehlt das lange Ende der Liste genau da, wo die selteneren Keywords stehen.

Die offizielle Doku listet für type genau diese Werte: path, entry, exit, title, query, referrer, channel, domain, country, region, city, browser, os, device, language, screen, event, hostname, tag, distinctId. Ein utm_source ist nicht dabei. Dieselbe Doku führt UTM-Parameter aber sehr wohl als zulässige Filter-Dimension. Diese Asymmetrie ist dokumentiert, ohne irgendwo erklärt zu werden.

Der Mechanismus hinter dem 400er steht in der Route selbst.

// 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();

Ein unbekannter Typ-String fällt stumm bis badRequest() durch. Und utm_source ist ein unbekannter Typ-String, denn in src/lib/constants.ts von v3.2.0 heißen die Einträge anders.

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',
  // ...
};

Die Metriktypen existieren also, aber in camelCase, und die API-Doku listet sie nicht. Die naheliegende snake_case-Schreibweise trifft keinen Eintrag. Ob type=utmSource auf der eigenen Instanz tatsächlich 200 liefert, hängt an der Version und gehört einmal geprüft, bevor man ein Auswertungsskript darauf aufbaut.

Der Weg, der unabhängig davon funktioniert, ist type=query. Der Endpunkt gibt den vollständigen Query-String je Aufruf zurück, inklusive gclid, und wird selbst aufgeschlüsselt.

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

Die eigentliche Gemeinheit ist nicht der Statuscode, sondern der übliche Umgang damit. Wer den Aufruf in ein try/except packt und bei Fehlern eine leere Liste zurückgibt, sieht keinen Fehler, sondern ein leeres Ergebnis. Und ein leeres Ergebnis liest sich wie “es kommt kein Ads-Traffic an”.

Noch ein Detail für alle, die eine ältere Instanz aktualisiert haben. Die Prisma-Migration 08_add_utm_clid ergänzt website_event um elf Spalten, fünf für UTM und sechs Click-IDs (fbclid, gclid, li_fat_id, msclkid, ttclid, twclid), alle als VARCHAR(255). Für Altdaten gibt es ein separates, manuell auszuführendes Skript db/postgresql/data-migrations/convert-utm-clid-columns.sql, das die neuen Spalten aus url_query nachträgt. Läuft es nicht, bleiben die neuen Spalten für historische Zeilen leer, während url_query die Werte weiterhin enthält. Die Abfrage über type=query deckt beide Fälle ab.

Das Tagging, das vorher stimmen muss

Ohne saubere Parameter in der Ziel-URL nützt der beste Endpunkt nichts. Das offiziell vorgesehene Feld dafür ist der Final-URL-Suffix, setzbar auf Konto-, Kampagnen-, Anzeigengruppen-, Anzeigen-, Keyword- und Dynamic-Ads-Target-Ebene. Bei Parallel Tracking landet der Nutzer direkt auf der Final URL inklusive Suffix-Parametern, während das Tracking-Template im Hintergrund verarbeitet wird.

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

Zwei Einschränkungen dazu aus der Google-Hilfe. Der ValueTrack-Parameter {keyword} liefert bei Suchkampagnen das Keyword aus dem Konto, das zur Suchanfrage passt, bei keywordlosen Formaten wie AI Max for Search, DSA und Performance Max dagegen einen leeren Wert. Und Auto-Tagging hängt zusätzlich ?gclid=... an, im Beispiel der Hilfeseite als www.example.com/?gclid=123xyz. Beides steht dann gemeinsam im Query-String.

Laptop-Bildschirm mit geöffnetem LinkedIn Campaign Manager und der Übersicht der Werbekonten Foto: zulfugarkarimov / Unsplash

Umami kann mit beidem etwas anfangen. Die Konstante PAID_AD_PARAMS enthält unter anderem gclid=, fbclid=, msclkid=, ttclid=, li_fat_id=, twclid=, dclid=, utm_medium=cpc, utm_medium=paid und utm_source=google. Der Attribution-Report klassifiziert bezahlten Traffic direkt über die Click-ID-Spalten, gclid ergibt dort “Google Ads”.

-- getAttribution.ts, zählt Sitzungen statt 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"

Wie Umami einen Widerspruch zwischen gclid und einem abweichenden utm_source auflöst, ist nicht dokumentiert. Ich würde mich darauf nicht verlassen und lieber beim manuellen Tagging konsistent bleiben.

Ein Tracker-Attribut kann die gesamte Auswertung übrigens still abschalten.

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

data-exclude-search gibt es seit v2.11.0 und verhindert das Sammeln der URL-Suchparameter. Wer das gesetzt hat, weil es nach Datensparsamkeit klingt, sammelt keine UTM-Daten mehr.

Falle 2, drei Uhren auf demselben Klick

Von den sieben Website-Stats-Endpunkten akzeptieren nur /pageviews und /events/series einen timezone-Parameter. /active, /daterange, /metrics, /metrics/expanded und /stats haben keinen, bei den Session-Endpunkten hat ihn nur /sessions/weekly. In meinen Abfragen gegen die eigene Instanz kommen die Werte in UTC an.

Google Ads rechnet dagegen in der Kontozeitzone. Die wird bei der Kontoerstellung gesetzt und ist danach faktisch fix, Änderungen werden in Standardkonten nicht mehr unterstützt, und die Hilfe hält ausdrücklich fest, dass Berichte und Statistiken von dieser Auswahl abhängen. Die Abrechnungsseiten folgen ihr nicht, dort werden alle Positionen ausschließlich in PST angezeigt.

Damit stehen drei Zeitachsen nebeneinander, sobald jemand Klicks, Sitzungen und Kosten zusammenführt. Ein Ads-Klick um 18:29 Kontozeit taucht in der Umami-Antwort als 16:29 auf. Wer das nicht umrechnet, ordnet ihn der falschen Stunde und damit dem falschen Keyword zu.

from datetime import datetime, timezone
from zoneinfo import ZoneInfo

ACCOUNT_TZ = ZoneInfo('Europe/Berlin')  # Google-Ads-Kontozeitzone

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)

Falle 3, warum gefilterte Ansichten 100 Prozent Bounce zeigen

Setzt man einen Referrer- oder UTM-Filter auf die Übersicht, zeigt sie regelmäßig 0 Sekunden Verweildauer, 100 Prozent Bounce und Views gleich Visits. Das sieht nach katastrophalem Ads-Traffic aus und ist ein Artefakt.

Die Filter greifen auf Pageview-Ebene, und nur der Einstiegs-View trägt den Referrer beziehungsweise die Kampagnenparameter. Die Folge-Views führen die eigene Domain. Übrig bleibt pro Sitzung genau ein Event. Die offiziellen Metrik-Definitionen erklären den Rest. Ein Bounce ist definiert als Besuch mit nur einem Event, und die Gesamtzeit wird nur für Besucher berechnet, die mehr als eine Seite ansehen. Ein Ein-Event-Besuch hat damit zwangsläufig 0 Sekunden Dauer und zählt als Absprung.

Für Verhalten gehören deshalb andere Endpunkte genommen. /api/websites/{id}/metrics/expanded liefert je Dimension Views, Visitors, Bounce und Dauer und umgeht das Artefakt für Seiten-Analysen. GET /api/websites/:websiteId/sessions/:sessionId/activity liefert je Event createdAt, urlPath, urlQuery, referrerDomain, eventId, eventType, eventName, visitId und hasData. Der komplette Query-String steht also auch auf Aktivitätsebene zur Verfügung, was den Klickpfad einer konkreten Ads-Sitzung rekonstruierbar macht.

Bildschirm mit Analytics-Dashboard, Kacheln zeigen CTR, Kosten pro Conversion und Quality Score Foto: dawson2406 / Unsplash

Ob das Artefakt in v3.2.0 unverändert auftritt, ist offen. Das Changelog nennt unter den Fixes “Channel metrics queries” sowie zwei Korrekturen an der Anzeige von url_query im Pages-Report, und mit den dedizierten utm_*-Spalten könnte ein Filter theoretisch alle Views einer Sitzung treffen. Der Kanal-Zuordnungsfehler aus Issue #3403, bei dem getChannelMetrics() aus referrer_query las, während die UTM-Werte in website_event.url_query landeten, ist dort als behoben markiert. Wer eine ältere Version fährt, sollte den Test an der eigenen Instanz machen, bevor er einer gefilterten Zahl glaubt.

Der Weg, der übrig bleibt

Self-hosted läuft die Authentifizierung über POST /api/auth/login mit Benutzername und Passwort, das Token wird danach als Authorization: Bearer <token> mitgeschickt.

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)"

Der Ablauf, der bei mir steht, hat vier Schritte. Token holen, type=query über den gewünschten Zeitraum ziehen und selbst aufschlüsseln, danach die Sitzungen über /sessions und /sessions/{id}/activity nachladen, und erst am Ende alle Zeitstempel auf die Kontozeitzone bringen. Die Reihenfolge ist kein Geschmack. Wer zuerst umrechnet und danach nach Kalendertagen gruppiert, verschiebt die Tagesgrenze und bekommt für den ersten und letzten Tag jedes Zeitraums Werte, die zu keinem Ads-Bericht passen. Die startAt- und endAt-Grenzen der Abfrage sind ebenfalls UTC und wollen entsprechend gesetzt werden. Für wiederkehrende Auswertungen lohnt sich ein kleines CLI statt Klicken im Dashboard, allein weil die Umrechnung dann genau einmal an einer Stelle steht.

Ein Betriebsdetail, das mich Zeit gekostet hat. Ein API-User mit der Rolle view-only sieht Websites, die einem Team gehören, nicht unter /api/websites. Sie müssen über GET /api/teams/{teamId}/websites geholt werden.

Zahlenschloss aus Messing auf einer weißen Tastatur, daneben zwei goldene Chipkarten Foto: towfiqu999999 / Unsplash

Arbeit abnehmen können zwei Report-Endpunkte. POST /api/reports/utm zählt Views, nicht Sitzungen, und liefert fünf getrennte Listen für source, medium, campaign, term und content, also keine Kreuztabelle aus Kampagne und Keyword. POST /api/reports/attribution zählt dagegen count(distinct session_id) und kennt die Modelle first-click und last-click, bei 20 Einträgen pro Dimension. Diese Details habe ich aus dem Quellcode gelesen (getUTM.ts, getAttribution.ts), nicht gegen eine laufende Instanz getestet, denn die öffentliche API-Doku beschreibt die Request-Bodies nicht vollständig. Für die Frage “welches Keyword hat die Sitzung gebracht” ist der Attribution-Report der passendere, für ein schnelles Ranking der Kampagnen reicht der UTM-Report.

Was am Ende ausgewertet wird, sind die Custom Events. Bei mir heißen sie kontakt_formular, kontakt_whatsapp und kontakt_anruf. Ein kontakt_klick bedeutet nur, dass der Kontaktbereich geöffnet wurde, und ist keine Kontaktaufnahme. Wer das verwechselt, sucht einen Fehler im Formularversand, den es nicht gibt. Und Verweildauern über 30 Minuten auf einer Seite lese ich als offenen Hintergrund-Tab, nicht als Leser. Neben den Kampagnendaten läuft auf derselben Instanz die Erfassung der Core Web Vitals echter Besucher über data-performance, was die Frage nach schlechten Ads-Landeseiten oft schneller beantwortet als jede Attributionsdebatte.

Wenn du eine Umami-Instanz betreibst und deine Ads-Zahlen nicht zum Google-Ads-Konto passen wollen, schau ich mir das gern an. Meist liegt es an einer der drei Stellen oben, und die sind in einem Nachmittag geradegezogen. Melde dich einfach über das Kontaktformular.

Häufige Fragen

Warum liefert die Umami-API bei type=utm_source einen HTTP 400?+

Weil die Metrics-Route den übergebenen Typ nacheinander gegen SESSION_COLUMNS, gegen EVENT_COLUMNS und gegen den Sonderfall channel prüft und andernfalls badRequest() zurückgibt. Die snake_case-Schreibweise utm_source steht in keiner dieser Listen. In v3.2.0 enthält EVENT_COLUMNS die Typen in camelCase als utmSource, utmMedium, utmCampaign, utmContent und utmTerm, in der öffentlichen API-Doku sind sie nicht aufgeführt.

Wie werte ich Google-Ads-Keywords in Umami aus?+

Über den Final-URL-Suffix in Google Ads mit utm_term={keyword} und anschließend über den Metrics-Endpunkt mit type=query, dessen Werte per parse_qs aufgeschlüsselt werden. Der ValueTrack-Parameter {keyword} liefert laut Google-Hilfe bei keywordlosen Formaten wie Performance Max, DSA und AI Max for Search einen leeren Wert. Für diese Kampagnen bleibt nur die Kampagnen- und Anzeigengruppenebene.

In welcher Zeitzone liefert die Umami-API ihre Daten?+

Die Endpunkte /metrics, /metrics/expanded, /stats, /active, /daterange und /sessions haben keinen timezone-Parameter, nur /pageviews, /events/series und /sessions/weekly nehmen einen entgegen. In meinen Abfragen gegen die eigene Instanz kommen die Zeiten in UTC an. Wer sie mit einem Google-Ads-Bericht in Kontozeit vergleicht, muss also selbst umrechnen, im Sommer in Deutschland um zwei Stunden.

Warum zeigt Umami bei einem Referrer-Filter 100 Prozent Bounce?+

Umami definiert einen Bounce als Besuch mit nur einem Event, und die Gesamtzeit wird laut Metrik-Definition nur für Besucher berechnet, die mehr als eine Seite ansehen. Ein Referrer-Filter reduziert den Besuch auf den Einstiegs-View, weil die Folge-Views die eigene Domain als Referrer führen. Übrig bleibt ein Ein-Event-Besuch, und der ist definitionsgemäß ein Bounce mit 0 Sekunden Dauer.

Du willst mehr erfahren?

In einem kostenlosen Erstgespräch besprechen wir, wie du diese Themen für dein Unternehmen nutzen kannst. Kein Verkaufsgespräch, sondern eine ehrliche Einschätzung.

Kostenloses Erstgespräch vereinbaren