#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 — Stripe charges ~0.8% (capped at $5) vs card's 2.9%; PlutoPay's 3% platform fee still applies.
- 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.
#Mandate (NACHA authorization)
US regulation (NACHA) requires the customer to authorize the debit. In the recommended client-side flow, Stripe.js collects the mandate during bank linking. If you confirm server-side (passing a verified payment_method), supply a mandate object (ip_address + user_agent of the customer's acceptance) and 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.
#Fees & refunds
| Component | Rate |
|---|---|
| Stripe ACH processing | ~0.8%, capped at $5 |
| PlutoPay platform fee | 3% of the charge (same as card) |
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. |