Blog von Obi Madu
Zurück zu allen Artikeln
MobileSystem DesignInfrastructureBackend

Architektur für zuverlässige mobile Abrechnung: Was ich auf die harte Tour gelernt habe

Ein Praxisblick auf die Reparatur von mobiler Abonnement-Abrechnung, wenn Webhooks, Sandbox-Käufe und Benutzeridentität versagen.

Architektur für zuverlässige mobile Abrechnung: Was ich auf die harte Tour gelernt habe

Die mobile Abonnement-Abrechnung sieht von außen oft trügerisch einfach aus. Das Standard-Verkaufsgespräch suggeriert, dass man einfach ein Paywall-SDK hinzufügt, seine Produkte in den jeweiligen App Stores konfiguriert, auf Webhooks lauscht und dann seine Datenbank aktualisiert, wenn ein Kauf erfolgreich ist. Leider ist diese Erzählung nur die halbe Wahrheit. Der Happy Path funktioniert wunderbar, bis man zwangsläufig an die unbequemeren Ränder des Systems stößt: Sandbox-Umgebungen, fehlende Identitätsfelder, Race Conditions, verzögerte Webhooks, wiederhergestellte Käufe, Verlängerungen, Stornierungen und die unangenehme Realität, dass dem App Store die Transaktion gehört, während der Anwendung der Benutzer gehört.

Ich bin kopfüber in diese Realität gerannt, als ich Superwall in Google Play-Abonnements integriert habe. Während Produktionskäufe gut aussahen, verkam das Sandbox-Testen schnell zu einem Chaos. Testkäufe wurden innerhalb der App erfolgreich abgeschlossen, aber mein Backend konnte nicht zuverlässig herausfinden, welcher Benutzer tatsächlich die Premium-Berechtigung erhalten sollte. Was als unkomplizierte Paywall-Integration begann, wurde plötzlich zu einem Problem verteilter Systeme, bei dem es um Identität, Eigentum und Abgleich ging. Dieser Artikel detailliert die Architektur, bei der ich letztendlich gelandet bin, und die schmerzhaften Lektionen, die mich dorthin gezwungen haben.

Die Architektur, an die ich glauben wollte

Die erste Version meines Abrechnungsablaufs folgte natürlich dem standardmäßigen webhook-gesteuerten Modell. Der Benutzer tippte auf einen Upgrade-Button, Superwall präsentierte die Paywall, verarbeitete den Kauf und sendete dann einen Webhook an mein Backend. Das Backend las einfach die Benutzer-ID aus dem eingehenden Webhook und führte das Upgrade für diesen spezifischen Benutzer durch.

Im Idealfall sah die Webhook-Nutzlast so aus:

{
  "data": {
    "originalAppUserId": "user_123",
    "transactionId": "GPA.3373-4052-0812-08085",
    "productId": "pro:pro-monthly"
  }
}

Diese Nutzlast enthält alles, was sich ein Backend-Entwickler wünscht. Sie enthält das Produkt, die Transaktion und am wichtigsten, die Identität des Benutzers auf App-Ebene. Angesichts dieser sauberen Daten ist die Gewährung des Zugriffs unkompliziert. Bei echten Produktionskäufen funktionierte dieser Ablauf außergewöhnlich gut. Benutzer kauften Abonnements, Webhooks kamen prompt an und die Datenbank verschob sie in den Premium-Plan. Dann begann ich, richtig mit der Google Play Sandbox zu testen.

Die Sandbox brach die Annahme

Das Testen in der Google Play Sandbox ist unglaublich nützlich, da Abonnementlaufzeiten drastisch beschleunigt werden. Ein monatliches Abonnement kann sich alle paar Minuten verlängern, was es ermöglicht, Verlängerungs-, Stornierungs-, Ablauf- und Neuabschlussabläufe gründlich zu testen, ohne einen tatsächlichen Monat warten zu müssen. Aber meine ersten Sandbox-Käufe schlugen auf höchst verwirrende Weise fehl. Der Kauf wurde in der App abgeschlossen, aber der Benutzer wurde nicht hochgestuft.

Die Backend-Logs erklärten schnell warum:

{
  "data": {
    "originalAppUserId": null,
    "userAttributes": null,
    "transactionId": "GPA.3373-4052-0812-08085",
    "originalTransactionId": "GPA.3373-4052-0812-08085",
    "productId": "pro:pro-monthly",
    "environment": "SANDBOX"
  }
}

Die Identitätsfelder fehlten komplett. Der Webhook besagte effektiv: "Jemand hat gerade dieses Abonnement gekauft, und hier ist die Transaktions-ID, aber ich habe absolut keine Ahnung, wer es ist." Dieses einzige Versäumnis machte das gesamte Design zunichte, da sich mein Backend stark auf den Webhook verließ, um die kritischste Frage zu beantworten: Welchem Benutzer gehört dieser Kauf?

