Bonaventure OgetoBy Bonaventure Ogeto|

Paystack Refunds: Full, Partial and Programmatic

To refund a transaction on Paystack, POST to /refund with the transaction reference or ID. Include an amount field for partial refunds (in the smallest currency unit). Paystack processes the refund and sends a refund.processed or refund.failed webhook event. Refunds can only be issued on successful transactions within 180 days, and the refund amount cannot exceed the transaction amount minus any previous refunds.

How Paystack Refunds Work

When a customer pays through Paystack, the money moves from the customer's bank or mobile money account to Paystack's pool, and then settles into your Paystack balance (minus fees). When you issue a refund, Paystack reverses that flow: it pulls from your Paystack balance and sends the money back to the customer's original payment method.

This means three things you need to understand before writing any refund code:

  1. Your balance must cover the refund. If you received 10,000 NGN from a transaction and your current Paystack balance is 5,000 NGN, you cannot issue a full refund. The refund will fail. Make sure your balance is sufficient before triggering refunds, especially if you do frequent withdrawals.
  2. Refunds go back to the original payment method. If a customer paid with a Visa card, the refund goes to that Visa card. If they paid via bank transfer, it goes back to that bank account. You cannot redirect a refund to a different account.
  3. Refunds are not instant. The API call succeeds immediately, but the actual money movement takes time. Card refunds take 5 to 10 business days. Bank transfer refunds can be faster. Mobile money refunds depend on the provider.

Paystack gives you two ways to issue refunds: through the dashboard (manual) and through the API (programmatic). This guide focuses on the API approach because that is what you need for automated systems, customer self-service refunds, and integration with your order management workflows.

Full Refunds

A full refund returns the entire transaction amount to the customer. To issue one, POST to /refund with the transaction reference. Do not include an amount field.

// fullRefund.js
const https = require('https');

function issueFullRefund(transactionReference) {
  var params = JSON.stringify({
    transaction: transactionReference,
  });

  var options = {
    hostname: 'api.paystack.co',
    port: 443,
    path: '/refund',
    method: 'POST',
    headers: {
      Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
      'Content-Type': 'application/json',
      'Content-Length': Buffer.byteLength(params),
    },
  };

  return new Promise(function(resolve, reject) {
    var req = https.request(options, function(res) {
      var body = '';
      res.on('data', function(chunk) { body += chunk; });
      res.on('end', function() {
        var parsed = JSON.parse(body);
        if (parsed.status) {
          resolve(parsed.data);
        } else {
          reject(new Error(parsed.message));
        }
      });
    });

    req.on('error', reject);
    req.write(params);
    req.end();
  });
}

// Example: refund an order
async function refundOrder(orderId) {
  var order = await db.query(
    'SELECT paystack_reference, amount, status FROM orders WHERE id = $1',
    [orderId]
  );

  if (order.status === 'refunded') {
    throw new Error('Order already refunded');
  }

  var refund = await issueFullRefund(order.paystack_reference);

  // Mark as refund_pending, not refunded
  // Wait for the webhook to confirm
  await db.query(
    'UPDATE orders SET status = $1, refund_id = $2 WHERE id = $3',
    ['refund_pending', refund.id, orderId]
  );

  return refund;
}

Notice the status is set to "refund_pending" and not "refunded" in the code above. The API call tells Paystack to process the refund, but the money has not actually moved yet. Only update the status to "refunded" when you receive the refund.processed webhook. This prevents your system from showing a refund as complete before the customer actually has the money.

Partial Refunds

A partial refund returns only part of the transaction amount. Include the amount field (in the smallest currency unit) in your POST to /refund.

// partialRefund.js
function issuePartialRefund(transactionReference, amountInKobo) {
  var params = JSON.stringify({
    transaction: transactionReference,
    amount: amountInKobo,
  });

  var options = {
    hostname: 'api.paystack.co',
    port: 443,
    path: '/refund',
    method: 'POST',
    headers: {
      Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
      'Content-Type': 'application/json',
      'Content-Length': Buffer.byteLength(params),
    },
  };

  return new Promise(function(resolve, reject) {
    var req = https.request(options, function(res) {
      var body = '';
      res.on('data', function(chunk) { body += chunk; });
      res.on('end', function() {
        var parsed = JSON.parse(body);
        if (parsed.status) {
          resolve(parsed.data);
        } else {
          reject(new Error(parsed.message));
        }
      });
    });

    req.on('error', reject);
    req.write(params);
    req.end();
  });
}

