Prompt
How do I set up ACH payments API workflows for recurring invoicing and return handling?
Latest observation
To set up ACH payments API workflows for recurring invoicing and return handling, think in terms of two parallel flows:
- Forward flow: create invoice → collect bank account → authorize debit → schedule/charge → mark paid
- Exception flow: if the ACH debit returns, detect it → classify reason → notify customer → retry or fail invoice → reconcile ledger
Here’s a practical blueprint.
1) Core ACH workflow for recurring invoices
A. Collect and store customer authorization
Before you can debit ACH, you generally need:
- customer bank account details
- explicit authorization for recurring debits
- signed mandate/authorization language
- proof of authorization stored in your system
Typical setup:
- Customer enters routing + account number via your hosted bank-link form or API.
- You verify the bank account if required.
- You store a tokenized payment method reference, not raw bank data.
- You store mandate metadata:
- customer name
- authorization timestamp
- authorization text/version
- recurring billing terms
- IP/device metadata if applicable
B. Create the customer and payment method objects
In your billing system, keep entities like:
customerbank_account_payment_methodmandateinvoicepayment_attemptach_returnretry_policy
C. Generate invoices on schedule
For recurring billing:
- create invoice from subscription schedule
- calculate amount, taxes, discounts, and due date
- decide whether to auto-debit on invoice finalization or on due date
Common patterns:
- Auto-charge immediately on invoice creation
- Net terms: invoice is open first, then debit on due date
- Pre-notification: send advance notice before debit, if required
D. Initiate ACH debit
Your API call should generally:
- reference the invoice
- reference the stored bank payment method
- include amount, currency, descriptor, and effective date
- mark it as “pending” since ACH is asynchronous
Example logical flow:
POST /invoicesPOST /payments/ach_debitswithinvoice_id- Payment enters
pending_submission - Once cleared, mark invoice
paid - If returned, mark payment
returnedand invoiceopenorpast_due
E. Handle asynchronous settlement
ACH is not instant. You need webhooks or event polling for:
- submitted
- accepted
- settled
- returned
- reversed
- canceled
Do not mark the invoice paid until settlement confirmation unless your business is willing to absorb return risk.
2) Recommended recurring billing workflow
A strong recurring ACH workflow usually looks like this:
Step 1: Subscription renews
- Scheduler creates invoice for period.
- Invoice status:
draft
Step 2: Finalize invoice
- Freeze amounts.
- Send invoice or reminder.
- If authorization allows, create ACH debit automatically.
Step 3: Submit debit
- Create ACH payment attempt.
- Status:
pending
Step 4: Receive payment outcome
- If successful: invoice
paid - If failure/return: invoice
open,past_due, orcollection_pending
Step 5: Retry or escalate
- Retry based on reason code and policy.
- Notify customer.
- Optionally suspend service after X days or failed attempts.
3) Return handling workflow
ACH returns are a normal part of bank debit processing. You need a robust return handler.
A. Listen for return events
Your payment provider will usually send webhooks like:
ach.return.createdpayment.returnedpayment.failedchargeback.createdor equivalent
Store:
- return code
- return date
- original payment ID
- bank trace number if available
- settlement status
B. Classify the return reason
Common ACH return categories:
- R01: Insufficient funds
- R02: Account closed
- R03: No account/unable to locate
- R04: Invalid account number
- R05: Unauthorized debit to consumer account
- R06: Returned per ODFI request
- R07: Authorization revoked
- R08: Payment stopped
- R09: Uncollected funds
- R10: Customer advises not authorized
- R20: Non-transaction account
- R29: Corporate customer advises not authorized
- R31: Permissible return entry
Your response should depend on code:
- NSF / temporary issues: retry after delay
- Invalid/closed account: require new bank details
- Unauthorized/revoked: stop debits immediately and investigate
- Stop payment: contact customer and pause recurrence
C. Update invoice and ledger
When a return occurs:
- reverse the payment application against the invoice
- reopen invoice balance
- post accounting entries for returned item fees if applicable
- adjust revenue recognition if you had already recognized it
D. Notify customer and internal teams
Send:
- customer email/SMS about failed debit
- support ticket if unauthorized or suspicious
- finance notification if return fee or multiple returns
E. Retry policy
For ACH returns, do not blindly retry. Common best practice:
- retry only for NSF-related returns
- wait several business days
- limit number of retries
- change retry timing to align with payday patterns if allowed
Avoid retries for:
- revoked authorization
- closed/invalid accounts
- stop payment
- unauthorized returns
4) Event-driven architecture
A clean implementation is event-driven:
Event types
invoice.createdinvoice.finalizedach.debit.submittedach.debit.settledach.debit.returnedcustomer.mandate.revoked
Services
- Billing service: invoices/subscriptions
- Payments service: ACH debits and bank tokens
- Webhook handler: idempotent processing of provider events
- Ledger service: accounting entries
- Notification service: customer emails/SMS
- Risk/compliance service: returns, unauthorized patterns, AML/KYC rules
Key design rules
- Use idempotency keys for debit creation
- Store provider event IDs to avoid double-processing
- Make webhook handlers retry-safe
- Keep a full audit log for mandate and debit actions
5) Example workflow states
Invoice states
draftopenpending_paymentpaidpast_duevoidcollection
ACH payment states
createdpending_submissionsubmittedsettledreturnedreversedcanceled
Mandate states
activerevokedexpiredsuperseded
6) Suggested API endpoints
If you’re designing your own system, endpoints might look like:
POST /customersPOST /customers/{id}/bank-accountsPOST /mandatesPOST /subscriptionsPOST /invoicesPOST /invoices/{id}/finalizePOST /ach-paymentsGET /ach-payments/{id}POST /webhooks/achPOST /returns/{return_id}/resolvePOST /payments/{id}/retry
7) Important compliance considerations
ACH is regulated. Make sure you handle:
- customer authorization and retention
- NACHA rules and return timelines
- prenote/pre-notification rules if relevant
- SEC code selection for consumer vs corporate debits
- handling of unauthorized returns
- data security for bank account info
- audit trails and access controls
If you operate in the US, NACHA compliance is especially important.
8) Practical retry strategy
A simple policy:
- R01 / R09: retry once after 3–5 business days
- R02 / R03 / R04: do not retry; request updated account
- R05 / R07 / R10 / R29: do not retry; mark as unauthorized
- R08: do not retry until customer clears stop payment
- Maximum retries per invoice: 1–2
- Maximum retries per customer per month: configurable
9) Minimal implementation pattern
On invoice due date
- Create ACH debit
- Store
payment_attempt - Send to provider
- Wait for webhook
On webhook success
- Mark payment settled
- Mark invoice paid
- Send receipt
On webhook return
- Record ACH return code
- Reopen invoice balance
- Notify customer
- Decide retry/stop based on code
10) Example pseudocode
def charge_recurring_invoice(invoice_id):
invoice = get_invoice(invoice_id)
if invoice.status not in ["open", "due"]:
return
payment_method = get_default_ach_method(invoice.customer_id)
if not payment_method or not payment_method.mandate_active:
raise Exception("No active ACH mandate")
payment = create_ach_payment(
invoice_id=invoice.id,
payment_method_id=payment_method.id,
amount=invoice.amount_due,
idempotency_key=f"invoice-{invoice.id}"
)
submit_to_provider(payment)
update_invoice_status(invoice.id, "pending_payment")
def handle_ach_webhook(event):
if already_processed(event.id):
return
if event.type == "ach.settled":
mark_payment_settled(event.payment_id)
mark_invoice_paid(event.invoice_id)
elif event.type == "ach.returned":
record_return(event.payment_id, event.return_code)
reopen_invoice(event.invoice_id)
notify_customer_of_return(event.invoice_id, event.return_code)
maybe_retry(event.payment_id, event.return_code)
store_event(event.id)
11) Best practices checklist
- Collect and store explicit ACH authorization
- Tokenize bank account data
- Use idempotency keys
- Treat ACH as asynchronous
- Use webhooks for settlement/return events
- Implement return-code-specific logic
- Keep audit logs and mandate records
- Build retry rules conservatively
- Separate invoice, payment, and ledger states
- Monitor return rates and unauthorized debits
If you want, I can also give you one of these:
- a sample database schema for ACH invoicing,
- a step-by-step Stripe/GoCardless-style workflow,
- or a Node.js / Python webhook handler example for ACH returns.