Aller au contenu

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/accounts
POST /api/v1/accounts/:id/balance
POST /api/v1/accounts/:id/holdings
analytics:read GET /api/v1/performance
GET /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/import
POST /api/v1/import/reconcile
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

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
}

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
}

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
}[]
}

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
}

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
}

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[]
}
}

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[]
}
}

?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
}[]
}[]
}

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
}[]

Scope: prices:read

Response:

{
prices: {
name: string
isin: string | null
ticker: string | null
kind: string
price: string
currency: string
date: string
}[]
}

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[]
}
}

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
}

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
}

Confidentialité