McTaba Labs logo
Software EngineeringThe WhiteboardProjectsEventsMasterclassesWebinarsLearn
Book Call

500+ developers already in the community

Get answers, find accountability partners, and stay ahead

Join Discord →
McTaba Labs logo

Educating 1 million students in tech by 2030.

Programs

  • HighSchool Graduates Launchpad
  • AI & Business Automation
  • Corporate Training
  • McTaba Story & Mission
  • Full-Stack Curriculum
  • Course Pricing & Plans
  • Frequently Asked Questions

Resources

  • Join Discord Community
  • Tech Events in Nairobi
  • Masterclasses
  • Free Resources
  • Learn
  • Campus
  • Africa Tech Talent Report
  • Partner With Us
  • Start a Project
  • Our Work

Contact Us

  • 124 Manyani East Road, Lavington, Nairobi
  • +254727795603
  • Book a Discovery Call

Office Hours:

Mon-Fri: 9am - 2pm EAT

Sat: 10am - 12pm EAT

© 2027 McTaba Labs - A product of Edtech Industries. All rights reserved.

Privacy PolicyTerms of ServiceRefund Policy
  1. Learn
  2. Paystack for African Developers
  3. Terminal Invoice Payments via API
Bonaventure OgetoBy Bonaventure Ogeto|Updated July 20, 2026

Terminal Invoice Payments via API

Create or look up the invoice in your system, get the outstanding amount, push a payment event to the terminal with the invoice ID as the reference, and process the webhook to mark the invoice as paid. The terminal event type is "invoice" with action "process". Include the invoice number in the data payload so you can match the webhook response to the correct invoice.

In this article

  1. 1. The Invoice Payment Flow
  2. 2. Building the Invoice Lookup
  3. 3. Pushing the Invoice Payment to Terminal
  4. 4. Handling Partial Payments
  5. 5. Webhook Reconciliation for Invoices
  6. 6. Generating Payment Receipts
  7. 7. Daily Reconciliation

The Invoice Payment Flow

Invoice payments at a terminal follow a specific sequence that differs from ad-hoc retail charges.

  1. Customer arrives and provides their invoice number (or the cashier looks it up).
  2. Cashier enters the invoice number in your app.
  3. Your app looks up the invoice in your database, displays the outstanding balance.
  4. Cashier confirms the amount and taps "Charge."
  5. Your backend pushes the payment to the assigned terminal with the invoice ID as reference.
  6. Terminal displays the amount. Customer taps their card.
  7. Paystack processes the payment and sends a webhook.
  8. Your backend receives the webhook, marks the invoice as paid, and notifies the cashier.

The key difference from retail: In retail, you build the order in your app. For invoice payments, the order already exists as an invoice. Your app just needs to find it and push the right amount.

Building the Invoice Lookup

// invoice-lookup.js
async function lookupInvoice(searchTerm) {
  // Search by invoice number, customer name, or phone
  var result = await db.query(
    'SELECT id, invoice_number, customer_name, total_amount, paid_amount, ' +
    '(total_amount - paid_amount) as outstanding, status ' +
    'FROM invoices ' +
    'WHERE (invoice_number = $1 OR customer_phone = $1 OR customer_name ILIKE $2) ' +
    'AND status != $3 ' +
    'ORDER BY created_at DESC LIMIT 10',
    [searchTerm, '%' + searchTerm + '%', 'paid']
  );

  return result.rows;
}

// Usage
var invoices = await lookupInvoice('INV-2026-0042');
// Returns: [{ id: 42, invoice_number: 'INV-2026-0042', customer_name: 'John Okafor',
//   total_amount: 2500000, paid_amount: 0, outstanding: 2500000, status: 'unpaid' }]

Search flexibility. Customers do not always remember their invoice number. Let the cashier search by customer name or phone number too. Return all unpaid invoices for that customer so the cashier can select the right one.

Show the outstanding balance. If partial payments have been made, the outstanding balance may be less than the total. Always push the outstanding balance, not the original total.

Get weekly developer tips

Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.

No spam. Unsubscribe anytime.

Pushing the Invoice Payment to Terminal

// push-invoice-payment.js
const axios = require('axios');

async function chargeInvoiceAtTerminal(invoiceId, terminalId) {
  // Get invoice details
  var invoice = await db.query(
    'SELECT invoice_number, total_amount, paid_amount FROM invoices WHERE id = $1 AND status != $2',
    [invoiceId, 'paid']
  );

  if (invoice.rows.length === 0) {
    return { error: true, message: 'Invoice not found or already paid' };
  }

  var outstanding = invoice.rows[0].total_amount - invoice.rows[0].paid_amount;
  var invoiceNumber = invoice.rows[0].invoice_number;

  if (outstanding <= 0) {
    return { error: true, message: 'Invoice has no outstanding balance' };
  }

  // Create a unique reference linking to the invoice
  var reference = 'INV-' + invoiceNumber + '-' + Date.now().toString(36);

  // Record the pending payment
  await db.query(
    'INSERT INTO invoice_payments (invoice_id, reference, amount, terminal_id, status, created_at) ' +
    'VALUES ($1, $2, $3, $4, $5, NOW())',
    [invoiceId, reference, outstanding, terminalId, 'pending']
  );

  // Push to terminal
  await axios.post(
    'https://api.paystack.co/terminal/' + terminalId + '/event',
    {
      type: 'invoice',
      action: 'process',
      data: {
        id: reference,
        amount: outstanding,
      },
    },
    {
      headers: {
        Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
        'Content-Type': 'application/json',
      },
    }
  );

  return { error: false, reference: reference, amount: outstanding };
}

