REST API reference
The /api/v1 surface: the 13 routes an API key reaches, and nothing else. The
application’s own routes are not here, because a key does not open them. Generated from
apps/api/src/api-keys/public-api.controller.ts: its decorators for the verb, the path and
the scope, its DTO classes for the body, the compiler’s own return type for the answer.
API keys explains how a key is issued.
A scope opens a route, and it also shapes the answer. A field reserved to another scope is
served only to a key carrying that scope, which is why the shapes below mark such fields
optional: the account an operation or a held line sits in is named by accounts:read, and
an insights card is served only to a key carrying the scope of the figure it rests on
(dashboard:read for the net worth, its change over the year and the allocation,
accounts:read for the unrealised gain, transactions:read for the biggest expenses and
the main categories). Two things are deliberate: analytics:read
answers the value series and the drift the settings screen names under it, which is the net
worth over time, and GET /api/v1/accounts answers what each account is worth without its
iban, its bic or a property’s address, which that scope names nowhere.
Every route is authenticated by a bearer key and by nothing else. No cookie is consulted, so a signed-in browser session reaches none of this without one:
Authorization: Bearer sk_...A missing, unknown, revoked or expired key is answered 401. A key that is valid but
does not carry the scope the route declares is answered 403. That only happens
when the key is called, never when it is created, so a
key issued one scope short looks healthy until the route it cannot reach is the one asked
for. The table below is what a key has to be issued against.
Money and quantities are sent as decimal strings, never as JSON numbers: a number is a
double by the time it is read, and a quantity of coins loses its last digits on the way
in, so a JSON number is refused with 400 instead of being rounded. What comes back
says which it is shape by shape below: string (decimal) where the figure is a stored
column handed over untouched, a plain number where this API computed it.
A day is sent as the ten characters YYYY-MM-DD, which is what Matches(ISO_DAY) means
in the tables below. An instant is refused with 400 rather than truncated, and that is
deliberate: the body carries no offset, so a server truncating one would be guessing, and
a wrong guess would shift the day while the request still succeeded.
Bodies go through a global validating pipe with whitelist and forbidNonWhitelisted,
so a key the route does not declare is refused by name with a 400 rather than ignored.
Scopes
Section titled “Scopes”| Scope | What it opens |
|---|---|
accounts:read |
GET /api/v1/accounts |
accounts:write |
POST /api/v1/accountsPOST /api/v1/accounts/:id/balancePOST /api/v1/accounts/:id/holdings |
analytics:read |
GET /api/v1/performanceGET /api/v1/evolution |
dashboard:read |
GET /api/v1/net-worth |
insights:read |
GET /api/v1/insights |
positions:read |
GET /api/v1/positions |
prices:read |
GET /api/v1/prices |
transactions:read |
GET /api/v1/transactions |
transactions:write |
POST /api/v1/importPOST /api/v1/import/reconcile |
Every route
Section titled “Every route”| Verb | Path | Scope required |
|---|---|---|
| POST | /api/v1/accounts |
accounts:write |
| POST | /api/v1/import |
transactions:write |
| POST | /api/v1/import/reconcile |
transactions:write |
| POST | /api/v1/accounts/:id/balance |
accounts:write |
| POST | /api/v1/accounts/:id/holdings |
accounts:write |
| GET | /api/v1/net-worth |
dashboard:read |
| GET | /api/v1/accounts |
accounts:read |
| GET | /api/v1/insights |
insights:read |
| GET | /api/v1/transactions |
transactions:read |
| GET | /api/v1/prices |
prices:read |
| GET | /api/v1/positions |
positions:read |
| GET | /api/v1/performance |
analytics:read |
| GET | /api/v1/evolution |
analytics:read |
The routes one by one
Section titled “The routes one by one”POST /api/v1/accounts
Section titled “POST /api/v1/accounts”Scope: accounts:write
Body (CreateAccountDto):
{ name: string type: "object" | "checking" | "savings" | "cto" | "pea" | "av" | "crypto_wallet" | "crowdlending" | "real_estate" | "loan" | "other" institution?: string currency?: string chain?: "BTC" | "ETH" address?: string openedAt?: string savingsProduct?: "livret_a" | "ldds" | "lep" | "livret_jeune" | "pel" | "cel" | "autre"}| Field | Checked with |
|---|---|
name |
Trimmed, IsString, MinLength(1), MaxLength(80) |
type |
IsIn(ACCOUNT_TYPES) |
institution |
IsOptional, Trimmed, IsString, MaxLength(80) |
currency |
IsOptional, IsString, MaxLength(3) |
chain |
IsOptional, IsIn([‘BTC’, ‘ETH’]) |
address |
IsOptional, IsString, MaxLength(120) |
openedAt |
IsOptional, IsString |
savingsProduct |
IsOptional, IsIn(SAVINGS_PRODUCTS) |
Response:
{ id: string name: string type: string institution: string | null currency: string iban: string | null bic: string | null syncMode: string connectorMeta: json archivedAt: string (ISO 8601) | null openedAt: string (ISO 8601) | null costBasis: string (decimal) | null contributionsEur: string (decimal) | null contributionsStatedAt: string (ISO 8601) | null savingsProduct: string | null platformFeeAnnual: string (decimal) | null platformFeeAnnualPct: string (decimal) | null taxSocialPct: string (decimal) | null taxIncomePct: string (decimal) | null taxMaturedIncomePct: string (decimal) | null loanOriginalPrincipal: string (decimal) | null loanRatePct: string (decimal) | null loanTermMonths: number | null linkedAccountId: string | null realEstateKind: string | null areaSqm: number | null address: string | null builtYear: number | null details: json inLetter: boolean createdAt: string (ISO 8601) updatedAt: string (ISO 8601) userId: string}POST /api/v1/import
Section titled “POST /api/v1/import”Scope: transactions:write
Body (V1ImportDto):
{ accountId: string transactions: { date: string amount: string label: string currency?: string type?: "fee" | "buy" | "credit" | "debit" | "dividend" | "interest" | "sell" | "transfer" counterparty?: string fee?: string tax?: string }[]}| Field | Checked with |
|---|---|
accountId |
IsString |
transactions |
IsArray, ArrayMaxSize(20_000) |
Each transactions entry is parsed against canonicalTransactionSchema:
| Field | Parsed with |
|---|---|
date |
z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(dayExists).refine(dayInReach) |
amount |
z.string().regex(/^-?\d+(\.\d+)?$/).refine(inRange(/^-?\d+(\.\d+)?$/)) |
currency |
z.string().length(3).default('EUR') |
label |
z.string().min(1) |
type |
z.enum(TRANSACTION_TYPES).optional() |
counterparty |
z.string().optional() |
fee |
z.string().regex(/^\d+(\.\d+)?$/).refine(inRange(/^\d+(\.\d+)?$/)).optional() |
tax |
z.string().regex(/^\d+(\.\d+)?$/).refine(inRange(/^\d+(\.\d+)?$/)).optional() |
Response:
{ duplicates: number deleted: number categorized: number received: number imported: number skipped: number}POST /api/v1/import/reconcile
Section titled “POST /api/v1/import/reconcile”Scope: transactions:write
Body (V1ImportDto):
{ accountId: string transactions: { date: string amount: string label: string currency?: string type?: "fee" | "buy" | "credit" | "debit" | "dividend" | "interest" | "sell" | "transfer" counterparty?: string fee?: string tax?: string }[]}| Field | Checked with |
|---|---|
accountId |
IsString |
transactions |
IsArray, ArrayMaxSize(20_000) |
Each transactions entry is parsed against canonicalTransactionSchema:
| Field | Parsed with |
|---|---|
date |
z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(dayExists).refine(dayInReach) |
amount |
z.string().regex(/^-?\d+(\.\d+)?$/).refine(inRange(/^-?\d+(\.\d+)?$/)) |
currency |
z.string().length(3).default('EUR') |
label |
z.string().min(1) |
type |
z.enum(TRANSACTION_TYPES).optional() |
counterparty |
z.string().optional() |
fee |
z.string().regex(/^\d+(\.\d+)?$/).refine(inRange(/^\d+(\.\d+)?$/)).optional() |
tax |
z.string().regex(/^\d+(\.\d+)?$/).refine(inRange(/^\d+(\.\d+)?$/)).optional() |
Response:
{ accountId: string total: number new: number duplicates: number deleted: number range: { from: string to: string } totals: { inflow: string outflow: string net: string } relabeled: number fuzzy: { date: string amount: string label: string }[] questions: string[] flagged: { date: string amount: string label: string dedupHash: string isDuplicate: boolean isDeleted: boolean }[]}POST /api/v1/accounts/:id/balance
Section titled “POST /api/v1/accounts/:id/balance”Scope: accounts:write
Path parameters: id
Body (AddBalanceDto):
{ balance: string date?: string}| Field | Checked with |
|---|---|
balance |
DecimalString(AMOUNT) |
date |
IsOptional, StatableDay(‘balance.dateFormat’) |
Response:
{ id: string currency: string createdAt: string (ISO 8601) date: string (ISO 8601) accountId: string balance: string (decimal) changeKind: string | null}POST /api/v1/accounts/:id/holdings
Section titled “POST /api/v1/accounts/:id/holdings”A purchase of a security this account already holds is written as a further line; a purchase is named by its entryDate, quantity, costBasis and feesTotal, so a call carrying an entry day is written once however many times it arrives and answers with the line already there; without one, on a security already held, it is refused instead and names the lines in place; and a second identical purchase on one day is declared by setting occurrence to 2, which is as replayable as the first call.
Scope: accounts:write
Path parameters: id
Body (AddHoldingDto):
{ isin: string quantity: string costBasis?: string feesTotal?: string ter?: string entryDate?: string occurrence?: number}| Field | Checked with |
|---|---|
isin |
IsString, MinLength(12), MaxLength(12) |
quantity |
DecimalString(QUANTITY) |
costBasis |
IsOptional, DecimalString(POSITIVE_AMOUNT) |
feesTotal |
IsOptional, DecimalString(POSITIVE_AMOUNT) |
ter |
IsOptional, DecimalString(PERCENT) |
entryDate |
IsOptional, Matches(ISO_DAY) |
occurrence |
IsOptional, IsNumber, Min(1) |
Response:
{ id: string costBasis: string (decimal) | null createdAt: string (ISO 8601) updatedAt: string (ISO 8601) accountId: string assetId: string quantity: string (decimal) feesTotal: string (decimal) | null worthlessAt: string (ISO 8601) | null entryDate: string (ISO 8601) | null}GET /api/v1/net-worth
Section titled “GET /api/v1/net-worth”The pricing block travels with every total this controller returns.
Scope: dashboard:read
Response:
{ netWorth: { gross: string liabilities: string net: string } allocation: { assetClass: "real_estate" | "other" | "cash" | "bonds" | "equities" | "crypto" | "collectible" valueEur: string pct: number }[] pricing: { pricedHoldings: number unpricedHoldings: number worthlessHoldings: number asOf: string | null asOfByKind: { [key: string]: string } foreignCurrency: string[] }}GET /api/v1/accounts
Section titled “GET /api/v1/accounts”The valued accounts, without the three fields that locate one rather than value it: iban, bic and a property’s postal address.
Scope: accounts:read
Response:
{ accounts: { id: string name: string type: string institution: string | null currency: string assetClass: "real_estate" | "other" | "cash" | "bonds" | "equities" | "crypto" | "collectible" valueEur: string gainAbs: string | null gainPct: number | null costBasis: string | null archived: boolean archivedAt: string | null openedAt: string | null realEstateKind: string | null areaSqm: number | null builtYear: number | null priceAsOf: string | null priceAsOfByKind: { [key: string]: string } balanceAsOf: string | null cashEur: string | null contributionsEur: string | null contributionsStatedAt: string | null savingsProduct: string | null taxSocialPct: string | null taxIncomePct: string | null taxMaturedIncomePct: string | null unpricedHoldings: number pricedHoldings: number worthlessHoldings: number foreignCurrency: string[] }[] pricing: { pricedHoldings: number unpricedHoldings: number worthlessHoldings: number asOf: string | null asOfByKind: { [key: string]: string } foreignCurrency: string[] }}GET /api/v1/insights
Section titled “GET /api/v1/insights”The cards, each served only to a key carrying the scope that names the figure it rests on, in the language ?lang= or Accept-Language asks for.
Scope: insights:read
Response:
{ cards: { id: string title: string value: string detail: string tone: "positive" | "negative" | "neutral" | "brand" items?: { label: string value: string seedKey?: string | null note?: string noteHint?: string }[] }[] smicNetMonthly: number}GET /api/v1/transactions
Section titled “GET /api/v1/transactions”The most recent operations, as a bare array: a documented shape held by API keys outside this repository, where the app’s own route answers { rows, total } instead.
Scope: transactions:read
Query parameters: accountId, limit
Response:
{ accountName?: string id: string date: string amount: string currency: string label: string type: string | null fee: string | null tax: string | null accountId: string categoryId: string | null categoryName: string | null note: string | null}[]GET /api/v1/prices
Section titled “GET /api/v1/prices”Scope: prices:read
Response:
{ prices: { name: string isin: string | null ticker: string | null kind: string price: string currency: string date: string }[]}GET /api/v1/positions
Section titled “GET /api/v1/positions”The held lines, with the account each sits in named only to a key that may read the accounts: accountId is what tells two lines of one security apart, and the name, the type and the opening day of the account are accounts:read’s subject.
Scope: positions:read
Response:
{ positions: { accountName?: string accountType?: string accountOpenedAt?: string | null id: string name: string ticker: string | null isin: string | null kind: string accountId: string ter: number | null quantity: string quantityFromLots: boolean costBasis: string | null costBasisUnit: string | null price: string | null value: string | null priceDate: string | null worthlessAt: string | null quoteCurrency: string | null weight: number | null gainAbs: string | null gainPct: number | null feesTotal: string | null feesFromLots: boolean spark: string[] editable: { quantity: string costBasis: string | null feesTotal: string | null ter: string | null entryDate: string | null } }[] movements: { accountName?: string date: string side: "buy" | "sell" name: string | null quantity: string unitPrice: string amount: string }[] summary: { unrealized: string realized: string | null total: string | null } pricing: { pricedHoldings: number unpricedHoldings: number worthlessHoldings: number asOf: string | null asOfByKind: { [key: string]: string } foreignCurrency: string[] }}GET /api/v1/performance
Section titled “GET /api/v1/performance”Scope: analytics:read
Query parameters: rate
Response:
{ summary: { totalValue: number investedValue: number contributionsNet: number | null contributionsSource: "costBasis" | "lots" | null unrealizedGain: number | null unrealizedGainPct: number | null tri: number | null ttwr: number | null volatility: number | null sharpe: number | null riskFreePct: number feesTotal: number } lotCoverage: { investedLines: number linesWithLots: number linesWithoutLots: number linesWithoutPrice: number } monthly: { year: number values: number | null[] annual: number | null }[] monthlyNet: { year: number values: number | null[] annual: number | null }[] annual: { year: number start: number end: number perfPct: number | null gainEur: number | null apports: number | null }[] drawdownPerf: { current: number | null max: number | null series: { date: string dd: number | null }[] } benchmark: { key: "world" | "sp500" | "eurostoxx50" label: string proxy: string proxyTicker: string baseMonth: string series: { date: string index: number portfolio: number }[] indexReturnPct: number portfolioReturnPct: number } | null note: string seriesGap: { incompletePoints: number firstDate: string lastDate: string } | null seriesAssumption: { assumedPoints: number firstDate: string lastDate: string } | null}GET /api/v1/evolution
Section titled “GET /api/v1/evolution”Scope: analytics:read
Query parameters: rate
Response:
{ rate: number categories: { key: "crowdlending" | "autre" | "crypto" | "courant" | "livret" | "investissement" | "immobilier" | "objets" label: string }[] series: { date: string total: number titres: number liquidites: number dettes: number incomplete: boolean }[] drift: { date: string byCat: { crowdlending?: number autre?: number crypto?: number courant?: number livret?: number investissement?: number immobilier?: number objets?: number } }[] drawdown: { current: number | null max: number | null series: { date: string dd: number | null }[] } milestones: { threshold: number reached: string | null projected: string | null share: number | null custom: boolean }[] seriesGap: { incompletePoints: number firstDate: string lastDate: string } | null seriesAssumption: { assumedPoints: number firstDate: string lastDate: string } | null}