Android – Payments with Stripe PaymentSheet

September 25, 20269 min readUpdated 10/11/2026

Taking money is the one feature where a bug costs real money, so it is worth being precise about who decides what. This lesson walks the app's checkout from "Place order" to a receipt that says PAID, using Stripe's PaymentSheet — and shows the one place the Android design has to differ from the iOS one, because of the lifecycle rules from lesson 5.

Rule one: the device never decides the price

The app shows totals everywhere: the builder, the cart, checkout. Every one of them is a preview. The order request the app sends contains no money at all:

data class OrderCreateRequest(
    val orderType: OrderType,
    val customerName: String,
    /** Ignored by the server when a token is present — the account's email wins. */
    val guestEmail: String?,
    val phone: String?,
    val addressLine1: String?,
    val addressLine2: String?,
    val city: String?,
    val state: String?,
    val postalCode: String?,
    val items: List<LineItemRequest>,
)

Identifiers and quantities. The server looks up every price in its own database, computes the total, and creates a Stripe PaymentIntent for exactly that amount. A patched APK that sends "total": 0.01 changes nothing, because there is no total field to send. That is why the app can use Double for money (lesson 2): it is never the authority.

Rule two: order first, then pay

Checkout is two steps, and the second exists only once the server says so:

sealed interface CheckoutStep {
    data object CollectingDetails : CheckoutStep

    /** The order exists server-side and is reserved; only the payment remains. */
    data class AwaitingPayment(val created: OrderCreateResponse) : CheckoutStep
}

"Place order" validates the form and creates the order. The response carries the order — priced by the server — and a client secret: a token that lets this device confirm that one PaymentIntent and nothing else. From then on, the screen shows the server's figures, not its own preview, because they are the ones being charged.

Setting Stripe up

The Stripe SDK needs the account's publishable key, once, at startup:

apiConfig.stripePublishableKey?.let { PaymentConfiguration.init(this, it) }

A publishable key is public by design: it identifies the account and can only create and confirm intents, never charge on its own. It lives in local.properties and reaches the app through BuildConfig. The secret key never comes near the app — it is on the server. If a key starting sk_ ever appears in an Android project, something has gone badly wrong.

Why the ViewModel cannot hold the sheet

The iOS version of this app hides Stripe behind a protocol its view model calls directly:

protocol PaymentGateway {
    /// Opens the payment sheet and returns once the customer is done with it.
    func pay(_ request: PaymentRequest) async -> PaymentOutcome
    /// Collects a card against a SetupIntent WITHOUT charging it, for the profile screen.
    func saveCard(setupIntentClientSecret: String) async -> CardSetupOutcome
    /// False when no publishable key is configured — the UI then explains, rather than failing at
    /// the moment of tapping "Pay".
    var isReady: Bool { get }
}

The view model awaits pay() and gets an outcome. It is a clean design, and on Android it is a memory leak. Stripe's PaymentSheet opens its own Activity and registers for that Activity's result with the current Activity. A ViewModel outlives that Activity — that is its whole purpose. After the first rotation, a ViewModel holding a PaymentSheet holds a destroyed Activity, which can never be garbage-collected, and a result that would be delivered to it goes nowhere.

So the responsibilities split along lifetimes. The ViewModel, which survives rotation, decides that a payment should start and handles the outcome. The screen, which lives exactly as long as the Activity, owns the sheet.

The screen's half

fun rememberPaymentLauncher(onOutcome: (PaymentOutcome) -> Unit): PaymentLauncher {
    // The sheet is registered once; this keeps it calling the LATEST lambda, not the first one.
    val currentOnOutcome by rememberUpdatedState(onOutcome)

    /*
     * `build()` here is the COMPOSABLE overload: it registers the sheet's activity-result
     * launcher with the current Activity and remembers it across recompositions — and, because
     * it is part of composition, registers again on the new Activity after a rotation.
     */
    val sheet = PaymentSheet.Builder { result ->
        currentOnOutcome(
            when (result) {
                is PaymentSheetResult.Completed -> PaymentOutcome.Succeeded
                is PaymentSheetResult.Canceled -> PaymentOutcome.Cancelled
                is PaymentSheetResult.Failed ->
                    PaymentOutcome.Failed(result.error.localizedMessage ?: "The payment did not go through.")
            },
        )
    }.build()
    return remember(sheet) { PaymentLauncher(sheet) }
}

PaymentSheet.Builder(…).build() is a composable overload: it registers the sheet's result launcher with the current Activity and remembers it, and because it is part of composition, it registers again with the new Activity after a rotation. (The older rememberPaymentSheet does the same and is deprecated in Stripe 23.) Stripe's three results become the app's own PaymentOutcome, so nothing above this file imports Stripe.

rememberUpdatedState handles a subtle problem: the sheet is registered once, but the onOutcome lambda might change between recompositions. Reading it through rememberUpdatedState means the result always goes to the latest lambda rather than the one captured at registration.

fun present(request: PaymentSheetRequest) {
    val configuration = PaymentSheet.Configuration.Builder(merchantDisplayName = "StayHub Pizza")
        .defaultBillingDetails(
            PaymentSheet.BillingDetails(name = request.customerName, email = request.customerEmail),
        )
        .allowsDelayedPaymentMethods(false)
        .build()

    if (request.isSetup) {
        sheet.presentWithSetupIntent(request.clientSecret, configuration)
    } else {
        sheet.presentWithPaymentIntent(request.clientSecret, configuration)
    }
}

Card only — allowsDelayedPaymentMethods(false) — because a bank debit that settles in three days would leave a pizza order "paid" long after it went cold.

The ViewModel's half: events as state

The ViewModel asks for the sheet the only way a ViewModel should talk to a screen — by changing its state:

