Paystack fires refund.processed when a refund completes successfully and refund.failed when a refund cannot be completed. Your webhook handler should update the order or transaction status in your database, notify the customer about the outcome, and for failed refunds, either retry the refund or escalate to manual resolution. Always deduplicate by the refund reference to prevent double-processing during webhook retries.
Refunds on Paystack are asynchronous. When you call the Refund API (or initiate a refund from the dashboard), Paystack does not process it instantly. The refund enters a processing queue, and the actual money movement happens later. Depending on the payment channel and the banks involved, this can take minutes, hours, or in some cases, a few business days.
This is why webhooks matter for refunds. You cannot rely on the API response to know when the money has been returned to the customer. The API response only tells you that Paystack accepted the refund request. The refund.processed and refund.failed webhooks tell you the actual outcome.
The typical flow:
pending.refund.processed. If it fails, Paystack sends refund.failed.Your application needs to handle both outcomes. Telling a customer their refund was processed before it actually was creates confusion when the money does not show up. Telling them nothing when a refund fails is worse.
This article is part of the Paystack webhooks engineering guide.
When a refund completes successfully, the webhook payload looks like this:
{
"event": "refund.processed",
"data": {
"id": 5678901,
"integration": 100001,
"domain": "live",
"transaction": {
"id": 1234567890,
"domain": "live",
"reference": "order-ref-abc123",
"amount": 500000,
"currency": "NGN",
"channel": "card",
"customer": {
"email": "chioma@example.com",
"customer_code": "CUS_chioma001"
}
},
"status": "processed",
"refunded_at": "2026-07-20T14:30:00.000Z",
"refunded_by": "merchant",
"customer_note": "Item was defective",
"merchant_note": "Customer reported defect",
"deducted_amount": 500000,
"currency": "NGN",
"channel": "card",
"fully_deducted": true,
"created_at": "2026-07-20T10:00:00.000Z"
}
}
Key fields:
The deducted_amount field is the one you care about most. It tells you exactly how much money was returned to the customer. Compare it against the original transaction.amount to determine if this was a full or partial refund.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
When you receive refund.processed, update the order status, record the refund, and notify the customer:
async function handleRefundProcessed(data) {
const refundId = data.id;
const transactionRef = data.transaction.reference;
const refundedAmount = data.deducted_amount;
const originalAmount = data.transaction.amount;
const customerEmail = data.transaction.customer.email;
const isFullRefund = data.fully_deducted;
// Deduplicate: check if we already processed this refund
const existing = await db.query(
'SELECT id FROM processed_refunds WHERE refund_id = $1',
[refundId]
);
if (existing.rows.length > 0) {
console.log('Refund ' + refundId + ' already processed, skipping.');
return;
}
await db.query('BEGIN');
try {
// Record the refund
await db.query(
'INSERT INTO processed_refunds (refund_id, transaction_ref, ' +
'amount, processed_at) VALUES ($1, $2, $3, NOW())',
[refundId, transactionRef, refundedAmount]
);
// Calculate total refunded for this transaction
const totalRefunded = await db.query(
'SELECT COALESCE(SUM(amount), 0) as total FROM processed_refunds ' +
'WHERE transaction_ref = $1',
[transactionRef]
);
const totalRefundedAmount = parseInt(
totalRefunded.rows[0].total, 10
);
// Determine the new order status
let newStatus;
if (totalRefundedAmount >= originalAmount) {
newStatus = 'fully_refunded';
} else {
newStatus = 'partially_refunded';
}
// Update the order
await db.query(
'UPDATE orders SET status = $1, refunded_amount = $2, ' +
'updated_at = NOW() WHERE payment_reference = $3',
[newStatus, totalRefundedAmount, transactionRef]
);
await db.query('COMMIT');
} catch (err) {
await db.query('ROLLBACK');
throw err;
}
// Notify the customer (outside the transaction)
const amountFormatted = (refundedAmount / 100).toLocaleString();
await emailQueue.add('refund-complete', {
to: customerEmail,
amount: amountFormatted,
currency: data.currency,
orderReference: transactionRef,
isFullRefund: isFullRefund,
});
}
A few things to note about this handler:
Deduplication by refund ID. Paystack can retry webhook deliveries. Without the deduplication check, you would record the same refund twice and inflate the total refunded amount. The refund ID is unique per refund, making it the right deduplication key.
Cumulative tracking for partial refunds. A single order can have multiple partial refunds. You might refund 200 Naira for a damaged item today and another 300 Naira for a missing item next week. Each triggers a separate refund.processed event. By summing all processed refunds for a transaction, you always know the current state.
Email after the database transaction. If the database write succeeds but the email fails, that is fine. The customer will see the refund in their bank statement. If you emailed first and then the database write failed, you would tell the customer about a refund that your system did not record.
When a refund cannot be completed, the payload looks like this:
{
"event": "refund.failed",
"data": {
"id": 5678902,
"integration": 100001,
"domain": "live",
"transaction": {
"id": 1234567890,
"domain": "live",
"reference": "order-ref-abc123",
"amount": 500000,
"currency": "NGN",
"channel": "card",
"customer": {
"email": "chioma@example.com",
"customer_code": "CUS_chioma001"
}
},
"status": "failed",
"currency": "NGN",
"channel": "card",
"created_at": "2026-07-20T10:00:00.000Z"
}
}
The payload for a failed refund is sparser. It includes the transaction details but not always a specific failure reason in the payload itself. The reason is usually available in the Paystack dashboard under the refund details.
Common reasons for refund failure:
Failed refunds need immediate attention. A customer is waiting for their money, and it did not go through. Your handler should log the failure, alert your team, and set up a recovery path:
async function handleRefundFailed(data) {
const refundId = data.id;
const transactionRef = data.transaction.reference;
const amount = data.transaction.amount;
const customerEmail = data.transaction.customer.email;
// Record the failed refund attempt
await db.query(
'INSERT INTO refund_failures (refund_id, transaction_ref, amount, ' +
'failed_at, retry_count, status) VALUES ($1, $2, $3, NOW(), 0, $4) ' +
'ON CONFLICT (refund_id) DO UPDATE SET ' +
'failed_at = NOW(), status = $4',
[refundId, transactionRef, amount, 'failed']
);
// Update the order to reflect the failed refund
await db.query(
'UPDATE orders SET refund_status = $1, updated_at = NOW() ' +
'WHERE payment_reference = $2',
['refund_failed', transactionRef]
);
// Alert the operations team immediately
await slackQueue.add('refund-failure-alert', {
channel: '#payments',
text: 'REFUND FAILED
' +
'Transaction: ' + transactionRef + '
' +
'Amount: ' + (amount / 100).toLocaleString() + ' ' +
data.currency + '
' +
'Customer: ' + customerEmail + '
' +
'Refund ID: ' + refundId + '
' +
'Check the Paystack dashboard for the failure reason.',
});
// Notify the customer with a helpful message
await emailQueue.add('refund-delayed', {
to: customerEmail,
orderReference: transactionRef,
message: 'Your refund is taking longer than expected. ' +
'Our team is looking into it and will resolve this within ' +
'2 business days. If you have questions, reply to this email.',
});
}
Notice that the customer email says "taking longer than expected" rather than "your refund failed." From the customer's perspective, they do not need to know about the technical failure. They just need to know that you are handling it. The Slack alert gives your team the details they need to investigate and resolve.
For the recovery path, you have two options:
Automatic retry. If the failure was likely transient (insufficient balance that you have now topped up, or a temporary bank issue), you can retry the refund after a delay:
// Schedule a retry in 4 hours
await retryQueue.add(
'retry-refund',
{
transactionRef: transactionRef,
amount: amount,
refundId: refundId,
attemptNumber: 1,
},
{ delay: 4 * 60 * 60 * 1000 }
);
// In the retry worker:
async function retryRefund(job) {
const { transactionRef, amount, refundId, attemptNumber } = job.data;
if (attemptNumber > 3) {
// Too many retries. Escalate to manual resolution.
await slackQueue.add('refund-escalation', {
channel: '#payments',
text: 'Refund for ' + transactionRef +
' failed after 3 retries. Manual resolution required.',
});
return;
}
// Call the Paystack Refund API again
try {
await initiateRefund(transactionRef, amount);
// If successful, wait for the webhook to confirm
} catch (err) {
// Schedule another retry with exponential backoff
await retryQueue.add(
'retry-refund',
{
transactionRef: transactionRef,
amount: amount,
refundId: refundId,
attemptNumber: attemptNumber + 1,
},
{ delay: Math.pow(2, attemptNumber) * 60 * 60 * 1000 }
);
}
}
Manual resolution. If the failure was permanent (card expired, account closed), you need a human to decide the next step. Options include refunding to a different payment method, issuing store credit, or sending the refund via bank transfer outside of Paystack.
Paystack supports partial refunds. You can refund any amount up to the original transaction amount. When you initiate a partial refund, the refund.processed payload will have a deducted_amount less than the transaction.amount.
The tricky part is tracking multiple partial refunds against the same transaction. Consider this scenario:
refund.processed arrives with deducted_amount: 500000.refund.processed arrives.partially_refunded.Your database needs to track each refund individually and compute the total:
CREATE TABLE processed_refunds (
id SERIAL PRIMARY KEY,
refund_id INTEGER UNIQUE NOT NULL,
transaction_ref VARCHAR(100) NOT NULL,
amount INTEGER NOT NULL, -- in smallest currency unit
reason TEXT,
processed_at TIMESTAMPTZ DEFAULT NOW()
);
-- Quick lookup: total refunded for a transaction
CREATE INDEX idx_refunds_transaction ON processed_refunds(transaction_ref);
With this schema, the handler shown earlier correctly computes the cumulative refund and sets the order status to partially_refunded or fully_refunded based on the total.
One edge case to watch: if you refund 15,000 NGN in two installments (7,500 + 7,500), the second refund.processed event should flip the order status to fully_refunded. Make sure your status logic checks the cumulative total, not just the current refund amount.
With refunds in the mix, your order status becomes more complex. Here is a clean state machine that covers the full lifecycle:
pending -> paid -> (fulfilled | partially_refunded | fully_refunded | refund_failed)
paid -> partially_refunded -> (fully_refunded | refund_failed)
partially_refunded -> fully_refunded
In code, validate transitions before applying them:
const VALID_TRANSITIONS = {
pending: ['paid'],
paid: ['fulfilled', 'partially_refunded', 'fully_refunded', 'refund_failed'],
fulfilled: ['partially_refunded', 'fully_refunded', 'refund_failed'],
partially_refunded: ['fully_refunded', 'refund_failed'],
refund_failed: ['partially_refunded', 'fully_refunded'],
fully_refunded: [], // Terminal state
};
async function updateOrderStatus(transactionRef, newStatus) {
const order = await db.query(
'SELECT status FROM orders WHERE payment_reference = $1',
[transactionRef]
);
if (order.rows.length === 0) {
throw new Error('Order not found: ' + transactionRef);
}
const currentStatus = order.rows[0].status;
const allowed = VALID_TRANSITIONS[currentStatus] || [];
if (!allowed.includes(newStatus)) {
console.warn(
'Invalid status transition for ' + transactionRef +
': ' + currentStatus + ' -> ' + newStatus
);
return false;
}
await db.query(
'UPDATE orders SET status = $1, updated_at = NOW() ' +
'WHERE payment_reference = $2',
[newStatus, transactionRef]
);
return true;
}
The state machine prevents illegal transitions. For example, a fully_refunded order cannot go back to partially_refunded. A refund_failed order can still transition to partially_refunded if a retry succeeds. This keeps your data consistent even when webhook events arrive out of order.
To test the full refund flow in Paystack test mode:
refund.processed webhook.For local development, simulate the events:
SECRET="sk_test_your_secret_key"
# Simulate refund.processed
BODY='{"event":"refund.processed","data":{"id":5678901,"transaction":{"id":1234567890,"reference":"test-order-001","amount":500000,"currency":"NGN","channel":"card","customer":{"email":"test@example.com","customer_code":"CUS_test1"}},"status":"processed","deducted_amount":500000,"currency":"NGN","channel":"card","fully_deducted":true,"refunded_at":"2026-07-20T14:30:00.000Z","created_at":"2026-07-20T10:00:00.000Z"}}'
SIG=$(echo -n "$BODY" | openssl dgst -sha512 -hmac "$SECRET" | awk '{print $2}')
curl -X POST http://localhost:3000/webhooks/paystack -H "Content-Type: application/json" -H "x-paystack-signature: $SIG" -d "$BODY"
Test both full and partial refund scenarios. Also test the refund.failed path to make sure your alerting and recovery logic works correctly.
For the full testing approach, see simulating Paystack webhook events for automated tests.
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