Your app

Android: Google Play Billing with Kotlin

Report each Play purchase token to Vend16. We check it with Google and acknowledge it straight away, so Google’s three-day auto-refund never fires. You consume consumables once we’ve answered.

1Get your public key

Create a free account, verify your email, and the setup steps ask for your app’s name, iOS bundle ID and Android package name. You get a public key (pk_…, it goes in the app) and a secret key (sk_…, only for a server, if you have one). Both are shown once, so copy them.

2Add it to your app

Use Google Play Billing (version 7 or later) as you normally would. When a purchase comes back, report it before you consume it:

Vend16.kt
import com.android.billingclient.api.Purchase
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder

object Vend16 {
    private const val KEY = "pk_your_public_key" // public key, safe in the app
    private const val BASE = "https://vend16.com"

    enum class Outcome { FINISH, FINISH_UNLOCK_NOTHING, TRY_LATER }

    /** productType: "consumable", "non_consumable" or "subscription". Google does not say. */
    suspend fun report(p: Purchase, userId: String, productType: String,
                       priceMicros: Long? = null, currency: String? = null): Outcome =
        withContext(Dispatchers.IO) {
            val body = JSONObject()
                .put("platform", "android").put("token", p.purchaseToken)
                .put("productId", p.products.first()).put("productType", productType)
                .put("appUserId", userId)
            if (priceMicros != null && currency != null)
                body.put("price", JSONObject().put("micros", priceMicros).put("currency", currency))
            val c = URL("$BASE/v1/receipts").openConnection() as HttpURLConnection
            try {
                c.requestMethod = "POST"
                c.doOutput = true
                c.setRequestProperty("Authorization", "Bearer $KEY")
                c.setRequestProperty("Content-Type", "application/json")
                c.outputStream.use { it.write(body.toString().toByteArray()) }
                when (c.responseCode) {
                    200 -> {
                        val status = JSONObject(c.inputStream.bufferedReader().readText()).optString("status")
                        if (status in setOf("recorded", "duplicate", "revoked")) Outcome.FINISH else Outcome.TRY_LATER
                    }
                    422 -> Outcome.FINISH_UNLOCK_NOTHING
                    else -> Outcome.TRY_LATER
                }
            } catch (e: IOException) {
                Outcome.TRY_LATER
            } finally {
                c.disconnect()
            }
        }

    /** What this user has paid for, as Vend16 sees it. */
    suspend fun activeProducts(userId: String): Set<String> = withContext(Dispatchers.IO) {
        val c = URL("$BASE/v1/subscribers/" + URLEncoder.encode(userId, "UTF-8")).openConnection() as HttpURLConnection
        try {
            c.setRequestProperty("Authorization", "Bearer $KEY")
            val ids = JSONObject(c.inputStream.bufferedReader().readText()).getJSONArray("active_product_ids")
            (0 until ids.length()).map { ids.getString(it) }.toSet()
        } finally {
            c.disconnect()
        }
    }
}
Billing.kt
// Google does not say whether a product is consumable, so your app does.
val productTypes = mapOf("coins_100" to "consumable", "pro_monthly" to "subscription")

// From onPurchasesUpdated, and from queryPurchasesAsync at every launch
// (purchases that were not finished last time come back there).
suspend fun handle(billing: BillingClient, p: Purchase, userId: String) {
    if (p.purchaseState != Purchase.PurchaseState.PURCHASED) return // pending: wait
    val type = productTypes[p.products.first()] ?: "consumable"
    when (Vend16.report(p, userId, type)) {
        Vend16.Outcome.FINISH -> if (type == "consumable") {
            // Vend16 has already acknowledged it with Google. Consume it so it can be bought again.
            billing.consumePurchase(ConsumeParams.newBuilder().setPurchaseToken(p.purchaseToken).build())
        } // Subscriptions and non-consumables: nothing to do, Vend16 acknowledged them.
        Vend16.Outcome.FINISH_UNLOCK_NOTHING -> Unit // not a real purchase: unlock nothing
        Vend16.Outcome.TRY_LATER -> Unit // it comes back from queryPurchasesAsync: report it again then
    }
    val active = Vend16.activeProducts(userId) // unlock from this
}

Pass the product’s price too, from ProductDetails (priceAmountMicros and priceCurrencyCode), so your revenue figures count it. Google doesn’t report prices.

3Keep the money safe

Three rules that protect your money
  1. Tell the store a purchase is finished only after Vend16 answers recorded, duplicate or revoked. If the answer is pending, or Vend16 can’t be reached (a 502 or no network), leave it unfinished. The store hands it back on the next launch and you try again.
  2. If Vend16 answers 422 invalid_receipt, finish it and unlock nothing. The store says it isn’t a real purchase.
  3. Unlock from active_product_ids in GET /v1/subscribers/:appUserId, never from a flag on the phone. Check it at launch and after every purchase.

Use a stable user ID from your own sign-in (or a UUID you keep on the device), never an email address.

4Connect the stores

Your code is done. The stores need to know about your products and about Vend16:

Stuck? Email hello@spruik.co and a real person replies.