Skip to content
~/sph.sh

Den Kreis schließen: Vom Werbeklick zum zahlenden Abonnenten in Mobile Apps

Ein Engineering-Leitfaden, der Werbeklicks mit bezahlten Abonnements unter SKAN 4, AdAttributionKit und Post-ATT-Privatsphäre verbindet. Event-Taxonomie, Referenzarchitektur und Abgleichmuster.

Zusammenfassung

Die meisten Attribution-Guides hören beim Install auf. Subscription-Apps verdienen aber kein Geld mit Installs. Sie verdienen Geld mit Trials, die zu bezahlten Abos werden, und mit Nutzern, die verlängern. Dieser Beitrag ist ein Durchlauf durch die komplette Messpipeline, die einen Werbeklick auf Meta, Google oder TikTok mit einem validierten bezahlten Abo-Event in deinem Data Warehouse verbindet. Er deckt SKAN 4, AdAttributionKit auf iOS 17.4+ (erweitert in iOS 18.4), serverseitige Conversion-APIs, RevenueCat als Source of Truth und den Abgleichjob ab, der Finance, Marketing und Produkt auf eine Zahl bringt.

Das Messproblem richtig einordnen

Wenn du eine Subscription-App betreibst, ist dein Attribution-Stack ein verteiltes System mit drei unabhängigen Uhren und drei unabhängigen Wahrheitsquellen. Marketing sieht ROAS im Meta Ads Manager. Finance sieht Umsatz in RevenueCat. Produkt sieht Kohorten im Warehouse. Die Zahlen passen selten zusammen.

Der Job ist nicht, die "richtige" Zahl zu wählen. Der Job ist, eine abgeglichene Pipeline zu bauen, in der jeder Konsument das Signal bekommt, das er braucht — mit bekannten Fehlerbalken.

Vier Events zählen für eine Subscription-App:

  • first_open — einmal pro Install, clientseitig
  • trial_start — serverseitig validiert, kodiert Produkt und Trial-Länge
  • subscribe — erste Zahlung nach dem Trial (oder direkt bezahlt)
  • renewal — n-ter Abrechnungszyklus, getrennt verfolgt

Das Schatten-Event churn schließt den Kreis für die LTV-Modellierung.

Die Privatsphäre-Landschaft nach ATT

App Tracking Transparency hat auf iOS immer noch Opt-in-Raten um die 25 Prozent; der Wert schwankt je nach Vertikale und Region (typisch sind 25 bis 30 Prozent). Deterministische Attribution ist für die meisten Nutzer vorbei. Was stattdessen kommt, ist ein Flickenteppich:

  • SKAdNetwork 4: drei Postback-Fenster bei 0-2, 3-7 und 8-35 Tagen nach dem Install. Fein granulare 6-Bit-Conversion-Werte nur im ersten Fenster. Grob granular (niedrig/mittel/hoch) in Fenster zwei und drei. Privatsphäre-Schwellen leeren kleine Kampagnen.
  • AdAttributionKit, eingeführt in iOS 17.4 und erweitert in iOS 18.4: konfigurierbare Attribution-Fenster, Re-Engagement-Überlappung, Ländercodes und Developer-Postbacks. Lebt parallel zu SKAN, ohne Abschalttermin.
  • Android: Google Play Install Referrer bleibt vorerst deterministisch. Privacy Sandbox für Android läuft in einem mehrjährigen Rollout.

Auf iOS brauchst du SKAN und AAK. Auf Android brauchst du Install Referrer. Und für die 25 Prozent, die ATT zugestimmt haben, lohnt sich deterministische Attribution über einen MMP oder direkte SDKs weiterhin.

Event-Taxonomie für Subscription-Apps

Eine saubere Event-Taxonomie ist die Entscheidung, die dir später am meisten Schmerz erspart. Halte sie klein, halte sie kanonisch, und gib jedem Event einen Idempotency-Key.

EventQuelleSchlüsselparameterIdempotency-Key
first_openClient SDKinstall_time, sourceinstall_id
trial_startRevenueCat Webhookproduct_id, trial_days, expected_priceoriginal_transaction_id + "trial"
subscribeRevenueCat Webhookproduct_id, revenue_usd, currencyoriginal_transaction_id + "subscribe"
renewalRevenueCat Webhookproduct_id, revenue_usd, period_numbertransaction_id
churnRevenueCat Webhookreason, refund_amountoriginal_transaction_id + "churn"

Hashe Nutzer-IDs mit SHA-256, bevor du sie an Werbeplattformen schickst. Erfasse Click-IDs (fbclid, gclid, ttclid) beim Install per Deferred Deep Linking und speichere sie am Nutzerdatensatz. Ohne Click-IDs fallen serverseitige Conversion-APIs auf grobes Matching zurück.

