Last updated: October 14, 2026
Card checkout uses CardClient and a single CardRequest, so you collect card details in your own UI and present any 3D Secure challenge yourself.
The SDK ships with no card-entry UI. You build your own fields for card number, expiry, CVV, and optionally cardholder name and billing address. You are responsible for input validation, including the Luhn check, brand detection, and formatting.
Card checkout does not use createPayPalSession().
On Android, presenting the 3D Secure (3DS) challenge is not automatic. If PayPal requires a step-up:
approveOrder() returns an AuthorizationRequired result carrying a challenge.presentAuthChallenge().finishApproveOrder(intent) when the buyer returns.This diagram shows the full round trip from card entry to a captured payment, including the optional 3D Secure step-up.
Complete Install and set up. For card integrations:
com.paypal.android:card-payments module.CoreConfig you built in setup:val cardClient = CardClient(context, config)merchantapp://return from setup. The SDK uses it only for the 3DS challenge return.Call the Orders v2 API server-to-server and return only the order ID to your app.
curl -X POST https://api-m.sandbox.paypal.com/v2/checkout/orders \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <access-token>' \
-d '{ "intent": "CAPTURE", "purchase_units": [ { "amount": { "currency_code": "USD", "value": "49.99" } } ] }'These steps take you from a blank card form to a captured payment. When the last step completes, the buyer sees their payment confirmed.
The SDK provides no card-entry fields, so build your own UI, then create the order on your server.
val card = Card(
number = "4111111111111111",
expirationMonth = "01",
expirationYear = "2028",
securityCode = "123",
cardholderName = "Jane Smith", // optional
billingAddress = Address(countryCode = "US") // optional
)
val orderId = myServer.createOrder()Pass the card and order to the SDK, which either approves the order directly or returns a challenge for you to present.
val request = CardRequest(
orderId = orderId,
card = card,
returnUrl = "merchantapp://return", // must match a registered intent-filter
sca = SCA.SCA_WHEN_REQUIRED // or SCA.SCA_ALWAYS to force a challenge every time
)
cardClient.approveOrder(request, object : CardApproveOrderCallback {
override fun onCardApproveOrderResult(result: CardApproveOrderResult) {
when (result) {
is CardApproveOrderResult.Success -> captureOrder(result.orderId)
is CardApproveOrderResult.AuthorizationRequired -> {
when (val presentResult = cardClient.presentAuthChallenge(activity, result.authChallenge)) {
is CardPresentAuthChallengeResult.Success -> Unit // challenge launched; resolve it in onNewIntent
is CardPresentAuthChallengeResult.Failure -> showError(presentResult.error)
}
}
is CardApproveOrderResult.Failure -> showError(result.error)
}
}
})If you presented a challenge, forward the return intent to the SDK on foreground re-entry.
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
when (val result = cardClient.finishApproveOrder(intent)) {
is CardFinishApproveOrderResult.Success -> captureOrder(result.orderId)
is CardFinishApproveOrderResult.Failure -> showError(result.error)
CardFinishApproveOrderResult.Canceled -> showCheckoutScreen()
CardFinishApproveOrderResult.NoResult -> Unit // unrelated intent; ignore
null -> Unit // no challenge was in flight
}
}After the SDK approves the order, capture it on your server to complete the payment.
fun captureOrder(orderId: String) {
// POST /v2/checkout/orders/{orderId}/capture on your server
myServer.captureOrder(orderId)
}Optionally, persist cardClient.instanceState before the process dies, in case Android recreates your activity while a 3DS challenge is in flight. Call cardClient.restore(instanceState) on recreation so finishApproveOrder(intent) can still resolve.
Save the card while completing a purchase in one approval, using the same approveOrder() sequence as above. The only difference is that your server creates the order with payment_source.card.attributes.vault set:
curl -X POST https://api-m.sandbox.paypal.com/v2/checkout/orders \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <access-token>' \
-d '{
"intent": "CAPTURE",
"purchase_units": [ { "amount": { "currency_code": "USD", "value": "49.99" } } ],
"payment_source": { "card": { "attributes": { "vault": { "store_in_vault": "ON_SUCCESS" }, "customer": { "id": "<existing-or-new-customer-id>" } } } }
}'On approval the SDK result carries only the order ID and status. Retrieve the saved card's token server-side after capture.
Save a card with no purchase now. This follows the identical pattern against a setup token: the vault() and finishVault(intent) methods mirror the approveOrder() and finishApproveOrder(intent) methods.
val vaultRequest = CardVaultRequest(setupTokenId = setupTokenId, card = card, returnUrl = "merchantapp://return")
cardClient.vault(vaultRequest, object : CardVaultCallback {
override fun onCardVaultResult(result: CardVaultResult) {
when (result) {
is CardVaultResult.Success -> savePaymentToken(result.setupTokenId)
is CardVaultResult.AuthorizationRequired -> {
when (val presentResult = cardClient.presentAuthChallenge(activity, result.authChallenge)) {
is CardPresentAuthChallengeResult.Success -> Unit // challenge launched; resolve it in onNewIntent
is CardPresentAuthChallengeResult.Failure -> showError(presentResult.error)
}
}
is CardVaultResult.Failure -> showError(result.error)
}
}
})
// In onNewIntent, resolve with: cardClient.finishVault(intent)Card splits the result across three calls when a challenge is required. The approveOrder() method returns whether a challenge is needed. The presentAuthChallenge() method returns whether the SDK launched the challenge. The finishApproveOrder() method carries the outcome after the buyer returns. Vault uses CardVaultResult and finishVault() with the same shape.
| Type | Cases | What you do |
|---|---|---|
CardApproveOrderResult and CardVaultResult | Success, AuthorizationRequired(authChallenge), or Failure(error) | Success: capture, or store the token. AuthorizationRequired: call presentAuthChallenge(). Failure: show the error. |
CardPresentAuthChallengeResult | Success or Failure(error) | presentAuthChallenge() returns this result itself. Success: the SDK launched the challenge. Wait for the buyer to return through onNewIntent. Failure: the SDK could not launch the challenge. Show the error. |
CardFinishApproveOrderResult and CardFinishVaultResult | Success, Failure(error), Canceled, or NoResult | Success: capture or store. Canceled: return to checkout. Failure: show the error. NoResult: the intent was not a card 3DS return. Ignore it. |
Platform note
Android opens whatever challenge URL comes back on the payer-action link; iOS validates that the challenge URL is a genuine PayPal 3DS page before opening it.
| Scenario | Expected result |
|---|---|
| No challenge required | approveOrder() returns Success. No Custom Tab opens. |
| 3DS challenge required | approveOrder() returns AuthorizationRequired. After presentAuthChallenge() and the buyer completes the challenge, finishApproveOrder(intent) returns Success. |
| Buyer cancels the 3DS challenge | finishApproveOrder(intent) returns Canceled. PayPal does not charge the buyer. |
sca = SCA.SCA_ALWAYS | Every approval attempts a challenge. Useful for testing the challenge path on demand. |
| Save with purchase | Order captures. The saved card's token is retrievable server-side. |
| Process death during a challenge | With instanceState persisted and restore() called, finishApproveOrder(intent) still resolves. |
CoreEnvironment.SANDBOX to CoreEnvironment.LIVE and use your live client ID and merchant ID.returnUrl, and that it returns the buyer to your app.approveOrder() and finishApproveOrder().