Pay a US bank account by ACH
Save a US checking or savings account as an ACH payee and pay it in USD, with a short note for the receiving bank.
Use this recipe to save a US checking or savings account as an ACH payee and pay it in USD, with a short note for the receiving bank.
Who it is for. A US payroll platform that pays a contractor's checking account every two weeks.
Status. Available from bank-held and wallet-backed USD accounts.
Money example
- Acme Payroll's customer account holds 10,000.00 USD.
- Acme sends 2,500.00 USD to Maya Chen's checking account by ACH. Your plan adds a fee, which is 1.50 USD in this example.
- Gravv debits the account 2,501.50 USD. Maya receives 2,500.00 USD in 1 to 3 business days.
- The transfer moves from
pendingtocompleted. If the bank later returns the ACH payment, you receivetransfer.status.returned.
Before you start
- The paying customer exists.
GET /v1/customers/{customer_id}returns HTTP 200. - A USD source account that is
activeand funded.GET /v2/accounts/{id}showsstatusactiveand abalanceof at least the amount plus the fee. - You know whether the source is bank-held or wallet-backed, because that decides which rails you can use. See Bank payouts.
- If your API key is scoped, it holds
payee:createandtransfer:initiate. - These details from the contractor: the full name on the account, the account number, the 9-digit routing number, whether the account is checking or savings, whether the owner is a person or a business, the bank name and the contractor's address.
Find out what is required
GET /v1/accounts/capabilities?customer_id=<id>¤cy=USDlists therailsthat an account at each bank offers, withrail,title,settlement_timeandcost. Use it to choose where to open the account. An emptyrailslist means you should contact support. It does not mean there are no rails.- The ACH payee fields are fixed, so there is no discovery call. An
ach_wirepayee needsrouting_number,account_number,payment_rail,account_owner_type,bank_name,address,account_name,currencyUSDandcustomer_id.bank_account_type(checkingorsavings) is optional and defaults tochecking.
Steps
1. Save the ACH payee
A payout always goes to a saved payee, and payment_rail has no default. If you will pay from a bank-held account, set internal_account_id to that account's id. This registers the payee against exactly that account at the bank.
curl --request POST \
--url https://api.gravv.xyz/v1/external-accounts \
--header 'Api-Key: <API_KEY>' \
--header 'Content-Type: application/json' \
--header 'Idempotency-Key: 79b9569c-ce91-45e5-99bb-06248b58cb82' \
--data '
{
"customer_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"payee_type": "ach_wire",
"currency": "USD",
"account_name": "Maya Chen",
"account_number": "000123456789",
"routing_number": "021000021",
"payment_rail": "ach",
"bank_account_type": "checking",
"account_owner_type": "individual",
"bank_name": "JPMorgan Chase Bank",
"internal_account_id": "c56a4180-65aa-42ec-a945-5fd21dec0538",
"address": {
"address_line1": "350 Fifth Avenue",
"city": "New York",
"state": "NY",
"postal_code": "10118",
"country": "US"
}
}
'
const response = await fetch("https://api.gravv.xyz/v1/external-accounts", {
method: "POST",
headers: {
"Api-Key": process.env.GRAVV_API_KEY,
"Content-Type": "application/json",
"Idempotency-Key": "79b9569c-ce91-45e5-99bb-06248b58cb82",
},
body: JSON.stringify({
"customer_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"payee_type": "ach_wire",
"currency": "USD",
"account_name": "Maya Chen",
"account_number": "000123456789",
"routing_number": "021000021",
"payment_rail": "ach",
"bank_account_type": "checking",
"account_owner_type": "individual",
"bank_name": "JPMorgan Chase Bank",
"internal_account_id": "c56a4180-65aa-42ec-a945-5fd21dec0538",
"address": {
"address_line1": "350 Fifth Avenue",
"city": "New York",
"state": "NY",
"postal_code": "10118",
"country": "US"
}
}),
});
console.log(response.status, await response.json());
import os
import requests
response = requests.post(
"https://api.gravv.xyz/v1/external-accounts",
headers={
"Api-Key": os.environ["GRAVV_API_KEY"],
"Content-Type": "application/json",
"Idempotency-Key": "79b9569c-ce91-45e5-99bb-06248b58cb82",
},
json={
"customer_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"payee_type": "ach_wire",
"currency": "USD",
"account_name": "Maya Chen",
"account_number": "000123456789",
"routing_number": "021000021",
"payment_rail": "ach",
"bank_account_type": "checking",
"account_owner_type": "individual",
"bank_name": "JPMorgan Chase Bank",
"internal_account_id": "c56a4180-65aa-42ec-a945-5fd21dec0538",
"address": {
"address_line1": "350 Fifth Avenue",
"city": "New York",
"state": "NY",
"postal_code": "10118",
"country": "US",
},
},
)
print(response.status_code, response.json())
The response is HTTP 202, because setup runs in the background.
{
"data": {
"account_name": "MAYA CHEN",
"account_number": "000123456789",
"account_owner_type": "individual",
"address": {
"address_line1": "350 Fifth Avenue",
"city": "New York",
"country": "US",
"postal_code": "10118",
"state": "NY"
},
"currency": "USD",
"customer_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"date_created": "2026-10-13T22:38:41.310614Z",
"id": "1b4e28ba-2fa1-41d2-883f-0016d3cca427",
"institution_name": "JPMorgan Chase Bank",
"payee_type": "ach_wire",
"routing_number": "021000021",
"status": "pending"
},
"error": null
}
Save data.id. account_name comes back upper-cased and your bank_name comes back as institution_name. The response does not return payment_rail, bank_account_type or internal_account_id, so store which rail you saved the payee for.
2. Wait until the payee is active
Listen for external_account.active (or external_account.setup_failed), or poll the payee every 30 to 60 seconds:
curl --request GET \
--url https://api.gravv.xyz/v1/external-accounts/1b4e28ba-2fa1-41d2-883f-0016d3cca427 \
--header 'Api-Key: <API_KEY>'
const response = await fetch("https://api.gravv.xyz/v1/external-accounts/1b4e28ba-2fa1-41d2-883f-0016d3cca427", {
method: "GET",
headers: {
"Api-Key": process.env.GRAVV_API_KEY,
},
});
console.log(response.status, await response.json());
import os
import requests
response = requests.get(
"https://api.gravv.xyz/v1/external-accounts/1b4e28ba-2fa1-41d2-883f-0016d3cca427",
headers={
"Api-Key": os.environ["GRAVV_API_KEY"],
},
)
print(response.status_code, response.json())
If status is active, go on to the next step. If it is setup_failed, see Fix a payee that failed setup. Do not poll a pending payee forever. Setup retries a limited number of times, and then the payee becomes setup_failed.
3. Check the sender's headroom (optional)
curl --request GET \
--url https://api.gravv.xyz/v1/customers/7c9e6679-7425-40de-944b-e07fc1f90ae7/limits \
--header 'Api-Key: <API_KEY>'
const response = await fetch("https://api.gravv.xyz/v1/customers/7c9e6679-7425-40de-944b-e07fc1f90ae7/limits", {
method: "GET",
headers: {
"Api-Key": process.env.GRAVV_API_KEY,
},
});
console.log(response.status, await response.json());
import os
import requests
response = requests.get(
"https://api.gravv.xyz/v1/customers/7c9e6679-7425-40de-944b-e07fc1f90ae7/limits",
headers={
"Api-Key": os.environ["GRAVV_API_KEY"],
},
)
print(response.status_code, response.json())
If the amount is above a window's remaining_usd, the transfer pauses. See Stay within transfer limits. A 404 not found: no limits policy applies means your business has no limits policy.
4. Send the payout
Put rail inside destination. client_reference is your id for this payout. payment_rail_reference is the note for the receiving bank. On ACH, it can be at most 10 letters, digits or spaces.
curl --request POST \
--url https://api.gravv.xyz/v1/transfer \
--header 'Api-Key: <API_KEY>' \
--header 'Content-Type: application/json' \
--header 'Idempotency-Key: e411b59d-2f3b-46b2-8ca4-6c4f33ee57d4' \
--data '
{
"customer_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"source": {
"source_type": "internal_account",
"id": "c56a4180-65aa-42ec-a945-5fd21dec0538"
},
"destination": {
"destination_type": "external_account",
"id": "1b4e28ba-2fa1-41d2-883f-0016d3cca427",
"rail": "ach"
},
"amount": "2500.00",
"description": "Contractor fee October",
"client_reference": "payout-2026-10-0001",
"payment_rail_reference": "INV2041"
}
'
const response = await fetch("https://api.gravv.xyz/v1/transfer", {
method: "POST",
headers: {
"Api-Key": process.env.GRAVV_API_KEY,
"Content-Type": "application/json",
"Idempotency-Key": "e411b59d-2f3b-46b2-8ca4-6c4f33ee57d4",
},
body: JSON.stringify({
"customer_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"source": {
"source_type": "internal_account",
"id": "c56a4180-65aa-42ec-a945-5fd21dec0538"
},
"destination": {
"destination_type": "external_account",
"id": "1b4e28ba-2fa1-41d2-883f-0016d3cca427",
"rail": "ach"
},
"amount": "2500.00",
"description": "Contractor fee October",
"client_reference": "payout-2026-10-0001",
"payment_rail_reference": "INV2041"
}),
});
console.log(response.status, await response.json());
import os
import requests
response = requests.post(
"https://api.gravv.xyz/v1/transfer",
headers={
"Api-Key": os.environ["GRAVV_API_KEY"],
"Content-Type": "application/json",
"Idempotency-Key": "e411b59d-2f3b-46b2-8ca4-6c4f33ee57d4",
},
json={
"customer_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"source": {
"source_type": "internal_account",
"id": "c56a4180-65aa-42ec-a945-5fd21dec0538",
},
"destination": {
"destination_type": "external_account",
"id": "1b4e28ba-2fa1-41d2-883f-0016d3cca427",
"rail": "ach",
},
"amount": "2500.00",
"description": "Contractor fee October",
"client_reference": "payout-2026-10-0001",
"payment_rail_reference": "INV2041",
},
)
print(response.status_code, response.json())
{
"data": {
"amount": "2500",
"amount_currency": "USD",
"reference": "3f0c6a8e-6b1d-4c1e-9a55-0d2f7b9e4a11",
"date_created": "2026-10-13T09:15:22.481Z",
"transfer_status": "pending",
"transaction_id": "8d4e2b6c-1f3a-4e7b-b0c9-5a6d7e8f9a01",
"local_amount": null,
"local_currency": null,
"fee": "1.5",
"fee_currency": "USD",
"requires_approval": false
},
"error": null
}
HTTP 200 does not mean the money is sent. If transfer_status is paused, read requirements and see Stay within transfer limits.
Save reference and transaction_id. reference is the transfer id. You use it to resume a paused transfer, and webhooks carry it as event_group_id.
On payouts from wallet-backed accounts, the note reaches the bank. Whether it shows on Maya's statement depends on her bank. On payouts from a bank-held account, Gravv sends description as the purpose of payment instead.
5. Follow it to the end
curl --request GET \
--url https://api.gravv.xyz/v1/transactions/8d4e2b6c-1f3a-4e7b-b0c9-5a6d7e8f9a01 \
--header 'Api-Key: <API_KEY>'
const response = await fetch("https://api.gravv.xyz/v1/transactions/8d4e2b6c-1f3a-4e7b-b0c9-5a6d7e8f9a01", {
method: "GET",
headers: {
"Api-Key": process.env.GRAVV_API_KEY,
},
});
console.log(response.status, await response.json());
import os
import requests
response = requests.get(
"https://api.gravv.xyz/v1/transactions/8d4e2b6c-1f3a-4e7b-b0c9-5a6d7e8f9a01",
headers={
"Api-Key": os.environ["GRAVV_API_KEY"],
},
)
print(response.status_code, response.json())
Poll until status is completed or failed, or wait for transfer.status.completed. The transaction shows transaction_type bank_account and channel ach. Follow a payout to completion covers failures, returns and manual review.
What can go wrong
| Response | Why | Fix |
|---|---|---|
400 payment rail required but missing for ach/wire payee (expected ach, wire, or rtp): invalid input |
The ach_wire payee has no payment_rail |
Add "payment_rail": "ach" and send with a new Idempotency-Key. The old key replays this 400 |
400 routing number checksum invalid: invalid input / routing number must be exactly 9 digits: invalid input |
The number is not a valid ABA routing number | Use the bank's real 9-digit routing number. Reject all-zero numbers on your side, because they pass the checksum |
400 account name may contain only unaccented Latin letters (A-Z); "é" is not allowed: invalid input / account name must not contain digits: invalid input |
The name must start with a letter and use only A-Z, spaces and ' - . , & / ( ) |
Transliterate (é becomes e) and remove digits |
400 address line 1 length cannot be less than 4: invalid input / … cannot exceed 34 |
address.address_line1 must be 4 to 34 characters |
Shorten or complete it |
400 <label> "X" is not valid for the selected country (US); expected a format like … |
The API checks the postal code against address.country |
Send a real ZIP (5 digits or ZIP+4) |
400 a recipient with the same account name, account number and bank already exists, with data.payee_id |
The customer already has this payee on this rail. Deleted and failed payees count | Use data.payee_id. See Save one US account for both ACH and wire |
Gravv saves the payee as checking although you sent account_type |
The field is bank_account_type |
Send "bank_account_type": "savings" |
400 payee <id> is not active: pending |
You sent the transfer before payee setup finished | Wait for external_account.active |
400 destination.rail must be 'ach', 'wire', or 'rtp' for US bank transfers; got "<nil>" |
rail is missing, or is at the top level |
Put "rail": "ach" inside destination |
400 this payee is registered for wire transfers, not ach — initiate on rail "wire", or add a payee on the ach rail |
You saved the payee for another rail | Use its rail, or save an ACH payee |
400 payment_rail_reference for ACH must be at most 10 letters, digits or spaces |
The note is too long, or has characters such as - or # |
Send INV2041, not INV-2041-OCT. Gravv charged nothing |
400 payment_rail_reference must be at most 140 plain characters (no emoji or accented letters) |
On every rail, the note must be plain ASCII | Remove emoji and accents |
400 insufficient balance |
The amount plus the fee is more than the balance | Fund the source, then retry with a new key |
400 amount must be a positive number / amount must have at most 2 decimal places |
The amount is invalid | Send "2500.00" |
400 payee <id> has a bank notification of change awaiting review; update the payee's account details before paying it |
The receiving bank reported changed details on an earlier payment | Ask the payee for current details and save a new payee |
400 transfers from this account to US/USD are not supported at this time |
You registered the payee against a different bank-held account | Save the payee with internal_account_id set to this source |
409 client_reference has already been used for a different transfer … (ERROR_CODE_DUPLICATE_TRANSACTION) |
Your business has an enforced limits policy, and you reused a client_reference with a different body |
Use a new client_reference for each payout |
422 Idempotency-Key was previously used with a different request payload (idempotency_key_reused) |
The body changed but the key stayed the same | Use a new key |
400 internal server error |
An unclassified server error | Look the payout up first. List the customer's newest transactions (GET /v1/transactions?customer_id=7c9e6679-7425-40de-944b-e07fc1f90ae7) and match client_reference payout-2026-10-0001. If none exists, retry later with a new key and the same client_reference |
Webhooks you will receive
external_account.created(statuspending), thenexternal_account.active.event_group_idis the payeeid, andevent_datais the full payee, including the fullaccount_number.transfer.status.pending, several times, one per internal step. Treat them all as "working".transfer.status.completed.event_group_idis the transferreference.event_datacarriesstatus,client_referenceandworkflow_type.
Deduplicate on the event_id in the body, never on X-Event-ID. See Transfer events.
Testing in sandbox
Use a grvSec_sandbox_ key. Fund a v2 account with POST /v2/accounts/simulate/deposit with "rail": "ach" and the amount as a string. Sandbox simulates ACH and wire payee setup, and pending becomes active in a few seconds. No test values force a payout to succeed or fail. A transfer from an empty source returns insufficient balance before any rail or payee check.
Sandbox does not complete customer KYC today, and new USD accounts there can stay in review. A full USD payout may not be possible in sandbox, but you can test payee setup and the request checks.