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. Push Payment Requests to a Paystack Terminal
Bonaventure OgetoBy Bonaventure Ogeto|Updated July 20, 2026

Push Payment Requests to a Paystack Terminal

POST to /terminal/:terminal_id/event with type "invoice", action "process", and data containing the amount in kobo and a unique reference. The terminal displays the amount and waits for the customer to present their card. You receive the result via your webhook endpoint. To cancel a pending request, send another event with action "cancel".

In this article

  1. 1. Understanding the Push Model
  2. 2. The Basic Push Request
  3. 3. Managing Transaction References
  4. 4. Handling Timeouts
  5. 5. Handling a Busy Terminal
  6. 6. Error Handling for Push Requests
  7. 7. Matching Webhooks to Terminal Payments
  8. 8. Full Express Integration Example

Understanding the Push Model

Paystack Terminal uses a push model: your server pushes payment requests to the device, not the other way around. The terminal does not decide how much to charge. Your application makes that decision and tells the terminal.

This is different from standalone POS terminals where a cashier types an amount on the device keypad. With Paystack Terminal, the amount comes from your backend. The cashier might tap a button in your app that says "Charge customer for Order #1234," and your backend sends the exact amount to the terminal.

Why this matters: It means your application is the source of truth for pricing. The terminal is just the payment acceptance device. This prevents manual entry errors and ensures every charge matches an order in your system.

The Basic Push Request

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

async function pushPayment(terminalId, amountInKobo, orderId) {
  var response = await axios.post(
    'https://api.paystack.co/terminal/' + terminalId + '/event',
    {
      type: 'invoice',
      action: 'process',
      data: {
        id: orderId,
        amount: amountInKobo,
      },
    },
    {
      headers: {
        Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
        'Content-Type': 'application/json',
      },
    }
  );

  return response.data;
}

// Push a 3,500 NGN payment
var result = await pushPayment('TERM_abc123', 350000, 'ORD-20260720-001');
console.log(result); // { status: true, message: "Event sent to terminal" }

type: Set to "invoice" for payment requests.

action: Set to "process" to start a payment. Other actions include "cancel" to cancel a pending payment.

data.id: Your unique reference for this transaction. Use your order ID or generate a unique string. This is how you match the webhook response to the correct order.

data.amount: The amount in kobo. Always calculate this on your server from your order data. Never accept the amount from the frontend.

Get weekly developer tips

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

No spam. Unsubscribe anytime.

Managing Transaction References

Every payment pushed to a terminal needs a unique reference. This reference appears in the webhook when the transaction completes, and it is how you reconcile terminal transactions with your orders.

// generate-reference.js
function generateTerminalReference(orderId) {
  var timestamp = Date.now().toString(36);
  return 'TRM-' + orderId + '-' + timestamp;
}

// Store the reference before pushing to terminal
async function initiateTerminalPayment(orderId, amount, terminalId) {
  var reference = generateTerminalReference(orderId);

  // Save the mapping
  await db.query(
    'INSERT INTO terminal_transactions (order_id, reference, terminal_id, amount, status, created_at) ' +
    'VALUES ($1, $2, $3, $4, $5, NOW())',
    [orderId, reference, terminalId, amount, 'pending']
  );

  // Push to terminal
  await pushPayment(terminalId, amount, reference);

  return { reference: reference, status: 'pushed' };
}

Store the reference before pushing. If the push call succeeds but your database write fails, you lose the mapping. Write to the database first, then push to the terminal.

Reference format. Use a format that encodes useful information: the order ID, a timestamp, and a prefix like "TRM" to distinguish terminal transactions from online ones in your logs.

Handling Timeouts

After you push a payment request, the terminal waits for the customer to present their card. If the customer does not act within a reasonable time, you need to handle the timeout.

// timeout-handler.js
var TERMINAL_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes

async function monitorTerminalPayment(reference) {
  // Set a timeout
  setTimeout(async function() {
    // Check if we received a webhook for this reference
    var transaction = await db.query(
      'SELECT status FROM terminal_transactions WHERE reference = $1',
      [reference]
    );

    if (transaction.rows.length > 0 && transaction.rows[0].status === 'pending') {
      // No webhook received within 5 minutes - cancel the request
      console.log('Terminal payment timed out: ' + reference);
      await cancelTerminalPayment(reference);
    }
  }, TERMINAL_TIMEOUT_MS);
}

