Peer-to-Peer Payment Integration With Stream and Flutter

14 min read
Souvik B.
Gordon H.
Souvik B. & Gordon H.
Published August 13, 2021 Updated February 7, 2022
peer to peer payment stream chat app

Adding a peer-to-peer payment integration to your Flutter application creates a richer in-app experience for your end-users. However, you need to make sure your payment process is fast and secure.

In this tutorial, you’ll learn how to integrate a peer-to-peer payment solution in your Stream Chat Flutter application using an in-app digital wallet that provides both speed and security.

This tutorial will cover the following in detail:

  • What Are Peer-to-Peer Payment Services?
  • Setting Up Your Stream Chat Flutter App Environment
    • Set Up Stream
    • Set Up Rapyd
  • Create Your Flutter App
  • Add a Custom Action Widget
  • Add Payment Input Functionality
  • Add an Attachment Thumbnail Preview
  • Create a Digital Wallet With Rapyd Client
    • Generate a Signature
    • Transfer Money
    • Confirm a Transaction
  • Perform a Transaction
  • Build a Custom Attachment Preview
  • Wrapping Up

What Are Peer-to-Peer Payment Services?

Peer-to-peer (P2P) payment services provide a secure platform for end-users to make in-app financial transactions with other users or businesses (think Venmo, Zelle, or PayPal).

To make these mobile transactions possible, users must link their credit card or bank account details to their app account. When you send another user a payment, they can keep the P2P payment in a digital wallet for future use or transfer it to their bank account.

Setting Up Your Stream Chat Flutter App Environment

Before starting, you’ll need a:

  • Stream account for accessing the Stream Chat Messaging API.
  • Rapyd account for building P2P payment using the Rapyd Wallet API.

Stream Setup

If you don't already have a Stream account, you can start your free trial for Stream’s Chat Messaging.

If you’re working on a personal project or own a small business, you can register for a Stream Maker Account and access Stream Chat for free indefinitely.

Stream Get Started 30-day trial landing page.

After creating a Stream account, you can view your Stream dashboard and your first app.

Stream dashboard page.

Your app comes with the following:

  • API Key
  • Secret

Your API Key is only an app identifier and safe to share publicly. Your Secret helps generate authenticated user tokens and should be kept private.

From this dashboard, you can also edit the app name and create new apps.

Rapyd Setup

You’ll need a Rapyd account to build your P2P payment solution using Rapyd’s Wallet API.

Rapyd API sign-up page.

After signing up, you’ll be redirected to your Rapyd Client Portal.

In your client portal:

  1. Select the Sandbox toggle button (this gives you sample data to work with so you can test transactions with their test wallets).
  2. Go to the Developers page and save your Secret Key and Access Key (both are required to access Rapyd’s API from your app).
Rapyd API dashboard where you can select Sandbox mode.

Create Your Flutter App

In your terminal, create a new Flutter project with the following command:

$ flutter create stream_payment

⚠️Note: This tutorial was tested using version 2.2.3 of Flutter.

Open the project in your preferred IDE, and add the following packages to your pubspec.yaml file:

dart
dependencies:
  stream_chat_flutter: ^2.0.0-nullsafety.8
  http: ^0.13.3
  loading_overlay: ^0.3.0

Replace the code inside main.dart with the following:

dart
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';

import 'screens/channel_list_page.dart';
import 'secrets.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  final client = StreamChatClient(
    STREAM_KEY,
    logLevel: Level.OFF,
  );

  const USER_ID = 'sbis04';

In the above code snippet, you:

  1. Instantiated a StreamChatClient using Stream’s Flutter SDK.
  2. Connected a user and set up a channel with the StreamChatClient.
  3. Called the StreamChat widget constructor inside MyApp and displayed the ChannelListPage, which contains a list of all channels for your Stream app.

The STREAM_KEY and USER_TOKEN are defined inside secrets.dart, like this:

dart
// Stream secrets
const STREAM_KEY = 'key-here';
const USER_TOKEN = 'user-token-here';

To get your STREAM_KEY and USER_TOKEN:

  1. Copy your API Secret from your Stream dashboard.
  2. Go to Stream’s User JWT Generator.
  3. In the Your secret field, paste your API Secret.
  4. In the User ID field, enter a unique string to identify your user.
Stream's JWT Generator page.

