# Margin, API guide for AI agents

> Margin is a stock portfolio tracking tool. Users import holdings, tradebooks and dividend statements downloaded from their brokerage, organise stocks into lists, and analyse their portfolio. This document tells an AI agent how to perform the operations a user will commonly ask for, over plain HTTP.

All paths are relative to the origin this document was fetched from. Every path begins with /web, except the signal endpoints under Screener criteria, which begin with /stock-signal and take the same token.

## Authentication

Every request needs this header:

    Authorization: Bearer <api-token>

API tokens start with mgn_. The user creates one in the Margin web app and gives it to you once; store it in a private file on the user's machine (for example ~/.margin/token) and reuse it.

- 401 means the token is invalid or revoked: ask the user for a new one.
- 403 means the operation is not available to API tokens: the user must do it in the Margin web app themselves.

API tokens work only with the endpoints listed in this document.

## Upload brokerage holdings

Holdings go in as JSON. You read the user's holdings wherever they are, a brokerage export, a screenshot they describe to you, or what they tell you directly, and send the rows.

    POST /web/stockOfInterest/upload/json

Body:

    {"tradingAccountId": 12,
     "holdings": [{"symbol": "RELIANCE", "isin": "INE002A01018", "quantity": 10, "averagePrice": 2400.5},
                  {"symbol": "TCS", "isin": "INE467B01029", "quantity": 5, "averagePrice": 3500}]}

- tradingAccountId: an id from GET /web/tradingAccount naming the trading account the holdings are recorded under. Send it whenever the user has a trading account; an id that is not the user's is a 400 with invalidFields ["tradingAccountId"]
- brokerageName: a name from GET /web/brokerage/summary matched whatever its case, accepted for older callers and landing on the oldest trading account at that brokerage. Add a trading account and send its id instead, as described under Choosing the trading account. Once tradingAccountId is sent, brokerageName is ignored
- holdings: at least one entry. Each needs a symbol or an isin, and both when the user's export carries both
- quantity: the number of units held, above zero, as a JSON number
- averagePrice: the average cost of one unit, not the value of the position, as a JSON number

The file uploads at /web/stockOfInterest/upload/csv and /upload/excel belong to the Margin web app and answer 403 to an API token. Read the user's file yourself and send its rows here. There is no format to conform to and no brokerage whose file is unsupported: whatever the export looks like, the rows become symbol, isin, quantity and averagePrice.

Example:

    curl -H "Authorization: Bearer $TOKEN" \
      -H "Content-Type: application/json" \
      -d '{"tradingAccountId": 12, "holdings": [{"symbol": "RELIANCE", "isin": "INE002A01018", "quantity": 10, "averagePrice": 2400.5}]}' \
      https://HOST/web/stockOfInterest/upload/json

Send the user's whole position in that trading account in one request. A user with two broker accounts at one brokerage has two trading accounts in GET /web/tradingAccount when they have set them up that way, and each gets its own request. Two rows in one request that resolve to the same stock are merged into a single holding with the quantities added and the cost averaged by quantity, so there is no need to combine them yourself.

The symbol on a row is not necessarily the symbol Margin records the holding under. Zerodha's series suffixed E2E-BE, for instance, is recorded as E2E. Send the ISIN whenever the user's export carries one, and match on ISIN rather than symbol when you read the holding back.

The response contains:

- tradingAccountId and brokerageName: the trading account the holdings were recorded under and its brokerage, spelled as Margin holds it
- holdingsReceived and holdingsProcessed: how many rows you sent and how many were recorded
- stocksNotFound[]: {symbol, isin} for rows whose stock could not be matched to a known stock; report these to the user
- changes: added, stocks that had no holding in this trading account and now do; updated, stocks whose holding in this trading account was replaced by the quantity and cost you sent; removedSinceNotHeld, holdings in this trading account for stocks absent from your request, which are deleted

The request is checked before anything is written, so a rejected import leaves the account untouched. A 400 carries invalidFields naming every row and field at fault in one list, for example ["tradingAccountId", "holdings[0].quantity", "holdings[3].symbol"]; read the whole list and fix it in one go. Numbers must be sent as JSON numbers, a numeric string is rejected rather than coerced, and an empty holdings array is rejected rather than treated as an instruction to clear the brokerage.

### Choosing the trading account

Read GET /web/tradingAccount before importing. Each entry carries its brokerageName, the name the user gave it and holdingCount, the number of stocks recorded in it now, so it tells you where the user's existing holdings sit. When exactly one trading account matches the brokerage the holdings came from, send its id. When two do, ask the user which one, naming each by its name. When none does, add one and send the id it comes back with:

    POST /web/tradingAccount

Body:

    {"brokerageId": 3, "name": "Zerodha, joint with spouse"}

- brokerageId: an id from GET /web/brokerage/summary, which returns [{id, name, numberOfAccounts, numberOfHoldings}] for every brokerage Margin knows. Use "Other" only for a brokerage genuinely not in that list. An id Margin does not know is a 400 with invalidFields ["brokerageId"]
- name: optional, what the user calls the account; ask the user for it when they keep more than one account at the brokerage, so the two can be told apart later

The response is the new account, {id, brokerageId, brokerageName, name, holdingCount, tradeCount}. Add a trading account only when the user has no account at that brokerage or tells you they hold a second one there: every POST opens a new account, and renaming, moving or removing one is done in the Margin web app, where PUT /web/tradingAccount/{id} answers 403 to an API token.

### What an import replaces and what it leaves behind

An import fully replaces the holdings recorded in the trading account it lands in and touches nothing else. Stocks in the request are overwritten with the quantity and cost you sent, holdings in that trading account for stocks not in the request are deleted, and holdings in every other trading account are left exactly as they were. Sending the same rows again changes nothing: the second call reports every stock under updated and no quantity moves.

So a portfolio imported into a trading account other than the one the user's earlier imports used is recorded a second time instead of replacing the first, and nothing in the response says so; changes counts only what happened in the trading account the import landed in. Check holdingCount on GET /web/tradingAccount, and when it does not settle which account the user meant, ask before defaulting to Other.

Nothing in this API deletes a holding, and clearing holdings is one of the operations API tokens cannot perform, so holdings recorded twice can only be put right by the user in the Margin web app, where Delete All on the dashboard can clear the holdings of one brokerage and leave the rest. Re-importing cannot undo it. Tell the user plainly rather than searching for an endpoint or trying to overwrite your way back.

### Verifying a holdings import

Read GET /web/holdings before the import as well as after. Each entry of holdings[] carries isin, stockSymbol, units and brokerages[], and a stock the user does not hold has no entry. Match the before and after entries on isin, not on symbol.

An account holding nothing returns an empty holdings array with a total of zero.

units is the user's whole position in that stock across every brokerage. brokerages[] on the same entry splits it by trading account, and its entry carrying the tradingAccountId the response returned is what your import wrote, so compare the quantity you sent against that entry's units. When the two differ, report the difference to the user with both numbers and stop. Do not import again to correct it.

## Upload tradebook

    POST /web/tradeBook/upload/csv     for CSV files (Zerodha, AngelOne, Other)
    POST /web/tradeBook/upload/excel   for Excel files (Groww, Upstox)

Multipart form fields:

- files: one or more tradebook files, at most 10 files of 10 MB each
- brokerageName: exactly one of Zerodha, AngelOne, Groww, Upstox, Other
- tradingAccountId: optional, an id from GET /web/tradingAccount; when sent it names the trading account the trades are filed under and its brokerage decides the file format, so brokerageName is not needed. An id that is not the user's is a 400 with invalidFields ["tradingAccountId"]

Trades go in as files rather than as JSON; upload the file the user downloaded.

Where users find the file: Zerodha at console.zerodha.com under Reports then Tradebook (CSV); Groww and Upstox in their trade report export (Excel).

Use brokerageName "Other" for any brokerage not listed above. Its CSV header row is "trade_date,symbol,isin,trade_type,quantity,price,trade_id,order_id". trade_date accepts YYYY-MM-DD, DD-MM-YYYY or DD/MM/YYYY, trade_type is buy or sell, and isin, trade_id and order_id may be left blank. When trade_id is blank the server derives one from the row, so re-uploading the same rows will not duplicate trades.

Example:

    curl -N -H "Authorization: Bearer $TOKEN" \
      -F "files=@tradebook.csv" \
      -F "brokerageName=Zerodha" \
      https://HOST/web/tradeBook/upload/csv

The response is a progress stream of concatenated JSON objects of the form {"progress": "...", "percentage": n}. The final object additionally has a "data" field with the result:

- insertedTradesCount[]: {financialYear, count} of newly inserted trades
- insertedStocks[]: {symbol, quantity, numberOfTrades}
- stocksNotFound[]: {symbol, isin} for trades skipped because the stock is unknown
- reassignedTradesCount: trades already on record under another of the user's brokerages that this upload moved to the brokerageName you sent, rather than inserting a second copy

Trades are filed under the trading account you send, or under the trading account of the brokerageName you send. A user can keep more than one trading account at the same brokerage, and a brokerageName alone then lands on the oldest of them, so when GET /web/tradingAccount lists two accounts at a brokerage, ask the user which one and send its tradingAccountId. A trade is recognised by its order id and trade id across every brokerage of the account, so re-uploading a tradebook under the brokerage it belongs to corrects an earlier upload filed under the wrong one: those trades move and are counted in reassignedTradesCount, and nothing is duplicated. Summarise data for the user: how many trades were inserted per year, how many were moved, and which stocks were skipped. A 400 response means a file could not be parsed or contained no valid trades; the error message names the file.

## Upload dividends

    POST /web/dividends/upload/csv

Multipart form fields:

- files: one or more dividend statements, at most 10 files of 10 MB each
- brokerageName: Zerodha, currently the only brokerage whose dividend statement Margin reads
- tradingAccountId: optional, an id from GET /web/tradingAccount naming the trading account the dividends are filed under; its brokerage must be one Margin reads, and brokerageName is then not needed
- financialYear: the year the upload speaks for, as the year it ends in, so 2026 means April 2025 to March 2026. Omit it only when the trading account holds no dividends at all, and every row is then filed under the year its own ex-date falls in. Omitting it on an account that already holds dividends is a 400
- final: "true" to record the upload even though rows remain unmatched, omitted or "false" otherwise. See "When Margin cannot match a stock" below, and do not send it on the first upload
- stockSelections: optional JSON array of [{"symbol": "...", "isin": "...", "stockId": n}], the stocks the user chose for rows Margin could not match. symbol and isin must be copied from the stocksNotFound entry the choice answers, and stockId is a Margin stock id

Where users find the file: console.zerodha.com under Reports then Dividends, downloaded as CSV. Upload it exactly as downloaded, do not edit or convert it.

The parser matches five column headers by name, in any order and in any case: "Symbol", "Ex-date", "Qty", "Dividend per share", "Total dividend". Dates accept YYYY-MM-DD, DD-MM-YYYY, DD/MM/YYYY or DD-MMM-YYYY. The ex-date is the only date Zerodha reports. It decides which financial year a row belongs to and is kept as the date of the payout, which is what puts the dividend on the timeline when XIRR is worked out. When "Total dividend" is blank the amount is worked out as Qty times Dividend per share; a row carrying neither is rejected with a 400 naming the row number, and nothing from that upload is written. The statement reports no tax deducted, so tax is recorded as zero and the gross equals the net.

Example:

    curl -N -H "Authorization: Bearer $TOKEN" \
      -F "files=@dividends-fy2026.csv" \
      -F "brokerageName=Zerodha" \
      -F "financialYear=2026" \
      https://HOST/web/dividends/upload/csv

No other brokerage is supported. Any other brokerageName is a 400 naming the ones that are, checked before anything is written. Do not relabel another brokerage's statement as Zerodha to get it in, and do not rewrite it into the five columns above: dividends are recorded against the trading account of the brokerageName you send, so the user's ledger would credit the wrong broker. Tell the user their brokerage is not read yet, and that the feedback button in the Margin web app is where they send the format so it can be added.

### What one upload records and what it replaces

Margin keeps one dividend record per payout: per stock, ex-date and brokerage. A stock that paid twice in a year is two records with their own dates. Two rows carrying the same stock and the same ex-date, which is what a file uploaded alongside an overlapping one produces, are added together into the one record that date can hold.

Recording dividends moves the user's returns, so an upload is not a bookkeeping entry that leaves the rest of the account alone. Every payout counts as cash returned on its ex-date, alongside the sales in the tradebook, both in the XIRR of the whole portfolio and in that of a single stock. A holding whose dividends have not been uploaded therefore reads as having returned less than it did, and every year missing from the record understates the return by what that year paid. Tell the user which years they have on record before they read an XIRR as final.

An upload rewrites the whole of that financial year for that brokerage. The files you send become the year as it stands: every payout in the files is written, payouts the year held before and the files do not mention are deleted, and other years and other brokerages are untouched. This is not an append. Uploading a second file for a year on its own, without the first, leaves the year holding only the second file's payouts.

So send every file for a year in one request. All files in a request are read together, so a user with two Zerodha statements covering one year gets every payout of that year in one place. Uploading them one after the other instead leaves only the last. Where the files overlap and report the same payout twice, on the same stock and the same ex-date, the two are added together rather than kept apart, so an overlap inflates that payout instead of duplicating it: send each payout once.

financialYear must be a whole year between 1990 and the year now running, and it is the year the upload is filed under, not a filter. Rows in the files whose ex-date falls in a different financial year are left out and reported back under rowsOutsideFinancialYear; they are not filed under the year you sent. Zerodha's export takes a date range and a range of a year or more spans two financial years, so expect this on a statement the user downloaded without trimming: upload it once per year it covers, with the matching financialYear each time. A financialYear that matches no row in the files is not an error, it empties that year.

On a trading account that holds no dividends yet there is nothing an upload can overwrite, so financialYear can be left out. Every row is then filed under the year its own ex-date falls in, a statement spanning several years is recorded in one request, rowsOutsideFinancialYear comes back empty, and years[] carries one entry per year the files covered. Once the account holds dividends the field is required again, because the year is what scopes the rewrite.

The response is a progress stream of concatenated JSON objects of the form {"progress": "...", "percentage": n}. The final object additionally has a "data" field with the result:

- saved: whether the years were written. false means nothing was recorded and the upload is waiting on the user's choice of stocks
- years[]: one entry per financial year the upload rewrote, oldest first, each carrying:
  - financialYear: the year that was rewritten
  - recordedStocks[]: {stockId, symbol, payouts, grossAmount, taxAmount, netAmount}, one per stock now on record for the year, rolling up that stock's payouts, where payouts is how many rows of the files it rolled up
  - removedStocks[]: {stockId, symbol, netAmount} for stocks the year held before and the files left out, now deleted; report these, they are the ones a partial upload silently drops
  - replacedStocks: how many of the recorded stocks already had a record for the year
  - totalStocks, totalPayouts, totalNetAmount: the year as it now stands
- totalStocks, totalPayouts, totalNetAmount: the upload as a whole, where totalStocks counts a stock once however many years it paid in
- stocksNotFound[]: {symbol, isin} for rows whose stock Margin could not match, one entry per stock however many rows it paid on
- rowsOutsideFinancialYear[]: {symbol, dividendDate, financialYear} for rows belonging to another year

years[] is empty only when no row could be matched to a stock, and in that case nothing was written and no year was emptied. When a financialYear was sent, years[] always holds exactly that one year.

When saved is false, years[] and the totals describe what the upload would write, not what the years hold. Do not report them as recorded, and do not read them back against the counts endpoint.

### When Margin cannot match a stock

An upload that leaves any row unmatched writes nothing at all. The year is untouched, saved comes back false, and the stocks are listed under stocksNotFound. This is deliberate: recording the matched rows on their own would rewrite the year with the unmatched stocks missing, and the user would read a year that looks complete while it silently understates their returns.

Tell the user what happened and that they can pick the stocks themselves, the same choice the Margin web app offers them on this screen. For each entry in stocksNotFound, search GET /web/stock/find/{text} with the symbol, and with the isin when the symbol finds nothing. Never guess: put the matches to the user and let them choose, and say plainly when a search returns nothing. A symbol like MODISNME6 is usually a delisted or renamed company, so there may be no stock to pick.

Then upload the same files again, unchanged, with final=true and stockSelections carrying the choices the user made. That upload records the year: the rows whose stock they picked go to that stock, and rows still unmatched are left out and reported again under stocksNotFound. Sending final=true is the user's decision to record the year without them, so ask before you send it, and never send it on the first upload to save a round trip. A stockSelections entry whose stockId is not a stock in Margin is not an error, the rows it stands for are simply left unmatched again.

Example:

    curl -N -H "Authorization: Bearer $TOKEN" \
      -F "files=@dividends-fy2026.csv" \
      -F "brokerageName=Zerodha" \
      -F "financialYear=2026" \
      -F "final=true" \
      -F 'stockSelections=[{"symbol":"SGML","isin":"INE979A01025","stockId":1234}]' \
      https://HOST/web/dividends/upload/csv

Uploading the same file twice is safe and is not a duplicate import: the second upload rewrites the year to the same totals and reports every stock under that year's replacedStocks with nothing removed. Read each year's removedStocks before telling the user an upload went cleanly, because a re-upload of a trimmed file looks identical to a successful one apart from that list.

Nothing in this API deletes a dividend, and clearing them is one of the operations API tokens cannot perform, so a year filed under the wrong financialYear can only be cleared by the user in the Margin web app, or overwritten by uploading that year again.

To verify, read GET /web/dividends/counts before and after and compare the year's stockCount and netAmount against totalStocks and totalNetAmount. Compare stockCount against totalStocks and not against totalPayouts: the counts endpoint counts the stocks a year paid, while totalPayouts counts the payouts written, and the two differ by every stock that paid more than once. GET /web/dividends/summary is where a payout count can be read back, as its totalRecords. The per-record reads, GET /web/dividends, /web/dividends/by-stock and /web/dividends/by-year, are not open to API tokens and answer 403, so verify against the counts and the summary rather than by reading rows back.

## Upload the funds statement (ledger)

    POST /web/fundsLedger/upload/json

The funds statement is the record of cash moving in and out of the user's brokerage account. It is the only source for what the user actually contributed, which is what a money-weighted return needs, and no broker API exposes it: the user downloads it from the brokerage console, one financial year at a time. On Zerodha it is console.zerodha.com under Funds then Statement.

The statement goes in as JSON. Read the user's file yourself, whatever it looks like, and send the rows; there is no multipart endpoint for it and no layout to conform to. Every brokerage names its columns differently, so do not expect a fixed header row: map what the file carries onto the fields below. The Margin web app reads a file per brokerage against a stored column mapping, and that is the web app's concern, not yours.

Body:

    {"brokerageName": "Zerodha",
     "financialYear": 2024,
     "openingBalance": 5244.68,
     "closingBalance": 81230.5,
     "entries": [{"postingDate": "2023-05-10", "particulars": "Funds added using payment gateway", "costCenter": "NSE-EQ - Z", "voucherType": "Bank Receipts", "debit": 0, "credit": 500000, "netBalance": 505244.68}]}

