Beginner 15 min Updated Feb 2025

Build Your First ACH Transfer in 15 Minutes

Create a bank account, initiate an ACH transfer, handle the response, and test your error handling — all in the sandbox. By the end, you'll have a working end-to-end payment flow.

1
Install & Auth
2
Bank Account
3
Create Transfer
4
Handle Response
5
Test in Sandbox
Prerequisites
1
Install the SDK & Set Up Authentication
~3 minutes

Install the SDK for your language and configure it with your sandbox API key. Never hardcode credentials — use environment variables.

# Install the SDK
npm install @paymentsapi/node-sdk dotenv

# .env file (add to .gitignore!)
PAYMENTS_API_KEY=sk_test_[your_secret_key_here]
PAYMENTS_BASE_URL=https://api.sandbox.paymentsapi.io
# Install the SDK
pip install paymentsapi python-dotenv

# .env file (add to .gitignore!)
PAYMENTS_API_KEY=sk_test_[your_secret_key_here]
PAYMENTS_BASE_URL=https://api.sandbox.paymentsapi.io
# Export to shell (or use direnv / .envrc)
export PAYMENTS_API_KEY=sk_test_[your_secret_key_here]

# Test your key — should return your account info
curl -H "Authorization: Bearer $PAYMENTS_API_KEY" \
     https://api.sandbox.paymentsapi.io/v1/account
// client.js — reusable SDK instance
require('dotenv').config();
const PaymentsAPI = require('@paymentsapi/node-sdk');

const client = new PaymentsAPI({
  apiKey: process.env.PAYMENTS_API_KEY,
  baseUrl: process.env.PAYMENTS_BASE_URL
});

module.exports = client;
# client.py — reusable SDK instance
import os
from dotenv import load_dotenv
from paymentsapi import PaymentsAPI

load_dotenv()

client = PaymentsAPI(
    api_key=os.environ["PAYMENTS_API_KEY"],
    base_url=os.environ["PAYMENTS_BASE_URL"]
)
# No setup needed for cURL — just use the env var in requests:
# Authorization: Bearer $PAYMENTS_API_KEY

# Verify your key works:
curl -s \
  -H "Authorization: Bearer $PAYMENTS_API_KEY" \
  https://api.sandbox.paymentsapi.io/v1/account | jq .
⚠ Never commit API keys to Git

Add .env to your .gitignore before making your first commit. A secret key leaked to a public repo should be considered compromised — rotate it immediately.

2
Create a Bank Account (Source)
~3 minutes

Before initiating a transfer, you need to create a bank account object that represents the source (debit from) account. In the sandbox, any routing number and account number combination is accepted.

const client = require('./client');

async function createBankAccount() {
  const account = await client.bankAccounts.create({
    account_type:   'checking',
    routing_number: '021000021',  // JPMorgan test routing
    account_number: '000123456789',
    owner: {
      name:  'Alice Testuser',
      email: 'alice@example.com'
    }
  });

  console.log('Created account:', account.id);
  // → acc_test_a1b2c3d4e5f6g7h8
  return account;
}

createBankAccount();
from client import client

def create_bank_account():
    account = client.bank_accounts.create(
        account_type="checking",
        routing_number="021000021",
        account_number="000123456789",
        owner={
            "name":  "Alice Testuser",
            "email": "alice@example.com"
        }
    )
    print(f"Created account: {account['id']}")
    return account

create_bank_account()
curl -X POST \
  -H "Authorization: Bearer $PAYMENTS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "account_type":   "checking",
    "routing_number": "021000021",
    "account_number": "000123456789",
    "owner": {
      "name":  "Alice Testuser",
      "email": "alice@example.com"
    }
  }' \
  https://api.sandbox.paymentsapi.io/v1/bank-accounts

The API returns a bank account object. Save the id — you'll need it in the next step.

// Response { "id": "acc_test_a1b2c3d4e5f6", "object": "bank_account", "status": "active", "account_type": "checking", "routing_number": "021000021", "last4": "6789", "owner_name": "Alice Testuser", "created_at": "2025-02-10T14:30:00Z" }
3
Initiate the ACH Transfer
~4 minutes

With a bank account object created, you can now initiate an ACH transfer. Use the bank account ID as the source. The idempotency_key is your unique request identifier — if the request is retried, the API won't create a duplicate transfer for the same key.

const { randomUUID } = require('crypto');
const client = require('./client');

async function createTransfer(sourceAccountId) {
  const transfer = await client.transfers.create({
    amount:          5000,            // $50.00 in cents
    currency:        'USD',
    direction:       'debit',          // pull from source
    source:          sourceAccountId,
    description:     'Invoice #1042 — SaaS subscription',
    statement_descr: 'MYCOMPANY BILL',  // appears on bank statement
    idempotency_key: randomUUID()      // unique per request
  });

  console.log('Transfer created:', transfer.id, 'Status:', transfer.status);
  return transfer;
}

