Bonaventure OgetoBy Bonaventure Ogeto|

Accept Payments with Paystack in Node.js and Express

To accept Paystack payments in Express, create a POST route that initializes a transaction with the Paystack API using your secret key. Return the authorization URL to your frontend, redirect the user to Paystack checkout, then verify the payment in a GET callback route before granting value.

Prerequisites

Before you start, make sure you have:

  • A Paystack account with test keys ready (Settings > API Keys & Webhooks)
  • Node.js 18 or later installed (for built-in fetch support)
  • Basic familiarity with Express.js route handlers

This guide uses plain JavaScript with CommonJS requires. The same patterns work in TypeScript or ESM imports.

Project Setup

Create a new project and install Express:

mkdir paystack-express && cd paystack-express
npm init -y
npm install express dotenv

Create a .env file:

PAYSTACK_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxx
BASE_URL=http://localhost:3000

Create your main server file:

// server.js
require('dotenv').config();
const express = require('express');
const app = express();

app.use(express.json());
app.use(express.urlencoded({ extended: true }));

const PAYSTACK_SECRET = process.env.PAYSTACK_SECRET_KEY;
const BASE_URL = process.env.BASE_URL;

app.listen(3000, function() {
  console.log('Server running on port 3000');
});

No Paystack SDK is needed. Node 18+ includes a global fetch function. If you are on an older version, install node-fetch.

Initialize a Transaction

Create a POST route that receives the customer email and amount, calls the Paystack Initialize Transaction endpoint, and returns the authorization URL.

// Initialize a Paystack transaction
app.post('/api/pay', async function(req, res) {
  var email = req.body.email;
  var amount = req.body.amount;

  var reference = 'order_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);

  var paystackRes = await fetch('https://api.paystack.co/transaction/initialize', {
    method: 'POST',
    headers: {
      Authorization: 'Bearer ' + PAYSTACK_SECRET,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      email: email,
      amount: Math.round(amount * 100),
      reference: reference,
      callback_url: BASE_URL + '/api/payment/callback',
      metadata: {
        custom_fields: [
          {
            display_name: 'Payment For',
            variable_name: 'payment_for',
            value: 'Order ' + reference,
          },
        ],
      },
    }),
  });

  var data = await paystackRes.json();

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

  // TODO: Save reference + amount + email to your database here

  res.json({
    authorization_url: data.data.authorization_url,
    access_code: data.data.access_code,
    reference: data.data.reference,
  });
});

Key details:

  • Math.round(amount * 100) converts the display amount to kobo and avoids floating-point issues.
  • The callback_url tells Paystack where to redirect after payment. We build that route next.
  • Save the reference to your database before returning. Both your webhook handler and callback route need it.

Redirect Checkout Flow

The simplest checkout approach: your frontend calls the initialize endpoint, gets the authorization URL, and redirects the browser.

<!-- public/checkout.html -->
<form id="checkout-form">
  <input type="email" id="email" placeholder="Email" required />
  <p>Amount: NGN 5,000</p>
  <button type="submit">Pay Now</button>
</form>

<script>
document.getElementById('checkout-form').addEventListener('submit', function(e) {
  e.preventDefault();
  var email = document.getElementById('email').value;

  fetch('/api/pay', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email: email, amount: 5000 }),
  })
  .then(function(res) { return res.json(); })
  .then(function(data) {
    if (data.authorization_url) {
      window.location.href = data.authorization_url;
    } else {
      alert(data.error || 'Something went wrong');
    }
  });
});
</script>

When the user clicks Pay Now, the browser redirects to Paystack's hosted checkout page. Paystack handles card entry, 3DS verification, bank transfers, and all other payment channels.

Handle the Redirect Callback

After payment, Paystack redirects back to your callback URL with ?trxref=REFERENCE&reference=REFERENCE as query parameters. Create a GET route to handle this:

// Handle Paystack redirect callback
app.get('/api/payment/callback', async function(req, res) {
  var reference = req.query.reference || req.query.trxref;

  if (!reference) {
    return res.status(400).send('Missing reference');
  }

  var paystackRes = await fetch(
    'https://api.paystack.co/transaction/verify/' + encodeURIComponent(reference),
    {
      headers: {
        Authorization: 'Bearer ' + PAYSTACK_SECRET,
      },
    }
  );

  var data = await paystackRes.json();

  if (data.status && data.data.status === 'success') {
    var amountPaid = data.data.amount / 100;
    var currency = data.data.currency;

    // TODO: Look up the order by reference in your database
    // TODO: Confirm amount and currency match what you expected
    // TODO: Mark the order as paid (check if already paid to prevent double-granting)

    return res.send(
      'Payment successful! Reference: ' + reference +
      ' Amount: ' + currency + ' ' + amountPaid
    );
  }

  res.status(400).send('Payment not successful. Status: ' + (data.data ? data.data.status : 'unknown'));
});

