Paystack Terminal is a POS device that accepts card payments at physical locations. You integrate it by pushing payment requests from your backend to the terminal via API. The customer taps or inserts their card. Paystack processes the payment and sends you a webhook with the result. You can also build custom Android apps for the terminal hardware or use the Virtual Terminal for phone and email-based card collection.
Paystack Terminal is a POS (Point of Sale) device that connects to your Paystack account and accepts card payments at physical locations. It is the bridge between online payment infrastructure and in-person transactions.
What it is:
What it is not:
How to get one. You request a terminal through your Paystack Dashboard. Paystack ships the device to your business address. Each terminal has a unique ID that you use in API calls. You can register multiple terminals for different locations or checkout counters.
For the full picture of how Terminal fits into the Paystack platform, see the complete Paystack engineering guide.
The core flow is simple. Your backend sends a payment request to a specific terminal. The terminal displays the amount. The customer taps or inserts their card. Paystack processes the payment and sends you a webhook.
// push-payment.ts
import axios from 'axios';
async function pushPaymentToTerminal(
terminalId: string,
amountInKobo: number,
reference: string
) {
const response = await axios.post(
'https://api.paystack.co/terminal/commission_device/send_event',
{
serial_number: terminalId,
type: 'invoice',
action: 'process',
data: {
id: reference,
offline_reference: reference,
amount: amountInKobo,
},
},
{
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
'Content-Type': 'application/json',
},
}
);
return response.data;
}
// Push a KES 1,500 payment to terminal
// Note: amount is in the smallest currency unit
// For NGN, that's kobo (150000 = NGN 1,500)
// For KES, check Paystack docs for the unit used
await pushPaymentToTerminal(
'TERMINAL_SERIAL_HERE',
150000,
`order_${Date.now()}`
);
The push model. This is different from traditional POS terminals where the cashier types the amount on the device. With Paystack Terminal, your system calculates the total (from your inventory, cart, or invoice) and pushes it to the device. The cashier does not need to type anything. This eliminates keying errors and connects the payment directly to the order in your system.
What happens on the terminal. The device screen shows the amount and prompts the customer to tap, insert, or swipe their card. The customer enters their PIN if required. The terminal communicates with Paystack's servers, which communicate with the card network and issuing bank.
What you get back. The API call itself returns a 200 confirming the event was sent to the terminal. The actual payment result comes via webhook. Do not treat the 200 as "payment successful." It only means "the terminal received the instruction."
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
For businesses that generate invoices before collecting payment (restaurants, repair shops, professional services), Paystack Terminal supports invoice-based flows.
The pattern:
// invoice-terminal-flow.ts
// Step 1: Create the invoice in your system
const invoice = await db.invoices.create({
customerId: 'cust_123',
items: [
{ name: 'Laptop Repair', amount: 350000 }, // KES 3,500 in smallest unit
{ name: 'Screen Protector', amount: 50000 }, // KES 500
],
total: 400000, // KES 4,000
reference: `inv_${Date.now()}`,
status: 'pending',
terminalId: 'TERMINAL_COUNTER_1',
});
// Step 2: Push to terminal when customer is ready
await pushPaymentToTerminal(
invoice.terminalId,
invoice.total,
invoice.reference
);
// Step 3: Handle the webhook
app.post('/webhooks/paystack', async (req, res) => {
// Verify signature first
const event = req.body;
if (event.event === 'charge.success') {
const reference = event.data.reference;
// Find and update the invoice
await db.invoices.update({
where: { reference },
data: {
status: 'paid',
paidAt: new Date(),
paystackTransactionId: event.data.id,
},
});
// Trigger receipt printing, inventory update, etc.
}
res.sendStatus(200);
});
Partial payments. Paystack Terminal does not natively support splitting a payment across two cards. If a customer wants to pay half with one card and half with another, your application needs to handle this by pushing two separate payment requests with adjusted amounts.
Tips and service charges. If your business collects tips (common in restaurants), calculate the tip in your application before pushing the final amount to the terminal. The terminal does not have a "add tip" prompt built in. You need to build that into your cashier-facing UI.
Paystack Terminal runs Android. This means you can build custom applications that run directly on the terminal hardware. Instead of using the default Paystack app that just shows an amount and waits for a card, you can build a full checkout experience.
Use cases for custom terminal apps:
Building a custom terminal app. You build an Android app using the Paystack Terminal SDK. The SDK provides methods to initiate a card payment from within your app.
// Example: Android (Kotlin) terminal payment initiation
// This runs ON the terminal device itself
// Initialize the terminal SDK
val terminal = PaystackTerminal.getInstance()
// When user clicks "Pay" in your custom UI
fun processPayment(amountInKobo: Long, reference: String) {
val paymentRequest = PaymentRequest.Builder()
.setAmount(amountInKobo)
.setReference(reference)
.build()
terminal.processPayment(paymentRequest, object : PaymentCallback {
override fun onSuccess(transaction: Transaction) {
// Payment successful
// Update your local UI
showReceipt(transaction)
}
override fun onError(error: TerminalError) {
// Payment failed
showError(error.message)
}
})
}
Deployment. You deploy your custom app to your terminals through the Paystack Dashboard or Terminal Management API. You can push updates to all your terminals at once or target specific devices.
Important limitation. Custom terminal apps require approval from Paystack. You cannot just sideload any APK onto the device. Submit your app for review, and Paystack will deploy it to your terminals once approved.
For a step-by-step tutorial on building a terminal app with Flutter, see the Flutter terminal app guide.
Paystack Virtual Terminal lets you accept card payments without a physical device. You (the merchant) enter the customer's card details into a form on your Paystack Dashboard. The payment processes as a card-not-present transaction.
When to use Virtual Terminal:
How it works:
Security considerations. Virtual Terminal transactions are card-not-present (CNP). They carry higher fraud risk because the card is not physically present. Card networks charge higher interchange fees for CNP transactions. Chargeback rates tend to be higher too.
Best practices for Virtual Terminal:
API access. Virtual Terminal is primarily a Dashboard feature, not an API feature. You cannot programmatically submit card details through an API without PCI DSS compliance. If you need programmatic card-not-present payments, use the standard Paystack Charge API with a tokenized card.
The real power of Paystack Terminal comes when you connect it to your existing business systems. A standalone POS that just accepts payments is useful. A POS that also updates inventory, prints receipts, and syncs with your accounting system is a business tool.
Architecture pattern:
// System architecture for terminal + inventory
//
// [Cashier UI] --> [Your Backend API] --> [Paystack Terminal API]
// | | |
// | v |
// | [Your Database] |
// | (orders, inventory) |
// | ^ |
// | | |
// | [Webhook Handler] <--- [Paystack Webhook]
// | |
// v v
// [Receipt Printer] [Inventory Update]
The flow in detail:
charge.success webhook to your backend.// inventory-update-on-payment.ts
async function handleTerminalPaymentSuccess(
reference: string,
transactionId: number
) {
// Find the order by reference
const order = await db.orders.findUnique({
where: { reference },
include: { items: true },
});
if (!order) {
console.error(`Order not found for reference: ${reference}`);
return;
}
if (order.status === 'paid') {
// Already processed (idempotency)
return;
}
// Update order status
await db.orders.update({
where: { id: order.id },
data: {
status: 'paid',
paidAt: new Date(),
paystackTransactionId: transactionId,
},
});
// Decrement inventory for each item
for (const item of order.items) {
await db.products.update({
where: { id: item.productId },
data: {
stockQuantity: { decrement: item.quantity },
},
});
}
// Trigger receipt
await printReceipt(order);
}
Multi-terminal setups. If you have multiple terminals at different counters or locations, include the terminal ID in your order record. This lets you track which terminal processed each payment and troubleshoot issues at specific locations.
Terminal payments trigger the same webhooks as online payments. The primary event you care about is charge.success. But there are terminal-specific details in the webhook payload.
// Terminal charge.success webhook payload (simplified)
{
"event": "charge.success",
"data": {
"id": 1234567890,
"reference": "order_1689012345",
"amount": 400000,
"currency": "NGN",
"channel": "card",
"status": "success",
"authorization": {
"authorization_code": "AUTH_abc123",
"bin": "539923",
"last4": "7890",
"card_type": "mastercard DEBIT",
"bank": "Guaranty Trust Bank",
"country_code": "NG",
"brand": "Mastercard",
"reusable": false,
"signature": "SIG_xyz789"
},
"metadata": {
"terminal_id": "TERM_001",
"custom_fields": []
}
}
}
Key differences from online payments:
authorization.reusable is usually false. Terminal card authorizations are tied to the physical card being present. You cannot reuse them for recurring charges. For recurring payments, use online tokenization instead.channel is always "card". Terminal only supports card payments. You will not see bank transfer, mobile money, or USSD through a terminal.Failed terminal payments. If the card is declined, the terminal shows an error to the customer. Your webhook handler receives a charge.failed event (if enabled). In practice, most failed terminal payments do not need webhook handling because the cashier and customer are standing right there and can try again immediately.
Timeout handling. If a customer walks away without completing the payment, the terminal eventually times out. Your order remains in "pending" status. Build a cleanup job that marks stale pending terminal orders as "expired" after a reasonable period (15 to 30 minutes).
Paystack Terminal requires an internet connection to process card payments. The card data needs to reach Paystack's servers, which then communicate with the card network and the issuing bank. This is not optional.
What happens when connectivity drops:
Mitigating connectivity issues:
// connection-health-check.ts (cashier UI)
let isOnline = true;
async function checkConnection() {
try {
const response = await fetch('/api/health', {
signal: AbortSignal.timeout(5000),
});
isOnline = response.ok;
} catch {
isOnline = false;
}
}
// Check every 30 seconds
setInterval(checkConnection, 30000);
// Before pushing a payment
async function initiateTerminalPayment(orderId: string) {
if (!isOnline) {
showWarning(
'Internet connection appears to be down. ' +
'Terminal payments may not work. ' +
'Consider using M-Pesa as a fallback.'
);
return;
}
// Proceed with terminal payment push
await fetch(`/api/orders/${orderId}/pay-terminal`, { method: 'POST' });
}
Store-and-forward is limited. Unlike some traditional POS systems that can authorize payments offline and batch them later, Paystack Terminal does not support true offline authorization. The terminal needs to be online at the time of the transaction. If you need offline payment capability, you are looking at a different architecture (possibly mobile money or cash with manual reconciliation).
For the full set of Paystack integration patterns, including how Terminal fits alongside online payments, see the complete engineering guide.
Want to build full-stack applications with payment integrations? The Full-Stack AI Engineering course covers payment systems, APIs, and deployment at your own pace.
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