# Sub-Accounts

> Create and manage end-user sub-accounts — each with an isolated USD wallet, portfolio, and order history. Covers create/auto-register, list/fetch, freeze, deposit, withdraw, and the KYC assertion API with an optional structured compliance profile.

Each of your end-users gets an isolated sub-account with its own USD wallet, portfolio, and order
history. All write operations (deposit, withdraw, trade) are scoped to a single sub-account. The master
wallet is the funding source for sub-account deposits.

<Callout type="info">
  Examples use the production base. Swap `/api/v1/partner` for `/api/sandbox/v1/partner` (and an
  `sk_sandbox_` key) to run against the sandbox.
</Callout>

## Create a sub-account

<MethodTag m="POST" /> `/users`

<TryEndpoint id="createUsers" />

Create a sub-account for one of your end-users. Returns HTTP 201. Use `externalId` to map MyStocks
sub-account IDs back to your own user table.

<ParamTable fields={[
  { name: 'externalId',  type: 'string', required: true,  desc: 'Your internal user ID. Must be unique per partner — the natural idempotency key.' },
  { name: 'displayName', type: 'string', required: false, desc: 'Full name shown in the MyStocks admin console.' },
  { name: 'email',       type: 'string', required: false, desc: 'User email address.' },
]} />

<CodeTabs
  curl={`curl -X POST "https://mystocks.africa/api/v1/partner/users" \\
  -H "Authorization: Bearer pk_live_<key>" \\
  -H "Content-Type: application/json" \\
  -d '{"externalId":"user_42","displayName":"Jane Doe","email":"jane@example.com"}'`}
  node={`const user = await fetch("https://mystocks.africa/api/v1/partner/users", {
  method: "POST",
  headers: { Authorization: "Bearer pk_live_<key>", "Content-Type": "application/json" },
  body: JSON.stringify({ externalId: "user_42", displayName: "Jane Doe", email: "jane@example.com" }),
}).then((res) => res.json());`}
  python={`import requests

user = requests.post(
    "https://mystocks.africa/api/v1/partner/users",
    headers={"Authorization": "Bearer pk_live_<key>"},
    json={"externalId": "user_42", "displayName": "Jane Doe", "email": "jane@example.com"},
).json()`}
  sdk={`const user = await client.subAccounts.create({
  externalId: "user_42",
  displayName: "Jane Doe",
  email: "jane@example.com",
});`}
/>

```json
{ "subAccountId": "usr_abc123", "externalId": "user_42", "displayName": "Jane Doe", "email": "jane@example.com", "kycStatus": "NONE", "kycLevel": "NONE", "status": "active", "walletBalance": 0 }
```

## Auto-register

<MethodTag m="POST" /> `/auto-register`

Idempotent sub-account creation — safe to call on every user login. If a sub-account already exists for
the given `uid` it is returned unchanged (no duplicate). Same response shape as `POST /users`.

<ParamTable fields={[
  { name: 'uid',     type: 'string', required: true,  desc: 'Your internal user identifier. Acts as the idempotency key.' },
  { name: 'email',   type: 'string', required: false, desc: 'Email address.' },
  { name: 'name',    type: 'string', required: false, desc: 'Display name.' },
  { name: 'phone',   type: 'string', required: false, desc: 'Phone in E.164 format, e.g. +254712345678.' },
  { name: 'country', type: 'string', required: false, desc: 'ISO 3166-1 alpha-2 country code.' },
]} />

## List & fetch

<MethodTag m="GET" /> `/users` · <MethodTag m="GET" /> `/users/{userId}`

List all sub-accounts (cursor-paginated, default 100/page, max 500) or fetch a single one by its
`subAccountId`. Filter by `?externalId=` to look up using your own ID.

## Update / freeze

<MethodTag m="PATCH" /> `/users/{userId}`

Update display name or email. Set `status: "frozen"` to suspend all trading, deposits, and withdrawals
for the sub-account; set back to `status: "active"` to restore access.

```bash
curl -X PATCH "https://mystocks.africa/api/v1/partner/users/usr_abc123" \
  -H "Authorization: Bearer pk_live_<key>" \
  -H "Idempotency-Key: freeze-user-usr_abc123" \
  -H "Content-Type: application/json" \
  -d '{"status":"frozen"}'
```

## Close / offboard

<MethodTag m="DELETE" /> `/users/{userId}`

Soft-close a sub-account when your user leaves your product or asks to terminate their investing
account. Closure is terminal: the sub-account is retained for audit and regulatory history, but it can
no longer trade, receive deposits, or withdraw.

