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
- Ada's USD account holds 1,000.00 USD.
- She sends 250.00 USD. Say the fee on your plan is 1.50 USD, so 251.50 USD leaves her account.
- Say the rate is 1,530.00 NGN per USD. The transfer response shows 382,500.00 NGN for Chinedu in
local_amount. - His bank credits the naira, and you receive
transfer.status.completed.
Before you start
- Ada is one of your customers (
GET /v1/customers/{customer_id}). - Her USD account is
activeand holds the amount plus the fee (GET /v2/accounts/{id}). - You have Chinedu's 10-digit account number (NUBAN) and his bank's name.
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_statusispending. If it ispaused, see Get a stuck remittance moving.local_amountmakes 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 thereference.- Store
referencefor a resume and for the webhooks, andtransaction_idfor 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
external_account.created, withevent_data.statusactive.transfer.status.pending, several times, one per internal step, withworkflow_typeglobal_remittance. Treat each one as a sign that the transfer is still in progress.transfer.status.completedortransfer.status.failed. Rarely,transfer.status.returnedlater.
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_nameSIM ACCOUNT HOLDER, and the payee takes that name. - A 5.00 USD payout from a USDC wallet (
source_typeinternal_crypto_wallet) to a Nigerian bank payee completed in about 2 minutes. - An NGN account funded with
POST /v2/accounts/simulate/depositcan 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.