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.
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:
// 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.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
// 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."
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.
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:
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.
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:
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.
KES 2,999
The definitive guide to building payment systems with Paystack — from your first transaction to production-grade payment infrastructure across Africa. 9 modules, 47 lessons. Free preview available.
Start Paystack Course