Das tiefere Architekturproblem besteht darin, dass Google Play meine internen Benutzer-IDs grundsätzlich nicht kennt. Das Abonnement gehört zu einem Google-Konto, während mein App-Benutzer zu meinem proprietären Authentifizierungssystem gehört. Superwall kann sicherlich versuchen, diese beiden unterschiedlichen Identitätsdomänen zu überbrücken, aber diese Brücke in jeder Umgebung als unfehlbare Quelle der Wahrheit zu betrachten, ist ein Fehler. Sobald ich diese Trennung verstand, sah die alte Architektur nicht mehr einfach aus, sondern begann, unglaublich fragil zu wirken.

Die gescheiterten Korrekturen

Bevor ich beim richtigen Modell landete, probierte ich natürlich ein paar offensichtliche Korrekturen aus, die letztendlich nicht ausreichten.

Diese wiederholten Fehlschläge zwangen mich dazu, zwei unterschiedliche Konzepte sorgfältig zu trennen, die ich in meinem anfänglichen Design versehentlich zusammengeführt hatte.

Eigentum und Lebenszyklus sind unterschiedliche Probleme

Der große Durchbruch war die Erkenntnis, dass Abonnement-Eigentum und Abonnement-Lebenszyklus überhaupt nicht dasselbe Problem sind. Eigentum beantwortet: Wer hat dieses Abonnement gekauft? Lebenszyklus beantwortet: Was ist der aktuelle Status dieses Abonnements? Ich hatte mich für beides auf Webhooks verlassen, was mein grundlegender Fehler war.

Das Eigentum muss nur einmal festgelegt werden, und es sollte strikt durch eine authentifizierte Benutzeraktion festgelegt werden. Änderungen im Lebenszyklus hingegen erfolgen im Laufe der Zeit wiederholt durch Verlängerungen, Stornierungen, Abläufe, Rückerstattungen und Neuabschlüsse. Webhooks eignen sich hervorragend für asynchrone Lebenszyklus-Updates, sind jedoch eine sehr schlechte primäre Quelle für das Eigentum, wenn kritische Identitätsfelder fehlen können.

Das entscheidende Datenstück, das dies schließlich zum Laufen brachte, war die Transaktionsherkunft:

{
  "transactionId": "GPA.3373-4052-0812-08085",
  "originalTransactionId": "GPA.3373-4052-0812-08085"
}

Die originalTransactionId identifiziert den Ursprung des Abonnements eindeutig. Sie bleibt über Verlängerungen und Lebenszyklusereignisse hinweg völlig stabil, was sie zum perfekten Schlüssel für die Bindung eines Store-Abonnements an einen bestimmten App-Benutzer macht. Das zweite wichtige Puzzleteil war das purchaseToken, das auf dem Client nach einem Google Play-Kauf leicht verfügbar ist. Das Backend kann dieses Token verwenden, um Google Play direkt zu fragen, ob der Kauf tatsächlich gültig ist.

Diese Erkenntnis führte zu einer viel besseren Architektur: Das Eigentum sofort über authentifizierten Client-Kontext binden, den Kauf direkt bei Google Play verifizieren und sich dann für den zukünftigen Lebenszyklus-Abgleich streng auf Webhooks verlassen.

Die Architektur, die funktionierte

Im endgültigen Design ist der Webhook nicht mehr die Autorität, die entscheidet, wer Premium-Zugriff erhält. Stattdessen dient er als Hintergrundbote, der den allgemeinen Abonnementstatus aktuell hält.

Der sofortige Kaufablauf beginnt nun auf dem Client. Wenn Superwall meldet, dass eine Transaktion abgeschlossen ist, erfasst die App die Transaktionsherkunft und das Kauf-Token. Sie sendet diese spezifischen Werte dann unter der normalen, sicheren Authentifizierungssitzung des Benutzers an mein Backend.

POST /v1/billing/superwall-binding
Authorization: Bearer <user_token>
Content-Type: application/json

{
  "store": "PLAY_STORE",
  "originalTransactionId": "GPA.3373-4052-0812-08085",
  "transactionId": "GPA.3373-4052-0812-08085",
  "purchaseToken": "AOP..."
}

Entscheidend ist, dass das Backend keiner Benutzer-ID vertraut, die im Body versteckt ist. Es verlässt sich zur Identifizierung des Benutzers vollständig auf den Authorization-Header. Dann erstellt es eine Bindung, die definitiv besagt: Dieser authentifizierte Benutzer besitzt diese ursprüngliche Transaktions-ID. Diese Bindung wird zum dauerhaften Eigentumsdatensatz.

Als Nächstes verifiziert das Backend den Kauf sofort direkt bei Google Play:

