Pay
Rejects
Disputes & Chargebacks

Disputes

Learn how to manage payment disputes and chargebacks


Payment disputes occur when customers challenge payments through their bank or card issuer. Disputes can result from suspected fraud, unauthorized transactions, service issues, or billing errors. Understanding how to monitor, respond to, and prevent disputes is essential for maintaining healthy payment operations and minimizing revenue loss.

Prerequisites

Before handling disputes, it's helpful to learn about the following topics.


Understanding Disputes


Disputes occur when customers contest payments after they have been processed and settled. The dispute process varies based on payment method, but all disputes result in temporary or permanent fund reversals.

How Disputes Work

Initial Processing

Payment is processed and marked as processed

Customer Dispute

Customer contacts their bank/card issuer to dispute the charge

Status Update

Transaction status changes from processed to rejected

Fund Reversal

Funds are immediately reversed from your processing account

Evidence Period

You have limited time to submit evidence (typically 7-21 days)

Resolution

Bank/issuer reviews evidence and makes final decision

Outcome

Dispute is won (funds returned) or lost (funds remain reversed) and transaction is set back to processed


Handling Different Dispute Types

The response process varies between card chargebacks and bank account disputes.

Card Chargebacks

Card chargebacks follow a formal dispute process governed by card network rules (Visa, Mastercard, etc.):

  • You have opportunity to provide evidence to the card issuer
  • Evidence submission typically has 7-21 day deadline
  • Card network makes final decision based on submitted evidence

Bank Account Disputes

Unlike card chargebacks, payment stopped orders don't have a formal evidence submission process through the payment network. Resolution typically involves direct communication with the customer or pursuing collection through other means.

import payload
pl = payload.Session('secret_key_3bW9...', api_version='v2')
 
# Retrieve a rejected transaction
transaction = pl.Transaction.get('txn_rejected123')
 
# Check if rejection is due to payment stopped
if transaction.status.code == 'payment_stopped':
    print(f"Payment stopped dispute for transaction {transaction.id}")
    print(f"Amount: ${transaction.amount}")
 
    # Record in dispute tracking system
    print("Creating dispute case in tracking system...")
    # dispute_case = create_internal_dispute_case(
    #     transaction_id=transaction.id,
    #     type='payment_stopped',
    #     status='pending_review',
    #     amount=transaction.amount
    # )
 
    # Notify dispute management team
    print("Notifying dispute team...")
    # send_notification(
    #     team='disputes',
    #     priority='high',
    #     message=f"Payment stopped order filed for transaction {transaction.id}",
    #     transaction_id=transaction.id,
    #     amount=transaction.amount
    # )
 
    # Contact customer to determine reason
    print("Initiating customer contact...")
    # send_email(
    #     to=customer.email,
    #     subject="Question About Recent Payment",
    #     body="We noticed a stop payment order on your recent transaction. Please contact us to discuss..."
    # )
 
    # Gather evidence for potential collection
    print("Gathering transaction evidence...")
    evidence = {
        'transaction_id': transaction.id,
        'original_authorization': 'Evidence of customer authorization',
        'service_delivery': 'Proof of service delivery or product shipment',
        'customer_agreement': 'Terms of service agreed to at purchase',
        'communication_history': 'Previous emails, chats, or phone calls'
    }
 
    # Document in dispute tracking system
    print("Creating dispute case...")
    # dispute_case = create_dispute_case(
    #     transaction_id=transaction.id,
    #     type='payment_stopped',
    #     amount=transaction.amount,
    #     evidence=evidence,
    #     customer_id=transaction.sender['account_id']
    # )
 
    # Flag account in your internal system
    print("Flagging account pending resolution...")
    # flag_account_for_review(
    #     account_id=transaction.sender['account_id'],
    #     reason='payment_stopped',
    #     action='suspend_autopay'
    # )
 
    print("Payment stopped dispute handling initiated")

