McTaba Labs logo
Software EngineeringThe WhiteboardProjectsEventsMasterclassesWebinarsLearn
Book Call

500+ developers already in the community

Get answers, find accountability partners, and stay ahead

Join Discord →
McTaba Labs logo

Educating 1 million students in tech by 2030.

Programs

  • HighSchool Graduates Launchpad
  • AI & Business Automation
  • Corporate Training
  • McTaba Story & Mission
  • Full-Stack Curriculum
  • Course Pricing & Plans
  • Frequently Asked Questions

Resources

  • Join Discord Community
  • Tech Events in Nairobi
  • Masterclasses
  • Free Resources
  • Learn
  • Campus
  • Africa Tech Talent Report
  • Partner With Us
  • Start a Project
  • Our Work

Contact Us

  • 124 Manyani East Road, Lavington, Nairobi
  • +254727795603
  • Book a Discovery Call

Office Hours:

Mon-Fri: 9am - 2pm EAT

Sat: 10am - 12pm EAT

© 2027 McTaba Labs - A product of Edtech Industries. All rights reserved.

Privacy PolicyTerms of ServiceRefund Policy
  1. Learn
  2. Paystack for African Developers
  3. Handling dispute.create and dispute.remind Events
Bonaventure OgetoBy Bonaventure Ogeto|Updated July 20, 2026

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.

In this article

  1. 1. What Is a Paystack Dispute
  2. 2. The dispute.create Payload
  3. 3. Handling dispute.create: The Immediate Response
  4. 4. Automated Evidence Gathering
  5. 5. The dispute.remind Payload and Handling
  6. 6. Deadline Tracking and Escalation
  7. 7. Reducing Disputes in the First Place
  8. 8. Dispute Database Schema

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.

Get weekly developer tips

Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.

No spam. Unsubscribe anytime.

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.

Learn Paystack Integration

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

Not ready? Create Free Account

Keep reading

Paystack Webhooks: Complete Engineering Guide

Everything you need to receive, verify, and process Paystack webhook events in production. Covers signature verification, the 200 OK rule, idempotency, local testing, and the failures that trip up most teams.

Read more

Handling customeridentification.success and Failure Events

A practical guide to handling Paystack customer identification webhook events. Covers BVN verification, identity validation payloads, updating KYC status in your database, and recovering from failed verification attempts.

Read more

Replaying Failed Paystack Webhooks Safely

When Paystack webhook processing fails, you need to replay the events from your stored payloads. This guide covers when to replay, how to build a safe replay tool, the idempotency checks that prevent double-processing, and batch replay strategies for recovering from outages.

Read more

Paystack Webhooks on Cloudflare Workers

A complete guide to handling Paystack webhooks on Cloudflare Workers. Covers the Web Crypto API for HMAC-SHA512 signature verification (since Workers do not have Node.js crypto), request body handling, environment secrets, and a production-ready Worker you can deploy in minutes.

Read more

Accepting Mobile Money Payments Through Paystack

Mobile money is the dominant payment method in Ghana, and Paystack supports MTN MoMo, AirtelTigo Money, and Vodafone Cash. This guide covers how mobile money payments work through both the hosted checkout and the Charge API, including handling the asynchronous approval flow.

Read more

Explore related topics

M-Pesa & African Stack

M-Pesa Daraja API tutorials, USSD development, and WhatsApp Business API guides. The African Stack that global bootcamps do not teach.

Learn to Code in Kenya

Everything a Kenyan needs to know before writing their first line of code. Career paths, real costs, hardware requirements, and honest answers about what it takes.

Learning to Code

Roadmaps, language guides, and practical advice for learning to code and launching a programming career.

Breaking Into Tech

Practical, no-nonsense guides for anyone looking to start or change their career in tech. From choosing your first language to acing interviews.

The African Stack: Skills for This Market

The skills that make Kenyan developers hard to replace: M-Pesa, USSD, WhatsApp, fintech, and building for the African market.

Bonaventure Ogeto

Bonaventure Ogeto

Founder, McTaba Labs

Bonaventure is the founder of McTaba Labs, a coding bootcamp in Nairobi, Kenya. He has spent years building products for the African market and mentoring the next generation of developers across the continent.