Der Deduplication-Vertrag mit Meta CAPI ist streng: SDK- und Server-Events müssen innerhalb von 48 Stunden dieselbe event_id und denselben event_name teilen, sonst zählst du doppelt. Nutze event_id = hash(transaction_id + event_name).

SKAN 4 Conversion-Value-Schema

Du hast 6 Bit für fein granulare Werte. Das sind 64 Slots für alles, was du über einen Nutzer in den ersten 48 Stunden kodieren willst. Die meisten Teams verschwenden dieses Budget.

Ein Schema, das für Subscription-Apps funktioniert:

  • Bits 0-2 (8 Werte): Funnel-Stufe — opened, onboarded, paywall_seen, trial_start, subscribe, renewal, Reserve, Reserve
  • Bits 3-5 (8 Werte): Umsatz-Bucket in USD — 0, <5, 5-10, 10-20, 20-50, 50-100, 100-200, 200+
swift
func encodeConversionValue(stage: FunnelStage, revenueUSD: Double) -> Int {    let stageBits = stage.rawValue & 0b111    let revenueBits = revenueBucket(for: revenueUSD) & 0b111    return (revenueBits << 3) | stageBits}
func updateSKAN(stage: FunnelStage, revenueUSD: Double) {    let value = encodeConversionValue(stage: stage, revenueUSD: revenueUSD)    let coarse: SKAdNetwork.CoarseConversionValue =        revenueUSD >= 20 ? .high : revenueUSD >= 5 ? .medium : .low
    SKAdNetwork.updatePostbackConversionValue(        value,        coarseValue: coarse,        lockWindow: false    )}

Senke einen Conversion-Wert niemals ab. SKAN erzwingt eine monotone Steigerung. Wenn du von trial_start zurück zu paywall_seen gehst, verwirft Apple das Update still.

Bei kleinen Kampagnen werden fein granulare Werte durch Apples Privatsphäre-Schwelle geleert. Plane deine Bidding-Strategie mit groben Werten als Grundfall, nicht als Ausnahme.

Referenzarchitektur

Drei parallele Pfade tragen das Signal vom Gerät zu den Werbeplattformen. Das Gerät selbst sendet SKAN- und AAK-Postbacks. Der MMP trägt deterministische Attribution für zustimmende Nutzer. Das Backend sendet serverseitige Conversion-Events über Meta CAPI, Google Ads API und TikTok Events API. Jeder Pfad hat andere Latenz, andere Genauigkeit und andere Privatsphäre-Kompromisse.

MMP vs direkte SDK-Integration

Ein Mobile Measurement Partner aggregiert SKAN-Postbacks über Netzwerke hinweg, macht deterministische Attribution für zustimmende Nutzer, leitet Kostendaten, filtert Betrug und vereinheitlicht ROAS. Die Frage ist, ob du ihn brauchst.

MMP-Preise liegen bei Volumen typischerweise im niedrigen Cent-Bereich pro Install und werden per Vertrag verhandelt. Für eine volumenstarke App ist das echtes Geld. Direkte SDK-Integration spart die Gebühr, schiebt aber SKAN-Aggregation, Postback-Routing und Betrugsfilterung in dein Engineering-Team. Der Break-even hängt von Install-Volumen und Anzahl der Netzwerke ab.

Implementierung: RevenueCat Webhook Fan-Out

RevenueCat sitzt als Subscription-Source-of-Truth in der Mitte der Pipeline. Sein Webhook ist der Trigger, der zu jeder Werbeplattform fan-out macht. Unten der Kern eines TypeScript-Handlers, der Deduplication, Authorization-Header-Prüfung und paralleles Fan-out abdeckt. RevenueCat-Webhooks nutzen einen Shared-Secret-Bearer-Token im Authorization-Header, keine HMAC-Signatur; die Prüfung ist daher ein einfacher String-Vergleich.

