← All recipes
Dividends & Corporate Actions
≈ half a day
Two webhook streams keep client portfolios truthful: dividend.paid announces cash that has already been credited, and corporateaction.declared warns you that units, symbols, or prices are about to change shape. Apps that ignore the second one show wrong quantities after every stock split.
The flow
| Step | Endpoint / Event | What it does |
|---|---|---|
| 1 | GET /dividends/calendar | Upcoming declarations. Filter on partnerEligible: true — set when at least one of your sub-accounts holds the stock. |
| 2 | GET /users/{id}/dividends | Per-user dividend income history with per-share amounts and pay dates. |
| 3 | Webhook: dividend.paid | One event per distribution batch, grouped by symbol, with a distributions[] array of every credited sub-account. |
| 4 | Webhook: corporateaction.declared | Split / rights / bonus / merger / delisting / symbol change, with type, exDate, payDate, ratio, and affectedSubAccounts[]. |
| 5 | Apply the action | Update cached units, symbols, and cost display in your app; the platform adjusts holdings and tax lots on its side. |
| 6 | GET /report/dividends | Partner-level dividend report for finance, with CSV export. |
Implementation
javascript
// Inside your webhook handler (see "Handle Webhooks Reliably" for the shell):
async function processEvent(event) {
switch (event.event) {
case 'dividend.paid': {
// Wallets are ALREADY credited — display + notify only.
const { symbol, dividendPerShare, distributions } = event.data;
for (const d of distributions) {
await notifyUser(d.externalId,
`Dividend: ${symbol} paid ${dividendPerShare}/share — $${d.usdYield} credited.`);
}
break;
}
case 'corporateaction.declared': {
const { type, symbol, ratio, exDate, affectedSubAccounts } = event.data;
// e.g. a 4:1 SPLIT — refresh cached holdings for affected users after exDate.
for (const a of affectedSubAccounts) {
await invalidatePortfolioCache(a.subAccountId);
await notifyUser(a.externalId,
`${symbol}: ${type} effective ${exDate}${ratio ? ` (ratio ${ratio})` : ''}.`);
}
// SYMBOL_CHANGE: remap any watchlists / references you store by ticker.
break;
}
}
}
// POST /webhooks/{id}/test emits test.event only. Use signed fixtures locally
// to exercise business-event branches with the same raw-body signature format.
const crypto = require('crypto');
const WEBHOOK_SECRET = process.env.MYSTOCKS_WEBHOOK_SECRET;
if (!WEBHOOK_SECRET) throw new Error('Set MYSTOCKS_WEBHOOK_SECRET before generating fixtures');
function signedFixture(event, secret) {
const rawBody = JSON.stringify(event);
return {
rawBody,
headers: {
'Content-Type': 'application/json',
'x-mystocks-event': event.event,
'x-mystocks-attempt': '1',
'x-mystocks-signature': 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex'),
},
};
}
const dividendFixture = {
eventId: 'evt_fixture_dividend_1',
event: 'dividend.paid',
data: {
symbol: 'SCOM.KE', name: 'Safaricom PLC', assetType: 'LISTED_STOCK',
dividendPerShare: 0.65, currency: 'KES', totalUsdPaid: 10,
distributionCount: 1,
distributions: [{ subAccountId: 'usr_abc123', externalId: 'user_42', units: 100, usdYield: 10 }],
},
timestamp: '2026-07-14T09:00:00.000Z',
};
const corporateActionFixture = {
eventId: 'evt_fixture_corporate_action_1',
event: 'corporateaction.declared',
data: {
corporateActionId: 'ca_scom_split_1', type: 'SPLIT', symbol: 'SCOM.KE', exchange: 'NSE',
newSymbol: null, oldSymbol: null, title: '4-for-1 stock split', description: null,
exDate: '2026-08-03T00:00:00.000Z', recordDate: '2026-08-04T00:00:00.000Z',
payDate: null, effectiveDate: '2026-08-03T00:00:00.000Z', amount: null, ratio: '4:1',
affectedSubAccounts: [{ subAccountId: 'usr_abc123', externalId: 'user_42', units: 100 }],
},
timestamp: '2026-07-14T09:00:00.000Z',
};
const dividendRequest = signedFixture(dividendFixture, WEBHOOK_SECRET);
const corporateActionRequest = signedFixture(corporateActionFixture, WEBHOOK_SECRET);
// Feed each request's rawBody + headers into your local webhook HTTP test client.Common mistakes
- —Ignoring corporateaction.declared. After a split, every cached quantity and average cost you display is wrong until you refresh from GET /users/{id}/portfolio.
- —Crediting dividend cash yourself — the platform already moved the money; double-crediting your own ledger is the classic reconciliation break.
- —Treating dividend.paid as one event per user. It is grouped by symbol with a distributions[] array — iterate it.
- —Storing watchlists and references by ticker without handling SYMBOL_CHANGE actions.
- —Showing calendar entries for stocks none of your users hold — filter on partnerEligible.
Prove it in sandbox first
- 1.POST /webhooks/{id}/test proves reachability and signature handling, but it always emits test.event; it cannot simulate dividend or corporate-action events.
- 2.Sandbox does not originate dividend.paid or corporateaction.declared. Exercise those branches locally with the signed fixtures in this recipe.
- 3.Replay past real events from GET /webhooks/{id}/deliveries once you have a live key.