Bonaventure OgetoBy Bonaventure Ogeto|

Handling dispute.create and dispute.remind Events

Paystack fires dispute.create when a customer or their bank opens a chargeback against a transaction on your account, and dispute.remind when the resolution deadline is approaching. Your webhook handler should immediately alert your team, log the dispute details, freeze the associated order, begin evidence gathering, and track the response deadline. Disputes have strict deadlines. Missing them means you automatically lose.

What Is a Paystack Dispute

A dispute (also called a chargeback) happens when a customer contacts their bank to reverse a transaction. The bank reaches out to Paystack, Paystack reaches out to you. You have a limited window to provide evidence that the transaction was legitimate and the customer received what they paid for.

Disputes can happen for several reasons:

  • Fraud. The cardholder did not authorize the transaction. Someone else used their card.
  • Service not rendered. The customer paid but never received the product or service.
  • Product not as described. The customer received something different from what they ordered.
  • Duplicate charge. The customer was charged twice for the same purchase.
  • Customer does not recognize the charge. The billing descriptor on their statement did not match your business name.

In the African payment ecosystem, disputes are particularly common with card payments in Nigeria and South Africa. Mobile money and bank transfer payments have different dispute mechanisms, but card chargebacks follow the international card network rules (Visa, Mastercard).

The financial stakes are real. If you lose a dispute, you lose the transaction amount plus a chargeback fee. If your dispute rate exceeds the card network thresholds (typically 1% of transactions), you can face fines, higher processing fees, or in extreme cases, lose the ability to accept card payments entirely.

This article is part of the Paystack webhooks engineering guide.

The dispute.create Payload

When a dispute is opened, Paystack sends a webhook with this structure:

{
  "event": "dispute.create",
  "data": {
    "id": 3456789,
    "refund_amount": 500000,
    "currency": "NGN",
    "status": "awaiting-merchant-feedback",
    "resolution": null,
    "domain": "live",
    "transaction": {
      "id": 1234567890,
      "domain": "live",
      "reference": "order-ref-abc123",
      "amount": 500000,
      "currency": "NGN",
      "channel": "card",
      "customer": {
        "email": "customer@example.com",
        "customer_code": "CUS_abc123"
      }
    },
    "message": "Customer claims they did not authorize this transaction",
    "due_at": "2026-07-23T00:00:00.000Z",
    "created_at": "2026-07-20T10:00:00.000Z",
    "updated_at": "2026-07-20T10:00:00.000Z"
  }
}

The fields that matter:

  • data.id - The dispute ID on Paystack. Use this when responding via the API.
  • data.status - Usually awaiting-merchant-feedback when the dispute is first created. This is your cue to act.
  • data.due_at - The deadline for your response. This is a hard deadline. If you miss it, you lose.
  • data.message - The reason for the dispute as communicated by the customer or their bank.
  • data.refund_amount - The amount being disputed, in the smallest currency unit.
  • data.transaction - The original transaction details, including the reference you can use to look up the order in your system.

The due_at field is the most critical. Count the hours between now and that timestamp. That is how long you have to gather evidence and respond.

Handling dispute.create: The Immediate Response

When a dispute arrives, speed matters. Your webhook handler should do four things immediately: log it, freeze the order, alert your team, and begin evidence gathering.

