Pay a bank account in Nigeria

Check the account holder's name, save a Nigerian bank payee and send US dollars that arrive as naira.

Use this recipe to check the account holder's name, save a Nigerian bank payee, and send US dollars that arrive as naira.

Who it is for. A US remittance app whose user, Ada Bello in Houston, sends dollars to her brother Chinedu's bank account in Lagos.

Status. Not yet available. Ask support to confirm that Gravv has switched it on for your business. The steps below show what the route needs.

Money example

  1. Ada's USD account holds 1,000.00 USD.
  2. She sends 250.00 USD. Say the fee on your plan is 1.50 USD, so 251.50 USD leaves her account.
  3. Say the rate is 1,530.00 NGN per USD. The transfer response shows 382,500.00 NGN for Chinedu in local_amount.
  4. His bank credits the naira, and you receive transfer.status.completed.

Before you start

Find out what is required

The currency

curl --request GET \
 --url 'https://api.gravv.xyz/v1/transfer/supported-countries?iso_code=NG' \
 --header 'Api-Key: <API_KEY>'
const response = await fetch("https://api.gravv.xyz/v1/transfer/supported-countries?iso_code=NG", {
method: "GET",
headers: {
"Api-Key": process.env.GRAVV_API_KEY,
},
});
console.log(response.status, await response.json());
import os

import requests

response = requests.get(
"https://api.gravv.xyz/v1/transfer/supported-countries?iso_code=NG",
headers={
    "Api-Key": os.environ["GRAVV_API_KEY"],
},
)
print(response.status_code, response.json())
{
  "data": {
    "countries": [
      {
        "iso_code": "NG",
        "name": "Nigeria",
        "iso3_code": "NGA",
        "currency_code": "NGN"
      }
    ]
  },
  "error": null
}

The payee's currency is NGN. iso_code matches part of the code, so N alone matches many countries.

The bank

The default page size is 10, so the example asks for 100 per page. Send the country code in upper case.

curl --request GET \
 --url 'https://api.gravv.xyz/v1/external-accounts/institutions?country_iso_code=NG&institution_type=bank&items_per_page=100' \
 --header 'Api-Key: <API_KEY>'
const response = await fetch("https://api.gravv.xyz/v1/external-accounts/institutions?country_iso_code=NG&institution_type=bank&items_per_page=100", {
method: "GET",
headers: {
"Api-Key": process.env.GRAVV_API_KEY,
},
});
console.log(response.status, await response.json());
import os

import requests

response = requests.get(
"https://api.gravv.xyz/v1/external-accounts/institutions?country_iso_code=NG&institution_type=bank&items_per_page=100",
headers={
    "Api-Key": os.environ["GRAVV_API_KEY"],
},
)
print(response.status_code, response.json())
{
  "data": {
    "items": [
      {
        "account_number_type": "bank_account_number",
        "country_iso_code": "NG",
        "currency": "NGN",
        "id": "2ce313c8-de20-48dd-bfc6-949b8fd5ad84",
        "max_amount": 10000,
        "min_amount": 1,
        "name": "GT Bank"
      }
    ],
    "items_per_page": 100,
    "page": 1,
    "total_items": 85,
    "total_pages": 1
  },
  "error": null
}

Store the bank's id. You send it as institution_id. min_amount and max_amount are reference values, not limits. Institution ids can differ between sandbox and live, so always take them from this list.

The rate

GET /v1/transfer/rates?currency=NGN returns the indicative rate. Read the item whose direction is USD_NGN. This call does not lock the rate.

The account number

The account number is exactly 10 digits. You do not need a routing number or an address.

Steps

1. Check the account holder's name (optional)

Show Ada the name the bank holds before she pays. This call does not need an Idempotency-Key.

