Resposta rápida: para implementar Google Play Billing com Kotlin e Jetpack Compose, trate a cobrança como um fluxo distribuído, não como um botão que “desbloqueia premium”. O app conecta ao BillingClient, consulta ProductDetails, abre o checkout com launchBillingFlow, recebe a Purchase, envia o token ao backend para validação, entrega o benefício e confirma a compra com acknowledgePurchase quando necessário. Na inicialização e no retorno ao app, consulte compras existentes para restaurar acesso. Para assinaturas, o servidor precisa acompanhar renovação, cancelamento, período de carência e expiração; confiar apenas no estado salvo no celular permite fraude e perde mudanças ocorridas fora do app.
A Google Play Billing Library é o caminho oficial para vender produtos digitais em apps distribuídos pela Google Play: versão premium, moedas virtuais, recursos extras e assinaturas. Ela não substitui um gateway comum para produtos físicos, delivery ou serviços consumidos fora do app; as políticas da loja e a categoria do produto determinam qual fluxo usar.
Este guia monta uma base moderna em Kotlin: arquitetura, catálogo, conexão, ProductDetails, compra com Compose, restauração, confirmação, backend e testes. Ele combina bem com MVVM no Android, segurança de dados locais e autenticação biométrica para proteger ações sensíveis sem confundir biometria com validação de pagamento.
O que você pode vender pela Google Play
Antes do código, modele o produto corretamente no Play Console. Os dois grupos principais são:
| Tipo | Exemplo | Estado esperado |
|---|---|---|
| Produto de compra única | remover anúncios, pacote de temas, créditos | permanente ou consumível |
| Assinatura | plano mensal de curso, backup premium, recursos Pro | renovável, com planos e ofertas |
Um produto de compra única pode ser não consumível, como “versão premium para sempre”, ou consumível, como um pacote de moedas. O item consumível precisa ser consumido depois da concessão para poder ser comprado novamente. Uma assinatura exige tratamento de ciclo de vida: renovação, cancelamento, pausa, troca de plano, carência e expiração.
Não codifique preço, moeda ou texto promocional no APK. Consulte ProductDetails e mostre os dados devolvidos pela Play. Assim o usuário vê o valor localizado para o país e a oferta elegível para a conta.
Arquitetura recomendada
Uma implementação sustentável separa responsabilidades:
- Play Console: define IDs, planos-base e ofertas.
- BillingClient: conversa com a Play Store no aparelho.
- BillingRepository: transforma callbacks em estado Kotlin e eventos de domínio.
- ViewModel: expõe
StateFlowpara a tela Compose. - Backend: valida token, registra direito de acesso e processa mudanças posteriores.
- UI: apresenta preço, inicia checkout e mostra estados; não decide sozinha se o usuário é premium.
O fluxo seguro fica assim:
Compose -> ViewModel -> BillingRepository -> Google Play
|
v
token da compra
|
v
Backend valida e concede
|
v
app recebe entitlement atual
Entitlement é o direito efetivo do usuário: “premium ativo”, “100 créditos disponíveis” ou “assinatura válida até determinada condição”. Compra e entitlement não são sinônimos. Uma compra pode estar pendente, cancelada, reembolsada ou ainda não validada.
Dependência e configuração inicial
Use a versão estável da Billing Library exigida atualmente pela Google Play. Como a política de versões muda ao longo do tempo, centralize o número no Version Catalog e confira as notas oficiais antes de publicar:
# gradle/libs.versions.toml
[versions]
playBilling = "<versao-estavel-atual>"
[libraries]
play-billing-ktx = {
module = "com.android.billingclient:billing-ktx",
version.ref = "playBilling"
}
// build.gradle.kts do módulo app
dependencies {
implementation(libs.play.billing.ktx)
}
O Billing não faz parte do Compose BOM. O BOM alinha bibliotecas Compose; a Billing Library mantém seu próprio ciclo de versão.
Defina IDs estáveis em um único lugar. Eles devem coincidir exatamente com o Play Console:
object BillingProducts {
const val PREMIUM_LIFETIME = "premium_lifetime"
const val PRO_SUBSCRIPTION = "pro_subscription"
}
Evite espalhar strings por composables, fragments e workers. Trocar um ID publicado é uma migração de produto, não uma refatoração comum.
Criando e conectando o BillingClient
O repositório implementa PurchasesUpdatedListener, porque o resultado da compra pode chegar depois que o checkout foi aberto:
class PlayBillingRepository(
private val context: Context,
private val backend: BillingBackend,
private val appScope: CoroutineScope,
) : PurchasesUpdatedListener {
private val _state = MutableStateFlow(BillingUiState())
val state: StateFlow<BillingUiState> = _state.asStateFlow()
private val billingClient = BillingClient.newBuilder(context)
.setListener(this)
.enablePendingPurchases()
.build()
fun connect() {
if (billingClient.isReady) return
billingClient.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(result: BillingResult) {
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
appScope.launch {
loadProducts()
restorePurchases()
}
} else {
_state.update {
it.copy(error = "Não foi possível conectar à Google Play.")
}
}
}
override fun onBillingServiceDisconnected() {
_state.update { it.copy(connected = false) }
// Agende retry com backoff enquanto a tela/feature estiver ativa.
}
})
}
}
Algumas regras importantes:
- conexão pode cair; implemente retry controlado, sem loop agressivo;
- não crie um
BillingClienta cada recomposição; - mantenha o client no escopo da aplicação ou de um repository de longa vida;
- encerre a conexão quando o dono real do objeto for destruído;
- não presuma que Play Store e conta Google estão disponíveis em todo aparelho.
Um estado simples para a UI:
data class BillingUiState(
val connected: Boolean = false,
val loading: Boolean = true,
val products: List<ProductDetails> = emptyList(),
val premiumActive: Boolean = false,
val purchaseInProgress: Boolean = false,
val message: String? = null,
val error: String? = null,
)
Consultando ProductDetails
Consulte separadamente produtos de compra única (INAPP) e assinaturas (SUBS):
private suspend fun queryProductDetails(
productIds: List<String>,
productType: String,
): List<ProductDetails> {
val products = productIds.map { id ->
QueryProductDetailsParams.Product.newBuilder()
.setProductId(id)
.setProductType(productType)
.build()
}
val params = QueryProductDetailsParams.newBuilder()
.setProductList(products)
.build()
val result = billingClient.queryProductDetails(params)
if (result.billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
throw IllegalStateException(result.billingResult.debugMessage)
}
return result.productDetailsList.orEmpty()
}
private suspend fun loadProducts() {
_state.update { it.copy(loading = true, error = null) }
runCatching {
val oneTime = queryProductDetails(
productIds = listOf(BillingProducts.PREMIUM_LIFETIME),
productType = BillingClient.ProductType.INAPP,
)
val subscriptions = queryProductDetails(
productIds = listOf(BillingProducts.PRO_SUBSCRIPTION),
productType = BillingClient.ProductType.SUBS,
)
oneTime + subscriptions
}.onSuccess { products ->
_state.update {
it.copy(connected = true, loading = false, products = products)
}
}.onFailure { cause ->
_state.update {
it.copy(loading = false, error = cause.message ?: "Catálogo indisponível")
}
}
}
Se um produto não aparece, confira antes de culpar o Kotlin:
- ID e tipo estão corretos?
- Produto/plano está ativo no Play Console?
- A build foi instalada por uma faixa de teste da Play?
- A conta do aparelho é testadora e elegível?
- O app usa o mesmo
applicationIde assinatura esperados? - A oferta está disponível no país e para aquela conta?
Escolhendo uma oferta de assinatura
Uma assinatura pode ter mais de um plano-base e várias ofertas. ProductDetails.subscriptionOfferDetails contém tokens de oferta; o checkout precisa enviar o token escolhido.
Não pegue cegamente first(). Crie uma regra explícita por tag, plano ou período:
fun ProductDetails.findOfferToken(tag: String): String? {
return subscriptionOfferDetails
?.firstOrNull { offer -> tag in offer.offerTags }
?.offerToken
}
A seleção também deve respeitar elegibilidade devolvida pela Play. Se a oferta introdutória não vier para aquela conta, mostre o plano normal. Nunca prometa “7 dias grátis” com texto fixo quando a Play não retornou a fase gratuita.
Para exibir o preço, percorra pricingPhases.pricingPhaseList. Uma oferta pode ter fase gratuita, preço promocional e preço recorrente. A tela deve deixar claro o que é cobrado agora e depois.
Abrindo o checkout
O checkout precisa de uma Activity visível. Para compra única:
fun launchOneTimePurchase(
activity: Activity,
details: ProductDetails,
) {
val productParams = BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(details)
.build()
val flowParams = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(listOf(productParams))
.build()
val result = billingClient.launchBillingFlow(activity, flowParams)
if (result.responseCode != BillingClient.BillingResponseCode.OK) {
_state.update { it.copy(error = result.debugMessage) }
} else {
_state.update { it.copy(purchaseInProgress = true) }
}
}
Para assinatura, inclua o offerToken:
fun launchSubscription(
activity: Activity,
details: ProductDetails,
offerToken: String,
) {
val productParams = BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(details)
.setOfferToken(offerToken)
.build()
val params = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(listOf(productParams))
.build()
billingClient.launchBillingFlow(activity, params)
}
O retorno imediato de launchBillingFlow só diz se a Play conseguiu abrir o fluxo. O resultado final chega em onPurchasesUpdated ou em uma consulta posterior.
Recebendo e processando compras
Trate todos os códigos relevantes e nunca conceda o benefício só porque uma Purchase existe:
override fun onPurchasesUpdated(
billingResult: BillingResult,
purchases: MutableList<Purchase>?,
) {
when (billingResult.responseCode) {
BillingClient.BillingResponseCode.OK -> {
purchases.orEmpty().forEach { purchase ->
appScope.launch { processPurchase(purchase) }
}
}
BillingClient.BillingResponseCode.USER_CANCELED -> {
_state.update {
it.copy(purchaseInProgress = false, message = "Compra cancelada.")
}
}
BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED -> {
appScope.launch { restorePurchases() }
}
else -> {
_state.update {
it.copy(
purchaseInProgress = false,
error = billingResult.debugMessage.ifBlank {
"Não foi possível concluir a compra."
},
)
}
}
}
}
O processamento deve diferenciar PENDING de PURCHASED:
private suspend fun processPurchase(purchase: Purchase) {
when (purchase.purchaseState) {
Purchase.PurchaseState.PENDING -> {
_state.update {
it.copy(
purchaseInProgress = false,
message = "Pagamento pendente. O acesso será liberado após a confirmação.",
)
}
}
Purchase.PurchaseState.PURCHASED -> {
val verification = backend.verifyPurchase(
purchaseToken = purchase.purchaseToken,
products = purchase.products,
)
if (!verification.valid) {
_state.update {
it.copy(purchaseInProgress = false, error = "Compra não validada.")
}
return
}
if (!purchase.isAcknowledged) {
acknowledge(purchase.purchaseToken)
}
_state.update {
it.copy(
premiumActive = verification.premiumActive,
purchaseInProgress = false,
message = "Compra confirmada.",
)
}
}
}
}
Compra pendente não libera conteúdo. Alguns meios de pagamento levam tempo para concluir. A UI deve explicar que o pedido está em análise e restaurar o direito quando o estado mudar para comprado.
Confirmar ou consumir?
Após validar e entregar o benefício, uma compra precisa ser confirmada ou consumida, conforme o produto.
Produto não consumível e assinatura usam confirmação:
private suspend fun acknowledge(purchaseToken: String) {
val params = AcknowledgePurchaseParams.newBuilder()
.setPurchaseToken(purchaseToken)
.build()
val result = billingClient.acknowledgePurchase(params)
check(result.responseCode == BillingClient.BillingResponseCode.OK) {
result.debugMessage
}
}
Produto consumível usa consumeAsync/a operação suspensa equivalente da versão adotada. O momento correto é depois de o backend registrar a concessão de créditos de forma idempotente. Se o app consome primeiro e falha antes de registrar, o usuário paga e não recebe. Se concede duas vezes ao repetir uma requisição, você cria um problema financeiro.
Idempotência significa que processar o mesmo purchaseToken novamente produz o mesmo resultado, sem duplicar benefício.
Validação no backend
A validação local não é suficiente para um produto com valor real. O APK pode ser modificado, respostas podem ser simuladas e estado local pode ser alterado. O backend deve:
- autenticar o usuário do seu app;
- receber
purchaseToken, package name e produto esperado; - consultar a API de desenvolvedor da Google Play com credencial de serviço guardada no servidor;
- verificar produto, estado, conta vinculada e contexto da compra;
- persistir o token com restrição de unicidade;
- conceder ou atualizar entitlement em transação;
- devolver ao app o estado atual, não apenas “HTTP 200”.
Nunca coloque credencial de serviço da Play dentro do APK. O aplicativo envia o token ao seu backend; o backend conversa com a API da Google.
Exemplo de contrato Ktor simplificado:
@Serializable
data class VerifyPurchaseRequest(
val purchaseToken: String,
val productIds: List<String>,
)
@Serializable
data class EntitlementResponse(
val valid: Boolean,
val premiumActive: Boolean,
val reason: String? = null,
)
post("/billing/google-play/verify") {
val user = call.requireAuthenticatedUser()
val request = call.receive<VerifyPurchaseRequest>()
val entitlement = billingService.verifyAndGrant(
userId = user.id,
token = request.purchaseToken,
expectedProducts = request.productIds,
)
call.respond(entitlement)
}
Se o seu backend usa Kotlin, o guia de APIs REST com Ktor e o de autenticação JWT no Ktor ajudam a estruturar esse endpoint. Use kotlinx.serialization para contratos, mas nunca registre o token completo em logs.
Assinaturas exigem eventos do servidor
Consultar a compra só quando o usuário abre a tela não cobre o ciclo completo. Cancelamentos, renovações, reembolsos e mudanças podem ocorrer sem o app estar aberto. Uma arquitetura de assinatura deve integrar notificações em tempo real da Google Play e, ao receber um evento, consultar a API oficial antes de alterar o entitlement.
O evento é um sinal para reconsultar, não a fonte final de verdade. O backend precisa lidar com entrega duplicada e fora de ordem. Guarde identificadores, datas e a última versão conhecida do estado.
Para a UI, pense em estados de domínio:
ACTIVE: acesso normal;PENDING: pagamento ainda não concluído;GRACE_PERIOD: comunique problema de pagamento sem cortar cedo demais;ON_HOLDou equivalente: acesso conforme regra de negócio e estado oficial;CANCELED_BUT_ACTIVE: cancelada, porém válida até o fim do período;EXPIRED: benefício encerrado;REVOKED: compra anulada/reembolsada conforme confirmação do servidor.
Não invente esses estados a partir de um boolean local. Derive o entitlement da resposta validada e das regras atuais da Play.
Restaurando compras
A restauração é necessária quando o app reinstala, troca de aparelho, perde o cache ou recebe atualização de assinatura. Consulte compras atuais ao conectar e quando a Activity volta ao primeiro plano após um checkout externo:
private suspend fun restorePurchases() {
val inApp = billingClient.queryPurchasesAsync(
QueryPurchasesParams.newBuilder()
.setProductType(BillingClient.ProductType.INAPP)
.build()
)
val subs = billingClient.queryPurchasesAsync(
QueryPurchasesParams.newBuilder()
.setProductType(BillingClient.ProductType.SUBS)
.build()
)
val purchases = inApp.purchasesList + subs.purchasesList
purchases.forEach { processPurchase(it) }
// A fonte final para "premium ativo" deve continuar sendo o backend.
val entitlement = backend.getCurrentEntitlement()
_state.update { it.copy(premiumActive = entitlement.premiumActive) }
}
Ofereça um botão “Restaurar compras” para suporte e transparência, mas a restauração também deve ocorrer automaticamente. O botão não pode ser o único caminho para recuperar acesso pago.
Integrando com Jetpack Compose
O ViewModel expõe estado e métodos sem entregar o BillingClient à tela:
class PaywallViewModel(
private val billing: PlayBillingRepository,
) : ViewModel() {
val state = billing.state.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = BillingUiState(),
)
fun connect() = billing.connect()
fun restore() = viewModelScope.launch { billing.restoreFromUserAction() }
}
Tela resumida:
@Composable
fun PaywallScreen(
viewModel: PaywallViewModel,
onBuy: (ProductDetails) -> Unit,
) {
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
viewModel.connect()
}
when {
state.loading -> CircularProgressIndicator()
state.premiumActive -> Text("Seu plano Pro está ativo.")
else -> Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Escolha seu plano", style = MaterialTheme.typography.headlineMedium)
state.products.forEach { product ->
ProductCard(
title = product.name,
description = product.description,
price = product.displayPrice(),
enabled = !state.purchaseInProgress,
onClick = { onBuy(product) },
)
}
TextButton(onClick = viewModel::restore) {
Text("Restaurar compras")
}
state.message?.let { Text(it) }
state.error?.let {
Text(it, color = MaterialTheme.colorScheme.error)
}
}
}
}
A Activity pode converter o evento da UI em launchBillingFlow, porque a API exige uma Activity. Em projetos com Navigation Compose, passe um callback da rota hospedeira. Não guarde uma Activity no ViewModel ou repository singleton: isso vaza contexto e mistura UI com infraestrutura.
Segurança e privacidade
Uma implementação de pagamento coleta identificadores sensíveis, mas não precisa registrar tudo. Práticas essenciais:
- envie token somente por HTTPS;
- não grave token completo em analytics, Crashlytics ou logs;
- associe compra ao usuário autenticado e, quando aplicável, use identificadores ofuscados suportados pelo fluxo;
- valide package, produto e token no servidor;
- armazene o mínimo necessário para auditoria e idempotência;
- não confie em
SharedPreferences/DataStore para autorização premium; - use biometria apenas para confirmar uma ação local sensível, nunca como prova de pagamento;
- trate replay: o mesmo token não pode conceder benefício a duas contas.
Para apps offline-first, o backend continua sendo a autoridade. Você pode manter um cache de entitlement com validade curta para não bloquear a UI durante uma queda de rede, seguindo o desenho de Android offline-first, mas precisa definir quando o cache expira e como evitar acesso indefinido após revogação.
Testando sem cobrar de verdade
Não valide Billing apenas com um APK instalado via adb. Use as ferramentas de teste da Play:
- configure testadores de licença no Play Console;
- publique em teste interno ou faixa apropriada;
- instale a build pela Play com a conta testadora;
- teste sucesso, cancelamento, pendência, item já possuído e indisponibilidade;
- confira confirmação/consumo e chamadas do backend;
- repita em conta sem oferta introdutória elegível;
- teste reinstalação e restauração;
- simule retry do endpoint para provar idempotência.
Tenha fakes para teste unitário. BillingClient é infraestrutura Android e não deve dominar o teste do ViewModel:
interface BillingGateway {
val state: StateFlow<BillingUiState>
fun connect()
suspend fun restore()
}
class FakeBillingGateway : BillingGateway {
override val state = MutableStateFlow(
BillingUiState(
loading = false,
connected = true,
premiumActive = false,
)
)
override fun connect() = Unit
override suspend fun restore() = Unit
}
Nos testes de UI, valide estados da paywall, loading, erro e premium ativo. O checkout real fica para teste integrado em faixa da Play. O guia de testes Android com Compose e Maestro complementa o fluxo visual.
Erros comuns
- Liberar premium no clique do botão. O clique só inicia o checkout.
- Confiar no callback sem backend. Um cliente modificado pode falsificar o caminho local.
- Ignorar
PENDING. Pagamento pendente não equivale a compra concluída. - Não confirmar a compra. A Play pode reverter compras não reconhecidas dentro da janela prevista pela política.
- Consumir antes de conceder créditos. Uma falha intermediária deixa o usuário sem compra e sem benefício.
- Conceder novamente em todo retry. Falta de idempotência duplica créditos.
- Usar
first()em ofertas. A primeira oferta não é necessariamente a desejada ou elegível. - Fixar preço no layout. O valor localizado deve vir de
ProductDetails. - Não restaurar na inicialização. Reinstalação parece “perder” a compra.
- Guardar Activity em singleton. Cria vazamento e dependência de lifecycle.
- Logar token de compra. Transforma observabilidade em risco de segurança.
- Tratar cancelamento como expiração imediata. Assinatura cancelada pode continuar ativa até o fim do período pago.
Checklist de produção
- Produtos, planos e ofertas ativos no Play Console.
- Billing Library na versão estável aceita pela política atual da loja.
BillingClientúnico por escopo e reconexão com backoff.- Catálogo consultado via
ProductDetails; preços não hardcoded. PURCHASEDePENDINGtratados separadamente.- Token validado no backend antes da concessão definitiva.
- Endpoint idempotente e token único por compra.
acknowledgePurchaseou consumo no momento correto.- Compras restauradas automaticamente e por ação manual.
- Assinaturas sincronizadas por eventos de servidor + consulta oficial.
- Tokens ausentes de logs e analytics.
- Testes internos cobrindo sucesso, cancelamento, pendência e reinstalação.
- Runbook de suporte para “paguei e não liberou”.
Perguntas frequentes
Posso liberar premium apenas com a validação no celular?
Para protótipo descartável até pode parecer suficiente, mas não é uma arquitetura segura para produção. O backend deve validar o token na Google Play, registrar a compra de modo idempotente e responder com o entitlement atual.
Quando devo chamar acknowledgePurchase?
Depois de confirmar que a compra está em estado comprado, validar o token e conceder o benefício com sucesso. Para consumíveis, use o fluxo de consumo depois de registrar a concessão. Não confirme uma compra pendente.
Como mostrar o preço da assinatura no Compose?
Use as fases de preço em subscriptionOfferDetails para a oferta selecionada. Mostre preço, período, fase grátis/promocional e cobrança recorrente de maneira clara. Não escreva valores fixos no composable.
O usuário cancelou. Devo remover o acesso na hora?
Normalmente, não. Uma assinatura cancelada pode continuar válida até o fim do período já pago. O backend deve consultar o estado oficial e calcular o entitlement; “cancelada” e “expirada” são situações diferentes.
Preciso de um botão Restaurar compras no Android?
É uma boa prática de suporte e transparência, mas o app também deve restaurar automaticamente ao conectar e consultar o backend. O usuário não deveria descobrir sozinho como recuperar um direito pago.
Google Play Billing serve para vender produto físico?
O Billing é voltado a produtos e recursos digitais no app. Produtos físicos e serviços consumidos fora do app seguem outro fluxo de pagamento e as políticas aplicáveis. Confirme a categoria atual nas políticas da Google Play antes de implementar.
Conclusão
Google Play Billing com Kotlin e Jetpack Compose fica confiável quando você separa UI, cliente de cobrança e autoridade de acesso. Compose apresenta o catálogo e inicia o checkout; BillingClient entrega detalhes e compras; o backend valida tokens, mantém idempotência e acompanha o ciclo de vida; o app restaura o entitlement sempre que necessário.
O principal aprendizado é simples: não modele “premium” como um boolean salvo no aparelho. Modele uma compra verificável e um direito de acesso derivado dela. Essa decisão evita fraude, compras perdidas, crédito duplicado e suporte manual quando uma assinatura muda fora do app.
Para continuar, revise arquitetura MVVM, segurança local no Android, App Links para retornos controlados e WorkManager para sincronizações que não dependem de uma tela aberta. Quem busca trabalhar com monetização mobile também pode acompanhar as vagas Kotlin e Android do Kotlin Brasil.