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:
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()
}
}
}// 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
- Tell the store a purchase is finished only after Vend16 answers
recorded,duplicateorrevoked. If the answer ispending, or Vend16 can’t be reached (a502or no network), leave it unfinished. The store hands it back on the next launch and you try again. - If Vend16 answers
422 invalid_receipt, finish it and unlock nothing. The store says it isn’t a real purchase. - Unlock from
active_product_idsinGET /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.