ACH & Bank Accounts
This page describes exactly what happens to an ACH (bank account) payment in HL Hunt, from saving the account to a return weeks later. Everything here is taken from the route handlers and webhook code.
1. Save the bank account
Call POST /v1/payment-methods with type: "bank_account", bank_account.routing_number, bank_account.account_number, optional bank_account.account_type (checking default, or savings), name (the account holder) and optionally customer_id. The response is the stored instrument:
json
{
"id": "PIxxxxxxxxxxxxxxxxxx",
"object": "payment_method",
"type": "bank_account",
"bank_account": { "last4": "3210", "account_type": "checking" },
"name": "Jenny Rosen",
"status": "requires_verification",
"verification": {
"method": "microdeposit",
"message": "A $0.01 deposit has been sent to this bank account. Submit the code from its statement description (starts with FNX) to POST /v1/payment-methods/{id}/verify before charging it."
},
"created": "2026-10-20T15:04:05.000Z"
}There is no bank-name field; name is the account holder and is what the dashboard shows. Lists (GET /v1/payment-methods?customer_id=…) return the same object without verification, and status is verified once the code has been accepted.
2. Microdeposit verification (required since 2026-10-19)
A bank account entered as routing + account number cannot be debited until the account owner proves access to it. The processor sends one $0.01 deposit whose statement description carries a code starting with FNX. Submit it:
text
POST /v1/payment-methods/{id}/verify
{ "verification_code": "FNX7Q2M" }- 200 →
{ id, object: "payment_method", type: "bank_account", status: "verified", bank_account: { last4 } }. The account can now be charged. - 422 →
{ "error": { "type": "invalid_request_error", "code": "incorrect_microdeposit_verification_code", "message": "That code does not match the deposit we sent. 2 tries remaining.", "attempts_remaining": 2, "terminal": false } }. Three code attempts per deposit and a 30-day window. Terminal codes (terminal: true) mean the account can never be verified and a different bank account must be collected:microdeposit_verification_attempts_exceeded,microdeposit_verification_expired,microdeposit_returned,total_microdeposit_attempts_exceeded_account_locked,invalid_bank_account_routing_number. - 400
missing_required_param, 400not_a_bank_account(the id is a card), 404resource_missing.
If the owner verifies on the processor's hosted page instead, HL Hunt notices on the next charge attempt or on its daily sync; you do not have to call /verify yourself in that case.
Charging an unverified account: POST /v1/charges answers 402 { "error": { "type": "card_error", "code": "bank_account_unverified", "message": "...", "param": "source" } }. A permanently failed account answers bank_account_verification_failed; a disabled one bank_account_disabled.
Bank accounts saved before 2026-10-19 were verified at creation and are not affected.
3. The debit: pending, then succeeded
POST /v1/charges with source set to the bank instrument id (or a one-time token from hlhunt.js createBankToken, in which case also send payment_type: "ach") returns 201 with "status": "pending". ACH is not settled synchronously: HL Hunt records the charge as pending and emits charge.created with the create-response shape.
From then on HL Hunt mirrors the processor. When the processor reports the transfer as succeeded (its transfer.updated webhook), the charge moves to succeeded and charge.succeeded is emitted with the full charge object. A cron also re-checks every pending transaction older than five minutes, so a missed processor webhook delays the event by minutes, not days. There is no fixed timer in HL Hunt: the time to succeeded is the ACH network's clearing time, normally a few business days.
If the debit fails outright, the charge becomes failed with failure_code and failure_message and charge.failed is emitted.
Poll with GET /v1/charges/{id} at any time; it returns the same object the webhooks carry.
ACH refunds (POST /v1/refunds on an ACH charge) are also asynchronous: the refund can be returned with "status": "pending".
4. Returns (the bank takes the money back)
A customer's bank can return an ACH debit days or weeks after it succeeded (insufficient funds, closed account, unauthorized debit…). HL Hunt records this the way card networks record a chargeback:
- The charge stays
status: "succeeded"; it is not rewritten tofailed, so nocharge.failedis emitted. - A dispute is created on the charge in state lost, with an id of the form
bankreturn_<reversal id>. It appears on the Disputes page and inGET /api/disputes; it cannot be contested. - The charge's metadata gains a
bankReturnobject, visible onGET /v1/charges/{id}:
json
"metadata": {
"order_id": "1234",
"bankReturn": {
"code": "R01",
"reason": "Bank return R01: insufficient funds",
"reversalId": "TRxxxxxxxxxxxxxxxxxx",
"amount": 4999,
"returnedAt": "2026-10-09T12:00:00.000Z",
"disputeId": "bankreturn_TRxxxxxxxxxxxxxxxxxx"
}
}- Anything the payment had paid for is unpaid again: a subscription billing cycle goes back to failed and the subscription to past due; an invoice goes back to sent.
- The returned amount is taken out of your next settlement.
Return codes HL Hunt names in reason (others are recorded with the bank's own description): R01 insufficient funds · R02 account closed · R03 no account / unable to locate · R04 invalid account number · R05 unauthorized debit to a consumer account · R06 returned at the request of the sending bank · R07 authorization revoked by customer · R08 payment stopped · R09 uncollected funds · R10 customer advises not authorized · R11 debit does not match the authorization · R16 account frozen · R20 not a transaction account · R29 corporate customer advises not authorized.
No webhook is emitted for a bank return today: neither charge.failed nor dispute.opened fires for a bankreturn_ dispute. To detect returns, poll GET /v1/charges/{id} for metadata.bankReturn, or watch the Disputes page. (The dispute webhooks fire only for card chargebacks reported by the processor.)
5. Test mode
Test keys debit the processor's sandbox. HL Hunt has no scripted ACH outcomes of its own (no magic routing or account number that forces a return), and no way to trigger a return from the dashboard. See Testing & Sandbox.