Skip to content

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.

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
}

Privacy