async function handleDisputeCreate(data) {
  const {
    id: disputeId,
    transaction,
    message,
    due_at,
    refund_amount,
    currency,
  } = data;

  const transactionRef = transaction.reference;
  const customerEmail = transaction.customer.email;
  const deadline = new Date(due_at);
  const hoursRemaining = Math.round(
    (deadline.getTime() - Date.now()) / (1000 * 60 * 60)
  );

  // 1. Record the dispute
  await db.query(
    'INSERT INTO disputes (dispute_id, transaction_ref, customer_email, ' +
    'amount, currency, reason, deadline, status, created_at) ' +
    'VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW()) ' +
    'ON CONFLICT (dispute_id) DO NOTHING',
    [
      disputeId, transactionRef, customerEmail,
      refund_amount, currency, message, deadline,
      'awaiting_response',
    ]
  );

  // 2. Freeze the order (prevent further fulfillment or refunds)
  await db.query(
    'UPDATE orders SET status = $1, dispute_id = $2, ' +
    'updated_at = NOW() WHERE payment_reference = $3',
    ['disputed', disputeId, transactionRef]
  );

  // 3. Alert the team immediately via Slack
  const amountFormatted = (refund_amount / 100).toLocaleString();
  await sendSlackAlert({
    channel: '#disputes',
    blocks: [
      {
        type: 'header',
        text: { type: 'plain_text', text: 'New Dispute Opened' },
      },
      {
        type: 'section',
        fields: [
          {
            type: 'mrkdwn',
            text: '*Transaction:*
' + transactionRef,
          },
          {
            type: 'mrkdwn',
            text: '*Amount:*
' + amountFormatted + ' ' + currency,
          },
          {
            type: 'mrkdwn',
            text: '*Customer:*
' + customerEmail,
          },
          {
            type: 'mrkdwn',
            text: '*Deadline:*
' + hoursRemaining + ' hours',
          },
          {
            type: 'mrkdwn',
            text: '*Reason:*
' + message,
          },
        ],
      },
    ],
  });

  // 4. Begin automated evidence gathering
  await evidenceQueue.add('gather-dispute-evidence', {
    disputeId: disputeId,
    transactionRef: transactionRef,
    customerEmail: customerEmail,
  });
}

The Slack alert is structured with blocks so the message is scannable. When a dispute comes in, whoever sees it needs to immediately understand: how much money, which customer, what reason, and how much time is left.

The evidence gathering queue job is covered in the next section.

Automated Evidence Gathering

When a dispute lands, you need to assemble evidence fast. The more of this you can automate, the less scrambling your team does when the clock is ticking.

Useful evidence for Paystack disputes includes:

  • Proof of delivery. Shipping tracking number, delivery confirmation, screenshot of delivery status.
  • Service usage logs. If the customer used your platform after the transaction (logged in, consumed content, used a feature), that proves the service was rendered.
  • Customer communication. Emails or support tickets where the customer acknowledged receiving the product.
  • Transaction metadata. IP address, device fingerprint, billing address match. These are relevant for fraud disputes.
  • Refund policy. Your published terms showing the customer agreed to the charge.
async function gatherDisputeEvidence(job) {
  const { disputeId, transactionRef, customerEmail } = job.data;

  const evidence = {};

  // Pull the order details
  const order = await db.query(
    'SELECT * FROM orders WHERE payment_reference = $1',
    [transactionRef]
  );

  if (order.rows.length > 0) {
    const orderData = order.rows[0];
    evidence.orderDate = orderData.created_at;
    evidence.fulfillmentStatus = orderData.fulfillment_status;
    evidence.deliveryDate = orderData.delivered_at;
    evidence.shippingTracker = orderData.tracking_number;
  }

  // Pull login activity after the transaction
  const loginActivity = await db.query(
    'SELECT login_at, ip_address FROM login_history ' +
    'WHERE user_email = $1 AND login_at > (' +
    '  SELECT created_at FROM orders WHERE payment_reference = $2' +
    ') ORDER BY login_at LIMIT 10',
    [customerEmail, transactionRef]
  );
  evidence.postTransactionLogins = loginActivity.rows;

  // Pull support tickets related to this order
  const tickets = await db.query(
    'SELECT subject, created_at, status FROM support_tickets ' +
    'WHERE customer_email = $1 AND order_reference = $2',
    [customerEmail, transactionRef]
  );
  evidence.supportTickets = tickets.rows;

  // Pull the original transaction IP
  const txnMeta = await db.query(
    'SELECT ip_address, user_agent, billing_address ' +
    'FROM transaction_metadata WHERE reference = $1',
    [transactionRef]
  );
  if (txnMeta.rows.length > 0) {
    evidence.transactionIp = txnMeta.rows[0].ip_address;
    evidence.userAgent = txnMeta.rows[0].user_agent;
  }

  // Store the gathered evidence
  await db.query(
    'UPDATE disputes SET evidence = $1, evidence_gathered_at = NOW() ' +
    'WHERE dispute_id = $2',
    [JSON.stringify(evidence), disputeId]
  );

  // Notify the team that evidence is ready for review
  await sendSlackAlert({
    channel: '#disputes',
    text: 'Evidence gathered for dispute ' + disputeId +
      ' (transaction ' + transactionRef + '). ' +
      'Ready for review and submission.',
  });
}

