Bonaventure OgetoBy Bonaventure Ogeto|

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".

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.

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.

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