Native Stripe Terminal Bridge — Build Brief

CSV → Google Sheets: File → Import → Upload. PDF → in the print dialog pick “Save as PDF” as the destination.

Claude Code build brief — On Time Home Experts CRM native Stripe Terminal shell

Published app: https://lead-dispatch-flow.base44.app · Backend function: stripeTerminal

1. Overview

GoalBuild a thin native Android Capacitor shell that wraps the LIVE Base44 CRM URL and adds card-present payments (Tap to Pay on Android + M2 Bluetooth reader) via the Stripe Terminal Android SDK, exposed to the WebView through a JS bridge.
v1.2 changesUpdated for Stripe Terminal Android SDK 5.8.1 + Capacitor 7. v1.0 targeted Stripe 4.7.x / Capacitor 6 with renamed APIs (SessionTokenProvider → ConnectionTokenProvider; two-step collect+confirm → processPaymentIntent) and a main-thread network call that crashes Android. Fixed here.
Base44 side statusALREADY implemented — backend `stripeTerminal` + web `NativeTapToPay.jsx`. This brief builds ONLY the native shell + plugin.
ScopeDo NOT add login screens, CRM UI, or business logic. All of that ships from Base44.

2. Stack

ShellCapacitor 7 (Android only for now)
LanguageKotlin
Stripe SDKStripe Terminal Android SDK 5.8.1 (core + taptopay + bluetooth modules)
Min SDK26 (target Android 15 / API 35)
Coroutineskotlinx-coroutines-android 1.8.1
HTTPOkHttp 4.12.0 (connection-token fetch, on a background dispatcher)

3. Step 1 — Scaffold

mkdirmkdir othe-crm-shell && cd othe-crm-shell
initnpm init -y
installnpm install @capacitor/core@^7 @capacitor/cli@^7 @capacitor/android@^7
cap initnpx cap init "OTHE CRM" "com.othe.crm" --web-dir=www
wwwmkdir www && echo '<meta http-equiv="refresh" content="0; url=https://lead-dispatch-flow.base44.app">' > www/index.html
add androidnpx cap add android
config.tsappId: com.othe.crm; appName: OTHE CRM; webDir: www; server.url: https://lead-dispatch-flow.base44.app; server.cleartext: false; android.allowMixedContent: false; SplashScreen launchShowDuration: 1200

4. Step 2 — Gradle deps

coreimplementation 'com.stripe:stripeterminal-core:5.8.1'
taptopayimplementation 'com.stripe:stripeterminal-taptopay:5.8.1'
bluetoothimplementation 'com.stripe:stripeterminal-bluetooth:5.8.1'
okhttpimplementation 'com.squareup.okhttp3:okhttp:4.12.0'
coroutinesimplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
variables.gradleminSdkVersion=26; compileSdkVersion=35; targetSdkVersion=35; kotlinVersion=1.9.25
reposmavenCentral() (default)

5. Step 2 — Service token

secrets.propertiesandroid/app/secrets.properties (gitignored): BASE44_SERVICE_TOKEN=PASTE_THE_BASE44_SERVICE_TOKEN_VALUE_HERE
BuildConfigdefaultConfig: read secrets.properties -> buildConfigField "String", "BASE44_SERVICE_TOKEN", "<value>" (never hardcode in source)

6. Step 3 — Manifest

INTERNET<uses-permission android:name="android.permission.INTERNET" />
NFC<uses-permission android:name="android.permission.NFC" /> + <uses-feature android:name="android.hardware.nfc" android:required="false" />
BT legacy<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" /> + BLUETOOTH_ADMIN
BT modern<uses-permission android:name="android.permission.BLUETOOTH_SCAN" /> + BLUETOOTH_CONNECT
Location<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

7. Step 4 — v5 API notes

TokenProviderConnectionTokenProvider (NOT SessionTokenProvider). Implement fetchConnectionToken(callback). callback.onSuccess(secret) | callback.onFailure(ConnectionTokenException(...))
ThreadingfetchConnectionToken runs on the SDK init thread — may be the UI thread. Network there throws NetworkOnMainThreadException and Android kills the app. ALWAYS wrap the HTTP call in withContext(Dispatchers.IO){…}.
InitTerminal.init(context, tokenProvider, listener, logger) once — from Application.onCreate via TerminalApplicationDelegate.onCreate(this), or lazily on first plugin use.
Unified paymentprocessPaymentIntent(intent, config, callback) (v5) replaces two-step collectPaymentMethod + confirmPaymentIntent. Two-step still compiles but processPaymentIntent is preferred.
PaymentIntentParametersPaymentIntentParameters.Builder(amount, currency).setCaptureMethod(CaptureMethod.AUTOMATIC).build()
Connection checkTerminal.getInstance().connectionStatus == ConnectionStatus.CONNECTED
Importsexternal.models: ConnectionTokenProvider, ConnectionTokenException, PaymentIntentParameters, CaptureMethod, TerminalException, TapToPayDiscoveryConfiguration, BluetoothDiscoveryConfiguration, TapToPayConnectionConfiguration, BluetoothConnectionConfiguration. callable: Callback, Cancelable, PaymentIntentCallback, ReaderCallback, TerminalListener, ConnectionTokenCallback.