- brokerageName: a name from GET /web/brokerage/summary, matched whatever its case
- tradingAccountId: optional, an id from GET /web/tradingAccount naming the trading account the rows are filed under; send it instead of brokerageName, which is then ignored
- financialYear: the year the statement ends in, so 2024 means April 2023 to March 2024
- openingBalance, closingBalance: the two balances the statement itself carries, as JSON numbers. A statement writes them as an opening and a closing line carrying only a running balance, whatever it calls those lines. Do not compute them and do not send zero when the file says otherwise, because they are what the server checks the rows against. They are checked and then discarded; Margin does not store statement balances
- entries: the real rows between those two lines, at least one. postingDate as YYYY-MM-DD whatever the file wrote it as, particulars optional and covered below, costCenter optional and holding the segment the row belongs to, voucherType exactly one of Book Voucher, Journal Entry, Bank Receipts, Bank Payments, Square Off, debit and credit as JSON numbers of zero or more with the side that does not apply omitted or zero, and netBalance the running balance after the row

voucherType is the field everything is read from, so it is the one to get right. A brokerage will write its own wording for these five, and translating that wording is your job: money arriving from the user's bank is Bank Receipts, money paid back to their bank is Bank Payments, a trade settlement is Book Voucher, a fee or charge is Journal Entry, and an intraday square off is Square Off. Net investment is Bank Receipts less Bank Payments and nothing else, so a deposit filed as a settlement is silently wrong rather than rejected. When the wording does not settle which of the five a row is, ask the user rather than guessing.

particulars is optional and is the user's call. The description of a row carries their client code, payment references and bank account, and Margin never keeps the wording either way: when you send it the server hashes it on arrival and stores the hash alone, and when you leave it out the row is stored with no fingerprint at all. Nothing reads the wording back, no endpoint returns it, and there is no text search over the ledger. A question about what a particular row was for cannot be answered from Margin and has to go back to the statement the user downloaded.

What the fingerprint buys is de-duplication. With it, two rows of one date and the same amounts are told apart by their descriptions; without it, they are indistinguishable and the second is kept only by its position in the file. Send particulars for every row in a file or for none of them, and stay with that choice for an account: a row sent once with a description and again without one is read as two different rows and lands twice. Ask the user which they want rather than deciding for them.

Amounts must be JSON numbers; a numeric string is rejected rather than coerced. The Zerodha file carries six decimals, so send them as they are rather than rounding to paise.

### What the server checks before it writes anything

Within the file, closingBalance must equal openingBalance plus every credit minus every debit. Nothing is written when it does not, because a statement that fails this is a partial or corrupted download rather than a smaller year. A mismatch is a 400 whose body carries balanceIdentity with openingBalance, closingBalance, totalDebit, totalCredit, derivedClosingBalance and difference. Do not retry with adjusted numbers and do not drop rows to make it balance; download the year again.

There is no check across years. Margin keeps no statement balances, so nothing on the server can tell you that a year was skipped or that an account had activity before the earliest year you uploaded. Whether the record is complete is yours and the user's to know. GET /web/fundsLedger/coverage lists the years on record and names any year missing between the earliest and the latest, and that is the whole of what can be verified.

A 400 carries invalidFields naming every row and field at fault in one list, for example ["brokerageName", "entries[0].postingDate", "entries[3].voucherType"]. A tradingAccountId that is not the user's is a 400 with invalidFields ["tradingAccountId"].

### What one upload adds

An upload adds rows. It never deletes anything, and it never rewrites a year. Rows whose postingDate falls in another financial year are not recorded and come back under rowsOutsideFinancialYear with a warning; upload the file once per year it covers.

Margin keeps one ledger per trading account. A user with two broker accounts at the same brokerage keeps them apart by adding a second trading account in the web app and sending its tradingAccountId; a brokerageName alone lands on the oldest trading account at that brokerage, so statements sent that way accumulate together and the ledger then answers what was contributed to the brokerage as a whole. Upload each statement for a year in its own request; they add up rather than overwriting each other, and the order does not matter.

Re-uploading a file you have already sent is safe and records nothing twice. Each row's identity is derived from its date, debit, credit, running balance and the hash of its particulars where you sent them, so rows already on record are skipped and reported under entriesAlreadyOnRecord. Two genuinely identical rows within one file are kept apart rather than collapsed.

Because nothing is deleted, a wrong upload cannot be undone by uploading again. Rows sent under the wrong financialYear or against the wrong brokerage stay until the year is cleared, and clearing is one of the operations API tokens cannot perform. Check brokerageName and financialYear before you send, and when you get one wrong, tell the user plainly that they need to clear that year in the Margin web app rather than trying to correct it with another upload.

Re-uploading the same file is safe and does not duplicate: each row's identity is derived from its account, date, amounts, running balance and the hash of its particulars where you sent them, and two genuinely identical rows in one file are kept apart rather than collapsed.

The response contains:

- brokerageName and tradingAccountId: what the rows were filed under
- entriesReceived, entriesRecorded, entriesAlreadyOnRecord: how many rows you sent, how many were newly written, and how many were already there and skipped
- balanceIdentity: the within-file check that passed
- voucherTypeCounts[]: {voucherType, count, totalDebit, totalCredit} for the year
- moneyIn, moneyOut, netAdded: the external cash of that year, Bank Receipts less Bank Payments
- financialYearsOnRecord: every year this brokerage now has
- rowsOutsideFinancialYear[] and warnings[]

### Reading the ledger back

    GET /web/fundsLedger/coverage       which years each brokerage has, and what is wrong with the chain
    GET /web/fundsLedger/contributions  net money added, per trading account, per year and cumulative
    GET /web/fundsLedger/charges        what the separately posted charges came to, per trading account per year
    GET /web/fundsLedger/return         the money-weighted return on the user's actual rupees

All four return an accounts[] with one entry per trading account the user has a ledger for, each carrying brokerageName and tradingAccountId. All four take an optional tradingAccountId, and contributions and charges also take an optional financialYear. The row-by-row read, GET /web/fundsLedger, is not open to API tokens and answers 403; use the four summaries.

Read coverage before quoting any total. It returns per trading account a coverage carrying financialYears, years with their entry counts, earliestFinancialYear, latestFinancialYear, missingFinancialYears and totalEntries, plus warnings written for the user. One of those warnings always fires: it names the earliest year on record and says the totals cover that year onwards rather than a lifetime. Repeat it. Margin has no way to detect activity before the earliest year uploaded, so a total presented as lifetime is a claim neither you nor the server can support.

GET /web/fundsLedger/contributions is exact over the rows on record: only Bank Receipts and Bank Payments move money between the user and the outside world, so netAdded is what the user put in across the years uploaded. It carries the same warnings.

GET /web/fundsLedger/return is a money-weighted return computed from the dated bank flows with the current value of holdings as the terminal inflow. Cash sitting idle with the broker is not part of it, because the ledger stores no balances, so the rate is understated by whatever the user has uninvested. It is not the same number as GET /web/xirr, which is derived from trades and dividends and measures the return on capital while it was deployed in particular stocks. The ledger figure measures the return on the user's rupees and accounts for when they were added. Present both, labelled, rather than replacing one with the other. Each entry carries a moneyWeightedReturn with status, xirr, moneyIn, moneyOut, netAdded, terminalValue, holdingsValue, gain, absoluteReturn and the span of the flows. A status of notComputable or noCashFlows means xirr is null and there is no rate to report; do not fill it in from the absolute return.

### What the ledger cannot tell you

Brokerage and STT are netted inside the Book Voucher settlement amounts and are not itemised anywhere in the statement. GET /web/fundsLedger/charges therefore reports only the charges posted as their own Journal Entry rows, as byYear entries of {financialYear, count, amount} with a chargeCount and a totalItemisedCharges, and it returns a limitation field saying so. Never present its total as total brokerage paid or as the cost of trading; that needs the tradebook or the brokerage P&L report.

There is no breakdown of what those charges were for. Telling a DP charge from a demat AMC or a payment gateway fee would take the wording of the row, which Margin hashes and does not store, so the count and the amount per year are the whole of what the ledger can say about charges. Do not guess a category from the amount. For how many times the user sold, read the trades instead.

## Stock lists

Users keep named lists of stocks, for example a screen downloaded from elsewhere.

    GET  /web/stocksList         all lists with their stock counts
    GET  /web/stocksList/{id}    the stocks on one list
    POST /web/stocksList         create a list, body: {"name": "...", "description": "...", "stocks": [stockId, ...]}
    PUT  /web/stocksList         update a list, body: {"id": n, "name": "...", "description": "...", "stocks": [stockId, ...]}

A list can carry a description, the user's note on what the list is for. It is optional everywhere: absent or null on lists that have none, and returned as the description field by GET /web/stocksList and GET /web/stocksList/{id}. On PUT, omit the field to leave the stored description as it is, send a string to replace it, and send an empty string to clear it. Never write a description the user did not ask for, and never rewrite an existing one when you were asked to change the membership.

Because PUT takes the whole list, omitting stocks leaves the membership untouched, which is how you change only the name or only the description. Send stocks only when you intend to set the membership.

The stocks array contains Margin stock ids. To turn symbols or company names into stock ids, search first:

    GET /web/stock/find/{text}

Search returns matching stocks with their id, symbol and name. Resolve each symbol, and when a search returns several plausible matches, confirm with the user instead of guessing. PUT replaces the list's membership with exactly what you send, so to add stocks to an existing list, read its current stocks first and send the combined set.

The user knows lists by name, not by id. GET /web/stocksList returns each list with its id and name; match the user's list name to that id yourself rather than asking the user for a number. Use that id for GET /web/stocksList/{id} and for the id field in PUT.

Four of the entries GET /web/stocksList returns are not lists the user made: All, Holdings, AllEverHeld and NotCurrentlyHeld. All is every stock the user tracks, whether or not it was ever held; Holdings is what the user holds now; AllEverHeld is every tracked stock that is held now or has a trade in the user's trading accounts; NotCurrentlyHeld is the rest. They carry autoGenerated: true and no id, because they are views the server derives from the account rather than stored membership. Over this API they are counts only: numberOfStocks is the whole of what they tell you, and there is no id to pass to GET /web/stocksList/{id}, PUT or DELETE. Do not invent one, and do not read one of them by guessing an id. For what the user actually holds, read GET /web/holdings, which carries the user's own BUY IF ABOVE and BUY IF BELOW prices on each entry as userPrices. Report the other three as counts, and say so plainly rather than presenting an empty list of stocks. A user price set on a stock the user does not hold is reachable only through the list the stock belongs to.

