PlutoPay Docs
API Reference Dashboard ↗

#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

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_session is 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 (then dispute.closed with 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.