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

  1. Acme Payroll's customer account holds 10,000.00 USD.
  2. 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.
  3. Gravv debits the account 2,501.50 USD. Maya receives 2,500.00 USD in 1 to 3 business days.
  4. The transfer moves from pending to completed. If the bank later returns the ACH payment, you receive transfer.status.returned.

Before you start

  • The paying customer exists. GET /v1/customers/{customer_id} returns HTTP 200.
  • A USD source account that is active and funded. GET /v2/accounts/{id} shows status active and a balance of 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:create and transfer: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

  1. GET /v1/accounts/capabilities?customer_id=<id>&currency=USD lists the rails that an account at each bank offers, with rail, title, settlement_time and cost. Use it to choose where to open the account. An empty rails list means you should contact support. It does not mean there are no rails.
  2. The ACH payee fields are fixed, so there is no discovery call. An ach_wire payee needs routing_number, account_number, payment_rail, account_owner_type, bank_name, address, account_name, currency USD and customer_id. bank_account_type (checking or savings) is optional and defaults to checking.

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

  1. external_account.created (status pending), then external_account.active. event_group_id is the payee id, and event_data is the full payee, including the full account_number.
  2. transfer.status.pending, several times, one per internal step. Treat them all as "working".
  3. transfer.status.completed. event_group_id is the transfer reference. event_data carries status, client_reference and workflow_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.

Reference

Was this page helpful?