UTM-Tracking in api.langify
Wie langify die Herkunft einer Installation (UTM-Parameter und Referrer) erfasst, einem Shop zuordnet und bei späteren Plan-Wechseln auswertet.
Überblick
Das Tracking läuft in vier Schritten in der API, ausgewertet wird es im Intranet (Schritt 5). Der Schlüssel, der alles zusammenhält, ist die install_uid (eine UUID v4). Sie entsteht beim ersten Kontakt, wird im Browser als Cookie gespeichert und beim OAuth-Callback zurück an die API geschickt.
Erfassen – v1 /install
Besucher klickt Kampagnen-Link. UTM + Referrer landen in install_attribution, die API liefert eine install_uid, die als Cookie gesetzt wird.POST /auth/remote/install-attribution
Verknüpfen – OAuth-Callback
Nach der Installation im App Store wird die install_uid aus dem Cookie dem Shop zugeordnet (nur bei Neu- oder Re-Install).POST /auth/remote/oauth/callback
Auswerten – Plan-Wechsel
shop_plan_event und GA4-Events werden mit den UTM-Daten der Installation geschrieben.ApplicationChargeRepository → PlanEventTracker, AnalyticsService
Reporting-API
Install-Attributionen und Plan-Events, abrufbar per JWT mit Scope report.GET /v2/api/staff/report/…
Intranet – intern.lovely-app.com
Kennzahlen, Umsatz/MRR je Quelle, Plan-Journeys, PDF-Export./report, /report/revenue
1Erfassen: POST /auth/remote/install-attribution
src/Controller/Auth/Remote/InstallAttributionController.phpsrc/Service/Auth/Remote/RemoteInstallAttributionService.php→record()
Die v1-Seite /install (die Einstiegsseite zum App Store; sie liegt nicht in diesem Repo) ruft diesen Endpunkt serverseitig auf. Die API speichert dabei weder einen Cookie noch leitet sie weiter. Beides übernimmt der Aufrufer.
Authentifizierung: JWT mit Scope remote-auth, als Authorization: Bearer <token> oder als ?token= (siehe ApiTokenAuthTrait::validateApiToken()).
Request-Body (JSON), alle Felder optional
| Feld | Ziel-Spalte | Max. Länge |
|---|---|---|
utm_source | utm_source | 255 |
utm_medium | utm_medium | 255 |
utm_campaign | utm_campaign | 255 |
utm_content | utm_content | 255 |
utm_term | utm_term | 255 |
landing_url | landing_url | 700 |
referer | referrer | 700 |
Achtung: Das Request-Feld heißt referer (HTTP-Schreibweise), die DB-Spalte referrer.
Verarbeitung
- Jeder Wert wird getrimmt, auf die Spaltenlänge gekürzt (
mb_substr), und ein leerer String wird zuNULL. referrer_domainwird aus dem Referrer berechnet: der Host ohne führendeswww.(z. B.https://www.google.com/search?...→google.com).- Die
install_uiderzeugt die API selbst (random_bytes, RFC 4122 v4). created_atist der Zeitpunkt der Erfassung.shop_idundinstall_datebleiben zunächst leer.
Antwort
{ "success": true, "install_uid": "3f6c2a1e-8b7d-4c3e-9a51-0d2f6e7b8c90" }
Der Aufrufer speichert die install_uid als Cookie und leitet in den Shopify App Store weiter.
Beispiel
curl -X POST https://api.langify-app.com/auth/remote/install-attribution \
-H "Authorization: Bearer $REMOTE_AUTH_JWT" \
-H "Content-Type: application/json" \
-d '{
"utm_source": "newsletter",
"utm_medium": "email",
"utm_campaign": "herbst-2026",
"landing_url": "https://langify-app.com/install?utm_source=newsletter&utm_medium=email&utm_campaign=herbst-2026",
"referer": "https://www.google.com/"
}'
2Verknüpfen: POST /auth/remote/oauth/callback
src/Controller/Auth/Remote/LoginController.php→oauthCallback()src/Service/Auth/Remote/RemoteLoginService.php→processOAuthCallback()src/Service/Auth/Remote/RemoteInstallAttributionService.php→link()
Nach der Installation im App Store läuft der Shopify-OAuth-Flow. Der v1-Callback leitet die Shopify-Query-Parameter an die API weiter und legt die install_uid aus dem Cookie bei:
{
"query": { "shop": "example.myshopify.com", "code": "...", "hmac": "...", "...": "..." },
"install_uid": "3f6c2a1e-8b7d-4c3e-9a51-0d2f6e7b8c90"
}
Ablauf
- Die API prüft das JWT (Scope
remote-auth) und die Shopify-HMAC. - Sie lädt den Shop oder legt ihn neu an.
- Als neue Installation (
new_install = true) zählt ein Shop, den es lokal noch nicht gibt, oder ein Shop mit gesetztemuninstalled_at(Re-Install). - Nur bei einer neuen Installation und vorhandener
install_uidruft die APIlink()auf: Sie sucht die Zeile mit dieserinstall_uidininstall_attributionund setztshop_idundinstall_date = now(). Gibt es keine passende Zeile, passiert nichts (Rückgabefalse, kein Fehler).
Logins in einen bereits installierten Shop verändern die Attribution also nicht.
3Auswerten: Plan-Wechsel
src/Repository/ApplicationChargeRepository.phpsrc/Service/PlanEventTracker.phpsrc/Service/Google/AnalyticsService.php→trackPlanChange()src/Entity/ShopPlanEvent.php
Bei jedem Plan-Wechsel lädt die API die Attribution des Shops (InstallAttributionRepository::findByShop()) und schreibt an zwei Stellen:
3a. Datenbank: shop_plan_event
PlanEventTracker::recordPlanChange() legt eine Zeile an mit:
old_plan,new_plan,price,currency(aktuell immerUSD) undbilling_interval(aktuell immermonthly)event_type:upgrade,downgradeodercancel. Upgrade oder Downgrade ergibt sich aus dem Vergleich der Plan-IDs (ApplicationCharge::PLAN_IDS). Ein Wechsel auf den Free-Plan istcancel.attribution_install_uid: dieinstall_uidder Attribution des Shops, falls vorhanden
Die Werte trial_start und trial_end sind im Enum definiert, werden derzeit aber nirgends geschrieben.
3b. Google Analytics 4 (Measurement Protocol)
AnalyticsService::trackPlanChange() sendet zwei Events an https://www.google-analytics.com/mp/collect:
| Event | Zweck |
|---|---|
plan_change | Haupt-Event für Analysen |
plan_<targetPlan> | Je Zielplan, für einfache Conversion-Reports |
Parameter
source_plan,target_plan,plan_change(<alt>_to_<neu>) undvalue = 1- falls eine Attribution existiert, die UTM-Werte mit dem Präfix
install_:install_source,install_medium,install_campaign,install_contentundinstall_term. Werte, dieNULLsind, werden weggelassen.
Details
client_id = sha1(shopify_shop_id). Die Events hängen also an einer festen, pseudonymen ID je Shop und nicht an der Browser-Session des Besuchers.- Konfiguration über
GOOGLE_ANALYTICS_MEASUREMENT_IDundGOOGLE_ANALYTICS_API_SECRET. Ist eine der beiden leer, wird nichts gesendet. - Die API sendet derzeit immer
debug_mode=truemit. - Zum Testen gibt es
src/Controller/Debug/GoogleTestController.php.
Wann es ausgelöst wird (ApplicationChargeRepository)
| Methode | Situation |
|---|---|
changePlanGetConfirmationUrl() | Direkter Planwechsel ohne Bestätigung durch den Händler |
checkCharge(), aktiver Charge | Der Händler hat den Charge bestätigt |
checkCharge(), declined, expired oder notfound | Charge abgelehnt oder abgelaufen; die API verbucht einen Wechsel auf Free (cancel) |
4Reporting: /v2/api/staff/report
src/Controller/Staff/ReportController.phpAuthentifizierung: JWT mit Scope
report| Endpunkt | Inhalt | Filter |
|---|---|---|
GET /install-attributions | Zeilen aus install_attribution | from, to (auf created_at), limit (max. 500, Standard 100), offset |
GET /plan-events | Zeilen aus shop_plan_event | wie oben, zusätzlich event_type |
Beide Endpunkte sortieren nach created_at DESC.
5Auslesen im Intranet: intern.lovely-app.com/report
Das Intranet (Repo intern.lovely-app, PHP/Phalcon) liest die beiden Report-Endpunkte aus Abschnitt 4 und wertet sie aus. Eine eigene Datenbank oder Kopie der Daten gibt es dort nicht. Jede Auswertung kommt live aus der API, für eine Stunde in Redis zwischengespeichert.
intern.lovely-app)app/engines/ReportApiService.php: API-Client (Guzzle), Pagination, Redis-Cacheapp/controllers/ReportController.php: Auswertung (Kennzahlen, Verknüpfung, Revenue, Plan-Journeys), PDFapp/views/report/index.phtml,tab.phtml,revenue.phtml: Oberfläche (Tabs werden per htmx nachgeladen)
Zugang
| Seite | URL | Rolle |
|---|---|---|
| Attribution Report | /report | reporting oder admin |
| Revenue Report | /report/revenue | reporting oder admin |
Im Menü stehen beide unter Reports (dort auch „Plan Overview“ und „Plan Flow“, die nicht auf den UTM-Daten beruhen).
Konfiguration (Umgebungsvariablen des Intranets, CapRover bzw. .env.local)
| Variable | Bedeutung |
|---|---|
LANGIFY_REPORT_API_URL | Basis-URL der API, live https://api.langify-app.com |
LANGIFY_REPORT_API_TOKEN | JWT mit Scope report, wird als Authorization: Bearer gesendet |
LANGIFY_REPORT_RESOLVE | optional, nur lokal: CURLOPT_RESOLVE-Eintrag (host:port:ip), um z. B. api.langify.dvlbox.de auf eine Docker-IP zu zeigen |
- Fehlt URL oder Token, zeigt die Seite „API nicht konfiguriert“.
- Lehnt die API den Token ab (HTTP 401/403), zeigt die Seite das mit dem HTTP-Code an.
- Ablauf des Tokens: Das Intranet liest
expaus dem JWT und warnt 14 Tage vor Ablauf oben auf der Seite. Dann einen neuen Token mit Scopereporterzeugen undLANGIFY_REPORT_API_TOKENtauschen.
Datenabruf
- Pagination:
ReportApiService::fetchAll()holt je Endpunkt Seiten à 500 Zeilen (limit/offset), bis eine Seite kürzer ist. - Cache: Das Ergebnis liegt eine Stunde in Redis unter
report:<md5(endpoint+parameter)>. Der Button „Daten neu laden“ (/report/reload) löscht allereport:*-Keys. - Install-Attributionen werden immer ab
2020-01-01bis zum Enddatum geladen. Die Revenue-Zuordnung braucht den ganzen Bestand, weil ein Shop vor dem gewählten Zeitraum installiert und erst darin upgraden kann. Auf den Zeitraum eingegrenzt wird erst danach im Intranet, nachinstallDate(fehlt es, nachcreatedAt). - Plan-Events werden für den gewählten Zeitraum geladen, optional mit
event_type. - Enddatum: Die API liest ein reines Datum als
00:00:00. Das Intranet hängt deshalb23:59:59an, sonst fehlen die Einträge des letzten Tages.
Feldnamen
Die API liefert camelCase, nicht die DB-Spaltennamen:
| API-Feld | DB-Spalte |
|---|---|
utmSource, utmMedium, utmCampaign, … | utm_source, utm_medium, utm_campaign, … |
installUid | install_uid |
shopId | shop_id |
referrerDomain | referrer_domain |
createdAt, installDate | created_at, install_date |
eventType, oldPlan, newPlan, price | event_type, old_plan, new_plan, price |
attributionInstallUid | attribution_install_uid |
Was die Seite zeigt
/report (Attribution Report)
- Filter: Zeitraum (
from,to, Standard die letzten 30 Tage), Event-Typ, Vorjahresvergleich (compare=1, derselbe Zeitraum ein Jahr früher). - Kennzahlen: Attributionen im Zeitraum (davon verknüpft), Plan-Events, Verteilung nach UTM-Quelle, -Medium und -Kampagne, Event-Typen, Events pro Monat, Umsatz und MRR je Quelle.
- Drill-down: Ein Klick auf eine Quelle oder ein Medium filtert die Attributionstabelle (
source=,medium=); leere Werte zählen alsunknown.linked=1zeigt nur verknüpfte Attributionen. - Tabs: Attributions, Events (Plan-Events), Journeys (Plan-Verlauf je Shop: stabil, gekündigt, Planwechsler, nur Trial, Reaktivierungen), Revenue (Umsatz je Shop mit Quelle).
- PDF-Export (
/report/pdf) mit denselben Filtern, A4 quer.
/report/revenue (Revenue Report): MRR pro Monat, Umsatz je Plan, verlorener Umsatz durch Kündigungen und Downgrades, ebenfalls mit Vorjahresvergleich.
Wann gilt eine Attribution als „verknüpft“?
Eine Attribution zählt als einem Shop zugeordnet, wenn shopId gesetzt ist (Abschnitt 2, link()) oder ihre installUid von einem Plan-Event als attributionInstallUid referenziert wird. Nur verknüpfte Attributionen gehen in die Umsatzzuordnung ein. Alle anderen sind Klicks ohne (zuordenbare) Installation, siehe offene Punkte 2 und 5.
Umsatz-Zuordnung (vereinfacht)
Pro Shop werden die Plan-Events chronologisch sortiert. Die Quelle kommt aus der Attribution des ersten Events mit attributionInstallUid. Ab dem ersten Upgrade zählt der Preis als Umsatz der Quelle; ist der Shop am Ende auf einem bezahlten Plan, geht dessen Preis in den MRR der Quelle ein. Shops ohne Attribution laufen unter unknown.
Datenmodell
Migration: migrations/Version20260310120000.php
install_attribution
| Spalte | Typ | Bedeutung |
|---|---|---|
id | INT PK | |
shop_id | INT, NULL | FK auf Shop.id, UNIQUE, ON DELETE CASCADE. Leer, bis link() läuft. |
install_uid | CHAR(36) | UUID, UNIQUE |
utm_source … utm_term | VARCHAR(255) | UTM-Parameter |
landing_url | VARCHAR(700) | Einstiegs-URL |
referrer | VARCHAR(700) | HTTP-Referrer |
referrer_domain | VARCHAR(500) | Host ohne www. |
created_at | DATETIME | Zeitpunkt der Erfassung (Klick) |
install_date | DATETIME, NULL | Zeitpunkt der Verknüpfung (Installation) |
shop_plan_event
| Spalte | Typ | Bedeutung |
|---|---|---|
shop_id | INT | FK auf Shop.id, ON DELETE CASCADE |
event_type | ENUM | upgrade, downgrade, trial_start, trial_end oder cancel |
old_plan / new_plan | VARCHAR(50) | |
price, currency, billing_interval | ||
attribution_install_uid | CHAR(36), NULL | FK auf install_attribution.install_uid, ON DELETE SET NULL |
created_at | DATETIME |
Beispiel-Abfragen
Installationen je Kampagne (nur verknüpfte):
SELECT utm_source, utm_medium, utm_campaign, COUNT(*) AS installs
FROM install_attribution
WHERE shop_id IS NOT NULL
GROUP BY utm_source, utm_medium, utm_campaign
ORDER BY installs DESC;
Conversion-Rate von Klick zu Installation je Quelle:
SELECT COALESCE(utm_source, referrer_domain, '(direct)') AS quelle,
COUNT(*) AS klicks,
SUM(shop_id IS NOT NULL) AS installs,
ROUND(100 * SUM(shop_id IS NOT NULL) / COUNT(*), 1) AS rate_pct
FROM install_attribution
GROUP BY quelle
ORDER BY klicks DESC;
Upgrades je Kampagne:
SELECT ia.utm_campaign, spe.new_plan, COUNT(*) AS upgrades
FROM shop_plan_event spe
JOIN install_attribution ia ON ia.install_uid = spe.attribution_install_uid
WHERE spe.event_type = 'upgrade'
GROUP BY ia.utm_campaign, spe.new_plan;
Auffälligkeiten und offene Punkte
- Re-Install mit neuer Attribution kann scheitern.
shop_idist UNIQUE. Hat ein Shop schon eine verknüpfte Attribution und wird nach einer Deinstallation über einen neuen Kampagnen-Link wieder installiert, versuchtlink(), eine zweite Zeile mit derselbenshop_idzu setzen. Das verletztuniq_shop_id. Die Exception landet imtry/catchvonoauthCallback(), und der Callback antwortet mit 502. Mögliche Lösung: die alte Verknüpfung vorher lösen oder die bestehende Zeile überschreiben. - Klicks ohne Installation bleiben liegen. Jeder Aufruf von
/installlegt eine Zeile an, auch wenn danach keine Installation folgt. Das ist für die Conversion-Rate gewollt. Eine Aufräum-Strategie gibt es derzeit nicht. debug_mode=truewird bei jedem GA4-Event gesendet. Die Events erscheinen dadurch in der GA4-DebugView. Ob das in Produktion so gewollt ist, sollte geprüft werden.- Keine Verbindung zur GA-Web-Session. Die
client_idistsha1(shopId)und nicht die GA-Client-ID aus dem Browser. Die UTM-Zuordnung in GA4 läuft deshalb ausschließlich über die Custom-Parameterinstall_*. Diese müssen in GA4 als benutzerdefinierte Dimensionen angelegt sein. - Ohne Cookie keine Zuordnung. Fehlt die
install_uidbeim Callback (Cookie blockiert, anderer Browser, Installation direkt aus dem App Store), bleibt der Shop ohne Attribution. Seine Plan-Events haben dannattribution_install_uid = NULL. trial_startundtrial_endexistieren im Schema, werden aber nicht befüllt.billing_intervalist immermonthly.