ACH Payments
Accept bank transfers with lower fees via CoinFlow's ACH infrastructure
Why ACH?
ACH payments have significantly lower fees than card payments (typically 0.8% vs 2.9%), making them ideal for high-value transactions, round-up micro-payments, and recurring billing. Hedge Payments uses CoinFlow as the underlying ACH provider with optional USDC instant settlement.
Fee Comparison
| Payment Method | Fee | Settlement |
|---|---|---|
| Credit/Debit Cards | 2.9% + $0.30 | Instant |
| ACH + USDC Settlement | 0.8% (max $5) | Instant (on Solana) |
| ACH + USD Settlement | 0.8% (max $5) | 3 business days |
How ACH Works with Hedge
The ACH flow has two phases: a one-time bank account linking step, and then per-transaction ACH debits.
Create Customer
POST /api/customer -- Register the consumer with CoinFlow
Get Session Key
GET /api/auth/session-key -- Generate a 24-hour scoped token
Link Bank Account
POST /api/customer/v2/bankAccount -- Tokenize the consumer's bank (one-time)
Submit ACH Checkout
POST /api/checkout/ach/{merchantId} -- Debit the consumer's bank account
Handle Webhooks
ACH Initiated > ACH Batched > Settled (or ACH Returned / ACH Failed)
Step 1: Get a Session Key
Generate a session key scoped to a specific consumer. Session keys are valid for 24 hours.
curl -X GET https://api-sandbox.coinflow.cash/api/auth/session-key \
-H "Authorization: YOUR_COINFLOW_API_KEY" \
-H "x-coinflow-auth-user-id: consumer-123" \
-H "Content-Type: application/json"Response:
{
"key": "eyJhbGciOiJIUzI1NiIs..." // Note: field is "key", not "sessionKey"
}Important
The response field is key, not sessionKey. Session keys expire after 24 hours and must be regenerated.
Step 2: Link Bank Account
Link a consumer's bank account for ACH debits. This is a one-time step per consumer. All fields including name and address are required by the CoinFlow API.
curl -X POST https://api-sandbox.coinflow.cash/api/customer/v2/bankAccount \
-H "x-coinflow-auth-wallet: consumer-123" \
-H "Content-Type: application/json" \
-d '{
"type": "checking",
"email": "user@example.com",
"alias": "My Checking",
"firstName": "Jane",
"lastName": "Doe",
"routingNumber": "110000000",
"account_number": "000123456789",
"address1": "123 Main St",
"city": "Austin",
"state": "TX",
"zip": "78701"
}'Response: 204 No Content on success.
Required Fields
| Parameter | Type | Description |
|---|---|---|
type | string | "checking" or "savings" |
email | string | Consumer's email address |
firstName | string | Account holder first name |
lastName | string | Account holder last name |
routingNumber | string | 9-digit ABA routing number |
account_number | string | Bank account number |
address1 | string | Street address |
city | string | City |
state | string | 2-letter state code |
zip | string | 5-digit ZIP code |
Header note
Bank account linking uses x-coinflow-auth-wallet as the auth header (not Authorization). This is the consumer's external ID.
Step 3: Submit ACH Checkout
Once a bank account is linked, submit an ACH debit to pull funds from the consumer's bank account.
curl -X POST https://api-sandbox.coinflow.cash/api/checkout/ach/{merchantId} \
-H "x-coinflow-auth-session-key: SESSION_KEY_HERE" \
-H "Content-Type: application/json" \
-d '{
"subtotal": { "cents": 63 },
"token": "BANK_ACCOUNT_TOKEN",
"settlementType": "USDC",
"webhookInfo": {
"roundUpId": "txn_abc123",
"merchantId": "your-merchant-id"
}
}'Response:
{
"paymentId": "pay_xyz789" // Note: field is "paymentId", not "id"
}Checkout Fields
| Parameter | Type | Description |
|---|---|---|
subtotal | object | Object with "cents" (integer) and optional "currency" |
token | string | Bank account token from the linking step |
settlementType | string | "USDC" (instant) or "Credits" or "Bank" (3 business days) |
webhookInfo | object | Arbitrary JSON passed through to webhook payloads |
jwtToken | string | Server-signed JWT to prevent client-side amount tampering |
ACH Payment Status Flow
ACH Initiated
Payment submitted to bank -- immediate
ACH Batched
Bank accepted and processing -- same day or next business day
Settled
Funds delivered to merchant -- instant (USDC) or 1-3 business days (USD)
Failure states
ACH Returned -- Bank returned the funds (2-5 business days after batch). Reasons: insufficient funds, account closed, unauthorized debit.
ACH Failed -- Bank rejected the debit (1-2 business days). Invalid account or routing number.
Handle ACH Webhooks
CoinFlow sends webhook events as the ACH payment progresses. Validate the Authorization header against your Webhook Validation Key.
{
"eventType": "ACH Batched",
"category": "ach",
"created": "2026-03-24T14:30:00Z",
"data": {
"id": "pay_xyz789",
"webhookInfo": { "roundUpId": "txn_abc123" },
"subtotal": { "cents": 63, "currency": "USD" },
"fees": { "cents": 0, "currency": "USD" },
"total": { "cents": 63, "currency": "USD" },
"merchantId": "your-merchant-id",
"customerId": "consumer-123"
}
}app.post('/webhooks/coinflow', async (req, res) => {
// Validate webhook authenticity
const webhookKey = req.headers['authorization']
if (webhookKey !== process.env.COINFLOW_WEBHOOK_KEY) {
return res.status(401).send('Unauthorized')
}
// Deduplicate (CoinFlow may send duplicates)
const eventId = req.body.data?.id
if (await isAlreadyProcessed(eventId)) {
return res.status(200).json({ status: 'duplicate' })
}
const { eventType, data } = req.body
switch (eventType) {
case 'ACH Initiated':
await updateTransaction(data.webhookInfo.roundUpId, 'INITIATED')
break
case 'ACH Batched':
await updateTransaction(data.webhookInfo.roundUpId, 'PROCESSING')
break
case 'Settled':
await updateTransaction(data.webhookInfo.roundUpId, 'SETTLED')
break
case 'ACH Returned':
await handleReturn(data)
break
case 'ACH Failed':
await handleFailure(data)
break
}
// Must respond within 5 seconds
res.status(200).json({ status: 'accepted' })
})Webhook Retry Policy
- CoinFlow retries until your server returns
200 OK, or 36 hours elapse - Your server must respond within 5 seconds or the request times out
- Webhooks are not exactly-once -- always deduplicate on
data.id
Merchant-Initiated Transactions (Recurring)
After a consumer's first ACH checkout, subsequent debits can be initiated server-side using the original paymentId. This is ideal for recurring round-ups where the consumer doesn't need to re-authenticate each time.
curl -X POST https://api-sandbox.coinflow.cash/api/checkout/merchant-initiated-transaction \
-H "x-coinflow-auth-user-id: consumer-123" \
-H "Content-Type: application/json" \
-d '{
"subtotal": { "cents": 47, "currency": "USD" },
"originalPaymentId": "pay_xyz789",
"settlementType": "USDC"
}'Rate limit
5 transactions per originalPaymentId per 90-second window. Exceeding this returns 429 Too Many Requests.
Sandbox Testing
Sandbox base URL
https://api-sandbox.coinflow.cash
In sandbox mode, CoinFlow accepts any valid-format routing and account numbers. No real bank verification occurs.
| Scenario | Routing Number | Account Number | Result |
|---|---|---|---|
| Success | 110000000 | 000123456789 | ACH Settled |
| Any valid format | Any 9 digits | Any digits | Works in sandbox |
Plaid Sandbox (if using SDK)
Username
user_goodPassword
pass_goodError Handling
| HTTP Code | Meaning | Action |
|---|---|---|
| 423 | Customer blocked | 5 failed attempts -- contact support |
| 428 | Re-verification required | Consumer must re-verify bank account |
| 429 | Rate limited | MIT: max 5 per originalPaymentId per 90s |
| 451 | KYC verification required | Consumer must complete identity verification |
Hedge, Inc. · Payments for the people who use them