Building Custom Paystack Terminal Apps
Build a backend service that manages orders and pushes payment requests to terminals via the Paystack Terminal API. Build a frontend (web, mobile, or tablet) for cashiers to create orders, select items, and trigger payments. The backend tracks terminal state (idle, waiting, processing), matches webhook results to orders, and handles timeouts and cancellations. Keep the frontend simple and resilient to network issues.
App Architecture Overview
A custom terminal app has three layers that communicate in real time.
1. Cashier frontend. A web app, mobile app, or tablet app that the cashier uses to build orders, add items, apply discounts, and trigger payments. This layer never talks to Paystack directly.
2. Backend service. An Express, Django, or Laravel server that manages orders, communicates with the Paystack Terminal API, processes webhooks, and pushes status updates to the frontend.
3. Paystack Terminal. The physical device that accepts card payments. It receives payment requests from your backend via the Paystack API and sends results back via webhooks.
Communication flow:
- Cashier taps "Charge" on the frontend.
- Frontend sends the order ID to the backend.
- Backend looks up the order total, pushes a payment request to the terminal.
- Terminal displays the amount and waits for the customer's card.
- Customer taps their card. Terminal processes the payment.
- Paystack sends a webhook to the backend.
- Backend updates the order status and pushes a real-time update to the frontend.
- Frontend shows "Payment Complete" to the cashier.
Order Management
// order-service.js
async function createOrder(items, terminalId, cashierId) {
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, cashier_id, status, created_at) ' +
'VALUES ($1, $2, $3, $4, $5, NOW()) RETURNING id',
[JSON.stringify(items), total, terminalId, cashierId, 'draft']
);
return { orderId: order.rows[0].id, total: total };
}
async function chargeOrder(orderId) {
var order = await db.query(
'SELECT total_amount, terminal_id, status FROM orders WHERE id = $1',
[orderId]
);
if (order.rows.length === 0) throw new Error('Order not found');
if (order.rows[0].status !== 'draft') throw new Error('Order already charged');
var terminalId = order.rows[0].terminal_id;
var amount = order.rows[0].total_amount;
var reference = 'ORD-' + orderId + '-' + Date.now().toString(36);
// Update order status before pushing
await db.query(
'UPDATE orders SET status = $1, payment_reference = $2 WHERE id = $3',
['payment_pending', reference, orderId]
);
// Push to terminal
await pushPaymentToTerminal(terminalId, amount, reference);
return { reference: reference, status: 'payment_pending' };
}
Order states: draft (items being added), payment_pending (waiting for terminal), paid (webhook confirmed), cancelled (timed out or manually cancelled). Every state transition should be logged.
Terminal State Management
// terminal-state.js
async function getTerminalState(terminalId) {
var state = await db.query(
'SELECT status, current_order_id, last_heartbeat FROM terminal_state WHERE terminal_id = $1',
[terminalId]
);
if (state.rows.length === 0) {
return { status: 'unknown', terminalId: terminalId };
}
return state.rows[0];
}
async function setTerminalState(terminalId, status, orderId) {
await db.query(
'INSERT INTO terminal_state (terminal_id, status, current_order_id, updated_at) ' +
'VALUES ($1, $2, $3, NOW()) ' +
'ON CONFLICT (terminal_id) ' +
'DO UPDATE SET status = $2, current_order_id = $3, updated_at = NOW()',
[terminalId, status, orderId]
);
}
// State transitions:
// idle -> payment_pending (when payment is pushed)
// payment_pending -> processing (when card is presented)
// processing -> idle (when webhook received or timeout)
// payment_pending -> idle (when cancelled)
Why track state: If a cashier accidentally taps "Charge" twice, the second request should be rejected because the terminal is already in payment_pending state. Without state tracking, you send two payment requests and create confusion.
Heartbeat. Periodically check if the terminal is online. If the last heartbeat is stale, show a warning to the cashier: "Terminal may be offline."
Real-Time Status Updates
The cashier needs to see payment status in real time. Polling the server every second works but is wasteful. WebSockets or Server-Sent Events (SSE) are better.
// sse-updates.js
var express = require('express');
var router = express.Router();
var clients = new Map(); // terminalId -> [response objects]
router.get('/api/terminal/:terminalId/events', function(req, res) {
var terminalId = req.params.terminalId;
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
if (!clients.has(terminalId)) {
clients.set(terminalId, []);
}
clients.get(terminalId).push(res);
req.on('close', function() {
var list = clients.get(terminalId) || [];
var index = list.indexOf(res);
if (index > -1) list.splice(index, 1);
});
});
function broadcastToTerminal(terminalId, eventType, data) {
var list = clients.get(terminalId) || [];
list.forEach(function(res) {
res.write('event: ' + eventType + '
');
res.write('data: ' + JSON.stringify(data) + '
');
});
}
// In your webhook handler:
// broadcastToTerminal(terminalId, 'payment_complete', { orderId: orderId, status: 'paid' });
When the webhook arrives and your backend processes it, broadcast the result to all frontends watching that terminal. The cashier sees the update instantly without refreshing.
Designing the Cashier UI
Cashiers use your app under pressure. Customers are waiting. The shop is busy. Your UI must be fast, obvious, and forgiving.
Large touch targets. Buttons should be at least 48x48 pixels, ideally larger. On a tablet, make the "Charge" button the biggest element on the screen.
Clear status feedback. Show exactly what is happening:
- "Building order..." (cashier is adding items)
- "Sending to terminal..." (API call in progress)
- "Waiting for customer's card..." (terminal is displaying amount)
- "Processing payment..." (card has been read)
- "Payment complete!" (webhook received, order paid)
- "Payment failed. Tap to retry." (card declined or error)
One-tap retry. When a payment fails, the cashier should be able to retry with a single tap. Do not make them re-build the order.
Offline resilience. If the frontend loses connection to the backend, show a clear "Connection lost" banner. Do not let the cashier tap "Charge" when the backend is unreachable. Queue actions locally if possible.
Cancel button always visible. The cashier should always be able to cancel a pending payment. The customer might change their mind or want to pay differently.
Multi-Terminal Coordination
Businesses with multiple checkout counters need multiple terminals. Your app must route payments to the correct device.
// terminal-assignment.js
async function assignTerminalToCashier(cashierId, terminalId) {
await db.query(
'INSERT INTO cashier_terminals (cashier_id, terminal_id, assigned_at) ' +
'VALUES ($1, $2, NOW()) ' +
'ON CONFLICT (cashier_id) DO UPDATE SET terminal_id = $2, assigned_at = NOW()',
[cashierId, terminalId]
);
}
async function getTerminalForCashier(cashierId) {
var result = await db.query(
'SELECT terminal_id FROM cashier_terminals WHERE cashier_id = $1',
[cashierId]
);
if (result.rows.length === 0) {
throw new Error('No terminal assigned to this cashier');
}
return result.rows[0].terminal_id;
}
Assignment models:
- Fixed assignment: Each cashier is permanently linked to a specific terminal. Simple, predictable.
- Dynamic assignment: Cashiers pick a terminal from a list of available devices. Flexible for shift changes.
- Location-based: Terminals are assigned by physical location (Counter 1, Counter 2). Cashiers inherit the terminal when they log into a specific counter.
Error Recovery Patterns
In a retail environment, every second of downtime is a frustrated customer. Build robust error recovery.
Webhook never arrives. After 5 minutes of waiting, check the transaction status directly with the Paystack API. The webhook might have been delayed or lost.
Terminal went offline mid-transaction. Show the cashier: "Terminal appears offline. The payment may still process when it reconnects. Do not charge the customer again until we confirm." Check the status periodically.
Double-charge prevention. Before pushing a new payment, check if there is already a pending payment for the same order. If there is, do not push again. Wait for the first one to resolve.
End-of-day reconciliation. At the end of each day, compare your order database with the Paystack transaction list. Flag any discrepancies (orders marked paid without a matching Paystack transaction, or Paystack transactions without matching orders).
For the broader testing approach, see the complete testing guide.
Key Takeaways
- ✓The custom app handles business logic (orders, inventory, pricing). The terminal handles payment acceptance. Keep these responsibilities separate.
- ✓Build a backend that acts as the coordinator between your frontend (cashier UI), the Paystack Terminal API, and your database.
- ✓Track terminal state in your database: idle, payment_pending, processing. This prevents sending conflicting requests to the same device.
- ✓Design the cashier UI for speed and error recovery. Large buttons, clear status messages, and one-tap retry on failures.
- ✓Handle network interruptions gracefully. The cashier should never be stuck with a frozen screen while a customer waits.
- ✓Use WebSockets or server-sent events to push real-time payment status updates from your backend to the cashier UI.
Frequently Asked Questions
- Should I build the cashier app as a web app or a native app?
- A web app (PWA) is the fastest to build and deploy. It works on any tablet or phone with a browser. A native app (Flutter, React Native) gives you better offline support and hardware access. For most businesses, start with a web app and go native only if you need offline functionality or barcode scanner integration.
- How do I handle split payments (part card, part cash)?
- Your app handles the split logic. If the total is 10,000 NGN and the customer pays 6,000 in cash, push only 4,000 NGN to the terminal. Record the cash portion in your order as a separate payment method. Paystack only handles the card portion.
- Can I print receipts from the terminal?
- Paystack terminals may have built-in receipt printers depending on the model. You can also print from your app using a connected receipt printer. See the terminal event webhooks guide for receipt data formatting.
- How do I handle returns and refunds through the terminal?
- Refunds are processed through the Paystack API, not through the terminal. When a customer returns an item, find the original transaction in your system and call the Paystack Refund API. The refund goes back to the customer's card. The terminal itself does not handle refund flows.
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