Closure is blocked if the account has open orders, positive holdings, or unsettled proceeds. If the USD
wallet still has cash, either call `/withdraw` first or pass `residualCashHandling:
"transfer_to_partner"` to move the remaining cash to your master wallet during closure.

<ParamTable fields={[
  { name: 'residualCashHandling', type: 'string', required: false, desc: 'require_zero (default) | transfer_to_partner.' },
  { name: 'reason',               type: 'string', required: false, desc: 'Closure reason stored in the audit trail.' },
]} />

```bash
curl -X DELETE "https://mystocks.africa/api/v1/partner/users/usr_abc123" \
  -H "Authorization: Bearer pk_live_<key>" \
  -H "Idempotency-Key: close_user42_001" \
  -H "Content-Type: application/json" \
  -d '{"residualCashHandling":"transfer_to_partner","reason":"User requested closure"}'
```

```json
{ "message": "Sub-account closed.", "subAccountId": "usr_abc123", "externalId": "user_42", "status": "closed", "residualTransferUsd": 0, "currency": "USD" }
```

Registered webhooks receive `account.closed` after a successful closure.

## Deposit

<MethodTag m="POST" /> `/users/{userId}/deposit`

<TryEndpoint id="createDeposit" />

Credit a sub-account wallet. Send a USD `amount`, or send a supported local-currency `amount` with
`currency` and MyStocks will calculate the USD ledger credit using managed FX. Always set
`Idempotency-Key`.

<ParamTable fields={[
  { name: 'amount',        type: 'number', required: true,  desc: 'Amount in currency; defaults to USD.' },
  { name: 'amountUsd',     type: 'number', required: false, desc: 'Exact USD ledger amount for direct-USD integrations.' },
  { name: 'currency',      type: 'string', required: false, desc: 'ISO 4217 code for amount, e.g. KES, NGN, GHS.' },
  { name: 'fxRate',        type: 'number', required: false, desc: 'Deprecated legacy partner rate; omit for managed FX.' },
  { name: 'note',          type: 'string', required: false, desc: 'Reference string shown in transaction history.' },
]} />

<CodeTabs
  curl={`curl -X POST "https://mystocks.africa/api/v1/partner/users/usr_abc123/deposit" \\
  -H "Authorization: Bearer pk_live_<key>" \\
  -H "Idempotency-Key: dep_user42_001" \\
  -H "Content-Type: application/json" \\
  -d '{"amount":65000,"currency":"KES","note":"mpesa_QHJ29SK"}'`}
  node={`const deposit = await fetch("https://mystocks.africa/api/v1/partner/users/usr_abc123/deposit", {
  method: "POST",
  headers: {
    Authorization: "Bearer pk_live_<key>",
    "Idempotency-Key": "dep_user42_001",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ amount: 65000, currency: "KES" }),
}).then((res) => res.json());`}
  python={`deposit = requests.post(
    "https://mystocks.africa/api/v1/partner/users/usr_abc123/deposit",
    headers={"Authorization": "Bearer pk_live_<key>", "Idempotency-Key": "dep_user42_001"},
    json={"amount": 65000, "currency": "KES"},
).json()`}
  sdk={`const deposit = await client.subAccounts.deposit(
  "usr_abc123",
  { amount: 65000, currency: "KES" },
  { idempotencyKey: "dep_user42_001" },
);`}
/>

```json
{ "message": "Deposit successful.", "subAccountId": "usr_abc123", "amount": 500, "currency": "USD", "newSubBalance": 500, "newMasterBalance": 99500 }
```

## Withdraw

<MethodTag m="POST" /> `/users/{userId}/withdraw`

Debit a sub-account wallet and return the USD ledger amount to your master wallet. For supported
non-USD requests, MyStocks converts `amount` in `currency`; your payout rail then delivers the local
currency to the user. The sub-account must have sufficient uninvested balance. Always set
`Idempotency-Key`.

<ParamTable fields={[
  { name: 'amount',        type: 'number', required: true,  desc: 'Amount in currency; defaults to USD.' },
  { name: 'amountUsd',     type: 'number', required: false, desc: 'Exact USD ledger amount for direct-USD integrations.' },
  { name: 'currency',      type: 'string', required: false, desc: 'ISO 4217 code for amount.' },
  { name: 'fxRate',        type: 'number', required: false, desc: 'Deprecated legacy partner rate; omit for managed FX.' },
  { name: 'note',          type: 'string', required: false, desc: 'Reference for transaction history.' },
]} />

