# Analytics

Figures rolled up from your transactions and balances over a date range: cashflow, net worth and spending by category. They cover your whole account, not a space, and every amount is in the minor units of your account's currency, given as `currency`.

<!-- Generated from the OpenAPI spec by `php artisan api:docs`. Do not edit by hand. -->

The example requests read your personal token from `$WHISPER_MONEY_TOKEN`: run `export WHISPER_MONEY_TOKEN=wm_api_your_token_here` before pasting them.

## Retrieve the cashflow

`GET /api/v1/analytics/cashflow`

The cashflow of a date range: income, expenses, savings and investments against the previous period, your expenses split by the budgets that track them (fixed, variable and general), what your budgets still reserve, where the money came from and went by category, and the monthly trend. The range can touch at most 24 calendar months.

**Scope:** read. A personal token needs `api:read`; an OAuth token needs `api:use`.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `from` | query | string (date) | Yes | The first day of the range, inclusive. |
| `to` | query | string (date) | Yes | The last day of the range, inclusive. Not before `from`. |

#### Response

`200`

| Field | Type | Description |
| --- | --- | --- |
| `data` | Cashflow |  |
| `data.currency` | string | The currency of every amount: your account's currency, as an ISO 4217 code. |
| `data.summary` | object |  |
| `data.summary.current` | CashflowPeriod | The range's cashflow. |
| `data.summary.current.income` | integer | What came in: the transactions in income categories, and the uncategorized ones that brought money in. |
| `data.summary.current.expense` | integer | What went out: the transactions in expense categories, and the uncategorized ones that took money out. Savings, investments and transfers are not in it. |
| `data.summary.current.net` | integer | `income` minus `expense`. |
| `data.summary.current.savings_rate` | number | `net` as a percentage of `income`, rounded to one decimal and never above 100. `0` with no income. |
| `data.summary.current.savings` | integer | What was set aside into savings categories, net of withdrawals. |
| `data.summary.current.investments` | integer | What was set aside into investment categories, net of withdrawals. |
| `data.summary.current.fixed_expense` | integer | The part of `expense` tracked by budgets marked fixed (rent, subscriptions). Budgets are variable unless marked fixed, so `0` may only mean none has been marked yet. A transaction tracked by a fixed and a variable budget counts here, once. |
| `data.summary.current.variable_expense` | integer | The part of `expense` tracked by budgets marked variable (groceries, leisure). Catch-all budgets do not count. |
| `data.summary.current.general_expense` | integer | The rest of `expense`, which no budget tracks. The three parts add up to `expense`, and any can be negative in a period of refunds. |
| `data.summary.current.reserved` | integer | Current period only: what the budgets in `budgets` still hold back for spending, adding up their positive margins. An over-budget period adds nothing. |
| `data.summary.current.budgets` | array of CashflowBudget | Current period only: the budget periods in progress today that overlap the range. A range of up to 31 days counts weekly, bi-weekly and monthly budgets, one of up to 92 days those plus quarterly ones, and a longer one only yearly budgets. |
| `data.summary.current.budgets[].budget_id` | string (uuid) | The budget's id. |
| `data.summary.current.budgets[].budget_period_id` | string (uuid) | The id of the budget's period in progress. |
| `data.summary.current.budgets[].name` | string | The budget's name. |
| `data.summary.current.budgets[].start_date` | string (date) | The period's first day. |
| `data.summary.current.budgets[].end_date` | string (date) | The period's last day, included. |
| `data.summary.current.budgets[].expense_type` | "fixed" \| "variable" | `fixed` or `variable`: which part of the expense split the budget's spending counts in. |
| `data.summary.current.budgets[].allocated` | integer | The period's limit, in minor units of `currency`. |
| `data.summary.current.budgets[].spent` | integer | What was spent against it so far. |
| `data.summary.current.budgets[].margin` | integer | `allocated` minus `spent`, negative once the period goes over its limit. Like the budgets endpoints, it leaves out any carried-over amount. |
| `data.summary.previous` | CashflowPeriod | The cashflow of the period before it, to compare with: the same number of days right before `from`, or the same number of whole months when the range is whole months. It has the expense split but no `reserved` or `budgets`, which are about today. |
| `data.summary.previous.income` | integer | What came in: the transactions in income categories, and the uncategorized ones that brought money in. |
| `data.summary.previous.expense` | integer | What went out: the transactions in expense categories, and the uncategorized ones that took money out. Savings, investments and transfers are not in it. |
| `data.summary.previous.net` | integer | `income` minus `expense`. |
| `data.summary.previous.savings_rate` | number | `net` as a percentage of `income`, rounded to one decimal and never above 100. `0` with no income. |
| `data.summary.previous.savings` | integer | What was set aside into savings categories, net of withdrawals. |
| `data.summary.previous.investments` | integer | What was set aside into investment categories, net of withdrawals. |
| `data.summary.previous.fixed_expense` | integer | The part of `expense` tracked by budgets marked fixed (rent, subscriptions). Budgets are variable unless marked fixed, so `0` may only mean none has been marked yet. A transaction tracked by a fixed and a variable budget counts here, once. |
| `data.summary.previous.variable_expense` | integer | The part of `expense` tracked by budgets marked variable (groceries, leisure). Catch-all budgets do not count. |
| `data.summary.previous.general_expense` | integer | The rest of `expense`, which no budget tracks. The three parts add up to `expense`, and any can be negative in a period of refunds. |
| `data.summary.previous.reserved` | integer | Current period only: what the budgets in `budgets` still hold back for spending, adding up their positive margins. An over-budget period adds nothing. |
| `data.summary.previous.budgets` | array of CashflowBudget | Current period only: the budget periods in progress today that overlap the range. A range of up to 31 days counts weekly, bi-weekly and monthly budgets, one of up to 92 days those plus quarterly ones, and a longer one only yearly budgets. |
| `data.summary.previous.budgets[].budget_id` | string (uuid) | The budget's id. |
| `data.summary.previous.budgets[].budget_period_id` | string (uuid) | The id of the budget's period in progress. |
| `data.summary.previous.budgets[].name` | string | The budget's name. |
| `data.summary.previous.budgets[].start_date` | string (date) | The period's first day. |
| `data.summary.previous.budgets[].end_date` | string (date) | The period's last day, included. |
| `data.summary.previous.budgets[].expense_type` | "fixed" \| "variable" | `fixed` or `variable`: which part of the expense split the budget's spending counts in. |
| `data.summary.previous.budgets[].allocated` | integer | The period's limit, in minor units of `currency`. |
| `data.summary.previous.budgets[].spent` | integer | What was spent against it so far. |
| `data.summary.previous.budgets[].margin` | integer | `allocated` minus `spent`, negative once the period goes over its limit. Like the budgets endpoints, it leaves out any carried-over amount. |
| `data.sankey` | object |  |
| `data.sankey.income_categories` | array of CategoryAmount | Where the money came from, by top-level category. A category is netted and lands on the side its net points at, so an expense category that got more back than it spent is listed here as a refund. Savings and investment withdrawals and transfers set to count as an inflow appear here too, and the uncategorized money that came in is one row with no `category_id`. |
| `data.sankey.income_categories[].category_id` | string (uuid) or null | The category's id, or `null` for the row that gathers the transactions with no category. |
| `data.sankey.income_categories[].name` | string or null | The category's name. The row with no category is `Unknown Income` or `Unknown Expense`. On the cashflow's top level, a category whose net crossed to the other side says so: `Groceries (refund)`, `Savings (withdrawal)`, `Salary (reversal)`. |
| `data.sankey.income_categories[].type` | string or null | The category's type: `income`, `expense`, `savings`, `investment` or `transfer`. |
| `data.sankey.income_categories[].color` | string or null | The category's colour in the app. |
| `data.sankey.income_categories[].icon` | string or null | The category's icon in the app, a Lucide icon name. |
| `data.sankey.income_categories[].amount` | integer | What the category adds up to over the period, as a positive amount in minor units of `currency`. |
| `data.sankey.income_categories[].has_children` | boolean | Whether the category has subcategories to drill into. |
| `data.sankey.income_categories[].is_direct` | boolean | When drilling into a category, `true` on the row for the transactions booked to that category itself rather than to one of its children. |
| `data.sankey.expense_categories` | array of CategoryAmount | Where the money went, by top-level category, built the same way. Savings, investments and transfers set to count as an outflow appear here too. |
| `data.sankey.expense_categories[].category_id` | string (uuid) or null | The category's id, or `null` for the row that gathers the transactions with no category. |
| `data.sankey.expense_categories[].name` | string or null | The category's name. The row with no category is `Unknown Income` or `Unknown Expense`. On the cashflow's top level, a category whose net crossed to the other side says so: `Groceries (refund)`, `Savings (withdrawal)`, `Salary (reversal)`. |
| `data.sankey.expense_categories[].type` | string or null | The category's type: `income`, `expense`, `savings`, `investment` or `transfer`. |
| `data.sankey.expense_categories[].color` | string or null | The category's colour in the app. |
| `data.sankey.expense_categories[].icon` | string or null | The category's icon in the app, a Lucide icon name. |
| `data.sankey.expense_categories[].amount` | integer | What the category adds up to over the period, as a positive amount in minor units of `currency`. |
| `data.sankey.expense_categories[].has_children` | boolean | Whether the category has subcategories to drill into. |
| `data.sankey.expense_categories[].is_direct` | boolean | When drilling into a category, `true` on the row for the transactions booked to that category itself rather than to one of its children. |
| `data.sankey.total_income` | integer | What `income_categories` adds up to. |
| `data.sankey.total_expense` | integer | What `expense_categories` adds up to. Savings, investments and outflow transfers are in it, so it is not `summary.current.expense`. |
| `data.trend` | array of object | Income, expense and net per calendar month the range touches, oldest first. Counted like `current`: savings, investments and transfers left out. |
| `data.trend[].month` | string |  |
| `data.trend[].income` | integer |  |
| `data.trend[].expense` | integer |  |
| `data.trend[].net` | integer |  |