Three checks are non-negotiable: the transaction status must be "success", the amount must match what you stored during initialization, and the currency must be correct. Skipping any of these opens you to underpayment or currency-swap exploits.

Inline Popup Checkout

If you want the customer to stay on your page, use Paystack's inline popup. Load the Paystack Inline.js script and open the popup with the access code from your initialization endpoint:

<script src="https://js.paystack.co/v2/inline.js"></script>
<script>
function payWithPopup(email, amount) {
  fetch('/api/pay', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email: email, amount: amount }),
  })
  .then(function(res) { return res.json(); })
  .then(function(data) {
    if (!data.access_code) {
      alert(data.error || 'Initialization failed');
      return;
    }

    var popup = new PaystackPop();
    popup.checkout({
      accessCode: data.access_code,
      onSuccess: function(transaction) {
        // Verify on your server
        fetch('/api/verify?reference=' + transaction.reference)
        .then(function(res) { return res.json(); })
        .then(function(result) {
          if (result.success) {
            alert('Payment confirmed!');
          }
        });
      },
      onCancel: function() {
        console.log('Customer closed popup');
      },
    });
  });
}
</script>

Add a separate verify route for the popup flow:

app.get('/api/verify', async function(req, res) {
  var reference = req.query.reference;
  if (!reference) return res.status(400).json({ error: 'Missing reference' });

  var paystackRes = await fetch(
    'https://api.paystack.co/transaction/verify/' + encodeURIComponent(reference),
    { headers: { Authorization: 'Bearer ' + PAYSTACK_SECRET } }
  );

  var data = await paystackRes.json();

  if (data.status && data.data.status === 'success') {
    return res.json({ success: true, amount: data.data.amount / 100, currency: data.data.currency });
  }

  res.status(400).json({ success: false });
});

Security Checklist and Going Live

  1. Secret key is server-only. Never send your secret key to the frontend. It stays in .env and is read by Express.
  2. Validate amounts server-side. Look up the correct price from your database. Never trust the amount the frontend sends without checking it.
  3. Verify every transaction. Never grant value based on the redirect alone. Always call the verify endpoint and check status, amount, and currency.
  4. Set up webhooks. Webhooks are the reliable delivery mechanism. The redirect can fail if the customer closes their browser. See Handle Paystack Webhooks in Node.js and Express.
  5. Prevent double-granting. Both the webhook and the callback can arrive for the same transaction. Use a database flag and check it before fulfilling.
  6. Use HTTPS in production. Paystack requires HTTPS for callback and webhook URLs.
  7. Switch keys. Replace sk_test_ with sk_live_ in your production environment variables.

Ship Payments Faster

This guide is part of the Paystack integration guides by language and framework series. We cover webhooks, verification, subscriptions, and more for every major stack.

Sign up for the McTaba newsletter

Key Takeaways

  • Express gives you full control over the payment flow. You call the Paystack REST API directly with fetch or axios from your route handlers.
  • Your Paystack secret key must live in environment variables. Never hardcode it or commit it to version control.
  • Amounts are in kobo (NGN), pesewas (GHS), or cents (ZAR/KES/USD). Multiply the display price by 100 before sending to Paystack.
  • Always verify the transaction server-side after the redirect callback. The redirect alone is not proof of payment.
  • Store the transaction reference in your database before redirecting so your webhook handler and callback handler can both find the correct order.
  • No npm packages are required. The Paystack API is a plain REST API. Use the built-in fetch in Node 18+ or install node-fetch for older versions.

Frequently Asked Questions

Do I need to install a Paystack npm package?
No. The Paystack API is a standard REST API. Node 18+ includes a global fetch function you can use directly. Community packages like paystack-node exist but add unnecessary dependencies for most integrations.
Can I use Express with TypeScript for Paystack?
Yes. The Paystack API calls are the same. You would add type annotations to your request and response objects, but the fetch calls and JSON parsing are identical.
Why does my test payment return "Transaction not found"?
This usually means you initialized with a test key but are verifying with a live key, or vice versa. Make sure PAYSTACK_SECRET_KEY matches the mode you used. Test keys start with sk_test_ and live keys with sk_live_.
How do I handle multiple currencies in Express?
Pass the currency parameter when initializing the transaction. Paystack supports NGN, GHS, ZAR, KES, and USD. Your account must be activated for each currency you want to accept.
Should I use Express or NestJS for Paystack integration?
Express is simpler and has fewer abstractions. NestJS adds dependency injection, modules, and decorators which help on larger projects. The Paystack API calls are identical in both. Choose based on your project size and team preference.

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