async function cancelTerminalPayment(reference) {
  // Find the terminal ID for this reference
  var record = await db.query(
    'SELECT terminal_id FROM terminal_transactions WHERE reference = $1',
    [reference]
  );

  if (record.rows.length > 0) {
    // Send cancel event to terminal
    await axios.post(
      'https://api.paystack.co/terminal/' + record.rows[0].terminal_id + '/event',
      {
        type: 'invoice',
        action: 'cancel',
        data: { id: reference },
      },
      {
        headers: {
          Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
          'Content-Type': 'application/json',
        },
      }
    );

    // Update status
    await db.query(
      'UPDATE terminal_transactions SET status = $1 WHERE reference = $2',
      ['cancelled', reference]
    );
  }
}

5 minutes is a reasonable timeout. It gives the customer time to find their card, try a PIN, or switch to a different card. Beyond 5 minutes, the customer has likely walked away.

Cancel the pending request. If you do not cancel, the terminal stays in a "waiting for payment" state and cannot accept the next customer's payment. Always cancel timed-out requests.

Handling a Busy Terminal

A terminal can only process one payment at a time. If you push a second payment while the first is still pending, the API will reject it.

// queue-payments.js
var terminalQueues = {};

async function queuePayment(terminalId, amount, orderId) {
  if (!terminalQueues[terminalId]) {
    terminalQueues[terminalId] = { busy: false, queue: [] };
  }

  var terminal = terminalQueues[terminalId];

  if (terminal.busy) {
    // Queue the request
    return new Promise(function(resolve, reject) {
      terminal.queue.push({
        amount: amount,
        orderId: orderId,
        resolve: resolve,
        reject: reject,
      });
    });
  }

  terminal.busy = true;

  try {
    var result = await pushPayment(terminalId, amount, orderId);
    return result;
  } catch (error) {
    terminal.busy = false;
    processNextInQueue(terminalId);
    throw error;
  }
}

function markTerminalFree(terminalId) {
  if (terminalQueues[terminalId]) {
    terminalQueues[terminalId].busy = false;
    processNextInQueue(terminalId);
  }
}

function processNextInQueue(terminalId) {
  var terminal = terminalQueues[terminalId];
  if (terminal && terminal.queue.length > 0) {
    var next = terminal.queue.shift();
    queuePayment(terminalId, next.amount, next.orderId)
      .then(next.resolve)
      .catch(next.reject);
  }
}

Call markTerminalFree when you receive the webhook for the completed (or failed) transaction. This releases the terminal for the next queued payment.

Error Handling for Push Requests

The push API can fail for several reasons. Handle each one specifically.

Terminal not found (404): The terminal ID is wrong or the terminal has been deactivated. Check the terminal ID against your registered devices.

Terminal offline: The device is not connected to the internet. The API may accept the event (Paystack queues it) or reject it depending on implementation. If queued, the payment will process when the device reconnects.

Terminal busy: Another payment is already pending on this device. Queue the request and retry when the current payment completes.

Invalid amount: Amount is zero, negative, or below the minimum. Validate on your end before calling the API.

// safe-push.js
async function safePushPayment(terminalId, amount, orderId) {
  // Validate locally first
  if (!amount || amount < 10000) { // Minimum 100 NGN
    return { error: true, message: 'Amount too low for terminal payment' };
  }

  try {
    var result = await pushPayment(terminalId, amount, orderId);
    return { error: false, data: result };
  } catch (err) {
    if (err.response) {
      var status = err.response.status;
      if (status === 404) {
        return { error: true, message: 'Terminal not found. Check the terminal ID.' };
      }
      if (status === 409) {
        return { error: true, message: 'Terminal is busy. Payment queued.' };
      }
    }
    return { error: true, message: 'Could not reach terminal. Please retry.' };
  }
}

Matching Webhooks to Terminal Payments

When the payment completes, Paystack sends a webhook. Match it to your pending terminal transaction using the reference.