This example handles payment stopped rejections:

  1. Check if rejection code is payment_stopped
  2. Record the transaction in your internal dispute tracking system
  3. Contact customer to determine reason for stop payment
  4. Consider whether to pursue collection or write off

Querying Disputed Transactions

Monitor disputed transactions by querying for payments with rejected status and appropriate status codes.

import payload
pl = payload.Session('secret_key_3bW9...', api_version='v2')
from datetime import datetime, timedelta
 
# Query disputed transactions from the last 90 days
ninety_days_ago = datetime.now() - timedelta(days=90)
date_string = ninety_days_ago.strftime('%Y-%m-%d')
 
disputed_transactions = pl.Transaction.filter_by(
    status={'value': 'rejected'},
    rejected_date='>='+date_string
).all()
 
print(f"Found {len(disputed_transactions)} disputed/rejected transactions\n")
 
# Separate disputes from other rejections
chargebacks = []
payment_stopped = []
other_rejects = []
 
for txn in disputed_transactions:
    if txn.status.code == 'payment_stopped':
        payment_stopped.append(txn)
    elif txn.status.code in ['insufficient_bal', 'invalid_account_number', 'general_reject']:
        other_rejects.append(txn)
    else:
        # Assume other codes are chargeback-related
        chargebacks.append(txn)
 
# Display dispute summary
print(f"Card Chargebacks: {len(chargebacks)}")
print(f"Payment Stopped: {len(payment_stopped)}")
print(f"Other Rejects (NSF, Invalid): {len(other_rejects)}")
print()
 
# Display chargeback details
if chargebacks:
    print("Card Chargebacks:")
    for txn in chargebacks:
        print(f"  Transaction: {txn.id}")
        print(f"    Amount: ${txn.amount}")
        print(f"    Rejected: {txn.rejected_date}")
        print(f"    Code: {txn.status.code}")
        print(f"    Message: {txn.status.message}")
        print()
 
# Display payment stopped details
if payment_stopped:
    print("Payment Stopped Orders:")
    for txn in payment_stopped:
        print(f"  Transaction: {txn.id}")
        print(f"    Amount: ${txn.amount}")
        print(f"    Rejected: {txn.rejected_date}")
        print()

This example demonstrates querying disputed transactions:

  1. Filter transactions by status.value='rejected'
  2. Optionally filter by date range using rejected_date
  3. Check status.code to identify dispute type
  4. Card chargebacks will have various codes based on reason
  5. Payment stopped orders have payment_stopped code
  6. Access status.message for human-readable details

Regular monitoring helps identify dispute patterns, track dispute rates, and respond promptly to new disputes.


Handling Dispute Webhooks

Receive immediate notifications when disputes are filed by registering a webhook.

import payload
pl = payload.Session('secret_key_3bW9...', api_version='v2')
 
from flask import Flask, request, jsonify
 
app = Flask(__name__)
 
