Skip to content
MyStocks Developers
API v1

API v1

Current stable contract

Versioning policyRelease changelog
Sandbox console
← All recipes

Nightly Reconciliation

≈ 1 day

A partner that moves client money needs an independent nightly check that its internal ledger agrees with the platform. The reconciliation report gives you a dated snapshot of every balance and position; tax lots and realized gains feed CGT statements from the same run.

The flow

StepEndpoint / EventWhat it does
1GET /report/reconciliation?asOf=YYYY-MM-DDDated snapshot: accounts, cash ledger, securities, unsettled trades, fees, dividends, corporate actions, custody positions.
2Compare cashMatch aggregate closing cash to summary.closingCashBalance, then match cashLedger transaction movements by userId and transactionId.
3Compare positionsMatch units per symbol per sub-account against the securities section; MyStocks omnibus units versus summed customer holdings are in the custody section.
4Explain the gapsThe unsettled section is the legitimate difference between wallet cash and bank cash until T+N — everything else is an exception.
5GET /users/{userId}/tax-lots · GET /users/{userId}/gainsFIFO open lots and realized disposals for capital-gains statements, from the same nightly run.
6?format=csv&section=cash|securities|…Pull any section as CSV straight into your warehouse or finance tooling.
7GET /client-activity · GET /auditAPI-level activity trail to investigate any exception you flagged.

Implementation

javascript
const BASE = process.env.MYSTOCKS_BASE_URL ?? 'https://mystocks.africa/api/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));
  }
}
// Sandbox returns a deterministic balanced certification fixture.
async function nightlyRecon(asOf /* '2026-07-09' — yesterday, UTC */) {
  const report = await api('/report/reconciliation?asOf=' + encodeURIComponent(asOf));

  // The sandbox proves authentication, scheduling, and balanced-state handling.
  // Detailed cash/securities rows and CSV exports require production pilot data.
  if (IS_SANDBOX) {
    if (!report.fixture || report.status !== 'BALANCED' ||
        report.cash?.differenceUsd !== 0 || report.positions?.differenceUsd !== 0) {
      throw new Error('Unexpected sandbox reconciliation fixture');
    }
    return report;
  }

  const exceptions = [];

  // 2. Cash: compare aggregate closing cash, then match each cash-ledger movement.
  const internalClosingCash = await myLedger.totalCashBalanceUsd(asOf);
  if (Math.abs(internalClosingCash - report.summary.closingCashBalance) > 0.01) {
    exceptions.push({
      kind: 'CASH_BALANCE', internal: internalClosingCash,
      platform: report.summary.closingCashBalance,
    });
  }
  for (const row of report.cashLedger) {
    const internal = await myLedger.transaction(row.transactionId);
    if (!internal || Math.abs(internal.amount - row.amount) > 0.01) {
      exceptions.push({ kind: 'CASH_MOVEMENT', userId: row.userId, transactionId: row.transactionId,
                        internal: internal?.amount ?? null, platform: row.amount });
    }
  }

  // 3. Positions: units per symbol per sub-account
  for (const pos of report.securitiesLedger) {
    const internal = await myLedger.units(pos.userId, pos.symbol, asOf);
    if (Math.abs(internal - pos.units) > 1e-6) {
      exceptions.push({ kind: 'POSITION', ...pos, internal });
    }
  }

  // 4. Unsettled trades explain cash-vs-bank timing — record, don't alert.
  const unsettledUsd = report.summary.unsettledSellAmount + report.summary.unsettledBuyAmount;

  if (exceptions.length) await alertFinance(asOf, exceptions);
  await warehouse.store('mystocks_recon', { asOf, summary: report.summary, exceptions, unsettledUsd });
}

Common mistakes

  • Reconciling against live endpoints instead of the asOf snapshot — balances move while you iterate; the report is internally consistent for its date.
  • Alerting on unsettled amounts. Unsettled SELL proceeds are a timing difference by design (T+N), not a break — record them, expect them to clear by settlement date.
  • Comparing floats for equality. Use a cent threshold for cash and a dust threshold (1e-6 units) for fractional positions.
  • Running at local midnight. Use UTC dates consistently — asOf is a UTC business date.
  • Only reconciling cash. Position breaks (missed corporate action, unapplied fill) are rarer but far more expensive to unwind late.

Prove it in sandbox first

  • 1.GET /report/reconciliation is available in sandbox and returns a deterministic BALANCED certification fixture for authentication, scheduling, and zero-difference handling.
  • 2.The sandbox fixture does not represent real custody or settlement records. Develop detailed row matching and CSV ingestion against saved production-shaped fixtures, then validate with controlled pilot credentials.