An id that is not a list in the user's account is a 404, on the read as well as on PUT and DELETE, so an empty stocksOfInterest from GET /web/stocksList/{id} means the list exists and holds nothing. Never report a stock as untracked on the strength of a list read that 404s; the list is the wrong one, not the stock.

## Target allocation

Users assign each tracked stock a target allocation, the share of the portfolio it should hold, as a decimal fraction of 1: 0.023 for 2.3 percent. It is set per stock but represents a portfolio-wide distribution, so send every stock's allocation together rather than one at a time; a partial update leaves the rest of the book as it was, not zeroed.

    POST /web/allocation

Body: an array of {"stockOfInterestId": n, "targetAllocation": decimal}. stockOfInterestId is not a Margin stock id; resolve it first with POST /web/stockOfInterest/track/{stockId} as described under Saving a valuation against a stock; for a stock the user holds, holdings[].stockOfInterestId on GET /web/holdings already carries it. The endpoint returns nothing; read the values back from GET /web/holdings.

Stored allocations keep four decimal places, so 0.023 reads back as sent; the fifth decimal, if any, rounds to the nearest ten-thousandth.

GET /web/holdings carries the current value as targetAllocation on each entry of holdings[], a decimal fraction of 1, or null when the stock has none set. Only held stocks appear there, so an allocation set on a stock the user does not hold cannot be read back. weight on the same entry is the stock's actual share of the portfolio's current value on the same scale, so the two compare directly.

## Generating quick stories

A quick story is the short research note Margin holds on a stock: what the company sells, the competitive landscape it sells into, its EBITDA margin range and its latest revenue. Generating one costs money and takes time, so Margin generates a story only for a stock that has none, and leaves every stock that already has one alone. The operation needs the CONSOLE privilege, the same privilege the admin console requires, and an account without it gets 403.

    POST /web/quick-story-generation/generate   queue stories for the stocks you name
    GET  /web/quick-story-generation/status     how those stocks are getting on

The POST body names stocks by id, by symbol, or by both:

    {"stockIds": [1234, 5678], "symbols": ["RELIANCE", "TCS"]}

Symbols are matched whatever their case, and a request carries at most 50 stocks. The response is 202 and the plan Margin made:

    {"queued": [{"stockId": 1234, "name": "...", "symbol": "..."}],
     "existing": [{"stockId": 5678, "name": "...", "symbol": "..."}],
     "unknown": ["NOSUCH"]}

queued are the stocks whose stories Margin has taken on, existing are the ones that already had a story, and unknown are the ids and symbols that match no stock Margin knows. A stock another request already queued appears in none of the three, because it is already on its way. Nothing in the plan is a failure: send a long list, read which parts of it Margin took on, and tell the user about the rest.

Generation runs in the background one stock at a time, so the POST returns before any story is written. Poll the status endpoint for the outcome:

    GET /web/quick-story-generation/status?stockIds=1234,5678&symbols=RELIANCE

It returns {statuses: [{stockId, name, symbol, state, error}], unknown, waiting}, where state is one of:

- pending, queued and not started
- generating, being written now
- done, the stock has a quick story, whether this batch wrote it or an earlier one did
- failed, generation failed and error says why. Send the stock again to retry it
- absent, the stock has no story and nobody has asked for one

waiting is how many stocks are queued behind the one being generated, counting every caller's requests. A story takes from a few seconds to a few minutes, so poll every ten seconds or so rather than in a tight loop. A server restart clears pending and generating stocks from the queue without generating them; a stock still absent long after it was queued needs to be sent again.

Example, generate stories for three stocks and then check on them:

    curl -H "Authorization: Bearer $TOKEN" \
      -H "Content-Type: application/json" \
      -d '{"symbols": ["RELIANCE", "TCS", "INFY"]}' \
      https://HOST/web/quick-story-generation/generate

    curl -H "Authorization: Bearer $TOKEN" \
      "https://HOST/web/quick-story-generation/status?symbols=RELIANCE,TCS,INFY"

Margin serves the story text to its own reader rather than to API tokens, so once a stock reads done, point the user at it in the Margin app. A stock that has a story is one the user can screen, which is what the verdict endpoints below record.

## Stock stories

A stock story is Margin's full research write-up on one company, longer than a quick story and made of eleven sections, in this order:

- companyWork, what the company does
- whyBusinessMatters, why the business matters
- competitiveLandscape, the competitive landscape
- howCompanyWins, how the company wins
- howCompanyMakesMoney, how the company makes money
- financialHealth, financial health
- whereGrowthComesFrom, where growth comes from
- whatCanGoWrong, what can go wrong
- managementCapitalAllocationQuality, management and capital allocation
- recentStory, the recent story
- whatMarketBelieves, what the market believes

Each section is researched and stored on its own, so a story can hold some sections and lack others. Margin generates only the sections a story is missing and never rewrites one it already has.

    GET  /web/stock/{stockId}/story/status     which of the eleven sections the story has
    POST /web/stock/{stockId}/story/generate   generate the missing sections, in the background
    GET  /web/stock/{stockId}/story            the story text, section by section

stockId is a Margin stock id; resolve symbols or names with GET /web/stock/find/{text} first, as for lists. A stockId Margin does not know is a 404 on the status and generate endpoints.

The status and generate endpoints return the same body:

    {"stockId": 1234, "name": "...", "symbol": "...", "state": "partial", "error": null,
     "sections": [{"key": "companyWork", "title": "What the company does", "present": true}, ...]}

sections always lists all eleven in the order above, and present says whether the story has that section. state is one of:

- absent, the story has none of the sections
- partial, it has some of them; the entries with present false are the ones missing
- complete, it has all eleven
- pending, queued behind another stock's story
- generating, its missing sections are being written now
- failed, the last generation failed before it finished and error says why; send the stock to generate again to retry it

Read the status first. When it is complete there is nothing to do, and POST to generate on a complete story returns the status without generating anything. Otherwise POST to generate answers 202 with the status, now pending or generating, and returns before any section is written. Researching one section takes a minute or more, so a story that lacks all eleven can take the better part of twenty minutes. Poll the status every thirty seconds or so and wait for the state to leave pending and generating. One stock's story is generated at a time across every caller, and a stock already pending or generating is not queued a second time.

A finished run can still leave the story partial, because a section whose research fails is skipped while the rest are written. Tell the user which sections are still missing and send the stock again to retry only those. A server restart drops pending and generating stocks without generating them, so a state that goes back to absent or partial with no error means the request was lost and needs sending again.

Starting a story that has no sections at all needs the CONTENT_GENERATION privilege, and an account without it gets 403. Filling in the missing sections of a story that already has at least one needs no privilege. These are the same rules the Margin app applies when a reader opens a story. Reading the status needs no privilege either.

### Reading a story

GET /web/stock/{stockId}/story returns what the Margin app's story reader shows: one field per section, keyed by the section keys above, each holding that section's text as Markdown with headings, lists, tables and bold, plus citations, the list of source URLs the research drew on, and createdAt, when the oldest section was written. citations merges the sources of every section into one list with duplicates removed, so a numbered marker such as [3] in a section's text counted that section's own sources and does not point at the third entry of citations. Present citations as the story's sources as a whole, and do not match markers to entries. Keep the Markdown as it is when you show a section to the user or save it, and render it wherever the user reads it, so the tables and emphasis survive.

Read a story once its status is complete. This endpoint is the one the app's reader calls, and it writes any missing sections before it answers, holding the request open while it does, so a read of a partial story can take many minutes and one of an absent story up to twenty. Use generate and poll the status instead, and read only when there is nothing left to write. A read that would start a story from nothing follows the same rule as generate: without the CONTENT_GENERATION privilege it is a 403.

Example, check a stock's story, fill in what is missing, and read it once complete:

    curl -H "Authorization: Bearer $TOKEN" \
      https://HOST/web/stock/1234/story/status

    curl -X POST -H "Authorization: Bearer $TOKEN" \
      https://HOST/web/stock/1234/story/generate

    curl -H "Authorization: Bearer $TOKEN" \
      https://HOST/web/stock/1234/story

## Screening verdicts

Users screen stocks by recording a verdict on each one, for example after reading its story. Verdicts are per stock; setting a new one replaces the old.

    GET    /web/screening-verdict                     the verdicts that exist, in display order
    POST   /web/quick-story-choice/stock/{stockId}   set a verdict, body: {"choice": "<code>"}
    DELETE /web/quick-story-choice/stock/{stockId}   clear the verdict on a stock
    GET    /web/quick-story-choice/stock/{stockId}   the verdict on one stock, {"choice": "<code>"} or {"choice": null}
    GET    /web/quick-story-choice/mine/list          list screened stocks and their verdicts
    GET    /web/quick-story-choice/deck               stocks not yet screened (no verdict)

GET /web/screening-verdict returns [{code, label}]. The codes are the only values POST accepts and the only values the list endpoint returns. They are currently study, track and reject, but read the endpoint rather than hard-coding them. Anything else is rejected with 400.

stockId is a Margin stock id; resolve symbols or names with GET /web/stock/find/{text} first, as for lists.

GET /web/quick-story-choice/mine/list accepts query params:

- verdict: filter to one code from GET /web/screening-verdict
- page: zero-based page number
- pageSize: rows per page, default 25, max 100

It returns {items: [{stockId, name, symbol, choice, updatedAt}], total}, where choice is a verdict code. Use it to answer "what have I marked for study" or to review past screening. GET /web/quick-story-choice/deck returns [{stockId, name}] of stocks the user has not screened yet, useful for "what should I look at next".

