ACH Guide
Accept bank account (ACH) payments and handle NACHA returns correctly.
ACH (Automated Clearing House) payments let you debit a customer's US bank account directly. ACH is commonly used for high-value transactions, recurring billing, and healthcare payments.
ACH vs. card payments
| ACH | Card | |
|---|---|---|
| Settlement | 1–3 business days | Next business day |
| Return window | Up to 60 days (R10) | 60–120 days (chargeback) |
| Cost | Lower per-transaction | Higher per-transaction |
| Failure mode | R-code returns | Declines |
Authorization language (NACHA requirement)
Before debiting a bank account, you must obtain the customer's authorization. NACHA requires specific language depending on the payment type:
You must display NACHA-compliant authorization language before collecting bank account details. See ACH Authorization Language → for the exact required text.
Create an ACH payment
The request is one flat JSON object — there is no nested bank_account block. The only ACH instrument is a saved account: pass its vault_token (cvt_…) plus the use_type that declares your reuse intent. Raw routing_number / account_number are rejected as unknown fields. A first-time payer saves the account through a hosted payment page — the hpp_session.completed webhook carries the token — or through the stored-credentials API. The three consent_* fields are your NACHA authorization proof, so the API will not accept a debit without them. On this request you fill all three, from your own checkout — Cresora resolves the registered authorization text from the version you name, records the evidence, and does not default any of the three for you here. (A first-time payer's hosted-page save is different: there the consent evidence comes from the page itself.)
# The instant the payer accepted the authorization. Computed here so the
# snippet still runs next month; in production send the instant you actually
# captured — it is your NACHA evidence, not a timestamp of convenience.
CONSENT_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
curl -X POST https://api.cresoracommerce.ai/api/v1/transactions \
-H "Authorization: Bearer csk_ab12cd34_xxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: idem_$(uuidgen)" \
-d '{
"type": "ACH_DEBIT",
"merchant_id": "0190a1b2-c3d4-7e5f-8901-23456789abcd",
"amount": "500.00",
"currency": "USD",
"vault_token": "cvt_0190d8a1-2b3c-7d4e-9f12-7a8b9c0d1e2f",
"use_type": "ONE_TIME_FUTURE",
"ach_sec_code": "WEB",
"consent_text_version": "v1.0",
"consent_ip": "203.0.113.42",
"consent_at": "'"$CONSENT_AT"'"
}'| Field | Notes |
|---|---|
merchant_id | UUID. Must be a merchant your partner account owns — a foreign id returns 404 |
vault_token | Saved bank account to debit (cvt_…), from an hpp_session.completed webhook or the stored-credentials API. The only ACH instrument |
use_type | ONE_TIME_FUTURE, RECURRING, INSTALLMENT or UNSCHEDULED_COF — your reuse intent for the saved account |
ach_sec_code | WEB, PPD, CCD or TEL. Pick the one matching how you obtained authorization. Checked against use_type: WEB is a consumer-initiated internet authorization and cannot accompany RECURRING / INSTALLMENT / UNSCHEDULED_COF — 422 ach_sec_code_mismatch |
consent_text_version | The Cresora-registered authorization-text version you displayed. Show that text verbatim — this field attests which registered version the payer saw, not your own wording. Ids come from the ACH Authorization Language catalog and match exactly, case included (v1.0 today); an unknown id is 400 ach_consent_version_unknown |
consent_ip | The IPv4/IPv6 literal the payer authorized from, as your checkout saw it — not your server's egress address, and never a hostname |
consent_at | ISO-8601 UTC instant the payer authorized, with seconds and a trailing Z as in the examples — offset forms such as +02:00 are rejected with 400. Never in the future beyond a small clock-skew tolerance. A 7-day recency window applies to payer-initiated debits only — see the note below |
The recency window depends on who initiated the debit.
A payer-initiated debit — use_type: ONE_TIME_FUTURE — attests a fresh online affirmation, so consent_at must fall within a 7-day window; older is rejected with 400. (The retention obligation is NACHA OR1 §2.5; the 7 days quantifying "reasonably recent" is Cresora policy.)
A merchant-initiated debit — use_type: RECURRING, INSTALLMENT or UNSCHEDULED_COF — stands on the standing authorization you already hold, which is legitimately older than 7 days. The window is not applied there.
Either way the instant must be the real capture time: it is recorded as the entry's authorization evidence and produced in a bank-return dispute (R10). Never in the future, on any debit — a small clock-skew tolerance absorbs clock drift between your servers and Cresora's, nothing more.
Separately, the merchant must be opted into the ach_payments modality. Without it the request returns 422 ach_not_enabled_for_merchant, which is not retryable until the modality is granted.
import requests, uuid
from datetime import datetime, timezone
# See the curl note: send the instant you actually captured the authorization.
consent_captured_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
requests.post(
"https://api.cresoracommerce.ai/api/v1/transactions",
headers={
"Authorization": "Bearer csk_ab12cd34_xxxxxxxxxxxxxxxxxxxxxxxx",
"Idempotency-Key": f"idem_{uuid.uuid4()}",
},
json={
"type": "ACH_DEBIT",
"merchant_id": "0190a1b2-c3d4-7e5f-8901-23456789abcd",
"amount": "500.00",
"currency": "USD",
"vault_token": "cvt_0190d8a1-2b3c-7d4e-9f12-7a8b9c0d1e2f",
"use_type": "ONE_TIME_FUTURE",
"ach_sec_code": "WEB",
"consent_text_version": "v1.0",
"consent_ip": "203.0.113.42",
"consent_at": consent_captured_at,
},
)// See the curl note: send the instant you actually captured the authorization.
const consentCapturedAt = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
await fetch("https://api.cresoracommerce.ai/api/v1/transactions", {
method: "POST",
headers: {
Authorization: "Bearer csk_ab12cd34_xxxxxxxxxxxxxxxxxxxxxxxx",
"Content-Type": "application/json",
"Idempotency-Key": "idem_" + crypto.randomUUID(),
},
body: JSON.stringify({
type: "ACH_DEBIT",
merchant_id: "0190a1b2-c3d4-7e5f-8901-23456789abcd",
amount: "500.00",
currency: "USD",
vault_token: "cvt_0190d8a1-2b3c-7d4e-9f12-7a8b9c0d1e2f",
use_type: "ONE_TIME_FUTURE",
ach_sec_code: "WEB",
consent_text_version: "v1.0",
consent_ip: "203.0.113.42",
consent_at: consentCapturedAt,
}),
});ACH returns
ACH returns occur when the receiving bank rejects the debit. Cresora fires a transaction.returned webhook. Every delivery arrives in the standard envelope — note the field is event_type, not event, and the business fields live inside data:
{
"envelope_version": 1,
"event_id": "0190d8a1-2b3c-7d4e-9f12-7a8b9c0d1e2f",
"event_type": "transaction.returned",
"occurred_at": "2026-05-23T14:32:11.847Z",
"partner_id": "0190a1b2-c3d4-7e5f-8901-23456789abcd",
"idempotency_key": "evt_0190d8a1-2b3c-7d4e-9f12-7a8b9c0d1e2f",
"data": { }
}Use idempotency_key (or event_id, which it embeds) as your deduplication key — both are stable across retries. data carries the returned transaction's identifier, the R-code and its reason; read the transaction back with GET /transactions/{transactionId} if you need its full current state.
Common return codes:
| Code | Reason | Action |
|---|---|---|
R01 | Insufficient funds | Retry after agreement from customer |
R02 | Account closed | Stop attempting; update payment method |
R10 | Customer advises not authorized | Stop immediately; dispute likely |
R29 | Corporate not authorized | Re-obtain written authorization |
R10 (customer advises not authorized) is a serious signal. Stop all retries immediately and investigate. Continued debiting after an R10 violates NACHA rules.
Triggering returns in the sandbox
Returns are not triggered by the routing number. In the sandbox the cents portion of the amount selects a canonical NACHA return, so 500.01 comes back R01 and 500.10 comes back R10:
| Cents | Code | Reason |
|---|---|---|
.01 | R01 | Insufficient Funds |
.02 | R02 | Account Closed |
.03 | R03 | No Account / Unable to Locate Account |
.04 | R04 | Invalid Account Number |
.07 | R07 | Authorization Revoked by Customer |
.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 also returns R01. A cents value not in this table settles normally, so use .00 for your approval path.
This is sandbox-only behaviour. In production, returns come from the receiving bank on its own schedule — up to 60 days later for R10 — so your handler must treat a return as an asynchronous event, never as a response to the debit.