When processing a Paystack refund, create a separate ledger entry with event_type "refund.processed" that records the refund amount as a debit. Do not modify the original charge entry. Track whether Paystack returns the processing fee on full refunds (behavior varies). Handle the refund.processed webhook to update your ledger automatically. For partial refunds, record each partial refund as a separate entry.
You can issue a refund through the Paystack dashboard or through the API. The API endpoint is POST /refund. You provide the transaction reference or ID and the amount to refund. For a full refund, you provide the full transaction amount. For a partial refund, you provide a smaller amount.
// Issue a refund via the Paystack API
async function issueRefund(transactionReference, amount, reason) {
var response = 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: amount, // In smallest currency unit. Omit for full refund.
merchant_note: reason,
}),
});
var data = await response.json();
if (!data.status) {
throw new Error('Refund failed: ' + data.message);
}
return data.data;
}
Paystack processes the refund and sends a refund.processed webhook when it is complete. The refund amount is typically deducted from your next settlement rather than being charged to your bank account directly.
For the broader accounting system, see the payments ledger design guide.
A refund is a new financial event. It gets its own ledger entry. You never modify the original charge entry because the charge did happen. The customer did pay. The refund is a separate event that reverses part or all of that charge.
// Record a refund in the payments ledger
async function recordRefund(refundData, originalReference) {
// Look up the original charge
var original = await db.query(
'SELECT gross_amount, fee_amount, currency, order_id FROM payment_ledger '
+ 'WHERE reference = $1 AND event_type = $2',
[originalReference, 'charge.success']
);
if (original.rows.length === 0) {
console.error('Cannot record refund: original charge not found for '
+ originalReference);
return { recorded: false };
}
var charge = original.rows[0];
// Check total refunded so far
var previousRefunds = await db.query(
'SELECT COALESCE(SUM(gross_amount), 0) as total_refunded FROM payment_ledger '
+ 'WHERE reference = $1 AND event_type LIKE $2',
[originalReference, 'refund%']
);
var totalRefunded = parseInt(previousRefunds.rows[0].total_refunded);
var newTotal = totalRefunded + refundData.amount;
if (newTotal > charge.gross_amount) {
console.error('Refund would exceed original charge: charge='
+ charge.gross_amount + ' already_refunded='
+ totalRefunded + ' new_refund=' + refundData.amount);
return { recorded: false, reason: 'exceeds_charge' };
}
// Record the refund entry
var refundEventType = newTotal === charge.gross_amount
? 'refund.full' : 'refund.partial';
await db.query(
'INSERT INTO payment_ledger '
+ '(reference, event_type, direction, gross_amount, fee_amount, '
+ 'net_amount, currency, order_id, metadata) '
+ 'VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) '
+ 'ON CONFLICT (reference, event_type) DO NOTHING',
[
originalReference,
refundEventType,
'debit',
refundData.amount,
0, // Fee handling depends on Paystack policy
refundData.amount,
charge.currency,
charge.order_id,
JSON.stringify(refundData),
]
);
return { recorded: true, event_type: refundEventType, total_refunded: newTotal };
}
Note the direction is "debit" for refunds. In the original charge, money came in (credit). In a refund, money goes out (debit). This distinction matters for balance calculations and financial reports.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
A customer buys three items for NGN 30,000. They want to return one item worth NGN 10,000. You issue a partial refund of NGN 10,000 (1,000,000 kobo). The ledger now has two entries for this reference:
-- Ledger state after partial refund
-- Reference: order_abc_123
-- Entry 1: charge.success, credit, 3000000 kobo (NGN 30,000)
-- Entry 2: refund.partial, debit, 1000000 kobo (NGN 10,000)
-- Net position: 2000000 kobo (NGN 20,000)
-- Query net position for a reference
SELECT
reference,
SUM(CASE WHEN direction = 'credit' THEN gross_amount ELSE 0 END) as total_charged,
SUM(CASE WHEN direction = 'debit' THEN gross_amount ELSE 0 END) as total_refunded,
SUM(CASE WHEN direction = 'credit' THEN gross_amount ELSE -gross_amount END) as net_position
FROM payment_ledger
WHERE reference = 'order_abc_123'
GROUP BY reference;
If the customer later returns the second item (another NGN 10,000 refund), you add a third ledger entry. The UNIQUE constraint on (reference, event_type) would prevent this since both are "refund.partial". To handle multiple partial refunds, either use a more specific event type (include a counter or the refund ID) or change the UNIQUE constraint:
// Record multiple partial refunds
async function recordPartialRefund(originalReference, refundData) {
// Use the Paystack refund ID to make the event unique
var eventType = 'refund.partial.' + refundData.id;
await db.query(
'INSERT INTO payment_ledger '
+ '(reference, event_type, direction, gross_amount, fee_amount, '
+ 'net_amount, currency, metadata) '
+ 'VALUES ($1, $2, $3, $4, $5, $6, $7, $8) '
+ 'ON CONFLICT (reference, event_type) DO NOTHING',
[
originalReference,
eventType,
'debit',
refundData.amount,
0,
refundData.amount,
refundData.currency,
JSON.stringify(refundData),
]
);
}
Paystack sends a refund.processed webhook when a refund is complete. Handle it in your webhook router:
// Webhook handler for refunds
app.post('/webhooks/paystack', async function(req, res) {
// Validate HMAC signature (not shown)
var event = req.body;
if (event.event === 'refund.processed') {
var refundData = event.data;
// Find the original transaction reference
var originalReference = refundData.transaction_reference
|| refundData.transaction.reference;
// Record in ledger
var result = await recordRefund(refundData, originalReference);
if (result.recorded) {
// Update order status if fully refunded
if (result.event_type === 'refund.full') {
await db.query(
'UPDATE orders SET status = $1, refunded_at = NOW() '
+ 'WHERE paystack_reference = $2',
['refunded', originalReference]
);
} else {
await db.query(
'UPDATE orders SET status = $1, partial_refund_total = $2 '
+ 'WHERE paystack_reference = $3',
['partially_refunded', result.total_refunded, originalReference]
);
}
console.log('Refund recorded: ' + originalReference
+ ' amount=' + refundData.amount
+ ' type=' + result.event_type);
}
}
return res.sendStatus(200);
});
Always verify that the refund amount does not exceed the original charge. A bug or a malicious webhook could try to record a refund larger than the charge. Your recordRefund function should reject this with a clear error.
When you charge a customer NGN 50,000, Paystack takes a processing fee. When you refund that charge, what happens to the fee? This depends on your Paystack agreement and may vary. Common scenarios:
Check your specific Paystack agreement for fee refund policy. Do not assume either way. In your ledger, record the fee treatment explicitly:
// Refund with fee tracking
async function recordRefundWithFees(originalReference, refundData, feeReturned) {
var feeAmount = feeReturned ? refundData.fee_returned || 0 : 0;
await db.query(
'INSERT INTO payment_ledger '
+ '(reference, event_type, direction, gross_amount, fee_amount, '
+ 'net_amount, currency, metadata) '
+ 'VALUES ($1, $2, $3, $4, $5, $6, $7, $8) '
+ 'ON CONFLICT (reference, event_type) DO NOTHING',
[
originalReference,
'refund.processed',
'debit',
refundData.amount,
feeAmount,
refundData.amount - feeAmount,
refundData.currency,
JSON.stringify({
refund: refundData,
fee_returned: feeReturned,
fee_amount: feeAmount,
}),
]
);
}
For a deeper treatment of fee recording, see the gross vs net recording guide.
Paystack deducts refund amounts from future settlements. If you issued a NGN 50,000 refund today, your next settlement will be reduced by NGN 50,000. This means:
// Settlement reconciliation with refund awareness
async function reconcileWithRefunds(settlementDate) {
// Fetch settlement data from Paystack
var settlement = await fetchSettlement(settlementDate);
// Expected settlement = sum of charges - sum of refunds - sum of fees
var charges = await db.query(
'SELECT COALESCE(SUM(net_amount), 0) as total FROM payment_ledger '
+ 'WHERE event_type = $1 AND DATE(created_at) = $2',
['charge.success', settlementDate]
);
var refunds = await db.query(
'SELECT COALESCE(SUM(gross_amount), 0) as total FROM payment_ledger '
+ 'WHERE event_type LIKE $1 AND DATE(created_at) = $2',
['refund%', settlementDate]
);
var expectedSettlement = parseInt(charges.rows[0].total)
- parseInt(refunds.rows[0].total);
var actualSettlement = settlement.total_amount;
var difference = actualSettlement - expectedSettlement;
if (Math.abs(difference) > 100) { // Allow small rounding tolerance
console.error('Settlement mismatch: expected=' + expectedSettlement
+ ' actual=' + actualSettlement + ' diff=' + difference);
}
return {
expected: expectedSettlement,
actual: actualSettlement,
difference: difference,
charges_total: parseInt(charges.rows[0].total),
refunds_total: parseInt(refunds.rows[0].total),
};
}
Track refund metrics for business visibility:
-- Monthly refund summary
SELECT
DATE_TRUNC('month', created_at) as month,
currency,
COUNT(*) as refund_count,
SUM(gross_amount) as total_refunded,
(SELECT SUM(gross_amount) FROM payment_ledger pl2
WHERE pl2.event_type = 'charge.success'
AND DATE_TRUNC('month', pl2.created_at) = DATE_TRUNC('month', pl.created_at)
AND pl2.currency = pl.currency) as total_charged,
ROUND(
SUM(gross_amount)::numeric * 100 /
NULLIF((SELECT SUM(gross_amount) FROM payment_ledger pl3
WHERE pl3.event_type = 'charge.success'
AND DATE_TRUNC('month', pl3.created_at) = DATE_TRUNC('month', pl.created_at)
AND pl3.currency = pl.currency), 0), 2
) as refund_rate_pct
FROM payment_ledger pl
WHERE event_type LIKE 'refund%'
GROUP BY DATE_TRUNC('month', created_at), currency
ORDER BY month DESC;
A healthy refund rate depends on your industry, but anything above 5-10% warrants investigation. High refund rates can indicate product quality issues, misleading checkout flows, or fraud.
For a comprehensive reporting setup, see the financial reports guide.
When you refund a payment, you typically need to revoke whatever value was granted. If the customer bought a course, revoke access. If they bought credits, deduct the credits. If they bought a physical product that has not shipped, cancel the shipment.
// Revoke access after a full refund
async function revokeAfterRefund(orderId) {
var order = await db.query(
'SELECT product_id, user_id, product_type FROM orders WHERE id = $1',
[orderId]
);
if (order.rows.length === 0) return;
var row = order.rows[0];
if (row.product_type === 'course') {
await db.query(
'UPDATE enrollments SET status = $1, revoked_at = NOW() '
+ 'WHERE user_id = $2 AND course_id = $3',
['revoked', row.user_id, row.product_id]
);
} else if (row.product_type === 'credits') {
await db.query(
'UPDATE user_credits SET balance = balance - '
+ '(SELECT expected_amount FROM orders WHERE id = $1) '
+ 'WHERE user_id = $2',
[orderId, row.user_id]
);
}
// Log the revocation
console.log(JSON.stringify({
event: 'access_revoked',
order_id: orderId,
user_id: row.user_id,
product_id: row.product_id,
timestamp: new Date().toISOString(),
}));
}
For partial refunds, the revocation logic depends on your business model. If a customer bought three items and returned one, you revoke access to that one item but keep the other two active. If they bought a subscription and got a partial refund, you might shorten the subscription period rather than revoking it entirely.
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