#### Example request

```bash
curl "https://whisper.money/api/v1/analytics/cashflow?from=2026-10-01&to=2026-10-31" \
  -H "Authorization: Bearer $WHISPER_MONEY_TOKEN"
```

#### Example response

```json
{
    "data": {
        "currency": "EUR",
        "summary": {
            "current": {
                "income": 260000,
                "expense": 96122,
                "net": 163878,
                "savings_rate": 63,
                "savings": 10000,
                "investments": 0,
                "fixed_expense": 90000,
                "variable_expense": 5345,
                "general_expense": 777,
                "reserved": 17950,
                "budgets": [
                    {
                        "budget_id": "0199a3f8-4c5d-7e6f-8a9b-0c1d2e3f4a5b",
                        "budget_period_id": "0199a3fa-1b2c-7d3e-8f4a-5b6c7d8e9f01",
                        "name": "Groceries",
                        "start_date": "2026-10-01",
                        "end_date": "2026-10-31",
                        "expense_type": "variable",
                        "allocated": 50000,
                        "spent": 32050,
                        "margin": 17950
                    }
                ]
            },
            "previous": {
                "income": 260000,
                "expense": 96122,
                "net": 163878,
                "savings_rate": 63,
                "savings": 10000,
                "investments": 0,
                "fixed_expense": 90000,
                "variable_expense": 5345,
                "general_expense": 777,
                "reserved": 17950,
                "budgets": [
                    {
                        "budget_id": "0199a3f8-4c5d-7e6f-8a9b-0c1d2e3f4a5b",
                        "budget_period_id": "0199a3fa-1b2c-7d3e-8f4a-5b6c7d8e9f01",
                        "name": "Groceries",
                        "start_date": "2026-10-01",
                        "end_date": "2026-10-31",
                        "expense_type": "variable",
                        "allocated": 50000,
                        "spent": 32050,
                        "margin": 17950
                    }
                ]
            }
        },
        "sankey": {
            "income_categories": [
                {
                    "category_id": "0199a3f5-2b3c-7d4e-8f5a-6b7c8d9e0f12",
                    "name": "Groceries",
                    "type": "expense",
                    "color": "orange",
                    "icon": "ShoppingCart",
                    "amount": 15345,
                    "has_children": false,
                    "is_direct": false
                }
            ],
            "expense_categories": [
                {
                    "category_id": "0199a3f5-2b3c-7d4e-8f5a-6b7c8d9e0f12",
                    "name": "Groceries",
                    "type": "expense",
                    "color": "orange",
                    "icon": "ShoppingCart",
                    "amount": 15345,
                    "has_children": false,
                    "is_direct": false
                }
            ],
            "total_income": 260000,
            "total_expense": 106122
        },
        "trend": [
            {
                "month": "2026-10",
                "income": 260000,
                "expense": 96122,
                "net": 163878
            }
        ]
    }
}
```

