Terminal Event Webhooks and Receipt Printing
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 Webhook Events
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);
}
Extracting Receipt Data from Webhooks
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."
Formatting a Receipt
// 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.
Connecting a Thermal Printer
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.
Digital Receipts via SMS and Email
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.
Storing Receipts for Reprints and Disputes
// 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.
Key Takeaways
- ✓Terminal transactions trigger standard charge.success and charge.failed webhooks. Your existing webhook handler works for terminal payments.
- ✓The webhook payload includes channel ("card"), card details (last 4 digits, brand), and amount. Use these for receipt data.
- ✓Format receipts with your business name, date, amount, payment method, reference number, and a unique receipt ID.
- ✓Thermal printers use ESC/POS commands. Libraries like escpos (Node.js) handle the formatting.
- ✓Offer digital receipts via SMS or email as an alternative to printed receipts. Collect the customer phone number or email at checkout.
- ✓Store receipt data in your database for reprints and for dispute resolution.
Frequently Asked Questions
- Do Paystack terminals have built-in receipt printers?
- Some Paystack terminal models include built-in thermal printers. Others do not and require an external printer connected to your POS system. Check the specifications of your terminal model. If the terminal has a built-in printer, you can send print commands through the Terminal API.
- What information must a receipt include by law?
- Requirements vary by country and business type. Generally, a receipt should include the business name, date, amount, payment method, and a unique reference number. For VAT-registered businesses, include the VAT number and tax breakdown. Check your local regulations.
- Can I customize the receipt format per business or location?
- Yes. Store receipt templates in your database, one per business or location. Each template can have a different business name, address, logo, and footer message. Apply the correct template when formatting the receipt.
- How do I handle receipt printing failures?
- Never let a printing failure block the payment. The payment has already been processed. Store the receipt in your database. Show the cashier a "Receipt saved - printer unavailable" message. Provide a reprint button they can use when the printer is back online.
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