# Webhooks

> Register HTTPS endpoints for real-time event notifications, HMAC-SHA256-signed over the raw body. Full event catalogue, per-event payloads, signature-verification code (Node & Python), at-least-once delivery semantics, and the delivery log.

Register HTTPS endpoints to receive real-time event notifications. MyStocks signs every delivery with
an HMAC-SHA256 signature over the raw request body using your webhook secret. Your endpoint must
respond HTTP 2xx within **8 seconds**; heavy processing should be deferred to a background queue.
Failed deliveries are retried with exponential backoff — check the delivery log via
`GET /webhooks/{id}/deliveries`.

For mobile foreground UX, use [`GET /stream` Server-Sent Events](/partners/docs/sse-events) to receive the same partner events
with near-real-time delivery and resume via `Last-Event-ID` / `?since=`. Webhooks and SSE carry order,
wallet, KYC, dividend, account, and market-status events; they do **not** provide tick-by-tick quotes
or Level-2 order book depth. Poll `/market/quotes` for fresh displayed prices, and use the
`/users/{userId}/devices` endpoints to manage your own APNs/FCM/Expo/Web Push fanout.

## Register a webhook

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

<TryEndpoint id="createWebhooks" />

<ParamTable fields={[
  { name: 'url',    type: 'string',   required: true,  desc: 'HTTPS endpoint. Must respond 2xx within 8 s.' },
  { name: 'events', type: 'string[]', required: true,  desc: 'Array of event type strings to subscribe to.' },
  { name: 'secret', type: 'string',   required: false, desc: 'HMAC signing secret (min 16 chars). Generated automatically if omitted.' },
]} />

<CodeTabs
  curl={`curl -X POST "https://mystocks.africa/api/v1/partner/webhooks" \\
  -H "Authorization: Bearer pk_live_<key>" \\
  -H "Idempotency-Key: webhook_primary_001" \\
  -H "Content-Type: application/json" \\
  -d '{"url":"https://yourapp.com/webhooks/mystocks","events":["order.filled","order.rejected"],"secret":"my-signing-secret-min-16-chars"}'`}
  node={`const webhook = await fetch("https://mystocks.africa/api/v1/partner/webhooks", {
  method: "POST",
  headers: {
    Authorization: "Bearer pk_live_<key>",
    "Idempotency-Key": "webhook_primary_001",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    url: "https://yourapp.com/webhooks/mystocks",
    events: ["order.filled", "order.rejected"],
    secret: "my-signing-secret-min-16-chars",
  }),
}).then((res) => res.json());`}
  python={`webhook = requests.post(
    "https://mystocks.africa/api/v1/partner/webhooks",
    headers={"Authorization": "Bearer pk_live_<key>", "Idempotency-Key": "webhook_primary_001"},
    json={
        "url": "https://yourapp.com/webhooks/mystocks",
        "events": ["order.filled", "order.rejected"],
        "secret": "my-signing-secret-min-16-chars",
    },
).json()`}
  sdk={`const webhook = await client.webhooks.register(
  {
    url: "https://yourapp.com/webhooks/mystocks",
    events: ["order.filled", "order.rejected"],
    secret: "my-signing-secret-min-16-chars",
  },
  { idempotencyKey: "webhook_primary_001" },
);`}
/>

## Event catalogue