```json
{ "message": "Withdrawal successful.", "subAccountId": "usr_abc123", "amount": 200, "currency": "USD", "newSubBalance": 300, "newMasterBalance": 99700 }
```

## Assert KYC

<MethodTag m="POST" /> `/users/{userId}/kyc`

Assert the KYC result you obtained using your own provider. Set `status: "VERIFIED"` and
`level: "BASIC"` to unlock trading; `FULL` unlocks enhanced limits and higher-value subscriptions.
Set `status: "REJECTED"`, `level: "NONE"`, and include both `reasonCode` and `reason` when your
provider declines the customer. Rejected customers remain blocked from trading and money movement.
This is a state-changing write, so a unique `Idempotency-Key` header is **required** (returns
`400 MISSING_IDEMPOTENCY_KEY` without it).

<ParamTable fields={[
  { name: 'status',    type: 'string', required: true,  desc: 'NONE | PENDING | VERIFIED | REJECTED' },
  { name: 'level',     type: 'string', required: true,  desc: 'NONE | BASIC (ID verified) | FULL (enhanced due diligence)' },
  { name: 'provider',  type: 'string', required: false, desc: 'KYC provider that performed the verification (e.g. sumsub).' },
  { name: 'reference', type: 'string', required: false, desc: 'Your KYC session ID for audit correlation.' },
  { name: 'reasonCode', type: 'string', required: false, desc: 'Required only for REJECTED. Stable 3-64 character uppercase machine code.' },
  { name: 'reason', type: 'string', required: false, desc: 'Required only for REJECTED. Human-readable remediation context, up to 500 characters.' },
]} />

You may also attach an optional **structured compliance profile** — all fields below are optional and
additive. The two sensitive identifiers, `idNumber` and `taxId`, are stored as a sha256 fingerprint
plus the last 4 characters only; they are **never persisted or returned in full**. The response and the
`kyc.updated` webhook echo back `idNumberLast4` / `taxIdLast4`.

<ParamTable fields={[
  { name: 'idDocumentType',     type: 'string',  required: false, desc: 'PASSPORT | NATIONAL_ID | DRIVERS_LICENSE | VOTERS_CARD | RESIDENCE_PERMIT | OTHER' },
  { name: 'idNumber',           type: 'string',  required: false, desc: 'ID document number. Stored as fingerprint + last4 only; never returned in full.' },
  { name: 'dateOfBirth',        type: 'string',  required: false, desc: 'ISO date, YYYY-MM-DD.' },
  { name: 'nationality',        type: 'string',  required: false, desc: 'ISO 3166-1 alpha-2 country code (e.g. KE).' },
  { name: 'countryOfResidence', type: 'string',  required: false, desc: 'ISO 3166-1 alpha-2 country code.' },
  { name: 'address',            type: 'string',  required: false, desc: 'Residential address, up to 500 characters.' },
  { name: 'taxResidency',       type: 'string',  required: false, desc: 'ISO 3166-1 alpha-2 country code of tax residency.' },
  { name: 'taxId',              type: 'string',  required: false, desc: 'TIN. Stored as fingerprint + last4 only; never returned in full.' },
  { name: 'pep',                type: 'boolean', required: false, desc: 'Politically exposed person flag.' },
  { name: 'sanctionsResult',    type: 'string',  required: false, desc: 'CLEAR | FLAGGED | PENDING — your sanctions/PEP screening result.' },
  { name: 'screeningProvider',  type: 'string',  required: false, desc: 'Sanctions/PEP screening provider name.' },
  { name: 'riskRating',         type: 'string',  required: false, desc: 'LOW | MEDIUM | HIGH.' },
  { name: 'consentAt',          type: 'string',  required: false, desc: 'ISO 8601 timestamp of user consent.' },
  { name: 'evidenceReference',  type: 'string',  required: false, desc: 'Your reference/URL to the stored KYC evidence bundle.' },
  { name: 'employmentStatus',   type: 'string',  required: false, desc: 'EMPLOYED | SELF_EMPLOYED | UNEMPLOYED | STUDENT | RETIRED | OTHER.' },
  { name: 'sourceOfFunds',      type: 'string/array', required: false, desc: 'SALARY | BUSINESS_INCOME | INVESTMENTS | INHERITANCE | SAVINGS | PENSION | GIFT | OTHER.' },
  { name: 'sourceOfWealth',     type: 'string/array', required: false, desc: 'BUSINESS_OWNERSHIP | EMPLOYMENT_INCOME | INVESTMENTS | INHERITANCE | PROPERTY | SAVINGS | PENSION | OTHER.' },
  { name: 'annualIncomeBand',   type: 'string',  required: false, desc: 'USD-equivalent income band, e.g. 50000_100000.' },
  { name: 'netWorthBand',       type: 'string',  required: false, desc: 'USD-equivalent net-worth band, e.g. 100000_500000.' },
  { name: 'investmentExperience', type: 'string', required: false, desc: 'NONE | LIMITED | MODERATE | EXPERIENCED | PROFESSIONAL.' },
  { name: 'investmentObjectives', type: 'array', required: false, desc: 'CAPITAL_PRESERVATION | INCOME | GROWTH | SPECULATION | HEDGING | RETIREMENT | OTHER.' },
  { name: 'suitabilityStatus',  type: 'string',  required: false, desc: 'NOT_ASSESSED | SUITABLE | UNSUITABLE | REVIEW_REQUIRED.' },
  { name: 'appropriatenessStatus', type: 'string', required: false, desc: 'NOT_ASSESSED | SUITABLE | UNSUITABLE | REVIEW_REQUIRED.' },
  { name: 'fatcaStatus',        type: 'string',  required: false, desc: 'NOT_US_PERSON | US_PERSON | EXEMPT | UNKNOWN.' },
  { name: 'crsTaxResidencies',  type: 'array',   required: false, desc: 'Array of country/taxId declarations. Tax IDs are stored as fingerprint + last4 only.' },
  { name: 'marketAccountReferences', type: 'array', required: false, desc: 'Market-specific broker/CSD references. Account numbers are stored as fingerprint + last4 only.' },
]} />

