Building the Trading section of the StarPay app
This guide follows one person — call her Ama — from the moment she downloads StarPay to the moment she sells USDT and the cedis land in her wallet. At every step it tells you which endpoint to call, what you get back, and what to do when something goes wrong.
You are integrating against two backends, and knowing which is which will save you a lot of confusion:
| What it is | Base URL | |
|---|---|---|
| StarPay Payments | Ama's StarPay account: signup, sign-in, her Ghana Card, her payments wallet | https://starpaygh.com |
| StarPay Trading | Buying and selling stablecoin: quotes, trades, the trading wallet | https://trading.starpaygh.com |
They share one thing only: who Ama is. Everything else is separate — separate databases, separate wallets, separate balances. The two are the same company and the same licence, so this is an engineering boundary, not a legal one.
The single most useful thing to internalise: Ama has two wallets. Her payments wallet (for sending money, paying bills) and her trading wallet (which funds a buy and receives the proceeds of a sell). Money does not move between them automatically. If your UI shows one number labelled "balance", people will be confused and support will hear about it.
Chapter 1 — Ama signs up
Nothing special here. You call the payments backend exactly as you do today:
POST https://starpaygh.com/api/v1/me/signup/start
{ "email": "ama@example.com" }
POST https://starpaygh.com/api/v1/me/signup/verify
{ "email": "ama@example.com", "code": "<emailed code>", "password": "…",
"device": { "install_id": "<your app's per-install id>", "platform": "ios" } }
You get back an access token (cat_…) and a refresh token (crt_…). That is her
StarPay session, and it is what every payments call uses. Returning users sign in
with POST /api/v1/me/login.
Keep that
install_id. Generate it once per installation and reuse it everywhere — you will send the same value again in Chapter 3, and the trading side refuses a pass presented from a different one.
On dev the code really is emailed. There is no fixed 123456 on the payments
backend's dev stage, so test with an inbox you can read.
What you don't see: in the background, StarPay Payments quietly registers Ama on the trading side too. She now has a trading account without ever being asked to create one. If that registration fails — trading is down, say — her signup still succeeds, and StarPay Payments simply tries again the first time she taps into Trading (Chapter 3). You don't have to handle the retry; you only have to show the message you get back if it still can't be done.
It never matters that Ama already uses the same email somewhere else at StarPay — as a merchant's teammate, say. Her trading account from the app is always its own.
Chapter 2 — Ama verifies her Ghana Card
Also unchanged, and also on the payments backend:
POST https://starpaygh.com/api/v1/me/kyc/ghana-card
{ "card_number": "GHA-123456789-0", "image": "<base64 selfie>" }
Behind the scenes, once NIA confirms she is who she says she is, the result is forwarded to the trading side — the method, the reference and the timestamp, signed so it cannot be forged. Her photo never leaves the payments backend.
Why you care: this is what unlocks trading. Before it, Ama can sign in to the Trading section but cannot trade — she's Tier 0, and Tier 0 has a hard block on it. After it, she is Tier 1 and can trade up to USD 5,000 a month.
One Ghana Card check. Both products. Do not build a second verification screen for trading. Order doesn't matter either: if her trading account is only set up after she verified — the retry in Chapter 1 — the result travels with it, and she arrives at Tier 1.
Chapter 3 — Ama opens the Trading section
This is the only genuinely new bit of plumbing, and it is two calls.
Step 1 — ask Payments for a pass.
POST https://starpaygh.com/api/v1/me/trading/session
Authorization: Bearer cat_… ← her normal StarPay session
{ "install_id": "<your app's per-install id>" }
{ "status": true, "data": {
"assertion": "spa1.eyJ…",
"expires_in": 60,
"exchange_url": "https://trading.starpaygh.com/api/v1/mobile/auth/exchange"
} }
Step 2 — trade it for a trading session.
POST {exchange_url}
{ "assertion": "spa1.eyJ…", "install_id": "<the same install_id>" }
{ "status": true, "data": {
"access_token": "tat_…", "expires_in": 900,
"refresh_token": "trt_…", "refresh_expires_in": 2592000,
"tier": 1, "trading_enabled": true
} }
From here on, every trading call uses Authorization: Bearer tat_…. Ama never
typed a second password and does not know any of this happened.
When Payments can't give her a pass
Step 1 is also where StarPay Payments quietly finishes setting Ama up if her trading account didn't make it at signup. So it can fail in a few ways, and each one wants something different from you:
| What you get | What it means | What to show |
|---|---|---|
404 not_found |
Trading isn't switched on for this stage | Hide the Trading section |
502 provider_unavailable |
Trading can't be reached right now | The message, and a Try again button. Retrying in a moment is the right move |
403 permission_denied with details.requirement: "support" |
She can't be set up for trading automatically — a person has to look at it | The message, and a way to contact support. Don't retry — it won't change |
400 validation_error |
The install_id you sent is bad |
Fix your call; this one is on you |
The messages are written for Ama. They say what to do next and never explain why — show them as they are.
Things that will bite you if you skip them
install_idmust be the same in both calls. It is baked into the assertion's signature. This stops a copied assertion opening a session on someone else's phone.- The assertion is good for about 60 seconds and works exactly once. Don't cache it. Don't retry an exchange with the same one — get a fresh pass.
- Do it lazily, when Ama taps into the Trading section, not at app launch.
trading_enabled: falseis normal, not an error. It means Tier 0. Send her to the Ghana Card screen (Chapter 2), not to a dead end.
Keeping the session alive
The access token lasts 15 minutes. When it expires:
POST https://trading.starpaygh.com/api/v1/mobile/auth/refresh
{ "refresh_token": "trt_…" }
You get a fresh pair, and the old pair stops working immediately — so store the new one before you use it.
⚠️ Never reuse a refresh token. Each one works once. If you send a token that has already been rotated, we assume it has been stolen — because two parties now hold it — and we end the whole session. Ama gets signed out of trading. The usual cause is a race between two refresh calls, so serialise them.
If a refresh fails, go back to Chapter 3 Step 1. You always can: her StarPay session is the source of truth.
Chapter 4 — Ama looks around
Three read-only calls to build your home screen. All take Bearer tat_….
GET /api/v1/mobile/limits → tier, monthly cap (USD), used, remaining
GET /api/v1/mobile/wallet → her TRADING cedi balance
GET /api/v1/mobile/orders → her 50 most recent trades
limits is the one to show prominently. monthly_remaining is what she can
still trade this month; monthly_cap is null only for uncapped accounts.
You can also show live prices without signing anyone in:
GET /api/v1/public/rates ← no auth, poll every ~10s
Chapter 5 — Ama puts money in her trading wallet
Her payments balance can't fund a trade. She tops the trading wallet up directly:
POST /api/v1/mobile/wallet/topup
Idempotency-Key: <a UUID you generate>
{ "amount": 50000, "channel": "momo", "instrument": "0244123456" }
amount is in pesewas — 50000 is GHS 500.00. Every money amount in both APIs
is an integer in minor units. There are no decimals anywhere; if you see one,
something has gone wrong.
A pending status is normal — mobile money settles asynchronously. Poll
GET /api/v1/mobile/wallet until the balance moves.
Idempotency-Keyon every money call. Generate a UUID per user action, not per HTTP attempt. If the network drops and you retry with the same key, you get the original result back instead of a second charge. This is your protection against double-spending on a flaky connection, and it only works if you reuse the key across retries of the same tap.
Chapter 6 — Ama buys USDT
Step 1 — get a price.
POST /api/v1/mobile/quote
{ "side": "buy", "asset": "USDT", "network": "TRC20", "fiat_amount": 50000 }
Send either fiat_amount (spend this many pesewas) or asset_amount
(buy this much USDT) — never both.
{ "data": { "reference": "qte_…", "applied_rate": "12.6250",
"asset_amount": 3960000, "fiat_amount": 50000,
"fee_amount": 0, "net_fiat_amount": 50000,
"expires_at": "2026-09-22T10:00:45Z" } }
The quote is good for 45 seconds. Show a countdown. When it runs out, get a new one — don't try to execute an expired quote.
Step 2 — execute it.
POST /api/v1/mobile/orders
Idempotency-Key: <UUID>
{ "quote_reference": "qte_…", "delivery_address": "TR7NHqje…" }
A buy settles immediately: her trading wallet is debited and the USDT is sent to
delivery_address.
⚠️ The delivery address is the dangerous field
There is no undo. Coins sent to the wrong address are gone — no support ticket recovers them, and no one can reverse it.
We check the address's checksum and reject anything that fails, which catches typos and truncated pastes. But we cannot tell whose address it is. A valid address belonging to a stranger looks exactly like a valid address belonging to Ama.
So please:
- Offer paste and QR scan. Both beat typing.
- Show the address back in a large, readable font before she confirms.
- Make the network unmistakable. A TRC20 address is not an ERC20 address, and sending to the wrong network loses the coins. We reject a mismatched one — but your UI should never offer the combination in the first place.
- Consider remembering addresses she has used before.
If the address is malformed you get 400 validation_error with a message written
for her, not for you — show it as-is:
"That TRON address failed its checksum. Copy it again — one wrong character sends the coins somewhere unrecoverable."
Chapter 7 — Ama sells USDT
A sell runs the other way: she sends coins to us, we send her cedis.
Step 1 — quote, exactly as for a buy but "side": "sell".
Step 2 — open the order.
POST /api/v1/mobile/orders
Idempotency-Key: <UUID>
{ "quote_reference": "qte_…" }
No delivery_address — she is sending to us.
{ "data": { "reference": "ord_…", "status": "pending_payment",
"deposit_address": "TXk…", "deposit_network": "TRC20",
"asset_amount": 5000000 } }
Step 3 — she sends the coins. Show deposit_address as a QR code and as
copyable text, with the exact amount beside it. Tell her plainly:
- Send exactly that amount.
- Send on that network.
- The address belongs to this trade only — it is not a reusable wallet address, and she should not save it.
Step 4 — poll until it settles.
GET /api/v1/mobile/orders/{reference}
Poll every few seconds while she is on the screen. This endpoint does more than read a database — it actively asks our custody provider whether her coins have arrived, so polling it is often what makes the trade complete. It is throttled server-side, so you cannot poll it too hard.
Watch status:
status |
What to show |
|---|---|
pending_payment |
"Waiting for your coins" |
funded |
"Coins received, completing your sale" |
settled |
Done — cedis are in her trading wallet |
suspended |
Her coins arrived but the sale is held. Support, not a retry. |
failed / expired |
Nothing was received. She can quote again. |
What if she sends the wrong amount?
- Too little — the order stays
pending_paymentand we keep her coins safely. She gets an SMS. She should quote again for the amount she actually sent. - Too much — the trade completes at the quoted amount and the surplus is held for our team to return. She gets an SMS.
Either way, her money is not lost. Say so in your copy; this is the moment people panic.
Chapter 8 — Ama takes her cedis out
POST /api/v1/mobile/wallet/withdraw
Idempotency-Key: <UUID>
{ "amount": 30000, "channel": "momo", "instrument": "0244123456", "pin": "4821" }
A withdrawal needs her transaction PIN. This is the one call that does, and deliberately: a stolen session token would otherwise be enough to empty her wallet to any phone number. The session says who is calling; the PIN says she is here.
It is the same PIN she uses everywhere in StarPay
Ama has one transaction PIN, and it lives in StarPay settings. The Trading section has no PIN screen of its own — don't build one.
She sets, changes and resets it on the payments backend, exactly as she would for a payment:
POST https://starpaygh.com/api/v1/me/pin
{ "pin": "4821" } ← first time
{ "current_pin": "4821", "pin": "9999" } ← changing it
POST https://starpaygh.com/api/v1/me/pin/reset/start ← forgot it: emails a code
POST https://starpaygh.com/api/v1/me/pin/reset/verify ← the code + her new PIN
Behind the scenes StarPay Payments passes the change on to the trading side — signed, and as a scrambled hash, never the PIN itself — and it applies to trading within seconds. A reset there also lifts a trading lockout.
So when she taps Withdraw, just ask for "your StarPay PIN" and send it as
pin. Two answers mean "she has to go to StarPay settings first":
| What you get | What it means | What to do |
|---|---|---|
400 validation_error with details.requirement: "pin" |
She hasn't set a PIN yet | Show the message and a button to StarPay's PIN screen |
403 permission_denied with details.requirement: "starpay_pin" |
You called POST /api/v1/mobile/pin — trading won't set her PIN |
Send her to StarPay's PIN screen. Better: never call it |
If she has only just set her PIN and the withdrawal still says
requirement: "pin", give it a few seconds and let her try again — the change
is on its way across.
4–6 digits. After 5 wrong attempts the PIN locks for 15 minutes and you get
422 limit_exceeded — note that this is not a 401, so do not sign her out over
a mistyped PIN. Resetting her PIN in StarPay settings unlocks it straight away.
After a reset, buys and withdrawals pause for a day
Say Ama forgets her PIN and resets it in StarPay settings (or resets her password). StarPay treats that as the moment someone else is most likely to be holding her account, so for the next 24 hours money can't leave it — and the trading side honours the same pause:
- Paused: withdrawals (Chapter 8) and buys (Chapter 6 — the coins go to an outside address, so a buy is money leaving too).
- Still working: sells (Chapter 7) and top-ups (Chapter 5). Those only bring money to her.
A paused call answers:
{ "status": false, "error": {
"code": "permission_denied",
"message": "For your security, withdrawals and buys are paused until 9 Oct 2026, 09:14 GMT after a recent PIN or password reset.",
"details": { "requirement": "security_hold", "until": "2026-10-09T09:14:03.512204+00:00" } } }
Show the message, and use details.until to tell her when it lifts — a
countdown, or "back on tomorrow at 09:14". Don't retry in a loop and don't
send her to support: nothing is wrong, and it ends on its own. A buy can be
refused at the quote or when she confirms it, if the reset happened in
between — handle it in both places.
Errors: the whole list
Every response is { "status": …, "message": …, "data": … } or
{ "status": false, "error": { "code", "message", "details" } }.
| Code | HTTP | What it means | What to do |
|---|---|---|---|
validation_error |
400 | Something in the request is wrong. With details.requirement: "pin": she has no PIN yet (Chapter 8) |
Show message — it is written for her. For "pin", add a button to StarPay's PIN screen |
authentication_failed |
401 | Session expired or invalid | Refresh; if that fails, re-exchange |
compliance_block |
403 | Not verified, or held for review | Send her to verification, or to support |
permission_denied |
403 | Getting a pass (Chapter 3): she can't be set up for trading automatically — details.requirement is "support". /mobile/pin (Chapter 8): details.requirement is "starpay_pin" — her PIN is managed in StarPay settings. A buy or a withdrawal (Chapter 8): details.requirement is "security_hold" — paused after a reset until details.until |
"support": show message and a way to reach support, don't retry. "starpay_pin": send her to StarPay's PIN screen. "security_hold": show message and when it lifts; sells and top-ups still work |
not_found |
404 | No such trade/quote for her | Don't retry |
rate_expired |
409 | The 45s quote lapsed | Get a fresh quote |
idempotency_conflict |
409 | Same key, different body | Fix the key; never reuse one across actions |
insufficient_funds |
422 | Her wallet can't cover it | Offer a top-up |
limit_exceeded |
422 | Tier cap, or PIN locked | Show the cap, or the lockout |
rate_limited |
429 | Too fast | Back off and retry |
server_error |
500 | Ours | Retry once, then apologise |
provider_unavailable |
502 | Getting a pass (Chapter 3): Trading can't be reached right now | Show message and let her try again in a moment |
message is written to be shown to a person. Please don't replace it with
your own copy — it usually says what to do next.
A checklist before you ship
- [ ] Two balances, clearly labelled. Never one number called "balance".
- [ ] Amounts are integer minor units everywhere. Never send a decimal.
- [ ] A fresh
Idempotency-Keyper user action, reused across retries of it. - [ ] Refresh calls serialised — never two in flight at once.
- [ ] A visible 45-second countdown on every quote.
- [ ] The delivery address confirmed on a screen of its own, big enough to read.
- [ ] Network shown everywhere coins move, and impossible to mismatch.
- [ ]
trading_enabled: falseroutes to verification, not to an error. - [ ] A pass refused with
requirement: "support"offers support, not a retry loop. - [ ] A PIN-locked message that does not look like being signed out.
- [ ] No PIN screen in Trading:
requirement: "pin"opens StarPay's PIN settings. - [ ]
requirement: "security_hold"shows when buys and withdrawals resume (details.until), on the quote and the confirm. - [ ] "Your money is safe" copy for
suspendedand for under/over-payment.
Quick reference
StarPay Payments — https://starpaygh.com, Bearer cat_…
| Method | Path | Why |
|---|---|---|
| POST | /api/v1/me/signup/start · /me/signup/verify |
Create her account |
| POST | /api/v1/me/login |
Sign a returning user in |
| POST | /api/v1/me/kyc/ghana-card |
Verify her — unlocks trading too |
| POST | /api/v1/me/trading/session |
Get a pass into Trading |
| POST | /api/v1/me/pin |
Set or change her PIN — for trading too |
| POST | /api/v1/me/pin/reset/start · /me/pin/reset/verify |
Reset a forgotten PIN |
StarPay Trading — https://trading.starpaygh.com, Bearer tat_…
| Method | Path | Why |
|---|---|---|
| POST | /api/v1/mobile/auth/exchange |
Pass → trading session |
| POST | /api/v1/mobile/auth/refresh |
Keep it alive |
| GET | /api/v1/mobile/limits |
Tier, cap, remaining |
| GET | /api/v1/mobile/wallet |
Trading cedi balance |
| POST | /api/v1/mobile/wallet/topup |
Put cedis in |
| POST | /api/v1/mobile/wallet/withdraw |
Take cedis out (PIN) |
| POST | /api/v1/mobile/quote |
Price a trade (45s) |
| POST | /api/v1/mobile/orders |
Execute it |
| GET | /api/v1/mobile/orders |
History |
| GET | /api/v1/mobile/orders/{ref} |
One trade + poll a sell |
| GET | /api/v1/mobile/kyc |
Verification status |
| GET | /api/v1/public/rates |
Live prices, no auth |