On this page
No Headings
Last updated: September 14, 2026
This guide shows you how to accept an Advanced Credit and Debit Card (ACDC) payment in your Android app with PayPal Mobile SDK v3.0.0, including saving a card with or without a purchase.
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 uses its own client, CardClient, and passes everything in a single CardRequest. It 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, and you call presentAuthChallenge(), then finishApproveOrder(intent) when the buyer returns.
Before you start
Complete Install and set up.
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. It is used 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: collect and validate the card, hand it to the SDK to approve, which also resolves any 3D Secure challenge, then capture the order. 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 a challenge was presented, 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
}
}Once the order is approved, 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, survive process death: if your activity may be recreated while a 3DS challenge is in flight, persist cardClient.instanceState before the process dies and 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 still only carries the order ID and status. It does not return a payment token. 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: vault() and finishVault(intent) mirror approveOrder() and finishApproveOrder(intent).
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. approveOrder() returns whether a challenge is needed. presentAuthChallenge() returns whether the challenge was launched. finishApproveOrder() 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) | Returned by presentAuthChallenge() itself. Success: the challenge was launched. Wait for the buyer to return through onNewIntent. Failure: the challenge could not be launched. Show the error instead of waiting for a return intent. |
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 | AuthorizationRequired is returned. After presentAuthChallenge() and the buyer completes the challenge, finishApproveOrder(intent) returns Success. |
| Buyer cancels the 3DS challenge | finishApproveOrder(intent) returns Canceled. No charge is made. |
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, not from the SDK result. |
| 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, is registered and returns the buyer to your app.approveOrder() and finishApproveOrder() are handled.