If you are searching for stripe terminal flutter, the short answer is this: Stripe's Terminal documentation lists SDKs for Android, iOS, React Native and JavaScript, plus a server-driven integration, and none for Flutter. A Flutter app, or a kiosk whose interface is a web page, reaches Stripe Terminal and Tap to Pay on Android through a native layer: a Kotlin plugin that owns the Terminal SDK, called from Dart over a platform channel. The design work is less about the bridge itself and more about where payment state lives. The server should create the PaymentIntent, the device should only collect and process it, and the order should be marked paid only when the server has confirmed the result.
This guide is written for CTOs and engineers who need card-present payments inside an Android kiosk, a countertop POS or a staff handheld, and who already have a Flutter or web interface they do not want to rewrite. It covers the SDK options, how to set up Stripe Tap to Pay on Android, three architectures that work, the connection token endpoint, the payment sequence, failure handling, device lifecycle, testing and a go-live checklist. Stripe's products, devices and regions change, so every requirement below links to the relevant page of the Stripe Terminal documentation, which is the source of truth.
Is there a Stripe Terminal SDK for Flutter?
Not from Stripe. At the time of writing, the Terminal setup guide offers variants for a server-driven integration, JavaScript, iOS, Android, React Native, Java, .NET and Apps on Devices. Flutter is not on the list. Community packages exist on pub.dev that wrap the native Terminal SDKs, and some teams use them. If you do, treat the package as a dependency you own: check how quickly it follows Stripe's Android SDK releases, whether it exposes the Tap to Pay APIs you need, and whether you could fork it if the maintainer stops. Tap to Pay device requirements are tied to SDK versions, and Stripe says those requirements can change for compliance or security reasons, so a wrapper that lags behind the native SDK can block you from taking payments.
The alternative, and the one we prefer for production kiosks, is to write a thin native plugin yourself. A platform channel is Flutter's mechanism for calling Kotlin (or Swift) code from Dart and getting a result back. You define a small, explicit interface, such as `initialize`, `connect`, `collect` and `cancel`, implement it in Kotlin against the official Stripe Terminal Android SDK, and keep the Dart side ignorant of Stripe's object model. The plugin is a few hundred lines, it upgrades whenever Stripe's SDK upgrades, and nothing between your app and Stripe depends on a third party's release schedule.
So when people ask about a stripe terminal sdk flutter option, the honest answer is that the SDK is native, and the Flutter question is really an architecture question: how thin can the native layer be, and how do you keep the Dart or web layer from ever touching a secret key?
Readers or Tap to Pay on Android: which does your kiosk need?
Stripe Terminal supports two broad kinds of card-present hardware. A reader is a separate certified device: a mobile reader that pairs over Bluetooth or USB, or a smart reader that connects over the internet and can run its own interface. Tap to Pay turns a compatible phone, tablet or Android kiosk into the reader itself, using the device's built-in NFC antenna to accept contactless cards and NFC-based mobile wallets. On Android, Tap to Pay is included in the Terminal Android SDK and the Terminal React Native SDK.
The choice usually comes down to four questions:
- Does the customer need to insert a card? Tap to Pay is contactless. Stripe's documentation notes that in some regions certain cards require insertion or an offline PIN that a phone cannot collect, and recommends a reader or another payment method as the fallback. If your customers regularly hit that case, plan for a reader.
- Is your hardware on Stripe's supported list? Tap to Pay on Android only connects on devices that meet every criterion Stripe publishes. A kiosk you already own may or may not qualify; a reader works with the hardware you have as long as your app can pair with it.
- Who is holding the device? A staff member with a phone suits Tap to Pay well. An unattended kiosk can work too (Stripe lists kiosk-class devices among supported types), but you need to think harder about the screen the SDK shows and about what happens when nobody is there to press retry.
- Do you need offline acceptance? As of this writing, Stripe's offline-mode documentation says Tap to Pay on Android does not support offline mode, while several mobile and smart readers do. More on that below.
Both routes use the same PaymentIntent flow on the server, so the backend design in the rest of this guide applies either way. The differences are in discovery, connection and the device's lifecycle.
How to set up Stripe Tap to Pay on Android: device and account requirements
Before any code, confirm three things: that Tap to Pay is available for your Stripe account's country, that your devices qualify, and that your app can run in the conditions the SDK requires. Stripe publishes the current country list and device criteria on its Tap to Pay setup page. Availability depends on country, account, device and configuration, and some countries are in preview, so check the page for your rollout rather than relying on any list copied into a blog post, including this one.
In general terms, the published device criteria for stripe tap to pay android devices cover an integrated, working NFC sensor and an ARM processor; a recent Android version and a recent security patch; Google Mobile Services with the Play Store installed; an unrooted device with a locked, unmodified bootloader running the manufacturer's OS; a hardware-backed keystore; a stable internet connection; and Developer options switched off. Emulators are not supported, and Stripe enforces the same device requirements for the simulated Tap to Pay reader as for the real one, so you test on real hardware from day one.
Several of those criteria collide with how kiosks are commonly provisioned, which is why we check them before writing anything:
- Developer options. Kiosk fleets are often set up with Developer options on for remote debugging. The production Tap to Pay reader will not connect while they are enabled, and it will not run from a debuggable build.
- Frozen OS images. Kiosk vendors sometimes pin an OS image and never patch it. Stripe requires a security update from within a recent window, so your device-management plan has to include patching.
- Lockdown tooling. Stripe's documentation says PIN collection fails if accessibility services are registered or running, if screen recording is active or if there are screen overlay windows. Some kiosk-lockdown tools rely on exactly those mechanisms. Test PIN entry on the locked-down build, not on a clean device.
- Foreground and process model. Tap to Pay runs its reader service in a dedicated process, which creates a second instance of your `Application` class, and Stripe says your app must be in the foreground for that service to start. Kiosk launchers and Flutter's engine both need to respect that.
On the Android side, setup follows Stripe's guide: add the Terminal SDK and the Tap to Pay module, request `ACCESS_FINE_LOCATION` (the SDK disables payments if it cannot determine location), initialize the SDK from an `Application` subclass through `TerminalApplicationDelegate`, and skip that initialization when `TapToPay.isInTapToPayProcess()` returns true. Discovery uses `TapToPayDiscoveryConfiguration`, with `isSimulated` set to true in development and false in release builds. Connection uses `TapToPayConnectionConfiguration` with a Terminal location ID. Stripe's stripe tap to pay android sdk guide also exposes `Terminal.supportsReadersOfType` so you can check hardware support at runtime and show a clear message on an unsupported device instead of failing at checkout.
Three architectures for a Stripe Terminal kiosk or Flutter app
Every stripe terminal kiosk we have looked at fits one of three shapes. They differ in where the interface lives and how many boundaries a payment has to cross.
1. A native Android app
The interface and the Terminal SDK live in the same Kotlin codebase. There is no bridge, the SDK's callbacks drive the UI directly, and Stripe's sample apps map closely onto your code. This is the simplest option for payments and the most expensive if your product's interface already exists somewhere else, because you are rebuilding it natively. Choose it when the kiosk is a new product or when the payment screen is most of the app.
2. Flutter with a platform channel to Kotlin
The interface is written in Dart and the Terminal SDK sits behind a Kotlin plugin. Dart calls methods such as `collect(clientSecret)` over a `MethodChannel`, and reader events such as connection status, reconnection and display prompts flow back over an `EventChannel`. The interface between the two should be narrow and boring: plain strings and small maps, with no Stripe objects serialized across. This keeps Flutter's state management separate from the SDK's own state, and it means a Stripe SDK upgrade touches one Kotlin file instead of your whole app.
3. A web kiosk UI inside a WebView, with a native bridge
Many POS and kiosk products already have a web interface, and rewriting it is the last thing anyone wants. Here the web app runs inside a WebView hosted by Flutter (or by a native activity), a JavaScript channel carries messages from the page to the host, and the host forwards payment requests to the Kotlin Terminal layer. This is the most layers of the three, which makes state discipline more important, not less. The web page should never hold a Stripe secret key or a connection token; at most it handles a PaymentIntent's client secret for the one payment in progress, and only because the native layer needs it.
This is the architecture we built for Megapos Ltd, a point-of-sale company whose kiosk interface is a web app while Stripe Terminal Tap to Pay runs natively on the device. The Megapos case study describes it: a Flutter WebView kiosk with a Node/Express backend and a native Kotlin bridge to Stripe Terminal Tap to Pay. The PaymentIntent and its client secret pass from the web layer to Flutter to Kotlin, so the payment is collected natively without exposing keys in the web app. The diagram below shows the layers in general form.
Whichever shape you choose, the same rule holds: the boundaries carry identifiers and outcomes, not authority. The web layer can ask for a payment, the native layer can collect one, and only the server decides that an order is paid.
The connection token endpoint
A connection token is a short-lived credential your backend creates with Stripe's `/v1/terminal/connection_tokens` endpoint and hands to the Terminal SDK so it can talk to Stripe and to a reader on your account's behalf. On Android you implement the `ConnectionTokenProvider` interface, whose `fetchConnectionToken` method calls your backend and passes the token's `secret` to the SDK's callback. The SDK calls it whenever it needs to authenticate, including when reconnecting after a disconnect.
Stripe's guidance is direct: the secret lets a client connect to any Terminal reader and take payments on your account, so the endpoint must authenticate its caller and be protected against cross-site request forgery, and you must not cache or hardcode the token because the SDK manages its lifecycle. For a kiosk that means the device needs an identity of its own: a per-device credential issued at provisioning, revocable from your admin side, that your backend checks before minting a token. A shared static API key baked into the app is the pattern to avoid, because anyone who extracts it can request tokens.
In a WebView architecture, the connection token request should come from the native layer, not the page. The Kotlin `ConnectionTokenProvider` calls your backend directly with the device credential. The web app never sees the token, which removes a whole class of leaks through browser storage, logging or third-party scripts.
Creating the PaymentIntent on the server and collecting on the device
A PaymentIntent is Stripe's object for one payment from start to finish, and Stripe's Terminal guide recommends using a single PaymentIntent per transaction, re-using it after a decline rather than creating a new one. Stripe allows creating it on the client through the SDK or on the server. For a kiosk with an order system we create it on the server, for three reasons: the amount comes from your order record rather than from a value the device sends, you can attach the order ID in metadata, and you can create it with an idempotency key so a retried request returns the same intent. For Terminal payments, Stripe requires `payment_method_types` to include `card_present`, and you choose `capture_method`: automatic to authorize and capture in one step, or manual if you want a reconciliation step before capture.
app.post("/terminal/connection-token", requireDevice, async (req, res) => {
const token = await stripe.terminal.connectionTokens.create();
res.json({ secret: token.secret });
});
app.post("/orders/:id/payment-intent", requireDevice, async (req, res) => {
const order = await orders.findOpen(req.params.id);
if (!order) return res.status(409).json({ error: "order_not_open" });
// One order, one PaymentIntent: return the existing one on retries.
const intent = order.paymentIntentId
? await stripe.paymentIntents.retrieve(order.paymentIntentId)
: await stripe.paymentIntents.create(
{
amount: order.totalMinor,
currency: order.currency,
payment_method_types: ["card_present"],
capture_method: "automatic",
metadata: { order_id: order.id },
},
{ idempotencyKey: "order-intent-" + order.id }
);
if (!order.paymentIntentId) await orders.attachIntent(order.id, intent.id);
res.json({ clientSecret: intent.client_secret });
});On the device, the native layer receives the client secret, calls `retrievePaymentIntent` to load the intent into the SDK, and then processes it. In the current Android SDK, Stripe documents two paths. `processPaymentIntent` collects the card and authorizes in one call, and it is what Stripe recommends for most cases. The two-step path calls `collectPaymentMethod`, lets you inspect the card, and then calls `confirmPaymentIntent`; Stripe notes that after collection you must authorize or cancel within 30 seconds, and that confirmation should always happen through the SDK on the client, not on the server, because server-side confirmation bypasses steps such as PIN prompts. With Tap to Pay, the SDK takes over the screen with a full-screen prompt during collection and returns control to your app afterwards.
Here is a minimal Kotlin sketch of the bridge, assuming the SDK is initialized and a reader is connected. It is deliberately small: the Dart or web layer passes a client secret in and gets an intent ID or an error code out.
MethodChannel(engine.dartExecutor.binaryMessenger, "kiosk/terminal")
.setMethodCallHandler { call, result ->
if (call.method != "collect") return@setMethodCallHandler result.notImplemented()
val secret = call.argument<String>("clientSecret")
?: return@setMethodCallHandler result.error("ARG", "clientSecret missing", null)
val main = Handler(Looper.getMainLooper()) // reply to Flutter on the main thread
Terminal.getInstance().retrievePaymentIntent(secret, object : PaymentIntentCallback {
override fun onSuccess(paymentIntent: PaymentIntent) {
Terminal.getInstance().processPaymentIntent(
paymentIntent = paymentIntent,
collectConfig = CollectPaymentIntentConfiguration.Builder().build(),
confirmConfig = ConfirmPaymentIntentConfiguration.Builder().build(),
callback = object : PaymentIntentCallback {
override fun onSuccess(paymentIntent: PaymentIntent) {
main.post { result.success(paymentIntent.id) } // UI shows "confirming"
}
override fun onFailure(e: TerminalException) {
main.post { result.error(e.errorCode.name, e.errorMessage, null) }
}
}
)
}
override fun onFailure(e: TerminalException) {
main.post { result.error(e.errorCode.name, e.errorMessage, null) }
}
})
}Notice what the success branch does not do: it does not mark the order paid. It tells the interface to show a confirming state. The server marks the order paid when it has seen the result from Stripe, either through a `payment_intent.succeeded` webhook or by retrieving the intent itself when the device reports back. If you use manual capture, the device's success means the intent is in `requires_capture`, and your backend captures it; Stripe's Terminal guide notes that uncaptured authorizations expire after a short window, so capture should not depend on a human remembering. Webhook handling deserves its own design; our guide to Stripe webhooks best practices covers signatures, idempotent handlers and out-of-order delivery.
Cancellations, timeouts and retries without double charging
Most production incidents in card-present payments are not declines. They are ambiguous states: the customer walked away mid-tap, the network dropped while the authorization was in flight, the app was killed, or the operator pressed cancel at the same moment the card was read. The design goal is simple to say and fiddly to implement: an interrupted payment leaves the order open and unpaid, and a retry can never produce a second charge.
Stripe's collect-payment guide gives the core rules for the device side. When processing fails, the SDK returns an error, and if Stripe declined the payment, the error includes the updated PaymentIntent. Your code inspects that intent's status:
- `requires_payment_method` means the payment method was declined. Collect again with the same PaymentIntent, so the customer can try another card.
- `requires_confirmation` means a temporary connectivity problem. Retry processing the same PaymentIntent.
- No PaymentIntent returned means the request to Stripe timed out and the status is unknown. Retry the original PaymentIntent. Stripe is explicit that you should not create a new one, because that can produce multiple authorizations on the cardholder's card.
Cancellation has two sources. Programmatic cancellation uses the `Cancelable` object the SDK returns when you start collection; call it when the operator abandons the sale or your own kiosk timeout fires. Customer cancellation comes from the reader or the SDK's screen. In Android SDK 6.0.0 and later, Stripe distinguishes the two with different error codes (`CANCELED` for programmatic and `CANCELED_BY_READER` for reader-side cancellation), which lets your interface respond differently: an operator cancel might return to the cart, while a customer cancel might offer to try again.
The server side mirrors those rules. The order record holds the PaymentIntent ID, and the creation route returns the existing intent for that order instead of creating another. A kiosk-level timeout cancels the SDK operation, not the order. If the app restarts mid-payment, its first job on launch is to ask the backend for any open order on this device and the current status of its PaymentIntent, then resume from there. Your end-of-day reconciliation looks for intents left in `requires_capture` (if you use manual capture) and matches them against orders: Stripe's guide suggests canceling an uncaptured intent that is not tied to a completed order, and capturing one that is, provided no other payment was taken for that order.
Reader and device lifecycle
Payments are only part of the code. The rest is keeping the reader connected through a day of real use. Stripe's Tap to Pay guidance recommends connecting to the reader in the background when the app starts, so the first customer does not wait, and using automatic reconnection when the app comes back to the foreground. Unexpected disconnects happen: Stripe lists the OS killing the Tap to Pay service under memory pressure and the device losing internet as examples.
With `autoReconnectOnUnexpectedDisconnect` enabled (the default if you omit it), the SDK attempts to reconnect and notifies your `TapToPayReaderListener` through `onReaderReconnectStarted`, `onReaderReconnectSucceeded` and `onReaderReconnectFailed`. Your interface should reflect each of those: a kiosk that silently shows a pay button while the reader is reconnecting will collect frustrated taps. If you prefer to handle disconnects yourself, Stripe says to disable automatic reconnection first; doing both can break the reader session.
A few lifecycle points matter more on kiosks than on phones:
- Startup order. Initialize the SDK in `Application`, skipping the Tap to Pay process, then connect once the main activity is in the foreground. Flutter's engine start and your WebView's page load should not block or race the reader connection.
- Discovery cleanup. Stripe's sample cancels an in-progress discovery when the activity stops, otherwise the SDK can be left stuck in discovery. Kiosk apps that swap activities or restart the Flutter engine need the same discipline.
- Update requirements. Stripe documents a `TapToPayReaderListener.onUpdateRequirementsAvailable` callback that warns about upcoming OS, security patch or SDK requirements, with an enforcement date. Route it to your device-management alerts, not just the device screen, because nobody reads warnings on an unattended kiosk.
- Health reporting. Have each device report its connection status, SDK version, OS patch level and last successful payment to your backend. When a site calls to say payments are down, you want to know which of those changed.
What about offline payments?
Stripe Terminal has an offline mode in which the SDK or a smart reader stores payments locally and forwards them when connectivity returns. As of this writing, Stripe's offline payments documentation states that Tap to Pay on Android does not support offline mode; offline support is listed for several mobile and smart readers, and Tap to Pay on iPhone offline is in private preview in one country. Tap to Pay on Android also lists a stable internet connection among its device requirements.
For a Tap to Pay kiosk, then, offline handling means failing well rather than taking payments offline. Detect connectivity loss before the customer reaches checkout, show a clear message, and keep the order open. If your venue genuinely needs to accept cards through outages, that is an argument for a reader that supports offline mode, with its own design work: stored payments can be declined when they are eventually forwarded, so your order system needs a state for "accepted offline, not yet confirmed" and a process for handling those declines. Check the current docs for your reader and region before committing to either path.
Testing with simulated readers and real devices
Stripe's Terminal testing guide describes a built-in simulated reader in each SDK, so you can build and exercise the whole flow before you have hardware. For Tap to Pay on Android, you set `isSimulated` on the discovery configuration, but remember that the simulated Tap to Pay reader enforces the same device requirements as the real one, so it runs on a compatible physical device, not an emulator. The simulated reader can be configured with Stripe's simulated test cards, including cards that produce specific declines, so you can drive your error handling deterministically.
A test plan we would expect before any pilot covers:
- The happy path, with automatic and (if you use it) manual capture, ending with the order marked paid by the server.
- Each decline type you can simulate, confirming the same PaymentIntent is re-used and the order stays open.
- Operator cancel, customer cancel and your own kiosk timeout during collection.
- Network loss at each step: before intent creation, during processing and after the device succeeds but before the webhook arrives.
- App kill and device reboot mid-payment, then recovery from the backend's view of the order.
- Webhooks delivered late, twice and out of order.
- Reader disconnect and automatic reconnection, with the interface showing each state.
- PIN entry on the locked-down kiosk build, because overlays and accessibility services can block it.
Stripe also notes that Terminal cannot be used with mobile wallets in test mode, so wallet flows need a controlled live check. And because the production Tap to Pay reader refuses debuggable builds, your release pipeline needs a signed, non-debuggable build that you install on real devices before go-live; that is the build that proves device eligibility.
A go-live checklist for Stripe Terminal kiosks
Stripe publishes its own Terminal integration checklist, and you should work through it. These are the items we add for Flutter and kiosk deployments, where most of the risk sits in the layers around the SDK.
Then launch small: one site, staff on hand, and a daily look at reconciliation for the first weeks. Most issues in this kind of system are found in the first few days of real customers, and they are much cheaper to find at one site than at fifty.
From here, this article is about Cevrynt
How Cevrynt builds Stripe Terminal and Tap to Pay integrations
Cevrynt is a Stripe partner, a member of the Stripe Partner Ecosystem, and our engineering team builds in-person payment integrations for POS and kiosk products. Arindam Pradhan, our founder, holds the Stripe Certified Professional, Implementation Architect certification, and Anupam Pradhan holds the Stripe Certified Professional, Payments Architect certification. Our Terminal work so far includes the Megapos kiosk: a web interface in a Flutter WebView with a Kotlin bridge to Tap to Pay and a Node/Express backend.
A Terminal engagement starts with discovery and a device and region check against Stripe's documentation, then a written integration design covering layers, payment state and failure handling, with a written scope and price. We build in your repository, test in simulated and failure scenarios and on compatible hardware, then support a controlled pilot. See Stripe Terminal and Tap to Pay engineering and our wider Stripe services, or tell us about your kiosk.
