External transfers
Pay a US bank account in USD by ACH, wire or RTP.
This page shows how to pay a US bank account in USD by ACH, wire or RTP. To send an external transfer:
- Save the bank account once as a payee (
payee_typeach_wire). - Wait until the payee is
active. - Send
POST /v1/transferwith the rail insidedestination.
Example. A payroll platform pays 2,500.00 USD to a contractor's checking account by ACH. The fee on its plan is 1.50 USD, so Gravv debits 2,501.50 USD from the account and the contractor receives 2,500.00 USD in 1 to 3 business days.
Choose the rail
| Rail | Speed | From a bank-held USD account | From a wallet-backed account |
|---|---|---|---|
ach |
1 to 3 business days | Available | Available |
wire |
Same day | Available | Available |
rtp |
Instant | Available, when the receiving bank takes instant payments | Not supported |
You save a payee for one rail. To pay the same account by ACH and by wire, save two payees, one per rail. See Add an external account. The API does not return a payee's rail, so store it on your side.
Before you open an account, GET /v1/accounts/capabilities?customer_id=<id>¤cy=USD lists the rails that each bank's account offers. An empty rails list does not mean that there are no rails. Ask support instead.
Send the transfer
Put rail inside destination, and make it match the payee's rail. The API ignores a top-level rail:
curl --request POST \
--url https://api.gravv.xyz/v1/transfer \
--header 'Api-Key: <API_KEY>' \
--header 'Content-Type: application/json' \
--header 'Idempotency-Key: 2a7d4c1e-9b3f-4d6a-8e5c-1f0b2a3c4d51' \
--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"
}
'
The API returns HTTP 200 with transfer_status pending or paused. For paused, see Paused transfers. The transaction shows transaction_type bank_account and channel ach, wire or rtp.
The note for the receiving bank
payment_rail_reference is a short note for the receiving bank, such as an invoice number. Its rules depend on the rail:
| Rail | Rule |
|---|---|
| ACH | At most 10 letters, digits or spaces (INV2041, not INV-2041-OCT) |
| Wire | At most 35 characters from letters, digits, spaces and / - ? : ( ) . , ' + |
| RTP | Accepted, but not sent |
| Every rail | Plain ASCII only, at most 140 characters |
The note reaches the bank on ACH and wire payouts from wallet-backed accounts. Whether it shows on the recipient's statement depends on their bank. On payouts from bank-held accounts, Gravv sends description as the purpose of payment instead.
What can go wrong
| Response | Why | Fix |
|---|---|---|
400 destination.rail must be 'ach', 'wire', or 'rtp' for US bank transfers; got "<nil>" |
rail is missing, or was sent 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 |
The payee was saved for another rail | Use its rail, or save a payee for this rail |
400 payee <id> is not active: pending |
The payee is still being set up | Wait for external_account.active |
400 payment_rail_reference for ACH must be at most 10 letters, digits or spaces |
The note is too long or contains - or # |
Shorten it. Nothing was charged |
400 payment_rail_reference for wire must be at most 35 characters: letters, digits, spaces and / - ? : ( ) . , ' + |
The wire note is too long or contains #, & or _ |
Shorten it |
400 transfers to US/USD are not supported at this time |
You sent RTP from a wallet-backed account | Use ACH or wire, or pay from a bank-held account |
400 transfers from this account to US/USD are not supported at this time |
The payee was saved against another bank-held account | Save the payee with internal_account_id set to this source |
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 insufficient balance |
The amount plus the fee is more than the balance | Fund the account, then send with a new Idempotency-Key |
Webhook transfer.status.failed with transfer_status manual_processing_required, transaction still pending |
The bank's answer was unclear, so a person checks it | Do not send it again. Wait for the next webhook, or contact support with the reference |
Webhook transfer.status.returned after completed |
The receiving bank returned the money, for example because the account is closed | The money is back in the source. Check the details with the payee |