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

  1. The operating account holds 40,000.00 USD and the payroll account holds 1,000.00 USD.
  2. Move 15,000.00 USD from operating to payroll.
  3. 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.

Reference

Was this page helpful?