@app.route('/webhooks/transaction-disputed', methods=['POST'])
def handle_dispute():
    # Acknowledge webhook immediately
    response = jsonify({'received': True})
 
    # Parse webhook payload
    event = request.json
 
    # Verify webhook signature (important for security)
    # signature = request.headers.get('Payload-Signature')
    # pl.Webhook.verify(signature, request.data)
 
    # Check if this is a rejection event
    if event['trigger'] != 'reject':
        return response, 200
 
    # Fetch full transaction details from API
    transaction = pl.Transaction.get(event['triggered_on']['id'])
    txn_id = transaction.id
    status_code = transaction.status.code
    amount = transaction.amount
    rejected_date = transaction.rejected_date
 
    print(f"Dispute notification: Transaction {txn_id}")
    print(f"Amount: ${amount}")
    print(f"Status code: {status_code}")
    print(f"Rejected: {rejected_date}")
 
    # Determine dispute type
    if status_code == 'payment_stopped':
        dispute_type = 'payment_stopped'
        print("Type: Payment Stopped Order")
    else:
        dispute_type = 'chargeback'
        print("Type: Card Chargeback")
 
    # Create dispute case immediately
    print("Creating dispute case...")
    dispute_case = {
        'transaction_id': txn_id,
        'type': dispute_type,
        'amount': amount,
        'status_code': status_code,
        'received_date': rejected_date,
        'evidence_due_date': 'Calculate based on network rules',
        'status': 'evidence_needed'
    }
    # save_dispute_case(dispute_case)
 
    # Notify dispute management team
    print("Notifying dispute team...")
    # send_urgent_notification(
    #     team='disputes',
    #     priority='urgent',
    #     subject=f"New {dispute_type}: ${amount}",
    #     transaction_id=txn_id,
    #     due_date=dispute_case['evidence_due_date']
    # )
 
    # Notify account manager if high value
    if amount >= 1000:
        print("High-value dispute - notifying account manager...")
        # notify_account_manager(transaction)
 
    # Begin automated evidence gathering
    print("Initiating evidence gathering...")
    # gather_transaction_evidence(txn_id)
    # retrieve_customer_history(transaction['sender']['account_id'])
    # pull_delivery_records(txn_id)
 
    # Update internal systems
    print("Updating internal systems...")
    # update_accounting_system(txn_id, 'disputed')
    # flag_customer_account(transaction.sender['account_id'], 'dispute_filed')
 
    return response, 200
 
if __name__ == '__main__':
    app.run(port=5000)

This example shows webhook handling:

  1. Register a webhook for the reject trigger
  2. Receive POST request when dispute occurs
  3. Parse webhook payload to extract transaction details
  4. Check status.code to determine dispute type
  5. Automatically create case in dispute management system
  6. Notify relevant team members immediately
  7. Begin evidence gathering process

Webhooks enable immediate response to disputes, maximizing the time available for evidence submission.


Analyzing Dispute Patterns

Track dispute rates and identify patterns to reduce future disputes.

from collections import defaultdict
from datetime import datetime, timedelta
 
import payload
 
pl = payload.Session("secret_key_3bW9...", api_version="v2")
 
# Query all transactions from the last 90 days
ninety_days_ago = datetime.now() - timedelta(days=90)
start_date = ninety_days_ago.strftime("%Y-%m-%d")
 
all_transactions = pl.Transaction.filter_by(created_at=">=" + start_date).all()
 
disputed_transactions = pl.Transaction.filter_by(
    status={"value": "rejected"}, rejected_date=">=" + start_date
).all()
print(f"Analyzing transactions from {start_date} to today")
print(f"Total transactions: {len(all_transactions)}")
print(f"Disputed transactions: {len(disputed_transactions)}")
 
# Calculate overall dispute rate
dispute_rate = (
    (len(disputed_transactions) / len(all_transactions) * 100)
    if len(all_transactions) > 0
    else 0
)
print(f"Overall dispute rate: {dispute_rate:.2f}%")
 
# Separate chargebacks from payment_stopped
chargebacks = []
payment_stopped = []
dispute_reasons = defaultdict(int)
 
for txn in disputed_transactions:
    status_code = txn.status.code
 
    if status_code == "payment_stopped":
        payment_stopped.append(txn)
    elif status_code not in [
        "insufficient_bal",
        "invalid_account_number",
        "general_reject",
    ]:
        chargebacks.append(txn)
        dispute_reasons[status_code] += 1
 
print(f"\nDispute breakdown:")
print(f"  Card chargebacks: {len(chargebacks)}")
print(f"  Payment stopped orders: {len(payment_stopped)}")
 
# Analyze chargeback reasons
print(f"\nChargeback reasons:")
for reason, count in sorted(dispute_reasons.items(), key=lambda x: x[1], reverse=True):
    percentage = (count / len(chargebacks) * 100) if len(chargebacks) > 0 else 0
    print(f"  {reason}: {count} ({percentage:.1f}%)")
 
# Identify products with high dispute rates
product_stats = defaultdict(lambda: {"total": 0, "disputed": 0})
 