Example, mark a stock for study:

    curl -H "Authorization: Bearer $TOKEN" \
      -H "Content-Type: application/json" \
      -d '{"choice": "study"}' \
      https://HOST/web/quick-story-choice/stock/1234

## Screener criteria

The Screener ranks or narrows the stocks Margin has extracted signals for. A signal is a judgement read out of a stock's story, such as how much pricing power the company has, and each signal has a fixed set of options. A criterion picks one signal and one or more of its options, and a stock satisfies the criterion when its value for that signal is one of those options. The user can save a set of criteria under a name, called a screen, and open it later in the Screener in the Margin web app. Helping the user turn an investing idea into criteria, trying them out, and saving the result is the main thing you can do here.

    GET    /stock-signal/definitions          every signal, with the options a criterion may pick
    POST   /stock-signal/universe             the stocks that satisfy a set of criteria
    GET    /stock-signal/stocks/{stockId}     every signal value extracted for one stock
    GET    /web/saved-signal-filter/mine      the user's screens
    POST   /web/saved-signal-filter           create a screen, body: {"name": "...", "filters": [{"signalKey": "...", "values": ["..."]}]}
    PUT    /web/saved-signal-filter/{id}      update a screen, same body as POST
    DELETE /web/saved-signal-filter/{id}      delete a screen

GET /stock-signal/definitions returns [{key, label, category, type, instructions, criteria, options}]. key is what a criterion names as signalKey, and options lists the only values it may pick. instructions and criteria describe what each option means; read them to map the user's idea onto signals, and do not infer the meaning of a signal from its key alone. Signal keys and options change as signals are added, so read the definitions each time instead of reusing ones you remember.

POST /stock-signal/universe takes {"conditions": [{"signalKey": "...", "values": ["..."]}], "mode": "filter" | "rank", "search": "...", "page": n, "pageSize": n}. Every field is optional. In filter mode, the default, a stock must satisfy every criterion. In rank mode a stock needs to satisfy only one, and stocks are ordered by how many they satisfy, most first, so 5 of 10 ranks above 3 of 10. search narrows by symbol, name, sector or industry; page is zero-based and pageSize defaults to 25 with a maximum of 100. It returns {universeSize, total, items: [{stockId, symbol, name, sector, industry, extractedAt, filteredSignals, matchCount}]}, where universeSize is the number of stocks with signals, total is how many satisfy the request, filteredSignals holds each named signal's value for the stock, null when the story did not settle it, and matchCount is how many criteria the stock satisfies. A signal key or option the definitions do not offer is a 400 whose message names it and lists the valid options.

Before saving, run the criteria through POST /stock-signal/universe and show the user what they produce. When filter mode returns nothing, the criteria are too strict together; say which ones, or suggest rank mode, instead of saving a screen that matches no stock.

Signals and screens are identified by key and id: a criterion names its signal by key, never by label, and a screen is read, updated and deleted by its id. GET /web/saved-signal-filter/mine returns [{id, name, filters}]. The user knows screens by name, so match the name to the id yourself, then use that id.

POST /web/saved-signal-filter always creates a new screen and returns it with its id. A name the user already has is a 409 whose message gives that screen's id; to change that screen, PUT to its id instead, after confirming with the user that they want it changed. PUT /web/saved-signal-filter/{id} replaces the screen's name and criteria with what you send, so send the current name to keep it, and read the screen first when you mean to add a criterion to it. An id that is not one of the user's screens is a 404, and renaming a screen to the name of another is a 409.

On both POST and PUT, criteria with no values are dropped and a screen needs at least one value. Every signal key and option is checked against the definitions, and one they do not offer is a 400 naming it, the same check POST /stock-signal/universe makes. A 503 means Margin could not read the definitions to check against and saved nothing; retry shortly. A screen does not store the mode; the user picks match all or rank in the Screener after opening it.

Example, rank stocks by two criteria:

    curl -H "Authorization: Bearer $TOKEN" \
      -H "Content-Type: application/json" \
      -d '{"mode": "rank", "conditions": [{"signalKey": "pricing_power", "values": ["Strong"]}, {"signalKey": "moat_strength", "values": ["Strong", "Very strong"]}]}' \
      https://HOST/stock-signal/universe

## Kanban boards

A board is how a user works a set of stocks through a process: named columns holding cards, each card moved along as the work on it gets done. Cards are free text. A card carries a name, a description and two urls, so nothing links one to a stock in Margin, and a card that names a stock does so only by the wording it was given.

    GET    /web/kanban/boards                              every board, with its column and card counts
    GET    /web/kanban/boards/{id}                         one board, with its columns and their cards
    POST   /web/kanban/boards                              create a board, body: {"name": "...", "description": "..."}
    PUT    /web/kanban/boards/{id}                         rename or re-describe a board
    POST   /web/kanban/boards/from-stocks-list             build a board from a stocks list, body: {"stocksListId": n}
    GET    /web/kanban/boards/{boardId}/columns            the board's columns with their cards
    POST   /web/kanban/columns                             create a column, body: {"name": "...", "kanbanBoardId": n, "position": n}
    PUT    /web/kanban/columns/{id}                        rename or reposition a column
    DELETE /web/kanban/columns/{id}                        delete a column and every card on it
    PATCH  /web/kanban/boards/{boardId}/columns/reorder    body: {"columnIds": [...]} in the order wanted
    GET    /web/kanban/columns/{columnId}/cards            the cards in one column
    POST   /web/kanban/cards                               create a card, body: {"name": "...", "description": "...", "url1": "...", "url2": "...", "kanbanColumnId": n, "position": n}
    POST   /web/kanban/columns/{columnId}/cards/bulk       add several cards at once, body: {"cards": [{"name": "...", "description": "...", "url1": "...", "url2": "..."}]}
    PUT    /web/kanban/cards/{id}                          rename, re-describe, re-link or reposition a card
    DELETE /web/kanban/cards/{id}                          delete a card
    PATCH  /web/kanban/cards/move                          body: {"cardId": n, "targetColumnId": n, "targetPosition": n}
    PATCH  /web/kanban/columns/{columnId}/cards/reorder    body: {"cardIds": [...]} in the order wanted

GET /web/kanban/boards returns a summary per board, id, name, description, columnCount and cardCount, without the columns themselves. Read a board by its id to get the columns, each carrying its cards, both already in position order. The user knows a board by name, so match the name to an id from the board list yourself rather than asking them for a number.

### The two urls on a card

A card carries url1 and url2, both optional, for the pages the user wants to reach from that card: a screener page, an annual report, a concall transcript, a filing. In the Margin web app they render as links on the card and open in a new browser tab. Send them on a create, a bulk create, or a PUT, and read them back as url1 and url2 on every card in a board, a column listing or a card response. A card with no url carries url1 and url2 as null rather than leaving them out, so read a null as no link rather than as a card you failed to read properly.

Each must be an absolute http or https url; a bare host such as screener.in/company/TCS, a relative path, or any other scheme is a 400 naming url1 or url2 in invalidFields, and cards[2].url1 in the bulk form. There is no fetching or checking of what a url points at, so a well-formed url to a page that does not exist is stored as sent.

The two are unordered. url1 is not a title or a primary link; it is simply the first of two slots, so when the user names one page, fill url1 and leave url2 absent. On a PUT, a url you omit is left as it was and a url you send replaces what was there. Ask the user before overwriting a url a card already carries.

### Positions

Columns on a board and cards in a column are ordered by a position counted from zero and read ascending. Omit position on a create and the new column or card goes to the end, which is what adding to a board usually means; the bulk card endpoint always appends. A position you send is stored as sent, so two records can end up sharing one, and their order between them is then undefined.

The reorder endpoints are what set an exact order: send the ids in the order you want and they are renumbered from zero. An id that belongs to another board or column, or one sent twice, is a 400 naming columnIds or cardIds, and nothing moves. A subset is accepted and renumbers only the ids you sent, which can leave the ones you left out sharing positions with them, so send every id of the board or the column.

Moving a card is PATCH /web/kanban/cards/move. Omit targetPosition and the card lands at the end of the target column, which is the form to prefer. The source and target columns must be on the same board; a target elsewhere is a 400 with invalidFields ["targetColumnId"]. Nothing renumbers the cards a move landed among, so after moving to an explicit position, reorder that column to settle the order.

### A board from a stocks list

POST /web/kanban/boards/from-stocks-list takes {"stocksListId": n} and builds the whole board in one call. Take the id from GET /web/stocksList, where the four derived entries, All, Holdings, AllEverHeld and NotCurrentlyHeld, carry no id and cannot be used here. The board is named after the list and gets six columns in this order: to do, results announced, results available, concall listened, valuation done, done. Every stock on the list becomes a card in "to do" with the symbol as the card name and the company name as its description, and no urls. The response is the board as built, so read the column and card ids from it rather than listing again.

The cards are a copy of the list taken at that moment. Stocks added to the list afterwards do not appear on the board, and renaming or deleting a card does not touch the list.

### Deleting

Deleting a board is one of the operations API tokens cannot perform and answers 403; the user deletes a board in the Margin web app. Columns and cards can be deleted, and deleting a column deletes every card on it, which nothing undoes. Tell the user what goes with a column before you delete it.

### Errors

A board, column or card that is not in the user's account is a 404, whether it belongs to another account or does not exist at all, and it carries the id you sent. A malformed body is a 400 carrying invalidFields, the fields at fault in one list, for example ["name", "kanbanColumnId"], ["cards[1].name"] or ["url2"]. Ids and positions must be sent as JSON numbers; a numeric string is rejected rather than coerced.

## Valuing a stock (DCF)

### Computing a DCF from assumptions

    POST /web/projection/dcf

Turns the assumptions a user states into a fully computed valuation, priced against the stock's latest quote. It saves nothing, needs no stockOfInterestId, and touches no account data, so use it freely to answer "what is this worth if it grows at 15 percent", to compare scenarios, and to build the rows the forward projection save expects.

