#ACH bank payments
Accept payments directly from a US bank account (ACH Direct Debit). ACH is fundamentally different from cards:
- Asynchronous — settles over ~4 business days, not seconds.
- Verified up front — the customer links + verifies their bank via Financial Connections (instant).
- Cheaper — PlutoPay's fee on ACH is 1%, capped at $7.00, against 3% on a card. On a $2,520 payment that is $7.00 instead of $75.60.
- Late returns — a payment can settle, then be returned days later. Stripe raises those as disputes.
#The two-step flow
The customer links their bank client-side (so bank credentials + the mandate never touch your server), then you create the payment.
1. Client (browser/app) → Stripe.js collectBankAccountForPayment()
├─ Financial Connections: customer picks their bank
├─ instant verification
└─ NACHA mandate acceptance
→ a verified us_bank_account payment_method
2. Your server → POST /v1/transactions (payment_method_type=ach)
→ PaymentIntent in `processing`
→ settles to `succeeded` (~4 business days) by webhook
#Step 1 — Link the bank (client-side)
Bank linking + mandate collection happen in the browser with Stripe.js, using a client_secret from the ACH payment you create in step 2. This keeps bank credentials and the mandate on Stripe's hosted UI — never on your server.
const { paymentIntent, error } = await stripe.collectBankAccountForPayment({
clientSecret,
params: {
payment_method_type: 'us_bank_account',
payment_method_data: {
billing_details: { name: 'Jane Doe', email: 'jane@example.com' },
},
},
expand: ['payment_method'],
});
// The customer picks their bank, verifies instantly, and accepts the ACH
// mandate — all in Stripe's UI. Then confirm:
await stripe.confirmUsBankAccountPayment(clientSecret);
#Step 2 — Create the ACH payment
Server-side, create the payment with payment_method_type: "ach". PlutoPay configures the PaymentIntent for us_bank_account + Financial Connections instant verification and returns a client_secret for step 1.
| Field | Type | Required | Description |
|---|---|---|---|
amount |
integer | ✔ | Cents. Minimum 50. |
payment_method_type |
string | ✔ | ach. |
currency |
string | Defaults to your account currency (usd). |
|
customer_id |
uuid | Recommended — associates the bank for reuse. | |
payment_method |
string | A pre-verified us_bank_account PM (server-side confirm flow). |
|
mandate |
object | { ip_address, user_agent } for a server-side mandate (only with payment_method). |
curl https://plutopayus.com/api/v1/transactions \
-H "Authorization: Bearer sk_test_your_key_here" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: ach_order_2001" \
-d '{
"amount": 12000,
"currency": "usd",
"payment_method_type": "ach",
"description": "Invoice #2001",
"customer_id": "019c8044-2ab5-7396-b5af-216ef14dc582"
}'
#Response 201
{
"data": {
"id": "019f48f7-f24a-72e3-8a0a-f8400fa13667",
"reference": "txn_HZRiT0thzZ9AJ0qCGVgS",
"type": "payment",
"status": "processing",
"amount": 12000,
"currency": "usd",
"payment_method_type": "ach",
"is_test": true,
"created_at": "2026-07-05T14:12:03+00:00"
},
"client_secret": "pi_3T2..._secret_9Yx..."
}
The payment stays processing until it settles.
#Charging a saved bank account
Once a bank account is saved (see Saved payment methods), charge
it by sending off_session: true and omitting mandate entirely:
curl https://plutopayus.com/api/v1/transactions \
-H "Authorization: Bearer sk_live_your_key_here" \
-H "Idempotency-Key: invoice_2044" \
-d amount=12000 \
-d payment_method_type=ach \
-d customer_id=019f... \
-d payment_method=pm_... \
-d off_session=true
off_sessionis not a "customer is absent" flag here. Stripe's own API reference describes it that way, but for bank debits it is the parameter that makes Stripe reuse the mandate already stored against that customer and payment method. Send it even when the customer is on the page. Leave it off and Stripe mints a new mandate, which emails your customer a direct-debit authorization they didn't expect.
#Mandate (NACHA authorization)
US regulation (NACHA) requires the customer to authorize the debit, and the record of how they authorized it is part of what makes the debit lawful. In the recommended client-side flow, Stripe.js collects the mandate during bank linking and you send nothing.
If you confirm server-side for the first time, tell us how authorization was obtained:
| Situation | Send |
|---|---|
| The customer accepted in a browser | mandate[ip_address] and mandate[user_agent] |
| You obtained authorization in writing or by phone | mandate[type]=offline, optionally mandate[accepted_at] (Unix timestamp) |
| The mandate was already captured | off_session=true, and no mandate at all |
| You want to reuse one specific mandate | mandate=mandate_… (the id, as a string) |
If you send none of these, the request is rejected with a message naming the options. That is deliberate. We will not record an authorization on your behalf that we have no evidence of — the type is a factual claim about how the customer agreed, not a formatting choice, and a plausible-looking default would be a fabricated compliance record.
Show authorization text such as:
By clicking [accept], you authorize [MERCHANT] to debit the bank account specified above for any amount owed for charges arising from your use of [MERCHANT]'s services … until this authorization is revoked. You may amend or cancel this authorization at any time with 30 days notice.
#Async lifecycle
| State | PlutoPay status | Webhook | Counts in Gross? |
|---|---|---|---|
| Created | processing |
payment.created |
No |
| Submitted, awaiting bank | processing |
payment.processing |
No |
| Settled | succeeded |
payment.succeeded |
Yes |
| Early failure | failed |
payment.failed |
No |
| Late return | succeeded + dispute |
dispute.created |
Yes (deducted from Net) |
While processing, an ACH charge is excluded from Gross Volume — it only counts once it settles to succeeded.
#Late returns are disputes
An ACH payment can settle (succeeded), then be returned by the bank days later (up to 60 days for personal accounts). Stripe raises these as disputes, not failures.
- Webhook:
dispute.created(thendispute.closedwith the outcome). - PlutoPay records the disputed amount and deducts it from Net Volume, while the charge stays in Gross (it did succeed) — mirroring how refunds work.
- Won → the amount returns to Net. Lost → the deduction stays; a non-refundable ~$15 dispute fee applies.
Handle dispute.created / dispute.closed in your webhook receiver to keep your books in sync.
#Merchant terms
ACH carries a return liability the card rails do not, so it is the one rail tied to the
PlutoPay Merchant Terms. When the terms are updated, every owner and admin is
emailed the new text with a 30-day window; the dashboard shows the same. If the window
passes unaccepted, live ACH debits and saved-ACH setups return 403 terms_acceptance_required until an owner or admin accepts — see
Errors. Cards and terminal keep working, and test mode is never
gated, so an integration in progress is not blocked by paperwork.
#Fees & refunds
| Component | Rate |
|---|---|
| Stripe ACH processing | ~0.8%, capped at $5 |
| PlutoPay platform fee | 1% of the charge, capped at $7.00 |
Refund a settled ACH payment exactly like a card (POST /v1/transactions/{transaction}/refunds). ACH refunds carry reverse_transfer + refund_application_fee automatically, so the refund cost lands on the correct account and the platform fee is returned.
#Testing
Use test-mode keys and Stripe's ACH test payment methods:
| Test payment method | Behavior |
|---|---|
pm_usBankAccount_success |
Settles to succeeded. |
pm_usBankAccount_processing |
Stays in processing indefinitely. |
pm_usBankAccount_insufficientFunds |
Early failure → failed. |
pm_usBankAccount_accountClosed |
Early failure → failed. |
pm_usBankAccount_dispute |
Succeeds, then a late return → dispute.created. |