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.

  1. 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.
  2. 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.
  3. 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 has wallet_address and blockchain_network.
  • The USDC wallet belongs to the same customer.
  • A scoped key needs fx:read for the quote and fx:order_create for 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=out shows an outgoing row of 1,530,000 NGN whose description is OTC order 17fc695a-07a0-4a6e-8822-e8f36c031199 source debit.
  • For the USDC, GET /v1/wallets/972a8469-1641-4f82-8b9d-2434e465e150 shows the balance 994.5 higher once the order is completed.

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.

Reference

Was this page helpful?