Tap, Pay, and Go: Implementing Stripe Tap-to-Pay in Flutter

Tap, Pay, and Go: Implementing Stripe Tap-to-Pay in Flutter

This article by Sahil Sharma explores how to integrate Stripe's Tap-to-Pay feature into your Flutter app, allowing users to accept contactless payment

Explore how to use Stripe's Tap-to-Pay feature with Flutter. This feature allows users to accept contactless payments on iPhone and Android devices without extra hardware. By integrating Tap-to-Pay, businesses can process transactions faster, increasing turnover and improving customer satisfaction.

There is no comprehensive guide on achieving this feature using Flutter, but we aim to change that with this article.

Let's dive into how you can set this up in your Flutter app.

Prerequisites

To follow along with this tutorial, you will need a Stripe account registered in locations where the Tap-to-Pay feature is available. For more details, please refer to the Stripe documentation here.
https://docs.stripe.com/terminal/payments/setup-reader/tap-to-pay

GitHub source code used in this blog:
https://github.com/SahilSharma2710/flutter_stripe_tap_to_pay

Getting started

To get started, we need to generate an API key from Stripe. To do this, you would need to create a Stripe account. After this, log in to your dashboard, activate Test mode for integration and testing, and go to Developers > API Keys to reveal your API keys (Publishable and Secret Keys).

Setting up our environment

Add the following packages to your dependencies:

mek_stripe_terminal: ^3.7.0
 http: ^1.2.2
 permission_handler: ^11.3.1
 flutter_dotenv: ^5.1.0

In this tutorial, we will use the mek_stripe_terminal package to integrate Stripe's Tap-to-Pay feature into our Flutter app—a Flutter plugin to scan stripe readers, connect to them, and get the payment methods.

With mek_stripe_terminal, you can manage readers, handle payments, and ensure secure transactions within your Flutter application. It simplifies the integration process, making it more accessible for developers to leverage Stripe's powerful payment solutions.

https://pub.dev/packages/mek_stripe_terminal

We will use HTTP to interact with the Stripe API; you can use Dio or any other package to handle your API requests.

Finally, the flutter_dotenv package protects our Stripe Keys and permission_handler to handle required permissions.

Android Setup

For using this package, minSdkVersion 26 is required.
So will change it in android/app/build.gradle-

defaultConfig {
       …
       minSdk = 26
       …
   }

The Stripe Terminal Android SDK requires different permissions depending on the type of terminal device you’re using. For Tap-to-Pay, Bluetooth and Location are required.

To do this, make sure you’re adding the following lines to the- android/app/src/main/AndroidManifest.xml file:

<uses-permission android:name="android.permission.BLUETOOTH" />
   <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />

   <uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
   <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
   <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

Additional Error Handling for Android-
I have experienced some unexpected Kotlin version errors with this mek_stripe_terminal package.
I solved this by updating my project’s Kotlin version to 1.9.0 + .

You can change the Kotlin version for your project by changing-android/setting.gradle -

plugins {
      …
      id "org.jetbrains.kotlin.android" version "1.9.10" apply false
   }

Also, change jvm target in android/app/build.gradle -

    android { 
    …

    kotlinOptions {
        jvmTarget = '1.8'
    }

    …

   }

You may also need to disable the android.enableJetifier in android/gradle.properties

android.enableJetifier=false

IOS Setup

You need to provide permission request strings to your Info.plist file. A sample content can be:-

 <key>NSLocationWhenInUseUsageDescription</key>
    <string>Location access is required in order to accept payments.</string>
    <key>NSBluetoothPeripheralUsageDescription</key>
    <string>Bluetooth access is required in order to connect to supported bluetooth card readers.</string>
    <key>N SBluetoothAlwaysUsageDescription</key>
    <string>This app uses Bluetooth to connect to supported card readers.</string>

You also need to authorize background modes authorization for bluetooth-central. Paste the following to your Info.plist file.

<key>UIBackgroundModes</key>
    <array>
        <string>bluetooth-central</string>
    </array>

You will also need to change the minimum deployment target to 13.0.

Just open your ios folder in Xcode, as shown below:-

Adding Tap-to-Pay Feature and App Store Submission Process:

To use the Tap-to-Pay feature in your production iOS app or while uploading it to TestFlight, you must request additional iPhone Entitlement Permissions from Apple. Here are the steps and requirements to make your app live on the App Store.

  1. Apple Developer Account:

    • Ensure you have an Apple Developer account. This is necessary to request the Tap-to-Pay on iPhone entitlement.
  2. Entitlement Request:

  3. Development and Publishing Entitlements:

    • Development Entitlement: You will initially receive a development entitlement that allows you to build and test your implementation.

    • Publishing Entitlement: Once your app meets the user experience requirements, you can request a publishing entitlement. This is required to submit your app to the App Store using TestFlight.

  4. Email Communication with Apple:

    • Upon receiving the development entitlement, you will get an email from with instructions and two attached documents: "Tap-to-Pay on iPhone App Requirements and Review" and "App Review Requirements Checklist".

    • Reply to this email with the requested materials for review to obtain the publishing entitlement.

  5. Requirements for App Review:

    • Your app must meet several requirements detailed in the "Tap-to-Pay on iPhone App Requirements and Review" document. This includes providing a video recording of the “New User Flow,” “Existing User Flow,” and “Checkout Flow,” and a completed App Review Requirements Checklist.
  6. Steps for App Store Submission:

    • Design and Build: Ensure your app design meets the guidelines provided by Apple.

    • Testing: Use TestFlight to distribute the app for testing outside your development team. Ensure the app meets all user experience requirements.

    • Checklist Submission: Complete the App Review Requirements Checklist and submit it with the required video recordings.

    • Final Review: Apple will review your submission. Once approved, you will receive the publishing entitlement.

  7. Publishing the App:

    • Once the app meets all requirements and passes Apple’s review, you can submit it to the App Store.

    • Ensure all marketing and educational materials adhere to Apple’s guidelines.

Email Example from Apple

Here is an example of the communication you might receive from Apple upon granting the development entitlement:

Subject: Tap-to-Pay on iPhone Entitlement Granted

Hello,

The entitlement request for Tap-to-Pay on iPhone has been granted, with the development distribution restriction in place. The restriction will allow you to incorporate Tap-to-Pay on iPhone in your new or existing app. Distribution is limited to registered test devices only.

There are two (2) files attached that you must review:

  • Tap-to-Pay on iPhone App Requirements and Review.

  • App Review Requirements Checklist.

IMPORTANT: Your app must meet the requirements in the attached documents to move forward.

Once you have built your app to meet the requirements, reply to this email to request the publishing entitlement. You will need to provide the following for review:

  1. A video recording of the “New User Flow”

  2. A video recording of the “Existing User Flow”

  3. A video recording of the “Checkout Flow”

  4. A completed App Review Requirements Checklist.

The Entitlements and App Review section of the attached Tap-to-Pay on iPhone App Requirements and Review document provides more details on what we expect in the video recording.

Thanks,

Apple.

By following these steps and ensuring your app meets Apple’s requirements, you can successfully implement the Tap-to-Pay feature and submit your app to the App Store.

So, now we have everything in its place, and we can start with the code.

Implementing Tap-to-Pay in our App

  1. Request Permission

This function, requestPermissions, requests necessary permissions for location and Bluetooth usage. For Android devices, it additionally requests Bluetooth scan and connect permissions.

Future<void> requestPermissions() async {
    final permissions = [
      Permission.locationWhenInUse,
      Permission.bluetooth,
      if (Platform.isAndroid) ...[
        Permission.bluetoothScan,
        Permission.bluetoothConnect,
      ],
    ];

    for (final permission in permissions) {
      final result = await permission.request();
      if (result == PermissionStatus.denied ||
          result == PermissionStatus.permanentlyDenied) return;
    }
  }
  1. Initialize Terminal

Initializing Stripe Terminal is crucial for setting up the environment to interact with Stripe's payment processing services. This step establishes a connection between your Flutter app and the Stripe Terminal SDK, enabling the app to handle various payment tasks such as discovering and connecting to payment readers, processing transactions, and managing device updates.

To initialize the stripe terminal, we first need a connection-token.

