Push Payment Requests to a Paystack Terminal
POST to /terminal/:terminal_id/event with type "invoice", action "process", and data containing the amount in kobo and a unique reference. The terminal displays the amount and waits for the customer to present their card. You receive the result via your webhook endpoint. To cancel a pending request, send another event with action "cancel".
Understanding the Push Model
Paystack Terminal uses a push model: your server pushes payment requests to the device, not the other way around. The terminal does not decide how much to charge. Your application makes that decision and tells the terminal.
This is different from standalone POS terminals where a cashier types an amount on the device keypad. With Paystack Terminal, the amount comes from your backend. The cashier might tap a button in your app that says "Charge customer for Order #1234," and your backend sends the exact amount to the terminal.
Why this matters: It means your application is the source of truth for pricing. The terminal is just the payment acceptance device. This prevents manual entry errors and ensures every charge matches an order in your system.
The Basic Push Request
// push-payment.js
const axios = require('axios');
async function pushPayment(terminalId, amountInKobo, orderId) {
var response = await axios.post(
'https://api.paystack.co/terminal/' + terminalId + '/event',
{
type: 'invoice',
action: 'process',
data: {
id: orderId,
amount: amountInKobo,
},
},
{
headers: {
Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
'Content-Type': 'application/json',
},
}
);
return response.data;
}
// Push a 3,500 NGN payment
var result = await pushPayment('TERM_abc123', 350000, 'ORD-20260720-001');
console.log(result); // { status: true, message: "Event sent to terminal" }
type: Set to "invoice" for payment requests.
action: Set to "process" to start a payment. Other actions include "cancel" to cancel a pending payment.
data.id: Your unique reference for this transaction. Use your order ID or generate a unique string. This is how you match the webhook response to the correct order.
data.amount: The amount in kobo. Always calculate this on your server from your order data. Never accept the amount from the frontend.
Managing Transaction References
Every payment pushed to a terminal needs a unique reference. This reference appears in the webhook when the transaction completes, and it is how you reconcile terminal transactions with your orders.
// generate-reference.js
function generateTerminalReference(orderId) {
var timestamp = Date.now().toString(36);
return 'TRM-' + orderId + '-' + timestamp;
}
// Store the reference before pushing to terminal
async function initiateTerminalPayment(orderId, amount, terminalId) {
var reference = generateTerminalReference(orderId);
// Save the mapping
await db.query(
'INSERT INTO terminal_transactions (order_id, reference, terminal_id, amount, status, created_at) ' +
'VALUES ($1, $2, $3, $4, $5, NOW())',
[orderId, reference, terminalId, amount, 'pending']
);
// Push to terminal
await pushPayment(terminalId, amount, reference);
return { reference: reference, status: 'pushed' };
}
Store the reference before pushing. If the push call succeeds but your database write fails, you lose the mapping. Write to the database first, then push to the terminal.
Reference format. Use a format that encodes useful information: the order ID, a timestamp, and a prefix like "TRM" to distinguish terminal transactions from online ones in your logs.
Handling Timeouts
After you push a payment request, the terminal waits for the customer to present their card. If the customer does not act within a reasonable time, you need to handle the timeout.
// timeout-handler.js
var TERMINAL_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
async function monitorTerminalPayment(reference) {
// Set a timeout
setTimeout(async function() {
// Check if we received a webhook for this reference
var transaction = await db.query(
'SELECT status FROM terminal_transactions WHERE reference = $1',
[reference]
);
if (transaction.rows.length > 0 && transaction.rows[0].status === 'pending') {
// No webhook received within 5 minutes - cancel the request
console.log('Terminal payment timed out: ' + reference);
await cancelTerminalPayment(reference);
}
}, TERMINAL_TIMEOUT_MS);
}
async function cancelTerminalPayment(reference) {
// Find the terminal ID for this reference
var record = await db.query(
'SELECT terminal_id FROM terminal_transactions WHERE reference = $1',
[reference]
);
if (record.rows.length > 0) {
// Send cancel event to terminal
await axios.post(
'https://api.paystack.co/terminal/' + record.rows[0].terminal_id + '/event',
{
type: 'invoice',
action: 'cancel',
data: { id: reference },
},
{
headers: {
Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
'Content-Type': 'application/json',
},
}
);
// Update status
await db.query(
'UPDATE terminal_transactions SET status = $1 WHERE reference = $2',
['cancelled', reference]
);
}
}
5 minutes is a reasonable timeout. It gives the customer time to find their card, try a PIN, or switch to a different card. Beyond 5 minutes, the customer has likely walked away.
Cancel the pending request. If you do not cancel, the terminal stays in a "waiting for payment" state and cannot accept the next customer's payment. Always cancel timed-out requests.
Handling a Busy Terminal
A terminal can only process one payment at a time. If you push a second payment while the first is still pending, the API will reject it.
// queue-payments.js
var terminalQueues = {};
async function queuePayment(terminalId, amount, orderId) {
if (!terminalQueues[terminalId]) {
terminalQueues[terminalId] = { busy: false, queue: [] };
}
var terminal = terminalQueues[terminalId];
if (terminal.busy) {
// Queue the request
return new Promise(function(resolve, reject) {
terminal.queue.push({
amount: amount,
orderId: orderId,
resolve: resolve,
reject: reject,
});
});
}
terminal.busy = true;
try {
var result = await pushPayment(terminalId, amount, orderId);
return result;
} catch (error) {
terminal.busy = false;
processNextInQueue(terminalId);
throw error;
}
}
function markTerminalFree(terminalId) {
if (terminalQueues[terminalId]) {
terminalQueues[terminalId].busy = false;
processNextInQueue(terminalId);
}
}
function processNextInQueue(terminalId) {
var terminal = terminalQueues[terminalId];
if (terminal && terminal.queue.length > 0) {
var next = terminal.queue.shift();
queuePayment(terminalId, next.amount, next.orderId)
.then(next.resolve)
.catch(next.reject);
}
}
Call markTerminalFree when you receive the webhook for the completed (or failed) transaction. This releases the terminal for the next queued payment.
Error Handling for Push Requests
The push API can fail for several reasons. Handle each one specifically.
Terminal not found (404): The terminal ID is wrong or the terminal has been deactivated. Check the terminal ID against your registered devices.
Terminal offline: The device is not connected to the internet. The API may accept the event (Paystack queues it) or reject it depending on implementation. If queued, the payment will process when the device reconnects.
Terminal busy: Another payment is already pending on this device. Queue the request and retry when the current payment completes.
Invalid amount: Amount is zero, negative, or below the minimum. Validate on your end before calling the API.
// safe-push.js
async function safePushPayment(terminalId, amount, orderId) {
// Validate locally first
if (!amount || amount < 10000) { // Minimum 100 NGN
return { error: true, message: 'Amount too low for terminal payment' };
}
try {
var result = await pushPayment(terminalId, amount, orderId);
return { error: false, data: result };
} catch (err) {
if (err.response) {
var status = err.response.status;
if (status === 404) {
return { error: true, message: 'Terminal not found. Check the terminal ID.' };
}
if (status === 409) {
return { error: true, message: 'Terminal is busy. Payment queued.' };
}
}
return { error: true, message: 'Could not reach terminal. Please retry.' };
}
}
Matching Webhooks to Terminal Payments
When the payment completes, Paystack sends a webhook. Match it to your pending terminal transaction using the reference.
// match-webhook.js
async function handleTerminalResult(webhookData) {
var reference = webhookData.data.reference;
// Find the pending terminal transaction
var pending = await db.query(
'SELECT order_id, amount, terminal_id FROM terminal_transactions WHERE reference = $1',
[reference]
);
if (pending.rows.length === 0) {
console.warn('No pending terminal transaction for reference: ' + reference);
return;
}
var record = pending.rows[0];
// Verify the amount matches
if (webhookData.data.amount !== record.amount) {
console.error('Amount mismatch! Expected ' + record.amount + ' got ' + webhookData.data.amount);
return;
}
// Update the terminal transaction
await db.query(
'UPDATE terminal_transactions SET status = $1, completed_at = NOW() WHERE reference = $2',
[webhookData.data.status === 'success' ? 'completed' : 'failed', reference]
);
// Update the order
if (webhookData.data.status === 'success') {
await db.query(
'UPDATE orders SET payment_status = $1, paid_at = NOW() WHERE id = $2',
['paid', record.order_id]
);
}
// Release the terminal for the next payment
markTerminalFree(record.terminal_id);
}
Always verify the amount. Even though the amount came from your server originally, verify it in the webhook response. This protects against edge cases where the terminal or API behaves unexpectedly.
Full Express Integration Example
// routes/terminal.js
var express = require('express');
var router = express.Router();
// Endpoint: cashier clicks "Charge" in the POS app
router.post('/api/terminal/charge', async function(req, res) {
var orderId = req.body.order_id;
var terminalId = req.body.terminal_id;
// Look up the order to get the amount
var order = await db.query('SELECT total_amount FROM orders WHERE id = $1', [orderId]);
if (order.rows.length === 0) {
return res.status(404).json({ error: 'Order not found' });
}
var amount = order.rows[0].total_amount; // Already in kobo
var result = await safePushPayment(terminalId, amount, orderId);
if (result.error) {
return res.status(400).json({ error: result.message });
}
monitorTerminalPayment(orderId); // Start timeout monitor
return res.json({
status: 'pushed',
message: 'Payment request sent to terminal. Waiting for customer.',
});
});
// Endpoint: cancel a pending terminal payment
router.post('/api/terminal/cancel', async function(req, res) {
var orderId = req.body.order_id;
await cancelTerminalPayment(orderId);
return res.json({ status: 'cancelled' });
});
module.exports = router;
Key Takeaways
- ✓Push payments by POSTing to /terminal/:terminal_id/event with type "invoice" and action "process".
- ✓Always include a unique reference in the data payload. This reference links the terminal event to your order.
- ✓Amount is in kobo (smallest currency unit). 500000 kobo = 5,000 NGN.
- ✓Handle three outcomes: success (webhook received), failure (webhook with failure details), and timeout (no webhook within your configured window).
- ✓Cancel pending requests by sending an event with action "cancel" to avoid zombie transactions on the device.
- ✓Queue payment requests if the terminal is busy. Only one payment can be active on a device at a time.
Frequently Asked Questions
- Can I push a payment request to multiple terminals simultaneously?
- Each payment request goes to one specific terminal identified by its terminal ID. If you have multiple terminals and multiple orders, you send each order to its assigned terminal separately. There is no broadcast feature.
- What happens if I push a payment and then the terminal loses power?
- If the terminal loses power before processing the card, the payment does not go through. No webhook is sent. Your timeout handler should detect the missing response and mark the transaction as timed out. The customer is not charged.
- Can I update the amount after pushing a payment request?
- No. Once a payment request is pushed to the terminal, you cannot change the amount. To change the amount, cancel the pending request and push a new one with the correct amount.
- Is there a minimum or maximum amount for terminal payments?
- Minimum and maximum amounts depend on your Paystack account settings and the card network limits. Check your Paystack dashboard for the specific limits. Generally, very small amounts (below the card network minimum) will be rejected.
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