Convert naira to USDC
Sell a customer's naira from their NGN account for USDC in their own USDC wallet, at a quoted rate.
This recipe sells a customer's naira from their NGN account for USDC in their own USDC wallet, at a quoted rate.
Who it is for. A Lagos savings app whose customers keep naira on Gravv and want to move part of it into USDC in their own Gravv wallet.
Status. Available once Gravv sets an OTC rate for your business.
Money example
Chidi has 1,600,000 NGN in his NGN account, and a USDC wallet.
- You get a quote to sell 1,530,000 NGN for USDC. The rate is 0.00065, so Chidi gets 994.5 USDC. The price holds until
expires_at. - Chidi taps "Convert". You place the order with that quote. Gravv takes 1,530,000 NGN from his NGN account at once, and the order is
pending. - 994.5 USDC reaches his USDC wallet, and the order is
completed.
Before you start
- The NGN account is
active, in NGN, and holds the sell amount plus any transfer fee on your pricing. The account is held on a wallet, so it haswallet_addressandblockchain_network. - The USDC wallet belongs to the same customer.
- A scoped key needs
fx:readfor the quote andfx:order_createfor the order.
Find out what is required
The quote is the real check. For a pair not priced for your business, the quote returns quote currency pair is not supported. Rates from GET /v1/fx/rates are for display only.
Read the source account:
curl --request GET \
--url https://api.gravv.xyz/v2/accounts/bd9c66b3-ad3c-4d6d-9a3d-1fa7bc8960a9 \
--header 'Api-Key: <API_KEY>'
const response = await fetch("https://api.gravv.xyz/v2/accounts/bd9c66b3-ad3c-4d6d-9a3d-1fa7bc8960a9", {
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/v2/accounts/bd9c66b3-ad3c-4d6d-9a3d-1fa7bc8960a9",
headers={
"Api-Key": os.environ["GRAVV_API_KEY"],
},
)
print(response.status_code, response.json())
currency must be NGN and balance must cover the sell amount. Gravv checks only the balance number, not the account's currency, so you must pick the right account.
Steps
1. Check the destination wallet
Gravv does not check that an internal destination can hold the currency you buy.
curl --request GET \
--url https://api.gravv.xyz/v1/wallets/972a8469-1641-4f82-8b9d-2434e465e150 \
--header 'Api-Key: <API_KEY>'
const response = await fetch("https://api.gravv.xyz/v1/wallets/972a8469-1641-4f82-8b9d-2434e465e150", {
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/wallets/972a8469-1641-4f82-8b9d-2434e465e150",
headers={
"Api-Key": os.environ["GRAVV_API_KEY"],
},
)
print(response.status_code, response.json())
Check that customer_id is Chidi's id and that a USDC asset is listed.
2. Get a quote
curl --request POST \
--url https://api.gravv.xyz/v1/fx/quote \
--header 'Api-Key: <API_KEY>' \
--header 'Idempotency-Key: e19fb31b-9e07-4fea-9e0b-5d5661e2cb51' \
--header 'Content-Type: application/json' \
--data '
{
"currency_pair": "NGN_USDC",
"amount": "1530000"
}
'
const response = await fetch("https://api.gravv.xyz/v1/fx/quote", {
method: "POST",
headers: {
"Api-Key": process.env.GRAVV_API_KEY,
"Idempotency-Key": "e19fb31b-9e07-4fea-9e0b-5d5661e2cb51",
"Content-Type": "application/json",
},
body: JSON.stringify({
"currency_pair": "NGN_USDC",
"amount": "1530000"
}),
});
console.log(response.status, await response.json());
import os
import requests
response = requests.post(
"https://api.gravv.xyz/v1/fx/quote",
headers={
"Api-Key": os.environ["GRAVV_API_KEY"],
"Idempotency-Key": "e19fb31b-9e07-4fea-9e0b-5d5661e2cb51",
"Content-Type": "application/json",
},
json={
"currency_pair": "NGN_USDC",
"amount": "1530000",
},
)
print(response.status_code, response.json())
{
"data": {
"quote_id": "GRVOTC_7A41C2",
"from_currency": "NGN",
"to_currency": "USDC",
"to_amount": "994.5",
"from_amount": "1530000",
"rate": "0.00065",
"expires_at": "2026-10-13T10:05:00Z"
},
"error": null
}
amount is always what you sell. rate is units of USDC per 1 NGN. to_amount is not rounded, so round it for display only. Show Chidi to_amount and expires_at, and get his confirmation.
3. Place the order
Place the order before expires_at. Do not send an amount, because the quote sets it.
curl --request POST \
--url https://api.gravv.xyz/v1/fx/orders \
--header 'Api-Key: <API_KEY>' \
--header 'Idempotency-Key: 7ffdf36d-8cbf-418d-ab68-5bb07a6bacd2' \
--header 'Content-Type: application/json' \
--data '
{
"quote_id": "GRVOTC_7A41C2",
"source": {
"type": "internal_account",
"id": "bd9c66b3-ad3c-4d6d-9a3d-1fa7bc8960a9"
},
"destination": {
"type": "internal_crypto_wallet",
"id": "972a8469-1641-4f82-8b9d-2434e465e150"
},
"client_reference": "conv-chidi-000187",
"metadata": {
"app_user": "chidi"
}
}
'
const response = await fetch("https://api.gravv.xyz/v1/fx/orders", {
method: "POST",
headers: {
"Api-Key": process.env.GRAVV_API_KEY,
"Idempotency-Key": "7ffdf36d-8cbf-418d-ab68-5bb07a6bacd2",
"Content-Type": "application/json",
},
body: JSON.stringify({
"quote_id": "GRVOTC_7A41C2",
"source": {
"type": "internal_account",
"id": "bd9c66b3-ad3c-4d6d-9a3d-1fa7bc8960a9"
},
"destination": {
"type": "internal_crypto_wallet",
"id": "972a8469-1641-4f82-8b9d-2434e465e150"
},
"client_reference": "conv-chidi-000187",
"metadata": {
"app_user": "chidi"
}
}),
});
console.log(response.status, await response.json());
import os
import requests
response = requests.post(
"https://api.gravv.xyz/v1/fx/orders",
headers={
"Api-Key": os.environ["GRAVV_API_KEY"],
"Idempotency-Key": "7ffdf36d-8cbf-418d-ab68-5bb07a6bacd2",
"Content-Type": "application/json",
},
json={
"quote_id": "GRVOTC_7A41C2",
"source": {
"type": "internal_account",
"id": "bd9c66b3-ad3c-4d6d-9a3d-1fa7bc8960a9",
},
"destination": {
"type": "internal_crypto_wallet",
"id": "972a8469-1641-4f82-8b9d-2434e465e150",
},
"client_reference": "conv-chidi-000187",
"metadata": {
"app_user": "chidi",
},
},
)
print(response.status_code, response.json())
{
"data": {
"order_id": "17fc695a-07a0-4a6e-8822-e8f36c031199",
"quote_id": "GRVOTC_7A41C2",
"status": "pending"
},
"error": null
}
The naira debit has already started. Save order_id.
4. Follow the order
curl --request GET \
--url https://api.gravv.xyz/v1/fx/orders/17fc695a-07a0-4a6e-8822-e8f36c031199 \
--header 'Api-Key: <API_KEY>'
const response = await fetch("https://api.gravv.xyz/v1/fx/orders/17fc695a-07a0-4a6e-8822-e8f36c031199", {
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/fx/orders/17fc695a-07a0-4a6e-8822-e8f36c031199",
headers={
"Api-Key": os.environ["GRAVV_API_KEY"],
},
)
print(response.status_code, response.json())
Poll until status is completed, cancelled or rejected. pending covers both "debit running" and "debited, waiting to settle". There is no progress field.
5. See where the money went
- For the naira,
GET /v1/transactions?account_id=bd9c66b3-ad3c-4d6d-9a3d-1fa7bc8960a9&direction=outshows an outgoing row of 1,530,000 NGN whosedescriptionisOTC order 17fc695a-07a0-4a6e-8822-e8f36c031199 source debit. - For the USDC,
GET /v1/wallets/972a8469-1641-4f82-8b9d-2434e465e150shows the balance 994.5 higher once the order iscompleted.
What can go wrong
| Response | Why | Fix |
|---|---|---|
400 quote currency pair is not supported |
The pair is not priced for your business, or its format is wrong | Send FROM_TO (NGN_USDC). If the format is right, ask for the pair |
400 quote amount must be a positive number |
The amount is missing, zero or negative | Send a positive decimal string |
400 invalid request encoding |
You sent amount as a number |
Send a string, such as "amount": "1530000" |
400 quote is invalid or expired on the order |
The quote is expired, already used, another business's, or missing. The API checks the quote first | Re-quote with a new key |
400 invalid order source / invalid order destination |
The type is not allowed, or the id is missing |
Use internal_account or internal_crypto_wallet as the source. Use either of those or external_account as the destination |
400 bad source: [400] invalid internal account id |
The id is unknown, or belongs to another business | Use your own account id |
400 bad source: [400] source balance is not enough for currency: NGN, amount: 1530000 |
The source balance is below the sell amount | Fund the source, then re-quote |
400 bad destination: [400] invalid order destination |
The destination is unknown or not yours | Fix the id |
400 bad source: [503] service unavailable: … |
A lookup did not respond | Retry later with a new quote and key |
400 order source could not be charged |
The debit was refused, or its outcome is not known yet | Do not place it again yet. Look for the order |
403 feature_access_required |
Your business has no OTC trade desk access | Request access |
403 forbidden, required_any fx:order_create |
The scoped key lacks the permission | Grant fx:order_create to the key |
The order stays pending a long time |
The order is waiting to settle, and you have nothing to send | Keep polling, and contact support with order_id. Do not cancel |
The order is cancelled without you cancelling |
The naira debit failed and moved nothing | Check the balance and re-quote |
Webhooks you will receive
Gravv sends no webhooks for an FX order. Poll the order.
Testing in sandbox
The sandbox has no OTC rates today. Every quote returns quote currency pair is not supported. Ask support to set a rate on your sandbox business before you build the flow.