⚠️Note: In a production scenario, you must generate a token using your server and one of Stream's server SDKs. You should never hardcode tokens in a production application.

Next, you’ll display a list of channels where the current user is a member. It’s better to separate the code into multiple files so that it’s easier to maintain as the code grows.

Create a new file called channel_list_page.dart and add the following code:

dart
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';

import 'channel_page.dart';

class ChannelListPage extends StatelessWidget {
  final Channel channel;

  const ChannelListPage(this.channel);

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Stream Chat'),

To display the message list view, create a new file called channel_page.dart and add the following code:

dart
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';

class ChannelPage extends StatelessWidget {
  final Channel channel;
  const ChannelPage(this.channel);

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: ChannelHeader(),
      body: Column(
        children: <Widget>[
          Expanded(
            child: MessageListView(),

Notice that you’ve passed down the channel variable as a constructor argument; you’ll need it for other operations later.

If you run the app now, you’ll find–with only a small amount of code–that you have a pretty robust messaging app complete with all the necessary functionality 😎 .

Add a Custom Action Widget

Next, add a custom action widget so that it’s convenient for users to trigger a payment. For example, in your MessageInput you can add an IconButton like the one below:

A custom action Flutter widget so users can initiate a payment.

To add the IconButton, add the custom actions argument below to channel_page.dart:

dart

Widget build(BuildContext context) {
  return Scaffold(
    appBar: ChannelHeader(),
    body: Column(
      children: <Widget>[
        Expanded(
          child: MessageListView(),
        ),
        MessageInput(
          actions: [
            IconButton(
              icon: Icon(Icons.payment),
              onPressed: _onPaymentRequestPressed,
            ), // custom action

Next, define what will happen when a user selects the payment icon. To handle this logic, do the following:

  1. Create an _onPaymentRequestPressed() method.
  2. In the method, create a new TransactionPage screen that allows users to enter the amount they’d like to send.
  3. Create a PageRouteBuilder method that surrounds the widget with a semi-transparent background.
dart
Future<void> _onPaymentRequestPressed() async {
  final String? amount = await Navigator.of(context).push(
    PageRouteBuilder(
      opaque: false,
      pageBuilder: (_, __, ___) => TransactionPage(
        destinationWalletAddress: 'ewallet-123',
      ),
    ),
  );
}

⚠️NOTE: At the moment, you’re using a placeholder for the destination wallet. You’ll update the placeholder after defining the Rapyd client.

After receiving the amount:

  1. Create a Stream Chat Attachment.
  2. Specify the type to be payment.
  3. Set the extraData with a key of amount and a value set to the amount the user entered.
dart
Future<void> _onPaymentRequestPressed() async {
  // ...

  if (amount != null) {
    _messageInputKey.currentState?.addAttachment(
      Attachment(
        type: 'payment',
        uploadState: UploadState.success(),
        extraData: {"amount": int.parse(amount)},
      ),
    );
  }
}

To do this, create a GlobalKey:

dart
class _ChannelPageState extends State<ChannelPage> {
  GlobalKey<MessageInputState> _messageInputKey = GlobalKey();
  // ...
}

Then, set it as the key on MessageInput:

dart
MessageInput(
  key: _messageInputKey, // add key here
  actions: [
    IconButton(
      icon: Icon(Icons.payment),
      onPressed: _onPaymentRequestPressed,
    ),
  ],
)

Add Payment Input Functionality

Next, you’ll create several UI elements for all the payment functionality.

First, create the TransactionPage where a user can enter the transaction amount.

This screen will contain a TextField to take the user input and preview the destination wallet address that was passed in.

Stream Chat Flutter app transaction page.

Create a new file called transaction_page.dart, and add the following code:

dart
import 'package:flutter/material.dart';

class TransactionPage extends StatelessWidget {
  final String destinationWalletAddress;

  TransactionPage({required this.destinationWalletAddress});

  final TextEditingController _amountTextController = TextEditingController();

  
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.black26,
      body: Center(
        child: Padding(

Add an Attachment Thumbnail Preview

To show the custom attachment thumbnail preview, you have to use the attachmentThumbnailBuilders property inside the MessageInput widget. This lets you create a custom widget to display the payment as a preview while the user types in a message they want to send along with it.

dart
MessageInput(
  key: _messageInputKey,
  attachmentThumbnailBuilders: {
    'payment': (context, attachment) => TransactionAttachment(
          amount: attachment.extraData['amount'] as int,
        )
  },
  actions: [
    IconButton(
      icon: Icon(Icons.payment),
      onPressed: _onPaymentRequestPressed,
    ),
  ],
)

Insert the code for the TransactionAttachment widget:

dart
class TransactionAttachment extends StatelessWidget {
  final int amount;

  const TransactionAttachment({required this.amount});

  
  Widget build(BuildContext context) {
    return Container(
      color: Color(0xFF4161ff),
      width: double.maxFinite,
      height: 300,
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Icon(

Below is an example of the attachment with an amount of $5:

Adding an attachment thumbnail in the Stream Flutter app.
Building your own app? Get early access to our Livestream or Video Calling API and launch in days!

With the basic UI set up to handle payments, you can integrate P2P payments with Rapyd Wallet.

Create a Digital Wallet With Your Rapyd Client

You must use wallets to process payments and enable transactions between users. With Sandbox mode turned on in your Rapyd Client Portal, you can access a number of sample wallets with default Account Balance amounts.

Rapyd Wallets Sandbox dashboard with test wallets.

You can choose any two wallets. You’ll use one wallet as the source wallet (the wallet sending the payment) and the other one as the destination wallet (the wallet receiving the payment).

Since the wallet address will be unique for each user, you can store the wallet address as extra data within the Stream user account.

In the main.dart file, pass the source wallet address in extraData for the User:

dart
// replace with your source wallet address
const String _myWalletId = 'ewallet_3d63cc520dff85b043914de569390fd1';
const USER_ID = 'sbis04';

await client.connectUser(
  User(
    id: USER_ID,
    extraData: {
      'wallet_id': _myWalletId, // pass it here
      'name': 'Souvik Biswas',
      'image': '<https://i.pravatar.cc/150?img=8>',
    },
  ),
  USER_TOKEN,
);

This will store the specified wallet address, for this user, within Stream's database. In a similar manner, you can create another user with a separate wallet address on Stream. Below is a demo showing multiple users and their information.

Example Stream demo using multiple users.

To view your users:

  1. Go to your Stream dashboard.
  2. Select Options.
  3. Select Open in Chat Explorer.

To create your Rapyd Client, create a new file called apyd_client.dart and define the RapydClient class inside it:

dart
class RapydClient {
  final _baseURL = '<https://sandboxapi.rapyd.net>';
  final _accessKey = RAPYD_ACCESS_KEY;
  final _secretKey = RAPYD_SECRET_KEY;

  // ...
}

Get your Rapyd Access Key and Secret Key from the Rapyd Client Portal and store them inside the secrets.dart file:

dart
// Rapyd secrets
const RAPYD_ACCESS_KEY = 'access-key-here';
const RAPYD_SECRET_KEY = 'secret-key-here';

Generate a Signature

To use the Rapyd API, you must calculate and pass a signature within the request headers. The formula provided by Rapyd for signature calculation is as follows:

dart
signature = BASE64 ( HASH ( http_method + url_path + salt + timestamp + access_key + secret_key + body_string ) )

Note: Please see the Rapyd documentation for additional examples and security considerations.

This process is a bit tricky to execute correctly using Dart, but here’s the entire process for generating the signature:

dart
Map<String, String> _generateHeader({
  required String method,
  required String endpoint,
  String body = '',
}) {
  int unixTimetamp = DateTime.now().millisecondsSinceEpoch;
  String timestamp = (unixTimetamp / 1000).round().toString();

  var salt = _generateSalt();

  var toSign = method + endpoint + salt + timestamp + _accessKey + _secretKey + body;

  var keyEncoded = ascii.encode(_secretKey);
  var toSignEncoded = ascii.encode(toSign);

The method _generateHeader is used to create the correct headers, specifically the signature. This requires the HTTP method, the endpoint, and the JSON data (if present) to generate the correct signature.

Transfer Money

With the difficult part out of the way, you can create a method to transfer money from one wallet to another.

This will require the source wallet address, the destination wallet address, and the amount to transfer.

dart
Future<Transfer?> transferMoney({
  required String sourceWallet,
  required String destinationWallet,
  required int amount,
}) async {
  Transfer? transferDetails;

  var method = "post";
  var transferEndpoint = '/v1/account/transfer';

  final transferURL = Uri.parse(_baseURL + transferEndpoint);

}

The Transfer class is a user-defined model that lets you store the details of a Rapyd transaction. To get the Transfer model, copy+paste the code from the Peer-to-Peer GitHub repo.

Define the JSON data you’ll send as a part of the transfer request:

dart
Future<Transfer?> transferMoney({
  required String sourceWallet,
  required String destinationWallet,
  required int amount,
}) async {
  // ...

  var data = jsonEncode({
    "source_ewallet": sourceWallet,
    "amount": amount,
    "currency": "USD",
    "destination_ewallet": destinationWallet,
  });

}

Get the headers by using the _generateHeader() method defined earlier:

dart
Future<Transfer?> transferMoney({
  required String sourceWallet,
  required String destinationWallet,
  required int amount,
}) async {
  //...

  final headers = _generateHeader(
    method: method,
    endpoint: transferEndpoint,
    body: data,
  );

}

Now, use the http.post() method to send the request to the API:

dart
Future<Transfer?> transferMoney({
  required String sourceWallet,
  required String destinationWallet,
  required int amount,
}) async {
  //...

  try {
    var response = await http.post(
      transferURL,
      headers: headers,
      body: data,
    );

    print(response.body);

If the request is successful (status code 200), the response body will contain the details of the transaction.

dart
{
    "status": {
        "status": "SUCCESS",
        "operation_id": "1845c31a-c1e6-4df4-8cb2-002d2dab250b"
    },
    "data": {
        "id": "bf61f300-dc1d-11eb-b38b-02240218ee6d",
        "status": "PEN",
        "amount": 5,
        "currency_code": "USD",
        "destination_phone_number": "+611868065687",
        "destination_ewallet_id": "ewallet_3d63cc520dff85b043914de569390fd1",
        "destination_transaction_id": "",
        "source_ewallet_id": "ewallet_b57a9c68acfea31b35990d215ba0eb8c",
        "source_transaction_id": "wt_2d00834757d8255cd656379926f1ea21",
        "created_at": 1625330588
    }
}

You can parse this JSON response and store it inside a Transfer object. For example, final transfer = Transfer.fromJson(json).

The JSON response will contain a transaction ID with the status PEN (for pending). In order to confirm the transaction, you must perform one more POST request.

Confirm Transaction

To confirm the transaction, you must send a Transfer request to the /v1/account/transfer/response endpoint.

To do so, you must define a new method that takes the following parameters:

  • Transaction ID (”id”: id)
  • Status (”status”: response)
dart
Future<Transfer?> transferResponse({
  required String id,
  required String response,
}) async {
  Transfer? transferDetails;

  var method = "post";
  var responseEndpoint = '/v1/account/transfer/response';

  final responseURL = Uri.parse(_baseURL + responseEndpoint);

  var data = jsonEncode({
    "id": id,
    "status": response,
  });

}

The JSON data you’ll send should contain the ID and the status. Use the _generateHeader() method to generate the headers, and use http.post() method to send the request:

dart
Future<Transfer?> transferResponse({
  required String id,
  required String response,
}) async {
  // ...

  final headers = _generateHeader(
    method: method,
    endpoint: responseEndpoint,
    body: data,
  );

  try {
    var response = await http.post(
      responseURL,

After confirming the transaction, its status will update to one of the following:

  • CLO (or closed), meaning you successfully completed the transaction.
  • DEC (or declined), meaning you declined the transaction.

Now that you’ve defined the Rapyd Client, you can use the methods in the next section to make a successful transfer.

Perform a Transaction

You should perform the transaction before sending or showing the message inside MessageListView. The best place to do this is in the preMessageSending callback.

Before you perform the transaction, you must retrieve the source and destination wallet addresses.
Define a method called getWallets() inside the _ChannelPageState class:

dart
late final String _sourceWalletId;
late final String _destinationWalletId;

getWallets() async {
  var members = await widget.channel.queryMembers();
  var destId = members.members[1].user!.extraData['wallet_id'] as String;
  var sourceId = members.members[0].user!.extraData['wallet_id'] as String;

  _sourceWalletId = sourceId;
  _destinationWalletId = destId;
}


void initState() {
  super.initState();
  getWallets();
}

This code uses the channel to retrieve the members, and stores their respective wallet addresses.

Perform the transaction inside the preMessageSending callback:

dart
MessageInput(
  key: _messageInputKey,
  preMessageSending: (msg) => _performTransaction(msg), // this
  attachmentThumbnailBuilders: {
    'payment': (context, attachment) => TransactionAttachment(
          amount: attachment.extraData['amount'] as int,
        )
  },
  actions: [
    IconButton(
      icon: Icon(Icons.payment),
      onPressed: _onPaymentRequestPressed,
    ),
  ],
)

Inside the _performTransaction() method, check if the message contains an attachment with the amount as extra data.

This way, you can verify that the attachment is related to the payment.

dart
bool _isSending = false;

Future<Message> _performTransaction(Message msg) async {
  if (msg.attachments.isNotEmpty &&
      msg.attachments[0].extraData['amount'] != null) {
    setState(() {
      _isSending = true;
    });

    // perform transaction here

    setState(() {
      _isSending = false;
    });
  }

  return msg;
}

The Boolean _isSending indicates whether the transaction is in progress. If the transaction is in progress, don’t allow the user to send any messages; instead, show a progress indicator.
Retrieve the amount from the attachment and perform the transaction using RapydClient. If the transaction is successful, update the message attachment.

dart
RapydClient _rapydClient = RapydClient();

Future<Message> _performTransaction(Message msg) async {
  if (msg.attachments.isNotEmpty &&
      msg.attachments[0].extraData['amount'] != null) {
    setState(() {
      _isSending = true;
    });

    // Retrieve the amount
    int amount = msg.attachments[0].extraData['amount'] as int;

    // Process the transaction
    var transactionInfo = await _rapydClient.transferMoney(
      amount: amount,

Once the transaction is complete, send the message with a custom attachment that will show up on the message list.

Build a Custom Attachment Preview

You can build your custom attachment and pass it to the MessageListView using the customAttachmentBuilders property.

dart
MessageListView(
  customAttachmentBuilders: {'payment': _buildPaymentMessage},
)

The custom widget is defined within the _buildPaymentMessage() method.

dart
Widget _buildPaymentMessage(
  BuildContext context,
  Message details,
  List<Attachment> _,
) {
  final transaction = Transfer.fromJson(details.attachments.first.extraData);
  final transactionInfo = transaction.data;

  int amount = transactionInfo.amount;
  String destWalletAddress = transactionInfo.destinationEwalletId;
  String status = transactionInfo.status;

  return wrapAttachmentWidget(
    context,
    TransactionWidget(

Now, you retrieved the required properties of the transaction stored inside the Transfer class.

The TransactionWidget will display the transaction attachment. Get the code for this widget in the Peer-to-Peer Github repo.

Completed transaction widget screen in  Flutter app.

Wrap the contents of the TransactionWidget with an InkWell to navigate to the DetailPage.

dart
class TransactionWidget extends StatelessWidget {
  // ...

  
  Widget build(BuildContext context) {
    return InkWell(
      onTap: () {
        Navigator.of(context).push(
          PageRouteBuilder(
            opaque: false,
            pageBuilder: (_, __, ___) => DetailPage(
              transaction: transaction,
            ),
          ),
        );

The DetailPage contains the transaction information. You can find the UI code for the page in the Peer-to-Peer GitHub repo.

Transaction details widget screen.

The final step requires the Boolean flag (_isSending) you used to indicate the progress of the transaction.

Use this Boolean flag to update the UI by showing a progress indicator with the help of the loading_overlay package.

Wrap the contents of the Scaffold with the LoadingOverlay widget and use the Boolean to indicate whether the transaction is in progress.

dart

Widget build(BuildContext context) {
  return Scaffold(
    appBar: ChannelHeader(),
    body: LoadingOverlay(
      isLoading: _isSending,
      color: Colors.black,
      // ...
    ),
  );
}

See the transaction process in action:

Wrapping Up

Congratulations 🎉, you successfully implemented a peer-to-peer payment solution using Stream’s Flutter SDK and Rapyd’s Wallet API.

Users can now send and receive payments within your messaging app.

decorative lines
Integrating Video With Your App?
We've built an audio and video solution just for you. Launch in days with our new APIs & SDKs!
Check out the BETA!