// match-webhook.js
async function handleTerminalResult(webhookData) {
  var reference = webhookData.data.reference;

  // Find the pending terminal transaction
  var pending = await db.query(
    'SELECT order_id, amount, terminal_id FROM terminal_transactions WHERE reference = $1',
    [reference]
  );

  if (pending.rows.length === 0) {
    console.warn('No pending terminal transaction for reference: ' + reference);
    return;
  }

  var record = pending.rows[0];

  // Verify the amount matches
  if (webhookData.data.amount !== record.amount) {
    console.error('Amount mismatch! Expected ' + record.amount + ' got ' + webhookData.data.amount);
    return;
  }

  // Update the terminal transaction
  await db.query(
    'UPDATE terminal_transactions SET status = $1, completed_at = NOW() WHERE reference = $2',
    [webhookData.data.status === 'success' ? 'completed' : 'failed', reference]
  );

  // Update the order
  if (webhookData.data.status === 'success') {
    await db.query(
      'UPDATE orders SET payment_status = $1, paid_at = NOW() WHERE id = $2',
      ['paid', record.order_id]
    );
  }

  // Release the terminal for the next payment
  markTerminalFree(record.terminal_id);
}

Always verify the amount. Even though the amount came from your server originally, verify it in the webhook response. This protects against edge cases where the terminal or API behaves unexpectedly.

Full Express Integration Example

// routes/terminal.js
var express = require('express');
var router = express.Router();

// Endpoint: cashier clicks "Charge" in the POS app
router.post('/api/terminal/charge', async function(req, res) {
  var orderId = req.body.order_id;
  var terminalId = req.body.terminal_id;

  // Look up the order to get the amount
  var order = await db.query('SELECT total_amount FROM orders WHERE id = $1', [orderId]);
  if (order.rows.length === 0) {
    return res.status(404).json({ error: 'Order not found' });
  }

  var amount = order.rows[0].total_amount; // Already in kobo

  var result = await safePushPayment(terminalId, amount, orderId);

  if (result.error) {
    return res.status(400).json({ error: result.message });
  }

  monitorTerminalPayment(orderId); // Start timeout monitor

  return res.json({
    status: 'pushed',
    message: 'Payment request sent to terminal. Waiting for customer.',
  });
});

// Endpoint: cancel a pending terminal payment
router.post('/api/terminal/cancel', async function(req, res) {
  var orderId = req.body.order_id;
  await cancelTerminalPayment(orderId);
  return res.json({ status: 'cancelled' });
});

module.exports = router;

Key Takeaways

  • ✓Push payments by POSTing to /terminal/:terminal_id/event with type "invoice" and action "process".
  • ✓Always include a unique reference in the data payload. This reference links the terminal event to your order.
  • ✓Amount is in kobo (smallest currency unit). 500000 kobo = 5,000 NGN.
  • ✓Handle three outcomes: success (webhook received), failure (webhook with failure details), and timeout (no webhook within your configured window).
  • ✓Cancel pending requests by sending an event with action "cancel" to avoid zombie transactions on the device.
  • ✓Queue payment requests if the terminal is busy. Only one payment can be active on a device at a time.

Frequently Asked Questions

Can I push a payment request to multiple terminals simultaneously?
Each payment request goes to one specific terminal identified by its terminal ID. If you have multiple terminals and multiple orders, you send each order to its assigned terminal separately. There is no broadcast feature.
What happens if I push a payment and then the terminal loses power?
If the terminal loses power before processing the card, the payment does not go through. No webhook is sent. Your timeout handler should detect the missing response and mark the transaction as timed out. The customer is not charged.
Can I update the amount after pushing a payment request?
No. Once a payment request is pushed to the terminal, you cannot change the amount. To change the amount, cancel the pending request and push a new one with the correct amount.
Is there a minimum or maximum amount for terminal payments?
Minimum and maximum amounts depend on your Paystack account settings and the card network limits. Check your Paystack dashboard for the specific limits. Generally, very small amounts (below the card network minimum) will be rejected.

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 Partial Debits Explained

Partial debit lets you attempt to charge a card and capture whatever amount is available if the full amount fails. Instead of an all-or-nothing charge, you get what the card can give. This guide covers how partial debits work, when to use them, and how to handle the resulting split.

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.