Référence de l'API REST
La surface /api/v1 : les 13 routes accessibles avec une clé d’API, et rien
d’autre. Les routes internes de l’application n’y figurent pas, puisqu’une clé n’y donne pas
accès. La page est générée à partir de apps/api/src/api-keys/public-api.controller.ts :
ses décorateurs donnent le verbe, le chemin et la portée, ses classes DTO le corps de la
requête, et le type de retour calculé par le compilateur la réponse. La page Clés
API explique comment créer une clé.
Une portée donne accès à une route, mais elle détermine aussi le contenu de la réponse. Un
champ réservé à une autre portée n’est renvoyé qu’aux clés qui la possèdent, c’est pourquoi
les formats ci-dessous le marquent comme facultatif. Ainsi, le compte d’une opération ou
d’une ligne détenue relève de accounts:read, et une carte de réalisations n’est renvoyée
qu’à une clé qui possède la portée du chiffre dont elle dépend (dashboard:read pour le
patrimoine net, son évolution sur un an et la répartition, accounts:read pour la
plus-value latente, transactions:read pour les plus grosses dépenses et les principales
catégories). Deux choix sont délibérés : analytics:read renvoie les séries de valeur et
la dérive que l’écran des réglages mentionne sous cette portée, c’est-à-dire le patrimoine
net dans le temps, et GET /api/v1/accounts renvoie la valeur de chaque compte sans son
iban, son bic ni l’adresse d’un bien, que cette portée ne mentionne nulle part.
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.
| 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 intitulée « 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 intitulée « The routes one by one »POST /api/v1/accounts
Section intitulée « 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 intitulée « 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 intitulée « 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 intitulée « 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 intitulée « 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 intitulée « 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 intitulée « 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 intitulée « 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 intitulée « 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 intitulée « 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 intitulée « 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 intitulée « 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 intitulée « 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}