typescript
import crypto from "node:crypto";import type { Request, Response } from "express";
interface RCWebhook {  event: {    type: "INITIAL_PURCHASE" | "RENEWAL" | "CANCELLATION" | "EXPIRATION";    original_transaction_id: string;    product_id: string;    price_in_purchased_currency: number;    currency: string;    app_user_id: string;    purchased_at_ms: number;    period_type: "TRIAL" | "NORMAL" | "INTRO" | "PROMOTIONAL";  };}
export async function handleRevenueCatWebhook(req: Request, res: Response) {  const authHeader = req.header("Authorization");  if (authHeader !== `Bearer ${process.env.RC_WEBHOOK_SECRET}`) {    return res.status(401).send("unauthorized");  }
  const body = req.body as RCWebhook;  const { event } = body;  const eventName = mapEventName(event);  const eventId = crypto    .createHash("sha256")    .update(`${event.original_transaction_id}:${eventName}`)    .digest("hex");
  const user = await loadUser(event.app_user_id);  const revenueUSD = await toUSD(event.price_in_purchased_currency, event.currency);
  await Promise.allSettled([    sendMetaCAPI({ eventId, eventName, user, revenueUSD, event }),    sendGoogleAdsConversion({ eventId, eventName, user, revenueUSD }),    sendTikTokEvent({ eventId, eventName, user, revenueUSD }),    writeWarehouse({ eventId, eventName, user, revenueUSD, event }),  ]);
  return res.status(200).send("ok");}
function mapEventName(event: RCWebhook["event"]): string {  if (event.type === "INITIAL_PURCHASE" && event.period_type === "TRIAL") {    return "trial_start";  }  if (event.type === "INITIAL_PURCHASE") return "subscribe";  if (event.type === "RENEWAL") return "renewal";  return "churn";}

Ein paar Details, die leicht zu übersehen sind. Das app_data-Objekt, das Meta CAPI für Mobile Events erwartet, muss advertiser_tracking_enabled, application_tracking_enabled, Bundle-ID und App-Version enthalten. Ohne das fällt Meta auf grobe Attribution zurück. Beachte: Meta hat die Offline Conversions API im Mai 2025 eingestellt; alle Mobile-App-Events laufen jetzt über die Haupt-Conversions-API. Für Google Ads unterstützt die Google Ads API serverseitiges Hochladen per UploadClickConversionsRequest mit gclid für App-Conversions. Enhanced Conversions mit gehashten Nutzerdaten sind eine separate, ergänzende Option, die die Match-Qualität verbessert.

Nutze Promise.allSettled, nicht Promise.all. Ein wackeliges Werbenetzwerk darf nicht Events für die anderen fallen lassen. Push Fehler in eine Dead-Letter-Queue und versuche mit exponentiellem Backoff erneut.

Implementierung: StoreKit 2 Transaction Listener

Auf iOS liefert StoreKit 2 Transaktionen als JWS-Payloads. Der Verifizierungsschritt ist Pflicht. Alles, was unverifiziert ankommt, solltest du als gefälscht behandeln.

