Move money between own accounts
Move money between two accounts your customer holds at Gravv with an internal transfer, which needs no payee and no rail.
Use this recipe to move money between two accounts your customer holds at Gravv with an internal transfer.
Who it is for. A treasury app that moves a customer's operating cash into their payroll account before payday.
Status. Available. A bank-held account can move money only to another account at the same bank.
Money example
- The operating account holds 40,000.00 USD and the payroll account holds 1,000.00 USD.
- Move 15,000.00 USD from operating to payroll.
- The operating account now holds 25,000.00 USD, less your fee if your plan has one. The payroll account holds 16,000.00 USD.
Before you start
Both accounts must be active. Read each one with GET /v2/accounts/{id}. If the source is bank-held, both accounts must show the same bank_code.
Find out what is required
You need only the two account ids. An internal transfer has no payee and no rail.
Steps
1. Send the internal transfer
curl --request POST \
--url https://api.gravv.xyz/v1/transfer \
--header 'Api-Key: <API_KEY>' \
--header 'Content-Type: application/json' \
--header 'Idempotency-Key: cae850ff-8e57-4c0c-bb78-1788523e38c2' \
--data '
{
"customer_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"source": {
"source_type": "internal_account",
"id": "c56a4180-65aa-42ec-a945-5fd21dec0538"
},
"destination": {
"destination_type": "internal_account",
"id": "e4d909c2-90d0-4b6a-9c1d-3f2e5a7b8c90"
},
"amount": "15000.00",
"description": "Fund payroll October 15",
"client_reference": "book-2026-10-13-01"
}
'
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": "cae850ff-8e57-4c0c-bb78-1788523e38c2",
},
body: JSON.stringify({
"customer_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"source": {
"source_type": "internal_account",
"id": "c56a4180-65aa-42ec-a945-5fd21dec0538"
},
"destination": {
"destination_type": "internal_account",
"id": "e4d909c2-90d0-4b6a-9c1d-3f2e5a7b8c90"
},
"amount": "15000.00",
"description": "Fund payroll October 15",
"client_reference": "book-2026-10-13-01"
}),
});
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": "cae850ff-8e57-4c0c-bb78-1788523e38c2",
},
json={
"customer_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"source": {
"source_type": "internal_account",
"id": "c56a4180-65aa-42ec-a945-5fd21dec0538",
},
"destination": {
"destination_type": "internal_account",
"id": "e4d909c2-90d0-4b6a-9c1d-3f2e5a7b8c90",
},
"amount": "15000.00",
"description": "Fund payroll October 15",
"client_reference": "book-2026-10-13-01",
},
)
print(response.status_code, response.json())
{
"data": {
"amount": "15000",
"amount_currency": "USD",
"date_created": "2026-10-13T23:05:53.17901Z",
"fee": "0",
"fee_currency": "USD",
"local_amount": null,
"local_currency": null,
"reference": "1a7c795a-a3f2-4d45-ba57-2441b7c310b7",
"requires_approval": false,
"transaction_id": "94d7f1d0-9769-4762-a09c-ef8d172a8d1e",
"transfer_status": "pending"
},
"error": null
}
2. Follow it to the end
GET /v1/transactions/94d7f1d0-9769-4762-a09c-ef8d172a8d1e shows transaction_type internal_transfer and channel internal, with balance_before and balance_after on the debit. The webhooks carry workflow_type internal_transfer.
What can go wrong
| Response | Why | Fix |
|---|---|---|
400 destination.id is required for an internal account transfer |
The request has no destination id | Add destination.id |
400 the destination account was not found |
A wrong id, or one from the other environment | Check the id. Sandbox ids do not exist in live |
400 invalid transfer input |
The destination is not active |
Wait until it is active |
400 this account can only send to a bank account or to another account held at the same bank |
The source is bank-held and the destination is an account at another bank, a card or a wallet | Pick a destination at the same bank |
400 amount must have at most 2 decimal places |
More than 2 decimals | Send "15000.00". Always round on your side. From a wallet-backed account, the API can accept an amount with more decimals, and the transfer then fails |
400 insufficient balance |
The amount plus the fee is more than the balance | Fund the source first |
Webhooks you will receive
You receive transfer.status.pending (twice in sandbox), then transfer.status.completed. The first pending event has no top-level transaction_id. Later events carry it, with the debit and credit rows in event_data.associated_transactions.
Testing in sandbox
Fund the source with POST /v2/accounts/simulate/deposit, then send the internal transfer. In sandbox, a move between two NGN accounts completed about 90 seconds after the request.