---
title: "Pay a US bank account by ACH"
description: "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}`](/api-reference/customers/get-v1-customers-customer-id) returns HTTP 200.
- A USD source account that is `active` and funded. [`GET /v2/accounts/{id}`](/api-reference/accounts-v2/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](/recipes/bank-payouts/overview#which-source-account-can-pay-which-rail).
- 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.

<CodeGroup>
```bash title="cURL"
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"
  }
}
'
```
```javascript title="Node"
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());
```
```python title="Python"
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())
```
</CodeGroup>

The response is HTTP **202**, because setup runs in the background.

```json title="Response"
{
  "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:

<CodeGroup>
```bash title="cURL"
curl --request GET \
     --url https://api.gravv.xyz/v1/external-accounts/1b4e28ba-2fa1-41d2-883f-0016d3cca427 \
     --header 'Api-Key: <API_KEY>'
```
```javascript title="Node"
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());
```
```python title="Python"
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())
```
</CodeGroup>

If `status` is `active`, go on to the next step. If it is `setup_failed`, see [Fix a payee that failed setup](/recipes/bank-payouts/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)

<CodeGroup>
```bash title="cURL"
curl --request GET \
     --url https://api.gravv.xyz/v1/customers/7c9e6679-7425-40de-944b-e07fc1f90ae7/limits \
     --header 'Api-Key: <API_KEY>'
```
```javascript title="Node"
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());
```
```python title="Python"
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())
```
</CodeGroup>

If the amount is above a window's `remaining_usd`, the transfer pauses. See [Stay within transfer limits](/recipes/bank-payouts/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.

<CodeGroup>
```bash title="cURL"
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"
}
'
```
```javascript title="Node"
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());
```
```python title="Python"
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())
```
</CodeGroup>

```json title="Response"
{
  "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](/recipes/bank-payouts/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

<CodeGroup>
```bash title="cURL"
curl --request GET \
     --url https://api.gravv.xyz/v1/transactions/8d4e2b6c-1f3a-4e7b-b0c9-5a6d7e8f9a01 \
     --header 'Api-Key: <API_KEY>'
```
```javascript title="Node"
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());
```
```python title="Python"
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())
```
</CodeGroup>

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](/recipes/bank-payouts/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](/recipes/bank-payouts/save-one-account-for-two-rails) |
| 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](/platform/webhooks/event-catalogue#transfers).

## Testing in sandbox

Use a `grvSec_sandbox_` key. Fund a v2 account with [`POST /v2/accounts/simulate/deposit`](/api-reference/accounts-v2/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

- [Add an external account](/api-reference/external-accounts/post-v1-external-accounts)
- [Get an external account](/api-reference/external-accounts/get-v1-external-accounts-external-account-id)
- [Get customer limits](/api-reference/limits/get-v1-customers-customer-id-limits)
- [Create a transfer](/api-reference/transfer/post-v1-transfer)
- [Get a transaction](/api-reference/transactions/get-v1-transactions-transaction-id)