Body:

- baseYear: the trailing twelve month year 0 everything grows from and discounts back to: fyNumber, financialYear, revenue, operatingProfit, otherIncome, depreciation, interest, tax, eps. Absolute figures in one consistent unit, typically crores. The share count is derived as PAT divided by eps, so eps must be the same unit basis as the profit figures.
- years: one row per projected year, in order, each with revenueGrowthRate, operatingMargin, otherIncomeRatio, depreciationRatio, interestRatio, taxRate, cashFlowMultiplier, numberOfSharesGrowth. The ratios and rates are whole percents, so 15 means 15 percent, and the ratios are percentages of that year's revenue. cashFlowMultiplier is a plain multiplier on PAT, so 0.8 means 80 percent of profit converts to cash. numberOfSharesGrowth is the percentage the share count grows that year, 0 for none.
- discountRates: one rate per five year band, as decimals, so 0.12 for 12 percent. Ten projected years need two entries. If you send fewer bands than the projection spans, the last one applies to the remaining years.
- terminalGrowthRate, terminalDiscountRate: decimals, optional, but the two go together. Omit both to get a valuation with no terminal value; sending one without the other is a 400 naming the missing one. terminalGrowthRate must be below terminalDiscountRate, because a perpetuity growing at or above its discount rate has no finite value. A pair that breaks that rule is rejected with 400 and invalidFields ["terminalGrowthRate", "terminalDiscountRate"], and the message says so; it is not valued as though the terminal were worth nothing.
- cash, debt: absolute, in the same unit as baseYear.
- symbol: required, the exchange symbol, for example ITC. The server prices the valuation from the last synced quote for that stock, so do not look the price up first and do not send a price, there is no price field. A symbol that matches no stock returns 404 with the message "No stock found for symbol X", and a stock with no synced quote returns 404 with "No quote found for symbol X". Both mean the valuation was not computed.

The response contains:

- baseYear and years[]: every year with its computed revenue, margins, tax, pat, cashFlow, numberOfShares, eps, and per-share present values. Each projected year also carries the cumulative NPVs, the intrinsic values with and without terminal value, and expectedReturnFromProfit and expectedReturnFromCashFlow, the annualised return implied if the stock is held to that year.
- intrinsicValueByProfit and intrinsicValueByCashFlow: each with forecastedValue, terminalValue, totalValue and terminalValuePercentage. Report terminalValuePercentage to the user, a valuation that is mostly terminal value rests on the growth assumption rather than the forecast.
- evAdjustedPrice: the price adjusted for net debt per share, the figure to compare intrinsic value against.
- price and priceAsOf: the quoted price the valuation was compared against and the date of that quote. Check priceAsOf before quoting a return to the user, a quote can be days old for a thinly traded stock.
- summary: growthBands and operatingMarginBands, each {fromYear, toYear, fromValue, toValue} over five year ranges, and priceToSalesTrend and priceToEarningsTrend as {year, value} for years 0 to 5. Use these to describe a projection to the user in a sentence rather than reading out every year.

Example:

    curl -H "Authorization: Bearer $TOKEN" \
      -H "Content-Type: application/json" \
      -d '{"baseYear": {"fyNumber": 2025, "financialYear": "2025", "revenue": 1000, "operatingProfit": 200, "otherIncome": 20, "depreciation": 50, "interest": 10, "tax": 40, "eps": 12},
           "years": [{"revenueGrowthRate": 15, "operatingMargin": 20, "otherIncomeRatio": 2, "depreciationRatio": 5, "interestRatio": 1, "taxRate": 25, "cashFlowMultiplier": 0.8, "numberOfSharesGrowth": 0}],
           "discountRates": [0.12], "terminalGrowthRate": 0.05, "terminalDiscountRate": 0.12,
           "cash": 100, "debt": 300, "symbol": "ITC"}' \
      https://HOST/web/projection/dcf

A 400 response carries invalidFields, the paths of everything wrong in one list, for example ["baseYear.eps", "years[1].taxRate", "discountRates[0]"]. Numbers must be sent as JSON numbers; a numeric string is rejected rather than coerced.

One vocabulary runs through the whole of this API: what you send here, what comes back, and what the save stores all call a quantity by the same name. Two of these fields were once spelled differently on the way in, taxAmount for tax and shareDilution for numberOfSharesGrowth. Both older spellings are still accepted so nothing already written stops working, but they are the only names that differ anywhere, they are not returned in any response, and invalidFields reports the current name. Write tax and numberOfSharesGrowth.

### Saving a valuation against a stock

Saved valuations are keyed by stockOfInterestId, which is NOT the same as the stockId returned by /web/stock/find. A stockOfInterestId exists only once the stock is tracked in the user's account (held, traded, or added to a list).

Step 1, resolve the stockOfInterestId. Turn a symbol or name into a stockId with /web/stock/find, then track it:

    POST /web/stockOfInterest/track/{stockId}

Returns {stockOfInterestId, stockId, alreadyTracked}. alreadyTracked is false when the stock was not in any list, holding, or trade and has just been added to the account for the first time; tell the user you added it. Use the returned stockOfInterestId for every valuation call below. If you call a valuation endpoint with a stockOfInterestId that is not in the account you get 404; resolve it with track first.

Step 2, save a valuation. Two kinds are supported.

### Reverse DCF

Given a holding period and starting and exit PE, infer the earnings growth the current price implies, or the return a growth assumption implies. This is the simple, recommended form.

    POST /web/valuation/reverseDCF/impliedGrowthRate   compute only, no save
    POST /web/valuation/reverseDCF/{stockOfInterestId}  save a reverse DCF valuation
    GET  /web/valuation/reverseDCF/{valuationId}        read one saved reverse DCF

Body fields:

- name: a label for the valuation
- numberOfYears: holding period in years
- startingPERatio: PE now
- exitPERatio: assumed PE at the end of the period
- annualisedReturn: target annual return as a decimal, for example 0.15 for 15 percent
- growthRate: expected annual earnings growth as a decimal
- valuationFyNumber: optional, the quarter of results the valuation speaks for, as a year with a quarter fraction
- eps: optional, the earnings per share the starting PE was worked out from; it is stored and read back so the valuation shows what it was based on, and it must be above zero when sent

Provide either annualisedReturn or growthRate; the server computes the other and stores both. impliedGrowthRate returns the computed growth rate as a number without saving. Omit id to create a new valuation; send an existing valuation's id to overwrite it (see Reading valuations for how to get that id).

valuationFyNumber records which quarter's results the valuation was formed on, so a valuation edited a year later still reads as the quarter it was made for rather than the day it was last touched. Write the year with a quarter fraction: 2026 for the March quarter, then 2026.25, 2026.5 and 2026.75 for the June, September and December ones. A number off a quarter boundary, 2026.4 for instance, is a 400 with invalidFields ["valuationFyNumber"], not a value rounded to the nearest quarter. Omit it on a new valuation and the server anchors it to the last quarter completed before now. Omit it when overwriting and the stored quarter is kept, so editing a valuation never re-dates it. Send it only when you mean to state a different quarter. The save responds with the stored valuation, so read the quarter back from there rather than assuming the one you sent or the one you expected the default to pick.

The save is checked before anything is written, so a rejected save leaves the account untouched. A 400 carries invalidFields naming every field at fault, for example ["numberOfYears", "exitPERatio"]. numberOfYears must be a whole number above zero, the PE ratios must be above zero, and sending neither annualisedReturn nor growthRate reports both. A 409 means a valuation of that name already exists for the stock. It carries conflictingField, name, and existingValuationId, the id of the valuation already holding the name, so send that id to overwrite it or choose another name without looking anything up.

The stored rates keep two decimals, so a growth rate of 0.128 reads back as 0.13 and the rate the server derives for you is rounded the same way. Quote the read-back to the user, not the number you sent.

The read takes a valuationId, not a stockOfInterestId, and returns one valuation: id, name, stockOfInterestId, numberOfYears, startingPERatio, exitPERatio, annualisedReturn, growthRate, valuationFyNumber, eps (null when it was never sent), createdDate and lastUpdatedDate. Get the id from the valuation list below. A valuationId that is not in the user's account, whether it exists on someone else's stock or not at all, is a 404 carrying the valuationId, never another account's valuation.

Example:

    curl -H "Authorization: Bearer $TOKEN" \
      -H "Content-Type: application/json" \
      -d '{"name": "base case", "numberOfYears": 5, "startingPERatio": 30, "exitPERatio": 25, "annualisedReturn": 0.15}' \
      https://HOST/web/valuation/reverseDCF/4567

### Forward DCF projection

A full multi-year projection saved against a stock. The server stores the projection exactly as sent and does NOT recompute derived figures, so you must send complete, internally consistent yearly rows (revenue, margins, PAT, cash flow, per-share values). Never hand-compute those rows. Either get them from POST /web/projection/dcf, whose years[] carry the same field names the save expects, or read the current projection, change it, and post it back. Compute rows go in as they are: it returns numberOfShares as a fraction, because the share count is derived from PAT and eps, and the save stores it to two decimals like the other figures, so send it unchanged.

    GET  /web/valuation/{stockOfInterestId}                                              the stock's valuations, with their ids and names
    GET  /web/projection/stockOfInterest?stockOfInterestId={id}&valuationId={id}          one saved projection, use as a template
    GET  /web/projection/stockOfInterest/summary?stockOfInterestId={id}&valuationId={id}  that projection's valuation summary
    POST /web/projection/stockOfInterest                                                  save, body is the full projection
    POST /web/projection/stockOfInterest/valuationQuarter                                 move an existing projection to another quarter
    POST /web/projection/stockOfInterest/note                                             write the note on an existing projection

valuationId is required on both reads. A stock can hold several saved projections and the server will not pick one for you, so start from the valuation list, match the user's name to an id, and read that id. Omitting it is a 400 with invalidFields ["valuationId"]. A valuationId the stock does not have, including one that belongs to another stock or another account, is a 404 carrying stockOfInterestId and valuationId, never someone else's projection. Every response from these endpoints has a JSON body; there is nothing to special case before parsing.