curl --request POST \
 --url https://api.gravv.xyz/v1/external-accounts/verify \
 --header 'Api-Key: <API_KEY>' \
 --header 'Content-Type: application/json' \
 --data '
{
"account_number": "0123456789",
"institution_id": "2ce313c8-de20-48dd-bfc6-949b8fd5ad84"
}
'
const response = await fetch("https://api.gravv.xyz/v1/external-accounts/verify", {
method: "POST",
headers: {
"Api-Key": process.env.GRAVV_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
"account_number": "0123456789",
"institution_id": "2ce313c8-de20-48dd-bfc6-949b8fd5ad84"
}),
});
console.log(response.status, await response.json());
import os

import requests

response = requests.post(
"https://api.gravv.xyz/v1/external-accounts/verify",
headers={
    "Api-Key": os.environ["GRAVV_API_KEY"],
    "Content-Type": "application/json",
},
json={
    "account_number": "0123456789",
    "institution_id": "2ce313c8-de20-48dd-bfc6-949b8fd5ad84",
},
)
print(response.status_code, response.json())
{
  "data": {
    "verified": true,
    "account_holder_name": "CHINEDU OKECHUKWU EZE",
    "verification_source": "provider"
  },
  "error": null
}

Trust the name only when verification_source is provider. local means the format looks right, not that the bank confirmed it. The verify call covers bank accounts only, never phone numbers.

2. Look for an existing payee

curl --request GET \
 --url 'https://api.gravv.xyz/v1/external-accounts?customer_id=bc426080-4122-4cb1-8d46-ff4ffda26b44&search=0123456789' \
 --header 'Api-Key: <API_KEY>'
const response = await fetch("https://api.gravv.xyz/v1/external-accounts?customer_id=bc426080-4122-4cb1-8d46-ff4ffda26b44&search=0123456789", {
method: "GET",
headers: {
"Api-Key": process.env.GRAVV_API_KEY,
},
});
console.log(response.status, await response.json());
import os

import requests

response = requests.get(
"https://api.gravv.xyz/v1/external-accounts?customer_id=bc426080-4122-4cb1-8d46-ff4ffda26b44&search=0123456789",
headers={
    "Api-Key": os.environ["GRAVV_API_KEY"],
},
)
print(response.status_code, response.json())

If an active payee with this account and bank exists, reuse its id and skip step 3. Gravv's duplicate check can miss a match when the bank's name check has replaced the name, so look first.

3. Save the payee

curl --request POST \
 --url https://api.gravv.xyz/v1/external-accounts \
 --header 'Api-Key: <API_KEY>' \
 --header 'Content-Type: application/json' \
 --header 'Idempotency-Key: 2367a2d6-f34c-43fb-bdb3-0e939145af9c' \
 --data '
{
"customer_id": "bc426080-4122-4cb1-8d46-ff4ffda26b44",
"payee_type": "bank_account",
"currency": "NGN",
"account_name": "Chinedu Eze",
"account_number": "0123456789",
"institution_id": "2ce313c8-de20-48dd-bfc6-949b8fd5ad84",
"institution_country_iso_code": "NG"
}
'
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": "2367a2d6-f34c-43fb-bdb3-0e939145af9c",
},
body: JSON.stringify({
"customer_id": "bc426080-4122-4cb1-8d46-ff4ffda26b44",
"payee_type": "bank_account",
"currency": "NGN",
"account_name": "Chinedu Eze",
"account_number": "0123456789",
"institution_id": "2ce313c8-de20-48dd-bfc6-949b8fd5ad84",
"institution_country_iso_code": "NG"
}),
});
console.log(response.status, await response.json());
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": "2367a2d6-f34c-43fb-bdb3-0e939145af9c",
},
json={
    "customer_id": "bc426080-4122-4cb1-8d46-ff4ffda26b44",
    "payee_type": "bank_account",
    "currency": "NGN",
    "account_name": "Chinedu Eze",
    "account_number": "0123456789",
    "institution_id": "2ce313c8-de20-48dd-bfc6-949b8fd5ad84",
    "institution_country_iso_code": "NG",
},
)
print(response.status_code, response.json())
{
  "data": {
    "account_name": "CHINEDU OKECHUKWU EZE",
    "account_number": "0123456789",
    "customer_id": "bc426080-4122-4cb1-8d46-ff4ffda26b44",
    "currency": "NGN",
    "date_created": "2026-10-13T22:34:50.223013Z",
    "id": "14418474-20a5-4553-86af-17ac34dde144",
    "institution_country_iso_code": "NG",
    "institution_id": "2ce313c8-de20-48dd-bfc6-949b8fd5ad84",
    "institution_name": "GT Bank",
    "institution_type": "bank",
    "payee_type": "bank_account",
    "status": "active"
  },
  "error": null
}

