Pay
Rejects
Bank Rejects

Bank Rejects

Learn how to monitor and handle bank account payment rejections


Bank account payments can be rejected by the receiving bank several days after they were initially processed. Rejections occur for various reasons including insufficient funds, closed accounts, or invalid account numbers. Understanding how to monitor and respond to bank rejects is essential for maintaining accurate financial records and minimizing losses.

Prerequisites

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


Understanding Bank Rejects


Bank rejects occur when a bank account payment that was initially processed successfully is later returned by the receiving bank. This typically happens 3-5 business days after the payment was processed, once the bank transfer network completes its settlement and verification process.

How Bank Rejects Work

  1. Initial Processing: Payment is processed and marked as processed
  2. Settlement: Payment enters the bank transfer settlement process
  3. Bank Verification: Receiving bank verifies account status and available funds
  4. Rejection: Bank returns the payment with a rejection code
  5. Status Update: Transaction status changes from processed to rejected
  6. Fund Reversal: Original funds are reversed from your processing account

Common Reject Reasons

Bank rejects happen for several reasons:

Insufficient Funds (NSF)

  • Customer's account lacks sufficient balance
  • Most common rejection reason
  • Status code: insufficient_bal

Invalid Account Number

  • Account number is incorrect or doesn't exist
  • Account has been closed
  • Status code: invalid_account_number

Querying Rejected Transactions

Monitor rejected transactions by querying for payments with rejected status.

import payload
pl = payload.Session('secret_key_3bW9...', api_version='v2')
from datetime import datetime, timedelta
 
# Query rejected transactions from the last 30 days
thirty_days_ago = datetime.now() - timedelta(days=30)
date_string = thirty_days_ago.strftime('%Y-%m-%d')
 
rejected_transactions = pl.Transaction.filter_by(
    status={'value': 'rejected'},
    rejected_date='>='+date_string
).all()
 
print(f"Found {len(rejected_transactions)} rejected transactions\n")
 
# Display rejection details
for txn in rejected_transactions:
    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()

This example demonstrates querying rejected transactions:

  1. Filter transactions by status.value='rejected'
  2. Optionally filter by date range using rejected_date
  3. Retrieve transaction details including rejection code
  4. Access status.code to determine specific rejection reason
  5. Use status.message for human-readable rejection details

Query results include all relevant rejection information, allowing you to analyze patterns, track rejection rates, and identify problematic accounts.


Handling Rejection Webhooks

Receive immediate notifications when payments are rejected 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-rejected', methods=['POST'])
def handle_rejection():
    event = request.json
 
    # Verify webhook signature (important for security)
    # signature = request.headers.get('Payload-Signature')
    # pl.Webhook.verify(signature, request.data)
 
    # Acknowledge webhook immediately
    response = jsonify({'received': True})
 
    # Check if this is a rejection event
    if event['trigger'] == 'reject':
        # Get transaction details
        transaction = pl.Transaction.get(event['triggered_on']['id'])
 
        txn_id = transaction.id
        status_code = transaction.status.code
        amount = transaction.amount
 
        print(f"Transaction {txn_id} rejected")
        print(f"Rejection code: {status_code}")
        print(f"Amount: ${amount}")
 
        # Take action based on rejection type
        if status_code == 'insufficient_bal':
            print("Action: NSF rejection - notify customer and schedule retry")
            # notify_customer_nsf(transaction)
            # schedule_retry(txn_id, days=7)
 
        elif status_code == 'invalid_account_number':
            print("Action: Invalid account - disable payment method")
            # disable_payment_method(transaction.sender.method_id)
            # request_new_payment_info(transaction.sender['account_id'])
 
        else:
            print(f"Action: General rejection - review manually")
            # log_rejection(transaction)
 
    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 transaction rejects
  3. Parse webhook payload to extract transaction details
  4. Check status.code to determine reject reason
  5. Take appropriate action based on rejection type
  6. Update internal systems and notify customer

Webhooks provide real-time notification of rejections, enabling immediate response to failed payments.


Responding to Rejected Payments

Take appropriate action when payments are rejected based on the rejection reason.

Handle NSF rejections

When a payment rejects due to insufficient funds, attempt recovery or mark the account for follow-up.

import payload
pl = payload.Session('secret_key_3bW9...', api_version='v2')
from datetime import datetime, timedelta
    # account = pl.Account.get(transaction.sender['account_id'])
 
# Retrieve a rejected transaction
transaction = pl.Transaction.get('txn_rejected123')
 
