Checkout/ACH Payments

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 MethodFeeSettlement
Credit/Debit Cards2.9% + $0.30Instant
ACH + USDC Settlement0.8% (max $5)Instant (on Solana)
ACH + USD Settlement0.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.

1

Create Customer

POST /api/customer -- Register the consumer with CoinFlow

2

Get Session Key

GET /api/auth/session-key -- Generate a 24-hour scoped token

3

Link Bank Account

POST /api/customer/v2/bankAccount -- Tokenize the consumer's bank (one-time)

4

Submit ACH Checkout

POST /api/checkout/ach/{merchantId} -- Debit the consumer's bank account

5

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.

Get Session Keybash
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:

Session Key Responsejson
{
  #98C379]">"key": "eyJhbGciOiJIUzI1NiIs..."  // Note: field is "key", not "sessionKey"
}

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.

Link Bank Accountbash
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

ParameterTypeDescription
type
string"checking" or "savings"
email
stringConsumer's email address
firstName
stringAccount holder first name
lastName
stringAccount holder last name
routingNumber
string9-digit ABA routing number
account_number
stringBank account number
address1
stringStreet address
city
stringCity
state
string2-letter state code
zip
string5-digit ZIP code

Step 3: Submit ACH Checkout

Once a bank account is linked, submit an ACH debit to pull funds from the consumer's bank account.

ACH Checkout Requestbash
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:

ACH Checkout Responsejson
{
  #98C379]">"paymentId": "pay_xyz789"  // Note: field is "paymentId", not "id"
}

Checkout Fields

ParameterTypeDescription
subtotal
objectObject with "cents" (integer) and optional "currency"
token
stringBank account token from the linking step
settlementType
string"USDC" (instant) or "Credits" or "Bank" (3 business days)
webhookInfo
objectArbitrary JSON passed through to webhook payloads
jwtToken
stringServer-signed JWT to prevent client-side amount tampering

ACH Payment Status Flow

1

ACH Initiated

Payment submitted to bank -- immediate

2

ACH Batched

Bank accepted and processing -- same day or next business day

3

Settled

Funds delivered to merchant -- instant (USDC) or 1-3 business days (USD)

Handle ACH Webhooks

CoinFlow sends webhook events as the ACH payment progresses. Validate the Authorization header against your Webhook Validation Key.

Webhook Payloadjson
{
  #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"
  }
}
Node.js Webhook Handlerjavascript
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.

Merchant-Initiated Transactionbash
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"
  }'

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.

ScenarioRouting NumberAccount NumberResult
Success110000000000123456789ACH Settled
Any valid formatAny 9 digitsAny digitsWorks in sandbox

Plaid Sandbox (if using SDK)

Username

user_good

Password

pass_good

Error Handling

HTTP CodeMeaningAction
423Customer blocked5 failed attempts -- contact support
428Re-verification requiredConsumer must re-verify bank account
429Rate limitedMIT: max 5 per originalPaymentId per 90s
451KYC verification requiredConsumer must complete identity verification