AppMsgr Flutter SDK
Official Flutter/Dart SDK for AppMsgr chat infrastructure.
🔐 Authentication Modes
The SDK supports two authentication modes:
1. API Key Authentication (Tenant-Level)
Use your tenant API key for operations on behalf of your organization:
final client = AppMsgrClient.withApiKey(
apiKey: 'appmsgr_live_xxxxx',
);
2. User Token Authentication (User-Level) ⭐ NEW
Use JWT user tokens for user-scoped operations (recommended for production):
// Get JWT from your GraphQL backend
final userToken = await yourGraphQL.query(appmsgrApiKeyQuery);
final client = AppMsgrClient.withUserToken(
userToken: userToken,
);
Recommended Pattern: Get user tokens from your GraphQL backend, use SDK to communicate directly with AppMsgr API.
⚠️ Architecture Recommendation
For Production Apps: We recommend the Backend + SDK Pattern where:
- Your Flutter app authenticates with your GraphQL backend
- Backend issues user-scoped JWT tokens for AppMsgr
- Flutter SDK uses JWT tokens to communicate directly with AppMsgr API
This provides:
- 🔒 Better Security - API keys stay server-side, users get scoped tokens
- 🎮 Custom Business Logic - Backend controls token issuance and user permissions
- 🔧 Flexibility - Backend manages authentication, SDK handles real-time chat
- ⚡ Performance - Direct SDK→AppMsgr communication, no double compute
Your Flutter App → Your Backend (Auth/Tokens) → Issues JWT
↓ ↓
AppMsgr SDK ───────────────────────────────────→ AppMsgr API
(Direct communication with JWT)
This SDK is best for:
- 🔬 Prototyping and testing (API key mode)
- 🚀 Production apps with user authentication (user token mode)
- 🛠️ Internal tools and admin dashboards
- 📱 Apps with or without existing backend infrastructure
See the Integration Patterns section below for detailed architecture guidance.
Features
- ✅ Complete REST API client (Channels, Messages, Reactions)
- ✅ GraphQL client for real-time subscriptions
- ✅ Automatic reconnection and error handling
- ✅ Offline message queue
- ✅ Type-safe API with strong typing
- ✅ Cross-platform (iOS, Android, Web, Desktop)
Installation
Add to your pubspec.yaml:
dependencies:
appmsgr_flutter: ^1.0.0
Or install via command:
flutter pub add appmsgr_flutter
Quick Start
Initialize Client
import 'package:appmsgr_flutter/appmsgr_flutter.dart';
// Option 1: API Key Authentication (tenant-level)
final client = AppMsgrClient.withApiKey(
apiKey: 'appmsgr_live_xxxxx',
baseUrl: 'https://api.appmsgr.com', // Optional
);
// Option 2: User Token Authentication (user-level, recommended)
final userToken = await yourBackend.getUserToken(userId);
final client = AppMsgrClient.withUserToken(
userToken: userToken,
baseUrl: 'https://api.appmsgr.com', // Optional
);
// With custom configuration
final client = AppMsgrClient.withUserToken(
userToken: userToken,
baseUrl: 'https://api.appmsgr.com',
config: AppMsgrConfig(
timeout: Duration(seconds: 30),
retryAttempts: 3,
enableOfflineQueue: true,
),
);
Example: Game Backend Integration
import 'package:appmsgr_flutter/appmsgr_flutter.dart';
class ChatService {
late AppMsgrClient _appmsgr;
Future<void> initialize(String userId) async {
// Get user token from GraphQL backend
final token = await graphQLClient.query(
appmsgrApiKeyQuery,
variables: {'userId': userId},
);
// Initialize AppMsgr SDK with user token
_appmsgr = AppMsgrClient.withUserToken(
userToken: token,
);
debugPrint('✅ AppMsgr SDK initialized');
}
Future<void> sendMessage(String channelId, String message) async {
await _appmsgr.messages.sendMessage(
channelId: channelId,
content: message,
);
}
}
Send Messages
// Send a text message
final message = await client.messages.send(
channelId: 'channel-123',
request: SendMessageRequest(
userId: 'player-456',
username: 'DragonSlayer',
content: 'Ready for the raid!',
messageType: MessageType.text,
),
);
debugPrint('Message sent: ${message.messageId}');
Get Messages (Paginated)
// Get latest messages
final response = await client.messages.getMessages(
channelId: 'channel-123',
limit: 50,
);
debugPrint('Loaded ${response.messages.length} messages');
debugPrint('Has more: ${response.hasMore}');
// Load next page
if (response.hasMore) {
final nextPage = await client.messages.getMessages(
channelId: 'channel-123',
limit: 50,
before: response.nextCursor,
);
}
Add Reactions
// Add emoji reaction
final reaction = await client.reactions.add(
messageId: 'msg-123',
request: AddReactionRequest(
playerId: 'player-456',
playerUsername: 'DragonSlayer',
emojiId: 'U+1F44D',
emojiPlatform: EmojiPlatform.ios,
emojiUnicode: '👍',
emojiName: 'thumbs_up',
),
);
// Get reactions grouped by emoji
final reactions = await client.reactions.getReactions(
messageId: 'msg-123',
groupByEmoji: true,
);
for (final group in reactions.reactions) {
debugPrint('${group.emojiUnicode}: ${group.count} reactions');
}
Manage Channels
// Create a channel
final channel = await client.channels.create(
tenantId: 'tenant-123',
request: CreateChannelRequest(
channelType: ChannelType.alliance,
channelName: 'Guild Chat',
serverId: 'your-game-us-west',
scope: ChannelScope.server,
groupId: 'alliance-456',
settings: ChannelSettings(
allowReactions: true,
allowAttachments: true,
allowTranslation: true,
),
),
);
// List channels
final channels = await client.channels.list(
tenantId: 'tenant-123',
type: ChannelType.alliance,
status: ChannelStatus.active,
limit: 50,
);
// Update channel
final updated = await client.channels.update(
channelId: channel.channelId,
request: UpdateChannelRequest(
channelName: 'Eternal Warriors - Updated',
settings: ChannelSettings(
messageRetentionDays: 60,
),
),
);
GraphQL Subscriptions (Real-time)
// Subscribe to new messages
final subscription = client.graphql.subscribeToMessages(
channelId: 'channel-123',
);
await for (final message in subscription) {
debugPrint('New message from ${message.username}: ${message.content}');
// Update your UI
}
// Subscribe to reactions
final reactionSub = client.graphql.subscribeToReactions(
messageId: 'msg-123',
);
await for (final reaction in reactionSub) {
debugPrint('${reaction.playerUsername} reacted with ${reaction.emojiUnicode}');
}
Architecture
Backend Integration
Flutter App → AppMsgr Backend GraphQL → AppMsgr REST API
The Flutter SDK communicates with your backend GraphQL server, which acts as a proxy to AppMsgr's REST API. This allows you to:
- Add custom authentication
- Implement rate limiting
- Cache responses
- Add custom business logic
Example Backend GraphQL Schema
type Mutation {
sendMessage(channelId: ID!, content: String!): Message!
addReaction(messageId: ID!, emoji: String!): Reaction!
}
type Subscription {
messageAdded(channelId: ID!): Message!
reactionAdded(messageId: ID!): Reaction!
}
Error Handling
try {
final message = await client.messages.send(
channelId: 'channel-123',
request: SendMessageRequest(
userId: 'player-456',
username: 'DragonSlayer',
content: 'Hello!',
),
);
} on AppMsgrApiException catch (e) {
if (e.statusCode == 404) {
debugPrint('Channel not found');
} else if (e.statusCode == 401) {
debugPrint('Unauthorized - check API key');
} else if (e.statusCode == 429) {
debugPrint('Rate limited - retry after ${e.retryAfter}');
} else {
debugPrint('Error: ${e.message}');
}
} on AppMsgrNetworkException catch (e) {
debugPrint('Network error: ${e.message}');
// Message will be queued if offline queue is enabled
}
Offline Support
The SDK automatically queues messages when offline and syncs them when connectivity is restored.
⚠️ Security note — queued messages are stored unencrypted on disk. The offline queue uses Hive for local persistence. Message content, channel IDs, and user IDs sit in the app's sandbox as plaintext JSON. Auth tokens are NOT stored, but message content is — on a rooted Android or jailbroken iOS device, that data is readable.
On logout, set
AppMsgrConfig(clearOfflineQueueOnDispose: true)(or callclient.clearQueue()manually) so the departing user's messages do not remain readable by the next user of the device.For strong data-at-rest protection, wrap the SDK init in your own encryption setup using
flutter_secure_storage. Hive-level encryption is planned for a future major release.
final client = AppMsgrClient.withApiKey(
apiKey: 'your_api_key',
config: AppMsgrConfig(
enableOfflineQueue: true,
maxOfflineQueueSize: 100,
clearOfflineQueueOnDispose: true, // recommended for multi-user devices
),
);
// Initialize to start offline queue
await client.initialize();
// Use client.sendMessage (not messages.sendMessage) for offline support
// This will be queued if offline
await client.sendMessage(
channelId: 'channel-123',
content: 'Sent while offline',
);
// Get queue status
final status = client.getQueueStatus();
debugPrint('Pending messages: ${status?.pendingCount}');
debugPrint('Failed messages: ${status?.failedCount}');
// Get all queued messages
final queued = client.getQueuedMessages();
for (final msg in queued ?? []) {
debugPrint('Queued: ${msg.content} (${msg.status})');
}
// Manual sync
await client.processPendingMessages();
// Clear failed messages
await client.clearFailedMessages();
Testing
Unit Tests
import 'package:appmsgr_flutter/appmsgr_flutter.dart';
import 'package:test/test.dart';
void main() {
test('should send message successfully', () async {
final client = AppMsgrClient(apiKey: 'test_key');
final message = await client.messages.send(
channelId: 'test-channel',
request: SendMessageRequest(
userId: 'test-user',
username: 'TestUser',
content: 'Test message',
),
);
expect(message.messageId, isNotEmpty);
expect(message.content, equals('Test message'));
});
}
Mock Client for Testing
import 'package:appmsgr_flutter/appmsgr_flutter.dart';
import 'package:mockito/mockito.dart';
class MockAppMsgrClient extends Mock implements AppMsgrClient {}
void main() {
test('should handle message send in app', () async {
final mockClient = MockAppMsgrClient();
when(mockClient.messages.send(any, any))
.thenAnswer((_) async => Message(
messageId: 'mock-123',
channelId: 'channel-123',
userId: 'user-123',
content: 'Mocked message',
sentAt: DateTime.now(),
));
// Test your app logic with mock client
final result = await yourAppFunction(mockClient);
expect(result, isTrue);
});
}
Example: Chat Screen
import 'package:flutter/material.dart';
import 'package:appmsgr_flutter/appmsgr_flutter.dart';
class ChatScreen extends StatefulWidget {
final String channelId;
const ChatScreen({required this.channelId});
@override
_ChatScreenState createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> {
final client = AppMsgrClient(apiKey: 'your_api_key');
final textController = TextEditingController();
List<Message> messages = [];
bool isLoading = false;
bool hasMore = true;
String? nextCursor;
@override
void initState() {
super.initState();
_loadMessages();
_subscribeToMessages();
}
Future<void> _loadMessages() async {
if (isLoading || !hasMore) return;
setState(() => isLoading = true);
try {
final response = await client.messages.getMessages(
channelId: widget.channelId,
limit: 50,
before: nextCursor,
);
setState(() {
messages.addAll(response.messages);
hasMore = response.hasMore;
nextCursor = response.nextCursor;
isLoading = false;
});
} catch (e) {
debugPrint('Error loading messages: $e');
setState(() => isLoading = false);
}
}
void _subscribeToMessages() {
client.graphql
.subscribeToMessages(channelId: widget.channelId)
.listen((message) {
setState(() {
messages.insert(0, message);
});
});
}
Future<void> _sendMessage() async {
final content = textController.text.trim();
if (content.isEmpty) return;
textController.clear();
try {
await client.messages.send(
channelId: widget.channelId,
request: SendMessageRequest(
userId: 'current-user-id',
username: 'CurrentUser',
content: content,
messageType: MessageType.text,
),
);
} catch (e) {
debugPrint('Error sending message: $e');
// Show error to user
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Chat')),
body: Column(
children: [
Expanded(
child: ListView.builder(
reverse: true,
itemCount: messages.length + (isLoading ? 1 : 0),
itemBuilder: (context, index) {
if (index == messages.length) {
return Center(child: CircularProgressIndicator());
}
final message = messages[index];
return ListTile(
title: Text(message.username),
subtitle: Text(message.content),
trailing: Text(
message.sentAt.toString(),
style: TextStyle(fontSize: 12),
),
);
},
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
Expanded(
child: TextField(
controller: textController,
decoration: InputDecoration(
hintText: 'Type a message...',
),
),
),
IconButton(
icon: Icon(Icons.send),
onPressed: _sendMessage,
),
],
),
),
],
),
);
}
@override
void dispose() {
textController.dispose();
super.dispose();
}
}
Performance Tips
- Use Pagination: Always paginate messages with reasonable limits (50-100)
- Cache Locally: Store messages in local database (sqflite, hive)
- Lazy Load: Only load more messages when user scrolls near top
- Debounce Typing: Don't send typing indicator on every keystroke
- Batch Reactions: Group reaction updates to reduce API calls
Integration Patterns
Pattern 1: Backend Proxy (Recommended for Production)
Best for: Production games, enterprise apps, apps with existing backend
Flutter App → Your Backend → AppMsgr API
Your Backend (Node.js/Express example):
// Your backend exposes GraphQL/REST that Flutter calls
app.post('/api/chat/send-message', async (req, res) => {
const { channelId, content, userId } = req.body;
// Add your business logic here
if (!await validateUser(userId)) {
return res.status(403).json({ error: 'Unauthorized' });
}
// Apply profanity filter, rate limits, etc.
const cleanContent = profanityFilter(content);
// Call AppMsgr API (API key stays server-side!)
const response = await fetch('https://api.appmsgr.com/api/v1/messages', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.APPMSGR_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ channelId, content: cleanContent })
});
const message = await response.json();
res.json(message);
});
Your Flutter App:
// Call YOUR backend, not AppMsgr directly
Future<void> sendMessage(String channelId, String content) async {
final response = await http.post(
Uri.parse('https://your-backend.com/api/chat/send-message'),
headers: {'Authorization': 'Bearer ${yourUserToken}'},
body: jsonEncode({
'channelId': channelId,
'content': content,
'userId': currentUser.id,
}),
);
// Your backend handles the AppMsgr integration
}
Advantages:
- ✅ API keys never exposed in client
- ✅ Add game-specific logic (anti-cheat, validation)
- ✅ Rate limiting per player
- ✅ Caching and optimization
- ✅ Version control without app updates
- ✅ Combine multiple services in one API
Pattern 2: Direct SDK (Good for Prototyping)
Best for: Prototypes, MVPs, internal tools, simple apps
Flutter App (with SDK) → AppMsgr API directly
// Direct SDK usage (what this package provides)
final client = AppMsgrClient(
apiKey: 'your_api_key', // ⚠️ Can be extracted from app!
baseUrl: 'https://api.appmsgr.com',
);
await client.messages.sendMessage(
channelId: 'channel-123',
content: 'Hello!',
);
Advantages:
- ✅ Quick to set up
- ✅ No backend needed
- ✅ Good for prototyping
Disadvantages:
- ❌ API keys in client (security risk)
- ❌ Hard to add custom logic
- ❌ Updates require app store releases
- ❌ No caching/optimization layer
Pattern 3: Hybrid (Best of Both)
Best for: Apps transitioning from prototype to production
Flutter App → Your Backend (authenticated endpoints)
↘ AppMsgr API (read-only subscriptions)
- Write operations (send message, add reaction) go through your backend
- Read operations and subscriptions can use SDK directly
- API keys for write operations stay server-side
- Real-time subscriptions connect directly for low latency
Production Example
For a production game:
Game Client (Flutter / Godot / Unity)
↓ (Your custom GraphQL or REST schema)
Your Game Backend (Node.js / Go / etc.)
↓ (AppMsgr API Key — server-side only)
AppMsgr API
Your Backend GraphQL Schema:
type Mutation {
sendGuildMessage(guildId: ID!, content: String!): Message
addReaction(messageId: ID!, emoji: String!): Reaction
}
type Subscription {
guildMessageAdded(guildId: ID!): Message
}
Implementation:
// Your GraphQL resolver
const resolvers = {
Mutation: {
sendGuildMessage: async (_, { guildId, content }, context) => {
// Verify player is in guild
const player = context.player;
if (!await isPlayerInGuild(player.id, guildId)) {
throw new Error('Not in guild');
}
// Call AppMsgr API
const message = await appMsgrClient.messages.sendMessage({
channelId: guildId,
content,
metadata: {
playerId: player.id,
playerName: player.name,
playerLevel: player.level
}
});
return message;
}
}
};
This way you control player authentication, guild membership, and can add game-specific data to messages.
Migration from Unity SDK
If migrating from Unity C#:
| Unity (C#) | Flutter (Dart) |
|---|---|
AppMsgrClient |
AppMsgrClient |
await client.Messages.SendAsync() |
await client.messages.send() |
Message[] |
List<Message> |
async/await |
async/await (same!) |
UnityWebRequest |
http package |
JsonUtility |
dart:convert |
Mail System
AppMsgr includes a comprehensive mail/inbox system for delivering rewards, notifications, and messages to players with attachment claiming and webhook integration.
Send Mail with Attachments
// Send reward mail with attachments
final mail = await client.mail.sendMail(
tenantId: 'your-tenant-id',
senderId: 'system',
senderType: 'system',
senderDisplayName: 'Game Rewards',
subject: 'Daily Login Reward',
body: 'Thank you for logging in! Here is your reward.',
mailType: 'reward',
category: 'daily_rewards',
priority: 'high',
recipientType: 'player',
recipientIds: ['player-123', 'player-456'],
icon: 'icons/gift.png',
highlightColor: '#FFD700',
metadata: {
'attachments': [
{
'type': 'gold',
'id': 'currency_gold',
'name': 'Gold',
'amount': 1000,
'icon': 'icons/gold.png',
},
{
'type': 'item',
'id': 'item_sword_legendary',
'name': 'Legendary Sword',
'amount': 1,
'icon': 'icons/sword.png',
'metadata': {
'rarity': 'legendary',
'level': 50,
},
},
],
},
webhookTriggers: ['mail.attachments.claimed'],
);
Get Inbox
// Get player's inbox with pagination
final inbox = await client.mail.getInbox(
tenantId: 'your-tenant-id',
playerId: 'player-123',
limit: 50,
offset: 0,
unreadOnly: false,
includeArchived: false,
);
debugPrint('Loaded ${inbox.mail.length} messages');
debugPrint('Unread: ${inbox.mail.where((m) => !m.isRead).length}');
// Display mail list
for (final mail in inbox.mail) {
debugPrint('${mail.subject} - ${mail.senderDisplayName}');
if (mail.metadata?['attachments'] != null) {
debugPrint(' 📦 Has attachments');
}
}
Claim Attachments
// Player claims mail attachments (triggers webhook)
try {
final result = await client.mail.claimAttachments(
mailId: 'mail-123',
playerId: 'player-456',
);
debugPrint('Claimed ${result.attachments.length} attachments!');
// Process attachments in your game
for (final attachment in result.attachments) {
switch (attachment.type) {
case 'gold':
addCurrency('gold', attachment.amount ?? 0);
break;
case 'item':
addItemToInventory(attachment.id, attachment.amount ?? 1);
break;
case 'troop':
recruitTroops(attachment.id, attachment.amount ?? 1);
break;
}
}
} catch (e) {
// Already claimed or mail not found
debugPrint('Could not claim attachments: $e');
}
Webhook Integration
When a player claims mail attachments, AppMsgr sends a webhook to your game backend:
POST https://your-game-backend.com/webhooks/appmsgr
Content-Type: application/json
X-AppMsgr-Signature: sha256=...
{
"event": "mail.attachments.claimed",
"timestamp": "2025-11-10T12:34:56.789Z",
"tenantId": "your-tenant-id",
"data": {
"mailId": "mail-123",
"playerId": "player-456",
"attachments": [
{
"type": "gold",
"id": "currency_gold",
"name": "Gold",
"amount": 1000
}
],
"claimedAt": "2025-11-10T12:34:56.789Z"
}
}
Backend Implementation:
// Your game backend webhook handler
app.post('/webhooks/appmsgr', async (req, res) => {
const { event, data } = req.body;
if (event === 'mail.attachments.claimed') {
const { playerId, attachments } = data;
// Grant rewards to player in your game database
for (const attachment of attachments) {
await grantReward(playerId, attachment);
}
// Mark as processed
await saveWebhookLog(req.body);
}
res.status(200).send('OK');
});
Mail Operations
// Mark as read
await client.mail.markAsRead(
mailId: 'mail-123',
playerId: 'player-456',
);
// Delete mail (soft delete)
await client.mail.deleteMail(
mailId: 'mail-123',
playerId: 'player-456',
);
// Get unread count (for badge)
final count = await client.mail.getUnreadCount(
tenantId: 'your-tenant-id',
playerId: 'player-456',
);
debugPrint('Unread: ${count.unreadCount}');
Mail Types & Categories
Organize mail using types and categories:
// System mail
mailType: 'system',
category: 'maintenance',
// Reward mail
mailType: 'reward',
category: 'daily_login', // daily_rewards, event_rewards, achievement_rewards
// Player-to-player mail
mailType: 'player',
category: 'trade', // trade, gift, message
// Alliance/guild mail
mailType: 'alliance',
category: 'announcement', // announcement, war, recruitment
// Admin broadcast
mailType: 'admin',
category: 'news', // news, update, warning
Example: Mail Screen Widget
See the example app for a complete implementation of:
- Mail inbox with unread badges
- Attachment claiming UI
- Mail detail modal
- Refresh and pagination
Troubleshooting
Common Issues
"API key is invalid"
- Verify your API key is correct in the client initialization
- Check that the API key hasn't been revoked in the AppMsgr dashboard
- Ensure you're using the correct environment (staging vs production)
Messages not appearing in real-time
- Verify GraphQL subscription is connected:
client.graphql.isConnected - Check that you're subscribed to the correct channel ID
- Ensure WebSocket connection isn't blocked by firewall/proxy
- Try reconnecting:
await client.graphql.connect()
Offline queue not working
- Ensure offline queue is enabled in config:
config.enableOfflineQueue = true - Call
await client.initialize()before sending messages - Check queue status:
final status = client.getQueueStatus() - Review queued messages:
final messages = client.getQueuedMessages()
High memory usage
- Implement pagination for message lists - don't load all messages at once
- Unsubscribe from GraphQL subscriptions when not needed
- Clear offline queue periodically:
await client.clearQueue() - Dispose client when done:
await client.dispose()
Slow API responses
- Check your internet connection
- Verify baseUrl points to nearest region
- Consider implementing caching in your backend
- Use pagination instead of loading all data
JSON serialization errors
- Ensure all model classes have proper
fromJson/toJsonmethods - Check that API response format matches expected model structure
- Validate that custom
metadatafields are JSON-serializable
Debug Mode
Enable detailed logging:
import 'package:logger/logger.dart';
final client = AppMsgrClient(
apiKey: 'your_api_key',
config: AppMsgrConfig(
logger: Logger(
level: Level.debug,
printer: PrettyPrinter(
methodCount: 0,
printTime: true,
),
),
),
);
Performance Tips
- Batch Operations: Send multiple messages in quick succession? Consider batching on your backend
- Pagination: Always use pagination for large message lists
- Subscriptions: Only subscribe to channels the user is actively viewing
- Offline Queue: Set reasonable
maxOfflineQueueSize(default 100) - Caching: Implement local caching for channels and messages to reduce API calls
Testing
For unit tests, use mockito to mock the HTTP client:
import 'package:mockito/mockito.dart';
import 'package:http/http.dart' as http;
final mockClient = MockClient();
when(mockClient.post(any, headers: anyNamed('headers'), body: anyNamed('body')))
.thenAnswer((_) async => http.Response('{"id": "msg-123"}', 200));
final client = AppMsgrClient(
apiKey: 'test-key',
httpClient: mockClient,
);
Support
- Documentation: https://docs.appmsgr.com/flutter-sdk
- Issues: https://github.com/appmsgr/flutter-sdk/issues
- Discord: https://discord.gg/appmsgr
- Email: support@appmsgr.com
License
MIT License - See LICENSE file for details
Libraries
- appmsgr_flutter
- AppMsgr Flutter SDK