8. Step 4 — Plugin

Interface (frozen)window.StripeTerminal = { isAvailable(), connect({mode, locationId?, isSimulated?}), discoverReaders(), connectReader(serial), collectPayment({amount, currency}), cancelCollect(), getConnectionStatus(), disconnect(), onReadersDiscovered(readers) }
ErrorsReject as { code: string, message: string }. Amounts in cents.
Packagecom.othe.crm.terminal
Annotation@CapacitorPlugin(name = "StripeTerminal")
MethodsisAvailable, connect, discoverReaders, connectReader, collectPayment, cancelCollect, getConnectionStatus, disconnect
TokenProviderobject : ConnectionTokenProvider { fetchConnectionToken(callback) -> ioScope.launch { withContext(IO){ fetchConnectionToken() } } -> callback.onSuccess(secret) | onFailure(ConnectionTokenException) }
fetchConnectionTokenwithContext(Dispatchers.IO): OkHttp POST stripeTerminal {action:get_connection_token} header x-base44-service-token: BuildConfig.BASE44_SERVICE_TOKEN -> {secret}
tap_to_pay connectTapToPayDiscoveryConfiguration(isSimulated) -> discoverReaders -> auto-connect built-in reader with TapToPayConnectionConfiguration
m2 connectBluetoothDiscoveryConfiguration(isSimulated) -> discoverReaders -> user selects -> connectReader(serial, BluetoothConnectionConfiguration(locationId))
collect flow (v5)createPaymentIntent(params) -> processPaymentIntent(intent, null, callback, cancelableCallback) -> return {paymentIntentId,status,amount,currency,card{brand,last4}}
discovery emitonUpdateDiscoveredReaders -> evaluateJavascript(window.__stripeTerminalOnReaders?.(arr)) -> calls window.StripeTerminal.onReadersDiscovered
load() gluePlugin load() installs window.__stripeTerminalOnReaders that forwards to window.StripeTerminal.onReadersDiscovered
MainActivityregisterPlugin(StripeTerminalPlugin::class.java) before super.onCreate
Import noteIf Stripe renamed any symbol between 5.8.1 and a later 5.x patch, adjust the import — the call shapes above are correct for 5.8.1.

9. Step 5 — Permissions

RuntimeRequest Bluetooth + location on first M2 use; ensure NFC for Tap to Pay. Handle onRequestPermissionsResult. Re-ask on denial.

10. Step 6 — Build

syncnpx cap sync android
AABAndroid Studio -> Generate Signed Bundle (AAB) -> upload to Google Play Console
Update cadenceONE-TIME upload. CRM updates ship automatically via Base44 publish (WebView loads live URL). Re-upload AAB only on shell/bridge changes.

11. Contract rules

1Load the LIVE Base44 URL — never bundle a static web copy (keeps CRM auto-updating).
2Freeze the bridge interface — add methods, never rename/reshape existing ones.
3Connection tokens stay server-side — shell only calls get_connection_token (auth via x-base44-service-token header = BASE44_SERVICE_TOKEN); capture + invoice updates happen in Base44 backend.
4Never block the main thread — all OkHttp / network calls run on Dispatchers.IO; doing them on the SDK callback thread triggers NetworkOnMainThreadException.

12. Backend contract

get_connection_tokenPOST /functions/stripeTerminal {action:get_connection_token} header x-base44-service-token: <BASE44_SERVICE_TOKEN> -> {secret} (no user session; only action the shell calls directly)
capture_paymentPOST /functions/stripeTerminal {action:capture_payment, payment_intent_id, invoice_id?, test_mode?} -> {status, payment_intent_id, amount, card{brand,last4}, invoice_updated} (called by web app, user session)
callerWeb app calls capture_payment AFTER collectPayment resolves succeeded.

13. Testing

1 SimulatedisSimulated: true, test card 4242 4242 4242 4242.
2 Tap to Pay liveAfter Stripe Terminal activated on the account.
3 M2 liveReal reader, $1 test charge, refund from Stripe Dashboard.
4 FallbackConfirm web app degrades gracefully when bridge absent (browser).

14. Deliverable

AABSigned AAB ready for Google Play.
SourceCapacitor shell source committed to its own repo (secrets.properties gitignored).
No web changesAll CRM/web changes ship from Base44.
Generated from the OTHE CRM Base44 app · src/lib/nativeBridgeBriefData.js