← All recipes
Build a Trading App
≈ 1–2 days to first settled trade
The end-to-end product loop behind a Robinhood-style app: isolated sub-accounts per user, KYC gating, funded wallets, the two-step quote → trade flow, and settlement pushed to you over webhooks. The whole sequence works in sandbox before you touch production.
The flow
| Step | Endpoint / Event | What it does |
|---|---|---|
| 1 | POST /users | Create an isolated sub-account (wallet + order book) per end user. Send a stable Idempotency-Key for this logical creation. |
| 2 | POST /users/{id}/kyc | Assert KYC from your own verification flow. Trading is blocked until VERIFIED (403 KYC_REQUIRED). |
| 3 | POST /users/{id}/deposit | Move USD from your master wallet into the sub-account. Always pass Idempotency-Key. |
| 4 | GET /quote/{symbol} | Required step 1 of every trade: firm fee breakdown + a single-use quoteId (60 s TTL). |
| 5 | POST /users/{id}/trade | Place the order with the quoteId. Production returns 202 PENDING; sandbox returns 200 FILLED immediately. |
| 6 | Webhook: order.filled / order.rejected | Settlement pushed to you. Prefer this over polling GET /orders/{id}. |
| 7 | GET /users/{id}/portfolio | Show updated holdings and unrealized P&L after settlement. |
| 8 | GET /users/{id}/buying-power | Show tradable vs withdrawable cash (unsettled SELL proceeds are excluded from withdrawable). |
Implementation
javascript
const BASE = process.env.MYSTOCKS_BASE_URL ?? 'https://mystocks.africa/api/sandbox/v1/partner';
const API_KEY = process.env.MYSTOCKS_API_KEY;
if (!API_KEY) throw new Error('Set MYSTOCKS_API_KEY before running this recipe');
const IS_SANDBOX = BASE.includes('/sandbox/');
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
function logicalKey(...parts) {
return parts.map(part => String(part).trim().replace(/[^a-zA-Z0-9_-]/g, '-')).join('_');
}
async function api(path, { method = 'GET', body, idempotencyKey, headers = {}, maxAttempts = 4 } = {}) {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const res = await fetch(BASE + path, {
method,
headers: {
'x-api-key': API_KEY,
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
...headers,
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
const contentType = res.headers.get('content-type') || '';
const payload = contentType.includes('json') ? await res.json() : await res.text();
if (res.ok) return payload;
const envelope = payload && typeof payload === 'object' ? payload.error : null;
const code = envelope && typeof envelope === 'object'
? envelope.code
: payload && typeof payload === 'object' && payload.code || 'HTTP_' + res.status;
const message = envelope && typeof envelope === 'object'
? envelope.message
: typeof envelope === 'string' ? envelope
: payload && typeof payload === 'object' && payload.message || res.statusText;
const retryable = (res.status === 429 || res.status >= 500) && (method === 'GET' || idempotencyKey);
if (!retryable || attempt === maxAttempts - 1) {
throw Object.assign(new Error(code + ': ' + message), {
status: res.status, code, details: envelope?.details ?? payload?.details ?? null, payload,
retryAfterSeconds: Number(res.headers.get('Retry-After')) || null,
});
}
const retryAfter = Number(res.headers.get('Retry-After'));
const delayMs = Number.isFinite(retryAfter) && retryAfter > 0
? retryAfter * 1000
: Math.min(1000 * (2 ** attempt), 30_000);
await sleep(delayMs + Math.floor(Math.random() * 250));
}
}
const ACTIVE_ORDER_STATES = new Set(['PENDING', 'WORKING', 'PROCESSING']);
const TERMINAL_ORDER_STATES = new Set(['FILLED', 'REJECTED', 'CANCELLED']);
function handleOrderStatus(order) {
if (ACTIVE_ORDER_STATES.has(order.status)) return { settled: false, status: order.status };
if (order.status === 'FILLED') return { settled: true, status: 'FILLED' };
if (order.status === 'REJECTED') return { settled: true, status: 'REJECTED', reason: order.rejectionReason };
if (order.status === 'CANCELLED') return { settled: true, status: 'CANCELLED' };
throw new Error('Unknown order status: ' + order.status);
}
// Sandbox is the safe default. Set MYSTOCKS_BASE_URL to the production base only after certification.
// 1. Create sub-account. Reuse this SAME key if the request is retried.
const { subAccountId } = await api('/users', {
method: 'POST', idempotencyKey: logicalKey('user-create', 'user_42'),
body: { externalId: 'user_42', displayName: 'Amara Diallo', email: 'amara@example.com' },
});
// 2. Assert KYC after your own verification flow
await api('/users/' + subAccountId + '/kyc', {
method: 'POST', idempotencyKey: logicalKey('kyc', 'user_42', 'verified-v1'),
body: { status: 'VERIFIED', level: 'FULL', provider: 'Sumsub', reference: 'sum_ref_123' },
});
// 3. Deposit $100 (idempotent — safe to retry with the SAME key)
await api('/users/' + subAccountId + '/deposit', {
method: 'POST', idempotencyKey: logicalKey('deposit', 'user_42', '2026-07-10'),
body: { amount: 100, localAmount: 13100, localCurrency: 'KES', fxRate: 131 },
});
// 4. Get a firm quote — REQUIRED before every trade (single-use, 60 s TTL)
const quote = await api('/quote/SCOM.KE?type=BUY&cashValue=50&subAccountId=' + encodeURIComponent(subAccountId));
// quote = { quoteId, grossValue, baseFee, partnerMarkupFee, totalCost, quoteExpiresAt, ... }
// 5. Place the BUY with the quoteId
const order = await api('/users/' + subAccountId + '/trade', {
method: 'POST', idempotencyKey: logicalKey('trade', 'user_42', 'SCOM.KE', '2026-07-10'),
body: { symbol: 'SCOM.KE', type: 'BUY', cashValue: 50, quoteId: quote.quoteId },
});
const orderState = handleOrderStatus(order);
// Production: 202 PENDING and funds escrowed. Sandbox: 200 FILLED immediately.
// 6. Settlement arrives on your webhook (order.filled / order.rejected) —
// see the "Handle Webhooks Reliably" recipe for the handler.
// 7. After order.filled: show the portfolio
if (orderState.status === 'FILLED') {
const portfolio = await api('/users/' + subAccountId + '/portfolio');
console.log(portfolio);
} else {
console.log('Wait for order.filled, order.rejected, or order.cancelled:', order.orderId);
}
// 8. Show tradable vs withdrawable cash
const bp = await api('/users/' + subAccountId + '/buying-power');
// bp = { cashBalanceUsd, buyingPowerUsd, unsettledUsd, withdrawableUsd, settlements: [...] }Common mistakes
- —Trading without a quoteId. Production AND sandbox require the two-step quote → trade flow — a missing, expired (60 s), or reused quoteId is rejected. Fetch a fresh quote and retry.
- —Generating a new Idempotency-Key when retrying a failed call. The key must be stable per logical operation — a fresh key on retry is how double-buys happen.
- —Polling order status instead of registering a webhook. Live production market orders submitted during exchange hours target fill within 5 minutes; order.filled tells you the moment it lands.
- —Skipping KYC assertion. Any trade or money movement on a non-VERIFIED sub-account returns 403 KYC_REQUIRED — assert it right after your own onboarding succeeds.
- —Showing cashBalanceUsd as withdrawable. SELL proceeds stay unsettled for T+N days; use withdrawableUsd from /buying-power for the withdraw screen.
Prove it in sandbox first
- 1.Register at POST /api/sandbox/v1/register to get an sk_sandbox_ key — sandbox accounts start with $100,000 virtual USD.
- 2.Run steps 1–5 against https://mystocks.africa/api/sandbox/v1/partner — same shapes, instant fills (no dealing desk).
- 3.Register a webhook in sandbox and confirm you receive order.filled for the instant fill.
- 4.When you request a live key, replay the exact same code against /api/v1/partner — only the base URL and key change.