Skip to main content
Cresora Commerce
Integration Guides

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

ACHCard
Settlement1–3 business daysNext business day
Return windowUp to 60 days (R10)60–120 days (chargeback)
CostLower per-transactionHigher per-transaction
Failure modeR-code returnsDeclines

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:

🔒NACHA authorization

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.)

curl
# 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"'"
  }'
FieldNotes
merchant_idUUID. Must be a merchant your partner account owns — a foreign id returns 404
vault_tokenSaved bank account to debit (cvt_…), from an hpp_session.completed webhook or the stored-credentials API. The only ACH instrument
use_typeONE_TIME_FUTURE, RECURRING, INSTALLMENT or UNSCHEDULED_COF — your reuse intent for the saved account
ach_sec_codeWEB, 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_versionThe 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_ipThe IPv4/IPv6 literal the payer authorized from, as your checkout saw it — not your server's egress address, and never a hostname
consent_atISO-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
⚠Warning

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.

Python
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,
    },
)
Node.js
// 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:

CodeReasonAction
R01Insufficient fundsRetry after agreement from customer
R02Account closedStop attempting; update payment method
R10Customer advises not authorizedStop immediately; dispute likely
R29Corporate not authorizedRe-obtain written authorization
⚠Warning

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:

CentsCodeReason
.01R01Insufficient Funds
.02R02Account Closed
.03R03No Account / Unable to Locate Account
.04R04Invalid Account Number
.07R07Authorization Revoked by Customer
.08R08Payment Stopped
.10R10Customer Advises Unauthorized
.16R16Account Frozen
.20R20Non-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.

💡Tip

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.