fun pay() {
    val step = _state.value.step as? CheckoutStep.AwaitingPayment ?: return
    val clientSecret = step.created.clientSecret ?: return

    _state.update {
        it.copy(
            isPaying = true,
            errorMessage = null,
            paymentRequest = PaymentSheetRequest(
                clientSecret = clientSecret,
                customerName = form.value.customerName.trim(),
                customerEmail = form.value.email.trim(),
            ),
        )
    }
}

The screen reacts, and reports back that it did:

LaunchedEffect(state.paymentRequest) {
    val request = state.paymentRequest ?: return@LaunchedEffect
    payment.present(request)
    viewModel.onPaymentSheetPresented()
}
fun onPaymentSheetPresented() {
    _state.update { it.copy(paymentRequest = null) }
}

The timing of that last call is the detail that makes it correct. The request is consumed when the sheet is presented, not when the result arrives. If it waited for the result, a rotation while the sheet was up would recompose the screen, find a request still pending, and open a second sheet on top of the first. Clearing it immediately means the request is handled exactly once, however many times the screen is recreated.

This "events as state" pattern — the ViewModel sets a value, the screen acts and clears it — is Google's recommended alternative to firing one-off events through a Channel, which can drop or duplicate an event that arrives while the screen is between Activities.

The outcome

fun onPaymentOutcome(outcome: PaymentOutcome) {
    val step = _state.value.step as? CheckoutStep.AwaitingPayment
    _state.update { it.copy(isPaying = false) }

    when (outcome) {
        PaymentOutcome.Succeeded -> {
            // The server — not this result — decides the order is paid; the receipt screen
            // polls for that. Emptying the cart here is the device's half of the bargain.
            cartStore.clear()
            _state.update { it.copy(completedOrderId = step?.created?.order?.id) }
        }
        // Not an error. The order is still reserved and they can try again.
        PaymentOutcome.Cancelled -> Unit
        is PaymentOutcome.Failed -> _state.update { it.copy(errorMessage = outcome.message) }
    }
}

Three outcomes, three different responses. Cancelled is not an error — the customer closed the sheet; the order is still reserved and the Pay button is still there. Failed shows Stripe's message ("Your card was declined"), on the same order, so a second card can be tried without creating a second order. Succeeded empties the cart and asks the screen to show the receipt — through state again, then the receipt replaces checkout on the back stack so Back cannot return to a paid order.

Rule three: ask the server whether it is paid

The sheet reporting "completed" means Stripe accepted the card. It does not mean the order is paid. The order becomes PAID when Stripe's webhook reaches the backend — a separate, signed server-to-server call that can lag by seconds. The device is never the authority on money, so it never marks anything paid. The receipt screen asks:

repeat(maxAttempts) { attempt ->
    try {
        val order = repository.paymentStatus(orderId)
        _state.value = UiState.Loaded(order)
        if (!order.status.isSettling) return
    } catch (e: CancellationException) {
        throw e
    } catch (e: Exception) {
        // Keep showing the last good receipt if there is one; a blip mid-poll is not news.
        if (_state.value.valueOrNull == null) _state.value = UiState.failure(e, "Could not load the order.")
        return
    }
    // No sleep after the final attempt — it would delay the "still processing" message.
    if (attempt < maxAttempts - 1) delay(pollInterval)
}

Every two seconds, up to ten times, until the server says the order has settled. If it still has not, the screen says the payment is being confirmed and an email will follow — honest, and true.

Saving a card

Signed-in customers can save cards on their profile. The flow is the same sheet with a different intent: a SetupIntent, which collects and verifies a card without charging it.

The wrinkle is that the sheet reports only "completed". The backend needs the id of the payment method Stripe created — a pm_… token — which lives on the SetupIntent. So after the sheet completes, the app reads it back from Stripe with the publishable key:

class StripeSetupIntentReader @Inject constructor(
    @ApplicationContext private val context: Context,
    private val config: ApiConfig,
) : SetupIntentReader {
    override suspend fun paymentMethodId(clientSecret: String): String? {
        val key = config.stripePublishableKey ?: return null
        return Stripe(context, key).retrieveSetupIntent(clientSecret).paymentMethodId
    }
}

…and sends only that token to its own backend, which attaches it to the customer at Stripe. The card number went from the customer's keyboard straight into Stripe's sheet and never touched this app's code, its memory, its logs or its server. That is the whole security model: the app handles nothing that would make it a target.

When it is not configured

A developer cloning the app without keys should not see a Pay button that does nothing. The ViewModel explains, specifically, what is missing:

fun paymentUnavailableMessage(created: OrderCreateResponse): String? = when {
    created.clientSecret == null ->
        "The server created this order but returned no Stripe client secret, which means no " +
            "Stripe key is configured on the backend. Run it with the local profile."
    !config.isStripeConfigured ->
        "Card payment is unavailable because this build has no Stripe publishable key. Set " +
            "pizza.stripePublishableKey in local.properties and rebuild."
    else -> null
}

Testing payments

The ViewModel never sees Stripe, so its tests do not either. They create an order, call pay(), assert a request was published, call onPaymentSheetPresented(), then feed onPaymentOutcome each of the three outcomes directly — including the one that matters most: a rotation must not open a second sheet.

The real sheet was exercised by hand against Stripe's test mode: test card 4242 4242 4242 4242, any future expiry, any CVC. That order reached PAID, which was confirmed in the database rather than on the screen — and the cart row on the server was empty afterwards, as it should be.

Next

The payment sheet is fully accessible because Stripe made it so. The rest of the app is accessible only if we did. The next lesson is accessibility in Compose: the semantics tree that TalkBack reads, and that the UI tests read too.