# Check if rejection is due to insufficient funds
if transaction.status.code == 'insufficient_bal':
    print(f"NSF rejection detected for transaction {transaction.id}")
    print(f"Amount: ${transaction.amount}")
 
    # Notify customer of failed payment
    print("Notifying customer of NSF rejection...")
    # send_email(
    #     to=customer.email,
    #     subject="Payment Failed - Insufficient Funds",
    #     body="Your payment was declined due to insufficient funds..."
    # )
 
    # Schedule retry in 7 days
    retry_date = datetime.now() + timedelta(days=7)
    print(f"Scheduling retry for {retry_date.strftime('%Y-%m-%d')}")
    # schedule_retry(transaction.id, retry_date)
 
    # Update account to track NSF occurrences
    print("Updating account NSF counter...")
    # account.update(nsf_count=account.nsf_count + 1)
 
    # If multiple NSF rejections, take additional action
    # if account.nsf_count >= 3:
    #     print("Multiple NSF rejections - requiring upfront payment")
    #     account.update(payment_status='prepay_required')
    #     notify_account_team(account)
 
    print("NSF handling complete")

This example handles NSF rejections:

  1. Check if rejection code is insufficient_bal
  2. Notify customer of failed payment
  3. Optionally schedule retry after a few days
  4. Update account status to track NSF occurrences
  5. Consider suspending service or requiring alternative payment

Multiple NSF rejections from the same account may indicate chronic insufficient funds. Consider requiring upfront payment or alternative payment methods for these customers.

Handle invalid account rejections

When an account number is invalid or closed, update your records and request new payment information.

import payload
pl = payload.Session('secret_key_3bW9...', api_version='v2')
    # account = pl.Account.get(transaction.sender['account_id'])
 
# Retrieve a rejected transaction
transaction = pl.Transaction.get('txn_rejected123')
 
# Check if rejection is due to invalid account
if transaction.status.code == 'invalid_account_number':
    print(f"Invalid account rejection for transaction {transaction.id}")
    print(f"Amount: ${transaction.amount}")
 
    # Get the payment method that was rejected
    method_id = transaction.sender.method_id
    print(f"Payment method: {method_id}")
 
    # Mark payment method as inactive
    print("Setting payment method to inactive...")
    payment_method = pl.PaymentMethod.get(method_id)
    payment_method.update(
        status='inactive'
    )
 
    # Alternatively, delete the payment method entirely:
    # payment_method.delete()
 
    # Notify customer to update payment information
    print("Notifying customer to update payment info...")
    # send_email(
    #     to=customer.email,
    #     subject="Payment Failed - Please Update Payment Method",
    #     body="Your bank account could not be verified. Please update your payment information..."
    # )
 
    # Remove from autopay if enrolled
    # if account.autopay_enabled:
    #     print("Disabling autopay...")
    #     account.update(autopay_enabled=False)
 
    # Log the invalid account for fraud monitoring
    print("Logging invalid account event...")
    # log_invalid_account(
    #     account_id=transaction.sender['account_id'],
    #     method_id=method_id,
    #     transaction_id=transaction.id
    # )
 
    print("Invalid account handling complete")

This example handles invalid account rejections:

  1. Check if rejection code is invalid_account_number
  2. Mark payment method as invalid in your system
  3. Disable the payment method to prevent future charges
  4. Notify customer to update payment information
  5. Request new bank account details

Invalid account rejections are unlikely to succeed on retry. Remove the payment method from your system and collect new payment information.


Analyzing Rejection Patterns

Track rejection rates and identify patterns to improve payment success rates.

import payload
pl = payload.Session('secret_key_3bW9...', api_version='v2')
from datetime import datetime, timedelta
    # account = pl.Account.get(account_id)
 
from collections import defaultdict
 
# Query rejected transactions from the last 90 days
ninety_days_ago = datetime.now() - timedelta(days=90)
date_string = ninety_days_ago.strftime('%Y-%m-%d')
 
rejected_transactions = pl.Transaction.filter_by(
    status={'value': 'rejected'},
    rejected_date='>='+date_string
).all()
 
print(f"Analyzing {len(rejected_transactions)} rejected transactions\n")
 
# Group rejections by status code
rejections_by_code = defaultdict(int)
rejections_by_method = defaultdict(int)
rejections_by_account = defaultdict(int)
 
for txn in rejected_transactions:
    rejections_by_code[txn.status.code] += 1
    rejections_by_method[txn.sender.method_id] += 1
    rejections_by_account[txn.sender['account_id']] += 1
 
# Display rejection breakdown by code
print("Rejections by Status Code:")
for code, count in sorted(rejections_by_code.items(), key=lambda x: x[1], reverse=True):
    percentage = (count / len(rejected_transactions)) * 100
    print(f"  {code}: {count} ({percentage:.1f}%)")
 
print()
 
# Calculate overall rejection rate
total_transactions = pl.Transaction.filter_by(
    created_at='>='+date_string
).all()
rejection_rate = (len(rejected_transactions) / len(total_transactions)) * 100
print(f"Overall Rejection Rate: {rejection_rate:.2f}%")
 
