Terminal transactions trigger the same charge.success and charge.failed webhooks as online payments. Extract the reference, amount, channel, and timestamp from the webhook payload. Format a receipt with your business name, transaction details, and a unique receipt number. Send the formatted data to a thermal printer via ESC/POS commands, or generate a digital receipt as PDF or SMS.
Terminal transactions produce the same webhook events as online payments. The key events are:
charge.success: The payment went through. The customer's card was charged successfully. This is your signal to update the order, print a receipt, and release the terminal for the next payment.
charge.failed: The payment did not go through. The card was declined, the PIN was wrong, or the bank rejected the transaction. Show the failure reason to the cashier and let them retry or ask the customer for a different card.
// terminal-webhook.js
var crypto = require('crypto');
function handleTerminalWebhook(req, res) {
var hash = crypto
.createHmac('sha512', process.env.PAYSTACK_SECRET_KEY)
.update(JSON.stringify(req.body))
.digest('hex');
if (hash !== req.headers['x-paystack-signature']) {
return res.status(401).send('Invalid signature');
}
var event = req.body;
var data = event.data;
if (event.event === 'charge.success') {
processSuccessfulTerminalPayment(data);
}
if (event.event === 'charge.failed') {
processFailedTerminalPayment(data);
}
return res.status(200).send('OK');
}
async function processSuccessfulTerminalPayment(data) {
var receiptData = {
reference: data.reference,
amount: data.amount,
currency: data.currency,
channel: data.channel,
card_type: data.authorization ? data.authorization.brand : 'card',
last_four: data.authorization ? data.authorization.last4 : '****',
paid_at: data.paid_at || new Date().toISOString(),
};
// Store receipt data
await storeReceipt(receiptData);
// Trigger receipt printing
await printReceipt(receiptData);
// Update order status
await markOrderPaid(data.reference);
}
The webhook payload contains everything you need for a receipt. Here are the fields that matter.
// extract-receipt-data.js
function extractReceiptFields(webhookData) {
var data = webhookData.data;
return {
transaction_reference: data.reference,
amount: data.amount, // in kobo
amount_display: formatAmount(data.amount, data.currency),
currency: data.currency, // "NGN", "KES", etc.
channel: data.channel, // "card"
card_brand: data.authorization ? data.authorization.brand : null, // "visa", "mastercard"
card_last4: data.authorization ? data.authorization.last4 : null, // "4081"
card_type: data.authorization ? data.authorization.card_type : null, // "debit"
bank: data.authorization ? data.authorization.bank : null,
paid_at: data.paid_at,
customer_email: data.customer ? data.customer.email : null,
};
}
function formatAmount(kobo, currency) {
var amount = kobo / 100;
if (currency === 'NGN') return 'NGN ' + amount.toLocaleString('en-NG', { minimumFractionDigits: 2 });
if (currency === 'KES') return 'KES ' + amount.toLocaleString('en-KE', { minimumFractionDigits: 2 });
return currency + ' ' + amount.toFixed(2);
}
Card last 4 digits. The authorization object in the webhook contains the last 4 digits of the card number. Show this on the receipt so the customer can verify which card was charged. Never show more than the last 4 digits.
Card brand. Show the card network (Visa, Mastercard, Verve) on the receipt. This helps with disputes: "I paid with my Visa ending in 4081."
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
// format-receipt.js
function formatReceiptText(receiptData, businessInfo) {
var lines = [];
// Header
lines.push(centerText(businessInfo.name, 32));
lines.push(centerText(businessInfo.address, 32));
lines.push(centerText(businessInfo.phone, 32));
lines.push('--------------------------------');
// Transaction details
lines.push('Date: ' + formatDate(receiptData.paid_at));
lines.push('Ref: ' + receiptData.transaction_reference);
lines.push('');
// Amount
lines.push('AMOUNT: ' + receiptData.amount_display);
lines.push('');
// Payment method
var cardInfo = (receiptData.card_brand || 'Card').toUpperCase();
cardInfo = cardInfo + ' ****' + (receiptData.card_last4 || '****');
lines.push('Paid by: ' + cardInfo);
lines.push('');
// Footer
lines.push('--------------------------------');
lines.push(centerText('Thank you!', 32));
lines.push(centerText('Powered by Paystack', 32));
return lines.join('\n');
}
function centerText(text, width) {
if (text.length >= width) return text;
var padding = Math.floor((width - text.length) / 2);
var spaces = '';
for (var i = 0; i < padding; i++) spaces += ' ';
return spaces + text;
}
function formatDate(dateString) {
var d = new Date(dateString);
return d.toLocaleDateString('en-GB') + ' ' + d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
}
32-character width is standard for most thermal receipt printers. Adjust based on your printer model. Some use 40 or 48 characters per line.
Thermal printers connect via USB, Bluetooth, or network. They use the ESC/POS command set. Node.js libraries like escpos handle the low-level commands.
// print-receipt.js
// Using escpos library
var escpos = require('escpos');
async function printToThermalPrinter(receiptText) {
var device = new escpos.USB(); // or escpos.Network('192.168.1.100')
var printer = new escpos.Printer(device);
return new Promise(function(resolve, reject) {
device.open(function(err) {
if (err) {
console.error('Printer error: ' + err.message);
return reject(err);
}
printer
.font('a')
.align('ct')
.style('b')
.size(1, 1)
.text(receiptText)
.cut()
.close();
resolve();
});
});
}
Fallback when the printer is offline. If the printer is not connected or out of paper, do not block the payment flow. Store the receipt in your database and let the cashier reprint later. Show a message: "Receipt saved. Printer not available."
USB vs Network printers. USB printers connect directly to the computer running your POS app. Network printers connect via Wi-Fi or Ethernet and can be shared across multiple registers. Network printers are more flexible but need proper network configuration.
Many customers prefer digital receipts. Offer to send the receipt via SMS or email instead of printing.
// digital-receipt.js
async function sendDigitalReceipt(receiptData, method, destination) {
var receiptText = formatReceiptText(receiptData, businessInfo);
if (method === 'sms') {
await smsService.send(destination, receiptText);
}
if (method === 'email') {
await emailService.send({
to: destination,
subject: 'Payment Receipt - ' + receiptData.transaction_reference,
text: receiptText,
});
}
// Log the delivery
await db.query(
'INSERT INTO receipt_deliveries (reference, method, destination, sent_at) VALUES ($1, $2, $3, NOW())',
[receiptData.transaction_reference, method, destination]
);
}
Ask before the payment, not after. Collect the customer's phone number or email before pushing the payment to the terminal. After the payment completes, send the receipt automatically. Do not make the customer wait while you ask for their contact details after they have already paid.
Privacy. Only use the phone number or email for the receipt. Do not add them to a marketing list without separate consent.
// store-receipt.js
async function storeReceipt(receiptData) {
await db.query(
'INSERT INTO receipts ' +
'(reference, amount, currency, card_brand, card_last4, paid_at, receipt_text, created_at) ' +
'VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())',
[
receiptData.transaction_reference,
receiptData.amount,
receiptData.currency,
receiptData.card_brand,
receiptData.card_last4,
receiptData.paid_at,
formatReceiptText(receiptData, businessInfo),
]
);
}
async function reprintReceipt(reference) {
var receipt = await db.query(
'SELECT receipt_text FROM receipts WHERE reference = $1',
[reference]
);
if (receipt.rows.length === 0) {
return { error: true, message: 'Receipt not found' };
}
await printToThermalPrinter(receipt.rows[0].receipt_text);
return { error: false };
}
Keep receipts for at least 1 year. Chargebacks and disputes can arise months after the transaction. Having the receipt data proves the transaction details and helps resolve disputes.
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