The API returns HTTP 200, not 202, and the payee is active at once. account_name is the holder's name as the bank records it, in upper case, not the name you sent.

4. Send the money

Leave rail out of the request.

curl --request POST \
 --url https://api.gravv.xyz/v1/transfer \
 --header 'Api-Key: <API_KEY>' \
 --header 'Content-Type: application/json' \
 --header 'Idempotency-Key: a70ed229-dbae-470a-84b4-f767c77f7a9b' \
 --data '
{
"customer_id": "bc426080-4122-4cb1-8d46-ff4ffda26b44",
"source": {
"source_type": "internal_account",
"id": "d070ac47-fd7a-4c5a-9a42-badd462c0420"
},
"destination": {
"destination_type": "external_account",
"id": "14418474-20a5-4553-86af-17ac34dde144"
},
"amount": "250.00",
"description": "Family support October",
"client_reference": "ada-ng-2026-10-001"
}
'
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": "a70ed229-dbae-470a-84b4-f767c77f7a9b",
},
body: JSON.stringify({
"customer_id": "bc426080-4122-4cb1-8d46-ff4ffda26b44",
"source": {
  "source_type": "internal_account",
  "id": "d070ac47-fd7a-4c5a-9a42-badd462c0420"
},
"destination": {
  "destination_type": "external_account",
  "id": "14418474-20a5-4553-86af-17ac34dde144"
},
"amount": "250.00",
"description": "Family support October",
"client_reference": "ada-ng-2026-10-001"
}),
});
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": "a70ed229-dbae-470a-84b4-f767c77f7a9b",
},
json={
    "customer_id": "bc426080-4122-4cb1-8d46-ff4ffda26b44",
    "source": {
        "source_type": "internal_account",
        "id": "d070ac47-fd7a-4c5a-9a42-badd462c0420",
    },
    "destination": {
        "destination_type": "external_account",
        "id": "14418474-20a5-4553-86af-17ac34dde144",
    },
    "amount": "250.00",
    "description": "Family support October",
    "client_reference": "ada-ng-2026-10-001",
},
)
print(response.status_code, response.json())
{
  "data": {
    "amount": "250",
    "amount_currency": "USD",
    "reference": "8b56ead0-bbe7-4bb2-aec8-a61ca66bd939",
    "date_created": "2026-10-13T09:15:22.481Z",
    "transfer_status": "pending",
    "transaction_id": "ded00040-b19c-46c9-8e43-b8729bc24a54",
    "local_amount": "382500",
    "local_currency": "NGN",
    "fee": "1.5",
    "fee_currency": "USD",
    "requires_approval": false
  },
  "error": null
}

Before you tell Ada the money is sent, check these points:

  • transfer_status is pending. If it is paused, see Get a stuck remittance moving.
  • local_amount makes sense for the rate. Here, 250 times about 1,530 is about 382,500. If it looks like 250 times 1, or is far off, do not show it. Contact support with the reference.
  • Store reference for a resume and for the webhooks, and transaction_id for polling.

5. Wait for the result