Amount comes from the database, not the UI. The cashier sees the outstanding balance but does not type it. Your backend reads the amount from the invoice record. This prevents manual errors and price tampering.

Handling Partial Payments

// partial-payment.js
async function chargePartialInvoice(invoiceId, partialAmount, terminalId) {
  var invoice = await db.query(
    'SELECT invoice_number, total_amount, paid_amount FROM invoices WHERE id = $1',
    [invoiceId]
  );

  var outstanding = invoice.rows[0].total_amount - invoice.rows[0].paid_amount;

  if (partialAmount > outstanding) {
    return { error: true, message: 'Partial amount exceeds outstanding balance' };
  }

  if (partialAmount <= 0) {
    return { error: true, message: 'Amount must be positive' };
  }

  var reference = 'PINV-' + invoice.rows[0].invoice_number + '-' + Date.now().toString(36);

  await db.query(
    'INSERT INTO invoice_payments (invoice_id, reference, amount, terminal_id, status, is_partial, created_at) ' +
    'VALUES ($1, $2, $3, $4, $5, true, NOW())',
    [invoiceId, reference, partialAmount, terminalId, 'pending']
  );

  await pushPaymentToTerminal(terminalId, partialAmount, reference);

  return {
    error: false,
    reference: reference,
    amount_charged: partialAmount,
    remaining_after: outstanding - partialAmount,
  };
}

Track partial payments. Each partial payment creates a new invoice_payments record. The invoice's paid_amount is the sum of all successful payments. The invoice status changes to "paid" only when paid_amount equals total_amount.

Show payment history on the invoice. When the cashier looks up an invoice, show all previous payments: dates, amounts, and methods. This helps when a customer disputes how much they have paid.

Webhook Reconciliation for Invoices

// invoice-webhook.js
async function handleInvoicePaymentWebhook(webhookData) {
  var reference = webhookData.data.reference;
  var status = webhookData.data.status;
  var amount = webhookData.data.amount;

  // Find the pending invoice payment
  var payment = await db.query(
    'SELECT invoice_id, amount FROM invoice_payments WHERE reference = $1 AND status = $2',
    [reference, 'pending']
  );

  if (payment.rows.length === 0) return;

  var invoiceId = payment.rows[0].invoice_id;

  if (status === 'success') {
    // Update the payment record
    await db.query(
      'UPDATE invoice_payments SET status = $1, completed_at = NOW() WHERE reference = $2',
      ['completed', reference]
    );

    // Update the invoice paid amount
    await db.query(
      'UPDATE invoices SET paid_amount = paid_amount + $1, updated_at = NOW() WHERE id = $2',
      [amount, invoiceId]
    );

    // Check if fully paid
    var invoice = await db.query(
      'SELECT total_amount, paid_amount FROM invoices WHERE id = $1',
      [invoiceId]
    );

    if (invoice.rows[0].paid_amount >= invoice.rows[0].total_amount) {
      await db.query(
        'UPDATE invoices SET status = $1, paid_at = NOW() WHERE id = $2',
        ['paid', invoiceId]
      );
    }
  } else {
    await db.query(
      'UPDATE invoice_payments SET status = $1, completed_at = NOW() WHERE reference = $2',
      ['failed', reference]
    );
  }
}

Amount verification. Compare the webhook amount with the amount you pushed to the terminal. They should match. If they do not, log the discrepancy and investigate.

Generating Payment Receipts

After the payment completes, generate a receipt that ties the payment to the invoice.

// receipt.js
async function generateInvoiceReceipt(reference) {
  var payment = await db.query(
    'SELECT ip.amount, ip.completed_at, i.invoice_number, i.customer_name, ' +
    'i.total_amount, i.paid_amount ' +
    'FROM invoice_payments ip JOIN invoices i ON ip.invoice_id = i.id ' +
    'WHERE ip.reference = $1',
    [reference]
  );

  var data = payment.rows[0];
  var remaining = data.total_amount - data.paid_amount;

  return {
    receipt_number: 'RCP-' + Date.now().toString(36),
    invoice_number: data.invoice_number,
    customer_name: data.customer_name,
    amount_paid: data.amount,
    payment_date: data.completed_at,
    invoice_total: data.total_amount,
    total_paid: data.paid_amount,
    remaining_balance: remaining > 0 ? remaining : 0,
    fully_settled: remaining <= 0,
  };
}

Show remaining balance on the receipt. If this was a partial payment, the receipt should clearly show how much the customer still owes. This prevents disputes about outstanding balances.

For receipt printing integration, see the terminal webhooks and receipt printing guide.

Daily Reconciliation

