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.
After creating a Stream account, you can view your Stream dashboard and your first app.
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.
After signing up, you’ll be redirected to your Rapyd Client Portal.
In your client portal:
- Select the Sandbox toggle button (this gives you sample data to work with so you can test transactions with their test wallets).
- Go to the Developers page and save your Secret Key and Access Key (both are required to access Rapyd’s API from your app).
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:
1234dependencies: 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:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253import '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'; await client.connectUser( User( id: USER_ID, extraData: { 'name': 'Souvik Biswas', 'image': '<https://i.pravatar.cc/150?img=8>', }, ), USER_TOKEN, ); final channel = client.channel('messaging', id: 'p2p-payment'); await channel.watch(); runApp(MyApp(client, channel)); } class MyApp extends StatelessWidget { final StreamChatClient client; final Channel channel; MyApp(this.client, this.channel); Widget build(BuildContext context) { return MaterialApp( builder: (context, widget) { return StreamChat( child: widget!, client: client, ); }, debugShowCheckedModeBanner: false, home: ChannelListPage(channel), ); } }
In the above code snippet, you:
- Instantiated a StreamChatClient using Stream’s Flutter SDK.
- Connected a user and set up a channel with the StreamChatClient.
- 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:
123// Stream secrets const STREAM_KEY = 'key-here'; const USER_TOKEN = 'user-token-here';
To get your STREAM_KEY and USER_TOKEN:
- Copy your API Secret from your Stream dashboard.
- Go to Stream’s User JWT Generator.
- In the Your secret field, paste your API Secret.
- In the User ID field, enter a unique string to identify your user.
⚠️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:
1234567891011121314151617181920212223242526272829import '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'), ), body: ChannelsBloc( child: ChannelListView( filter: Filter.in_('members', [StreamChat.of(context).user!.id]), sort: [SortOption('last_message_at')], pagination: PaginationParams(limit: 30), channelWidget: Builder( builder: (context) => ChannelPage(channel), ), ), ), ); } }
To display the message list view, create a new file called channel_page.dart
and add the following code:
12345678910111213141516171819202122import '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(), ), MessageInput(), ], ), ); } }
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:
To add the IconButton, add the custom actions
argument below to channel_page.dart
:
123456789101112131415161718192021Widget 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:
- Create an _onPaymentRequestPressed() method.
- In the method, create a new TransactionPage screen that allows users to enter the amount they’d like to send.
- Create a PageRouteBuilder method that surrounds the widget with a semi-transparent background.
12345678910Future<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:
- Create a Stream Chat Attachment.
- Specify the type to be payment.
- Set the extraData with a key of amount and a value set to the amount the user entered.
12345678910111213Future<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:
1234class _ChannelPageState extends State<ChannelPage> { GlobalKey<MessageInputState> _messageInputKey = GlobalKey(); // ... }
Then, set it as the key on MessageInput
:
123456789MessageInput( 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.
Create a new file called transaction_page.dart
, and add the following code:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113import '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( padding: const EdgeInsets.only(left: 16.0, right: 16.0), child: Container( width: double.maxFinite, decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(20.0), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ Container( width: double.maxFinite, decoration: BoxDecoration( color: Color(0xFF4161ff), borderRadius: BorderRadius.only( topLeft: Radius.circular(20.0), topRight: Radius.circular(20.0), ), ), child: Padding( padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Sending To', style: TextStyle( color: Colors.white70, fontSize: 16.0, ), ), SizedBox(height: 4), Text( destinationWalletAddress, style: TextStyle( color: Colors.white, fontSize: 14.0, ), ), ], ), ), ), Padding( padding: const EdgeInsets.all(16.0), child: Container( width: 150, child: TextField( // textAlign: TextAlign.center, controller: _amountTextController, decoration: InputDecoration( isDense: true, prefixIcon: Padding( padding: const EdgeInsets.only(right: 4.0), child: Text( '\\$', style: TextStyle(fontSize: 40.0), ), ), prefixIconConstraints: BoxConstraints(minWidth: 0, minHeight: 0), ), keyboardType: TextInputType.number, style: TextStyle(fontSize: 80.0), ), ), ), Padding( padding: const EdgeInsets.all(24.0), child: Container( width: double.maxFinite, child: ElevatedButton( style: ElevatedButton.styleFrom( primary: Color(0xFF4161ff), ), onPressed: () { if (_amountTextController.text != '') { Navigator.pop(context, _amountTextController.text); } }, child: Padding( padding: const EdgeInsets.all(10.0), child: Text( 'Pay', style: TextStyle(color: Colors.white, fontSize: 24.0), ), ), ), ), ) ], ), ), ), ), ); } }
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.
1234567891011121314MessageInput( 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:
123456789101112131415161718192021222324252627282930313233class 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( Icons.payment, color: Colors.white, size: 40, ), SizedBox(width: 8), Column( children: [ Text( '$amount USD', style: TextStyle(color: Colors.white, fontSize: 20.0), ), ], ), ], ), ); } }
Below is an example of the attachment with an amount of $5:
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.
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:
123456789101112131415// 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.
To view your users:
- Go to your Stream dashboard.
- Select Options.
- 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:
1234567class 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:
123// 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:
1signature = 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:
123456789101112131415161718192021222324252627282930313233343536373839Map<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); var hmacSha256 = Hmac(sha256, keyEncoded); // HMAC-SHA256 var digest = hmacSha256.convert(toSignEncoded); var ss = hex.encode(digest.bytes); var tt = ss.codeUnits; var signature = base64.encode(tt); var headers = { 'Content-Type': 'application/json', 'access_key': _accessKey, 'salt': salt, 'timestamp': timestamp, 'signature': signature, }; return headers; } String _generateSalt() { final _random = Random.secure(); // Generate 16 characters for salt by generating 16 random bytes // and encoding it. final randomBytes = List<int>.generate(16, (index) => _random.nextInt(256)); return base64UrlEncode(randomBytes); }
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.
12345678910111213Future<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:
123456789101112131415Future<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:
1234567891011121314Future<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:
1234567891011121314151617181920212223242526Future<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 (response.statusCode == 200) { print('SUCCESSFULLY TRANSFERRED'); transferDetails = Transfer.fromJson(jsonDecode(response.body)); } } catch (e) { print('Failed to transfer amount'); } return transferDetails; }
If the request is successful (status code 200), the response body will contain the details of the transaction.
123456789101112131415161718{ "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
)
1234567891011121314151617Future<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:
12345678910111213141516171819202122232425262728293031Future<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, headers: headers, body: data, ); print(response.body); if (response.statusCode == 200) { print('TRANSFER STATUS UPDATED: $response'); transferDetails = Transfer.fromJson(jsonDecode(response.body)); } } catch (e) { print('Failed to update transfer status'); } return transferDetails; }
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:
1234567891011121314151617late 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:
123456789101112131415MessageInput( 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.
123456789101112131415161718bool _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.
12345678910111213141516171819202122232425262728293031323334353637RapydClient _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, sourceWallet: _sourceWalletId, destinationWallet: _destinationWalletId, ); // Confirm the transaction var updatedInfo = await _rapydClient.transferResponse( id: transactionInfo!.data.id, response: 'accept'); // Update the attachment msg.attachments[0] = Attachment( type: 'payment', uploadState: UploadState.success(), extraData: updatedInfo!.toJson(), ); setState(() { _isSending = false; }); } return msg; }
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.
123MessageListView( customAttachmentBuilders: {'payment': _buildPaymentMessage}, )
The custom widget is defined within the _buildPaymentMessage()
method.
123456789101112131415161718192021222324Widget _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( transaction: transaction, destWalletAddress: destWalletAddress, amount: amount, status: status, ), RoundedRectangleBorder(), true, ); }
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.
Wrap the contents of the TransactionWidget
with an InkWell
to navigate to the DetailPage
.
123456789101112131415161718192021class 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.
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.
1234567891011Widget 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.