Bonaventure OgetoBy Bonaventure Ogeto|

Handling refund.processed and refund.failed Events

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.

How Paystack Refunds Work

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:

  1. A customer requests a refund on your platform.
  2. Your backend calls the Paystack Refund API with the transaction reference and the amount to refund (full or partial).
  3. Paystack returns a success response with a refund ID. The refund is now pending.
  4. Paystack processes the refund. This happens asynchronously.
  5. When the refund completes, Paystack sends 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.

The refund.processed Payload

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:

  • data.id - The refund ID. Use this for deduplication.
  • data.transaction.reference - The original payment reference. Links the refund back to the order in your system.
  • data.transaction.amount - The original transaction amount (in kobo/pesewas/cents).
  • data.deducted_amount - The amount actually refunded. For a full refund, this equals the transaction amount. For a partial refund, it is less.
  • data.fully_deducted - Whether this refund covers the full transaction amount.
  • data.refunded_at - When the refund was actually processed.
  • data.customer_note - The reason the customer gave for the refund, if any.

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.

Handling Successful Refunds

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.

The refund.failed Payload

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:

  • Insufficient balance. Your Paystack balance does not have enough funds to cover the refund. This is the most common reason for merchants who sweep their balance frequently.
  • Card scheme rejection. The card network (Visa, Mastercard) rejected the refund. This can happen with expired cards or cards that have been reported stolen.
  • Bank rejection. The customer's bank refused the credit. This is rare but happens with closed accounts.
  • Time limit exceeded. Some payment channels have a maximum window for refunds (e.g., 90 or 180 days from the original transaction). Refunds attempted after this window may fail.

Handling Failed Refunds

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.

Handling Partial Refunds

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:

  1. Customer buys 3 items for a total of 15,000 NGN (1,500,000 kobo).
  2. Item 1 was defective. You refund 5,000 NGN. refund.processed arrives with deducted_amount: 500000.
  3. Item 2 was also defective. You refund another 5,000 NGN. A second refund.processed arrives.
  4. Total refunded: 10,000 NGN out of 15,000 NGN. Order status: 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.

Order Status State Machine

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.

Testing Refund Webhooks

To test the full refund flow in Paystack test mode:

  1. Make a test payment using a Paystack test card.
  2. Initiate a refund via the API or dashboard.
  3. Wait for the 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.

Key Takeaways

  • Paystack fires refund.processed when a refund is completed and refund.failed when it cannot be processed.
  • Refund processing is asynchronous. The Refund API call returns immediately, but the actual refund may take hours or days to complete.
  • The refund.processed payload includes the original transaction reference, refund amount, and refund ID. Use the refund ID for deduplication.
  • For partial refunds, track the cumulative refunded amount against the original transaction amount to know if the order is fully or partially refunded.
  • When a refund fails, log the reason, notify your operations team, and decide whether to retry programmatically or handle manually.
  • Always update the order status in the same database transaction as your deduplication record to prevent inconsistent state.

Frequently Asked Questions

How long does a Paystack refund take to process?
It depends on the payment channel. Card refunds can take 5 to 10 business days to reflect in the customer account, though Paystack processes them faster on their end. Bank transfer refunds are typically faster. The refund.processed webhook fires when Paystack has completed the refund on their side, but the customer may not see the money in their account for a few more days depending on their bank.
Can I refund more than the original transaction amount?
No. Paystack does not allow refunds exceeding the original transaction amount. If you need to pay the customer more than they originally paid (for example, as compensation), you would need to send a transfer to their bank account, which is a separate API call and workflow.
What happens to my Paystack balance when a refund is processed?
The refund amount is deducted from your Paystack balance. If your balance is insufficient, the refund will fail and you will receive a refund.failed webhook. Make sure you maintain adequate balance if you process refunds regularly. Paystack does not pull from your bank account to cover refunds.
Can I retry a failed refund automatically?
Yes. If the failure was transient (insufficient balance, temporary bank issue), you can retry by calling the Paystack Refund API again with the same transaction reference and amount. Implement exponential backoff and a maximum retry count (three is reasonable) to avoid hammering the API. For permanent failures like a closed customer account, manual resolution is required.
Does Paystack send a webhook for refunds initiated from the dashboard?
Yes. Paystack sends refund.processed and refund.failed webhooks regardless of whether the refund was initiated via the API or the dashboard. Your webhook handler will receive the event either way, so make sure it handles both sources correctly.

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