GET https://androidpublisher.googleapis.com/androidpublisher/v3/applications/{packageName}/purchases/subscriptionsv2/tokens/{purchaseToken}

Wenn Google antwortet, dass das Kauf-Token gültig und das Abonnement aktiv ist, gewährt das Backend die Berechtigung an Ort und Stelle. Der Benutzer erhält nach Abschluss des Kaufs sofortigen Premium-Zugriff, wodurch jegliche Abhängigkeit von einer Webhook-Wartezeit vollständig entfällt.

Später, wenn ein Superwall-Webhook schließlich eintrifft, kann er gefahrlos Identitätsfelder vermissen lassen und bleibt dennoch nützlich:

{
  "data": {
    "originalAppUserId": null,
    "originalTransactionId": "GPA.3373-4052-0812-08085",
    "name": "renewal",
    "expirationAt": 1775832567843
  }
}

Das Backend verwendet einfach die originalTransactionId, um die Bindung nachzuschlagen, und aktualisiert dann sicher den bestehenden Abonnement-Datensatz. Der Webhook muss nicht mehr wissen, wer der Benutzer ist, da das System dies bereits weiß.

Warum dies Edge Cases besser handhabt

Dieses entkoppelte Modell macht es erheblich einfacher, über all die hässlichen Randfälle nachzudenken.

Sollte ein Webhook zufällig vor der Client-Bindung ankommen, speichert das Backend ihn einfach als ausstehend. Wenn der authentifizierte Client die Bindung später übermittelt, kann das Backend dieses ausstehende Lebenszyklusereignis sicher abspielen. Wenn ein Benutzer das Abonnement direkt aus der Play Store-App anstatt innerhalb meiner App erneuert, wird kein sofortiges Client-Ereignis ausgelöst. Der Webhook kann jedoch weiterhin eintreffen und in einem ausstehenden Status verbleiben, bis der Benutzer die App erneut öffnet. Sobald das SDK den wiederhergestellten Kauf erkennt, sendet der Client die Bindung und das Backend holt nahtlos auf.

Am wichtigsten ist, dass nichts kaputtgeht, wenn die Google Play Sandbox Identitätsfelder auslässt. Die direkte Überprüfung beruht auf dem Kauf-Token und der Webhook-Abgleich auf der ursprünglichen Transaktions-ID. Kein kritischer Schritt hängt davon ab, dass originalAppUserId tatsächlich vorhanden ist. Dies ist der tiefgreifende Unterschied zwischen einem Abrechnungssystem, das nur hofft, dass alle Integrationen die Identität wahren, und einem System, das sein eigenes verbindliches Eigentumsmodell durchsetzt.

Das mentale Modell

Dem Store gehört die Transaktion. Deiner App gehört der Benutzer. Dein Backend muss die Bindung zwischen ihnen fest im Griff haben. Sobald du diese Dynamik wirklich akzeptierst, wird die gesamte Architektur viel klarer.

AnliegenQuelle der Wahrheit
BenutzeridentitätDein Authentifizierungssystem
KaufgültigkeitGoogle Play oder App Store
Abonnement-EigentumDeine Bindungstabelle
Verlängerungen und StornierungenStore-Ereignisse via Webhooks, abgeglichen durch Bindungen

Dem Client ist es vollkommen erlaubt, Korrelationsdaten wie das purchaseToken und die originalTransactionId zu tragen. Es ist ihm jedoch grundsätzlich nicht gestattet, sich selbst Zugriff zu gewähren. Das Backend muss die Transaktion immer mit dem Store verifizieren, bevor dauerhafte Berechtigungen erstellt werden.

Die Lektion

Eine zuverlässige mobile Abrechnung erfordert grundsätzlich, Webhooks als asynchrone Lebenszyklusnachrichten zu behandeln, anstatt sie als perfekte Identitätsdatensätze zu betrachten. Webhooks können verzögert werden, sie können völlig außer der Reihe ankommen und es können leicht wichtige Benutzerfelder fehlen. Sandbox-Umgebungen können sich völlig anders verhalten als die Produktion. Keiner dieser Faktoren sollte jemals darüber entscheiden, ob ein legitimer Käufer tatsächlich Zugriff auf die Funktionen erhält, für die er bezahlt hat.

Das robuste, belastbare Muster besteht darin, das Eigentum fest durch eine authentifizierte Client-Anfrage zu etablieren, den Kauf direkt beim Store zu verifizieren und Webhooks ausschließlich dazu zu verwenden, den Lebenszyklus im Laufe der Zeit aktuell zu halten. Diese eine architektonische Verschiebung hat mein System komplett stabilisiert. Ich hörte auf, den Webhook zu fragen, wer das Abonnement besaß, machte das Eigentum explizit, verifizierte es an der Quelle und ließ Webhooks die Hintergrund-Synchronisationsarbeit erledigen, für die sie eigentlich gebaut sind.