Subscription Event Tracking Implementation Guide
Implement comprehensive subscription tracking to measure subscription revenue, user retention, and campaign performance across all platforms using Singular's SDK, server-to-server integration, or third-party partners.
Singular enables accurate attribution of subscription events—including new subscriptions, renewals, trials, cancellations, and refunds—to the marketing campaigns that generated users, providing complete visibility into subscription lifecycle and revenue generation.
Implementation Options
Choose the implementation method that best fits your app architecture and business requirements.
| Consideration | SDK Integration | Server-to-Server (S2S) | Third-Party Integration |
|---|---|---|---|
| Implementation | Send subscription events via custom revenue methods in the Singular SDK | Send events from your backend to Singular's REST API with required mobile device properties | Configure integrations with RevenueCat, Adapty, or other subscription platforms |
| App Activity Dependency | App must be launched for SDK to send events | Events sent in real time, independent of app state | Events sent in real time from partner platform |
| Event Timing | Event time may differ from actual subscription time based on app launch | Real-time event tracking as processed in backend | Near real-time tracking after partner processing |
| Complexity | Simple client-side implementation | Requires secure backend infrastructure | Requires partner account and configuration |
Important: Most customers using S2S or third-party integrations for subscription events should still integrate the Singular SDK to manage sessions and non-subscription events for complete attribution tracking.
Android: Google Play Billing Integration
Integrate Google Play Billing Library 8.0.0 to query subscription information and manage subscription state directly on device for Singular SDK integration.
Prerequisites
Configure Google Play Console
Set up in-app products and subscription items in the Google Play Console before implementing billing in your app.
- Create Subscription Products: Define subscription tiers, billing periods, and pricing in Play Console
- Configure Offers: Set up base plans, offer tokens, and promotional pricing
- Test Setup: Create test accounts and verify product availability
Add Billing Library Dependency
Update build.gradle
Add Google Play Billing Library 8.0.0 to your app's dependencies with Kotlin extensions for coroutine support.
dependencies {
val billingVersion = "8.0.0"
// Google Play Billing Library
implementation("com.android.billingclient:billing:$billingVersion")
// Kotlin extensions and coroutines support
implementation("com.android.billingclient:billing-ktx:$billingVersion")
}
Version 8.0.0 Features: This version introduces
automatic service reconnection with
enableAutoServiceReconnection(), improved purchase query
APIs, and enhanced pending transaction handling.
Initialize BillingClient
Create Billing Manager
Set up a billing manager class to handle all subscription operations and integrate with Singular SDK for event tracking.
class SubscriptionManager(private val context: Context) {
private val purchasesUpdatedListener = PurchasesUpdatedListener { billingResult, purchases ->
when (billingResult.responseCode) {
BillingResponseCode.OK -> {
purchases?.forEach { purchase ->
handlePurchaseUpdate(purchase)
}
}
BillingResponseCode.USER_CANCELED -> {
Log.d(TAG, "User canceled the purchase")
}
else -> {
Log.e(TAG, "Purchase error: ${billingResult.debugMessage}")
}
}
}
private val billingClient = BillingClient.newBuilder(context)
.setListener(purchasesUpdatedListener)
.enablePendingPurchases() // Required for all purchases
.enableAutoServiceReconnection() // NEW in 8.0.0 - handles reconnection automatically
.build()
fun initialize() {
billingClient.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(billingResult: BillingResult) {
if (billingResult.responseCode == BillingResponseCode.OK) {
Log.d(TAG, "Billing client ready")
// Query existing subscriptions
queryExistingSubscriptions()
} else {
Log.e(TAG, "Billing setup failed: ${billingResult.debugMessage}")
}
}
override fun onBillingServiceDisconnected() {
// With enableAutoServiceReconnection(), SDK handles reconnection
// Manual retry is no longer required
Log.d(TAG, "Billing service disconnected - auto-reconnection enabled")
}
})
}
companion object {
private const val TAG = "SubscriptionManager"
}
}
Key Features:
-
Auto-Reconnection: Version 8.0.0's
enableAutoServiceReconnection()automatically re-establishes connections when needed - Purchase Listener: Receives callbacks for all purchase updates including renewals and pending transactions
- Error Handling: Comprehensive handling for user cancellations and billing errors
Query Subscription Information
Retrieve Active Subscriptions
Query existing subscriptions using queryPurchasesAsync()
to handle renewals and subscriptions purchased outside your app.
private suspend fun queryExistingSubscriptions() {
val params = QueryPurchasesParams.newBuilder()
.setProductType(BillingClient.ProductType.SUBS)
.build()
withContext(Dispatchers.IO) {
val result = billingClient.queryPurchasesAsync(params)
if (result.billingResult.responseCode == BillingResponseCode.OK) {
result.purchasesList.forEach { purchase ->
when (purchase.purchaseState) {
Purchase.PurchaseState.PURCHASED -> {
// Active subscription - process it
handleSubscriptionPurchase(purchase)
}
Purchase.PurchaseState.PENDING -> {
// Pending payment - notify user
Log.d(TAG, "Subscription pending: ${purchase.products}")
}
}
}
} else {
Log.e(TAG, "Query failed: ${result.billingResult.debugMessage}")
}
}
}
Best Practice: Call queryPurchasesAsync()
in onResume() to catch subscription renewals that occurred
while your app was backgrounded or closed.
Query Product Details
Retrieve subscription product details including pricing, billing periods, and offer tokens before displaying to users.
private suspend fun querySubscriptionProducts() {
val productList = listOf(
QueryProductDetailsParams.Product.newBuilder()
.setProductId("premium_monthly")
.setProductType(BillingClient.ProductType.SUBS)
.build(),
QueryProductDetailsParams.Product.newBuilder()
.setProductId("premium_yearly")
.setProductType(BillingClient.ProductType.SUBS)
.build()
)
val params = QueryProductDetailsParams.newBuilder()
.setProductList(productList)
.build()
withContext(Dispatchers.IO) {
val productDetailsResult = billingClient.queryProductDetails(params)
if (productDetailsResult.billingResult.responseCode == BillingResponseCode.OK) {
// Process successfully retrieved product details
productDetailsResult.productDetailsList?.forEach { productDetails ->
// Extract subscription offers
productDetails.subscriptionOfferDetails?.forEach { offer ->
val price = offer.pricingPhases.pricingPhaseList.firstOrNull()
Log.d(TAG, "Product: ${productDetails.productId}, Price: ${price?.formattedPrice}")
}
}
// Handle unfetched products
productDetailsResult.unfetchedProductList?.forEach { unfetchedProduct ->
Log.w(TAG, "Unfetched: ${unfetchedProduct.productId}")
}
}
}
}
Process Purchase Updates
Handle Subscription Events
Process purchase state changes and send appropriate events to Singular based on subscription lifecycle.
private fun handlePurchaseUpdate(purchase: Purchase) {
when (purchase.purchaseState) {
Purchase.PurchaseState.PURCHASED -> {
if (!purchase.isAcknowledged) {
// Verify purchase on your backend first
verifyPurchaseOnBackend(purchase) { isValid ->
if (isValid) {
// Send subscription event to Singular
trackSubscriptionToSingular(purchase)
// Grant entitlement to user
grantSubscriptionAccess(purchase)
// Acknowledge purchase to Google
acknowledgePurchase(purchase)
}
}
}
}
Purchase.PurchaseState.PENDING -> {
// Notify user that payment is pending
Log.d(TAG, "Purchase pending approval: ${purchase.products}")
notifyUserPendingPayment(purchase)
}
}
}
private fun trackSubscriptionToSingular(purchase: Purchase) {
// Get cached product details for pricing information
val productDetails = getCachedProductDetails(purchase.products.first())
productDetails?.let { details ->
val subscriptionOffer = details.subscriptionOfferDetails?.firstOrNull()
val pricingPhase = subscriptionOffer?.pricingPhases?.pricingPhaseList?.firstOrNull()
val price = pricingPhase?.priceAmountMicros?.div(1_000_000.0) ?: 0.0
val currency = pricingPhase?.priceCurrencyCode ?: "USD"
// Determine event name based on purchase type
val eventName = if (isNewSubscription(purchase)) {
"sng_subscribe"
} else {
"subscription_renewed"
}
// Send to Singular WITHOUT receipt
Singular.customRevenue(
eventName,
currency,
price,
mapOf(
"subscription_id" to purchase.products.first(),
"order_id" to (purchase.orderId ?: ""),
"purchase_time" to purchase.purchaseTime.toString()
)
)
Log.d(TAG, "Tracked $eventName to Singular: $price $currency")
}
}
private fun acknowledgePurchase(purchase: Purchase) {
val acknowledgePurchaseParams = AcknowledgePurchaseParams.newBuilder()
.setPurchaseToken(purchase.purchaseToken)
.build()
lifecycleScope.launch {
val ackResult = withContext(Dispatchers.IO) {
billingClient.acknowledgePurchase(acknowledgePurchaseParams)
}
if (ackResult.responseCode == BillingResponseCode.OK) {
Log.d(TAG, "Purchase acknowledged successfully")
} else {
Log.e(TAG, "Acknowledgement failed: ${ackResult.debugMessage}")
}
}
}
Critical: Do NOT send the Google Play receipt to
Singular when tracking subscription events. Use
customRevenue() method without receipt validation. Acknowledge
purchases within 3 days or they will be automatically refunded.
iOS: StoreKit Integration
Integrate Apple's StoreKit framework to manage subscriptions and send events to Singular for iOS apps.
Prerequisites
Configure App Store Connect
Set up subscription products and subscription groups in App Store Connect before implementing StoreKit in your app.
- Create Subscription Groups: Organize subscriptions into groups based on access levels
- Define Subscription Products: Set up subscription tiers, durations, and pricing
- Configure Offers: Create introductory offers, promotional offers, and subscription codes
- Test Environment: Set up sandbox test accounts for development
Implement StoreKit
Set Up Transaction Observer
Implement SKPaymentTransactionObserver to receive subscription
purchase notifications and renewals.
import StoreKit
class SubscriptionManager: NSObject, SKPaymentTransactionObserver {
static let shared = SubscriptionManager()
private override init() {
super.init()
// Add transaction observer
SKPaymentQueue.default().add(self)
}
deinit {
SKPaymentQueue.default().remove(self)
}
// MARK: - SKPaymentTransactionObserver
func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
for transaction in transactions {
switch transaction.transactionState {
case .purchased:
// New subscription or renewal
handlePurchasedTransaction(transaction)
case .restored:
// Subscription restored from another device
handleRestoredTransaction(transaction)
case .failed:
// Purchase failed
handleFailedTransaction(transaction)
case .deferred:
// Purchase awaiting approval (family sharing)
print("Transaction deferred: \(transaction.payment.productIdentifier)")
case .purchasing:
// Transaction in progress
break
@unknown default:
break
}
}
}
private func handlePurchasedTransaction(_ transaction: SKPaymentTransaction) {
// Verify receipt with your backend
verifyReceipt { isValid in
if isValid {
// Send to Singular
self.trackSubscriptionToSingular(transaction)
// Grant entitlement
self.grantSubscriptionAccess(transaction)
// Finish transaction
SKPaymentQueue.default().finishTransaction(transaction)
}
}
}
private func trackSubscriptionToSingular(_ transaction: SKPaymentTransaction) {
// Get product details for pricing
let productId = transaction.payment.productIdentifier
// You should cache product details from SKProductsRequest
if let product = getCachedProduct(productId) {
let price = product.price.doubleValue
let currency = product.priceLocale.currencyCode ?? "USD"
// Determine event name
let eventName = isNewSubscription(transaction) ? "sng_subscribe" : "subscription_renewed"
// Send to Singular WITHOUT receipt
Singular.customRevenue(
eventName,
currency: currency,
amount: price,
withAttributes: [
"subscription_id": productId,
"transaction_id": transaction.transactionIdentifier ?? "",
"transaction_date": transaction.transactionDate?.timeIntervalSince1970 ?? 0
]
)
}
}
}
Query Subscription Status
Use receipt validation to check current subscription status and handle renewals.
func refreshSubscriptionStatus() {
let request = SKReceiptRefreshRequest()
request.delegate = self
request.start()
}
extension SubscriptionManager: SKRequestDelegate {
func requestDidFinish(_ request: SKRequest) {
// Receipt refreshed successfully
if let appStoreReceiptURL = Bundle.main.appStoreReceiptURL,
FileManager.default.fileExists(atPath: appStoreReceiptURL.path) {
// Send receipt to your backend for validation
validateReceiptOnServer()
}
}
func request(_ request: SKRequest, didFailWithError error: Error) {
print("Receipt refresh failed: \(error.localizedDescription)")
}
}
SDK Integration Method
Send subscription events from your app using Singular SDK's custom revenue methods without receipt validation.
Platform-Specific SDK Methods
Use the appropriate SDK method for your platform to track subscription events.
Android SDK
// Track subscription without receipt
Singular.customRevenue(
"sng_subscribe", // Event name
"USD", // Currency
9.99, // Amount
mapOf( // Additional attributes
"subscription_id" to "premium_monthly",
"billing_period" to "monthly"
)
)
Documentation: Android SDK Revenue Tracking
iOS SDK
// Track subscription without receipt
Singular.customRevenue(
"sng_subscribe", // Event name
currency: "USD", // Currency
amount: 9.99, // Amount
withAttributes: [ // Additional attributes
"subscription_id": "premium_monthly",
"billing_period": "monthly"
]
)
Documentation: iOS SDK Revenue Tracking
React Native SDK
// Track subscription without receipt
Singular.customRevenueWithArgs(
"sng_subscribe", // Event name
"USD", // Currency
9.99, // Amount
{ // Additional attributes
subscription_id: "premium_monthly",
billing_period: "monthly"
}
);
Documentation: React Native SDK Revenue Tracking
Unity SDK
// Track subscription without receipt
SingularSDK.CustomRevenue(
"sng_subscribe", // Event name
"USD", // Currency
9.99, // Amount
new Dictionary<string, object> { // Additional attributes
{ "subscription_id", "premium_monthly" },
{ "billing_period", "monthly" }
}
);
Documentation: Unity SDK Revenue Tracking
Flutter SDK
// Track subscription without receipt
Singular.customRevenueWithAttributes(
"sng_subscribe", // Event name
"USD", // Currency
9.99, // Amount
{ // Additional attributes
"subscription_id": "premium_monthly",
"billing_period": "monthly"
}
);
Documentation: Flutter SDK Revenue Tracking
Cordova SDK
// Track subscription without receipt
cordova.plugins.SingularCordovaSdk.customRevenueWithArgs(
"sng_subscribe", // Event name
"USD", // Currency
9.99, // Amount
{ // Additional attributes
subscription_id: "premium_monthly",
billing_period: "monthly"
}
);
Documentation: Cordova SDK Revenue Tracking
Critical: DO NOT use IAP (In-App Purchase) methods
or send receipt values to Singular for subscription tracking. Only
use customRevenue() methods without receipts.
Server-to-Server Integration
Send subscription events in real time from your backend to Singular's REST API for immediate tracking independent of app state.
Implementation Requirements
Use Singular's Event Endpoint to send subscription events with revenue parameters from your secure backend.
Event Endpoint
Send POST requests to Singular's Event API with subscription event data and required mobile device properties.
Endpoint:
POST https://api.singular.net/api/v1/evt
Required Parameters:
-
SDK Key: Your Singular SDK key (
aparameter) -
Event Name: Subscription event name (
nparameter) -
Revenue: Subscription amount (
amtparameter) -
Currency: ISO 4217 currency code (
curparameter) - Device Identifiers: IDFA (iOS) or GAID (Android) for attribution
-
Platform: Operating system (
pparameter)
Documentation: Event Endpoint Reference and Revenue Parameters
Example Request
curl -X POST 'https://api.singular.net/api/v1/evt' \
-H 'Content-Type: application/json' \
-d '{
"a": "YOUR_SDK_KEY",
"n": "sng_subscribe",
"r": 9.99,
"pcc": "USD",
"idfa": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
"p": "iOS",
"install_time": 1697000000000,
"extra": {
"subscription_id": "premium_monthly",
"order_id": "GPA.1234.5678.9012"
}
}'
SDK Not Required: If using S2S for subscription events, you don't need to send these events from the SDK. However, most apps should still integrate the SDK for session tracking and non-subscription events.
Third-Party Integrations
Configure subscription tracking through third-party platforms that manage subscription infrastructure and send events directly to Singular.
Supported Partners
Integrate with subscription management platforms that have built-in Singular support.
RevenueCat Integration
RevenueCat handles subscription infrastructure and automatically sends subscription events to Singular's REST API.
Setup Steps:
- Create RevenueCat Account: Set up your app in RevenueCat dashboard
- Configure Singular Integration: Add Singular SDK key in RevenueCat integrations settings
- Map Events: Configure which RevenueCat events should be sent to Singular
- Test Integration: Verify events appear in Singular dashboard
Documentation: RevenueCat Singular Integration
Adapty Integration
Adapty provides subscription analytics and sends events to Singular through their integration.
Setup Steps:
- Create Adapty Account: Register and configure your app in Adapty
- Enable Singular Integration: Navigate to integrations and add Singular credentials
- Configure Event Mapping: Select which subscription events to forward
- Verify Data Flow: Confirm events reach Singular successfully
Documentation: Adapty Singular Integration
Note: Third-party integrations are managed by partner platforms. Configuration must be completed in their respective dashboards. Singular SDK should still be integrated for session and non-subscription event tracking.
Subscription Event Types
Track various subscription lifecycle events to measure user engagement, retention, and revenue across the complete subscription journey.
Standard Event Names
Use Singular's standard event names for consistent reporting across platforms, or define custom event names based on your tracking requirements.
| Subscription State | Event Name | SDK Method | S2S Integration |
|---|---|---|---|
| New Subscription |
sng_subscribe
|
customRevenue() with revenue amount (no receipt) | Event endpoint with revenue parameters |
| Trial Start |
sng_start_trial
|
event() method without revenue | Standard event endpoint without revenue |
| Trial End |
sng_end_trial
|
event() method without revenue | Standard event endpoint without revenue |
| Subscription Renewal |
subscription_renewed
|
customRevenue() with revenue amount (no receipt) | Event endpoint with revenue parameters |
| Cancellation |
subscription_cancelled
|
event() method without revenue | Standard event endpoint without revenue |
| Refund |
subscription_refunded
|
customRevenue() with negative amount (optional) OR event() without revenue | Event endpoint with negative revenue (optional) OR standard event |
Important: When sending subscription events via
customRevenue(), only send events where isRestored is
false. Restored purchases represent existing subscriptions,
not new revenue.
Event Implementation Examples
New Subscription
Track when users purchase a new subscription for the first time.
// New subscription purchase
Singular.customRevenue(
"sng_subscribe",
"USD",
9.99,
mapOf(
"subscription_tier" to "premium",
"billing_period" to "monthly",
"product_id" to "premium_monthly"
)
)
Trial Start
Track when users begin a free trial period without revenue.
// Trial start (no revenue)
Singular.event(
"sng_start_trial",
mapOf(
"trial_duration" to "7_days",
"subscription_tier" to "premium"
)
)
Subscription Renewal
Track automatic subscription renewals with revenue amount.
// Subscription renewal
Singular.customRevenue(
"subscription_renewed",
"USD",
9.99,
mapOf(
"subscription_tier" to "premium",
"renewal_count" to 3,
"product_id" to "premium_monthly"
)
)
Cancellation
Track when users cancel their subscription (no revenue event).
// Subscription cancelled (no revenue)
Singular.event(
"subscription_cancelled",
mapOf(
"cancellation_reason" to "too_expensive",
"days_active" to 45,
"subscription_tier" to "premium"
)
)
SKAdNetwork Measurement
Measure subscription events through Apple's SKAdNetwork for iOS privacy-compliant attribution.
SKAN Subscription Support
Singular can measure subscription event data via all integration methods (SDK, S2S, and third-party). Event measurement may be limited by SKAN postback timing windows depending on the SKAN version.
Hybrid SKAN Implementation
For apps using Singular SDK for lifecycle events and S2S/third-party integrations for subscriptions, enable Hybrid SKAN to combine both data sources.
Contact Support: Reach out to your Customer Success Manager (CSM) to enable Hybrid SKAN for your app. This ensures subscription events from backend sources are properly attributed in SKAN conversion values.
SKAN Postback Windows:
- SKAN 4.0: Three postback windows (0-2 days, 3-7 days, 8-35 days) enable measurement of early subscription events and short-term renewals
- SKAN 3.0: Single 24-hour postback window limits measurement to immediate subscriptions only
- Conversion Values: Configure subscription events in your SKAN conversion value schema to prioritize high-value events
Best Practices and Validation
Follow implementation best practices and verify subscription tracking is working correctly before production deployment.
Implementation Best Practices
Key Guidelines
- No Receipt Validation: Never send platform receipts (Google Play, App Store) to Singular when tracking subscriptions—use customRevenue() without receipts
- Filter Restored Purchases: Only send events where isRestored is false to avoid duplicate revenue reporting
- Backend Verification: Always verify purchases on your secure backend before sending events to Singular
- Timely Acknowledgement: Acknowledge Google Play purchases within 3 days to prevent automatic refunds
- Query on Resume: Call queryPurchasesAsync() in onResume() to catch renewals that occurred while app was closed
- Consistent Event Names: Use standard Singular event names (sng_subscribe, subscription_renewed) for unified reporting
- Include Metadata: Add attributes like subscription_tier, billing_period, and product_id for detailed analysis
Testing and Validation
Testing Checklist
- Test Environment Setup: Use sandbox/test accounts for both Google Play and App Store
- New Subscription: Verify sng_subscribe event sends correctly with proper amount and currency
- Trial Flow: Test trial start and trial end events without revenue
- Renewal Handling: Confirm renewals trigger subscription_renewed events with correct revenue
- Cancellation Tracking: Verify cancellation events fire when users cancel subscriptions
- Restore Filtering: Ensure restored purchases don't send duplicate events
- Singular Dashboard: Check events appear in Singular with correct attribution
- Cross-Device: Test subscription syncing across multiple devices
Common Issues
- Missing Events: Verify billing client connection is active and queryPurchasesAsync() is called on app resume
- Duplicate Events: Check that restored purchases are properly filtered with isRestored check
- Wrong Revenue: Ensure price extraction from ProductDetails correctly divides micros by 1,000,000
- Attribution Issues: Confirm device identifiers (IDFA/GAID) are properly included in S2S requests
- Acknowledgement Failures: Verify backend receipt validation completes before acknowledgement
Support Resources: For troubleshooting assistance, contact Singular Support with SDK version, platform details, sample event payloads, and Singular dashboard screenshots showing the issue.