# Errors

> Every MyStocks Partner API error carries a machine-readable error.code and a human-readable error.message. Full reference of HTTP status codes, error codes, and structured order-rejection codes.

Every error response carries a top-level `error` object with a machine-readable `code` string and a
human-readable `message`. This applies to all endpoints. Check `error.code` for programmatic handling;
use `error.message` for logging or UI display.

```jsonc
// 401 auth error
{ "error": { "code": "AUTH_MISSING", "message": "Provide a valid partner API key via Authorization: Bearer pk_live_... or X-Api-Key header." } }

// 400 validation error
{ "error": { "code": "VALIDATION_ERROR", "message": "Ambiguous symbol \"MTN\" matches multiple listings: MTN.GH, MTN.NG. Use an exchange-qualified symbol." } }
```

## HTTP status codes

<ParamTable fields={[
  { name: '200', type: 'OK',                    desc: 'Request succeeded.' },
  { name: '201', type: 'Created',               desc: 'Resource created (register, create sub-account).' },
  { name: '202', type: 'Accepted',              desc: 'Order accepted into the live fill flow (trade, subscribe). Funds escrowed immediately; settlement is asynchronous.' },
  { name: '400', type: 'Bad Request',           desc: 'Missing or invalid parameters, a missing idempotency key, or insufficient funds. Check error.code.' },
  { name: '401', type: 'Unauthorized',          desc: 'API key missing, invalid, or expired.' },
  { name: '403', type: 'Forbidden',             desc: 'Action not permitted: revoked/suspended key, missing scope, KYC required, frozen sub-account, or an operation outside your tier.' },
  { name: '404', type: 'Not Found',             desc: 'Stock symbol, sub-account, holding, or instrument not found.' },
  { name: '409', type: 'Conflict',              desc: 'Idempotency collision — a concurrent request with the same Idempotency-Key is still in progress. Retry after it resolves.' },
  { name: '422', type: 'Unprocessable',         desc: 'Business-rule violation such as MARKET_CLOSED, a fund not being open, or requested units exceeding holdings.' },
  { name: '429', type: 'Too Many Requests',     desc: 'Rate limit exceeded. Check X-RateLimit-Remaining and X-RateLimit-Reset headers.' },
  { name: '500', type: 'Internal Server Error', desc: 'Server-side failure — retry with exponential backoff or contact support.' },
  { name: '503', type: 'Service Unavailable',   desc: 'Planned maintenance (MAINTENANCE) or an unplanned dependency outage (SERVICE_UNAVAILABLE). Always carries Retry-After. Never means your request was wrong — retry it unchanged.' },
]} />

## Machine-readable error codes

When the `error` field is an object, `error.code` is one of the values below.

<ParamTable fields={[
  { name: 'AUTH_MISSING',            type: '401', desc: 'No API key supplied. Send Authorization: Bearer pk_live_... or X-Api-Key.' },
  { name: 'AUTH_INVALID',            type: '401', desc: 'Key format is wrong or the key does not exist in the registry.' },
  { name: 'AUTH_SUSPENDED',          type: '403', desc: 'Partner account temporarily suspended. Contact support@mystocks.africa.' },
  { name: 'AUTH_REVOKED',            type: '403', desc: 'Key permanently revoked. A new key must be issued — revocation cannot be undone.' },
  { name: 'RATE_LIMITED',            type: '429', desc: 'Too many requests. Back off and retry after X-RateLimit-Reset.' },
  { name: 'MISSING_PARAM',           type: '400', desc: 'A required query/body parameter is absent. Check error.param or error.params[].' },
  { name: 'MISSING_IDEMPOTENCY_KEY', type: '400', desc: 'An authenticated POST, PATCH, PUT, or DELETE was sent without the required Idempotency-Key header. The documented registration, reset, application, upgrade, and OAuth token endpoints are exempt.' },
  { name: 'INVALID_SYMBOL',          type: '400', desc: 'Symbol fails format validation (2–20 alphanumerics, optionally dot-separated). Check error.invalid[] in a batch.' },
  { name: 'UNKNOWN_EXCHANGE',        type: '400', desc: 'Exchange code not recognised. See the supported codes list.' },
  { name: 'INVALID_TYPE',            type: '400', desc: 'A parameter has the wrong type.' },
  { name: 'VALIDATION_ERROR',        type: '400', desc: 'Request body or query param failed validation. See error.message.' },
  { name: 'BATCH_LIMIT_EXCEEDED',    type: '400', desc: 'Batch exceeds the max symbol count. Check error.max and error.received.' },
  { name: 'NOT_FOUND',               type: '404', desc: 'Requested resource does not exist.' },
  { name: 'CONFLICT',                type: '409', desc: 'Concurrent request conflict: idempotency collision or order state changed mid-operation.' },
  { name: 'QUOTE_REQUIRED',          type: '400', desc: 'No quoteId supplied on a trade. Every trade needs a fresh single-use quoteId from GET /quote/{symbol}.' },
  { name: 'QUOTE_NOT_FOUND',         type: '404', desc: 'quoteId never issued, or already expired and garbage-collected. Fetch a fresh quote.' },
  { name: 'QUOTE_SCOPE_MISMATCH',    type: '403', desc: 'quoteId belongs to a different account context. Fetch with the same subAccountId you trade on.' },
  { name: 'STALE_QUOTE',             type: '409', desc: 'quoteId expired (60s TTL) or already used. Fetch a fresh quote and retry.' },
  { name: 'QUOTE_ORDER_MISMATCH',    type: '409', desc: 'quoteId does not match the submitted symbol/side/quantity/cashValue. Request a new quote.' },
  { name: 'INSUFFICIENT_FUNDS',      type: '400', desc: 'Wallet balance too low. Top up the master wallet or deposit into the sub-account first.' },
  { name: 'KYC_REQUIRED',            type: '403', desc: 'Sub-account KYC not verified. Assert via POST /users/{userId}/kyc then retry.' },
  { name: 'MARKET_CLOSED',           type: '422', desc: 'The exchange is closed. Check GET /market/status and retry when the venue is open.' },
  { name: 'FORBIDDEN',               type: '403', desc: 'Authenticated but not authorised (e.g. read-only data key on a write endpoint).' },
  { name: 'INTERNAL_ERROR',          type: '500', desc: 'Unexpected server-side failure. Safe to retry with backoff.' },
  { name: 'MAINTENANCE',             type: '503', desc: 'Planned maintenance window. Honour the Retry-After header. In read-only maintenance, GETs still succeed and only mutations return this.' },
  { name: 'SERVICE_UNAVAILABLE',     type: '503', desc: 'Unplanned outage in a downstream dependency. Retry with exponential backoff; check the status page.' },
]} />

