Skip to content
Partner API Docs
← 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; pooled custody vs the sum of holdings is 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/{id}/tax-lots · /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));
  }
}
// Production-only endpoint. Do not point this recipe at the sandbox base.
async function nightlyRecon(asOf /* '2026-07-09' — yesterday, UTC */) {
  if (IS_SANDBOX) throw new Error('Reconciliation is production-only');
  const report = await api('/report/reconciliation?asOf=' + encodeURIComponent(asOf));

  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.The reconciliation endpoint is production-only. Develop matching logic against a saved production-shaped fixture, then validate it with controlled pilot credentials.
  • 2.CSV reconciliation exports are also production-only; use a saved cash or securities CSV fixture when designing warehouse imports.