| Event | Trigger |
| --- | --- |
| `order.pending` | An order was submitted and is awaiting live fill. |
| `order.filled` | An order was filled and executed. Shares/proceeds credited. |
| `order.rejected` | An order was declined. Includes `rejectionCode` and `rejectionReason`. |
| `order.cancelled` | An order was cancelled via `DELETE /orders/{orderId}`. |
| `order.replaced` | A resting order was modified via `PATCH /users/{userId}/orders/{orderId}` (same orderId). |
| `trade.settled` | Alias for `order.filled` — use `order.filled` in new integrations. |
| `trade.rejected` | Alias for `order.rejected` — use `order.rejected` in new integrations. |
| `deposit.confirmed` | A sub-account deposit was recorded. |
| `withdraw.confirmed` | A sub-account withdrawal was processed. |
| `wallet.credited` | Master wallet received a top-up from MyStocks ops. |
| `kyc.updated` | A sub-account KYC status changed. Inspect `kycStatus`: `VERIFIED` unlocks trading; `REJECTED` includes reason evidence and remains blocked. |
| `account.frozen` | A sub-account was frozen (or unfrozen) by partner or MyStocks compliance. |
| `account.closed` | A sub-account was soft-closed/offboarded via `DELETE /users/{userId}`. |
| `dividend.paid` | A dividend was received and credited to a sub-account. |
| `corporateaction.declared` | Generic corporate-action event for backwards compatibility. |
| `corporateaction.split` | Stock split, reverse split, or bonus issue affecting holdings. |
| `corporateaction.suspension` | Trading suspension or halt affecting a listed security. |
| `corporateaction.delisting` | Delisting event for a listed security. |
| `corporateaction.rights_issue` | Rights issue or similar subscription entitlement. |
| `corporateaction.symbol_changed` | Ticker/symbol migration, rename, or exchange code change. |
| `quote.expired` | A tradeable quote reached its 60-second TTL without being used. |
| `price.alert` | A price alert you registered crossed its threshold. See [Price alerts](#price-alerts). |
| `market.status` | An exchange changed phase (OPEN/CLOSED/HOLIDAY). |
| `incident.declared` | A platform incident affecting your integration was opened. |
| `incident.resolved` | A previously declared incident was closed. |

## Price alerts

`price.alert` does not fire on its own — you subscribe to the thresholds you care about. Register one
with `POST /price-alerts`; `threshold` is in the instrument's **local trading currency**, the same
basis as a quote's `price`.

```bash
curl -X POST https://mystocks.africa/api/v1/partner/price-alerts \
  -H "Authorization: Bearer pk_live_..." \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "symbol": "SCOM.KE", "exchange": "NSE", "condition": "above", "threshold": 20.00 }'
```

<ParamTable fields={[
  { name: 'symbol',        type: 'string',  desc: 'Required. Must exist and trade on the given exchange.' },
  { name: 'exchange',      type: 'string',  desc: 'Required. Exchange code, e.g. NSE, NGX, JSE.' },
  { name: 'condition',     type: 'string',  desc: 'Required. "above" or "below".' },
  { name: 'threshold',     type: 'number',  desc: 'Required. Positive, in the local trading currency.' },
  { name: 'repeat',        type: 'boolean', desc: 'Default false. When false the alert fires once and disarms. When true it re-arms, but only after the price crosses back through the threshold — so a price hovering at the threshold cannot spam your endpoint.' },
  { name: 'clientAlertId', type: 'string',  desc: 'Optional. Your own reference, echoed back on the alert and in the webhook payload.' },
]} />

Alerts are evaluated against the **live** price and only while the exchange is open, so a stale
closing price can never trigger one. Because quotes are polled on a ~15-minute cycle
(see [Data freshness](/partners/docs/market-data-quotes)), an alert fires on the first poll after the
crossing — not at the instant of the tick. Do not use `price.alert` as an execution trigger; use a
resting `LIMIT` or `STOP` order, which is evaluated on the same cycle but actually places the trade.

List with `GET /price-alerts`, inspect one with `GET /price-alerts/{alertId}`, and remove one with
`DELETE /price-alerts/{alertId}`. You may hold up to 200 active alerts.

```jsonc
// price.alert
{ "event": "price.alert", "timestamp": "2026-07-13T11:45:02Z", "data": { "alertId": "alr_abc123", "clientAlertId": null, "symbol": "SCOM.KE", "exchange": "NSE", "condition": "above", "threshold": 20, "price": 20.15, "currency": "KES", "repeat": false, "state": "DISARMED", "triggeredAt": "2026-07-13T11:45:02.000Z" } }
```

## Delivery envelope

Every delivery wraps the event-specific payload in a standard three-field envelope. Sandbox deliveries
add `isSandbox: true` at the top level.

```json
{ "event": "order.filled", "timestamp": "2026-06-06T10:32:00Z", "data": { "...": "event-specific fields" } }
```

## Event payloads

```jsonc
// order.pending
{ "event": "order.pending", "timestamp": "2026-06-06T10:00:00Z", "data": { "orderId": "ord_abc123", "subAccountId": "usr_abc123", "externalId": "user_42", "type": "BUY", "symbol": "SCOM.KE", "quantity": 1000, "totalCost": 12.67, "status": "PENDING" } }

// order.filled (+ alias trade.settled)
{ "event": "order.filled", "timestamp": "2026-06-06T10:32:00Z", "data": { "orderId": "ord_abc123", "subAccountId": "usr_abc123", "type": "BUY", "symbol": "SCOM.KE", "exchange": "NSE", "quantity": 1000, "feeAmount": 0.09, "status": "FILLED", "settledAt": "2026-06-06T10:32:00Z", "settlementUsdPrice": 0.01261, "totalCost": 12.70 } }

// order.rejected (+ alias trade.rejected) — BUY escrow refunded before this fires
{ "event": "order.rejected", "timestamp": "2026-06-06T10:05:00Z", "data": { "orderId": "ord_abc123", "subAccountId": "usr_abc123", "type": "BUY", "symbol": "SCOM.KE", "quantity": 1000, "status": "REJECTED", "rejectionCode": "LIQUIDITY_UNAVAILABLE", "rejectionReason": "Insufficient market liquidity for this order size." } }

// order.cancelled — BUY includes refunded; SELL has no wallet impact
{ "event": "order.cancelled", "timestamp": "2026-06-06T09:15:00Z", "data": { "orderId": "ord_abc123", "subAccountId": "usr_abc123", "type": "BUY", "symbol": "SCOM.KE", "quantity": 1000, "status": "CANCELLED", "refunded": 12.67, "currency": "USD" } }

// deposit.confirmed — local-currency fields appear when provided in the deposit
{ "event": "deposit.confirmed", "timestamp": "2026-06-06T08:30:00Z", "data": { "subAccountId": "usr_abc123", "externalId": "user_42", "amount": 50.0, "currency": "USD", "newBalance": 150.0, "localAmount": 6500, "localCurrency": "KES", "fxRate": 130.0 } }

// withdraw.confirmed
{ "event": "withdraw.confirmed", "timestamp": "2026-06-06T08:45:00Z", "data": { "subAccountId": "usr_abc123", "amount": 25.0, "currency": "USD", "newBalance": 125.0 } }

// wallet.credited — master wallet top-up fulfilled
{ "event": "wallet.credited", "timestamp": "2026-06-06T09:00:00Z", "data": { "amount": 500.0, "newBalance": 1500.0, "currency": "USD" } }

// corporateaction.split (also sent as corporateaction.declared)
{ "event": "corporateaction.split", "timestamp": "2026-06-06T09:00:00Z", "data": { "corporateActionId": "ca_abc123", "type": "SPLIT", "symbol": "SCOM.KE", "exchange": "NSE", "ratio": "2:1", "effectiveDate": "2026-07-01", "affectedSubAccounts": [{ "subAccountId": "usr_abc123", "externalId": "user_42", "units": 1000 }] } }

// kyc.updated — overriddenByAdmin: true appears only on admin overrides
{ "event": "kyc.updated", "timestamp": "2026-06-06T11:00:00Z", "data": { "subAccountId": "usr_abc123", "externalId": "user_42", "kycStatus": "VERIFIED", "kycLevel": "FULL", "reference": "sumsub_ref_abc123" } }

// kyc.updated rejection — gate the trading UI and show partner-approved remediation text
{ "event": "kyc.updated", "timestamp": "2026-06-06T11:05:00Z", "data": { "subAccountId": "usr_abc123", "externalId": "user_42", "kycStatus": "REJECTED", "kycLevel": "NONE", "reasonCode": "DOCUMENT_EXPIRED", "reason": "Submit a current government-issued identity document.", "reviewedAt": "2026-06-06T11:05:00Z" } }

// account.frozen — frozen boolean distinguishes freeze/unfreeze
{ "event": "account.frozen", "timestamp": "2026-06-06T12:00:00Z", "data": { "subAccountId": "usr_abc123", "frozen": true, "frozenBy": "platform_admin" } }

// account.closed
{ "event": "account.closed", "timestamp": "2026-06-06T12:05:00Z", "data": { "subAccountId": "usr_abc123", "externalId": "user_42", "status": "closed", "residualTransferUsd": 0, "currency": "USD" } }

// dividend.paid — one per distribution batch, grouped by symbol
{ "event": "dividend.paid", "timestamp": "2026-06-06T09:00:00Z", "data": { "symbol": "SCOM.KE", "dividendPerShare": 0.00065, "currency": "KES", "totalUsdPaid": 0.65, "distributions": [{ "subAccountId": "usr_abc123", "units": 1000, "usdYield": 0.50 }] } }

// incident.declared — severity P0 (critical) → P3 (minor)
{ "event": "incident.declared", "timestamp": "2026-06-06T14:00:00Z", "data": { "id": "inc_abc123", "title": "NGX market data delay", "severity": "P2", "affectedServices": ["market-data"], "status": "investigating" } }

// incident.resolved — duration is a human-readable string
{ "event": "incident.resolved", "timestamp": "2026-06-06T16:30:00Z", "data": { "id": "inc_abc123", "severity": "P2", "status": "resolved", "resolvedAt": "2026-06-06T16:30:00Z", "duration": "2h 30m" } }
```

## Signature verification

Every delivery includes an `x-mystocks-signature` header containing an HMAC-SHA256 hex digest of the
raw request body signed with your webhook secret. Always verify using a constant-time comparison.

<UITabs items={['Node.js', 'Python']}>
  <Tab>
    ```javascript
    const crypto = require('crypto');

    app.post('/webhooks/mystocks', express.raw({ type: '*/*' }), (req, res) => {
      const sig = req.headers['x-mystocks-signature'].replace(/^sha256=/i, '');
      const expected = crypto
        .createHmac('sha256', process.env.MYSTOCKS_WEBHOOK_SECRET)
        .update(req.body) // raw Buffer — do NOT parse JSON first
        .digest('hex');

      const verified = crypto.timingSafeEqual(
        Buffer.from(sig, 'utf8'),
        Buffer.from(expected, 'utf8'),
      );
      if (!verified) return res.status(401).send('Invalid signature');

      const event = JSON.parse(req.body);
      res.status(200).send('OK');
    });
    ```
  </Tab>
  <Tab>
    ```python
    import hmac, hashlib, os, json
    from fastapi import FastAPI, Header, HTTPException, Request

    app = FastAPI()

    @app.post("/webhooks/mystocks")
    async def handle_webhook(request: Request, x_mystocks_signature: str = Header(...)):
        payload = await request.body()
        expected = hmac.new(
            key=os.getenv("MYSTOCKS_WEBHOOK_SECRET").encode(),
            msg=payload,
            digestmod=hashlib.sha256,
        ).hexdigest()
        supplied = x_mystocks_signature.removeprefix("sha256=")
        if not hmac.compare_digest(supplied, expected):
            raise HTTPException(status_code=401, detail="Invalid signature")
        event = json.loads(payload)
        return {"status": "ok"}
    ```
  </Tab>
</UITabs>

<Callout type="warn">
  **Delivery semantics — at-least-once, not exactly-once.** The same event can arrive more than once;
  events are not guaranteed to arrive in order (a retried `order.pending` can land after
  `order.filled`); and a delivery can fail all retries. The robust pattern: **dedupe by `eventId`**,
  treat handlers as idempotent, and derive state from the event _payload_ rather than arrival order.
  Respond `2xx` quickly and process asynchronously — you have 8 seconds before the attempt fails.
</Callout>

## Manage & inspect

<MethodTag m="GET" /> `/webhooks` · <MethodTag m="DELETE" /> `/webhooks/{id}` · <MethodTag m="POST" /> `/webhooks/{id}/test` · <MethodTag m="GET" /> `/webhooks/{id}/deliveries`

<TryEndpoint id="listDeliveries" label="Try delivery log" />

List registered webhooks, delete one, fire a test event, or inspect delivery history.
`POST /webhooks/{id}/test` sends a `test.event` payload immediately and logs the delivery — useful for
verifying reachability and signature verification before going live. `/deliveries` returns each attempt
with HTTP status, response snippet, duration (ms), and the retry schedule if it failed. Retry schedule:
immediate → 5 s → 30 s → 5 min → 30 min → 2 h (6 attempts max).

```bash
# Fire a test.event delivery immediately
curl -X POST "https://mystocks.africa/api/v1/partner/webhooks/wh_abc123/test" \
  -H "Authorization: Bearer pk_live_<key>" \
  -H "Idempotency-Key: webhook-test-wh_abc123-2026-08-10"

# Inspect delivery log
curl "https://mystocks.africa/api/v1/partner/webhooks/wh_abc123/deliveries?limit=10" \
  -H "Authorization: Bearer pk_live_<key>"
```

```json
{ "deliveries": [{ "id": "del_abc", "eventId": "evt_abc123", "event": "order.filled", "status": 200, "duration": 142, "attemptedAt": "2026-06-04T11:30:01Z", "success": true }, { "id": "del_def", "eventId": "evt_def456", "event": "deposit.confirmed", "status": 503, "duration": 8000, "success": false, "nextRetryAt": "2026-06-04T10:05:00Z" }], "count": 2, "hasMore": false, "nextCursor": null }
```
