DEV Community

Payout Rail
Payout Rail

Posted on

Building ACH-Aware Code: Detect, Decode, and Route Returns Programmatically

Building ACH-Aware Code: Detect, Decode, and Route Returns Programmatically

Why ACH Return Handling Belongs in Your Integration Layer

When you process payouts at scale, ACH returns aren't edge cases—they're operational facts. The Federal Reserve reports that roughly 0.5–1% of ACH transactions result in returns. If you're moving thousands of dollars daily, that means your code will encounter return codes regularly. The difference between a robust payout system and a broken one is whether your application detects the return, decodes the reason, and routes to the right recovery path automatically.

This article walks through a concrete integration pattern: how to structure your ACH handling so that when a return lands in your webhook, your system knows exactly what to do.

The Return Code Landscape

ACH returns use a three-character code (R-codes) defined by Nacha. Here are the most common ones your code will see:

Code Meaning Recoverable Action
R01 Insufficient funds Yes Retry after 2–5 days or contact recipient
R03 No account/invalid account No Require new bank details
R04 Invalid account number No Require new bank details
R07 Authorization revoked No Contact recipient, get new consent
R10 Customer advises not authorized No Dispute or re-consent
R29 Corporate customer advises not authorized No Dispute or re-consent
R31 Permissible return by originating bank Yes Investigate & retry
R37 Source document presented for payment No Manual review required

The key insight: some returns are recoverable (retry logic applies), while others signal that the recipient's details or consent are broken and need human or recipient interaction.

A Concrete Integration Pattern

Here's how to structure your ACH return handler:


python
from enum import Enum
from datetime import datetime, timedelta

class ReturnAction(Enum):
    RETRY = "retry"
    REQUIRE_NEW_DETAILS = "require_new_details"
    REQUIRE_CONSENT = "require_consent"
    MANUAL_REVIEW = "manual_review"
    MARK_FAILED = "mark_failed"

RETURN_CODE_ROUTING = {
    "R01": {"action": ReturnAction.RETRY, "retry_after_days": 3},
    "R03": {"action": ReturnAction.REQUIRE_NEW_DETAILS},
    "R04": {"action": ReturnAction.REQUIRE_NEW_DETAILS},
    "R07": {"action": ReturnAction.REQUIRE_CONSENT},
    "R10": {"action": ReturnAction.MANUAL_REVIEW},
    "R29": {"action": ReturnAction.MANUAL_REVIEW},
    "R31": {"action": ReturnAction.RETRY, "retry_after_days": 5},
    "R37": {"action": ReturnAction.MANUAL_REVIEW},
}

def handle_ach_return(return_event):
    """
    Receives webhook from payment processor with return details.
    Decodes the return code and routes to appropriate handler.
    """
    return_code = return_event["return_code"]  # e.g., "R01"
    payout_id = return_event["payout_id"]
    recipient_id = return_event["recipient_id"]

    if return_code not in RETURN_CODE_ROUTING:
        # Unknown code—escalate to manual review
        log_and_escalate(payout_id, return_code)
        return

    routing = RETURN_CODE_ROUTING[return_code]
    action = routing["action"]

    if action == ReturnAction.RETRY:
        retry_after = routing.get("retry_after_days", 2)
        scheduled_retry = datetime.utcnow() + timedelta(days=retry_after)
        db.payouts.update_one(
            {"_id": payout_id},
            {
                "$set": {
                    "status": "retry_scheduled",
                    "retry_at": scheduled_retry,
                    "return_code": return_code,
                    "attempt_count": db.payouts.find_one({"_id": payout_id})["attempt_count"] + 1
                }
            }
        )
        queue_retry_job(payout_id, scheduled_retry)

    elif action == ReturnAction.REQUIRE_NEW_DETAILS:
        db.payouts.update_one(
            {"_id": payout_id},
            {"$set": {"status": "blocked_invalid_details", "return_code": return_code}}
        )
        notify_recipient(recipient_id, "Please update your bank account details")

    elif action == ReturnAction.REQUIRE_CONSENT:
        db.

---

*Decoding ACH return codes programmatically? The [ACH Return Codes API](https://rapidapi.com/payoutrail-ach-return-codes/api/ach-return-codes-api?utm_source=nichestream&utm_medium=devto&utm_campaign=payoutrail-ach-returns) returns the full Nacha R01–R85 set with plain-language descriptions and handling guidance.*
Enter fullscreen mode Exit fullscreen mode

Top comments (0)