#### Errors

| Status | When |
| --- | --- |
| `401` | No bearer token, or one that is unknown, expired or revoked. |
| `403` | The token lacks the scope (`insufficient_scope`), the account has no Pro plan (`plan_required`, with a `subscribe_url`) or it is a shared demo account (`shared_account`). |
| `422` | Validation error |
| `429` | The rate limit is exhausted. Wait the seconds in `Retry-After`. |

## Retrieve the net worth

`GET /api/v1/analytics/net-worth`

Your net worth at the end of a date range against the end of the previous period, and every account's balance over the range, monthly or daily. A monthly range can touch at most 120 calendar months, a daily one cover at most 366 days.

**Scope:** read. A personal token needs `api:read`; an OAuth token needs `api:use`.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `from` | query | string (date) | Yes | The first day of the range, inclusive. |
| `to` | query | string (date) | Yes | The last day of the range, inclusive. Not before `from`. |
| `granularity` | query | "monthly" \| "daily" | No | `monthly` (the default) for a point at the end of each month of the range, `daily` for one per day. |

#### Response

`200`

| Field | Type | Description |
| --- | --- | --- |
| `data` | NetWorth |  |
| `data.currency` | string | The currency of every amount: your account's currency, as an ISO 4217 code. Balances in another currency are converted at each day's exchange rate. |
| `data.granularity` | "monthly" \| "daily" | `monthly` or `daily`, as requested. |
| `data.net_worth` | integer | Your net worth on `to`: the balances of the accounts with `counts_in_net_worth`, converted to `currency`, a loan's debt counting against it. Credit cards are never part of it, and loans and real estate only when your net worth settings include them. |
| `data.previous_net_worth` | integer | Your net worth at the end of the previous period: the same number of days right before `from`, or the same number of whole months when the range is whole months. |
| `data.accounts` | array of object | Every account the evolution covers, archived ones and the ones left out of `net_worth` included: its id, name, type, own currency, and whether it is part of `net_worth`. |
| `data.accounts[].id` | string |  |
| `data.accounts[].name` | string |  |
| `data.accounts[].type` | string |  |
| `data.accounts[].currency` | string |  |
| `data.accounts[].counts_in_net_worth` | boolean |  |
| `data.evolution` | array of NetWorthPoint | One point per month the range touches, at the month's last day even when `to` falls before it, or one per day of the range; oldest first, with each account's balance on it. |
| `data.evolution[].period` | string | The month (`2026-10`) or, with `daily` granularity, the day (`2026-10-15`). |
| `data.evolution[].balances` | array of object | One entry per account in `accounts`, in the same order: `balance` in `currency`, a loan's as the positive amount owed; `original_balance` and `original_currency` the account's own figure when its currency is another one, `null` otherwise; `invested_amount` what was put into an investment or retirement account on a monthly point, `null` for other types, on daily points or when not recorded. |
| `data.evolution[].balances[].account_id` | string |  |
| `data.evolution[].balances[].balance` | integer |  |
| `data.evolution[].balances[].original_balance` | integer or null |  |
| `data.evolution[].balances[].original_currency` | string or null |  |
| `data.evolution[].balances[].invested_amount` | integer or null |  |

