Categories
A category says what a transaction was for. Each transaction has at most one. Categories form a tree up to 3 levels deep through parent_id, and a whole tree shares its root's type and cashflow direction. A budget that tracks a parent category also tracks its children.
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 categories
GET /api/v1/categories
Your categories in a space, newest first, roots and children alike: rebuild the tree from parent_id. As in the app, another member's categories are not listed.
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. |
space |
query | string (uuid) | No | The id of the space to work in (see GET /spaces). Defaults to your personal space. On writes it may also be sent in the JSON body. |
Response
200
| Field | Type | Description |
|---|---|---|
data |
array of Category | |
data[].id |
string (uuid) | The category's id. |
data[].space_id |
string (uuid) or null | The id of the space the category is in. |
data[].name |
string | The category's name, unique among its siblings. |
data[].icon |
string | The name of the icon the app shows for it, a Lucide icon name. |
data[].color |
string | The category's color. |
data[].type |
"income" | "expense" | "transfer" | "savings" | "investment" | What the category's transactions are: income, expense, transfer, savings or investment. A whole tree shares its root's type. |
data[].cashflow_direction |
"hidden" | "inflow" | "outflow" | How the category's transactions count in the cashflow: inflow, outflow, or hidden to leave them out of it. A whole tree shares its root's direction. |
data[].parent_id |
string (uuid) or null | The id of the category it is nested under, or null for a root. |
data[].created_at |
string (date-time) or null | When the category was created. |
has_more |
boolean | Whether more objects exist past this page, in the direction being walked. |
Example request
curl https://whisper.money/api/v1/categories \
-H "Authorization: Bearer $WHISPER_MONEY_TOKEN"
Example response
{
"data": [
{
"id": "0199a3f5-2b3c-7d4e-8f5a-6b7c8d9e0f12",
"space_id": "0199a3f2-6c1e-7b4d-9a8e-2f5c1d3b4a60",
"name": "Groceries",
"icon": "ShoppingBag",
"color": "green",
"type": "expense",
"cashflow_direction": "hidden",
"parent_id": null,
"created_at": "2026-01-15T09:30: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 category
POST /api/v1/categories
A root category takes the type you send, and its cashflow direction follows from it: savings and investment count as an outflow, a transfer keeps the cashflow_direction you send (hidden when you send none) and the rest are left out of the cashflow. A child, created with a parent_id, takes its parent's type and direction whatever you send.
Scope: write. A personal token needs api:write (a read & write token); an OAuth token needs api:use.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
space |
query | string (uuid) | No | The id of the space to work in (see GET /spaces). Defaults to your personal space. On writes it may also be sent in the JSON body. |
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 category's name, unique among its siblings: two categories can share a name only under different parents. Your categories at one level of the tree also need different names across all your spaces. |
icon |
string | Yes | The name of the icon the app shows for it, a Lucide icon name. |
color |
CategoryColor | Yes | The category's color. |
type |
CategoryType | Yes | What the category's transactions are: income, expense, transfer, savings or investment. Ignored on a child, which always takes its parent's type. |
cashflow_direction |
CategoryCashflowDirection | No | How the category's transactions count in the cashflow. Only a root transfer category keeps the one you send: a savings or investment root is always outflow, any other root hidden, and a child takes its parent's. |
parent_id |
string (uuid) or null | No | The id of the category to nest it under, or null for a root. It must be one of your categories in the space. Categories nest up to 3 levels deep, and a category cannot sit under itself or one of its own children. |
Response
201
| Field | Type | Description |
|---|---|---|
data |
Category | |
data.id |
string (uuid) | The category's id. |
data.space_id |
string (uuid) or null | The id of the space the category is in. |
data.name |
string | The category's name, unique among its siblings. |
data.icon |
string | The name of the icon the app shows for it, a Lucide icon name. |
data.color |
string | The category's color. |
data.type |
"income" | "expense" | "transfer" | "savings" | "investment" | What the category's transactions are: income, expense, transfer, savings or investment. A whole tree shares its root's type. |
data.cashflow_direction |
"hidden" | "inflow" | "outflow" | How the category's transactions count in the cashflow: inflow, outflow, or hidden to leave them out of it. A whole tree shares its root's direction. |
data.parent_id |
string (uuid) or null | The id of the category it is nested under, or null for a root. |
data.created_at |
string (date-time) or null | When the category was created. |
Example request
curl -X POST https://whisper.money/api/v1/categories \
-H "Authorization: Bearer $WHISPER_MONEY_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Groceries",
"icon": "ShoppingBag",
"color": "amber",
"type": "income"
}'
Example response
{
"data": {
"id": "0199a3f5-2b3c-7d4e-8f5a-6b7c8d9e0f12",
"space_id": "0199a3f2-6c1e-7b4d-9a8e-2f5c1d3b4a60",
"name": "Groceries",
"icon": "ShoppingBag",
"color": "green",
"type": "expense",
"cashflow_direction": "hidden",
"parent_id": null,
"created_at": "2026-01-15T09:30: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 category
GET /api/v1/categories/{category}
Scope: read. A personal token needs api:read; an OAuth token needs api:use.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
category |
path | string (uuid) | Yes | The category's id. |
space |
query | string (uuid) | No | The id of the space to work in (see GET /spaces). Defaults to your personal space. On writes it may also be sent in the JSON body. |
Response
200
| Field | Type | Description |
|---|---|---|
data |
Category | |
data.id |
string (uuid) | The category's id. |
data.space_id |
string (uuid) or null | The id of the space the category is in. |
data.name |
string | The category's name, unique among its siblings. |
data.icon |
string | The name of the icon the app shows for it, a Lucide icon name. |
data.color |
string | The category's color. |
data.type |
"income" | "expense" | "transfer" | "savings" | "investment" | What the category's transactions are: income, expense, transfer, savings or investment. A whole tree shares its root's type. |
data.cashflow_direction |
"hidden" | "inflow" | "outflow" | How the category's transactions count in the cashflow: inflow, outflow, or hidden to leave them out of it. A whole tree shares its root's direction. |
data.parent_id |
string (uuid) or null | The id of the category it is nested under, or null for a root. |
data.created_at |
string (date-time) or null | When the category was created. |
Example request
curl https://whisper.money/api/v1/categories/0199a3f2-6c1e-7b4d-9a8e-2f5c1d3b4a60 \
-H "Authorization: Bearer $WHISPER_MONEY_TOKEN"
Example response
{
"data": {
"id": "0199a3f5-2b3c-7d4e-8f5a-6b7c8d9e0f12",
"space_id": "0199a3f2-6c1e-7b4d-9a8e-2f5c1d3b4a60",
"name": "Groceries",
"icon": "ShoppingBag",
"color": "green",
"type": "expense",
"cashflow_direction": "hidden",
"parent_id": null,
"created_at": "2026-01-15T09:30: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 |
The request failed validation. |
429 |
The rate limit is exhausted. Wait the seconds in Retry-After. |
Update a category
PATCH /api/v1/categories/{category}
Changes only the fields you send. Moving it under another parent, or making it a root with parent_id: null, re-derives its type and cashflow direction as on create, and its whole subtree follows: its children and their children take its new type and direction. While it has a parent, type and cashflow_direction are ignored.
Scope: write. A personal token needs api:write (a read & write token); an OAuth token needs api:use.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
category |
path | string (uuid) | Yes | The category's id. |
space |
query | string (uuid) | No | The id of the space to work in (see GET /spaces). Defaults to your personal space. On writes it may also be sent in the JSON body. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | No | The category's name, unique among its siblings: two categories can share a name only under different parents. Your categories at one level of the tree also need different names across all your spaces. |
icon |
string | No | The name of the icon the app shows for it, a Lucide icon name. |
color |
CategoryColor | No | The category's color. |
type |
CategoryType | No | What the category's transactions are: income, expense, transfer, savings or investment. Ignored on a child, which always takes its parent's type. |
cashflow_direction |
CategoryCashflowDirection | No | How the category's transactions count in the cashflow. Only a root transfer category keeps the one you send: a savings or investment root is always outflow, any other root hidden, and a child takes its parent's. |
parent_id |
string (uuid) or null | No | The id of the category to nest it under, or null for a root. It must be one of your categories in the space. Categories nest up to 3 levels deep, and a category cannot sit under itself or one of its own children. |
Response
200
| Field | Type | Description |
|---|---|---|
data |
Category | |
data.id |
string (uuid) | The category's id. |
data.space_id |
string (uuid) or null | The id of the space the category is in. |
data.name |
string | The category's name, unique among its siblings. |
data.icon |
string | The name of the icon the app shows for it, a Lucide icon name. |
data.color |
string | The category's color. |
data.type |
"income" | "expense" | "transfer" | "savings" | "investment" | What the category's transactions are: income, expense, transfer, savings or investment. A whole tree shares its root's type. |
data.cashflow_direction |
"hidden" | "inflow" | "outflow" | How the category's transactions count in the cashflow: inflow, outflow, or hidden to leave them out of it. A whole tree shares its root's direction. |
data.parent_id |
string (uuid) or null | The id of the category it is nested under, or null for a root. |
data.created_at |
string (date-time) or null | When the category was created. |
Example request
curl -X PATCH https://whisper.money/api/v1/categories/0199a3f2-6c1e-7b4d-9a8e-2f5c1d3b4a60 \
-H "Authorization: Bearer $WHISPER_MONEY_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Groceries",
"icon": "ShoppingBag"
}'
Example response
{
"data": {
"id": "0199a3f5-2b3c-7d4e-8f5a-6b7c8d9e0f12",
"space_id": "0199a3f2-6c1e-7b4d-9a8e-2f5c1d3b4a60",
"name": "Groceries",
"icon": "ShoppingBag",
"color": "green",
"type": "expense",
"cashflow_direction": "hidden",
"parent_id": null,
"created_at": "2026-01-15T09:30: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 |
The request failed validation. |
429 |
The rate limit is exhausted. Wait the seconds in Retry-After. |
Delete a category
DELETE /api/v1/categories/{category}
Deletes the category and takes it off its transactions, which are left uncategorized. strategy decides what happens to its children; when moving them would leave two categories with the same name at one level, nothing is deleted and the answer is a 422 under strategy: rename one first.
Scope: write. A personal token needs api:write (a read & write token); an OAuth token needs api:use.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
category |
path | string (uuid) | Yes | The category's id. |
strategy |
query | CategoryDeletionStrategy | No | What happens to the category's children: reparent moves them up to its parent (or makes them roots when it is one), promote makes them roots, cascade deletes them too, with their own children. Defaults to reparent. |
space |
query | string (uuid) | No | The id of the space to work in (see GET /spaces). Defaults to your personal space. On writes it may also be sent in the JSON body. |
Response
200
| Field | Type | Description |
|---|---|---|
id |
string (uuid) | The deleted category's id. |
deleted |
boolean | Always true. |
strategy |
"reparent" | "promote" | "cascade" | The strategy that was applied to the children. |
Example request
curl -X DELETE https://whisper.money/api/v1/categories/0199a3f2-6c1e-7b4d-9a8e-2f5c1d3b4a60 \
-H "Authorization: Bearer $WHISPER_MONEY_TOKEN"
Example response
{
"id": "0199a3f2-6c1e-7b4d-9a8e-2f5c1d3b4a60",
"deleted": true,
"strategy": "reparent"
}
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 |
The request failed validation. |
429 |
The rate limit is exhausted. Wait the seconds in Retry-After. |