This automated gathering saves your team from manually digging through databases and systems under deadline pressure. The evidence is assembled and waiting. A team member reviews it, adds any manual context, and submits the response through the Paystack dashboard or API.

The dispute.remind Payload and Handling

Paystack sends dispute.remind when the response deadline is approaching and you have not yet submitted evidence. This is an escalation. If you already responded, you should not receive this event.

{
  "event": "dispute.remind",
  "data": {
    "id": 3456789,
    "refund_amount": 500000,
    "currency": "NGN",
    "status": "awaiting-merchant-feedback",
    "transaction": {
      "id": 1234567890,
      "reference": "order-ref-abc123",
      "amount": 500000,
      "currency": "NGN",
      "customer": {
        "email": "customer@example.com",
        "customer_code": "CUS_abc123"
      }
    },
    "message": "Customer claims they did not authorize this transaction",
    "due_at": "2026-07-23T00:00:00.000Z",
    "created_at": "2026-07-20T10:00:00.000Z",
    "updated_at": "2026-07-22T10:00:00.000Z"
  }
}

Handle this with maximum urgency:

async function handleDisputeRemind(data) {
  const { id: disputeId, transaction, due_at, refund_amount, currency } = data;
  const transactionRef = transaction.reference;
  const deadline = new Date(due_at);
  const hoursRemaining = Math.round(
    (deadline.getTime() - Date.now()) / (1000 * 60 * 60)
  );

  // Check if we already submitted a response
  const dispute = await db.query(
    'SELECT status, evidence_gathered_at FROM disputes WHERE dispute_id = $1',
    [disputeId]
  );

  const disputeStatus = dispute.rows[0]?.status;
  const evidenceReady = dispute.rows[0]?.evidence_gathered_at;

  // Update the dispute status to reflect the reminder
  await db.query(
    'UPDATE disputes SET reminder_sent_at = NOW(), ' +
    'hours_remaining = $1 WHERE dispute_id = $2',
    [hoursRemaining, disputeId]
  );

  // Escalated Slack alert
  const amountFormatted = (refund_amount / 100).toLocaleString();
  const urgencyEmoji = hoursRemaining <= 12 ? 'CRITICAL' : 'URGENT';
  await sendSlackAlert({
    channel: '#disputes',
    text: urgencyEmoji + ' - DISPUTE REMINDER
' +
      'Dispute ' + disputeId + ' for ' + amountFormatted + ' ' +
      currency + ' needs a response.
' +
      'Transaction: ' + transactionRef + '
' +
      'Time remaining: ' + hoursRemaining + ' hours
' +
      'Evidence gathered: ' + (evidenceReady ? 'Yes' : 'NO') + '
' +
      'Response submitted: ' + (disputeStatus === 'responded' ? 'Yes' : 'NO'),
  });

  // If evidence was gathered but not submitted, send a direct alert
  if (evidenceReady && disputeStatus !== 'responded') {
    // Evidence is ready but nobody submitted it
    await sendSlackAlert({
      channel: '#disputes',
      text: 'Evidence for dispute ' + disputeId +
        ' was gathered but NOT submitted. ' +
        hoursRemaining + ' hours remaining. ' +
        'Submit now through the Paystack dashboard.',
    });
  }
}

The remind event is your safety net. If someone on your team saw the original dispute alert but forgot to follow through, this reminder prevents you from losing by default.

Deadline Tracking and Escalation

Relying solely on Paystack's dispute.remind event for deadline awareness is risky. The webhook could fail, be delayed, or arrive too late. Build your own deadline tracking as a backup.

