Astro Developer API
Give each of your users their own crypto wallet, get notified the moment they receive crypto, sell crypto for naira, swap coins, send to outside wallets and sell gift cards — through one REST API.
Create users with your own reference and give them deposit addresses on supported coins and networks.
Receive a deposit.received event, signed with your secret, when one of your users is paid.
Crypto USD prices and gift card rates per $1 in naira and cedis.
https://api.astroafrica.site/v1/partnerSent to you with your test keyTrading is open in the sandbox. Selling, swaps, withdrawals and gift card sales work with test keys now. They are switched on for live accounts at launch, after your business is approved.
Getting access
- Request a sandbox account. Email
support@myastroafrica.comwith your business name and a technical contact. You get a test key (ak_test_…) and the sandbox address. - Build against the sandbox. Sandbox wallets are not real and never hold real funds. Use sandbox deposits to send yourself pretend deposits and test your webhook handling end to end.
- Complete the business check (KYB). Company registration (the CAC number in Nigeria) and an identity check for each director: BVN, ID selfie and address.
- Go live. Once your business is approved, you receive a live key (
ak_live_…) for the live base URL.
Quickstart
Three calls take you from a new user to an address they can be paid on.
# 1. Create a user with your own reference
curl -X POST "$ASTRO_BASE/users" \
-H "Authorization: Bearer $ASTRO_KEY" -H "Content-Type: application/json" \
-d '{"reference":"cust_8812","first_name":"Ada","last_name":"Okafor"}'
# 2. Ask for a USDT (TRC20) deposit address
curl -X POST "$ASTRO_BASE/users/ref:cust_8812/addresses" \
-H "Authorization: Bearer $ASTRO_KEY" -H "Content-Type: application/json" \
-d '{"currency":"usdt","network":"trc20"}'
# 3. Read it until status is "ready"
curl "$ASTRO_BASE/users/ref:cust_8812/addresses/usdt" \
-H "Authorization: Bearer $ASTRO_KEY"
const base = process.env.ASTRO_BASE;
const headers = { Authorization: `Bearer ${process.env.ASTRO_KEY}`, "Content-Type": "application/json" };
const call = async (method, path, body) => {
const res = await fetch(base + path, { method, headers, body: body && JSON.stringify(body) });
const json = await res.json();
if (!res.ok) throw new Error(`${json.error.code}: ${json.error.message}`);
return json.data;
};
await call("POST", "/users", { reference: "cust_8812", first_name: "Ada", last_name: "Okafor" });
await call("POST", "/users/ref:cust_8812/addresses", { currency: "usdt", network: "trc20" });
let addr;
do {
await new Promise((r) => setTimeout(r, 3000));
[addr] = await call("GET", "/users/ref:cust_8812/addresses/usdt");
} while (addr?.status !== "ready");
console.log(addr.address);
import os, time, requests
BASE = os.environ["ASTRO_BASE"]
S = requests.Session()
S.headers["Authorization"] = f"Bearer {os.environ['ASTRO_KEY']}"
def call(method, path, body=None):
r = S.request(method, BASE + path, json=body, timeout=20)
data = r.json()
if not r.ok:
raise RuntimeError(f"{data['error']['code']}: {data['error']['message']}")
return data["data"]
call("POST", "/users", {"reference": "cust_8812", "first_name": "Ada", "last_name": "Okafor"})
call("POST", "/users/ref:cust_8812/addresses", {"currency": "usdt", "network": "trc20"})
while True:
time.sleep(3)
addr = call("GET", "/users/ref:cust_8812/addresses/usdt")[0]
if addr["status"] == "ready":
print(addr["address"]); break
Authentication
Send your key as a bearer token on every request.
Authorization: Bearer ak_live_xxxxxxxx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
- Keys are shown once, when they are created. Astro stores only a fingerprint and cannot recover a key; a lost key is revoked and replaced.
- Test keys work only on the sandbox and live keys only on live. A key used in the wrong place returns
wrong_environment. - You can hold several keys (for example, one per server). A revoked key stops working immediately.
- Keys can be restricted to a list of your server IP addresses.
- Keep keys on your servers. Never ship a key in a mobile app, a web page or a public repository.
Requests and responses
Send JSON with Content-Type: application/json (bodies up to 50 KB). Successful responses are wrapped in data. Errors are wrapped in error, with a stable code to program against and a human-readable message.
{ "data": { "id": 401, "reference": "cust_8812" } }
{ "error": { "code": "user_not_found", "message": "User not found." } }
Every response includes X-Astro-Mode: test or live, showing which kind of key made the call. Amounts are always strings, so no precision is lost.
Account
Confirms your key works and returns your partner account.
| Field | Description |
|---|---|
status | pending, active or suspended. |
mode | The kind of key used: test or live. |
environment | sandbox or live. |
sandbox_wallets | true when sandbox deposits are available (sandbox only). |
webhook_url | Where events are sent, or null. |
{
"data": {
"id": 12,
"name": "Acme Pay",
"country": "NG",
"status": "active",
"mode": "live",
"environment": "live",
"webhook_url": "https://acme.example/astro/webhooks"
}
}
Users
A user is one of your customers. You identify each user with your own reference, and Astro returns an id. Wherever a path takes :id, you can pass the Astro id or ref:<your reference>.
Creates a user and sets up their wallet.
| Field | Type | Description |
|---|---|---|
reference required | string | Your ID for this user: 1–64 letters, digits, . - or _. Unique in your account. |
first_name required | string | At least 2 characters. |
last_name required | string | At least 2 characters. |
email | string | The user's email, if you have it. |
Safe to retry. If the wallet can't be set up right away you get 502 wallet_provider_error; send the same request again with the same reference to finish. A reference that is already fully set up returns 409 user_exists.
curl -X POST "$ASTRO_BASE/users" \
-H "Authorization: Bearer $ASTRO_KEY" \
-H "Content-Type: application/json" \
-d '{
"reference": "cust_8812",
"first_name": "Ada",
"last_name": "Okafor",
"email": "ada@example.com"
}'
{
"data": {
"id": 401,
"reference": "cust_8812",
"email": "ada@example.com",
"first_name": "Ada",
"last_name": "Okafor",
"status": "active",
"kyc_status": "none",
"created_at": "2026-11-09T19:02:11.000Z"
}
}
Returns one user in the same shape as above. GET /users/ref:cust_8812 also works.
curl "$ASTRO_BASE/users/ref:cust_8812" \
-H "Authorization: Bearer $ASTRO_KEY"Lists your users, newest first.
| Query | Description |
|---|---|
page | Page number, from 1. |
per_page | 1–100. Default 50. |
{
"data": {
"users": [ { "id": 401, "reference": "cust_8812", "status": "active" } ],
"page": 1,
"per_page": 50,
"total": 1
}
}Deposit addresses
Creates the user's deposit address for a coin on a network. If the user already has an address on that network, it is returned instead.
| Field | Type | Description |
|---|---|---|
currency required | string | Coin code, for example usdt or btc. |
network | string | Required for coins with more than one network. Valid names come from GET /rates. |
A new address often starts as pending with address: null. Read it every few seconds until status is ready, then show it to your user.
curl -X POST "$ASTRO_BASE/users/ref:cust_8812/addresses" \
-H "Authorization: Bearer $ASTRO_KEY" \
-H "Content-Type: application/json" \
-d '{ "currency": "usdt", "network": "trc20" }'
{
"data": {
"id": "a1b2c3",
"currency": "usdt",
"network": "trc20",
"address": null,
"destination_tag": null,
"status": "pending"
}
}
Returns all of the user's addresses for one coin, as a list in the shape above.
Tell your users to send only the matching coin on the matching network. Crypto sent on the wrong network, or as a different coin, can't be recovered. When an address has a destination_tag, it must be included.
{
"data": [
{
"id": "a1b2c3",
"currency": "usdt",
"network": "trc20",
"address": "TQ5x…9vKp",
"destination_tag": null,
"status": "ready"
}
]
}Balances
Returns the user's crypto balances. locked is the part held for an action in progress.
{
"data": [
{ "currency": "usdt", "balance": "125.50", "locked": "0.0" },
{ "currency": "btc", "balance": "0.0012", "locked": "0.0" }
]
}Rates
Supported coins with their networks and USD price, plus every gift card rate that is live right now, per $1 of card value. Cards without a live rate are left out.
sell_ngn is Astro's naira rate for selling each coin, in bands by the sale's dollar value. Use entry_id to sell a gift card.
Rates move during the day. Read them fresh before showing a price to a user. The values in the example are illustrations, not quotes.
{
"data": {
"crypto": [
{
"currency": "usdt",
"name": "Tether",
"usd_price": 1,
"networks": ["trc20", "bep20", "erc20"],
"sell_ngn": [
{ "min_usd": 1, "max_usd": null, "ngn_per_usd": 1360 }
]
}
],
"giftcards": [
{
"entry_id": 214,
"card": "Steam",
"uid": "steam",
"range": "$50–$100",
"description": "USA, physical",
"ngn_per_usd": 1000,
"ghs_per_usd": null
}
],
"as_of": "2026-11-09T19:05:00.000Z"
}
}Sell to naira
Sell a user's crypto for naira at Astro's rate. The naira goes to your Astro naira balance; you then pay your user in your own app. Naira only. There is no fee: the price is the sell_ngn rate.
Body: currency, amount (in the coin). Returns a quote that holds its price for 30 seconds.
Body: quote_id, reference (your own id for this sale, unique). Each quote works once. Sending the same reference again returns the same sale, so retries are safe.
The sale is processing until the coin has moved, usually seconds, then completed. You get sell.completed or sell.failed.
{
"data": {
"id": 5012,
"type": "sell",
"status": "quoted",
"reference": null,
"user": { "id": 401, "reference": "cust_8812" },
"from": { "currency": "usdt", "amount": "10" },
"to": { "currency": "ngn", "amount": "13600" },
"fee": "0",
"rate": { "ngn_per_usd": 1360, "usd_value": "10.00" },
"expires_at": "2026-11-09T19:20:30.000Z"
}
}Swaps
Swap one coin for another inside the user's wallet. Astro's fee is 0.5% of the amount, taken in the coin being swapped. The smallest swap is $1 worth.
Body: from_currency, to_currency, amount. The quote shows the fee and what the user receives, and lasts 30 seconds.
Body: quote_id, reference. If the price has moved more than 1% against the user since the quote, the swap is refused with price_changed and nothing moves. You get swap.completed.
{
"data": {
"id": 5013,
"type": "swap",
"status": "completed",
"reference": "swap_77",
"from": { "currency": "btc", "amount": "0.005" },
"to": { "currency": "usdt", "amount": "372.28" },
"fee": "0.000025",
"fee_currency": "btc",
"price": "74830.5"
}
}Withdrawals
Send crypto from a user's wallet to an outside address. Astro charges only the network fee, which comes from the user's balance on top of the amount.
The network fee and the total that will leave the balance.
Body: currency, network, address, amount, reference, and memo for XRP and XLM when the receiving wallet needs one. The address is checked against the network before anything is sent. You get withdrawal.completed (with txid) or withdrawal.failed.
Crypto sent on the wrong network, or to a wrong address, can't be recovered. Show your user the network clearly before they confirm.
{
"data": {
"id": 5014,
"type": "withdraw",
"status": "processing",
"reference": "wd_3391",
"from": { "currency": "usdt", "amount": "5" },
"to": { "currency": "usdt", "amount": "5" },
"network": "trc20",
"address": "TJCn…sxMW",
"memo": null,
"network_fee": "1",
"txid": null
}
}Gift cards
Submit a user's gift card for Astro to review. Approved cards are paid in naira to your Astro naira balance at the card's rate on GET /rates, fixed when you submit.
Body: entry_id (from giftcards on GET /rates), amount (the card's value in US dollars), images (1 to 6 https links to clear photos of the card, and the receipt if there is one), reference, and an optional note.
Our team reviews each card. You get giftcard.approved with the naira paid, or giftcard.declined with the reason. A card can be approved for less than its stated value if part of it is already used. A submitted card can't be cancelled.
{
"data": {
"id": 5015,
"type": "giftcard",
"status": "processing",
"reference": "gc_120",
"from": { "currency": "usd", "amount": "100" },
"to": { "currency": "ngn", "amount": "100000", "amount_is_estimate": true },
"card": "Steam",
"card_range": "$50–$100",
"ngn_per_usd": 1000
}
}Trades and balance
One trade, or a paged list of sells, swaps, withdrawals, gift card sales and payouts (type is sell, swap, withdraw, giftcard or payout). status is processing, completed or failed. A failed trade has a failure with a code and message.
Your Astro naira balance: sales and approved gift cards add to it. Astro pays it to your company bank account. Each payout appears as a payout trade with the bank transfer reference, and you get a payout.completed webhook.
{ "data": { "currency": "ngn", "balance": "13600.00" } }Webhooks
Astro sends events as POST requests with a JSON body to an https URL on a public address. Respond with any 2xx within 10 seconds. Redirects are not followed.
| Event | Sent when |
|---|---|
deposit.received | Crypto arrives at one of your users' addresses. |
deposit.on_hold | A deposit is held for review and is not in the balance yet. If ID is needed, Astro tells you what to collect from your user. |
deposit.rejected | A deposit was refused and is not in the balance. Contact Astro support with the deposit id. |
user.address_ready | A new deposit address is ready to show, so you don't need to poll. |
sell.completed, sell.failed | A sale finished. Completed sales are in your naira balance. |
swap.completed | A swap finished. |
withdrawal.completed, withdrawal.failed | A withdrawal was sent (with txid), or didn't go out and nothing was taken. |
giftcard.approved, giftcard.declined | Our team finished reviewing a gift card. |
payout.completed | Astro paid naira from your balance to your company bank account. |
webhook.test | You call POST /webhook/test. |
POST /astro/webhooks
Content-Type: application/json
X-Astro-Event: deposit.received
X-Astro-Signature: t=1794250000,v1=5f2c…
{
"id": "evt_9031",
"created_at": "2026-11-09T19:10:42.000Z",
"event": "deposit.received",
"data": {
"user": { "id": 401, "reference": "cust_8812" },
"deposit": {
"id": "dep_77a1",
"currency": "btc",
"amount": "0.02",
"network": "bitcoin",
"txid": "8f3e…",
"status": "accepted",
"fee": "0.0001",
"fee_percent": 0.5,
"credited_amount": "0.0199",
"fee_status": "collected"
}
}
}
| Deposit field | Meaning |
|---|---|
amount | What arrived on the blockchain. |
fee, fee_percent | Astro's deposit fee. "0" on USDT and USDC. |
credited_amount | What was added to the user's balance. Credit your user with this figure. |
fee_status | none (no fee), collected, or not_collected (we couldn't take it, so the user keeps the full amount). |
| Behaviour | Details |
|---|---|
| Retries | Without a 2xx, we retry after 30 seconds, 2 minutes, 10 minutes and 45 minutes: 5 attempts in about an hour. |
| Duplicates | Each deposit or trade makes one event, but you may receive it more than once. Use the event id to ignore repeats. |
| Trade events | data.trade is the same object GET /trades/:id returns. |
| Ordering | Events can arrive out of order. Use created_at. |
Sends a webhook.test event to your URL, so you can check your signature code end to end.
{ "data": { "queued": true, "event_id": "evt_9032" } }Verifying signatures
Every event is signed with your webhook secret (whsec_…, shown once when it is created). The X-Astro-Signature header is t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of t + "." + raw body.
- Use the raw request body exactly as received, not re-encoded JSON.
- Compare in constant time.
- Reject signatures older than 5 minutes.
const crypto = require("crypto");
function verifyAstro(secret, rawBody, header, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec || !parts.v1) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
const got = Buffer.from(parts.v1, "hex");
return got.length === expected.length && crypto.timingSafeEqual(got, expected);
}
// Express: keep the raw body for this route
app.post("/astro/webhooks", express.raw({ type: "application/json" }), (req, res) => {
const ok = verifyAstro(process.env.ASTRO_WEBHOOK_SECRET, req.body.toString("utf8"),
req.get("X-Astro-Signature") || "");
if (!ok) return res.sendStatus(400);
const event = JSON.parse(req.body);
// Skip if event.id was already handled, then do the work.
res.sendStatus(200);
});
import hashlib, hmac, time
def verify_astro(secret: str, raw_body: bytes, header: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
try:
t = int(parts["t"])
except (KeyError, ValueError):
return False
if abs(time.time() - t) > tolerance or "v1" not in parts:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"])
function verify_astro(string $secret, string $rawBody, string $header, int $tolerance = 300): bool {
$parts = [];
foreach (explode(",", $header) as $p) {
[$k, $v] = array_pad(explode("=", $p, 2), 2, null);
$parts[trim($k)] = trim((string) $v);
}
$t = (int) ($parts["t"] ?? 0);
if (!$t || abs(time() - $t) > $tolerance || empty($parts["v1"])) return false;
$expected = hash_hmac("sha256", $t . "." . $rawBody, $secret);
return hash_equals($expected, $parts["v1"]);
}
// $rawBody = file_get_contents("php://input");
Sandbox deposits
On the sandbox, every user's wallet is pretend. Addresses look like SANDBOX-USDT-TRC20-… and cannot receive real crypto. Never send real funds to them. To test your deposit handling, send the user a pretend deposit: the balance goes up and you receive a signed deposit.received webhook, exactly as on live.
Sandbox only. On live this endpoint returns 404 not_found. Make an address for the coin and network first.
| Field | Description |
|---|---|
currency | For example usdt. |
network | The network of an address the user already has, for example trc20. |
amount | A string above 0 with at most 8 decimals, for example "25.5". |
curl -X POST "$ASTRO_BASE/users/ref:cust_8812/sandbox/deposits" \
-H "Authorization: Bearer $ASTRO_KEY" \
-H "Content-Type: application/json" \
-d '{"currency":"usdt","network":"trc20","amount":"25.5"}'
{
"data": {
"id": "sbxdep_cba44de2dfe1909c",
"currency": "usdt",
"amount": "25.5",
"network": "trc20",
"txid": "sandbox-9f2c…",
"status": "accepted",
"fee": "0",
"fee_percent": 0,
"credited_amount": "25.5",
"fee_status": "none",
"address": "SANDBOX-USDT-TRC20-6c46c20e1812a6ed7dfb"
}
}Fees
| Action | Fee |
|---|---|
| Creating users and addresses, rates, webhooks | Free |
| USDT and USDC deposits | Free |
| Deposits of other coins (BTC, ETH, SOL and others) | 0.5% of the deposit, taken when it arrives. Your agreement with Astro may set a different rate. |
| Selling crypto for naira | No fee. You get Astro's naira rate (sell_ngn). |
| Swaps | 0.5% of the amount swapped |
| Withdrawals to outside wallets | The network fee only |
| Gift cards | No fee. You get the card's naira rate. |
The sandbox applies the same fees, so you can test your accounting. Sandbox network fees are made up.
Error codes
| HTTP | Code | Meaning and fix |
|---|---|---|
| 400 | invalid_referenceinvalid_nameinvalid_email | A field is missing or malformed. The message names it. |
| 400 | invalid_id | Use the Astro id or ref:<reference>. |
| 400 | invalid_currencyunsupported_currencyinvalid_network | Check GET /rates for supported coins and networks. |
| 400 | no_webhook_url | Ask Astro to set your webhook URL first. |
| 400 | invalid_amount | Amounts must be above 0 with at most 8 decimals (gift cards: $1 to $10,000). |
| 400 | amount_out_of_rangeamount_too_small | Below the smallest sale or swap. Check sell_ngn on GET /rates. |
| 400 | invalid_addressinvalid_memo | The address doesn't fit the network, or the memo is malformed. Nothing was sent. |
| 400 | invalid_quoteinvalid_cardinvalid_imagesimage_unreadable | A trade field is missing or unusable. The message says which. |
| 400 | card_unavailable | This gift card isn't accepted right now. |
| 400 | invalid_json | The request body is not valid JSON. |
| 401 | invalid_key | The key is missing, mistyped or revoked. |
| 401 | wrong_environment | A test key on live, or a live key on the sandbox. |
| 403 | partner_not_approved | Live keys work once your business is approved. |
| 403 | partner_suspended | Your account is paused. Contact Astro. |
| 403 | ip_not_allowed | The request came from an IP address that isn't on your list. |
| 404 | user_not_foundquote_not_foundtrade_not_foundnot_found | Not found in your account, or no such endpoint. |
| 409 | user_exists | That reference is already set up. Use GET /users/ref:…. |
| 409 | user_busy | Another request for this user is in progress. Retry shortly. |
| 409 | insufficient_balance | The user's balance doesn't cover the trade (and the network fee, for withdrawals). |
| 409 | quote_usedprice_changed | Get a new quote. |
| 409 | duplicate_reference | That reference belongs to a different trade. |
| 409 | sell_unavailable | Selling this coin for naira is paused for now. |
| 410 | quote_expired | Quotes last 30 seconds. Get a new one. |
| 409 | no_address | Sandbox only: make an address for that coin and network before sending a pretend deposit. |
| 409 | user_not_ready | The user's wallet isn't set up yet. Call POST /users again with the same reference. |
| 413 | body_too_large | The request body is over 50 KB. |
| 429 | rate_limited | Too many requests. Wait for the time in the RateLimit headers. |
| 502 | wallet_provider_errorquote_unavailablefee_unavailable | A temporary problem on our side. Retry with backoff; for trades, use the same reference. |
| 500 | server_error | Something went wrong. Retry, and contact us if it continues. |
| 503 | unavailableprice_unavailable | Briefly unavailable. Retry with backoff. |
Limits
| Limit | Value |
|---|---|
| Requests | 120 per minute per key, with standard RateLimit response headers |
| Request body | 50 KB |
| Allowed IP addresses | Up to 20 per account |
| Webhook response time | 10 seconds |
| Test webhooks | 5 per minute per key |
| Sandbox deposits | 20 per minute per key |
| Trades (quotes, sells, swaps, withdrawals, gift cards) | 30 per minute per key; one at a time per user |
| Quotes | 30 seconds |
Coming next
The following are planned and not available yet. This page will be updated as each one is released.
| Feature | What it adds |
|---|---|
| Payout requests Planned | Ask for a payout of your naira balance through the API or the dashboard, instead of by email. |
| Partner dashboard Planned | Sign in to manage keys, webhooks, users, trades and payouts yourself, with a company wallet to hold, swap, sell and withdraw your business's own crypto. |