#### Example request

```bash
curl "https://whisper.money/api/v1/analytics/net-worth?from=2026-10-01&to=2026-10-31" \
  -H "Authorization: Bearer $WHISPER_MONEY_TOKEN"
```

#### Example response

```json
{
    "data": {
        "currency": "EUR",
        "granularity": "monthly",
        "net_worth": 366364,
        "previous_net_worth": 360000,
        "accounts": [
            {
                "id": "0199a3f2-8b1c-7d4e-9f5a-1b2c3d4e5f60",
                "name": "Main checking",
                "type": "checking",
                "currency": "EUR",
                "counts_in_net_worth": true
            }
        ],
        "evolution": [
            {
                "period": "2026-10",
                "balances": [
                    {
                        "account_id": "0199a3f2-8b1c-7d4e-9f5a-1b2c3d4e5f60",
                        "balance": 130000,
                        "original_balance": null,
                        "original_currency": null,
                        "invested_amount": null
                    }
                ]
            }
        ]
    }
}
```

#### Errors

| Status | When |
| --- | --- |
| `401` | No bearer token, or one that is unknown, expired or revoked. |
| `403` | The token lacks the scope (`insufficient_scope`), the account has no Pro plan (`plan_required`, with a `subscribe_url`) or it is a shared demo account (`shared_account`). |
| `422` | Validation error |
| `429` | The rate limit is exhausted. Wait the seconds in `Retry-After`. |