## Maintenance and downtime

During a maintenance window the Partner API returns `503` with a `Retry-After` header (in seconds).
There are two modes, reported as `error.mode`:

<ParamTable fields={[
  { name: 'full',      type: 'mode', desc: 'Every request is refused with 503 MAINTENANCE.' },
  { name: 'read_only', type: 'mode', desc: 'GET and HEAD are served normally; every mutation (orders, payments, account changes) is refused with 503 MAINTENANCE. Used during a settlement or payment-rail incident so you can still read positions and reconcile while money movement is frozen.' },
]} />

```jsonc
// 503 during a read-only window
{
  "error": {
    "code": "MAINTENANCE",
    "message": "The Partner API is in read-only maintenance: reads are being served normally, but order, payment, and account mutations are temporarily refused.",
    "mode": "read_only",
    "endsAt": "2026-07-14T02:00:00.000Z",
    "statusPage": "https://mystocks.africa/status"
  }
}
```

Do not treat a `503` as a rejected request — nothing was processed, and the same request is safe to
send again once `Retry-After` elapses. Keep your `Idempotency-Key` the same when you retry.

Live status, uptime, and incident history are published at
[mystocks.africa/status](https://mystocks.africa/status) (raw JSON: `GET /api/v1/status`). You do not
need to poll it: incidents are pushed to your webhooks as `incident.declared` / `incident.resolved`.

## Order rejection codes

The codes above are returned **synchronously** — the HTTP call itself fails and no order is created.
A `rejectionCode` is different: it is attached **asynchronously**, when an order that was already
accepted (`202`, status `PENDING`) is later rejected by the MyStocks internal-book execution engine. You learn about it through
the `order.rejected` webhook, the execution report, or `rejectionCode` on `GET /orders/{orderId}` —
always alongside the free-text `rejectionReason`.

This is the exhaustive list. No other value is ever written.

<ParamTable fields={[
  { name: 'INSUFFICIENT_FUNDS', type: 'retriable', desc: 'The account lacked settled balance at settlement time. Top up and resubmit.' },
  { name: 'MARKET_CLOSED',      type: 'retriable', desc: 'The exchange was closed when the desk attempted execution. Orders placed outside market hours are normally queued rather than rejected, so this usually indicates an unscheduled closure. Check GET /market/status, then resubmit.' },
  { name: 'TECHNICAL_ISSUE',    type: 'retriable', desc: 'Execution failed for an operational reason at the venue or on the desk. Safe to resubmit; if it recurs, check /status for an open incident.' },
  { name: 'KYC_REQUIRED',       type: 'not retriable', desc: 'The account is not KYC-approved. Complete KYC via POST /users/{userId}/kyc, wait for kyc.updated, then resubmit. Retrying as-is will be rejected again.' },
  { name: 'COMPLIANCE_HOLD',    type: 'not retriable', desc: 'Blocked by compliance review (sanctions, market-abuse check, restricted security, or an account hold). Do not resubmit — contact support@mystocks.africa.' },
  { name: 'OTHER',              type: 'not retriable', desc: 'Does not fit a category above. rejectionReason always carries the specific explanation.' },
]} />

`retriable` means resubmitting the same order unchanged could plausibly succeed later. `not retriable`
means something must change first — funds, KYC, the order itself — or the same rejection will repeat.

Order **expiry** is not a rejection. When a `DAY` or `GTD` order reaches its deadline it is
**cancelled**, carrying `orderLifecycle: "EXPIRED"` and a `cancelReason` — never a `rejectionCode`.