swift
import StoreKit
actor TransactionListener {    func start() async {        for await result in Transaction.updates {            guard case .verified(let transaction) = result else {                continue            }            // Das signierte JWS vom aeusseren VerificationResult holen,            // nicht vom decodierten Transaction-Wert.            await report(transaction: transaction, jws: result.jwsRepresentation)            await transaction.finish()        }    }
    private func report(transaction: Transaction, jws: String) async {        let payload = TransactionPayload(            originalTransactionID: transaction.originalID,            productID: transaction.productID,            purchaseDate: transaction.purchaseDate,            offerType: transaction.offerType?.rawValue,            jws: jws        )        try? await BackendClient.shared.post("/transactions", payload)    }}

Das Feld offerType ist der Weg, einen Einführungs-Trial von einem direkten bezahlten Kauf zu unterscheiden. Ein wichtiges Detail: Das signierte JWS liegt auf dem äußeren VerificationResult-Enum als jwsRepresentation, nicht auf dem decodierten Transaction-Wert. Transaction.jsonRepresentation ist einfaches decodiertes JSON und nicht signiert; schickst du das ans Backend, hast du keinerlei kryptographische Garantie. Lies result.jwsRepresentation vor dem Auspacken des Enums und validiere die Signatur dann im Backend mit Apples öffentlichem Schlüssel, bevor du dem Payload vertraust. RevenueCat macht das für dich, wenn du es nutzt; baust du direkt, nutze App Store Server Notifications v2 für Renewal-Events.

Predictive LTV und tROAS

Kurzfenster-ROAS ist das einzige Signal, das schnell genug für algorithmisches Bidding ist. Werbeplattformen brauchen Umsatzereignisse innerhalb von 24 bis 72 Stunden, um Gebote zu optimieren. Auf echte bezahlte Conversions nach einem 7-Tage-Trial zu warten, ist zu spät.

Die Lösung ist Predictive LTV. Ein einfaches Modell: Basisrate Trial-zu-Bezahlt, multipliziert mit erwarteten Verlängerungen, zeitlich diskontiert. Gib diese Zahl als Umsatz an Werbeplattformen auf trial_start, statt auf echtes Bezahltes zu warten.

Das Risiko ist eine Rückkopplungsschleife. Die Werbeplattform optimiert gegen deine Vorhersage. Deine Vorhersage driftet. Du optimierst gegen driftende Daten. Kalibriere pLTV in einem rollierenden 30-Tage-Fenster gegen echten Umsatz und alarmiere, wenn die Lücke 15 Prozent überschreitet.

Metriken, die zählen

CPI und CPA sind Frühindikatoren. Sie sagen dir nichts über die Gesundheit des Geschäfts. CAC, LTV, LTV:CAC-Ratio und Payback-Periode sind die Business-Metriken. Eine gesunde Consumer-Subscription-App zielt auf LTV:CAC über 3:1 und Payback unter 12 Monaten. Die Trial-zu-Bezahlt-Rate liegt bei Consumer-Subs typischerweise zwischen 30 und 50 Prozent.

Erwarte eine Lücke von 20 bis 40 Prozent zwischen gemischtem ROAS (was dein Warehouse sagt) und dem ROAS der Plattform (was Meta sagt). Die Lücke ist kein Bug. Sie ist die Summe aus SKAN-Grobbucketing, Privatsphäre-Schwellen und plattformübergreifender Überlappung. Berichte sie, verstecke sie nicht.

Was du nicht tun solltest

Ein paar Muster, die dich Monate kosten, wenn du sie übernimmst:

  • Doppeltes Zählen von trial_start, weil SDK und Backend beide ohne geteilte event_id feuern. Erzeuge die Event-ID einmal und gib sie durch beide Pfade.
  • SKAN-Null-Conversion-Werte als Null behandeln. Null heißt "Privatsphäre-Schwelle nicht erreicht", nicht "kein Umsatz". Bucket sie in eine eigene unknown-Kategorie und modelliere sie separat.
  • Brutto-Umsatz an Werbeplattformen senden. Ziehe Store-Gebühren (15 bis 30 Prozent) und erwartete Erstattungen (2 bis 5 Prozent bei Consumer-Subs) ab, bevor du Umsatz zurückspielst. Sonst überinvestiert dein tROAS-Bidding.
  • Währungsumrechnung vergessen. RevenueCat normalisiert auf USD; Werbeplattformen berichten eventuell in der Kontowährung. Wähle eine kanonische Währung im Warehouse.
  • Zeitzonen-Drift. RevenueCat ist UTC. Meta und Google Ads berichten in Konto-Zeitzone. Speichere Events immer in UTC und konvertiere nur in der Präsentationsschicht.
  • Auf unverifizierten App Store Server Notifications handeln. Validiere die JWS-Signatur, bevor du mit dem Payload etwas machst.

Abgleich: Den Kreis schließen

Der nächtliche Abgleichjob ist das, was Finance zum Vertrauen in die Zahlen bringt. Joine die MMP-Attribution-Tabelle mit der RevenueCat-Events-Tabelle auf original_transaction_id. Rolle auf Kampagnen- und Kohortenebene. Wende das rollierende 35-Tage-SKAN-Anpassungsfenster an. Berichte den nicht-attribuierten Bucket explizit.

Ein funktionierendes Warehouse-Modell:

  • raw_mmp_attributions — eine Zeile pro Install, attribuierte Quelle
  • raw_revenuecat_events — eine Zeile pro Abo-Event
  • reconciled_users — Join-Key ist app_user_id, bringt Install-Quelle und Abo-Lebenszyklus zusammen
  • cohort_revenue — tägliche Kohorte × Quelle, mit rollierendem LTV-Ist

Finance liest cohort_revenue. Marketing liest das MMP-Dashboard. Produkt liest reconciled_users. Sie werden sich bei Randfällen weiterhin uneinig sein. Das ist okay. Dokumentiere die bekannten Deltas und mach weiter.

Fazit

Die Messpipeline für eine Subscription-App ist ein Problem verteilter Systeme, verkleidet als Marketingproblem. Du hast drei Uhren, drei Wahrheitsquellen und drei Konsumenten mit unterschiedlichen Toleranzen für Latenz und Genauigkeit. Der Job ist nicht, die "echte" Zahl zu finden. Der Job ist, eine abgeglichene Pipeline zu bauen, in der jeder Konsument Signal mit bekannten Fehlerbalken bekommt, und in der SKAN-Schema, Webhook-Fan-out und Warehouse-Modell sich einig sind, was ein Event bedeutet.

Fang mit der Event-Taxonomie an. Bekomm Deduplication sauber hin. Dann schichte SKAN, CAPI und Abgleich darauf. Teams, die die Taxonomie überspringen und direkt zu Dashboards springen, verbringen das nächste Jahr damit, Zahlen zu debuggen, die nicht zusammenpassen.

Referenzen

Ähnliche Beiträge