ACH returns
Trigger NACHA return codes with the amount's cents, and handle them asynchronously.
An ACH debit does not fail in its own response. It is accepted, moves
to SUBMITTED, and a return — if there is one — arrives later as a
separate event. Sandbox lets you provoke a specific return code by
choosing the amount's cents.
Routing numbers do not trigger returns. 021000021 is simply a
valid test routing number; any valid test routing number behaves the
same. The cents portion of the amount is the only trigger.
Cents to R-code
| Cents | Code | Meaning |
|---|---|---|
.00 | — | settles normally |
.01 | R01 | Insufficient Funds |
.02 | R02 | Account Closed |
.03 | R03 | No Account |
.04 | R04 | Invalid Account Number |
.07 | R07 | Authorization Revoked |
.08 | R08 | Payment Stopped |
.10 | R10 | Customer Advises Unauthorized |
.16 | R16 | Account Frozen |
.20 | R20 | Non-Transaction Account |
Any amount of $50,000 or more returns R01, whatever its cents.
This map is ACH-only. On the card rail the same cents mean something
else entirely — .01 is "refer to issuer" there. See
test cards.
Returns are asynchronous
The debit response tells you the entry was accepted, not that it was paid:
POST debit -> 200, state: SUBMITTED
(bank processing)
return arrives -> state: RETURNED + transaction.returned webhookIn production a return typically lands 1–3 banking days after submission. Sandbox is faster, but do not build a test that depends on a particular sandbox delay — poll for the state change or wait on the webhook.
Do not treat SUBMITTED as success in your reconciliation, your
fulfilment trigger, or your UI. Funds are not guaranteed until the
return window has passed.
The return webhook
transaction.returned is delivered in the standard envelope:
{
"envelope_version": 1,
"event_id": "0190a1e6-7081-7c9d-8e0f-3a4b5c6d7e8f",
"event_type": "transaction.returned",
"occurred_at": "2026-08-12T14:32:09Z",
"partner_id": "0190a190-2b3c-7d4e-8f50-6a7b8c9d0e1f",
"idempotency_key": "evt_0190a1e6-7081-7c9d-8e0f-3a4b5c6d7e8f",
"data": {
"transactionId": "0190a1e5-1111-7abc-8def-2a3b4c5d6e7f",
"returnCode": "R01",
"returnDescription": "Insufficient funds",
"retryable": true,
"stopsRecurring": false,
"returnedAt": "2026-08-12T14:32:09Z"
}
}The transaction itself moves to state: RETURNED — confirm with
GET /transactions/{transactionId} if you poll instead of consuming
the webhook. (data carries the event's own fields, camelCase; the
R-code is returnCode, not a decline_code.)
Handling rules:
- Branch on
event_type, never on payload shape. idempotency_keyis your dedup key — it isevt_+ theevent_id(which is a bare UUID). Store it and ignore a repeat.envelope_versionis1. Treat an unknown version as unprocessable rather than guessing.
R10 is terminal
R10 means the customer told their bank the debit was unauthorized. Stop retrying that account: NACHA does not permit re-presenting an entry returned as unauthorized. Resolve it with the customer and obtain a fresh authorization before debiting again.
R02, R03, R04, R16 and R20 all say the account itself cannot be debited — closed, missing, mistyped, frozen, or not a transaction account. Retrying the same details returns again; correct them first.
The full ACH contract, including authorization and consent requirements, is in the ACH guide.