Skip to content
Partner API Docs
← 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

StepEndpoint / EventWhat it does
1POST /usersCreate an isolated sub-account (wallet + order book) per end user. Send a stable Idempotency-Key for this logical creation.
2POST /users/{id}/kycAssert KYC from your own verification flow. Trading is blocked until VERIFIED (403 KYC_REQUIRED).
3POST /users/{id}/depositMove USD from your master wallet into the sub-account. Always pass Idempotency-Key.
4GET /quote/{symbol}Required step 1 of every trade: firm fee breakdown + a single-use quoteId (60 s TTL).
5POST /users/{id}/tradePlace the order with the quoteId. Production returns 202 PENDING; sandbox returns 200 FILLED immediately.
6Webhook: order.filled / order.rejectedSettlement pushed to you. Prefer this over polling GET /orders/{id}.
7GET /users/{id}/portfolioShow updated holdings and unrealized P&L after settlement.
8GET /users/{id}/buying-powerShow 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.