for txn in all_transactions:
    if hasattr(txn, "line_items") and txn.line_items:
        for item in txn.line_items:
            product_id = item.get("product_id")
            if product_id:
                product_stats[product_id]["total"] += 1
 
for txn in disputed_transactions:
    if hasattr(txn, "line_items") and txn.line_items:
        for item in txn.line_items:
            product_id = item.get("product_id")
            if product_id:
                product_stats[product_id]["disputed"] += 1
 
print(f"\nProducts with highest dispute rates:")
product_rates = []
for product_id, stats in product_stats.items():
    if stats["total"] >= 10:  # Only consider products with 10+ transactions
        rate = stats["disputed"] / stats["total"] * 100
        product_rates.append((product_id, rate, stats["total"], stats["disputed"]))
 
for product_id, rate, total, disputed in sorted(
    product_rates, key=lambda x: x[1], reverse=True
)[:5]:
    print(f"  Product {product_id}: {rate:.1f}% ({disputed}/{total} transactions)")
 
# Analyze timing patterns
dispute_days = defaultdict(int)
for txn in disputed_transactions:
    if txn.rejected_date:
        print(txn.rejected_date)
        rejected_date = datetime.strptime(txn.rejected_date, "%Y-%m-%d")
        day_of_week = rejected_date.strftime("%A")
        dispute_days[day_of_week] += 1
 
print(f"\nDispute timing by day of week:")
for day in [
    "Monday",
    "Tuesday",
    "Wednesday",
    "Thursday",
    "Friday",
    "Saturday",
    "Sunday",
]:
    count = dispute_days[day]
    print(f"  {day}: {count}")
 
# Flag high-risk accounts
account_disputes = defaultdict(int)
for txn in disputed_transactions:
    if hasattr(txn, "sender") and txn.sender:
        account_id = txn.sender.get("account_id")
        if account_id:
            account_disputes[account_id] += 1
 
print(f"\nAccounts with multiple disputes:")
for account_id, count in sorted(
    account_disputes.items(), key=lambda x: x[1], reverse=True
):
    if count >= 2:
        print(f"  Account {account_id}: {count} disputes")

This example analyzes dispute patterns:

  1. Query all disputed transactions for a date range
  2. Calculate dispute rate (disputes / total transactions)
  3. Group disputes by reason code
  4. Identify products or services with high dispute rates
  5. Analyze timing patterns (disputes after X days)
  6. Generate reports for management

Key Metrics to Track:

  • Overall Dispute Rate: Should be less than 1% for most businesses
  • Win Rate: Percentage of disputes won with evidence
  • Average Dispute Amount: Track financial impact
  • Time to First Dispute: Days between transaction and dispute
  • Dispute Reason Distribution: Most common dispute types

Testing Disputes

Test dispute handling in test mode before production deployment.

Simulating Disputes

Use test transactions to trigger dispute scenarios:

import payload
 
pl = payload.Session("secret_key_3bW9...", api_version="v2")
 
print("Testing dispute scenarios in test mode\n")
 
# Test 1: Simulate a card chargeback (fraud)
print("Test 1: Simulating card chargeback (fraud)")
test_card_fraud = pl.Transaction.create(
    type="payment",
    amount=150.00,
    sender={
        "method": {
            "type": "card",
            "card": {
                "card_number": "4111111111111111",  # Test card
                "card_code": "999",  # Special CVV that triggers fraud chargeback
                "expiry": "12/28",
            },
            "billing_address": {"postal_code": "12345"},
        }
    },
    description="Test transaction for fraud dispute",
)
print(f"  Created transaction: {test_card_fraud.id}")
print(f"  Status: {test_card_fraud.status}")
print("  Expected: Will be disputed as fraud in test mode\n")
 
