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 #98C379]">"Authorization: YOUR_COINFLOW_API_KEY" \
-H #98C379]">"x-coinflow-auth-user-id: consumer-123" \
-H #98C379]">"Content-Type: application/json"Response:
{
#98C379]">"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 #98C379]">"x-coinflow-auth-wallet: consumer-123" \
-H #98C379]">"Content-Type: application/json" \
-d '{
#98C379]">"type": "checking",
#98C379]">"email": "user@example.com",
#98C379]">"alias": "My Checking",
#98C379]">"firstName": "Jane",
#98C379]">"lastName": "Doe",
#98C379]">"routingNumber": "110000000",
#98C379]">"account_number": "000123456789",
#98C379]">"address1": "123 Main St",
#98C379]">"city": "Austin",
#98C379]">"state": "TX",
#98C379]">"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 #98C379]">"x-coinflow-auth-session-key: SESSION_KEY_HERE" \
-H #98C379]">"Content-Type: application/json" \
-d '{
#98C379]">"subtotal": { "cents": 63 },
#98C379]">"token": "BANK_ACCOUNT_TOKEN",
#98C379]">"settlementType": "USDC",
#98C379]">"webhookInfo": {
#98C379]">"roundUpId": "txn_abc123",
#98C379]">"merchantId": "your-merchant-id"
}
}'Response:
{
#98C379]">"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.
{
#98C379]">"eventType": "ACH Batched",
#98C379]">"category": "ach",
#98C379]">"created": "2026-03-24T14:30:00Z",
#98C379]">"data": {
#98C379]">"id": "pay_xyz789",
#98C379]">"webhookInfo": { "roundUpId": "txn_abc123" },
#98C379]">"subtotal": { "cents": 63, "currency": "USD" },
#98C379]">"fees": { "cents": 0, "currency": "USD" },
#98C379]">"total": { "cents": 63, "currency": "USD" },
#98C379]">"merchantId": "your-merchant-id",
#98C379]">"customerId": "consumer-123"
}
}app.post(class="text-[class="text-[#5C6370] italic">#98C379]">'/webhooks/coinflow', async (req, res) => {
class="text-[#5C6370] italic">// Validate webhook authenticity
const webhookKey = req.headers[class="text-[class="text-[#5C6370] italic">#98C379]">'authorization']
if (webhookKey !== process.env.COINFLOW_WEBHOOK_KEY) {
return res.status(class="text-[#D19A66]">401).send(class="text-[class="text-[#5C6370] italic">#98C379]">'Unauthorized')
}
class="text-[#5C6370] italic">// Deduplicate (CoinFlow may send duplicates)
const eventId = req.body.data?.id
if (await isAlreadyProcessed(eventId)) {
return res.status(class="text-[#D19A66]">200).json({ status: class="text-[class="text-[#5C6370] italic">#98C379]">'duplicate' })
}
const { eventType, data } = req.body
switch (eventType) {
case class="text-[class="text-[#5C6370] italic">#98C379]">'ACH Initiated':
await updateTransaction(data.webhookInfo.roundUpId, class="text-[class="text-[#5C6370] italic">#98C379]">'INITIATED')
break
case class="text-[class="text-[#5C6370] italic">#98C379]">'ACH Batched':
await updateTransaction(data.webhookInfo.roundUpId, class="text-[class="text-[#5C6370] italic">#98C379]">'PROCESSING')
break
case class="text-[class="text-[#5C6370] italic">#98C379]">'Settled':
await updateTransaction(data.webhookInfo.roundUpId, class="text-[class="text-[#5C6370] italic">#98C379]">'SETTLED')
break
case class="text-[class="text-[#5C6370] italic">#98C379]">'ACH Returned':
await handleReturn(data)
break
case class="text-[class="text-[#5C6370] italic">#98C379]">'ACH Failed':
await handleFailure(data)
break
}
class="text-[#5C6370] italic">// Must respond within class="text-[#D19A66]">5 seconds
res.status(class="text-[#D19A66]">200).json({ status: class="text-[class="text-[#5C6370] italic">#98C379]">'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 #98C379]">"x-coinflow-auth-user-id: consumer-123" \
-H #98C379]">"Content-Type: application/json" \
-d '{
#98C379]">"subtotal": { "cents": 47, "currency": "USD" },
#98C379]">"originalPaymentId": "pay_xyz789",
#98C379]">"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 |