A connection token is needed to authenticate your app with Stripe's servers securely. This token allows your app to connect with Stripe Terminal and perform operations like processing payments and managing readers.

Future<String> getConnectionToken() async {
    http.Response response = await http.post(
        Uri.parse("https://api.stripe.com/v1/terminal/connection_tokens"),
      headers: {
        'Authorization': 'Bearer ${dotenv.env['STRIPE_SECRET']}',
        'Content-Type': 'application/x-www-form-urlencoded'
      },
    );
    Map jsonResponse = json.decode(response.body);
    print(jsonResponse);
    if (jsonResponse['secret'] != null) {
      return jsonResponse['secret'];
    } else {
      return "";
    }
  }

With this connection token, we can initialize our stripe terminal.

Terminal? _terminal;
   StreamSubscription? _onConnectionStatusChangeSub;
   var _connectionStatus = ConnectionStatus.notConnected;
   StreamSubscription? _onPaymentStatusChangeSub;
   PaymentStatus _paymentStatus = PaymentStatus.notReady;
   StreamSubscription? _onUnexpectedReaderDisconnectSub;



   Future<void> initTerminal() async {
    final connectionToken = await getConnectionToken();
    final terminal = await Terminal.getInstance(
      shouldPrintLogs: false,
      fetchToken: () async {
        return connectionToken;
      },
    );
    _terminal = terminal;
    showSnackBar("Initialized Stripe Terminal");

    _onConnectionStatusChangeSub =
        terminal.onConnectionStatusChange.listen((status) {
      print('Connection Status Changed: ${status.name}');
      _connectionStatus = status;
      scanStatus = _connectionStatus.name;
    });
    _onUnexpectedReaderDisconnectSub =
        terminal.onUnexpectedReaderDisconnect.listen((reader) {
      print('Reader Unexpected Disconnected: ${reader.label}');
    });
    _onPaymentStatusChangeSub = terminal.onPaymentStatusChange.listen((status) {
      print('Payment Status Changed: ${status.name}');
      _paymentStatus = status;
    });
    if (_terminal == null) {
      print('Please try again later!');
    }
  }
  1. Fetching Location

To use Stripe Terminal, you must register one or more locations to manage readers and their activity by associating them with a physical location.

You can create these locations via the Stripe Dashboard or using the API.

And these locations must be those where Stripe Tap-to-Pay is available.

This way, the activity of a reader associated with a location will be reflected in the dashboard, and you will be able to collect data about how the different locations perform in sales.

var _locations = <Location>[];
    Location? _selectedLocation;

    Future<void> _fetchLocations() async {
    final locations = await _terminal!.listLocations();
    _selectedLocation = locations.first;
    print(_selectedLocation);
    if (_selectedLocation == null) {
      throw AssertionError(
          'Please create location on stripe dashboard to proceed further!');
    }
  }
  1. Discover & Connect Reader

For testing, set**_isSimulated** to true to open the Tap-to-Pay simulation screen. When you're ready to launch your app in production, set**_isSimulated** to false.

You need to call the discoverReaders method to find available readers and select the one you want to use.

StreamSubscription? _discoverReaderSub;
    List<Reader> _readers = [];
    bool _isSimulated = true; //if testing >> true otherwise false

    void _startDiscoverReaders(Terminal terminal) {
    isScanning = true;
    _readers = [];
    final discoverReaderStream =
        terminal.discoverReaders(const LocalMobileDiscoveryConfiguration(
      isSimulated: _isSimulated,
    ));
    setState(() {
      _discoverReaderSub = discoverReaderStream.listen((readers) {
        scanStatus = "Tap on Any To connect ";
        setState(() => _readers = readers);
      }, onDone: () {
        setState(() {
          _discoverReaderSub = null;
          _readers = const [];
        });
      });
    });
  }

Connecting a reader to a location

Now that we have listed our locations and discovered the reader we want to use, let’s connect the location to the reader using the connectMobileReader method in the _tryConnectReader function created in the code.

