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.
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 .
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.
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.
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.
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
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 |
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.