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 is the page that says how a key is issued.
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.
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 |
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 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" 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 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" 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 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, 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 titled “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 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”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 titled “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 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:
{ 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 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”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 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}