// Example: refund a single item from a multi-item order
async function refundOrderItem(orderId, itemId) {
  var order = await db.query(
    'SELECT paystack_reference FROM orders WHERE id = $1',
    [orderId]
  );

  var item = await db.query(
    'SELECT price_kobo FROM order_items WHERE id = $1 AND order_id = $2',
    [itemId, orderId]
  );

  var refund = await issuePartialRefund(
    order.paystack_reference,
    item.price_kobo
  );

  await db.query(
    'UPDATE order_items SET refund_status = $1, refund_id = $2 WHERE id = $3',
    ['refund_pending', refund.id, itemId]
  );

  return refund;
}

You can issue multiple partial refunds on the same transaction. Paystack tracks the cumulative refunded amount. If a transaction was 50,000 kobo (500 NGN) and you refund 20,000 kobo, you can later refund another 30,000 kobo but no more. The API will reject any refund that would push the total beyond the original amount.

Partial refunds are common in e-commerce. A customer orders three items, one arrives damaged, and you refund only that item. They are also used for prorated subscription credits when a customer downgrades mid-cycle.

Refund Conditions and Limits

Not every transaction can be refunded. Paystack enforces several conditions:

Condition Can You Refund? What to Do Instead
Transaction was successful Yes N/A
Transaction failed or was abandoned No No money was collected. Nothing to refund.
Transaction was reversed by the bank No The bank already returned the money.
Transaction is older than 180 days No Process a manual transfer to the customer.
Your Paystack balance is too low No Fund your balance first, then retry.
Total refunds would exceed original amount No Only the remaining un-refunded portion can be refunded.
Transaction has an open dispute Depends Resolve the dispute first. Refunding during a dispute may complicate the chargeback process.

Before issuing a refund programmatically, build a validation layer that checks these conditions. Fetch the transaction first, verify its status, age, and previous refund amounts, then proceed.

// Validate before refunding
async function validateRefundRequest(reference, requestedAmountKobo) {
  var response = await fetch(
    'https://api.paystack.co/transaction/verify/' + reference,
    {
      headers: {
        Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
      },
    }
  );
  var data = await response.json();
  var txn = data.data;

  if (txn.status !== 'success') {
    return { valid: false, reason: 'Transaction is not successful' };
  }

  // Check age
  var txnDate = new Date(txn.paid_at);
  var daysSince = (Date.now() - txnDate.getTime()) / (1000 * 60 * 60 * 24);
  if (daysSince > 180) {
    return { valid: false, reason: 'Transaction is older than 180 days' };
  }

  // Check amount
  var alreadyRefunded = txn.amount_refunded || 0;
  var remaining = txn.amount - alreadyRefunded;
  var refundAmount = requestedAmountKobo || remaining;

  if (refundAmount > remaining) {
    return {
      valid: false,
      reason: 'Refund amount exceeds remaining: ' + remaining + ' kobo available',
    };
  }

  return { valid: true, refundableAmount: remaining };
}

Handling Refund Webhooks

Paystack sends two webhook events for refunds:

  • refund.processed - the refund was successful. Money is on its way back to the customer.
  • refund.failed - the refund could not be processed. This can happen if the customer's bank rejects the reversal or if there is a processing error.
// refundWebhookHandler.js
var crypto = require('crypto');
var express = require('express');
var app = express();

app.post('/webhooks/paystack', express.json(), function(req, res) {
  // Verify the webhook signature
  var hash = crypto
    .createHmac('sha512', process.env.PAYSTACK_SECRET_KEY)
    .update(JSON.stringify(req.body))
    .digest('hex');

  if (hash !== req.headers['x-paystack-signature']) {
    return res.status(401).send('Invalid signature');
  }

  // Return 200 immediately
  res.status(200).send('OK');

  // Process asynchronously
  var event = req.body;
  handleRefundEvent(event).catch(function(err) {
    console.error('Refund webhook processing error:', err);
  });
});

