A Paystack refund fails when the original transaction is too old (beyond the refund window), the partial refund amount exceeds the remaining refundable balance, or the payment channel does not support automated refunds. A refund stuck at "pending" means the bank or payment provider has not processed it yet, which can take 5 to 10 business days for card refunds. Check the refund status via the API, wait for the refund.failed or refund.processed webhook, and use a manual transfer to the customer bank account as a last resort.
A Paystack refund reverses a previous successful transaction. The process is asynchronous:
POST /refund with the transaction reference or IDrefund.processed or refund.failed webhookThe API returning a 200 at step 3 does NOT mean the refund is complete. It means Paystack accepted the request. The refund can still fail at step 5 if the bank rejects it.
// Initiating a refund
const res = await fetch('https://api.paystack.co/refund', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
transaction: 'txn_abc123', // Transaction reference or ID
amount: 250000, // Amount in kobo (optional, defaults to full refund)
}),
});
const data = await res.json();
// data.data.status will be "pending"
1. Transaction too old
Banks have a window within which refunds can be processed. For card transactions, this is typically 90 to 180 days. Attempting to refund a transaction beyond this window fails because the bank will not accept the reversal instruction.
// Error response for an old transaction
{
"status": false,
"message": "Transaction is not eligible for refund"
}
2. Partial refund exceeds remaining balance
If you already issued partial refunds on a transaction, the total of all refunds cannot exceed the original transaction amount. For example, on a 10,000 kobo transaction where you already refunded 7,000 kobo, attempting to refund 5,000 kobo fails because only 3,000 kobo is left.
// Error when partial refund exceeds remaining
{
"status": false,
"message": "Refund amount is more than the remaining amount on this transaction"
}
3. Transaction was not successful
You can only refund successful transactions. Attempting to refund a failed, abandoned, or pending transaction returns an error.
4. Channel does not support automated refunds
Not all payment channels support automated refunds through the API. Mobile money refunds and bank transfer refunds may not be available depending on the provider. Card refunds are the most reliable.
5. Duplicate refund
Attempting to fully refund a transaction that has already been fully refunded returns an error. Paystack blocks the second refund to prevent double-refunding.
6. Insufficient Paystack balance
Refunds are funded from your Paystack balance. If your balance is too low to cover the refund amount, the refund fails.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
A "pending" refund is not necessarily stuck. Card refunds can legitimately take 5 to 10 business days. Here is how to tell the difference between normal processing and an actual problem:
Normal pending (1 to 10 business days): The refund was accepted by Paystack and sent to the bank. The bank is processing it. This is the standard flow. Do not panic.
Abnormally pending (more than 15 business days): If a refund has been pending for more than 15 business days, something may be wrong. Contact Paystack support with the refund reference.
Check the current status of a refund through the API:
// Check refund status
async function checkRefundStatus(refundId: string) {
const res = await fetch(
`https://api.paystack.co/refund/${refundId}`,
{
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
},
}
);
const data = await res.json();
const refund = data.data;
console.log('Refund status:', refund.status);
console.log('Created at:', refund.createdAt);
console.log('Amount:', refund.amount / 100, 'NGN');
// Calculate days since refund was created
const daysSinceCreated = Math.floor(
(Date.now() - new Date(refund.createdAt).getTime()) / (1000 * 60 * 60 * 24)
);
if (refund.status === 'pending' && daysSinceCreated > 15) {
console.warn(
'Refund ' + refundId + ' has been pending for ' + daysSinceCreated + ' days. ' +
'Contact Paystack support.'
);
}
return refund;
}
Set up a scheduled job that checks all pending refunds older than 10 business days and alerts your team if any are found. Most will resolve on their own, but the alert catches the ones that need manual follow-up.
Paystack sends two refund-related webhook events:
refund.processed: The refund was completed. Money is on its way to the customer.refund.failed: The refund could not be processed. You need to take action.// Webhook handler for refund events
app.post('/api/webhooks/paystack', async (req, res) => {
// Return 200 immediately
res.status(200).json({ received: true });
const event = req.body;
switch (event.event) {
case 'refund.processed': {
const { transaction, amount } = event.data;
const reference = transaction.reference;
// Update refund record
await db.query(
`UPDATE refunds SET status = 'processed', processed_at = NOW()
WHERE transaction_reference = $1`,
[reference]
);
// Notify the customer
const order = await getOrderByReference(reference);
await sendRefundConfirmationEmail({
email: order.customerEmail,
amount: amount / 100,
reference,
});
console.log(`Refund processed for ${reference}: ${amount / 100} NGN`);
break;
}
case 'refund.failed': {
const { transaction, amount } = event.data;
const reference = transaction.reference;
// Update refund record
await db.query(
`UPDATE refunds SET status = 'failed', failed_at = NOW()
WHERE transaction_reference = $1`,
[reference]
);
// Alert the team to investigate
await sendSlackAlert({
channel: '#payments',
message: 'Refund failed for transaction ' + reference + '. ' +
'Amount: ' + (amount / 100) + ' NGN. Manual intervention needed.',
});
console.error(`Refund failed for ${reference}`);
break;
}
}
});
Do not notify the customer about a failed refund automatically. Your team should investigate first and determine the best recovery approach before communicating with the customer.
When the automated refund fails or is not available for the payment channel, the fallback is to send money to the customer via a Paystack transfer. This bypasses the refund system entirely and sends the money directly from your Paystack balance to the customer's bank account.
// Manual refund via transfer
async function manualRefund(
customerName: string,
accountNumber: string,
bankCode: string,
amount: number,
originalReference: string,
reason: string
) {
// Step 1: Validate the customer's bank account
const validation = await fetch(
`https://api.paystack.co/bank/resolve?account_number=${accountNumber}&bank_code=${bankCode}`,
{
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
},
}
);
const validationData = await validation.json();
if (!validationData.status) {
throw new Error('Could not validate customer bank account: ' + validationData.message);
}
// Step 2: Create transfer recipient
const recipientRes = await fetch('https://api.paystack.co/transferrecipient', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: 'nuban',
name: validationData.data.account_name,
account_number: accountNumber,
bank_code: bankCode,
currency: 'NGN',
}),
});
const recipientData = await recipientRes.json();
if (!recipientData.status) {
throw new Error('Could not create recipient: ' + recipientData.message);
}
// Step 3: Initiate transfer
const transferRes = await fetch('https://api.paystack.co/transfer', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
source: 'balance',
amount,
recipient: recipientData.data.recipient_code,
reason: `Refund for transaction ${originalReference}: ${reason}`,
reference: `refund_manual_${originalReference}_${Date.now()}`,
}),
});
const transferData = await transferRes.json();
// Step 4: Record the manual refund
await db.query(
`INSERT INTO manual_refunds
(original_reference, transfer_reference, recipient_account, amount, reason, status, created_at)
VALUES ($1, $2, $3, $4, $5, 'pending', NOW())`,
[originalReference, transferData.data.reference, accountNumber, amount, reason]
);
return transferData;
}
Important considerations for manual refunds:
Partial refunds require careful tracking. You need to know how much has already been refunded so you do not exceed the original transaction amount.
// Database schema for refund tracking
// CREATE TABLE refunds (
// id SERIAL PRIMARY KEY,
// transaction_reference VARCHAR(100) NOT NULL,
// refund_reference VARCHAR(100) UNIQUE,
// amount INTEGER NOT NULL, -- in kobo
// type VARCHAR(20) NOT NULL DEFAULT 'automated', -- 'automated' or 'manual'
// status VARCHAR(20) NOT NULL DEFAULT 'pending',
// reason TEXT,
// created_at TIMESTAMPTZ DEFAULT NOW(),
// processed_at TIMESTAMPTZ
// );
async function initiatePartialRefund(
transactionReference: string,
refundAmount: number,
reason: string
) {
// Calculate how much has already been refunded
const refunded = await db.query(
`SELECT COALESCE(SUM(amount), 0) as total_refunded
FROM refunds
WHERE transaction_reference = $1
AND status IN ('pending', 'processed')`,
[transactionReference]
);
const totalRefunded = parseInt(refunded.rows[0].total_refunded);
// Get original transaction amount
const txRes = await fetch(
`https://api.paystack.co/transaction/verify/${transactionReference}`,
{
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
},
}
);
const txData = await txRes.json();
const originalAmount = txData.data.amount;
const remainingRefundable = originalAmount - totalRefunded;
if (refundAmount > remainingRefundable) {
throw new Error(
'Cannot refund ' + (refundAmount / 100) + ' NGN. ' +
'Original amount: ' + (originalAmount / 100) + ' NGN. ' +
'Already refunded: ' + (totalRefunded / 100) + ' NGN. ' +
'Maximum refundable: ' + (remainingRefundable / 100) + ' NGN.'
);
}
// Proceed with refund
const res = await fetch('https://api.paystack.co/refund', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
transaction: transactionReference,
amount: refundAmount,
}),
});
const data = await res.json();
if (data.status) {
await db.query(
`INSERT INTO refunds (transaction_reference, refund_reference, amount, reason, status)
VALUES ($1, $2, $3, $4, 'pending')`,
[transactionReference, data.data.id, refundAmount, reason]
);
}
return data;
}
Refund timing frustrates customers more than almost anything else. Set expectations clearly:
When a refund is initiated:
When a refund fails and you are doing a manual transfer:
When a customer says the refund has not arrived:
Never tell a customer a refund is "instant". Even when Paystack processes the refund immediately, the customer's bank controls when the money appears in their account.
Test each refund scenario in test mode:
refund.processed event.For your accounting team, generate a monthly refund report showing automated vs. manual refunds, success vs. failure rates, and average processing time. This data helps you negotiate with Paystack support when refunds take longer than expected.
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