Re-quote without selling twice
When a quote expires or the order call fails, get a new price and end up with exactly one order.
This recipe gets a new price when a quote expires or the order call fails, so that you end up with exactly one order.
Who it is for. Any app where a person looks at a price before confirming, so quotes expire and calls time out.
Status. Available.
Money example
Bola wants to sell 200,000 NGN.
- At 10:00 you get a quote of 130 USDC, which expires at 10:05.
- Bola confirms at 10:07. The order returns
quote is invalid or expired, and Gravv takes nothing. - You get a new quote with a new key. The new quote is 129.6 USDC and expires at 10:12. Bola confirms the new price.
- You place the order with the new quote, and Gravv takes the 200,000 NGN once.
Before you start
- The prerequisites of Convert naira to USDC.
- Keep your own record for each conversion: your
client_reference, the keys you used, and theorder_idonce you have it.
Find out what is required
The quote's expires_at is the deadline. A quote lasts about 5 minutes, but always read the field. You can use a quote once.
Steps
1. First quote
curl --request POST \
--url https://api.gravv.xyz/v1/fx/quote \
--header 'Api-Key: <API_KEY>' \
--header 'Idempotency-Key: 6e4e441e-707e-48aa-a0af-05af5807b43c' \
--header 'Content-Type: application/json' \
--data '
{
"currency_pair": "NGN_USDC",
"amount": "200000"
}
'
const response = await fetch("https://api.gravv.xyz/v1/fx/quote", {
method: "POST",
headers: {
"Api-Key": process.env.GRAVV_API_KEY,
"Idempotency-Key": "6e4e441e-707e-48aa-a0af-05af5807b43c",
"Content-Type": "application/json",
},
body: JSON.stringify({
"currency_pair": "NGN_USDC",
"amount": "200000"
}),
});
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": "6e4e441e-707e-48aa-a0af-05af5807b43c",
"Content-Type": "application/json",
},
json={
"currency_pair": "NGN_USDC",
"amount": "200000",
},
)
print(response.status_code, response.json())
2. An order after expiry
If you place the order after expires_at, the API returns 400 quote is invalid or expired. No money moves.
3. Re-quote with a new key
Send the same body with a new Idempotency-Key. Reusing the first key replays the old, expired quote.
curl --request POST \
--url https://api.gravv.xyz/v1/fx/quote \
--header 'Api-Key: <API_KEY>' \
--header 'Idempotency-Key: 80d28a9d-50a7-4e1a-ba6a-61db6e79dd8d' \
--header 'Content-Type: application/json' \
--data '
{
"currency_pair": "NGN_USDC",
"amount": "200000"
}
'
const response = await fetch("https://api.gravv.xyz/v1/fx/quote", {
method: "POST",
headers: {
"Api-Key": process.env.GRAVV_API_KEY,
"Idempotency-Key": "80d28a9d-50a7-4e1a-ba6a-61db6e79dd8d",
"Content-Type": "application/json",
},
body: JSON.stringify({
"currency_pair": "NGN_USDC",
"amount": "200000"
}),
});
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": "80d28a9d-50a7-4e1a-ba6a-61db6e79dd8d",
"Content-Type": "application/json",
},
json={
"currency_pair": "NGN_USDC",
"amount": "200000",
},
)
print(response.status_code, response.json())
{
"data": {
"quote_id": "GRVOTC_4E5F60",
"from_currency": "NGN",
"to_currency": "USDC",
"to_amount": "129.6",
"from_amount": "200000",
"rate": "0.000648",
"expires_at": "2026-10-13T10:12:00Z"
},
"error": null
}
Show the new price and ask Bola again.
4. Order with a new key and the same reference
curl --request POST \
--url https://api.gravv.xyz/v1/fx/orders \
--header 'Api-Key: <API_KEY>' \
--header 'Idempotency-Key: dd9aa615-0c74-4c23-ad8f-ca9dacc48f71' \
--header 'Content-Type: application/json' \
--data '
{
"quote_id": "GRVOTC_4E5F60",
"source": {
"type": "internal_account",
"id": "bd9c66b3-ad3c-4d6d-9a3d-1fa7bc8960a9"
},
"destination": {
"type": "internal_crypto_wallet",
"id": "972a8469-1641-4f82-8b9d-2434e465e150"
},
"client_reference": "conv-bola-0001"
}
'
const response = await fetch("https://api.gravv.xyz/v1/fx/orders", {
method: "POST",
headers: {
"Api-Key": process.env.GRAVV_API_KEY,
"Idempotency-Key": "dd9aa615-0c74-4c23-ad8f-ca9dacc48f71",
"Content-Type": "application/json",
},
body: JSON.stringify({
"quote_id": "GRVOTC_4E5F60",
"source": {
"type": "internal_account",
"id": "bd9c66b3-ad3c-4d6d-9a3d-1fa7bc8960a9"
},
"destination": {
"type": "internal_crypto_wallet",
"id": "972a8469-1641-4f82-8b9d-2434e465e150"
},
"client_reference": "conv-bola-0001"
}),
});
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": "dd9aa615-0c74-4c23-ad8f-ca9dacc48f71",
"Content-Type": "application/json",
},
json={
"quote_id": "GRVOTC_4E5F60",
"source": {
"type": "internal_account",
"id": "bd9c66b3-ad3c-4d6d-9a3d-1fa7bc8960a9",
},
"destination": {
"type": "internal_crypto_wallet",
"id": "972a8469-1641-4f82-8b9d-2434e465e150",
},
"client_reference": "conv-bola-0001",
},
)
print(response.status_code, response.json())
5. If the order call times out or answers a 5xx
Retry with the same key and the same body. The API returns the result of the first call. While the first call still runs, the API returns 409 idempotency_in_flight with Retry-After: 1. Wait, then retry.
6. If the order could not be charged
On 400 order source could not be charged, list your pending orders and look for your client_reference. The list has no reference or date filter, so page from the newest order.
curl --request GET \
--url 'https://api.gravv.xyz/v1/fx/orders?status=pending&items_per_page=100' \
--header 'Api-Key: <API_KEY>'
const response = await fetch("https://api.gravv.xyz/v1/fx/orders?status=pending&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/fx/orders?status=pending&items_per_page=100",
headers={
"Api-Key": os.environ["GRAVV_API_KEY"],
},
)
print(response.status_code, response.json())
- If you find the order and it is
pending, the debit may have happened. Do not place another order. Follow it and give support itsid. - If you do not find the order, or it is
cancelled, Gravv took nothing. Re-quote and start again.
What can go wrong
| Response | Why | Fix |
|---|---|---|
400 quote is invalid or expired |
The quote passed expires_at, was already used, or belongs to another business |
Get a new quote with a new key, and show the new price |
| The quote call returns an expired quote | You reused the key | Use a new key for every new quote |
422 idempotency_key_reused on the order |
The same key with another body, such as a new quote_id |
Use a new key for each new quote |
| A 400 comes back after you fixed the body | Gravv replays 4xx responses for 24 hours under the same key | Use a new key after any 4xx |
429 Too many request |
More than 600 requests a minute | Back off, then retry with a new key |
400 order source could not be charged |
The debit was refused or its outcome is not known | Follow step 6 before anything else |
503 service_unavailable |
FX is unreachable | Retry later. For an order call, follow step 6 before you place the order again |
| You want "buy exactly 1,000 USDC" | Quotes take the amount you sell | Work out the sell amount from a display rate, quote it, and show the real to_amount |
Webhooks you will receive
Gravv sends no webhooks for FX orders.
Testing in sandbox
The sandbox needs an OTC rate for any quote. Without one, you can still test the error paths:
- An unknown
quote_idreturnsquote is invalid or expired. - A repeated key with the same body replays the 400.
- The same key with another body returns 422
idempotency_key_reused.