async function handleRefundEvent(event) {
  if (event.event === 'refund.processed') {
    var refundData = event.data;
    var transactionRef = refundData.transaction.reference;
    var refundAmount = refundData.amount;

    console.log(
      'Refund processed: ' + refundAmount + ' kobo on ' + transactionRef
    );

    // Update your order status
    await db.query(
      'UPDATE orders SET status = $1, refunded_at = NOW() WHERE paystack_reference = $2',
      ['refunded', transactionRef]
    );

    // Notify the customer
    await sendEmail({
      to: refundData.customer.email,
      subject: 'Your refund has been processed',
      body: 'Your refund of ' + (refundAmount / 100) + ' has been processed. '
        + 'It should reflect in your account within 5 to 10 business days.',
    });
  }

  if (event.event === 'refund.failed') {
    var refundData = event.data;
    var transactionRef = refundData.transaction.reference;

    console.error('Refund failed for ' + transactionRef);

    // Mark as failed and alert your support team
    await db.query(
      'UPDATE orders SET status = $1 WHERE paystack_reference = $2',
      ['refund_failed', transactionRef]
    );

    await alertSupportTeam({
      type: 'refund_failed',
      transactionRef: transactionRef,
      reason: refundData.status || 'unknown',
    });
  }
}

The webhook-first approach is critical. Your refund button on the admin panel should trigger the API call, then update the UI to "refund pending." Only when the webhook arrives should you flip the status to "refunded" or "refund_failed." This way, your system always reflects reality.

Building a Complete Refund Handler

A production refund system combines validation, the API call, idempotency, and webhook handling into one flow. Here is a complete handler:

// refundService.js
var crypto = require('crypto');
var https = require('https');

var RefundService = {
  // Issue a refund (full or partial)
  issue: async function(transactionRef, amountKobo, reason) {
    // 1. Check if we already processed this refund request
    var existing = await db.query(
      'SELECT id, status FROM refund_requests WHERE transaction_ref = $1 AND amount_kobo = $2',
      [transactionRef, amountKobo || 'full']
    );

    if (existing && existing.status !== 'failed') {
      console.log('Refund already in progress: ' + existing.id);
      return existing;
    }

    // 2. Validate the transaction is refundable
    var validation = await validateRefundRequest(transactionRef, amountKobo);
    if (!validation.valid) {
      throw new Error('Cannot refund: ' + validation.reason);
    }

    // 3. Build the refund payload
    var payload = { transaction: transactionRef };
    if (amountKobo) {
      payload.amount = amountKobo;
    }

    // 4. Call the Paystack Refund API
    var params = JSON.stringify(payload);
    var options = {
      hostname: 'api.paystack.co',
      port: 443,
      path: '/refund',
      method: 'POST',
      headers: {
        Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
        'Content-Type': 'application/json',
        'Content-Length': Buffer.byteLength(params),
      },
    };

    var refund = await new Promise(function(resolve, reject) {
      var req = https.request(options, function(res) {
        var body = '';
        res.on('data', function(chunk) { body += chunk; });
        res.on('end', function() {
          var parsed = JSON.parse(body);
          if (parsed.status) {
            resolve(parsed.data);
          } else {
            reject(new Error(parsed.message));
          }
        });
      });
      req.on('error', reject);
      req.write(params);
      req.end();
    });

    // 5. Record the refund request
    await db.query(
      'INSERT INTO refund_requests (transaction_ref, refund_id, amount_kobo, reason, status) VALUES ($1, $2, $3, $4, $5)',
      [transactionRef, refund.id, amountKobo || refund.amount, reason, 'pending']
    );

    return refund;
  },

  // Fetch refund status
  fetch: async function(refundId) {
    var response = await fetch(
      'https://api.paystack.co/refund/' + refundId,
      {
        headers: {
          Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
        },
      }
    );
    return (await response.json()).data;
  },

  // List all refunds for your integration
  list: async function(page, perPage) {
    var url = 'https://api.paystack.co/refund?page=' + (page || 1)
      + '&perPage=' + (perPage || 50);
    var response = await fetch(url, {
      headers: {
        Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
      },
    });
    return (await response.json()).data;
  },
};

This service gives you a single place for all refund operations. The idempotency check in step 1 prevents duplicate refund requests if a user clicks the refund button twice or if your system retries a failed request.