# Test 2: Simulate a card chargeback (service not rendered)
print("Test 2: Simulating card chargeback (service not rendered)")
test_card_service = pl.Transaction.create(
    type="payment",
    amount=250.00,
    sender={
        "method": {
            "type": "card",
            "card": {
                "card_number": "4111111111111111",
                "card_code": "998",  # Special CVV that triggers service dispute
                "expiry": "12/28",
            },
            "billing_address": {"postal_code": "12345"},
        }
    },
    description="Test transaction for service dispute",
)
print(f"  Created transaction: {test_card_service.id}")
print(f"  Status: {test_card_service.status}")
print("  Expected: Will be disputed as service not rendered\n")
 
# Test 3: Simulate a payment_stopped order
print("Test 3: Simulating payment stopped order")
test_payment_stopped = pl.Transaction.create(
    type="payment",
    amount=500.00,
    sender={
        "method": {
            "type": "bank_account",
            "bank_account": {
                "account_number": "1234567890123",  # Test account ending in 3 triggers payment_stopped
                "routing_number": "110000000",
                "account_type": "checking",
            },
            "account_holder": "Jane Smith",
        }
    },
    description="Test transaction for payment stopped",
)
print(f"  Created transaction: {test_payment_stopped.id}")
print(f"  Status: {test_payment_stopped.status}")
print("  Expected: Will be rejected with payment_stopped status code\n")
 
# Test 4: Query and verify disputed transactions
print("Test 4: Querying test disputed transactions")
disputed = pl.Transaction.filter_by(status={"value": "rejected"}).all()
print(f"  Found {len(disputed)} disputed transactions")
for txn in disputed:
    print(f"    {txn.id}: {txn.status.code} - ${txn.amount}")
 
print("\nAll dispute test scenarios completed")
print("\nNote: In test mode, disputes are simulated immediately")
print("In production, disputes occur days or weeks after the transaction")

In test mode, you can simulate disputes for testing:

  • Create test transactions that will automatically dispute
  • Test webhook endpoints receive dispute notifications

Dispute Status Codes


Common dispute-related status codes:

payment_stopped
string
Payment stopped by customer - Customer placed a stop payment order on the bank transaction

Card chargebacks may have various status codes depending on the specific chargeback reason. Check the status.message field for detailed information about the dispute reason.


Schema Reference


The following fields are relevant for handling disputes:

Transaction Fields

status
object
The status information for this transaction, including the current state, a detailed status code, and a human-readable message explaining the result.
code
enum[string]
A machine-readable code detailing the specific reason or result for the transaction status. This provides granular information about success, failure reasons, or other status conditions.
Values: approved, card_expired, duplicate_attempt, exceeded_limit, general_decline, insufficient_bal, invalid_card_code, invalid_card_number, invalid_zip, invalid_address, invalid_account_number, suspicious_activity, too_many_attempts, processing_issue, issue_reading_card, not_supported, general_reject, general_adjustment, payment_stopped
Read-only permissions: "Undocumented"
message
stringRead-only
A human-readable message describing the transaction status and code. This text is suitable for display to users and provides context about the transaction outcome.
value
enum[string]
The current processing status of the transaction, indicating its lifecycle state such as processing, processed, authorized, declined, voided, or rejected.
Values: processing, authorized, processed, declined, rejected, voided, adjusted
rejected_date
string (date)
The date on which this transaction was rejected. This field is automatically set when a transaction is set to rejected.
Read-only permissions: "Undocumented"

For complete transaction field documentation, see the Transaction API Reference.

Next Steps

Enhance dispute handling and payment operations


Prevent Disputes

Manage Payment Methods to validate payment information, implement Payment Method Verification to confirm account ownership, and use clear billing descriptors and customer communication to reduce disputes.

Handle Payment Issues

Respond to Payment Declines for immediate payment failures, manage Bank Rejects for NSF and invalid account rejections, and process Voids and Refunds to cancel and reverse payments.

Monitor Payment Health

Set up Webhook Events for real-time dispute notifications, track metrics with Reporting Overview, and analyze transaction data to identify dispute patterns and improve prevention strategies.


Related articles