// Run this as a scheduled job every hour
async function checkDisputeDeadlines() {
  const pendingDisputes = await db.query(
    'SELECT dispute_id, transaction_ref, customer_email, amount, ' +
    'currency, deadline, status, evidence_gathered_at ' +
    'FROM disputes WHERE status IN ($1, $2) AND deadline > NOW()',
    ['awaiting_response', 'evidence_gathered']
  );

  for (const dispute of pendingDisputes.rows) {
    const hoursLeft = Math.round(
      (new Date(dispute.deadline).getTime() - Date.now()) / (1000 * 60 * 60)
    );

    // Escalation tiers
    if (hoursLeft <= 6 && dispute.status !== 'responded') {
      // Less than 6 hours: page the on-call person
      await sendSlackAlert({
        channel: '#disputes',
        text: 'FINAL WARNING: Dispute ' + dispute.dispute_id +
          ' has ' + hoursLeft + ' hours remaining. ' +
          'Amount: ' + (dispute.amount / 100).toLocaleString() + ' ' +
          dispute.currency + '. Respond NOW or lose by default.',
      });
    } else if (hoursLeft <= 24 && dispute.status === 'awaiting_response') {
      // Less than 24 hours and no evidence gathered yet
      await sendSlackAlert({
        channel: '#disputes',
        text: 'WARNING: Dispute ' + dispute.dispute_id +
          ' has ' + hoursLeft +
          ' hours remaining and evidence has NOT been gathered.',
      });
    }
  }

  // Also check for missed deadlines
  const missedDisputes = await db.query(
    'SELECT dispute_id, transaction_ref, amount, currency ' +
    'FROM disputes WHERE status = $1 AND deadline < NOW()',
    ['awaiting_response']
  );

  for (const missed of missedDisputes.rows) {
    await db.query(
      'UPDATE disputes SET status = $1 WHERE dispute_id = $2',
      ['missed_deadline', missed.dispute_id]
    );

    await sendSlackAlert({
      channel: '#disputes',
      text: 'MISSED DEADLINE: Dispute ' + missed.dispute_id +
        ' for ' + (missed.amount / 100).toLocaleString() + ' ' +
        missed.currency + ' was not responded to in time. ' +
        'This dispute will be lost.',
    });
  }
}

Schedule this job to run every hour. The escalation tiers (24 hours, 6 hours) ensure that as the deadline approaches, the alerts become more urgent and more visible. Adjust the thresholds based on your team's response time and timezone coverage.

Reducing Disputes in the First Place

The best dispute strategy is prevention. Most disputes come from a handful of root causes that you can address proactively:

Set a recognizable billing descriptor. When a customer sees "PAY*RANDOMCODE" on their bank statement, they do not recognize it and file a chargeback. Work with Paystack to set a billing descriptor that matches your brand name.

Send payment confirmation emails immediately. When a charge.success webhook arrives, send the customer a receipt with the amount, date, and what they purchased. If they see the charge on their statement, they can cross-reference with your email and know it is legitimate.

Deliver quickly and communicate delays. "Service not rendered" disputes happen when there is a long gap between payment and delivery with no communication. If fulfillment takes time, send updates.

Make your refund policy accessible. If a customer can easily request a refund from your platform, they are less likely to go through their bank. A visible "Request Refund" button prevents chargebacks.

Track your dispute rate. Card networks watch your dispute ratio (disputes / total transactions). Keep it below 0.5% to stay safe. If it creeps above that, investigate the root cause:

async function getDisputeRate(startDate, endDate) {
  const disputes = await db.query(
    'SELECT COUNT(*) as count FROM disputes ' +
    'WHERE created_at BETWEEN $1 AND $2',
    [startDate, endDate]
  );

  const transactions = await db.query(
    'SELECT COUNT(*) as count FROM orders ' +
    'WHERE status = $1 AND created_at BETWEEN $2 AND $3',
    ['paid', startDate, endDate]
  );

  const disputeCount = parseInt(disputes.rows[0].count, 10);
  const transactionCount = parseInt(transactions.rows[0].count, 10);
  const rate = transactionCount > 0
    ? (disputeCount / transactionCount * 100).toFixed(2)
    : '0.00';

  return {
    disputes: disputeCount,
    transactions: transactionCount,
    rate: rate + '%',
  };
}