Refund Timelines by Payment Channel

How long a refund takes to reach the customer depends on how they paid.

Payment Channel Typical Refund Timeline Notes
Card (Visa, Mastercard, Verve) 5 to 10 business days Depends on the issuing bank. Some banks are faster.
Bank transfer 1 to 3 business days Generally faster than card refunds.
USSD 1 to 3 business days Refund goes to the bank account linked to the USSD code.
Mobile money 1 to 5 business days Depends on the mobile money provider (MTN, Vodafone, etc.).

Set customer expectations clearly. When you show a refund confirmation, tell them the approximate timeline. "Your refund has been processed. Card refunds take 5 to 10 business days to appear on your statement." This prevents support tickets from customers who expect instant refunds.

If a customer contacts you saying the refund has not arrived after the expected window, fetch the refund from the API and check its status. If it shows as processed on Paystack's end, the issue is with the customer's bank. Direct them to contact their bank with the refund reference.

Refund Strategy for Your Business

Before you write refund code, decide on your refund policy. The API is flexible enough to support any policy you choose. Here are common patterns:

Full refund within X days. E-commerce sites often allow full refunds within 7 or 14 days of purchase. After that, only partial refunds or store credit. Your code checks the order date before allowing the refund.

Automatic refund on cancellation. SaaS products that bill monthly often auto-refund the current period if a user cancels within a certain window. This can be fully automated with no human in the loop.

Approval-based refunds. High-value transactions or marketplaces may require a support agent to approve refunds before the API call fires. Build a refund request queue that an admin reviews.

Prorated refunds. Subscription services that bill annually can calculate the unused portion and issue a partial refund for that amount when a customer cancels mid-year.

Whatever your policy, log every refund with the reason, the person who approved it (or "automatic"), the original transaction reference, and the timestamp. This log is essential for financial reconciliation and dispute resolution. If a customer claims a refund was never received, or if your finance team needs to reconcile end-of-month numbers, these records are your source of truth.

For a broader view of how refunds interact with disputes and chargebacks, read the developer playbook for disputes and chargebacks.

Key Takeaways

  • Refunds are initiated via POST /refund with a transaction reference. Omit the amount for a full refund. Include an amount (in kobo/pesewas/cents) for a partial refund.
  • You can issue multiple partial refunds on a single transaction, as long as the total refunded amount does not exceed the original transaction amount.
  • Refunds can only be processed on successful transactions. You cannot refund a failed, abandoned, or reversed transaction.
  • Paystack sends refund.processed and refund.failed webhook events. Always build your refund status updates around these webhooks, not the initial API response.
  • Card refunds typically take 5 to 10 business days to reflect in the customer bank account. Bank transfer and mobile money refund timelines vary by provider.
  • There is a time window for refunds. Paystack supports refunds on transactions within 180 days of the original charge. After that, handle the refund outside Paystack.
  • Your Paystack balance must have sufficient funds to cover the refund. If your balance is too low, the refund will fail.

Frequently Asked Questions

Can I refund a transaction that was paid via bank transfer?
Yes. Paystack supports refunds on all payment channels including card, bank transfer, USSD, and mobile money. The refund goes back to the original payment method. Bank transfer refunds are typically faster than card refunds.
What happens if a partial refund and a chargeback both hit the same transaction?
If you have already partially refunded a transaction and the customer also files a chargeback for the full amount, you may end up losing more than the original transaction value. When you see a dispute on a partially refunded transaction, submit evidence of the partial refund to contest the full chargeback amount.
Is there a fee on refunds?
Paystack returns the full refund amount to the customer, but the original transaction fee you paid is not refunded back to you. Check the Paystack pricing page or your merchant agreement for the current fee structure, as terms may vary.
Can I refund to a different payment method than the original?
No. Paystack refunds go back to the original payment method used for the transaction. If you need to send money to a different account, use the Transfer API instead, which is a separate operation from a refund.
How do I handle a failed refund?
When you receive a refund.failed webhook, log the failure reason and alert your support team. Common causes are insufficient balance or bank-side rejections. Fix the underlying issue (fund your balance, wait for bank availability) and retry the refund with a new API call.

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