Budgets
A budget is a spending limit per period over some of your categories and labels. Budgets cover your whole account, not a space, and only ever track your own categories and labels. Amounts are in the minor units of your account's currency, given as currency. A budget's period length, start day, rollover and what it tracks are fixed once it is created: to change them, delete the budget and create it again.
The example requests read your personal token from $WHISPER_MONEY_TOKEN: run export WHISPER_MONEY_TOKEN=wm_api_your_token_here before pasting them.
List budgets
GET /api/v1/budgets
Your budgets, newest first, each with the period in progress. Archived budgets are left out; retrieve one by its id to read it.
Scope: read. A personal token needs api:read; an OAuth token needs api:use.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
limit |
query | integer | No | How many objects to return, between 1 and 100. Defaults to 25. |
starting_after |
query | string (uuid) | No | An object id: return the page that follows it. Pass the last id of the page you have. |
ending_before |
query | string (uuid) | No | An object id: return the page that precedes it. Pass the first id of the page you have. Cannot be combined with starting_after. |
Response
200
| Field | Type | Description |
|---|---|---|
data |
array of Budget | |
data[].id |
string (uuid) | The budget's id. |
data[].name |
string | The budget's name. |
data[].period_type |
"monthly" | "weekly" | "biweekly" | "yearly" | "quarterly" | How often the budget starts over: monthly, weekly, biweekly, quarterly or yearly. Fixed once created. |
data[].period_start_day |
integer or null | The day a period starts: the day of the month (1–31) for a monthly budget, the day of the week (0 is Sunday) for a weekly or biweekly one. Quarterly and yearly budgets follow the calendar. null when not set. Fixed once created. |
data[].rollover_type |
"carry_over" | "reset" | What happens to what is left at the end of a period: carry_over adds it to the next period, reset starts every period at the limit. Fixed once created. |
data[].expense_type |
"fixed" | "variable" | fixed for spending that repeats at the same amount every period (rent, subscriptions), variable for spending you can adjust (groceries, leisure). It decides how the cashflow analytics split your expenses. A catch-all budget is always variable. |
data[].is_catch_all |
boolean | Whether the budget tracks every expense category no other budget tracks. An account has at most one. Fixed once created. |
data[].categories |
array of object | The categories the budget tracks; tracking a parent category also tracks its children. Fixed once created. |
data[].categories[].id |
string | |
data[].categories[].name |
string | |
data[].labels |
array of object | The labels the budget tracks. Fixed once created. |
data[].labels[].id |
string | |
data[].labels[].name |
string | |
data[].currency |
string | The currency of the budget's amounts: your account's currency, as an ISO 4217 code. |
data[].current_period |
BudgetPeriod or null | The period that covers today, or null when no period does. |
data[].current_period.id |
string (uuid) | The period's id. |
data[].current_period.start_date |
string (date) | The period's first day. |
data[].current_period.end_date |
string (date) | The period's last day, included. |
data[].current_period.allocated_amount |
integer | The limit for this period. |
data[].current_period.carried_over_amount |
integer | What the previous period left over, carried into this one when the budget's rollover_type is carry_over. |
data[].current_period.spent_amount |
integer | What the tracked transactions of the period add up to, as a positive number; a refund lowers it. Provisional while processing_historical is true. |
data[].current_period.remaining_amount |
integer | allocated_amount minus spent_amount, the figure the app shows. It leaves out carried_over_amount, and it is negative once the period goes over its limit. |
data[].current_period.processing_historical |
boolean | true while the transactions that were already in the period when the budget was created are still being added to it, in the background. Until then spent_amount and remaining_amount are provisional: read the budget again in a few seconds. |
data[].archived_at |
string (date-time) or null | When the budget was archived in the app, or null. An archived budget keeps its history but can no longer change; it can still be deleted. |
data[].created_at |
string (date-time) or null | When the budget was created. |
data[].updated_at |
string (date-time) or null | When the budget was last changed. |
has_more |
boolean | Whether more objects exist past this page, in the direction being walked. |
Example request
curl https://whisper.money/api/v1/budgets \
-H "Authorization: Bearer $WHISPER_MONEY_TOKEN"
Example response
{
"data": [
{
"id": "0199a3f8-4c5d-7e6f-8a9b-0c1d2e3f4a5b",
"name": "Groceries",
"period_type": "monthly",
"period_start_day": 1,
"rollover_type": "reset",
"expense_type": "variable",
"is_catch_all": true,
"categories": [
{
"id": "0199a3f5-2b3c-7d4e-8f5a-6b7c8d9e0f12",
"name": "Groceries"
}
],
"labels": [
{
"id": "0199a3f6-3c4d-7e5f-9a6b-7c8d9e0f1a23",
"name": "Essentials"
}
],
"currency": "EUR",
"current_period": {
"id": "0199a3fa-1b2c-7d3e-8f4a-5b6c7d8e9f01",
"start_date": "2026-10-01",
"end_date": "2026-10-31",
"allocated_amount": 50000,
"carried_over_amount": 0,
"spent_amount": 32050,
"remaining_amount": 17950,
"processing_historical": true
},
"archived_at": null,
"created_at": "2026-01-15T09:30:00+00:00",
"updated_at": "2026-01-20T18:05:00+00:00"
}
],
"has_more": true
}
Errors
| Status | When |
|---|---|
400 |
A malformed request: an unknown pagination cursor (invalid_cursor) or an invalid Idempotency-Key (invalid_idempotency_key). |
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 |
The request failed validation. |
429 |
The rate limit is exhausted. Wait the seconds in Retry-After. |
Create a budget
POST /api/v1/budgets
The budget needs at least one category or label to track, or is_catch_all: true. It starts with the period in progress and the one before it. The transactions already in them are added in the background, so current_period.spent_amount is provisional while current_period.processing_historical is true: read the budget again a few seconds later.
Scope: write. A personal token needs api:write (a read & write token); an OAuth token needs api:use.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
Idempotency-Key |
header | string | No | Any unique string up to 255 characters. Retrying with the same key within 24 hours returns the first response instead of repeating the request. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | Yes | The budget's name. |
allocated_amount |
integer | Yes | The limit for each period, in the minor units of your account's currency. |
period_type |
BudgetPeriodType | Yes | How often the budget starts over. |
rollover_type |
RolloverType | Yes | carry_over adds what is left at the end of a period to the next one, reset starts every period at the limit. |
period_start_day |
integer or null | No | The day a period starts: the day of the month (1–31) for a monthly budget, the day of the week (0 is Sunday, 6 Saturday) for a weekly or biweekly one. Quarterly and yearly budgets follow the calendar and ignore it. |
expense_type |
BudgetExpenseType | No | fixed for spending that repeats at the same amount every period (rent, subscriptions), variable (the default) for spending you can adjust. A catch-all budget is always variable. |
is_catch_all |
boolean | No | Track every expense category no other budget tracks. A catch-all budget takes no category_ids or label_ids, and an account has at most one. |
category_ids |
array of string (uuid) | No | The ids of the categories to track, each one of your own, 100 at most. Tracking a parent also tracks its children. A budget needs at least one category or label, unless it is the catch-all. |
label_ids |
array of string (uuid) | No | The ids of the labels to track, each one of your own, 100 at most. |
Response
201
| Field | Type | Description |
|---|---|---|
data |
Budget | |
data.id |
string (uuid) | The budget's id. |
data.name |
string | The budget's name. |
data.period_type |
"monthly" | "weekly" | "biweekly" | "yearly" | "quarterly" | How often the budget starts over: monthly, weekly, biweekly, quarterly or yearly. Fixed once created. |
data.period_start_day |
integer or null | The day a period starts: the day of the month (1–31) for a monthly budget, the day of the week (0 is Sunday) for a weekly or biweekly one. Quarterly and yearly budgets follow the calendar. null when not set. Fixed once created. |
data.rollover_type |
"carry_over" | "reset" | What happens to what is left at the end of a period: carry_over adds it to the next period, reset starts every period at the limit. Fixed once created. |
data.expense_type |
"fixed" | "variable" | fixed for spending that repeats at the same amount every period (rent, subscriptions), variable for spending you can adjust (groceries, leisure). It decides how the cashflow analytics split your expenses. A catch-all budget is always variable. |
data.is_catch_all |
boolean | Whether the budget tracks every expense category no other budget tracks. An account has at most one. Fixed once created. |
data.categories |
array of object | The categories the budget tracks; tracking a parent category also tracks its children. Fixed once created. |
data.categories[].id |
string | |
data.categories[].name |
string | |
data.labels |
array of object | The labels the budget tracks. Fixed once created. |
data.labels[].id |
string | |
data.labels[].name |
string | |
data.currency |
string | The currency of the budget's amounts: your account's currency, as an ISO 4217 code. |
data.current_period |
BudgetPeriod or null | The period that covers today, or null when no period does. |
data.current_period.id |
string (uuid) | The period's id. |
data.current_period.start_date |
string (date) | The period's first day. |
data.current_period.end_date |
string (date) | The period's last day, included. |
data.current_period.allocated_amount |
integer | The limit for this period. |
data.current_period.carried_over_amount |
integer | What the previous period left over, carried into this one when the budget's rollover_type is carry_over. |
data.current_period.spent_amount |
integer | What the tracked transactions of the period add up to, as a positive number; a refund lowers it. Provisional while processing_historical is true. |
data.current_period.remaining_amount |
integer | allocated_amount minus spent_amount, the figure the app shows. It leaves out carried_over_amount, and it is negative once the period goes over its limit. |
data.current_period.processing_historical |
boolean | true while the transactions that were already in the period when the budget was created are still being added to it, in the background. Until then spent_amount and remaining_amount are provisional: read the budget again in a few seconds. |
data.archived_at |
string (date-time) or null | When the budget was archived in the app, or null. An archived budget keeps its history but can no longer change; it can still be deleted. |
data.created_at |
string (date-time) or null | When the budget was created. |
data.updated_at |
string (date-time) or null | When the budget was last changed. |
Example request
curl -X POST https://whisper.money/api/v1/budgets \
-H "Authorization: Bearer $WHISPER_MONEY_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Groceries",
"allocated_amount": 50000,
"period_type": "monthly",
"rollover_type": "carry_over",
"period_start_day": 1,
"is_catch_all": false,
"category_ids": [
"0199a3f5-2b3c-7d4e-8f5a-6b7c8d9e0f12"
],
"label_ids": [
"0199a3f6-3c4d-7e5f-9a6b-7c8d9e0f1a23"
]
}'
Example response
{
"data": {
"id": "0199a3f8-4c5d-7e6f-8a9b-0c1d2e3f4a5b",
"name": "Groceries",
"period_type": "monthly",
"period_start_day": 1,
"rollover_type": "reset",
"expense_type": "variable",
"is_catch_all": true,
"categories": [
{
"id": "0199a3f5-2b3c-7d4e-8f5a-6b7c8d9e0f12",
"name": "Groceries"
}
],
"labels": [
{
"id": "0199a3f6-3c4d-7e5f-9a6b-7c8d9e0f1a23",
"name": "Essentials"
}
],
"currency": "EUR",
"current_period": {
"id": "0199a3fa-1b2c-7d3e-8f4a-5b6c7d8e9f01",
"start_date": "2026-10-01",
"end_date": "2026-10-31",
"allocated_amount": 50000,
"carried_over_amount": 0,
"spent_amount": 32050,
"remaining_amount": 17950,
"processing_historical": true
},
"archived_at": null,
"created_at": "2026-01-15T09:30:00+00:00",
"updated_at": "2026-01-20T18:05:00+00:00"
}
}
Errors
| Status | When |
|---|---|
400 |
A malformed request: an unknown pagination cursor (invalid_cursor) or an invalid Idempotency-Key (invalid_idempotency_key). |
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). |
409 |
A request with the same Idempotency-Key is still being processed. |
422 |
The request failed validation. |
429 |
The rate limit is exhausted. Wait the seconds in Retry-After. |
Retrieve a budget
GET /api/v1/budgets/{budget}
Archived budgets included.
Scope: read. A personal token needs api:read; an OAuth token needs api:use.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
budget |
path | string (uuid) | Yes | The budget's id. |
Response
200
| Field | Type | Description |
|---|---|---|
data |
Budget | |
data.id |
string (uuid) | The budget's id. |
data.name |
string | The budget's name. |
data.period_type |
"monthly" | "weekly" | "biweekly" | "yearly" | "quarterly" | How often the budget starts over: monthly, weekly, biweekly, quarterly or yearly. Fixed once created. |
data.period_start_day |
integer or null | The day a period starts: the day of the month (1–31) for a monthly budget, the day of the week (0 is Sunday) for a weekly or biweekly one. Quarterly and yearly budgets follow the calendar. null when not set. Fixed once created. |
data.rollover_type |
"carry_over" | "reset" | What happens to what is left at the end of a period: carry_over adds it to the next period, reset starts every period at the limit. Fixed once created. |
data.expense_type |
"fixed" | "variable" | fixed for spending that repeats at the same amount every period (rent, subscriptions), variable for spending you can adjust (groceries, leisure). It decides how the cashflow analytics split your expenses. A catch-all budget is always variable. |
data.is_catch_all |
boolean | Whether the budget tracks every expense category no other budget tracks. An account has at most one. Fixed once created. |
data.categories |
array of object | The categories the budget tracks; tracking a parent category also tracks its children. Fixed once created. |
data.categories[].id |
string | |
data.categories[].name |
string | |
data.labels |
array of object | The labels the budget tracks. Fixed once created. |
data.labels[].id |
string | |
data.labels[].name |
string | |
data.currency |
string | The currency of the budget's amounts: your account's currency, as an ISO 4217 code. |
data.current_period |
BudgetPeriod or null | The period that covers today, or null when no period does. |
data.current_period.id |
string (uuid) | The period's id. |
data.current_period.start_date |
string (date) | The period's first day. |
data.current_period.end_date |
string (date) | The period's last day, included. |
data.current_period.allocated_amount |
integer | The limit for this period. |
data.current_period.carried_over_amount |
integer | What the previous period left over, carried into this one when the budget's rollover_type is carry_over. |
data.current_period.spent_amount |
integer | What the tracked transactions of the period add up to, as a positive number; a refund lowers it. Provisional while processing_historical is true. |
data.current_period.remaining_amount |
integer | allocated_amount minus spent_amount, the figure the app shows. It leaves out carried_over_amount, and it is negative once the period goes over its limit. |
data.current_period.processing_historical |
boolean | true while the transactions that were already in the period when the budget was created are still being added to it, in the background. Until then spent_amount and remaining_amount are provisional: read the budget again in a few seconds. |
data.archived_at |
string (date-time) or null | When the budget was archived in the app, or null. An archived budget keeps its history but can no longer change; it can still be deleted. |
data.created_at |
string (date-time) or null | When the budget was created. |
data.updated_at |
string (date-time) or null | When the budget was last changed. |
Example request
curl https://whisper.money/api/v1/budgets/0199a3f2-6c1e-7b4d-9a8e-2f5c1d3b4a60 \
-H "Authorization: Bearer $WHISPER_MONEY_TOKEN"
Example response
{
"data": {
"id": "0199a3f8-4c5d-7e6f-8a9b-0c1d2e3f4a5b",
"name": "Groceries",
"period_type": "monthly",
"period_start_day": 1,
"rollover_type": "reset",
"expense_type": "variable",
"is_catch_all": true,
"categories": [
{
"id": "0199a3f5-2b3c-7d4e-8f5a-6b7c8d9e0f12",
"name": "Groceries"
}
],
"labels": [
{
"id": "0199a3f6-3c4d-7e5f-9a6b-7c8d9e0f1a23",
"name": "Essentials"
}
],
"currency": "EUR",
"current_period": {
"id": "0199a3fa-1b2c-7d3e-8f4a-5b6c7d8e9f01",
"start_date": "2026-10-01",
"end_date": "2026-10-31",
"allocated_amount": 50000,
"carried_over_amount": 0,
"spent_amount": 32050,
"remaining_amount": 17950,
"processing_historical": true
},
"archived_at": null,
"created_at": "2026-01-15T09:30:00+00:00",
"updated_at": "2026-01-20T18:05:00+00:00"
}
}
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). |
404 |
No object with that id exists, or it belongs to someone else. |
429 |
The rate limit is exhausted. Wait the seconds in Retry-After. |
Update a budget
PATCH /api/v1/budgets/{budget}
Changes only the fields you send: the name, the limit and the expense type. A new allocated_amount applies to the period in progress and every later one; past periods keep theirs. period_type, period_start_day, rollover_type, is_catch_all, category_ids and label_ids are fixed, since the budget's periods are calculated over its history: sending any of them is a 422 under that field, and the way to change them is to delete the budget and create it again. An archived budget can no longer change: 422 under budget.
Scope: write. A personal token needs api:write (a read & write token); an OAuth token needs api:use.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
budget |
path | string (uuid) | Yes | The budget's id. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | No | The budget's new name. |
allocated_amount |
integer | No | The new limit, in the minor units of your account's currency. It applies to the period in progress and every later one; past periods keep theirs. |
expense_type |
BudgetExpenseType | No | fixed or variable. A catch-all budget stays variable whatever you send. |
Response
200
| Field | Type | Description |
|---|---|---|
data |
Budget | |
data.id |
string (uuid) | The budget's id. |
data.name |
string | The budget's name. |
data.period_type |
"monthly" | "weekly" | "biweekly" | "yearly" | "quarterly" | How often the budget starts over: monthly, weekly, biweekly, quarterly or yearly. Fixed once created. |
data.period_start_day |
integer or null | The day a period starts: the day of the month (1–31) for a monthly budget, the day of the week (0 is Sunday) for a weekly or biweekly one. Quarterly and yearly budgets follow the calendar. null when not set. Fixed once created. |
data.rollover_type |
"carry_over" | "reset" | What happens to what is left at the end of a period: carry_over adds it to the next period, reset starts every period at the limit. Fixed once created. |
data.expense_type |
"fixed" | "variable" | fixed for spending that repeats at the same amount every period (rent, subscriptions), variable for spending you can adjust (groceries, leisure). It decides how the cashflow analytics split your expenses. A catch-all budget is always variable. |
data.is_catch_all |
boolean | Whether the budget tracks every expense category no other budget tracks. An account has at most one. Fixed once created. |
data.categories |
array of object | The categories the budget tracks; tracking a parent category also tracks its children. Fixed once created. |
data.categories[].id |
string | |
data.categories[].name |
string | |
data.labels |
array of object | The labels the budget tracks. Fixed once created. |
data.labels[].id |
string | |
data.labels[].name |
string | |
data.currency |
string | The currency of the budget's amounts: your account's currency, as an ISO 4217 code. |
data.current_period |
BudgetPeriod or null | The period that covers today, or null when no period does. |
data.current_period.id |
string (uuid) | The period's id. |
data.current_period.start_date |
string (date) | The period's first day. |
data.current_period.end_date |
string (date) | The period's last day, included. |
data.current_period.allocated_amount |
integer | The limit for this period. |
data.current_period.carried_over_amount |
integer | What the previous period left over, carried into this one when the budget's rollover_type is carry_over. |
data.current_period.spent_amount |
integer | What the tracked transactions of the period add up to, as a positive number; a refund lowers it. Provisional while processing_historical is true. |
data.current_period.remaining_amount |
integer | allocated_amount minus spent_amount, the figure the app shows. It leaves out carried_over_amount, and it is negative once the period goes over its limit. |
data.current_period.processing_historical |
boolean | true while the transactions that were already in the period when the budget was created are still being added to it, in the background. Until then spent_amount and remaining_amount are provisional: read the budget again in a few seconds. |
data.archived_at |
string (date-time) or null | When the budget was archived in the app, or null. An archived budget keeps its history but can no longer change; it can still be deleted. |
data.created_at |
string (date-time) or null | When the budget was created. |
data.updated_at |
string (date-time) or null | When the budget was last changed. |
Example request
curl -X PATCH https://whisper.money/api/v1/budgets/0199a3f2-6c1e-7b4d-9a8e-2f5c1d3b4a60 \
-H "Authorization: Bearer $WHISPER_MONEY_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Food",
"allocated_amount": 60000
}'
Example response
{
"data": {
"id": "0199a3f8-4c5d-7e6f-8a9b-0c1d2e3f4a5b",
"name": "Groceries",
"period_type": "monthly",
"period_start_day": 1,
"rollover_type": "reset",
"expense_type": "variable",
"is_catch_all": true,
"categories": [
{
"id": "0199a3f5-2b3c-7d4e-8f5a-6b7c8d9e0f12",
"name": "Groceries"
}
],
"labels": [
{
"id": "0199a3f6-3c4d-7e5f-9a6b-7c8d9e0f1a23",
"name": "Essentials"
}
],
"currency": "EUR",
"current_period": {
"id": "0199a3fa-1b2c-7d3e-8f4a-5b6c7d8e9f01",
"start_date": "2026-10-01",
"end_date": "2026-10-31",
"allocated_amount": 50000,
"carried_over_amount": 0,
"spent_amount": 32050,
"remaining_amount": 17950,
"processing_historical": true
},
"archived_at": null,
"created_at": "2026-01-15T09:30:00+00:00",
"updated_at": "2026-01-20T18:05:00+00:00"
}
}
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). |
404 |
No object with that id exists, or it belongs to someone else. |
422 |
Validation error |
429 |
The rate limit is exhausted. Wait the seconds in Retry-After. |
Delete a budget
DELETE /api/v1/budgets/{budget}
Deletes the budget with its periods and their spending history, archived budgets included. The transactions it tracked are untouched.
Scope: write. A personal token needs api:write (a read & write token); an OAuth token needs api:use.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
budget |
path | string (uuid) | Yes | The budget's id. |
Response
200
| Field | Type | Description |
|---|---|---|
id |
string (uuid) | The deleted budget's id. |
deleted |
boolean | Always true. |
Example request
curl -X DELETE https://whisper.money/api/v1/budgets/0199a3f2-6c1e-7b4d-9a8e-2f5c1d3b4a60 \
-H "Authorization: Bearer $WHISPER_MONEY_TOKEN"
Example response
{
"id": "0199a3f2-6c1e-7b4d-9a8e-2f5c1d3b4a60",
"deleted": true
}
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). |
404 |
No object with that id exists, or it belongs to someone else. |
422 |
Validation error |
429 |
The rate limit is exhausted. Wait the seconds in Retry-After. |