## Retrieve spending by category

`GET /api/v1/analytics/spending-by-category`

Your expenses in a date range by top-level category, each with its subcategories rolled into it. Pass `parent_category_id` to drill into a category: its children come back instead, plus a row for what was booked to the category itself (`is_direct`). The range can touch at most 120 calendar months.

**Scope:** read. A personal token needs `api:read`; an OAuth token needs `api:use`.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `from` | query | string (date) | Yes | The first day of the range, inclusive. |
| `to` | query | string (date) | Yes | The last day of the range, inclusive. Not before `from`. |
| `parent_category_id` | query | string (uuid) or null | No | Drill into one of your categories: its children are returned instead of the top-level categories. |

#### Response

`200`

| Field | Type | Description |
| --- | --- | --- |
| `data` | SpendingByCategory |  |
| `data.currency` | string | The currency of every amount: your account's currency, as an ISO 4217 code. |
| `data.total` | integer | What `categories` adds up to. |
| `data.categories` | array of CategoryAmount | The expense categories with spending in the range, largest first, each with its subcategories rolled into it. Only the ones whose spending is above zero: a category that refunds took below zero is left out, and so are uncategorized transactions, so `total` can be above the cashflow's `expense` for the same range. |
| `data.categories[].category_id` | string (uuid) or null | The category's id, or `null` for the row that gathers the transactions with no category. |
| `data.categories[].name` | string or null | The category's name. The row with no category is `Unknown Income` or `Unknown Expense`. On the cashflow's top level, a category whose net crossed to the other side says so: `Groceries (refund)`, `Savings (withdrawal)`, `Salary (reversal)`. |
| `data.categories[].type` | string or null | The category's type: `income`, `expense`, `savings`, `investment` or `transfer`. |
| `data.categories[].color` | string or null | The category's colour in the app. |
| `data.categories[].icon` | string or null | The category's icon in the app, a Lucide icon name. |
| `data.categories[].amount` | integer | What the category adds up to over the period, as a positive amount in minor units of `currency`. |
| `data.categories[].has_children` | boolean | Whether the category has subcategories to drill into. |
| `data.categories[].is_direct` | boolean | When drilling into a category, `true` on the row for the transactions booked to that category itself rather than to one of its children. |

#### Example request

```bash
curl "https://whisper.money/api/v1/analytics/spending-by-category?from=2026-10-01&to=2026-10-31" \
  -H "Authorization: Bearer $WHISPER_MONEY_TOKEN"
```

#### Example response

```json
{
    "data": {
        "currency": "EUR",
        "total": 105345,
        "categories": [
            {
                "category_id": "0199a3f5-2b3c-7d4e-8f5a-6b7c8d9e0f12",
                "name": "Groceries",
                "type": "expense",
                "color": "orange",
                "icon": "ShoppingCart",
                "amount": 15345,
                "has_children": false,
                "is_direct": false
            }
        ]
    }
}
```

#### Errors

| Status | When |
| --- | --- |
| `401` | No bearer token, or one that is unknown, expired or revoked. |
| `403` | The token lacks the scope (`insufficient_scope`), the account has no Pro plan (`plan_required`, with a `subscribe_url`) or it is a shared demo account (`shared_account`). |
| `422` | Validation error |
| `429` | The rate limit is exhausted. Wait the seconds in `Retry-After`. |

---

Full documentation index: https://whisper.money/documentation.md?lang=en