The POST body carries stockOfInterestId, name, terminalGrowthRate, terminalDiscountRate, discountRates (an array), cash, debt, dashboardNumberOfYears, dashboardReturnBasis, baseYear, valuationFyNumber, note, and yearlyStockProjections (one fully computed row per year). Send an id to update an existing projection, omit it to create one. Market-cap banded discount rates live at GET and POST /web/projection/discountRate.

Unlike on compute, terminalGrowthRate and terminalDiscountRate are required here. A saved projection is a stored valuation of a company, not a scenario, so it carries a terminal value; there is no stored equivalent of the no-terminal valuation compute will hand you. Omitting them is a 400 naming both.

baseYear is the trailing twelve month year 0 the first projected year grows from: fyNumber, revenue, operatingProfit, otherIncome, depreciation, interest, tax and eps. The figures are absolute, in the same units as the yearly rows, and the share count is eps derived rather than sent. Year 1 revenue is baseYear.revenue grown by its revenueGrowthRate, so baseYear sets the level of the whole projection, and baseYear.fyNumber is the year everything discounts back to, so a saved valuation keeps showing the values it was created with. Read baseYear from the saved projection and send it back unchanged unless you mean to restate year 0. Projections saved before this field existed return baseYear as null, and a POST that omits baseYear leaves the stored one untouched.

valuationFyNumber is the quarter the projection speaks for, written and validated exactly as on reverse DCF above, and returned on every read of a saved projection. The default differs in one way: a new projection that omits it is anchored to baseYear.fyNumber, and only falls back to the last completed quarter when there is no baseYear, so a projection carries the quarter of the results it was built from. Omitting it on an update keeps the stored quarter.

To re-date a projection that is already saved, post to /web/projection/stockOfInterest/valuationQuarter rather than sending the whole projection back. The body is stockOfInterestId, valuationId and valuationFyNumber. The quarter moves as one piece: valuationFyNumber and baseYear.fyNumber both become the quarter you send, and every yearly row keeps its distance from the anchor, so year 1 becomes the anchor plus one and year 10 the anchor plus ten. Nothing else is touched. Revenue, margins, per-share figures and the discount rates stay as they were, and because each row keeps the same number of whole years between itself and the base year, the discounting and the intrinsic values do not move either. This is the endpoint to use when a valuation was built on one quarter's results and the user wants it to read as another, such as after the next quarter's numbers land. valuationFyNumber must be on a quarter boundary here too; an off-boundary number is a 400 with invalidFields ["valuationFyNumber"] and nothing is written. A valuationId the stock does not have is the same 404 the reads give. The response is the projection as stored, so read the moved years back from there.

note is the reasoning the user keeps beside the valuation, the case for the growth and margin assumptions rather than a restatement of the numbers, and it is shown next to the intrinsic value on the valuation screen. It is a string of HTML, the format the screen's editor reads and writes: paragraphs and the bold, italic, underline and strikethrough marks survive the round trip, anything else, headings and lists included, is flattened to plain paragraph text when the note is opened, and the string is stored as sent apart from surrounding whitespace. Send it in the full projection body, or write it on its own with POST /web/projection/stockOfInterest/note, whose body is stockOfInterestId, valuationId and note. The note endpoint touches nothing but the note and the last updated date, so prefer it over posting a whole projection back to record a comment. On the full save, omitting note leaves the stored one untouched and an empty string clears it; on the note endpoint note is required, and omitting it is a 400 with invalidFields ["note"] rather than a silent clear. Read the note back from either endpoint's response or from the projection read, and quote what is already there before replacing it, since a write overwrites the whole note rather than appending to it.

The body is checked before anything is written, so a rejected save leaves the account untouched. A 400 carries invalidFields, the paths of everything wrong in one list, for example ["cash", "yearlyStockProjections[0].pat", "baseYear.eps"]; read the whole list and fix it in one go rather than resending to find the next complaint. Numbers must be sent as JSON numbers, a numeric string is rejected rather than coerced, and year must be a whole number. Every field of a yearly row is required; there are no derived figures the server fills in for you. Omitting id creates a new projection, and a new projection cannot own rows that already belong to another one, so when you copy a saved projection to save it under a new name, drop the id on every yearly row as well as the one on the projection. Keeping them is reported as yearlyStockProjections[n].id. A 409 means a valuation of that name already exists for the stock. It carries conflictingField, name, and existingValuationId, the id of the valuation already holding the name, so send that id to overwrite it or choose another name without looking anything up.

A read-back does not return the figures you sent digit for digit, and that is not a failed save. The yearly rows, cash and debt are held to two decimal places and come back rounded to the nearest, so 1150.555 reads back as 1150.56 and an operating margin of 20.129 as 20.13. baseYear.eps is the exception and keeps six decimals. discountRates keep the full precision of every rate they carry. So compare a read-back against what you sent at two decimals, and do not resend a projection because the third decimal moved.

discountRates is the one field that can come back longer than you sent it. Saving applies the same last-band rule compute does, and writes the result out: send [0.16] for a fifteen year projection and the stored array is [0.16, 0.16, 0.16], one rate per five year band, because that is the rate every one of those years was discounted at. Send [0.16, 0.14] for the same projection and it stores [0.16, 0.14, 0.14]. Rates past the last band the projection spans are kept rather than trimmed. This is why a short array is not a shortcut that leaves anything out: the valuation screen reads one rate per band, so a stored array that stops early would show the later bands with no rate beside years that were discounted at the last one. Read the array back rather than assuming it matches your request, and quote the read-back to the user when they ask what a saved valuation was discounted at.

terminalGrowthRate and terminalDiscountRate are the exception to all of that: they are held to three decimals and are never rounded silently. 0.145 stores and reads back as 0.145. A rate that needs a fourth decimal, such as 0.1425, is rejected with 400 and invalidFields ["terminalDiscountRate"] rather than accepted and rounded, because the yearly rows you send were computed at the rate you asked for and a stored rate that differs from them makes the projection inconsistent with itself. Round the rate to three decimals yourself, recompute the rows at that rate with POST /web/projection/dcf, and tell the user which rate you used. Both rates are required here, and as on the compute endpoint terminalGrowthRate must be below terminalDiscountRate; a pair that breaks that rule is rejected rather than stored as a projection whose terminal value reads back as zero.

Because the forward projection is stored verbatim, prefer reverse DCF unless the user specifically wants a full year-by-year model.

### Reading and updating valuations

    GET /web/valuation/{stockOfInterestId}   all saved valuations for a stock, DCF, reverse DCF and segmental

Each entry has an id, name, type, createdDate, lastUpdatedDate and valuationFyNumber. The user refers to a valuation by name, not id; match the name to its id here. Read the quarter a valuation speaks for from valuationFyNumber, not from lastUpdatedDate, which moves whenever a valuation is edited while the quarter it was made for does not. A segmental analysis carries no quarter, so its valuationFyNumber is absent. To update rather than duplicate a valuation, read it first, then send that id back: reverse DCF in the id field of POST /web/valuation/reverseDCF/{stockOfInterestId}, forward DCF in the id field of POST /web/projection/stockOfInterest. Omitting the id always creates a new valuation, and the save responds with the projection as stored, carrying the id it was given, so after creating one you can keep working with it without listing again.

## Recording a stock story video's YouTube link

Stock story videos are published to the Margin YouTube channel outside this API. Margin keeps a link from each stock to the videos that tell its story, and that link is what shows a video on the stock's page. This API records and corrects that link and does nothing on YouTube itself.

Recording a link needs the CONTENT_GENERATION privilege, and an account without it gets a 403 saying which privilege is missing. A missing privilege is not something a token can grant itself, so report the 403 to the user and stop rather than looking for another route.

A video is identified by the stock it is about, with no id for you to create:

- stockId: the stock the video tells the story of, a Margin stock id; resolve symbols or names with GET /web/stock/find/{text} first, as for lists. It is the stock id, not a stockOfInterestId, and the stock does not have to be tracked in the account
- storyType: "stockStory" for the story of the whole company, "productSegmentStory" for the story of one of its business segments. Omit it and the video is a stockStory
- segmentName: the segment the video covers, required for productSegmentStory and ignored otherwise

Margin names the subject from those: Infosys (INFY) for a stock story, Infosys (INFY) - Digital for a segment one. The response carries the subjectName that was used; quote that name back to the user and not one you composed.

    POST /web/video/youtubeUrl

Body fields:

- stockId, storyType, segmentName: the story, as above
- youtubeUrl: the video the story should point at, required
- language: the language of the narration as a code such as "hi", optional and defaulting to "en"
- mediaFileId: which of the story's videos to repoint, needed only when the story has more than one on YouTube in that language

A story keeps one video per language. When the story has no video in the language you send, Margin records the link as a new video, so an English and a Hindi telling of the same story sit side by side. When it already has one in that language, the call repoints that video to the link you sent, which is how a video that was taken down and uploaded again under a new id gets corrected. Nothing is uploaded, renamed or deleted on YouTube, so a wrong link here shows the user the wrong video while the video itself stays where it was.

youtubeUrl takes any form YouTube hands out: a watch link, a youtu.be share link, a shorts, live or embed link, or the bare 11 character video id. Extra query parameters such as a playlist or a start time are dropped, and Margin stores the canonical https://www.youtube.com/watch?v=ID form. Anything that is not one video, a playlist or a channel page among them, is a 400 with invalidFields ["youtubeUrl"]. A 400 names every field at fault in one list.