At the end of each day, reconcile your invoice payments against Paystack transactions to catch any discrepancies.

// reconcile.js
async function dailyReconciliation(date) {
  // Get all invoice payments for the day
  var localPayments = await db.query(
    'SELECT reference, amount, status FROM invoice_payments ' +
    'WHERE created_at::date = $1',
    [date]
  );

  var issues = [];

  for (var i = 0; i < localPayments.rows.length; i++) {
    var local = localPayments.rows[i];

    if (local.status === 'pending') {
      // Still pending after end of day - investigate
      issues.push({
        type: 'STUCK_PENDING',
        reference: local.reference,
        amount: local.amount,
      });
    }
  }

  return {
    date: date,
    total_payments: localPayments.rows.length,
    issues: issues,
    needs_attention: issues.length > 0,
  };
}

Stuck pending payments. If an invoice payment is still in "pending" status at end of day, something went wrong. Either the webhook was lost, the terminal went offline, or the customer walked away. Investigate each one and update the status accordingly.

Key Takeaways

  • ✓Use the invoice number or ID as the reference in the terminal event payload. This creates a direct link between the terminal payment and the invoice.
  • ✓Look up the invoice from your database before pushing to the terminal. Never accept the amount from the cashier UI for invoice payments.
  • ✓Handle partial payments: if the customer can only pay part of the invoice, push the partial amount and update the invoice balance.
  • ✓Mark the invoice as paid only after receiving the webhook confirmation, not after pushing to the terminal.
  • ✓Build an invoice lookup interface so the cashier can find the invoice by number, customer name, or phone number.
  • ✓Reconcile terminal payments against invoices daily to catch any mismatches.

Frequently Asked Questions

Can I send the invoice details to display on the terminal screen?
The terminal typically displays the amount. Depending on the terminal model and Paystack API capabilities, you may be able to send a short description. Check the current API documentation for supported display fields. For detailed invoice information, display it on the cashier screen instead.
What if a customer wants to pay multiple invoices at once?
You have two options. Either create a single combined payment for the total of all invoices (and split the payment across invoices in your database after the webhook), or process each invoice as a separate terminal payment one after another. The combined approach is faster for the customer.
How do I handle overpayment on an invoice?
If the customer pays more than the outstanding balance (perhaps due to a rounding issue), record the full payment amount and mark the invoice as paid. Track the overpayment as a credit on the customer account that can be applied to their next invoice.
Can I void an invoice payment after it has been processed?
You cannot void a card payment at the terminal. Use the Paystack Refund API to refund the transaction. Update the invoice to reverse the payment and restore the outstanding balance.

Learn Paystack Integration

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

Not ready? Create Free Account

Keep reading

Paystack Terminal and In-Person Payments: Complete Guide

Paystack Terminal lets you accept card payments at a physical location using Paystack POS devices. You push a payment request from your server to the terminal, the customer taps or inserts their card, and you get a webhook when it succeeds. This guide covers the full integration.

Read more

Building Custom Paystack Terminal Apps

A Paystack Terminal alone is just a card reader. The custom app you build around it turns it into a complete point-of-sale system. This guide covers the architecture of terminal-connected apps: how to structure the order flow, manage device state, handle multiple terminals, and build an interface cashiers can use under pressure.

Read more

Building a Terminal App with Flutter

Flutter is a natural fit for POS tablet apps: fast UI rendering, cross-platform deployment, and strong offline capabilities. This guide shows you how to build a Flutter app that creates orders, pushes payments to Paystack Terminal devices, and handles the full payment lifecycle from the cashier perspective.

Read more

Retail Checkout with Paystack Terminal and Barcode Scanning

Build a retail POS checkout by combining barcode scanning with Paystack Terminal. This guide covers looking up products by barcode from your inventory API, creating an invoice with line items, pushing it to the Paystack Terminal device, and handling the charge.success webhook to print a receipt.

Read more

Paystack Card Preauthorization for Holds and Deposits

Preauthorization lets you verify a customer has funds and place a hold without immediately capturing the full amount. This is how ride-hailing apps, hotel bookings, and equipment rental platforms work. This guide covers how to implement card holds and deferred captures with Paystack.

Read more

Explore related topics

M-Pesa & African Stack

M-Pesa Daraja API tutorials, USSD development, and WhatsApp Business API guides. The African Stack that global bootcamps do not teach.

Learn to Code in Kenya

Everything a Kenyan needs to know before writing their first line of code. Career paths, real costs, hardware requirements, and honest answers about what it takes.

Learning to Code

Roadmaps, language guides, and practical advice for learning to code and launching a programming career.

Breaking Into Tech

Practical, no-nonsense guides for anyone looking to start or change their career in tech. From choosing your first language to acing interviews.

The African Stack: Skills for This Market

The skills that make Kenyan developers hard to replace: M-Pesa, USSD, WhatsApp, fintech, and building for the African market.

Bonaventure Ogeto

Bonaventure Ogeto

Founder, McTaba Labs

Bonaventure is the founder of McTaba Labs, a coding bootcamp in Nairobi, Kenya. He has spent years building products for the African market and mentoring the next generation of developers across the continent.