Reader? _reader;

   Future<void> _connectReader(Terminal terminal, Reader reader) async {
    await _tryConnectReader(terminal, reader).then((value) {
      final connectedReader = value;
      if (connectedReader == null) {
        throw Exception("Error connecting to reader ! Please try again");
      }
      _reader = connectedReader;
    });
  }

  Future<Reader?> _tryConnectReader(Terminal terminal, Reader reader) async {
    String? getLocationId() {
      final locationId = _selectedLocation?.id ?? reader.locationId;
      if (locationId == null) throw AssertionError('Missing location');

      return locationId;
    }

    final locationId = getLocationId();

    return await terminal.connectMobileReader(
      reader,
      locationId: locationId!,
    );
  }

After connecting to the reader, we can create the payment intent and then collect the payment.

  1. Create PaymentIntent

As mentioned earlier, tap-to-pay is available at limited locations, so you can create the payment intent with the currency of an eligible country.

To know more about Tap-to-Pay and for updated currency, please visit.

 PaymentIntent? _paymentIntent;
  CancelableFuture<PaymentIntent>? _collectingPaymentMethod;


  Future<bool> _createPaymentIntent(Terminal terminal, String amount) async {
    final paymentIntent =
        await terminal.createPaymentIntent(PaymentIntentParameters(
      amount:
          (double.parse(double.parse(amount).toStringAsFixed(2)) * 100).ceil(),
      currency: "usd",
      captureMethod: CaptureMethod.automatic,
      paymentMethodTypes: [PaymentMethodType.cardPresent],
    ));
    _paymentIntent = paymentIntent;
    if (_paymentIntent == null) {
      showSnackBar('Payment intent is not created!');
    }

    return await _collectPaymentMethod(terminal, _paymentIntent!);
  }
  1. CollectPaymentMethod & ConfirmPaymentMethod

Calling_collectPaymentMethod and_confirmPaymentIntent: After creating a payment intent, it is necessary to first gather the payment details from the user and then confirm the payment with Stripe. This two-step process ensures that the payment method is valid and securely processes the transaction, completing the payment cycle.


   bool _isPaymentSuccessful = false;


   Future<bool> _collectPaymentMethod(
      Terminal terminal, PaymentIntent paymentIntent) async {
    final collectingPaymentMethod = terminal.collectPaymentMethod(
      paymentIntent,
      skipTipping: true,
    );

    try {
      final paymentIntentWithPaymentMethod = await collectingPaymentMethod;
      _paymentIntent = paymentIntentWithPaymentMethod;
      await _confirmPaymentIntent(terminal, _paymentIntent!).then((value) {});
      return true;
    } on TerminalException catch (exception) {
      switch (exception.code) {
        case TerminalExceptionCode.canceled:
          showSnackBar('Collecting Payment method is cancelled!');
          return false;
        default:
          rethrow;
      }
    }
  }

  Future<void> _confirmPaymentIntent(
      Terminal terminal, PaymentIntent paymentIntent) async {
    try {
      showSnackBar('Processing!');

      final processedPaymentIntent =
          await terminal.confirmPaymentIntent(paymentIntent);
      _paymentIntent = processedPaymentIntent;
      // Show the animation for a while and then reset the state
      Future.delayed(Duration(seconds: 3), () {
        setState(() {
          _isPaymentSuccessful = false;
        });
      });
      setState(() {
        _isPaymentSuccessful = true;
      });
      showSnackBar('Payment processed!');
    } catch (e) {
      showSnackBar('Inside collect payment exception ${e.toString()}');

      print(e.toString());
    }
    // navigate to payment success screen
  }

We have completed Stripe Tap-to-Pay integration into our Flutter application.

After completing all the mentioned steps, we can process the payment through a simulated tap-to-pay.

And our end product looks like this-

You can keep track of transactions on your stripe terminal.

If you want to get started quickly, feel free to clone the demo application's repository and customize it to suit your use case.

Conclusion

In this article, we explored how to integrate Stripe's Tap-to-Pay feature into a Flutter app using the mek_stripe_terminal package. Following the steps outlined, you can enable users to accept contactless payments directly on their devices, enhancing transaction efficiency and customer satisfaction. We covered essential aspects like initializing Stripe Terminal, requesting necessary permissions, and securely processing payments. With this setup, your Flutter app can handle modern, seamless payment methods, paving the way for improved business operations and a better user experience.