Listen for the webhooks, or poll GET /v1/transactions/ded00040-b19c-46c9-8e43-b8729bc24a54 every 30 to 60 seconds until status is completed or failed.

On the transaction, transaction_type is global_remittance and channel is bank_transfer. metadata names the recipient and the sender but not the local amount. Keep local_amount from the transfer response, and read the fee Gravv charged from the transaction.

Gravv promises no timing. A route that keeps reporting "processing" stays pending.

Paying from an NGN account

If Ada holds an NGN account, use its id as source.id. No currency exchange takes place. When you pay from an NGN account:

  • The payee must receive NGN.
  • The amount has at most 2 decimal places.
  • A zero balance blocks payouts.

What can go wrong

Response Why Fix
400 nigerian account numbers must be exactly 10 digits (NUBAN) The number is not 10 digits Ask for the 10-digit number, then resend with a new Idempotency-Key
400 account number must not contain spaces The number has spaces or dashes Send digits only
400 account verification failed: bank could not resolve account holder: invalid input The bank found no such account Check the number and the bank
400 account verification temporarily unavailable; please retry: invalid input The name check is down Retry later with a new key
400 a recipient with the same account name, account number and bank already exists, with data.payee_id The payee already exists. Deleted and failed payees count Use data.payee_id
400 transfers to NG/NGN are not available at this time The route is not open for your business Contact support
400 transfers to NG/NGN are not supported at this time No route can pay this country and currency now Contact support
400 transfers from this account to NG/NGN are not supported at this time The source account cannot pay this route Pay from a different account
400 this payee is not yet set up for transfers — please contact support to complete its setup The payee has no institution_id from the institutions list Save the payee with an institution_id
400 the payee is missing required information for this transfer type — please update the payee details The route needs a bank code that Gravv's record of the bank lacks. Your fields are usually fine Contact support with the payee id. Saving the payee again does not help
400 payee … is not active: deleted The payee was deleted Save a new payee
400 insufficient balance The amount plus the fee is more than the balance Fund the account, then retry with a new key
400 FX rate unavailable for currency conversion No rate is available right now Retry shortly with a new key
400 the selected source account cannot be used for an international transfer This account cannot send abroad Use the customer's USD account
400 the selected source account is not compatible with this transfer — a crypto wallet account is required The route pays only from a wallet-backed account Use a wallet-backed USD account
400 payee type "bank_account" does not accept ACH/Wire transfers You sent "rail": "wire" Leave rail out
local_amount far from the published rate A wrong rate on Gravv's side Do not show it. Contact support with the reference before the payout completes
transfer.status.failed, transaction failed, and a transfer_refund row The bank refused the payout Gravv refunds the amount and fee. Check the details and send again
transfer.status.failed, but the transaction is still pending The transfer is in manual review. The outcome was unclear, so a person checks it Do not send it again. Contact support with the reference
transfer.status.returned after completed The bank sent the money back Gravv refunds the customer. Fix the payee and send again

Webhooks you will receive

  1. external_account.created, with event_data.status active.
  2. transfer.status.pending, several times, one per internal step, with workflow_type global_remittance. Treat each one as a sign that the transfer is still in progress.
  3. transfer.status.completed or transfer.status.failed. Rarely, transfer.status.returned later.

Each event_data carries amount, associated_transactions, client_reference, destination_id (the payee), reference, status, transfer_status and workflow_type.

Testing in sandbox

  • Every naira account check returns account_holder_name SIM ACCOUNT HOLDER, and the payee takes that name.
  • A 5.00 USD payout from a USDC wallet (source_type internal_crypto_wallet) to a Nigerian bank payee completed in about 2 minutes.
  • An NGN account funded with POST /v2/accounts/simulate/deposit can pay an NGN bank payee end to end.
  • A new sandbox customer cannot open an NGN account until its KYC is completed.
  • New sandbox USD accounts can stay in review, so a payout from a USD account may not run in sandbox.

Reference

Was this page helpful?