print()
 
# Identify problematic payment methods (3+ rejections)
print("Payment Methods with Multiple Rejections:")
problem_methods = {k: v for k, v in rejections_by_method.items() if v >= 3}
for method_id, count in sorted(problem_methods.items(), key=lambda x: x[1], reverse=True):
    print(f"  {method_id}: {count} rejections")
    # Consider disabling these methods:
    # payment_method = pl.PaymentMethod.get(method_id)
    # payment_method.update(disabled=True)
 
print()
 
# Identify accounts with multiple rejections
print("Accounts with Multiple Rejections:")
problem_accounts = {k: v for k, v in rejections_by_account.items() if v >= 3}
for account_id, count in sorted(problem_accounts.items(), key=lambda x: x[1], reverse=True):
    print(f"  {account_id}: {count} rejections")
    # Consider flagging these accounts:
    # account.update(risk_level='high')

This example analyzes rejection patterns:

  1. Query all rejected transactions for a date range
  2. Group rejections by status code
  3. Calculate rejection rate by payment method
  4. Identify accounts with multiple rejections
  5. Generate reports for analysis

Regular analysis helps identify problematic payment methods, detect fraud patterns, and improve payment validation strategies.


Testing Bank Rejects

Test rejection handling in test mode before production deployment.

Simulating Rejections

Use test account numbers to trigger specific rejection scenarios:

import payload
 
pl = payload.Session('secret_key_3bW9...', api_version='v2')
 
# Test NSF rejection (account ending in 0001)
print("Testing NSF rejection...")
print(
    f"Payment created: {pl.Transaction.create(
    type='payment',
    amount=100.00,
    sender={
        'account_id': 'acct_test123',
        'method': {
            'type': 'bank_account',
            'bank_account': {
                'routing_number': '110000000',
                'account_number': '0001',
                'account_type': 'checking'
            },
            'account_holder': 'Test User'
        }
    },
    receiver={'account_id': 'acct_merchant123'}
).id}"
)
print(f"Expected rejection: insufficient_bal")
print()
 
# Test invalid account rejection (account ending in 0002)
print("Testing invalid account rejection...")
print(
    f"Payment created: {pl.Transaction.create(
    type='payment',
    amount=100.00,
    sender={
        'account_id': 'acct_test123',
        'method': {
            'type': 'bank_account',
            'bank_account': {
                'routing_number': '110000000',
                'account_number': '0002',
                'account_type': 'checking'
            },
            'account_holder': 'Test User'
        }
    },
    receiver={'account_id': 'acct_merchant123'}
).id}"
)
print(f"Expected rejection: invalid_account_number")
print()
 
# Test general reject (account ending in 0003)
print("Testing general rejection...")
print(
    f"Payment created: {pl.Transaction.create(
    type='payment',
    amount=100.00,
    sender={
        'account_id': 'acct_test123',
        'method': {
            'type': 'bank_account',
            'bank_account': {
                'routing_number': '110000000',
                'account_number': '0003',
                'account_type': 'checking'
            },
            'account_holder': 'Test User'
        }
    },
    receiver={'account_id': 'acct_merchant123'}
).id}"
)
print(f"Expected rejection: general_reject")
print()
 
print("In test mode, rejections occur immediately.")
print("Check transaction status to verify rejection codes.")

In test mode, specific account numbers trigger different rejection codes:

  • Test NSF rejections with account ending in 0001
  • Test invalid account with account ending in 0002
  • Test general reject with account ending in 0003

Verify Handling Logic

Test your rejection handling workflows:

  • Confirm webhook endpoints receive rejection notifications
  • Verify status updates propagate correctly
  • Test customer notification emails
  • Validate retry logic executes properly
  • Ensure UI reflects rejection status

Rejection Codes Reference


Common bank rejection status codes:

insufficient_bal
string
Non-sufficient funds - Account lacks sufficient balance to complete the transaction
invalid_account_number
string
Invalid or closed account - Account number is incorrect or the account has been closed
general_reject
string
General rejection - Account is not authorized for bank transactions or bank blocked the payment for other reasons

Schema Reference


The following fields are relevant for handling bank rejects:

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 bank reject handling and payment operations


Prevent Rejections

Manage Payment Methods to validate and update bank account details, use Payment Method Verification to confirm account ownership, and leverage Plaid for instant account verification.

Handle Payment Issues

Respond to Payment Declines for immediate payment failures, process Voids and Refunds to cancel and reverse payments, and resolve Disputes for customer disputes and chargebacks.

Monitor Payment Health

Set up Webhook Events for real-time rejection notifications, track metrics with Reporting Overview, and analyze transaction data using Build Report Queries.


Related articles