SubscriptionFlow ANDROID SDK Contact Support

SubscriptionFlow Android SDK (1.0.0)

Download OpenAPI specification:Download

Sell Google Play subscriptions with Play Billing and track customers, invoices and entitlements in SubscriptionFlow.

Overview

Android · Play Billing
Google Play subscriptions with SubscriptionFlow

Sell subscriptions with Google Play Billing. SubscriptionFlow tracks every customer, invoice, refund and entitlement. There is no SDK to install: your app makes two HTTPS calls.

App API base URLhttps://<your-subscriptionflow-domain>/api/v1/mobile-stores/client

Requirements: Play Billing Library 8 or later · Kotlin · No SDK to install

How it works

Your backend, the store and SubscriptionFlow each do one part. Your SubscriptionFlow API credentials never leave your server.

1
Your backend issues a token pair

It asks SubscriptionFlow for an account token and client secret for the signed-in customer and hands them to the app.

2
The app buys

The purchase goes through the store, tagged with the account token (as Play Billing's obfuscatedAccountId), so Apple or Google keep it attached to that customer.

3
SubscriptionFlow verifies

The app reports the purchase. SubscriptionFlow checks it with Apple or Google and creates the subscription, a Paid invoice and a payment.

4
The app unlocks

Only then the app acknowledges the purchase and unlocks features from the customer's entitlements, web subscriptions included.

i
From then on, renewals, cancellations, refunds and billing problems reach SubscriptionFlow directly from Apple and Google. SubscriptionFlow never invoices, charges or duns these subscriptions: the store bills them.

Account Setup

Do this once, in your SubscriptionFlow account.

Integrations
Connect the store

Under Settings > Marketplace > Integrations, connect Apple App Store and/or Google Play and set the notification URL in the store console.

Products
Map your products

Map every store product (and Google base plan) to a SubscriptionFlow plan price on the same page.

Entitlements
Define entitlements

On the Entitlements tab, add keys such as pro and tick the plans that grant them.

Backend
Create an API client

For your backend: client-credentials grant, scope access-global-api.

Step 1: Token Pair

Your backend issues one pair per customer with its SubscriptionFlow API token. Never call this from the app. Full reference: Issue an account token.

Request

POST https://<your-subscriptionflow-domain>/api/v1/mobile-stores/account-tokens
Authorization: Bearer <access_token>
Content-Type: application/json

{ "customer_id": "9f1c2e0a-5b7d-4c1e-8a64-0d3b2f7a9e11" }

Response 201

{
  "status": true,
  "data": {
    "account_token": "72b8598b-8706-4a5e-96a2-52abbd0400cb",
    "customer_id": "9f1c2e0a-5b7d-4c1e-8a64-0d3b2f7a9e11",
    "created_at": "2026-11-03T09:00:00+00:00",
    "revoked_at": null,
    "client_secret": "OOkXxkYIfion1fcWJqL1GIR5tdbVqMFI5wlABZafEJKlI8EO",
    "reused": false
  }
}
Secret
The secret is shown once

Store it on your backend. Later calls answer 200 with the same account_token, client_secret: null and reused: true.

Devices
Same pair on every device

Hand the pair to each device of that customer through an endpoint of your own, behind your user login. Keep it in secure storage on the device.

Step 2: Buy

Set the account token as obfuscatedAccountId so Google keeps the purchase attached to the customer.

// Declare the listener before the client: Kotlin initialises properties in order.
private val purchasesListener = PurchasesUpdatedListener { result, purchases ->
    if (result.responseCode == BillingClient.BillingResponseCode.OK) {
        purchases?.filter { it.purchaseState == Purchase.PurchaseState.PURCHASED }
            ?.forEach { report(it) }
    }
}

private val billingClient = BillingClient.newBuilder(context)
    .setListener(purchasesListener)
    .enablePendingPurchases(PendingPurchasesParams.newBuilder().enableOneTimeProducts().build())
    .build()

fun buy(activity: Activity, product: ProductDetails, pair: SFPair) {
    val offer = product.subscriptionOfferDetails?.firstOrNull() ?: return
    val params = BillingFlowParams.newBuilder()
        .setProductDetailsParamsList(listOf(
            BillingFlowParams.ProductDetailsParams.newBuilder()
                .setProductDetails(product)
                .setOfferToken(offer.offerToken)
                .build()
        ))
        .setObfuscatedAccountId(pair.accountToken)   // links the purchase to the customer
        .build()
    billingClient.launchBillingFlow(activity, params)
}

Step 3: Report

Send the purchase token. SubscriptionFlow verifies it with Google and acknowledges the purchase for you when it links it. Full reference: Report a purchase (app).

private val http = OkHttpClient()

fun report(purchase: Purchase) {
    val pair = currentPair()   // from secure storage
    val body = JSONObject()
        .put("store", "google")
        .put("purchase_token", purchase.purchaseToken)
        .toString()
        .toRequestBody("application/json".toMediaType())

    val request = Request.Builder()
        .url("https://<your-subscriptionflow-domain>/api/v1/mobile-stores/client/purchases")
        .header("Accept", "application/json")
        .header("X-SF-Account-Token", pair.accountToken)
        .header("X-SF-Client-Secret", pair.clientSecret)
        .post(body)
        .build()

    http.newCall(request).enqueue(object : Callback {
        override fun onResponse(call: Call, response: Response) {
            response.use {
                when (it.code) {
                    200 -> unlock()                       // linked and acknowledged
                    409, 422 -> showProblem(it.body?.string())
                    401 -> refreshPairAndRetry(purchase)  // once
                    else -> Unit                          // 429 / 5xx: retried on next sweep
                }
            }
        }
        override fun onFailure(call: Call, e: IOException) = Unit  // retried on next sweep
    })
}
!
Report every purchase promptly. Google refunds purchases nobody acknowledges within 3 days. Do not acknowledge in the app: SubscriptionFlow does it once the purchase is linked.

Step 4: Sweep Purchases

Play does not redeliver purchases on its own. When the app starts and whenever it returns to the foreground, ask Play for the subscriptions and report the ones not acknowledged yet.

fun sweep() {
    val params = QueryPurchasesParams.newBuilder()
        .setProductType(BillingClient.ProductType.SUBS)
        .build()
    billingClient.queryPurchasesAsync(params) { result, purchases ->
        if (result.responseCode == BillingClient.BillingResponseCode.OK) {
            purchases.filter { it.purchaseState == Purchase.PurchaseState.PURCHASED && !it.isAcknowledged }
                .forEach { report(it) }
        }
    }
}
i
For a Restore purchases button, report every purchase the query returns, not only unacknowledged ones. Reporting twice never creates duplicates.

Step 5: Entitlements

Unlock features from the entitlements, not from product ids: they also cover web and App Store subscriptions. Full reference: Get my entitlements (app).

Request

GET https://<your-subscriptionflow-domain>/api/v1/mobile-stores/client/entitlements
X-SF-Account-Token: <account_token>
X-SF-Client-Secret: <client_secret>

Response 200

{
  "status": true,
  "data": [
    {
      "key": "pro",
      "name": "Pro",
      "source": "AppStore",
      "subscription_id": "5faf8...",
      "expires_at": "2026-11-28T09:00:00+00:00",
      "grants": [
        { "source": "AppStore", "subscription_id": "5faf8...",
          "status": "Active", "expires_at": "2026-11-28T09:00:00+00:00" }
      ]
    }
  ]
}
i
source is web, AppStore or GooglePlay. expires_at is null while a subscription renews. All times are UTC.

Handling Answers

What the purchase report can answer, and what the app should do.

401 Wrong pair
Refresh the pair once

The token pair is wrong or revoked. Fetch the current pair from your backend once and retry.

409 / 422 Refused
Do not retry

"This purchase cannot be linked to this account", a validation error, a store that is not connected, or a purchase that needs review. Show a message, then complete the purchase.

429 / 5xx / network
Retry later

Rate limit, temporary problem or store outage. Retry with backoff and honour Retry-After; the next sweep reports it again.

Claimable Purchases

Anyone using your app holds a token, so SubscriptionFlow only links purchases that belong to the caller.

  • A purchase tagged with an account token links only to that token's customer, even after the token is revoked.
  • An untagged Google purchase links: only the buyer's device knows its purchase token.
  • An untagged Apple purchase links only from a signed StoreKit 2 transaction, and only when Let the app SDK claim untagged purchases is on (off by default).
  • Register any other untagged purchase from your backend with POST /api/v1/mobile-stores/purchases.
  • Every refusal gets the same answer, so the API never reveals whether a purchase exists.

Endpoints & Limits

The app endpoints have their own limits and never use your REST API quota. Over a limit the answer is 429 with Retry-After.

Call Endpoint Limit
Report a purchase POST https://<your-subscriptionflow-domain>/api/v1/mobile-stores/client/purchases 30 per minute per token, 1000 per minute per account
Read entitlements GET https://<your-subscriptionflow-domain>/api/v1/mobile-stores/client/entitlements 60 per minute per token, 3000 per minute per account
Wrong token or secret Headers X-SF-Account-Token, X-SF-Client-Secret 30 per minute per device IP address
Issue or rotate a pair (backend) POST https://<your-subscriptionflow-domain>/api/v1/mobile-stores/account-tokens Uses your REST API token

Testing

How do I test end to end?

Use a SubscriptionFlow account whose Google Play connection is set to Sandbox, upload the build to the internal testing track and buy with a licence tester account.

What happens with licence-tester purchases on a live account?

On a Production connection they are verified and answered with "sandbox": true and the plan's entitlements, but nothing is recorded.

How do renewals, holds and refunds reach SubscriptionFlow?

Through Google's real-time developer notifications (Pub/Sub). Set them up with the notification URL from the Google Play integration page.

Why can I not buy in my debug build?

Google Play only sells to builds of an app that has been uploaded to a testing track, signed in with a tester account.

Need Help?

Open a support ticket with the store, the environment and the last 6 characters of the transaction id or purchase token.

Token Management

Issue, reuse, rotate and revoke per-customer account tokens. See Account Tokens for the concepts.

Issue an account token

Issues an account token and client secret for a customer, or returns the customer's active token.

  • New token: 201, client_secret set (shown once), reused: false.
  • Active token reused: 200, client_secret: null, reused: true.
  • rotate: true: revokes every active token of the customer and issues a new pair (201).

created_at / revoked_at are ISO 8601 with the server's UTC offset.

Authorizations:
backendOAuth
Request Body schema: application/json
required
customer_id
required
string <= 64 characters

SubscriptionFlow customer ID.

rotate
boolean
Default: false

Revoke the customer's active tokens and issue a new pair.

Responses

Request samples

Content type
application/json
Example
{
  • "customer_id": "9f1c2e0a-5b7d-4c1e-8a64-0d3b2f7a9e11"
}

Response samples

Content type
application/json
{
  • "status": true,
  • "data": {
    }
}

App Purchases

The app (or the Flutter SDK) reports a store purchase right after the store completes it. The SDK completes the store transaction only after a 200.

Report a purchase (app)

Reports a store purchase from the app. The customer is always the token's customer: a customer_id in the body is ignored. SubscriptionFlow verifies the purchase with Apple or Google, then links and mirrors it.

Ownership rules. Anyone using your app holds a token, so the app may only claim purchases that are its own:

  • Tagged purchase (the store reports an account token): links only when the tag is one of this customer's issued tokens (revoked ones included) or, with Require SubscriptionFlow-issued account tokens off, this customer's ID. A tag naming anyone else, or nobody, is refused.
  • Untagged purchase already linked: only its own customer may re-sync it.
  • Untagged Google purchase: links (only the buyer's device knows its purchase token).
  • Untagged Apple purchase: links only from a signed_transaction and only when Let the app SDK claim untagged purchases is on in the Apple settings (off by default). A bare Apple transaction_id never claims an untagged purchase: register those from your backend.

Every refusal answers the same 409, so the answer never reveals whether a purchase exists. Refusals and untagged claims are logged with customer, store reference and IP address.

Sandbox. On a Production connection, a sandbox / TestFlight / App Review / licence-tester purchase is verified but not recorded, and answers 200 with "sandbox": true (see Sandbox and App Review).

Retries. Retry 502, 503 and 429 (honour Retry-After); treat 409 and 422 as final. Reporting the same purchase again is safe.

Rate limits: 30 per minute per token, 1000 per minute per account.

Authorizations:
(accountTokenclientSecret)
Request Body schema: application/json
required
store
required
string (Store)
Enum: "apple" "google"

apple (App Store) or google (Google Play).

transaction_id
string or null <= 64 characters ^[0-9]+$

Apple only. Any transaction ID of the subscription (digits). Required for Apple unless signed_transaction is sent.

signed_transaction
string or null <= 20000 characters

Apple only. StoreKit 2 signed transaction (JWS). Used instead of transaction_id when both are sent.

purchase_token
string or null <= 4096 characters

Google only, required. The Play Billing purchase token.

Responses

Request samples

Content type
application/json
Example
{
  • "store": "apple",
  • "signed_transaction": "eyJhbGciOiJFUzI1NiIsIng1YyI6WyJNSUlFTURDQ0E3YWdBd0lCQWdJUWZUbGZk..."
}

Response samples

Content type
application/json
Example
{
  • "status": true,
  • "data": {
    }
}

App Entitlements

The app reads its own customer's entitlements with its account token pair. Same data as the backend Entitlements endpoint.

Get my entitlements (app)

Returns the entitlements of the token's customer. Same shape and rules as GET /mobile-stores/customers/{customer}/entitlements. Sandbox (App Review / TestFlight) entitlements are never included: they live only in the app session.

Rate limits: 60 per minute per token, 3000 per minute per account.

Authorizations:
(accountTokenclientSecret)

Responses

Response samples

Content type
application/json
{
  • "status": true,
  • "data": [
    ]
}