The response is {contentSubjectId, mediaFileId, subjectName, storyType, youtubeUrl, youtubeVideoId, previousYoutubeUrl, created}. youtubeUrl is what was stored, which is the canonical form of what you sent rather than the string itself. created is true when Margin recorded a new video and false when it repointed one, and previousYoutubeUrl then carries the link the story pointed at before. Nothing in Margin keeps that old link once the call returns, so quote it back to the user in the same breath as the new one. When you meant to add a new video and created comes back false, tell the user which link was replaced.

A story with more than one video on YouTube in the same language is a 409 instead of a guess. It carries contentSubjectId and videos, each entry a mediaFileId and the youtubeUrl it currently points at. Show those to the user, and send the call again with the mediaFileId of the one they mean. A mediaFileId that is not a media file of that story is a 404 carrying mediaFileId and contentSubjectId, which usually means it came from a different stock or story type. A 404 without a mediaFileId means the stockId is not a stock Margin knows; it carries the stockId, and the fix is to search for the company again, not to track it.

Example:

    curl -H "Authorization: Bearer $TOKEN" \
      -H "Content-Type: application/json" \
      -d '{"stockId": 4567, "youtubeUrl": "https://youtu.be/dQw4w9WgXcQ", "language": "en"}' \
      https://HOST/web/video/youtubeUrl

To read what is already linked for a stock:

    GET /web/stock/youtube-videos?externalSubjectId={stockId}

externalSubjectId takes the Margin stock id. Each item carries youtubeUrl, subjectType, subjectName, createdAt, and the stockId, stockSymbol and stockName of the company. It is open to anyone. When the service behind it cannot answer, the endpoint returns an empty items array instead of an error, so an empty result means either that nothing is linked for the stock or that the lookup failed, and the two cannot be told apart from the response.

## Checking state and verifying uploads

    GET /web/tradingAccount      [{id, brokerageId, brokerageName, name, holdingCount, tradeCount}], every trading account the user has, and there may be more than one at the same brokerage; the id is the tradingAccountId the endpoints below accept, and name is what the user calls the account in the web app, null until they name it, so refer to an account by its name when it has one; holdingCount and tradeCount are how many holdings and trades are filed under it, which tells two unnamed accounts at one brokerage apart
    GET /web/tradeBook/counts    trade counts per financial year, plus totalTrades
    GET /web/trades/summary      totalTrades, corporateActionTrades, distinctStocks, totalBuyValue, totalSellValue, earliestTradeDate, latestTradeDate, where corporateActionTrades is the part of totalTrades that a recorded split, bonus or other corporate action created rather than a tradebook upload
    GET /web/dividends/counts    {counts: [{financialYear, stockCount, netAmount}], totalRecords}, where both counts are of stocks, not payouts: stockCount is how many stocks the year paid and totalRecords adds those up across years
    GET /web/dividends/summary   totalRecords, distinctStocks, totalGrossAmount, totalTaxAmount, totalNetAmount, earliestFinancialYear, latestFinancialYear, where totalRecords is how many payouts are on record, so it runs ahead of the counts endpoint's totalRecords whenever a stock paid more than once in a year
    GET /web/holdings            what the user holds and what it is worth, includes lastHoldingUploadDate and latestTradeDate
    GET /web/fundsLedger/coverage which financial years of the funds statement each trading account has on record, and which years are missing between the earliest and the latest
    GET /web/uploads/last        when each kind of data was last uploaded, per trading account

Every count, summary and holdings read above takes an optional tradingAccountId, an id from GET /web/tradingAccount, and then answers for that trading account alone; without it they cover every trading account of the account. A tradingAccountId that is not the user's is a 400. Use it when the user asks about one broker, or to tell which brokerage a total came from.

### How current the user's data is

GET /web/uploads/last answers when the user last uploaded each kind of data. Read it before any answer that depends on the data being current: a portfolio value, a return, an allocation, a tax figure. It returns {uploads: [{uploadType, lastUploadedAt, accounts: [{tradingAccountId, brokerageName, financialYear, recordCount, lastUploadedAt}]}]}, with one entry for each of holding, trade, dividend and ledger, in that order, whether or not that kind was ever uploaded.

The type's lastUploadedAt is the most recent upload of that kind across every trading account, and is null when the user has never uploaded it. Each entry in accounts is the last upload of that kind for one trading account, so a user who uploads Zerodha every month and Groww once a year shows one recent and one stale entry, and the type level date only tells you about the more recent of the two. Report per trading account whenever the accounts disagree. financialYear is the year the upload was for, and is set for dividend and ledger uploads and null for holding and trade uploads, which are not sent a year at a time. recordCount is how many rows that upload wrote, which is zero for a ledger upload whose rows were all already on record.

These are upload dates, not the dates of the data. A holdings upload dated last week says the position was current last week; it says nothing about whether the user has traded since. The last trade upload date is not the date of the last trade, which is latestTradeDate on GET /web/holdings, and a ledger upload date is not the coverage of the ledger, which is GET /web/fundsLedger/coverage. Use the upload date to say how stale the record is, and the coverage and count endpoints to say what the record contains.

Uploads before this endpoint existed were reconstructed from the rows already on record, so a date from before September 2026 is when the rows were written rather than a recorded upload, and a kind uploaded then wholly deleted since shows nothing.

### What the user holds and what it is worth

    GET /web/holdings

Returns {lastHoldingUploadDate, latestTradeDate, total, holdings}, with one entry in holdings[] for each stock the user holds, ordered by symbol. This endpoint is kept for API tokens and the Margin web app's session cannot read it. GET /web/dashboard serves the web app and answers 403 to an API token, so read holdings here.

Each entry identifies its stock by isin, stockSymbol, stockId and stockOfInterestId, and carries:

- units, the quantity held, and averageCost, the average cost per unit, both covering the user's whole position in that stock across every brokerage
- investedValue, units times averageCost
- currentPrice, the stock's last traded price in the exchange's most recent end of day file, and priceAsOf, the trading day that price is from
- currentValue, units times currentPrice, and pnl, currentValue less investedValue, with pnlPercent as pnl over investedValue in percent
- dayChange, the whole position's move on priceAsOf against the previous close, and dayChangePercent, the stock's own move that day in percent
- weight, the position's share of total.currentValue as a decimal fraction of 1, on the same scale as targetAllocation
- targetAllocation, or null when none is set
- lastValuationFyNumber and lastValuationType, described below
- asOfDate, when the holding was last written
- brokerages[], one {tradingAccountId, brokerageName, units, averageCost, asOfDate} for each brokerage the stock is recorded under
- userPrices[], the prices the user set on the stock themselves, one {id, priceTypeId, priceType, price} for each of their price types that carries a price on it

A stock with no price on record has currentPrice, priceAsOf, currentValue, pnl, pnlPercent, dayChange, dayChangePercent and weight all null. There are no quantity or averagePrice fields.

priceType on a userPrices entry is the name the user gave that type, typically BUY IF ABOVE or BUY IF BELOW, and price is the level they set in rupees. These are the user's own thresholds, not market data, so never present one as a quote or compare it against currentPrice as though both came from the exchange. The array is empty when the user set no price on the stock, and a price type they defined but left blank on this stock has no entry rather than an entry with a null price. The same prices appear on the stocksOfInterest entries of GET /web/stocksList/{id}, in the same shape.

total adds the positions up as {investedValue, currentValue, pnl, pnlPercent, dayChange, priceAsOf, holdings, unpricedHoldings}, in rupees except pnlPercent and the two counts. investedValue, currentValue, pnl and dayChange cover only the positions that have a price, and unpricedHoldings says how many were left out; when it is above zero, tell the user the total is missing those stocks. holdings counts every position, priced or not. priceAsOf is the date of the oldest price in the total, and every other price in it is from that day or later. Prices come from the exchanges' end of day files and do not move during the trading session, so give priceAsOf alongside any value you report. GET /web/fundsLedger/return also carries a holdingsValue, which is the terminal inflow of its return calculation; read the value of the holdings from total here.

A stock recorded under two brokerages appears once, with units adding both and two entries in brokerages[]. A brokerage the user does not use showing up there usually means the portfolio was imported twice under different brokerageNames, as described under What an import replaces and what it leaves behind, and every figure on this endpoint then counts those stocks twice. Tell the user which stocks are affected. GET /web/holdings?tradingAccountId={id} reads one brokerage's positions on their own, valued and weighted within that brokerage, and GET /web/stockOfInterest/{stockOfInterestId}/brokerageHoldings lists one stock's rows as [{tradingAccountId, brokerageId, brokerageName, units, cost, asOfDate}].

Each entry of holdings[] also carries lastValuationFyNumber, the latest quarter any of its valuations was made for, and lastValuationType, DCF or REVERSE_DCF for whichever valuation that was. Both are null when the stock has never been valued. Use them to tell the user which holdings are valued on stale results without reading every stock's valuations, and prefer them over any date for that question.

To verify an upload landed, read one of these before and after and report the difference to the user. For holdings, follow the comparison described under Verifying a holdings import; the after read alone will not tell you whether what you sent is what landed.

## Limits and errors

- At most 10 files per upload request, 10 MB per file
- The JSON imports, POST /web/stockOfInterest/upload/json and POST /web/fundsLedger/upload/json, take a request body of up to 10 MB. A year of a busy funds statement sent with particulars is a few hundred KB, so send each year whole in one request
- Every other JSON request body is capped at 100 KB
- 400: bad input, the message in the body explains what was wrong
- 401: token invalid or revoked
- 403: operation not permitted with an API token, or the account is missing a privilege the operation needs, such as CONTENT_GENERATION for recording a video link or starting a stock story, or CONSOLE for quick story generation
- 404: the referenced stock is not tracked in the account; track it first with POST /web/stockOfInterest/track/{stockId}. On a video link and on the stock story endpoints it means something else: the stockId is not a stock Margin knows at all
- 409: the request left something ambiguous that Margin will not guess at, such as which of a story's videos a link correction meant, or a valuation name already in use. The body names the candidates or the conflicting record
- 413: the request body is larger than the endpoint accepts, such as a JSON import above 10 MB
