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:

  1. Save the bank account once as a payee (payee_type ach_wire).
  2. Wait until the payee is active.
  3. Send POST /v1/transfer with the rail inside destination.

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>&currency=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

Recipes

Was this page helpful?