Skip to main content
The Zennopay Android SDK launches the PaymentSheet — the full native pay experience (QR scan → amount + FX quote → slide-to-pay → result) — as its own full-screen Activity over your app, and delivers exactly one PaymentResult to your callback. The UI is Jetpack Compose; scanning is CameraX + ML Kit, on-device.
Animated flow: scan a QR code, review the amount and USD quote, slide to pay, and see the success screen.

The PaymentSheet, end to end, presented from the sample wallet app.

Migrating from hosted checkout (beta)? The Chrome Custom Tab, the partner-defined deep link, and Zennopay.openCheckout(...) are gone. Replace the call with Zennopay.presentCheckout(...) below and delete the ZennopayCallbackActivity intent-filter from your manifest — the result now comes back in-process via the callback, not a deep link. Your app passes the Zennopay-minted session token to presentCheckout (below).

Requirements

  • minSdk 24 (Android 7.0), compileSdk 34
  • Kotlin 1.9+, AndroidX
  • Your app does not need to use Compose — the sheet runs in its own Activity. A Compose-first entry point is provided for hosts that are.
  • A backend session endpoint that creates the intent and returns the Zennopay-minted session token

Install

The artifact is published to Maven Central, so no extra repository declaration is needed — mavenCentral() in your settings.gradle.kts (the Android default) resolves it. The Gradle coordinate is in.zennopay:sdk; the Kotlin import namespace stays com.zennopay.sdk.

Camera permission

The SDK’s manifest already merges in <uses-permission android:name="android.permission.CAMERA" /> (with the camera marked as an optional feature), and the SDK requests the runtime permission itself when the scanner opens. You add nothing to your manifest — just declare camera use in your Play Store listing.
If the user denies the permission, the sheet degrades to a paste-QR-data fallback — the flow is always completable without a camera.

Configure

The environment is a value, not a code path:
Already shipped on zennopay.in? Nothing to do. Previously published SDK versions resolve SANDBOX / PRODUCTION to the api.zennopay.in hosts, and those hosts continue to be served — same API, same credentials. You do not need to ship an app update to stay working. See Environments.
You can also set a process-wide default once with Zennopay.configure(config) — per-call config still overrides it. Your API keys never live in the app: per payment, your backend creates the intent and Zennopay returns a session token. Your backend passes it to your app, and the SDK holds only that short-lived token.

Present the PaymentSheet

With a checkout session from your backend in hand, your app makes one call. Two integration patterns: For Compose hosts, rememberZennopayLauncher binds your result callback and config once and hands you a stable present lambda — safe to pass straight to click handlers, stable across recompositions:

Pattern 2 — one-shot presentCheckout

From any ComponentActivity (Views or Compose), call the entry point directly:
The sheet runs in its own Activity, so it owns its lifecycle end to end; your callback fires exactly once with the terminal result. The session token is handed to that Activity in memory — it is never serialized into the launching Intent, so it can’t leak via logcat or recents.
The SDK validates the session token’s structure, expiry, and intent binding synchronously, before launching anything. A token issued for a different intent fails immediately with PaymentResult.Failed(error = IntentMismatch) — no blank screen, no network call.

Handle the result

ZennopayError is a sealed taxonomy with stable code strings (InvalidJwt, IntentMismatch, JwtExpired, MalformedToken, InvalidIssuer, quote/confirm/network cases, …) — exhaustive in a when. On a timeout the payment is effectively pending — it may still settle; reconcile via your webhook or GET /v1/payment_intents/:id rather than assuming a terminal failure.

Customize appearance

ZennopayAppearance themes the sheet to your brand — colors, corner radii, typography, primary button, and an optional header logo:
Pass nothing (ZennopayAppearance.Automatic) and the sheet uses the default Zennopay look, following system light/dark.
Structural rules are not overridable: corner radii are clamped to ≤ 12 dp, amounts always render in tabular figures, and the accent color is reserved for state signals.

Test

  • Emulator: the emulator’s virtual camera won’t decode a real QR reliably — use the sheet’s paste-QR fallback (or the emulator’s virtual-scene camera image) with any VietQR payload string. The backend does the authoritative parse either way.
  • Sandbox end-to-end: run ZennopayConfig.SANDBOX with a session from your sandbox backend; the payout runs on the sandbox rail and the intent reaches captured.
  • Fail-fast checks: pass a blank or mismatched session token and assert an immediate Failed(InvalidJwt) / Failed(IntentMismatch) with no UI.
  • Physical device: camera scanning and the runtime-permission prompt are the pre-release checklist.
The full sandbox checklist — including limit-blocked payments and pending reconciliation — is in Test your integration.

Under the hood (what you can rely on)

  • The session token is held in memory and sent as Authorization: Bearer on every REST call — never placed in a URL or an Intent extra.
  • Slide-to-pay fires POST /confirm exactly once; the idempotency key is persisted (DataStore) before the network call, so retries and process death can never double-debit.
  • On relaunch mid-payment, the SDK recovers the true terminal status with a GET rather than re-confirming.

ProGuard / R8

The SDK ships consumer rules; no additional configuration is needed.

Next steps

Build your session endpoint

The one backend route the sheet needs.

Test your integration

The sandbox loops to run before release.

Webhooks

Reconcile terminal states server-side.

Per-user limits

The corridor limits Zennopay enforces for you.