Référence de l'API REST
La surface /api/v1 : les 13 routes qu’une clé d’API atteint, et rien d’autre.
Les routes propres à l’application ne sont pas ici, parce qu’une clé ne les ouvre pas. Engendré
depuis apps/api/src/api-keys/public-api.controller.ts – ses décorateurs pour le verbe, le
chemin et la portée, ses classes DTO pour le corps, le type de retour du compilateur lui-même
pour la réponse. Clés API est la page qui dit comment une clé
est émise.
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, and this is the failure
worth knowing about: it 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 rather than quietly 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 guess here is a day of drift dressed up as a success. The balance route took a full
timestamp until 5 September 2026 and truncated it on UTC, which dated a Paris caller’s
00:30 entry to the day before and overwrote whatever that day already held.
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 |
IsString, MinLength(1), MaxLength(80) |
type |
IsIn(ACCOUNT_TYPES) |
institution |
IsOptional, 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 platformFeePerOperation: 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 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" category?: string counterparty?: string fee?: string tax?: string }[]}| Field | Checked with |
|---|---|
accountId |
IsString |
transactions |
IsArray |
Each transactions entry is parsed against canonicalTransactionSchema:
| Field | Parsed with |
|---|---|
date |
z.string().regex(/^\d{4}-\d{2}-\d{2}$/) |
amount |
z.string().regex(/^-?\d+(\.\d+)?$/) |
currency |
z.string().length(3).default('EUR') |
label |
z.string().min(1) |
type |
z.enum(TRANSACTION_TYPES).optional() |
category |
z.string().optional() |
counterparty |
z.string().optional() |
fee |
z.string().regex(/^\d+(\.\d+)?$/).optional() |
tax |
z.string().regex(/^\d+(\.\d+)?$/).optional() |
Response:
{ duplicates: number deleted: 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" category?: string counterparty?: string fee?: string tax?: string }[]}| Field | Checked with |
|---|---|
accountId |
IsString |
transactions |
IsArray |
Each transactions entry is parsed against canonicalTransactionSchema:
| Field | Parsed with |
|---|---|
date |
z.string().regex(/^\d{4}-\d{2}-\d{2}$/) |
amount |
z.string().regex(/^-?\d+(\.\d+)?$/) |
currency |
z.string().length(3).default('EUR') |
label |
z.string().min(1) |
type |
z.enum(TRANSACTION_TYPES).optional() |
category |
z.string().optional() |
counterparty |
z.string().optional() |
fee |
z.string().regex(/^\d+(\.\d+)?$/).optional() |
tax |
z.string().regex(/^\d+(\.\d+)?$/).optional() |
Response:
{ accountId: string account: string total: number new: number duplicates: number deleted: number uncategorized: number range: { from: string to: string } totals: { inflow: string outflow: string net: string } existing: { count: number lastDate: string | null } fuzzy: { date: string amount: string label: string existingLabels: string[] }[] questions: string[] items: { date: string amount: string currency: string label: string category?: string fee?: string tax?: 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, Matches(ISO_DAY) |
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 »Scope: accounts:write
Path parameters: id
Body (AddHoldingDto):
{ isin: string quantity: string costBasis?: string feesTotal?: string ter?: string entryDate?: string}| 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) |
Response:
{ id: string costBasis: string (decimal) | null createdAt: string (ISO 8601) updatedAt: string (ISO 8601) accountId: string quantity: string (decimal) feesTotal: string (decimal) | null worthlessAt: string (ISO 8601) | null entryDate: string (ISO 8601) | null assetId: string}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 »Scope: accounts:read
Response:
{ accounts: { id: string name: string type: string institution: string | null currency: string iban: string | null bic: string | null assetClass: "real_estate" | "other" | "cash" | "bonds" | "equities" | "crypto" | "collectible" valueEur: string gainAbs: string | null gainPct: number | null costBasis: string | null archived: boolean openedAt: string | null realEstateKind: string | null areaSqm: number | null address: string | 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 »?lang= still picks the language, and Accept-Language now picks it too: a key holder scripting against this route sets a header far more readily than they remember a parameter, and the cards were coming back in French for them.
Scope: insights:read
Response:
{ smicNetMonthly: number 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 }[] }[]}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:
{ id: string date: string amount: string currency: string label: string type: string | null fee: string | null tax: string | null accountId: string accountName: 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 »Scope: positions:read
Response:
{ summary: { unrealized: number realized: number | null total: number | null } positions: { id: string name: string ticker: string | null isin: string | null kind: string accountId: string accountName: string accountType: string accountOpenedAt: string | null ter: number | null quantity: number quantityFromLots: boolean costBasis: number | null costBasisUnit: number | null price: number | null value: number | null priceDate: string | null worthlessAt: string | null quoteCurrency: string | null weight: number | null gainAbs: number | null gainPct: number | null feesTotal: number | null feesFromLots: boolean spark: number[] editable: { quantity: string costBasis: string | null feesTotal: string | null ter: string | null entryDate: string | null } }[] movements: { date: string side: "buy" | "sell" name: string | null accountName: string quantity: number unitPrice: number amount: number }[] 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}