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.
v2.0 changesBridge v2 — the native shell no longer holds or sends BASE44_SERVICE_TOKEN / the x-base44-service-token header. Connection tokens are fetched by the web page using the logged-in user's Base44 session and handed to the shell via setConnectionTokenProvider. (v1.2 targeted Stripe Terminal Android SDK 5.8.1 + Capacitor 7.)
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
HTTPNo HTTP in the shell for v2 — the page fetches connection tokens via its logged-in Base44 session and passes them to the shell via setConnectionTokenProvider. (OkHttp optional only if you add other server calls.)

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 — No service token

v2Bridge v2: the shell does NOT use BASE44_SERVICE_TOKEN or the x-base44-service-token header. No secrets.properties / BuildConfig token needed — connection tokens come from the page session.

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). The secret is supplied by the web page (logged-in Base44 session) via setConnectionTokenProvider — the shell does NOT call the backend or send BASE44_SERVICE_TOKEN. 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(), setConnectionTokenProvider(fn), 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, setConnectionTokenProvider, connect, discoverReaders, connectReader, collectPayment, cancelCollect, getConnectionStatus, disconnect
TokenProviderobject : ConnectionTokenProvider { fetchConnectionToken(callback) -> call the page-provided JS provider set via window.StripeTerminal.setConnectionTokenProvider(fn) -> fn() returns a promise resolving to {secret} -> callback.onSuccess(secret) | onFailure(ConnectionTokenException) }. No HTTP, no BASE44_SERVICE_TOKEN in the shell.
fetchConnectionTokenNo OkHttp/HTTP in the shell. The web page calls POST /functions/stripeTerminal {action:get_connection_token} with the logged-in user's session cookie and returns {secret}; the shell receives it through setConnectionTokenProvider and forwards it to callback.onSuccess(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 are fetched by the web page using the logged-in user's Base44 session (POST /functions/stripeTerminal {action:get_connection_token}) and handed to the shell via setConnectionTokenProvider — the shell never holds BASE44_SERVICE_TOKEN or sends the x-base44-service-token header. 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} -> {secret}. Called by the WEB PAGE with the logged-in user's Base44 session (NOT by the shell, NOT via x-base44-service-token). The page then passes {secret} to the shell through window.StripeTerminal.setConnectionTokenProvider.
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. No app secrets stored in the shell (v2 — connection tokens come from the page session).
No web changesAll CRM/web changes ship from Base44.
Generated from the OTHE CRM Base44 app · src/lib/nativeBridgeBriefData.js