Run this report monthly and act on the results. A sudden spike in disputes often signals a fulfillment problem, a fraud ring, or a bug in your payment flow.

Dispute Database Schema

Here is a practical schema for tracking disputes through their lifecycle:

CREATE TABLE disputes (
  id SERIAL PRIMARY KEY,
  dispute_id INTEGER UNIQUE NOT NULL,
  transaction_ref VARCHAR(100) NOT NULL,
  customer_email VARCHAR(255),
  amount INTEGER NOT NULL,
  currency VARCHAR(3) DEFAULT 'NGN',
  reason TEXT,
  status VARCHAR(30) DEFAULT 'awaiting_response',
  -- awaiting_response | evidence_gathered | responded |
  -- won | lost | missed_deadline
  deadline TIMESTAMPTZ NOT NULL,
  evidence JSONB,
  evidence_gathered_at TIMESTAMPTZ,
  response_submitted_at TIMESTAMPTZ,
  resolution VARCHAR(20),
  resolved_at TIMESTAMPTZ,
  reminder_sent_at TIMESTAMPTZ,
  hours_remaining INTEGER,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_disputes_status ON disputes(status);
CREATE INDEX idx_disputes_deadline ON disputes(deadline)
  WHERE status IN ('awaiting_response', 'evidence_gathered');
CREATE INDEX idx_disputes_transaction ON disputes(transaction_ref);

The partial index on deadline keeps the hourly deadline check fast by only indexing disputes that still need attention. Once a dispute is responded to or resolved, it drops out of the index.

Key Takeaways

  • dispute.create fires when a chargeback is opened against one of your transactions. This is time-sensitive and requires immediate action.
  • dispute.remind fires as the resolution deadline approaches. Treat it as an escalation, not just a notification.
  • Disputes have hard deadlines (typically 24 to 72 hours for the initial response). Missing the deadline means you lose the dispute automatically.
  • Automate your initial response: log the dispute, freeze the order, alert the team via Slack, and begin gathering evidence immediately.
  • Evidence for dispute resolution includes delivery confirmation, customer communication logs, IP addresses, and proof the service was rendered.
  • Track dispute metrics (win rate, average response time, dispute-to-transaction ratio) to identify patterns and reduce future disputes.
  • The financial cost of a dispute goes beyond the refund. Card networks can fine merchants with high dispute ratios.

Frequently Asked Questions

How much time do I have to respond to a Paystack dispute?
The deadline varies depending on the card network and the type of dispute, but it is typically 24 to 72 hours from when the dispute is created. The exact deadline is in the due_at field of the dispute.create webhook payload. Treat this as a hard deadline. Missing it means you lose the dispute automatically.
What happens if I lose a Paystack dispute?
The disputed amount is deducted from your Paystack balance and returned to the customer. You may also be charged a chargeback fee by the card network. If your dispute rate is high, Paystack or the card network may impose additional penalties, including higher processing fees or account restrictions.
Can I accept a dispute instead of fighting it?
Yes. If you agree that the customer deserves a refund, you can accept the dispute through the Paystack dashboard. This resolves it immediately. Accepting a dispute still counts toward your dispute ratio, but it is better than fighting a dispute you know you will lose, because the resolution is faster.
What evidence should I submit for a Paystack dispute?
Submit proof that the customer received what they paid for. This includes delivery tracking numbers, screenshots of service usage, customer communication confirming receipt, the transaction IP address and device details, and your refund policy. The stronger the evidence that the customer authorized and received the service, the better your chances of winning.
Do mobile money payments have chargebacks like card payments?
Mobile money dispute mechanisms vary by provider and country. In general, mobile money chargebacks are less common than card chargebacks because the authentication flow (entering a PIN) provides stronger proof of authorization. However, fraud disputes can still occur. Check with Paystack for the specific dispute process for each payment channel.

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