Bonaventure OgetoBy Bonaventure Ogeto|

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.

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.

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.

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