createTransfer('acc_test_a1b2c3d4e5f6');
import uuid
from client import client

def create_transfer(source_account_id):
    transfer = client.transfers.create(
        amount=5000,
        currency="USD",
        direction="debit",
        source=source_account_id,
        description="Invoice #1042 — SaaS subscription",
        statement_descr="MYCOMPANY BILL",
        idempotency_key=str(uuid.uuid4())
    )
    print(f"Transfer: {transfer['id']} — Status: {transfer['status']}")
    return transfer

create_transfer("acc_test_a1b2c3d4e5f6")
curl -X POST \
  -H "Authorization: Bearer $PAYMENTS_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "amount":          5000,
    "currency":        "USD",
    "direction":       "debit",
    "source":          "acc_test_a1b2c3d4e5f6",
    "description":     "Invoice #1042",
    "statement_descr": "MYCOMPANY BILL"
  }' \
  https://api.sandbox.paymentsapi.io/v1/transfers

The created transfer will have status: "pending" — this is expected. ACH transfers are not real-time; they go through overnight batch processing.

{ "id": "txn_test_1a2b3c4d5e6f7g8", "object": "transfer", "status": "pending", "amount": 5000, "currency": "USD", "direction": "debit", "source": "acc_test_a1b2c3d4e5f6", "description": "Invoice #1042 — SaaS subscription", "statement_descr": "MYCOMPANY BILL", "idempotency_key": "a0b1c2d3-e4f5-6789-abcd-ef0123456789", "created_at": "2025-02-10T14:32:00Z", "estimated_settlement": "2025-02-11T00:00:00Z" // T+1 }
4
Understand and Handle Transfer Statuses
~3 minutes

ACH transfers move through several statuses over their lifecycle. Your integration must handle all of them — especially returned, which can arrive 2–5 business days after the initial debit.

Status Meaning What Your Code Should Do
pending Transfer accepted, awaiting next ACH batch window Store the transfer ID; set up webhook to receive updates
processing Transfer submitted to NACHA batch No action; continue waiting for final status
completed Funds settled — transfer is final Mark invoice paid, trigger fulfillment, send receipt
returned Receiving bank rejected the debit (see return code) Notify customer, check return_code, attempt retry or invoice manually
failed Transfer could not be initiated (invalid account, bad routing) Surface error to user — prompt them to verify bank account details
async function handleTransferStatus(transferId) {
  const transfer = await client.transfers.retrieve(transferId);

  switch (transfer.status) {
    case 'completed':
      await markInvoicePaid(transfer.description);
      break;

    case 'returned':
      console.warn(`Return code: ${transfer.return_code}`);
      // R01 = insufficient funds, R02 = closed account, etc.
      await notifyCustomer(transfer.owner_email, transfer.return_code);
      break;

    case 'failed':
      throw new Error(`Transfer failed: ${transfer.failure_reason}`);

    default:
      console.log(`Transfer ${transfer.id}: ${transfer.status}`);
  }
}
def handle_transfer_status(transfer_id):
    transfer = client.transfers.retrieve(transfer_id)

    status = transfer["status"]

    if status == "completed":
        mark_invoice_paid(transfer["description"])

    elif status == "returned":
        return_code = transfer.get("return_code")
        print(f"Returned: {return_code}")
        notify_customer(transfer["owner_email"], return_code)

    elif status == "failed":
        raise Exception(
            f"Transfer failed: {transfer['failure_reason']}"
        )
# Retrieve transfer by ID
curl -H "Authorization: Bearer $PAYMENTS_API_KEY" \
     https://api.sandbox.paymentsapi.io/v1/transfers/txn_test_1a2b3c
5
Test Error Scenarios in Sandbox
~2 minutes

The sandbox uses special account number suffixes to trigger specific return codes. Test your error handling by creating bank accounts with these trigger numbers:

Account Number Triggered Return Code Scenario
000000000001 R01 — Insufficient Funds Customer's account has insufficient funds
000000000002 R02 — Bank Account Closed Account closed at receiving bank
000000000003 R03 — No Account / Cannot Locate Routing/account mismatch
000000000004 R04 — Invalid Account Number Account number fails routing validation
000000000010 R10 — Customer Advises Not Authorized Customer disputes the debit authorization
Tip — Test at least R01 and R10

R01 (insufficient funds) and R10 (unauthorized) are the two most common return codes in production. Make sure your code handles both — they require different customer communication flows.

What's Next?