```bash
curl -X POST "https://mystocks.africa/api/v1/partner/users/usr_abc123/kyc" \
  -H "Authorization: Bearer pk_live_<key>" \
  -H "Idempotency-Key: kyc_usr_abc123_001" \
  -H "Content-Type: application/json" \
  -d '{
    "status": "VERIFIED",
    "level": "BASIC",
    "provider": "sumsub",
    "reference": "kyc_session_88721",
    "idDocumentType": "NATIONAL_ID",
    "idNumber": "24681012",
    "dateOfBirth": "1990-04-17",
    "nationality": "KE",
    "taxResidency": "KE",
    "sanctionsResult": "CLEAR",
    "riskRating": "LOW",
    "consentAt": "2026-07-11T09:30:00Z"
  }'
```

```json
{ "message": "KYC status updated.", "subAccountId": "usr_abc123", "kycStatus": "VERIFIED", "kycLevel": "BASIC", "profile": { "idDocumentType": "NATIONAL_ID", "idNumberLast4": "1012", "nationality": "KE", "taxResidency": "KE", "sanctionsResult": "CLEAR", "riskRating": "LOW" } }
```

## Notification devices

<MethodTag m="GET" /> `/users/{userId}/devices` · <MethodTag m="POST" /> `/users/{userId}/devices` · <MethodTag m="DELETE" /> `/users/{userId}/devices/{deviceId}`

Register mobile/web push tokens for a sub-account so your app can fan out user-visible notifications
after receiving MyStocks webhooks or SSE events. MyStocks stores only a token hash plus `tokenLast4`;
raw APNs/FCM/Expo/Web Push tokens are never returned.

<ParamTable fields={[
  { name: 'token',    type: 'string', required: true,  desc: 'APNs, FCM, Expo, or Web Push token. Stored as fingerprint + last4 only.' },
  { name: 'platform', type: 'string', required: true,  desc: 'ios | android | web.' },
  { name: 'provider', type: 'string', required: true,  desc: 'apns | fcm | expo | webpush.' },
  { name: 'appId',    type: 'string', required: false, desc: 'Bundle/package/app identifier.' },
  { name: 'deviceId', type: 'string', required: false, desc: 'Your stable device identifier; keeps the same MyStocks deviceId across token rotations.' },
  { name: 'locale',   type: 'string', required: false, desc: 'Locale such as en-KE.' },
  { name: 'timezone', type: 'string', required: false, desc: 'IANA timezone such as Africa/Nairobi.' },
  { name: 'tags',     type: 'array',  required: false, desc: 'Optional partner tags for segmentation.' },
]} />

```bash
curl -X POST "https://mystocks.africa/api/v1/partner/users/usr_abc123/devices" \
  -H "Authorization: Bearer pk_live_<key>" \
  -H "Idempotency-Key: device_usr_abc123_001" \
  -H "Content-Type: application/json" \
  -d '{"token":"ExpoPushToken[xxxxxxxxxxxxxxxxxxxxxx]","platform":"ios","provider":"expo","appId":"com.afritrade.app","deviceId":"device-42"}'
```
