Building a Terminal App with Flutter
Build a Flutter app with three layers: a UI layer for the cashier (order building, payment status), a service layer that communicates with your backend API, and a state management layer (Provider, Riverpod, or Bloc) that tracks terminal state and order status. The Flutter app never talks to Paystack directly. It sends requests to your backend, which handles Paystack Terminal API calls and webhook processing.
Flutter Terminal App Architecture
Your Flutter app sits between the cashier and your backend. It never touches Paystack APIs directly.
Frontend (Flutter): Displays the product catalog, lets the cashier build orders, shows the payment status, and triggers charge requests.
Backend (Node.js/Express): Stores products, orders, and terminal assignments. Calls the Paystack Terminal API to push payments. Receives webhooks and pushes status updates to the Flutter app.
Why not call Paystack from Flutter? The Terminal API requires your secret key. Secret keys must never exist in frontend code, mobile or web. Your backend is the only component that holds the secret key and makes Paystack API calls.
The Flutter app communicates with your backend over HTTP. For real-time updates (payment status changes), use Server-Sent Events (SSE) or periodic polling. WebSockets are another option but add complexity.
Project Structure
Organize your Flutter project around features, not layers.
// Flutter project structure
// lib/
// main.dart
// models/
// product.dart
// order.dart
// terminal_status.dart
// services/
// api_service.dart // HTTP calls to your backend
// terminal_service.dart // Terminal-specific API calls
// providers/
// order_provider.dart // Order state management
// terminal_provider.dart // Terminal state management
// screens/
// pos_screen.dart // Main POS interface
// payment_screen.dart // Payment status screen
// settings_screen.dart // Terminal selection, config
// widgets/
// product_grid.dart
// order_summary.dart
// payment_status_card.dart
models/ define your data structures. services/ handle HTTP communication. providers/ manage state. screens/ are full-page views. widgets/ are reusable components.
API Service Layer
The API service handles all communication with your backend. Here is the core structure in Dart-like pseudocode that maps to your Node.js backend endpoints.
// The Flutter app calls your backend like this:
// POST /api/orders - create an order
// POST /api/terminal/charge - push payment to terminal
// GET /api/terminal/:id/status - check terminal state
// GET /api/orders/:id - get order details
// Your backend (Node.js) handles these and calls Paystack Terminal API:
// api-service.js (backend)
var express = require('express');
var router = express.Router();
router.post('/api/orders', async function(req, res) {
var items = req.body.items;
var terminalId = req.body.terminal_id;
var total = items.reduce(function(sum, item) {
return sum + (item.price * item.quantity);
}, 0);
var order = await db.query(
'INSERT INTO orders (items, total_amount, terminal_id, status) ' +
'VALUES ($1, $2, $3, $4) RETURNING *',
[JSON.stringify(items), total, terminalId, 'draft']
);
return res.json(order.rows[0]);
});
router.post('/api/terminal/charge', async function(req, res) {
var orderId = req.body.order_id;
var result = await chargeOrderAtTerminal(orderId);
return res.json(result);
});
router.get('/api/terminal/:id/status', async function(req, res) {
var state = await getTerminalState(req.params.id);
return res.json(state);
});
The Flutter app makes HTTP calls to these endpoints. It receives JSON responses and updates the UI accordingly.
State Management for Terminal Status
The terminal can be in several states. Your state management needs to track the current state and update the UI reactively.
// terminal-state-backend.js
// Backend endpoint that Flutter polls for status
router.get('/api/terminal/:id/payment-status/:reference', async function(req, res) {
var reference = req.params.reference;
var payment = await db.query(
'SELECT status, amount, completed_at FROM terminal_transactions WHERE reference = $1',
[reference]
);
if (payment.rows.length === 0) {
return res.json({ status: 'unknown' });
}
return res.json(payment.rows[0]);
});
// Flutter polls this endpoint every 2 seconds while waiting for payment
// States the Flutter app tracks:
// - idle: terminal ready for new payment
// - pushing: sending payment request to terminal
// - waiting_for_card: terminal displaying amount, waiting for customer
// - processing: card presented, payment being processed
// - success: payment completed
// - failed: payment declined or error
// - timeout: no response within the timeout window
Polling interval. Poll every 2 seconds while a payment is active. Stop polling once you get a terminal status (success, failed, or timeout). Do not poll when the terminal is idle.
Optimistic UI. When the cashier taps "Charge," immediately show "Sending to terminal..." without waiting for the API response. Update based on what comes back. This makes the app feel responsive.
Designing the Tablet UI
POS apps live on tablets, not phones. Design for a landscape orientation on a 10-inch screen.
Split view layout. Left side: product catalog as a grid of large, tappable cards. Right side: current order summary with item list, quantities, and total. Bottom right: "Charge" button, large and prominent.
Minimum touch target: 56x56 logical pixels. Cashiers tap quickly and sometimes inaccurately. Make buttons big. Product cards should be at least 100x100 pixels.
Visual feedback on every tap. Ripple effects, color changes, haptic feedback. The cashier needs to know their tap registered, especially in a noisy environment.
Status colours. Use consistent colours for payment states: blue for waiting, green for success, red for failure, yellow for warning. The cashier should be able to read the status from across the counter.
Keep it simple. A POS screen should have no more than 3 to 5 actions visible at any time. Hide advanced features (reports, settings, refunds) behind a menu. The main screen is for building orders and charging customers.
Handling Offline Scenarios
Internet connectivity in African retail environments is not guaranteed. Build your Flutter app to handle interruptions.
Cache the product catalog. Store products locally using SQLite or Hive. The cashier can build orders without internet. When connectivity returns, sync any pending orders to the backend.
Queue payment requests. If the network is down when the cashier taps "Charge," queue the payment locally. Show "Payment will be sent when internet is available." Process the queue when connectivity returns.
Show connectivity status. Display a small indicator (green dot for online, red dot for offline) in the app bar. The cashier should always know whether the app is connected.
// connectivity-check.js (backend health endpoint)
router.get('/api/health', function(req, res) {
return res.json({ status: 'ok', timestamp: Date.now() });
});
// The Flutter app pings this endpoint every 10 seconds
// If it fails 3 times in a row, show the offline indicator
Do not allow charges when offline. Even though you can queue the request, the terminal needs internet to process card payments. Tell the cashier: "Terminal payments require internet. Accept cash or wait for connectivity."
Testing the Flutter Terminal App
Test at three levels: unit tests for business logic, widget tests for UI, and integration tests for the full flow.
Mock the backend. Your Flutter tests should not hit your real backend. Use a mock HTTP client that returns predefined responses for each endpoint. This lets you test every state transition without a running server.
Test every payment state. Simulate success, failure, timeout, and network error. Verify the UI shows the correct message and buttons for each state.
Test on a real tablet. Emulators do not replicate the feel of tapping a physical screen. Test on an actual 10-inch tablet to verify touch targets, scroll behaviour, and readability.
Test with a real terminal. Before going live, test the full flow with a physical Paystack Terminal device and test cards. Verify that the Flutter app shows the correct status at each stage.
For broader testing strategies, see the complete testing guide.
Key Takeaways
- ✓The Flutter app is the cashier frontend. It talks to your backend API, not to Paystack directly. The backend handles Terminal API calls and webhooks.
- ✓Use a state management solution (Provider, Riverpod, or Bloc) to track order state and terminal status across the app.
- ✓Design for tablets. POS apps are typically used on 10-inch tablets. Use a split-view layout: item catalog on the left, order summary on the right.
- ✓Handle offline scenarios. Cache the product catalog locally so the cashier can build orders even without internet. Queue the payment push for when connectivity returns.
- ✓Use Server-Sent Events or polling to get real-time payment status updates from your backend.
- ✓Build large, finger-friendly touch targets. Cashiers use the app standing up, often with one hand.
Frequently Asked Questions
- Should I use Flutter or a web app for my POS frontend?
- Flutter gives you a native feel, better offline support, and access to hardware features like barcode scanners and Bluetooth printers. A web app (PWA) is faster to develop and deploy. For a basic POS, start with a web app. For a feature-rich POS with offline capability and hardware integration, choose Flutter.
- Can the Flutter app communicate directly with the Paystack Terminal device?
- No. The Flutter app communicates with your backend, which communicates with the Paystack Terminal API. The Terminal API is the only way to send commands to the device. There is no direct Bluetooth or local network connection between your app and the terminal.
- Which state management solution should I use?
- For a POS app with moderate complexity, Riverpod or Provider work well. Bloc is more structured but adds boilerplate. If you are already comfortable with one solution, use that. The choice matters less than consistent usage across the app.
- How do I handle app updates without disrupting active payments?
- Check for updates on app launch, not during active transactions. If an update is available, show a notification and let the cashier update when they are between customers. Never force an update while a payment is in progress.
Ready to build real-world apps?
Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.
Apply to the McTaba Marathon