ACH Bank Transfer
Send USD to external ACH bank accounts.
Before You Start
Read the following guides before proceeding:
| Guide | Why |
|---|---|
| Getting Started | Platform overview and setup |
| Api Basics | Required headers and request configuration |
| Authentication | How to obtain access tokens |
| Onboarding | User and wallet registration |
| KYC | KYC verification requirements |
| Recipients | Recipient management |
Overview
Users can send USD from their unified balance to external ACH bank accounts. The transfer flow consists of two steps:
- Estimate — Get the token amount required for the transfer
- Execute — Initiate the transfer using the estimation
ACH transfers support both first-party (to user's own accounts) and third-party (to other recipients) transfers, controlled by separate capabilities.
Transfer Flow
Step 1: Estimate Transfer
Get an estimate for the token amount required to send a specific USD amount.
POST /api/v2/bank/transfer/estimate
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
account_id | string | Yes | User's ACH account ID in {account_id}:{details_id} format |
amount | number | Yes | USD amount to send |
tokens | array | No | Token addresses to estimate against. If omitted, estimates all available tokens |
recipient | object | Yes | Recipient details |
recipient.first_name | string | Yes* | Recipient first name |
recipient.last_name | string | Yes* | Recipient last name |
recipient.company_name | string | Yes* | Company name (for business recipients) |
recipient_account | object | Yes | Recipient bank account |
recipient_account.account_number | string | Yes | Recipient account number |
recipient_account.routing_number | string | Yes | ABA routing number |
recipient_account.bank_name | string | No | Bank name |
recipient_account.legal_address | object | Yes | Recipient's address |
reference | string | No | Payment reference |
*Provide either first_name + last_name for personal recipients, or company_name for business recipients.
Legal Address Fields
| Field | Type | Required | Description |
|---|---|---|---|
line1 | string | Yes | Street address |
line2 | string | No | Additional address line |
city | string | Yes | City |
state | string | Yes (US) | State code (required for US addresses) |
zip_code | string | Yes | Postal code |
country | string | Yes | ISO 3166-1 alpha-2 country code |
Code Examples
const response = await fetch(`${baseUrl}/api/v2/bank/transfer/estimate`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'X-User-Address': userEoaAddress,
'X-Chain-Id': chainId,
'Content-Type': 'application/json'
},
body: JSON.stringify({
account_id: '1334726cbd7641c09b4124e3e52f53fe:8d5a65eb59d94afea64374d45591fe9f',
amount: 500.00,
recipient: {
first_name: 'Alex',
last_name: 'Grey'
},
recipient_account: {
account_number: '123456789012',
routing_number: '026073150',
bank_name: 'Bank of America',
legal_address: {
line1: '123 Main Street',
city: 'New York',
state: 'NY',
zip_code: '10001',
country: 'US'
}
},
reference: 'Invoice #12345'
})
});
const estimate = await response.json();
console.log('Estimation ID:', estimate.id);response = requests.post(
f"{base_url}/api/v2/bank/transfer/estimate",
headers={
"Authorization": f"Bearer {access_token}",
"X-User-Address": user_eoa_address,
"X-Chain-Id": chain_id,
"Content-Type": "application/json"
},
json={
"account_id": "1334726cbd7641c09b4124e3e52f53fe:8d5a65eb59d94afea64374d45591fe9f",
"amount": 500.00,
"recipient": {
"first_name": "Alex",
"last_name": "Grey"
},
"recipient_account": {
"account_number": "123456789012",
"routing_number": "026073150",
"bank_name": "Bank of America",
"legal_address": {
"line1": "123 Main Street",
"city": "New York",
"state": "NY",
"zip_code": "10001",
"country": "US"
}
},
"reference": "Invoice #12345"
}
)
estimate = response.json()
print("Estimation ID:", estimate["id"])body := map[string]interface{}{
"account_id": "1334726cbd7641c09b4124e3e52f53fe:8d5a65eb59d94afea64374d45591fe9f",
"amount": 500.00,
"recipient": map[string]string{
"first_name": "Alex",
"last_name": "Grey",
},
"recipient_account": map[string]interface{}{
"account_number": "123456789012",
"routing_number": "026073150",
"bank_name": "Bank of America",
"legal_address": map[string]string{
"line1": "123 Main Street",
"city": "New York",
"state": "NY",
"zip_code": "10001",
"country": "US",
},
},
"reference": "Invoice #12345",
}
jsonBody, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", baseURL+"/api/v2/bank/transfer/estimate", bytes.NewBuffer(jsonBody))
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("X-User-Address", userEoaAddress)
req.Header.Set("X-Chain-Id", chainId)
req.Header.Set("Content-Type", "application/json")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var estimate BankTransferEstimateResponse
json.NewDecoder(resp.Body).Decode(&estimate)Response
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"expires_at": 1704110400,
"amount": 500.00,
"currency": "USD",
"estimated_amounts": [
{
"amount": 500.00,
"precise_amount": "500000000",
"token_address": "0x0774164DC20524Bb239b39D1DC42573C3E4C6976",
"token_symbol": "WUSD",
"rate": 1.0
},
{
"amount": 460.83,
"precise_amount": "460830000000000000000",
"token_address": "0x5c55F314624718019A326F16a62A05D6C6d8C8A2",
"token_symbol": "WEUR",
"rate": 0.9217
}
]
}Response Fields
| Field | Description |
|---|---|
id | Estimation ID (use in execute request) |
expires_at | Unix timestamp when estimation expires |
amount | Fiat amount to be transferred |
currency | Transfer currency (USD for ACH) |
estimated_amounts | Token amounts for each requested token |
estimated_amounts[].amount | Token amount (human-readable) |
estimated_amounts[].precise_amount | Token amount in smallest unit (wei) |
estimated_amounts[].token_address | Token contract address |
estimated_amounts[].token_symbol | Token symbol |
estimated_amounts[].rate | Exchange rate (token per USD) |
Step 2: Execute Transfer
Execute the transfer using the estimation ID or specify the amount directly.
POST /api/v1/bank/transfer
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
account_id | string | Yes | User's ACH account ID |
token_address | string | Yes | Token to charge from user's balance |
estimation_id | string | Yes* | Estimation ID from step 1 |
amount | number | Yes* | USD amount (if not using estimation) |
recipient | object | Yes | Recipient details (same as estimate) |
recipient_account | object | Yes | Recipient bank account (same as estimate) |
reference | string | No | Payment reference |
*Provide either estimation_id or amount, not both.
Code Examples
const response = await fetch(`${baseUrl}/api/v1/bank/transfer`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'X-User-Address': userEoaAddress,
'X-Chain-Id': chainId,
'Content-Type': 'application/json'
},
body: JSON.stringify({
account_id: '1334726cbd7641c09b4124e3e52f53fe:8d5a65eb59d94afea64374d45591fe9f',
token_address: '0x0774164DC20524Bb239b39D1DC42573C3E4C6976',
estimation_id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
recipient: {
first_name: 'Alex',
last_name: 'Grey'
},
recipient_account: {
account_number: '123456789012',
routing_number: '026073150',
bank_name: 'Bank of America',
legal_address: {
line1: '123 Main Street',
city: 'New York',
state: 'NY',
zip_code: '10001',
country: 'US'
}
},
reference: 'Invoice #12345'
})
});
const result = await response.json();
console.log('Transfer ID:', result.id);response = requests.post(
f"{base_url}/api/v1/bank/transfer",
headers={
"Authorization": f"Bearer {access_token}",
"X-User-Address": user_eoa_address,
"X-Chain-Id": chain_id,
"Content-Type": "application/json"
},
json={
"account_id": "1334726cbd7641c09b4124e3e52f53fe:8d5a65eb59d94afea64374d45591fe9f",
"token_address": "0x0774164DC20524Bb239b39D1DC42573C3E4C6976",
"estimation_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"recipient": {
"first_name": "Alex",
"last_name": "Grey"
},
"recipient_account": {
"account_number": "123456789012",
"routing_number": "026073150",
"bank_name": "Bank of America",
"legal_address": {
"line1": "123 Main Street",
"city": "New York",
"state": "NY",
"zip_code": "10001",
"country": "US"
}
},
"reference": "Invoice #12345"
}
)
result = response.json()
print("Transfer ID:", result["id"])body := map[string]interface{}{
"account_id": "1334726cbd7641c09b4124e3e52f53fe:8d5a65eb59d94afea64374d45591fe9f",
"token_address": "0x0774164DC20524Bb239b39D1DC42573C3E4C6976",
"estimation_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"recipient": map[string]string{
"first_name": "Alex",
"last_name": "Grey",
},
"recipient_account": map[string]interface{}{
"account_number": "123456789012",
"routing_number": "026073150",
"bank_name": "Bank of America",
"legal_address": map[string]string{
"line1": "123 Main Street",
"city": "New York",
"state": "NY",
"zip_code": "10001",
"country": "US",
},
},
"reference": "Invoice #12345",
}
jsonBody, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", baseURL+"/api/v1/bank/transfer", bytes.NewBuffer(jsonBody))
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("X-User-Address", userEoaAddress)
req.Header.Set("X-Chain-Id", chainId)
req.Header.Set("Content-Type", "application/json")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var result BankTransferExecuteResponse
json.NewDecoder(resp.Body).Decode(&result)Response
{
"id": "8b4f6e59-4287-4079-a3a3-3742557d07fd"
}The id is the activity ID. Use this to track the transfer status via webhooks.
Webhooks
Outgoing ACH Transfer
Endpoint: POST {your_webhook_base_url}/v2/webhooks/activities
{
"id": "8b4f6e59-4287-4079-a3a3-3742557d07fd",
"user_address": "0x7e1Cc1685D68D486b22D3880030C24434E3b90a9",
"type": "AchPush",
"status": "Completed",
"direction": "Outbound",
"source": {
"type": "Wallet",
"wallet": {
"address": "0xAAFF0821A09A1Aac28B72dD3Ff410A7ea5FEb874"
}
},
"destination": {
"type": "AchBankAccount",
"bank_account": {
"account_number": "8310931284",
"routing_number": "026073150",
"owner_name": "Alex Grey",
"is_business": false
}
},
"source_amount": {
"amount": 34.64,
"token_symbol": "WUSD",
"token_address": "0x0774164DC20524Bb239b39D1DC42573C3E4C6976"
},
"destination_amount": {
"amount": 34.64,
"currency": "USD"
},
"operations": [
{
"hash": "0xdfe7dfe5633580cf9b34aa2f0021d9a3e53e8d97b99003cf20e3dce9ebba8dea",
"operation_amount": {
"amount": -34.64,
"token_symbol": "WUSD",
"token_address": "0x0774164DC20524Bb239b39D1DC42573C3E4C6976"
},
"rate": {
"ticker": "USD/WUSD",
"rate": 1
}
}
],
"created_at": "2024-01-01T08:37:35.000Z",
"activity_steps": [
{
"type": "Initiated",
"status": "Completed",
"created_at": "2024-01-01T08:37:35.000Z",
"completed_at": "2024-01-01T08:37:35.000Z"
},
{
"type": "CryptoOut",
"status": "Completed",
"created_at": "2024-01-01T08:37:35.000Z",
"completed_at": "2024-01-01T08:37:35.000Z"
},
{
"type": "BankOut",
"status": "Completed",
"created_at": "2024-01-01T08:37:35.000Z",
"completed_at": "2024-01-01T08:37:47.000Z"
}
]
}Activity Steps
| Step | Description |
|---|---|
Initiated | Transfer request received |
CryptoOut | Tokens debited from user's wallet |
BankOut | Funds sent to recipient's bank account |
Error Handling
Validation Errors
| Error Reason | Field | Description |
|---|---|---|
ErrorMissingField | account_id | Account ID is required |
ErrorInvalidField | account_id | Invalid account ID format |
ErrorNotFound | account_id | Account not found or not owned by user |
ErrorMissingField | recipient | Recipient details required |
ErrorMissingField | recipient_account.account_number | Account number is required |
ErrorMissingField | recipient_account.routing_number | Routing number is required |
ErrorMissingField | recipient_account.legal_address | Legal address is required for ACH |
ErrorMissingField | legal_address.state | State is required for US addresses |
ErrorInvalidField | recipient_account.account_number | Invalid account number format |
ErrorInvalidField | recipient_account.routing_number | Invalid routing number format |
ErrorMissingField | amount | Amount or estimation_id required |
ErrorInvalidField | token_address | Invalid token address |
ErrorNotSupported | recipient | Capability not active for this transfer type |
Error Response Format
{
"error_reason": "ErrorMissingField",
"error_description": "recipient account.legal address is required for ACH",
"error_category": {
"category": "CategoryValidationFailure",
"http_status_code": 400
},
"error_details": [
{
"key": "field",
"details": "recipient_account.legal_address"
},
{
"key": "issue",
"details": "required_for_ach"
}
]
}Capabilities
The following capabilities control ACH transfer access:
| Capability | Description |
|---|---|
AchOut1stParty | Send ACH transfers to own accounts (recipient name matches user's verified name) |
AchOut3rdParty | Send ACH transfers to third-party accounts (any recipient) |
The system automatically determines which capability applies based on whether the recipient name matches the user's verified name.
Check capabilities via GET /api/v2/user before initiating transfers.
Updated 8 days ago
