{"openapi":"3.0.0","paths":{"/oauth/token":{"post":{"operationId":"issueToken","summary":"Exchange an authorization code or refresh a token","parameters":[],"requestBody":{"required":true,"content":{"application/x-www-form-urlencoded":{"schema":{"$ref":"#/components/schemas/OAuthTokenRequest"}},"application/json":{"schema":{"$ref":"#/components/schemas/OAuthTokenRequest"}}},"description":"Token request parameters."},"responses":{"200":{"description":"A new access token and refresh token pair.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthTokenResponse"}}}},"400":{"description":"`invalid_request` (missing or contradictory parameter), `invalid_grant` (code or refresh token invalid, expired, already used, or the connection is no longer active), `unsupported_grant_type`, `invalid_scope`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthErrorResponse"}}}},"401":{"description":"`invalid_client` — unknown or blocked client, a wrong secret, or a secret sent by a public client.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthErrorResponse"}}}},"404":{"description":"The public API is not enabled.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthErrorResponse"}}}},"429":{"description":"`slow_down` — more than 30 token requests per minute for this client (a public client is counted only on requests with its valid code or refresh token). Retry after 60 seconds.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthErrorResponse"}}}},"503":{"description":"`temporarily_unavailable` — retry shortly.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthErrorResponse"}}}}},"tags":["OAuth"],"description":"Accepts `application/x-www-form-urlencoded` (standard) or JSON.\n\nClient authentication: HTTP Basic (`client_id:client_secret`) or `client_id` + `client_secret` in the body. A public client (no secret) sends only `client_id` and relies on PKCE.\n\n- `grant_type=authorization_code`: `code`, `redirect_uri` (exactly as in the authorize request) and `code_verifier` are required. A code is valid for 10 minutes and can be used once — a failed attempt burns it too.\n- `grant_type=refresh_token`: `refresh_token` is required. The response carries a NEW refresh token; the old one stops working. Presenting an already used refresh token is treated as theft and revokes the whole connection — refresh from one place only.\n\nLimit: 30 requests per minute per client."}},"/oauth/revoke":{"post":{"operationId":"revokeToken","summary":"Revoke a token (disconnect)","parameters":[],"requestBody":{"required":true,"content":{"application/x-www-form-urlencoded":{"schema":{"$ref":"#/components/schemas/OAuthRevokeRequest"}},"application/json":{"schema":{"$ref":"#/components/schemas/OAuthRevokeRequest"}}},"description":"The token to revoke, plus client authentication."},"responses":{"200":{"description":"Revoked (or nothing to revoke).","content":{"application/json":{"schema":{"type":"object","properties":{},"example":{}}}}},"400":{"description":"`invalid_request` (missing or contradictory parameter), `invalid_grant` (code or refresh token invalid, expired, already used, or the connection is no longer active), `unsupported_grant_type`, `invalid_scope`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthErrorResponse"}}}},"401":{"description":"`invalid_client` — unknown or blocked client, a wrong secret, or a secret sent by a public client.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthErrorResponse"}}}},"404":{"description":"The public API is not enabled.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthErrorResponse"}}}},"429":{"description":"`slow_down` — more than 30 token requests per minute for this client (a public client is counted only on requests with its valid code or refresh token). Retry after 60 seconds.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthErrorResponse"}}}},"503":{"description":"`temporarily_unavailable` — retry shortly.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthErrorResponse"}}}}},"tags":["OAuth"],"description":"RFC 7009. Revoking an access or a refresh token revokes the whole connection (every token of it). Always answers `200 {}` once the client is authenticated, whether or not the token existed."}},"/public/v1/account":{"get":{"operationId":"getAccount","summary":"Get the connected account","parameters":[{"name":"X-Account-Id","in":"header","required":false,"schema":{"type":"string","format":"uuid"},"description":"Which account of the connection the call is about. Optional when the connection has exactly one account; required when it has several (`400 account_required`), counted on the granted accounts, not the usable ones. An account that is not part of the connection answers `403 account_not_granted`. Take the ids from `GET /public/v1/accounts`."}],"responses":{"200":{"description":"The connected account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicAccount"}}}},"400":{"description":"`validation_failed` — the request is malformed (unknown parameter, wrong type or format, missing required field); `details.fields` lists the problems per field. `invalid_cursor` — the cursor was not taken unchanged from a previous response. A well-formed request that refers to records outside the connected account, or to fields a shared group owns, is `422`, not `400`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"401":{"description":"`invalid_token` — the access token is missing, invalid, expired or revoked. Refresh it once; if refreshing fails with `invalid_grant`, the user has to connect your app again.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"403":{"description":"`insufficient_scope` (the connection lacks a scope this request needs; `details.required` lists it), `grant_inactive` (the user who connected the app can no longer edit this account), `account_encrypted` (an end-to-end encrypted account: read-only, no merchant/search filters, no receipts).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"404":{"description":"`not_found` — no such record in the connected account (or it was deleted).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"429":{"description":"`rate_limited` — too many requests. Wait the number of seconds in the `Retry-After` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"503":{"description":"`unavailable` — a temporary failure on our side. Retry shortly with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}}},"tags":["Account"],"security":[{"oauth2":[]}],"description":"The account addressed by `X-Account-Id` (or the only account of the connection) and the user who connected it. Works with any valid token, whatever its scopes; with several accounts the header is required. `read_only: true` means the account is end-to-end encrypted: reads work with some fields withheld, writes answer `403 account_encrypted`."}},"/public/v1/accounts":{"get":{"operationId":"listAccounts","summary":"List the connection's accounts","parameters":[],"responses":{"200":{"description":"The accounts of the connection.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicConnectedAccountList"}}}},"400":{"description":"`validation_failed` — the request is malformed (unknown parameter, wrong type or format, missing required field); `details.fields` lists the problems per field. `invalid_cursor` — the cursor was not taken unchanged from a previous response. A well-formed request that refers to records outside the connected account, or to fields a shared group owns, is `422`, not `400`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"401":{"description":"`invalid_token` — the access token is missing, invalid, expired or revoked. Refresh it once; if refreshing fails with `invalid_grant`, the user has to connect your app again.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"403":{"description":"`insufficient_scope` (the connection lacks a scope this request needs; `details.required` lists it), `grant_inactive` (the user who connected the app can no longer edit this account), `account_encrypted` (an end-to-end encrypted account: read-only, no merchant/search filters, no receipts).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"404":{"description":"`not_found` — no such record in the connected account (or it was deleted).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"429":{"description":"`rate_limited` — too many requests. Wait the number of seconds in the `Retry-After` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"503":{"description":"`unavailable` — a temporary failure on our side. Retry shortly with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}}},"tags":["Account"],"security":[{"oauth2":[]}],"description":"Every account this connection covers, usable or not. Works with any valid token and needs no `X-Account-Id`. `access` is `read_write` only when the app holds `expenses:write` and the account allows writes; `active: false` with a `reason` (`role`, `encrypted`) means requests addressed to it are refused; `reason: archived` is an archived trip (active, read-only). Send the chosen `id` as `X-Account-Id` on every other call."}},"/public/v1/budgets":{"get":{"operationId":"listBudgets","summary":"List budgets","parameters":[{"name":"X-Account-Id","in":"header","required":false,"schema":{"type":"string","format":"uuid"},"description":"Which account of the connection the call is about. Optional when the connection has exactly one account; required when it has several (`400 account_required`), counted on the granted accounts, not the usable ones. An account that is not part of the connection answers `403 account_not_granted`. Take the ids from `GET /public/v1/accounts`."}],"responses":{"200":{"description":"The budgets of the account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicBudgetList"}}}},"400":{"description":"`validation_failed` — the request is malformed (unknown parameter, wrong type or format, missing required field); `details.fields` lists the problems per field. `invalid_cursor` — the cursor was not taken unchanged from a previous response. A well-formed request that refers to records outside the connected account, or to fields a shared group owns, is `422`, not `400`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"401":{"description":"`invalid_token` — the access token is missing, invalid, expired or revoked. Refresh it once; if refreshing fails with `invalid_grant`, the user has to connect your app again.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"403":{"description":"`insufficient_scope` (the connection lacks a scope this request needs; `details.required` lists it), `grant_inactive` (the user who connected the app can no longer edit this account), `account_encrypted` (an end-to-end encrypted account: read-only, no merchant/search filters, no receipts).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"404":{"description":"`not_found` — no such record in the connected account (or it was deleted).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"429":{"description":"`rate_limited` — too many requests. Wait the number of seconds in the `Retry-After` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"503":{"description":"`unavailable` — a temporary failure on our side. Retry shortly with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}}},"tags":["Budgets"],"security":[{"oauth2":["budgets:read"]}],"description":"All live budgets of the account, newest first, each with its progress for the CURRENT period. Budgets are few per account, so there is no pagination and no changes feed.\n\nProgress is in the budget's own currency and is never converted; a monthly budget follows the account's financial-month start day."},"post":{"operationId":"createBudget","summary":"Create a budget","parameters":[{"name":"X-Account-Id","in":"header","required":false,"schema":{"type":"string","format":"uuid"},"description":"Which account of the connection the call is about. Optional when the connection has exactly one account; required when it has several (`400 account_required`), counted on the granted accounts, not the usable ones. An account that is not part of the connection answers `403 account_not_granted`. Take the ids from `GET /public/v1/accounts`."},{"name":"Idempotency-Key","in":"header","required":true,"schema":{"type":"string","format":"uuid","example":"b7e3c1a9-4f2d-4e8b-9a6c-0d5f7e1b3a24"},"description":"A UUID you generate for this budget and reuse on every retry of the same request. A repeat returns the budget already created (`200`, the repeated body is ignored). Send retries one after another, not in parallel. A key already used by another record answers `409 idempotency_key_conflict`."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicBudgetCreateDto"}}},"description":"The new budget."},"responses":{"200":{"description":"Already created earlier with this `Idempotency-Key`: the original budget, unchanged.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicBudget"}}}},"201":{"description":"Created.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicBudget"}}}},"400":{"description":"`validation_failed` — the request is malformed (unknown parameter, wrong type or format, missing required field); `details.fields` lists the problems per field. `invalid_cursor` — the cursor was not taken unchanged from a previous response. A well-formed request that refers to records outside the connected account, or to fields a shared group owns, is `422`, not `400`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"401":{"description":"`invalid_token` — the access token is missing, invalid, expired or revoked. Refresh it once; if refreshing fails with `invalid_grant`, the user has to connect your app again.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"403":{"description":"`insufficient_scope` (the connection lacks a scope this request needs; `details.required` lists it), `grant_inactive` (the user who connected the app can no longer edit this account), `account_encrypted` (an end-to-end encrypted account: read-only, no merchant/search filters, no receipts).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"404":{"description":"`not_found` — no such record in the connected account (or it was deleted).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"409":{"description":"`idempotency_key_conflict` — the key belongs to a record your app did not create, or to a budget that has since been deleted. Use a new UUID.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"422":{"description":"`validation_failed` — an allocation `category_id` is not an expense category of this account; `details.fields` lists `allocations[i].category_id`. Also: a `category_id` repeated in `allocations` (the repeats are named as `allocations[j].category_id`, `duplicate category_id`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"429":{"description":"`rate_limited` — too many requests. Wait the number of seconds in the `Retry-After` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"503":{"description":"`unavailable` — a temporary failure on our side. Retry shortly with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}}},"tags":["Budgets"],"security":[{"oauth2":["budgets:write"]}],"description":"Requires the `Idempotency-Key` header. Amounts are decimal strings (`\"1500.00\"`), never JSON numbers. A field that is not listed in the schema is refused with `400 validation_failed`.\n\nA budget has no category of its own: it covers all spending in its currency unless you send `allocations`, and then only those categories (and their subcategories). Every `category_id` must be one of the account's expense categories (`GET /public/v1/categories?type=expense`); otherwise `422 validation_failed` names `allocations[i].category_id`. Allocation amounts are not required to add up to the budget amount. `currency` defaults to the account's base currency."}},"/public/v1/budgets/{id}":{"get":{"operationId":"getBudget","summary":"Get a budget","parameters":[{"name":"X-Account-Id","in":"header","required":false,"schema":{"type":"string","format":"uuid"},"description":"Which account of the connection the call is about. Optional when the connection has exactly one account; required when it has several (`400 account_required`), counted on the granted accounts, not the usable ones. An account that is not part of the connection answers `403 account_not_granted`. Take the ids from `GET /public/v1/accounts`."},{"name":"id","required":true,"in":"path","schema":{"format":"uuid","example":"4c8e2a6f-1b3d-4f5a-9c7e-0a2b4d6f8e10","type":"string"},"description":"The budget id."}],"responses":{"200":{"description":"The budget.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicBudget"}}}},"400":{"description":"`validation_failed` — the request is malformed (unknown parameter, wrong type or format, missing required field); `details.fields` lists the problems per field. `invalid_cursor` — the cursor was not taken unchanged from a previous response. A well-formed request that refers to records outside the connected account, or to fields a shared group owns, is `422`, not `400`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"401":{"description":"`invalid_token` — the access token is missing, invalid, expired or revoked. Refresh it once; if refreshing fails with `invalid_grant`, the user has to connect your app again.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"403":{"description":"`insufficient_scope` (the connection lacks a scope this request needs; `details.required` lists it), `grant_inactive` (the user who connected the app can no longer edit this account), `account_encrypted` (an end-to-end encrypted account: read-only, no merchant/search filters, no receipts).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"404":{"description":"`not_found` — no such record in the connected account (or it was deleted).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"429":{"description":"`rate_limited` — too many requests. Wait the number of seconds in the `Retry-After` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"503":{"description":"`unavailable` — a temporary failure on our side. Retry shortly with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}}},"tags":["Budgets"],"security":[{"oauth2":["budgets:read"]}],"description":"One budget with its progress for the current period."},"patch":{"operationId":"updateBudget","summary":"Edit a budget","parameters":[{"name":"X-Account-Id","in":"header","required":false,"schema":{"type":"string","format":"uuid"},"description":"Which account of the connection the call is about. Optional when the connection has exactly one account; required when it has several (`400 account_required`), counted on the granted accounts, not the usable ones. An account that is not part of the connection answers `403 account_not_granted`. Take the ids from `GET /public/v1/accounts`."},{"name":"id","required":true,"in":"path","schema":{"format":"uuid","example":"4c8e2a6f-1b3d-4f5a-9c7e-0a2b4d6f8e10","type":"string"},"description":"The budget id."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicBudgetUpdateDto"}}},"description":"The fields to change."},"responses":{"200":{"description":"The updated budget.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicBudget"}}}},"400":{"description":"`validation_failed` — the request is malformed (unknown parameter, wrong type or format, missing required field); `details.fields` lists the problems per field. `invalid_cursor` — the cursor was not taken unchanged from a previous response. A well-formed request that refers to records outside the connected account, or to fields a shared group owns, is `422`, not `400`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"401":{"description":"`invalid_token` — the access token is missing, invalid, expired or revoked. Refresh it once; if refreshing fails with `invalid_grant`, the user has to connect your app again.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"403":{"description":"`insufficient_scope` (the connection lacks a scope this request needs; `details.required` lists it), `grant_inactive` (the user who connected the app can no longer edit this account), `account_encrypted` (an end-to-end encrypted account: read-only, no merchant/search filters, no receipts).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"404":{"description":"`not_found` — no such record in the connected account (or it was deleted).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"422":{"description":"`validation_failed` — an allocation `category_id` is not an expense category of this account; `details.fields` lists `allocations[i].category_id`. Also: a `category_id` repeated in `allocations` (the repeats are named as `allocations[j].category_id`, `duplicate category_id`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"429":{"description":"`rate_limited` — too many requests. Wait the number of seconds in the `Retry-After` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"503":{"description":"`unavailable` — a temporary failure on our side. Retry shortly with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}}},"tags":["Budgets"],"security":[{"oauth2":["budgets:write"]}],"description":"Partial update: a field you omit is left unchanged, and no field can be `null`. `allocations` REPLACES the whole set of category allocations (`[]` removes them all, so the budget covers all spending again); omit it to keep the current allocations."},"delete":{"operationId":"deleteBudget","summary":"Delete a budget","parameters":[{"name":"X-Account-Id","in":"header","required":false,"schema":{"type":"string","format":"uuid"},"description":"Which account of the connection the call is about. Optional when the connection has exactly one account; required when it has several (`400 account_required`), counted on the granted accounts, not the usable ones. An account that is not part of the connection answers `403 account_not_granted`. Take the ids from `GET /public/v1/accounts`."},{"name":"id","required":true,"in":"path","schema":{"format":"uuid","example":"4c8e2a6f-1b3d-4f5a-9c7e-0a2b4d6f8e10","type":"string"},"description":"The budget id."}],"responses":{"204":{"description":"Deleted."},"400":{"description":"`validation_failed` — the request is malformed (unknown parameter, wrong type or format, missing required field); `details.fields` lists the problems per field. `invalid_cursor` — the cursor was not taken unchanged from a previous response. A well-formed request that refers to records outside the connected account, or to fields a shared group owns, is `422`, not `400`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"401":{"description":"`invalid_token` — the access token is missing, invalid, expired or revoked. Refresh it once; if refreshing fails with `invalid_grant`, the user has to connect your app again.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"403":{"description":"`insufficient_scope` (the connection lacks a scope this request needs; `details.required` lists it), `grant_inactive` (the user who connected the app can no longer edit this account), `account_encrypted` (an end-to-end encrypted account: read-only, no merchant/search filters, no receipts).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"404":{"description":"`not_found` — no such record in the connected account (or it was deleted).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"429":{"description":"`rate_limited` — too many requests. Wait the number of seconds in the `Retry-After` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"503":{"description":"`unavailable` — a temporary failure on our side. Retry shortly with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}}},"tags":["Budgets"],"security":[{"oauth2":["budgets:write"]}],"description":"Deletes the budget, as deleting it in the app does. Deleting it again answers `404 not_found`."}},"/public/v1/categories":{"get":{"operationId":"listCategories","summary":"List categories","parameters":[{"name":"X-Account-Id","in":"header","required":false,"schema":{"type":"string","format":"uuid"},"description":"Which account of the connection the call is about. Optional when the connection has exactly one account; required when it has several (`400 account_required`), counted on the granted accounts, not the usable ones. An account that is not part of the connection answers `403 account_not_granted`. Take the ids from `GET /public/v1/accounts`."},{"name":"type","required":false,"in":"query","schema":{"default":"expense","example":"income","enum":["expense","income"],"type":"string"},"description":"Which categories to list: `expense` (default) or `income`."}],"responses":{"200":{"description":"All categories of the requested type for the account (not paginated).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicCategoryList"}}}},"400":{"description":"`validation_failed` — the request is malformed (unknown parameter, wrong type or format, missing required field); `details.fields` lists the problems per field. `invalid_cursor` — the cursor was not taken unchanged from a previous response. A well-formed request that refers to records outside the connected account, or to fields a shared group owns, is `422`, not `400`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"401":{"description":"`invalid_token` — the access token is missing, invalid, expired or revoked. Refresh it once; if refreshing fails with `invalid_grant`, the user has to connect your app again.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"403":{"description":"`insufficient_scope` (the connection lacks a scope this request needs; `details.required` lists it), `grant_inactive` (the user who connected the app can no longer edit this account), `account_encrypted` (an end-to-end encrypted account: read-only, no merchant/search filters, no receipts).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"404":{"description":"`not_found` — no such record in the connected account (or it was deleted).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"429":{"description":"`rate_limited` — too many requests. Wait the number of seconds in the `Retry-After` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"503":{"description":"`unavailable` — a temporary failure on our side. Retry shortly with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}}},"tags":["Categories"],"security":[{"oauth2":["categories:read"]}],"description":"The account's own categories of the requested `type` (`expense` by default, or `income`). With the default type this is exactly what an expense write accepts as `category_id`; with `type=income` it is what an income write accepts. On an end-to-end encrypted account `name` is `null` and listed in that category's `encrypted_fields`."}},"/public/v1/expenses":{"get":{"operationId":"listExpenses","summary":"List expenses","parameters":[{"name":"X-Account-Id","in":"header","required":false,"schema":{"type":"string","format":"uuid"},"description":"Which account of the connection the call is about. Optional when the connection has exactly one account; required when it has several (`400 account_required`), counted on the granted accounts, not the usable ones. An account that is not part of the connection answers `403 account_not_granted`. Take the ids from `GET /public/v1/accounts`."},{"name":"date_from","required":false,"in":"query","schema":{"format":"date","example":"2026-10-01","type":"string"},"description":"Only expenses on or after this date (`YYYY-MM-DD`, inclusive)."},{"name":"date_to","required":false,"in":"query","schema":{"format":"date","example":"2026-10-31","type":"string"},"description":"Only expenses on or before this date (`YYYY-MM-DD`, inclusive)."},{"name":"category_id","required":false,"in":"query","schema":{"format":"uuid","example":"8a6e0f52-1c3b-4d7e-a9f8-2b4c6d8e0f13","type":"string"},"description":"Only expenses whose own `category_id` is this one (category splits are not searched)."},{"name":"merchant","required":false,"in":"query","schema":{"minLength":1,"maxLength":200,"example":"Biedronka","type":"string"},"description":"Case-insensitive \"contains\" match on the merchant."},{"name":"project_id","required":false,"in":"query","schema":{"format":"uuid","example":"5e4d3c2b-1a09-4f8e-b7d6-c5b4a3928170","type":"string"},"description":"Only expenses linked to this project."},{"name":"search","required":false,"in":"query","schema":{"minLength":1,"maxLength":200,"example":"coffee","type":"string"},"description":"Case-insensitive \"contains\" match on the description or the notes."},{"name":"limit","required":false,"in":"query","schema":{"minimum":1,"maximum":200,"default":50,"example":50,"type":"integer"},"description":"Page size, 1–200 (default 50)."},{"name":"cursor","required":false,"in":"query","schema":{"maxLength":1024,"example":"eyJ1IjoiMjAyNi0xMC0zMVQxMjozNDo1Ni43ODlaIiwiaSI6IjNmMmI4YzFlIn0","type":"string"},"description":"`next_cursor` from the previous page, unchanged."}],"responses":{"200":{"description":"One page of expenses.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicExpenseList"}}}},"400":{"description":"`validation_failed` — the request is malformed (unknown parameter, wrong type or format, missing required field); `details.fields` lists the problems per field. `invalid_cursor` — the cursor was not taken unchanged from a previous response. A well-formed request that refers to records outside the connected account, or to fields a shared group owns, is `422`, not `400`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"401":{"description":"`invalid_token` — the access token is missing, invalid, expired or revoked. Refresh it once; if refreshing fails with `invalid_grant`, the user has to connect your app again.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"403":{"description":"`insufficient_scope` (the connection lacks a scope this request needs; `details.required` lists it), `grant_inactive` (the user who connected the app can no longer edit this account), `account_encrypted` (an end-to-end encrypted account: read-only, no merchant/search filters, no receipts).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"404":{"description":"`not_found` — no such record in the connected account (or it was deleted).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"429":{"description":"`rate_limited` — too many requests. Wait the number of seconds in the `Retry-After` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"503":{"description":"`unavailable` — a temporary failure on our side. Retry shortly with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}}},"tags":["Expenses"],"security":[{"oauth2":["expenses:read"]}],"description":"Newest first (by `date`, then `id`), paginated with an opaque cursor: pass `next_cursor` as `cursor` with the SAME filters until `has_more` is `false`. The list shape has no `items`; fetch one expense for its line items. Planned expenses are never returned.\n\nOn an end-to-end encrypted account the `merchant` and `search` filters answer `403 account_encrypted`.\n\nTo keep a copy in sync, use `GET /public/v1/expenses/changes` instead of re-listing."},"post":{"operationId":"createExpense","summary":"Create an expense","parameters":[{"name":"X-Account-Id","in":"header","required":false,"schema":{"type":"string","format":"uuid"},"description":"Which account of the connection the call is about. Optional when the connection has exactly one account; required when it has several (`400 account_required`), counted on the granted accounts, not the usable ones. An account that is not part of the connection answers `403 account_not_granted`. Take the ids from `GET /public/v1/accounts`."},{"name":"Idempotency-Key","in":"header","required":true,"schema":{"type":"string","format":"uuid","example":"b7e3c1a9-4f2d-4e8b-9a6c-0d5f7e1b3a24"},"description":"A UUID you generate for this expense and reuse on every retry of the same request. A repeat returns the expense already created (`200`, the repeated body is ignored). Send retries one after another, not in parallel. A key already used by another record answers `409 idempotency_key_conflict`."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicExpenseCreateDto"}}},"description":"The new expense."},"responses":{"200":{"description":"Already created earlier with this `Idempotency-Key`: the original expense, unchanged.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicExpense"}}}},"201":{"description":"Created.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicExpense"}}}},"400":{"description":"`validation_failed` — the request is malformed (unknown parameter, wrong type or format, missing required field); `details.fields` lists the problems per field. `invalid_cursor` — the cursor was not taken unchanged from a previous response. A well-formed request that refers to records outside the connected account, or to fields a shared group owns, is `422`, not `400`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"401":{"description":"`invalid_token` — the access token is missing, invalid, expired or revoked. Refresh it once; if refreshing fails with `invalid_grant`, the user has to connect your app again.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"403":{"description":"`insufficient_scope` (the connection lacks a scope this request needs; `details.required` lists it), `grant_inactive` (the user who connected the app can no longer edit this account), `account_encrypted` (an end-to-end encrypted account: read-only, no merchant/search filters, no receipts).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"404":{"description":"`not_found` — no such record in the connected account (or it was deleted).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"409":{"description":"`idempotency_key_conflict` — the key belongs to a record your app did not create, or to an expense that has since been deleted. Use a new UUID.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"422":{"description":"`validation_failed` — `category_id`, `tag_ids` or `project_id` is not a live record of this account; `details.fields` lists them.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"429":{"description":"`rate_limited` — too many requests. Wait the number of seconds in the `Retry-After` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"503":{"description":"`unavailable` — a temporary failure on our side. Retry shortly with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}}},"tags":["Expenses"],"security":[{"oauth2":["expenses:write"]}],"description":"Requires the `Idempotency-Key` header. Amounts are decimal strings (`\"42.50\"`), never JSON numbers. A field that is not listed in the schema is refused with `400 validation_failed`.\n\n`category_id`, `tag_ids` and `project_id` must belong to the connected account (`422 validation_failed` otherwise, with `details.fields`). `currency` defaults to the account's base currency."}},"/public/v1/expenses/changes":{"get":{"operationId":"listExpenseChanges","summary":"Changes feed (incremental sync)","parameters":[{"name":"X-Account-Id","in":"header","required":false,"schema":{"type":"string","format":"uuid"},"description":"Which account of the connection the call is about. Optional when the connection has exactly one account; required when it has several (`400 account_required`), counted on the granted accounts, not the usable ones. An account that is not part of the connection answers `403 account_not_granted`. Take the ids from `GET /public/v1/accounts`."},{"name":"limit","required":false,"in":"query","schema":{"minimum":1,"maximum":200,"default":200,"example":200,"type":"integer"},"description":"Page size, 1–200 (default 200)."},{"name":"cursor","required":false,"in":"query","schema":{"maxLength":1024,"example":"eyJ1IjoiMjAyNi0xMC0zMVQxMjozNDo1Ni43ODlaIiwiaSI6IjNmMmI4YzFlIn0","type":"string"},"description":"`next_cursor` from the previous page, unchanged. Omit it to start from the beginning."}],"responses":{"200":{"description":"One page of changes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicExpenseChanges"}}}},"400":{"description":"`validation_failed` — the request is malformed (unknown parameter, wrong type or format, missing required field); `details.fields` lists the problems per field. `invalid_cursor` — the cursor was not taken unchanged from a previous response. A well-formed request that refers to records outside the connected account, or to fields a shared group owns, is `422`, not `400`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"401":{"description":"`invalid_token` — the access token is missing, invalid, expired or revoked. Refresh it once; if refreshing fails with `invalid_grant`, the user has to connect your app again.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"403":{"description":"`insufficient_scope` (the connection lacks a scope this request needs; `details.required` lists it), `grant_inactive` (the user who connected the app can no longer edit this account), `account_encrypted` (an end-to-end encrypted account: read-only, no merchant/search filters, no receipts).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"404":{"description":"`not_found` — no such record in the connected account (or it was deleted).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"429":{"description":"`rate_limited` — too many requests. Wait the number of seconds in the `Retry-After` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"503":{"description":"`unavailable` — a temporary failure on our side. Retry shortly with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}}},"tags":["Expenses"],"security":[{"oauth2":["expenses:read"]}],"description":"Every change to the account's expenses after `cursor`, oldest first. Each entry is an `upsert` (the full expense, with line items) or a `delete` (just the `id`).\n\n- Without `cursor` the feed starts from the beginning: every expense, plus deletions from the past — deleting an id you never had is a no-op.\n- An expense that is deleted, or turned into a planned expense, arrives as `delete`.\n- An expense edited again moves to the end of the feed and arrives again with its latest state.\n- A change appears in the feed about 10 seconds after it was made.\n- Store `next_cursor` after processing each page (it is present even on an empty page) and continue from it; repeat while `has_more` is `true`.\n\nKnown limitations: an expense moved to another account is not reported as deleted in this one; renaming a tag or project does not re-emit the expenses that carry it. If names matter, do a full resync now and then."}},"/public/v1/expenses/{id}/receipt":{"get":{"operationId":"getExpenseReceipt","summary":"Download the receipt file","parameters":[{"name":"X-Account-Id","in":"header","required":false,"schema":{"type":"string","format":"uuid"},"description":"Which account of the connection the call is about. Optional when the connection has exactly one account; required when it has several (`400 account_required`), counted on the granted accounts, not the usable ones. An account that is not part of the connection answers `403 account_not_granted`. Take the ids from `GET /public/v1/accounts`."},{"name":"id","required":true,"in":"path","schema":{"format":"uuid","example":"3f2b8c1e-7d4a-4f6b-9c2e-1a5d8e9f0b37","type":"string"},"description":"The expense id."}],"responses":{"200":{"description":"The receipt file.","content":{"image/jpeg":{"schema":{"type":"string","format":"binary"}},"image/png":{"schema":{"type":"string","format":"binary"}},"image/gif":{"schema":{"type":"string","format":"binary"}},"image/webp":{"schema":{"type":"string","format":"binary"}},"image/heic":{"schema":{"type":"string","format":"binary"}},"image/heif":{"schema":{"type":"string","format":"binary"}},"application/pdf":{"schema":{"type":"string","format":"binary"}}}},"400":{"description":"`validation_failed` — the request is malformed (unknown parameter, wrong type or format, missing required field); `details.fields` lists the problems per field. `invalid_cursor` — the cursor was not taken unchanged from a previous response. A well-formed request that refers to records outside the connected account, or to fields a shared group owns, is `422`, not `400`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"401":{"description":"`invalid_token` — the access token is missing, invalid, expired or revoked. Refresh it once; if refreshing fails with `invalid_grant`, the user has to connect your app again.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"403":{"description":"`insufficient_scope` (the connection lacks a scope this request needs; `details.required` lists it), `grant_inactive` (the user who connected the app can no longer edit this account), `account_encrypted` (an end-to-end encrypted account: read-only, no merchant/search filters, no receipts).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"404":{"description":"`not_found` — no such record in the connected account (or it was deleted).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"429":{"description":"`rate_limited` — too many requests. Wait the number of seconds in the `Retry-After` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"503":{"description":"`unavailable` — a temporary failure on our side. Retry shortly with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}}},"tags":["Expenses"],"security":[{"oauth2":["receipts:read"]}],"description":"The original receipt image or PDF attached to the expense (`has_receipt: true`). The `Content-Type` is detected from the file itself. `404 not_found` when the expense has no receipt; `403 account_encrypted` on an end-to-end encrypted account."}},"/public/v1/expenses/{id}":{"get":{"operationId":"getExpense","summary":"Get an expense","parameters":[{"name":"X-Account-Id","in":"header","required":false,"schema":{"type":"string","format":"uuid"},"description":"Which account of the connection the call is about. Optional when the connection has exactly one account; required when it has several (`400 account_required`), counted on the granted accounts, not the usable ones. An account that is not part of the connection answers `403 account_not_granted`. Take the ids from `GET /public/v1/accounts`."},{"name":"id","required":true,"in":"path","schema":{"format":"uuid","example":"3f2b8c1e-7d4a-4f6b-9c2e-1a5d8e9f0b37","type":"string"},"description":"The expense id."}],"responses":{"200":{"description":"The expense.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicExpense"}}}},"400":{"description":"`validation_failed` — the request is malformed (unknown parameter, wrong type or format, missing required field); `details.fields` lists the problems per field. `invalid_cursor` — the cursor was not taken unchanged from a previous response. A well-formed request that refers to records outside the connected account, or to fields a shared group owns, is `422`, not `400`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"401":{"description":"`invalid_token` — the access token is missing, invalid, expired or revoked. Refresh it once; if refreshing fails with `invalid_grant`, the user has to connect your app again.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"403":{"description":"`insufficient_scope` (the connection lacks a scope this request needs; `details.required` lists it), `grant_inactive` (the user who connected the app can no longer edit this account), `account_encrypted` (an end-to-end encrypted account: read-only, no merchant/search filters, no receipts).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"404":{"description":"`not_found` — no such record in the connected account (or it was deleted).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"429":{"description":"`rate_limited` — too many requests. Wait the number of seconds in the `Retry-After` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"503":{"description":"`unavailable` — a temporary failure on our side. Retry shortly with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}}},"tags":["Expenses"],"security":[{"oauth2":["expenses:read"]}],"description":"One expense with its line items (`items`)."},"patch":{"operationId":"updateExpense","summary":"Edit an expense","parameters":[{"name":"X-Account-Id","in":"header","required":false,"schema":{"type":"string","format":"uuid"},"description":"Which account of the connection the call is about. Optional when the connection has exactly one account; required when it has several (`400 account_required`), counted on the granted accounts, not the usable ones. An account that is not part of the connection answers `403 account_not_granted`. Take the ids from `GET /public/v1/accounts`."},{"name":"id","required":true,"in":"path","schema":{"format":"uuid","example":"3f2b8c1e-7d4a-4f6b-9c2e-1a5d8e9f0b37","type":"string"},"description":"The expense id."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicExpenseUpdateDto"}}},"description":"The fields to change."},"responses":{"200":{"description":"The updated expense.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicExpense"}}}},"400":{"description":"`validation_failed` — the request is malformed (unknown parameter, wrong type or format, missing required field); `details.fields` lists the problems per field. `invalid_cursor` — the cursor was not taken unchanged from a previous response. A well-formed request that refers to records outside the connected account, or to fields a shared group owns, is `422`, not `400`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"401":{"description":"`invalid_token` — the access token is missing, invalid, expired or revoked. Refresh it once; if refreshing fails with `invalid_grant`, the user has to connect your app again.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"403":{"description":"`insufficient_scope` (the connection lacks a scope this request needs; `details.required` lists it), `grant_inactive` (the user who connected the app can no longer edit this account), `account_encrypted` (an end-to-end encrypted account: read-only, no merchant/search filters, no receipts).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"404":{"description":"`not_found` — no such record in the connected account (or it was deleted).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"422":{"description":"`validation_failed` — a referenced category, tag or project is not in this account, or a field owned by a shared group (`amount`, `currency`, `date`) was sent; `details.fields` lists them.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"429":{"description":"`rate_limited` — too many requests. Wait the number of seconds in the `Retry-After` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"503":{"description":"`unavailable` — a temporary failure on our side. Retry shortly with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}}},"tags":["Expenses"],"security":[{"oauth2":["expenses:write"]}],"description":"Partial update: a field you omit is left unchanged. `null` clears `time`, `category_id`, `description`, `merchant`, `notes`, `location` and `project_id`; `tag_ids` replaces the whole tag set (`[]` removes every tag). `amount`, `currency`, `date` and `tag_ids` cannot be `null`. Line items cannot be edited.\n\nAn expense that is a share of a shared group expense (`source: \"group\"`) takes its `amount`, `currency` and `date` from the group: changing them answers `422 validation_failed`."},"delete":{"operationId":"deleteExpense","summary":"Delete an expense","parameters":[{"name":"X-Account-Id","in":"header","required":false,"schema":{"type":"string","format":"uuid"},"description":"Which account of the connection the call is about. Optional when the connection has exactly one account; required when it has several (`400 account_required`), counted on the granted accounts, not the usable ones. An account that is not part of the connection answers `403 account_not_granted`. Take the ids from `GET /public/v1/accounts`."},{"name":"id","required":true,"in":"path","schema":{"format":"uuid","example":"3f2b8c1e-7d4a-4f6b-9c2e-1a5d8e9f0b37","type":"string"},"description":"The expense id."}],"responses":{"204":{"description":"Deleted."},"400":{"description":"`validation_failed` — the request is malformed (unknown parameter, wrong type or format, missing required field); `details.fields` lists the problems per field. `invalid_cursor` — the cursor was not taken unchanged from a previous response. A well-formed request that refers to records outside the connected account, or to fields a shared group owns, is `422`, not `400`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"401":{"description":"`invalid_token` — the access token is missing, invalid, expired or revoked. Refresh it once; if refreshing fails with `invalid_grant`, the user has to connect your app again.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"403":{"description":"`insufficient_scope` (the connection lacks a scope this request needs; `details.required` lists it), `grant_inactive` (the user who connected the app can no longer edit this account), `account_encrypted` (an end-to-end encrypted account: read-only, no merchant/search filters, no receipts).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"404":{"description":"`not_found` — no such record in the connected account (or it was deleted).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"429":{"description":"`rate_limited` — too many requests. Wait the number of seconds in the `Retry-After` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"503":{"description":"`unavailable` — a temporary failure on our side. Retry shortly with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}}},"tags":["Expenses"],"security":[{"oauth2":["expenses:write"]}],"description":"Deletes the expense, as deleting it in the app does. It then appears as a `delete` in the changes feed. Deleting it again answers `404 not_found`."}},"/public/v1/incomes":{"get":{"operationId":"listIncomes","summary":"List incomes","parameters":[{"name":"X-Account-Id","in":"header","required":false,"schema":{"type":"string","format":"uuid"},"description":"Which account of the connection the call is about. Optional when the connection has exactly one account; required when it has several (`400 account_required`), counted on the granted accounts, not the usable ones. An account that is not part of the connection answers `403 account_not_granted`. Take the ids from `GET /public/v1/accounts`."},{"name":"date_from","required":false,"in":"query","schema":{"format":"date","example":"2026-10-01","type":"string"},"description":"Only incomes on or after this date (`YYYY-MM-DD`, inclusive)."},{"name":"date_to","required":false,"in":"query","schema":{"format":"date","example":"2026-10-31","type":"string"},"description":"Only incomes on or before this date (`YYYY-MM-DD`, inclusive; not earlier than `date_from`)."},{"name":"category_id","required":false,"in":"query","schema":{"format":"uuid","example":"8a6e0f52-1c3b-4d7e-a9f8-2b4c6d8e0f13","type":"string"},"description":"Only incomes of this category."},{"name":"project_id","required":false,"in":"query","schema":{"format":"uuid","example":"5e4d3c2b-1a09-4f8e-b7d6-c5b4a3928170","type":"string"},"description":"Only incomes linked to this project."},{"name":"search","required":false,"in":"query","schema":{"minLength":1,"maxLength":200,"example":"salary","type":"string"},"description":"Case-insensitive \"contains\" match on the description or the notes."},{"name":"limit","required":false,"in":"query","schema":{"minimum":1,"maximum":200,"default":50,"example":50,"type":"integer"},"description":"Page size, 1–200 (default 50)."},{"name":"cursor","required":false,"in":"query","schema":{"maxLength":1024,"example":"eyJ1IjoiMjAyNi0xMC0zMVQxMjozNDo1Ni43ODlaIiwiaSI6IjNmMmI4YzFlIn0","type":"string"},"description":"`next_cursor` from the previous page, unchanged."}],"responses":{"200":{"description":"One page of incomes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicIncomeList"}}}},"400":{"description":"`validation_failed` — the request is malformed (unknown parameter, wrong type or format, missing required field); `details.fields` lists the problems per field. `invalid_cursor` — the cursor was not taken unchanged from a previous response. A well-formed request that refers to records outside the connected account, or to fields a shared group owns, is `422`, not `400`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"401":{"description":"`invalid_token` — the access token is missing, invalid, expired or revoked. Refresh it once; if refreshing fails with `invalid_grant`, the user has to connect your app again.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"403":{"description":"`insufficient_scope` (the connection lacks a scope this request needs; `details.required` lists it), `grant_inactive` (the user who connected the app can no longer edit this account), `account_encrypted` (an end-to-end encrypted account: read-only, no merchant/search filters, no receipts).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"404":{"description":"`not_found` — no such record in the connected account (or it was deleted).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"429":{"description":"`rate_limited` — too many requests. Wait the number of seconds in the `Retry-After` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"503":{"description":"`unavailable` — a temporary failure on our side. Retry shortly with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}}},"tags":["Incomes"],"security":[{"oauth2":["incomes:read"]}],"description":"Newest first (by `date`, then `id`), paginated with an opaque cursor: pass `next_cursor` as `cursor` with the SAME filters until `has_more` is `false`.\n\nOn an end-to-end encrypted account the `search` filter answers `403 account_encrypted`.\n\nTo keep a copy in sync, use `GET /public/v1/incomes/changes` instead of re-listing."},"post":{"operationId":"createIncome","summary":"Create an income","parameters":[{"name":"X-Account-Id","in":"header","required":false,"schema":{"type":"string","format":"uuid"},"description":"Which account of the connection the call is about. Optional when the connection has exactly one account; required when it has several (`400 account_required`), counted on the granted accounts, not the usable ones. An account that is not part of the connection answers `403 account_not_granted`. Take the ids from `GET /public/v1/accounts`."},{"name":"Idempotency-Key","in":"header","required":true,"schema":{"type":"string","format":"uuid","example":"b7e3c1a9-4f2d-4e8b-9a6c-0d5f7e1b3a24"},"description":"A UUID you generate for this income and reuse on every retry of the same request. A repeat returns the income already created (`200`, the repeated body is ignored). Send retries one after another, not in parallel. A key already used by another record answers `409 idempotency_key_conflict`."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicIncomeCreateDto"}}},"description":"The new income."},"responses":{"200":{"description":"Already created earlier with this `Idempotency-Key`: the original income, unchanged.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicIncome"}}}},"201":{"description":"Created.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicIncome"}}}},"400":{"description":"`validation_failed` — the request is malformed (unknown parameter, wrong type or format, missing required field); `details.fields` lists the problems per field. `invalid_cursor` — the cursor was not taken unchanged from a previous response. A well-formed request that refers to records outside the connected account, or to fields a shared group owns, is `422`, not `400`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"401":{"description":"`invalid_token` — the access token is missing, invalid, expired or revoked. Refresh it once; if refreshing fails with `invalid_grant`, the user has to connect your app again.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"403":{"description":"`insufficient_scope` (the connection lacks a scope this request needs; `details.required` lists it), `grant_inactive` (the user who connected the app can no longer edit this account), `account_encrypted` (an end-to-end encrypted account: read-only, no merchant/search filters, no receipts).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"404":{"description":"`not_found` — no such record in the connected account (or it was deleted).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"409":{"description":"`idempotency_key_conflict` — the key belongs to a record your app did not create, or to an income that has since been deleted. Use a new UUID.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"422":{"description":"`validation_failed` — `category_id` is not an income category of this account, or `tag_ids` or `project_id` is not a live record of it; `details.fields` lists them.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"429":{"description":"`rate_limited` — too many requests. Wait the number of seconds in the `Retry-After` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"503":{"description":"`unavailable` — a temporary failure on our side. Retry shortly with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}}},"tags":["Incomes"],"security":[{"oauth2":["incomes:write"]}],"description":"Requires the `Idempotency-Key` header. Amounts are decimal strings (`\"4200.00\"`), never JSON numbers. A field that is not listed in the schema is refused with `400 validation_failed` (debt fields and `source` are not writable).\n\n`category_id` must be one of the account's income categories (`GET /public/v1/categories?type=income`); a `category_id`, `tag_ids` or `project_id` that is not a live record of the connected account answers `422 validation_failed` with `details.fields`. `currency` defaults to the account's base currency."}},"/public/v1/incomes/changes":{"get":{"operationId":"listIncomeChanges","summary":"Incomes changes feed (incremental sync)","parameters":[{"name":"X-Account-Id","in":"header","required":false,"schema":{"type":"string","format":"uuid"},"description":"Which account of the connection the call is about. Optional when the connection has exactly one account; required when it has several (`400 account_required`), counted on the granted accounts, not the usable ones. An account that is not part of the connection answers `403 account_not_granted`. Take the ids from `GET /public/v1/accounts`."},{"name":"limit","required":false,"in":"query","schema":{"minimum":1,"maximum":200,"default":200,"example":200,"type":"integer"},"description":"Page size, 1–200 (default 200)."},{"name":"cursor","required":false,"in":"query","schema":{"maxLength":1024,"example":"eyJ1IjoiMjAyNi0xMC0zMVQxMjozNDo1Ni43ODlaIiwiaSI6IjNmMmI4YzFlIn0","type":"string"},"description":"`next_cursor` from the previous page, unchanged. Omit it to start from the beginning."}],"responses":{"200":{"description":"One page of changes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicIncomeChanges"}}}},"400":{"description":"`validation_failed` — the request is malformed (unknown parameter, wrong type or format, missing required field); `details.fields` lists the problems per field. `invalid_cursor` — the cursor was not taken unchanged from a previous response. A well-formed request that refers to records outside the connected account, or to fields a shared group owns, is `422`, not `400`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"401":{"description":"`invalid_token` — the access token is missing, invalid, expired or revoked. Refresh it once; if refreshing fails with `invalid_grant`, the user has to connect your app again.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"403":{"description":"`insufficient_scope` (the connection lacks a scope this request needs; `details.required` lists it), `grant_inactive` (the user who connected the app can no longer edit this account), `account_encrypted` (an end-to-end encrypted account: read-only, no merchant/search filters, no receipts).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"404":{"description":"`not_found` — no such record in the connected account (or it was deleted).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"429":{"description":"`rate_limited` — too many requests. Wait the number of seconds in the `Retry-After` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"503":{"description":"`unavailable` — a temporary failure on our side. Retry shortly with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}}},"tags":["Incomes"],"security":[{"oauth2":["incomes:read"]}],"description":"Every change to the account's incomes after `cursor`, oldest first. Each entry is an `upsert` (the full income) or a `delete` (just the `id`).\n\n- Without `cursor` the feed starts from the beginning: every income, plus deletions from the past — deleting an id you never had is a no-op.\n- An income edited again moves to the end of the feed and arrives again with its latest state.\n- A change appears in the feed about 10 seconds after it was made.\n- Store `next_cursor` after processing each page (it is present even on an empty page) and continue from it; repeat while `has_more` is `true`.\n\nKnown limitation: renaming a tag, project or category does not re-emit the incomes that carry it. If names matter, do a full resync now and then."}},"/public/v1/incomes/{id}":{"get":{"operationId":"getIncome","summary":"Get an income","parameters":[{"name":"X-Account-Id","in":"header","required":false,"schema":{"type":"string","format":"uuid"},"description":"Which account of the connection the call is about. Optional when the connection has exactly one account; required when it has several (`400 account_required`), counted on the granted accounts, not the usable ones. An account that is not part of the connection answers `403 account_not_granted`. Take the ids from `GET /public/v1/accounts`."},{"name":"id","required":true,"in":"path","schema":{"format":"uuid","example":"6b1f4a7c-2e9d-4c3a-8f5b-0d7e9a1c3b25","type":"string"},"description":"The income id."}],"responses":{"200":{"description":"The income.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicIncome"}}}},"400":{"description":"`validation_failed` — the request is malformed (unknown parameter, wrong type or format, missing required field); `details.fields` lists the problems per field. `invalid_cursor` — the cursor was not taken unchanged from a previous response. A well-formed request that refers to records outside the connected account, or to fields a shared group owns, is `422`, not `400`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"401":{"description":"`invalid_token` — the access token is missing, invalid, expired or revoked. Refresh it once; if refreshing fails with `invalid_grant`, the user has to connect your app again.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"403":{"description":"`insufficient_scope` (the connection lacks a scope this request needs; `details.required` lists it), `grant_inactive` (the user who connected the app can no longer edit this account), `account_encrypted` (an end-to-end encrypted account: read-only, no merchant/search filters, no receipts).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"404":{"description":"`not_found` — no such record in the connected account (or it was deleted).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"429":{"description":"`rate_limited` — too many requests. Wait the number of seconds in the `Retry-After` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"503":{"description":"`unavailable` — a temporary failure on our side. Retry shortly with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}}},"tags":["Incomes"],"security":[{"oauth2":["incomes:read"]}],"description":"One income."},"patch":{"operationId":"updateIncome","summary":"Edit an income","parameters":[{"name":"X-Account-Id","in":"header","required":false,"schema":{"type":"string","format":"uuid"},"description":"Which account of the connection the call is about. Optional when the connection has exactly one account; required when it has several (`400 account_required`), counted on the granted accounts, not the usable ones. An account that is not part of the connection answers `403 account_not_granted`. Take the ids from `GET /public/v1/accounts`."},{"name":"id","required":true,"in":"path","schema":{"format":"uuid","example":"6b1f4a7c-2e9d-4c3a-8f5b-0d7e9a1c3b25","type":"string"},"description":"The income id."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicIncomeUpdateDto"}}},"description":"The fields to change."},"responses":{"200":{"description":"The updated income.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicIncome"}}}},"400":{"description":"`validation_failed` — the request is malformed (unknown parameter, wrong type or format, missing required field); `details.fields` lists the problems per field. `invalid_cursor` — the cursor was not taken unchanged from a previous response. A well-formed request that refers to records outside the connected account, or to fields a shared group owns, is `422`, not `400`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"401":{"description":"`invalid_token` — the access token is missing, invalid, expired or revoked. Refresh it once; if refreshing fails with `invalid_grant`, the user has to connect your app again.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"403":{"description":"`insufficient_scope` (the connection lacks a scope this request needs; `details.required` lists it), `grant_inactive` (the user who connected the app can no longer edit this account), `account_encrypted` (an end-to-end encrypted account: read-only, no merchant/search filters, no receipts).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"404":{"description":"`not_found` — no such record in the connected account (or it was deleted).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"422":{"description":"`validation_failed` — a referenced category, tag or project is not in this account, or the income is one side of a transfer; `details.fields` lists the fields.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"429":{"description":"`rate_limited` — too many requests. Wait the number of seconds in the `Retry-After` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"503":{"description":"`unavailable` — a temporary failure on our side. Retry shortly with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}}},"tags":["Incomes"],"security":[{"oauth2":["incomes:write"]}],"description":"Partial update: a field you omit is left unchanged. `null` clears `category_id`, `description`, `notes` and `project_id`; `tag_ids` replaces the whole tag set (`[]` removes every tag). `amount`, `currency`, `date` and `tag_ids` cannot be `null`. Debt fields cannot be edited.\n\nEditing the amount of a debt repayment changes the debt's outstanding balance. An income that is one side of a transfer between accounts is read-only: it answers `422 validation_failed`."},"delete":{"operationId":"deleteIncome","summary":"Delete an income","parameters":[{"name":"X-Account-Id","in":"header","required":false,"schema":{"type":"string","format":"uuid"},"description":"Which account of the connection the call is about. Optional when the connection has exactly one account; required when it has several (`400 account_required`), counted on the granted accounts, not the usable ones. An account that is not part of the connection answers `403 account_not_granted`. Take the ids from `GET /public/v1/accounts`."},{"name":"id","required":true,"in":"path","schema":{"format":"uuid","example":"6b1f4a7c-2e9d-4c3a-8f5b-0d7e9a1c3b25","type":"string"},"description":"The income id."}],"responses":{"204":{"description":"Deleted."},"400":{"description":"`validation_failed` — the request is malformed (unknown parameter, wrong type or format, missing required field); `details.fields` lists the problems per field. `invalid_cursor` — the cursor was not taken unchanged from a previous response. A well-formed request that refers to records outside the connected account, or to fields a shared group owns, is `422`, not `400`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"401":{"description":"`invalid_token` — the access token is missing, invalid, expired or revoked. Refresh it once; if refreshing fails with `invalid_grant`, the user has to connect your app again.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"403":{"description":"`insufficient_scope` (the connection lacks a scope this request needs; `details.required` lists it), `grant_inactive` (the user who connected the app can no longer edit this account), `account_encrypted` (an end-to-end encrypted account: read-only, no merchant/search filters, no receipts).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"404":{"description":"`not_found` — no such record in the connected account (or it was deleted).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"422":{"description":"`validation_failed` — the income is one side of a transfer between accounts.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"429":{"description":"`rate_limited` — too many requests. Wait the number of seconds in the `Retry-After` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"503":{"description":"`unavailable` — a temporary failure on our side. Retry shortly with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}}},"tags":["Incomes"],"security":[{"oauth2":["incomes:write"]}],"description":"Deletes the income, as deleting it in the app does. It then appears as a `delete` in the changes feed. Deleting it again answers `404 not_found`.\n\nDeleting a debt repayment changes the debt's outstanding balance. An income that is one side of a transfer between accounts cannot be deleted on its own (`422 validation_failed`)."}},"/public/v1/analytics/summary":{"get":{"operationId":"getAnalyticsSummary","summary":"Totals for a period","parameters":[{"name":"X-Account-Id","in":"header","required":false,"schema":{"type":"string","format":"uuid"},"description":"Which account of the connection the call is about. Optional when the connection has exactly one account; required when it has several (`400 account_required`), counted on the granted accounts, not the usable ones. An account that is not part of the connection answers `403 account_not_granted`. Take the ids from `GET /public/v1/accounts`."},{"name":"from","required":true,"in":"query","schema":{"format":"date","example":"2026-10-01","type":"string"},"description":"First day, `YYYY-MM-DD`, inclusive. Required."},{"name":"to","required":true,"in":"query","schema":{"format":"date","example":"2026-10-31","type":"string"},"description":"Last day, `YYYY-MM-DD`, inclusive. Required; not earlier than `from`, at most 366 days after it (otherwise `400 validation_failed` on `to`)."},{"name":"currency","required":false,"in":"query","schema":{"pattern":"^[A-Z]{3}$","example":"PLN","type":"string"},"description":"ISO 4217 code to report in: USD, EUR, PLN, GBP, UAH, RUB or BYN (any other code answers `400 validation_failed`). Default: the display currency of the user who connected your app."}],"responses":{"200":{"description":"The totals of the period.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicAnalyticsSummary"}}}},"400":{"description":"`validation_failed` — the request is malformed (unknown parameter, wrong type or format, missing required field); `details.fields` lists the problems per field. `invalid_cursor` — the cursor was not taken unchanged from a previous response. A well-formed request that refers to records outside the connected account, or to fields a shared group owns, is `422`, not `400`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"401":{"description":"`invalid_token` — the access token is missing, invalid, expired or revoked. Refresh it once; if refreshing fails with `invalid_grant`, the user has to connect your app again.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"403":{"description":"`insufficient_scope` (the connection lacks a scope this request needs; `details.required` lists it), `grant_inactive` (the user who connected the app can no longer edit this account), `account_encrypted` (an end-to-end encrypted account: read-only, no merchant/search filters, no receipts).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"404":{"description":"`not_found` — no such record in the connected account (or it was deleted).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"429":{"description":"`rate_limited` — too many requests. Wait the number of seconds in the `Retry-After` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"503":{"description":"`unavailable` — a temporary failure on our side. Retry shortly with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}}},"tags":["Analytics"],"security":[{"oauth2":["analytics:read"]}],"description":"Total income, total spending, net and the number of records counted from `from` to `to` (both inclusive, at most 366 days), in one currency.\n\nHow it is counted: every expense and income is converted into `currency` with the server's exchange rates, then added up. A record whose currency has no known rate is left out and the response says `fx_approximate: true` — it is never added unconverted. Deleted records, planned expenses and the shares of a split bill that friends owe you are not counted; money you lent or borrowed, repayments, and a transfer marked to count as income are counted, as in the app's Analytics tab. `currency` always converts (the app's currency picker instead shows only records in that currency), and the app converts with rates fetched at a different time, so a total can differ slightly from the phone."}},"/public/v1/analytics/by-category":{"get":{"operationId":"getAnalyticsByCategory","summary":"Totals per category","parameters":[{"name":"X-Account-Id","in":"header","required":false,"schema":{"type":"string","format":"uuid"},"description":"Which account of the connection the call is about. Optional when the connection has exactly one account; required when it has several (`400 account_required`), counted on the granted accounts, not the usable ones. An account that is not part of the connection answers `403 account_not_granted`. Take the ids from `GET /public/v1/accounts`."},{"name":"from","required":true,"in":"query","schema":{"format":"date","example":"2026-10-01","type":"string"},"description":"First day, `YYYY-MM-DD`, inclusive. Required."},{"name":"to","required":true,"in":"query","schema":{"format":"date","example":"2026-10-31","type":"string"},"description":"Last day, `YYYY-MM-DD`, inclusive. Required; not earlier than `from`, at most 366 days after it (otherwise `400 validation_failed` on `to`)."},{"name":"currency","required":false,"in":"query","schema":{"pattern":"^[A-Z]{3}$","example":"PLN","type":"string"},"description":"ISO 4217 code to report in: USD, EUR, PLN, GBP, UAH, RUB or BYN (any other code answers `400 validation_failed`). Default: the display currency of the user who connected your app."},{"name":"type","required":false,"in":"query","schema":{"default":"expense","example":"expense","enum":["expense","income"],"type":"string"},"description":"`expense` (default) for spending per category, `income` for income per category."}],"responses":{"200":{"description":"The totals per category.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicAnalyticsByCategory"}}}},"400":{"description":"`validation_failed` — the request is malformed (unknown parameter, wrong type or format, missing required field); `details.fields` lists the problems per field. `invalid_cursor` — the cursor was not taken unchanged from a previous response. A well-formed request that refers to records outside the connected account, or to fields a shared group owns, is `422`, not `400`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"401":{"description":"`invalid_token` — the access token is missing, invalid, expired or revoked. Refresh it once; if refreshing fails with `invalid_grant`, the user has to connect your app again.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"403":{"description":"`insufficient_scope` (the connection lacks a scope this request needs; `details.required` lists it), `grant_inactive` (the user who connected the app can no longer edit this account), `account_encrypted` (an end-to-end encrypted account: read-only, no merchant/search filters, no receipts).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"404":{"description":"`not_found` — no such record in the connected account (or it was deleted).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"429":{"description":"`rate_limited` — too many requests. Wait the number of seconds in the `Retry-After` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"503":{"description":"`unavailable` — a temporary failure on our side. Retry shortly with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}}},"tags":["Analytics"],"security":[{"oauth2":["analytics:read"]}],"description":"Spending (or income, with `type=income`) per category from `from` to `to` (both inclusive, at most 366 days), largest first. An expense split across categories counts toward each of them with its share; records without a category are grouped under `category_id: null`. On an end-to-end encrypted account every `category_name` is `null` and listed in `encrypted_fields`.\n\nHow it is counted: every expense and income is converted into `currency` with the server's exchange rates, then added up. A record whose currency has no known rate is left out and the response says `fx_approximate: true` — it is never added unconverted. Deleted records, planned expenses and the shares of a split bill that friends owe you are not counted; money you lent or borrowed, repayments, and a transfer marked to count as income are counted, as in the app's Analytics tab. `currency` always converts (the app's currency picker instead shows only records in that currency), and the app converts with rates fetched at a different time, so a total can differ slightly from the phone."}},"/public/v1/analytics/monthly":{"get":{"operationId":"getAnalyticsMonthly","summary":"Totals per month","parameters":[{"name":"X-Account-Id","in":"header","required":false,"schema":{"type":"string","format":"uuid"},"description":"Which account of the connection the call is about. Optional when the connection has exactly one account; required when it has several (`400 account_required`), counted on the granted accounts, not the usable ones. An account that is not part of the connection answers `403 account_not_granted`. Take the ids from `GET /public/v1/accounts`."},{"name":"from","required":true,"in":"query","schema":{"format":"date","example":"2026-10-01","type":"string"},"description":"First day, `YYYY-MM-DD`, inclusive. Required."},{"name":"to","required":true,"in":"query","schema":{"format":"date","example":"2026-10-31","type":"string"},"description":"Last day, `YYYY-MM-DD`, inclusive. Required; not earlier than `from`, at most 24 calendar months after it (otherwise `400 validation_failed` on `to`)."},{"name":"currency","required":false,"in":"query","schema":{"pattern":"^[A-Z]{3}$","example":"PLN","type":"string"},"description":"ISO 4217 code to report in: USD, EUR, PLN, GBP, UAH, RUB or BYN (any other code answers `400 validation_failed`). Default: the display currency of the user who connected your app."}],"responses":{"200":{"description":"The totals per month.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicAnalyticsMonthly"}}}},"400":{"description":"`validation_failed` — the request is malformed (unknown parameter, wrong type or format, missing required field); `details.fields` lists the problems per field. `invalid_cursor` — the cursor was not taken unchanged from a previous response. A well-formed request that refers to records outside the connected account, or to fields a shared group owns, is `422`, not `400`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"401":{"description":"`invalid_token` — the access token is missing, invalid, expired or revoked. Refresh it once; if refreshing fails with `invalid_grant`, the user has to connect your app again.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"403":{"description":"`insufficient_scope` (the connection lacks a scope this request needs; `details.required` lists it), `grant_inactive` (the user who connected the app can no longer edit this account), `account_encrypted` (an end-to-end encrypted account: read-only, no merchant/search filters, no receipts).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"404":{"description":"`not_found` — no such record in the connected account (or it was deleted).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"429":{"description":"`rate_limited` — too many requests. Wait the number of seconds in the `Retry-After` header.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}},"503":{"description":"`unavailable` — a temporary failure on our side. Retry shortly with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicApiErrorResponse"}}}}},"tags":["Analytics"],"security":[{"oauth2":["analytics:read"]}],"description":"Income, spending and net per calendar month (not the financial month), from the month of `from` to the month of `to`, at most 24 months. Every month of the range is present, with `\"0.00\"` where nothing was counted. Only days from `from` to `to` count, so the first and the last month may be partial.\n\nHow it is counted: every expense and income is converted into `currency` with the server's exchange rates, then added up. A record whose currency has no known rate is left out and the response says `fx_approximate: true` — it is never added unconverted. Deleted records, planned expenses and the shares of a split bill that friends owe you are not counted; money you lent or borrowed, repayments, and a transfer marked to count as income are counted, as in the app's Analytics tab. `currency` always converts (the app's currency picker instead shows only records in that currency), and the app converts with rates fetched at a different time, so a total can differ slightly from the phone."}}},"info":{"title":"AI Budget API","description":"**English** · [Polski](/public/docs/pl) · [Русский](/public/docs/ru)\n\n# Getting started\n\nThe AI Budget API lets your app read and write a user's expenses — with the user's permission, through OAuth 2.0. This guide takes you from registering an app to keeping a synchronised copy of the expenses. The reference below documents every endpoint, field and error.\n\n- API base URL: `https://api.ai-budget.pl/public/v1`\n- Authorize URL: `https://app.ai-budget.pl/authorize`\n- Token URL: `https://api.ai-budget.pl/oauth/token`\n- JSON fields are `snake_case`. Amounts are decimal **strings** (`\"42.50\"`), dates are `YYYY-MM-DD`, timestamps are ISO-8601 in UTC.\n\n## 1. Register an app\n\nIn the AI Budget app open **Settings → Developers** and create an app. Give it a name, a homepage and one or more **redirect URIs**. A redirect URI must use `https://`; for development `http://localhost[:port]` and `http://127.0.0.1[:port]` are allowed too. The URI you send later must match a registered one **exactly**, character for character.\n\nChoose the client type:\n\n- **Confidential** — your backend can keep a secret. You receive a `client_id` and a `client_secret`. The secret is shown only once: store it securely, and never put it in a browser or mobile app.\n- **Public** — a single-page or mobile app. Only a `client_id`; PKCE alone protects the flow.\n\nOne developer can register up to 10 apps.\n\n## 2. Send the user to the authorize URL\n\nEvery authorization request uses PKCE with `S256` (no other method is accepted). Generate a random `code_verifier`, send its SHA-256 as `code_challenge`, and keep the verifier until the user comes back. Also generate a random `state` and check it on return.\n\n| Parameter | Value |\n| --- | --- |\n| `response_type` | `code` |\n| `client_id` | your client id |\n| `redirect_uri` | one of your registered redirect URIs, exactly |\n| `scope` | space-separated: `expenses:read`, `expenses:write`, `receipts:read`, `categories:read`, `incomes:read`, `incomes:write`, `budgets:read`, `budgets:write`, `analytics:read` |\n| `state` | a random value you check on return |\n| `code_challenge` | base64url SHA-256 of the `code_verifier` (43 characters) |\n| `code_challenge_method` | `S256` |\n\nJavaScript for the browser (it redirects and uses `sessionStorage`; the PKCE part alone also runs in Node.js 20+):\n\n```js\nfunction base64url(bytes) {\n  return btoa(String.fromCharCode(...new Uint8Array(bytes)))\n    .replace(/\\+/g, '-')\n    .replace(/\\//g, '_')\n    .replace(/=+$/, '');\n}\n\nconst codeVerifier = base64url(crypto.getRandomValues(new Uint8Array(32)));\nconst codeChallenge = base64url(\n  await crypto.subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier)),\n);\nconst state = base64url(crypto.getRandomValues(new Uint8Array(16)));\nsessionStorage.setItem('aba_pkce', JSON.stringify({ codeVerifier, state }));\n\nconst url = new URL('https://app.ai-budget.pl/authorize');\nurl.search = new URLSearchParams({\n  response_type: 'code',\n  client_id: 'aba_ci_q7Lx2Vb9TmR4kWc8Zp1sYg',\n  redirect_uri: 'https://example.com/oauth/callback',\n  scope: 'expenses:read expenses:write receipts:read categories:read',\n  state,\n  code_challenge: codeChallenge,\n  code_challenge_method: 'S256',\n}).toString();\nwindow.location.assign(url);\n```\n\nThe resulting URL looks like this:\n\n```text\nhttps://app.ai-budget.pl/authorize?response_type=code&client_id=aba_ci_q7Lx2Vb9TmR4kWc8Zp1sYg&redirect_uri=https%3A%2F%2Fexample.com%2Foauth%2Fcallback&scope=expenses%3Aread+expenses%3Awrite+receipts%3Aread+categories%3Aread&state=af0ifjsldkj&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256\n```\n\nThe user signs in, chooses **one or more** accounts and approves the scopes. Then the browser returns to your redirect URI:\n\n- approved: `https://example.com/oauth/callback?code=...&state=...`\n- declined: `https://example.com/oauth/callback?error=access_denied&state=...`\n\nCheck that `state` is the one you sent before you use the code.\n\n**One connection, one or more accounts.** The user ticks which accounts your app gets; the connection covers exactly those. Only an **owner** or an **editor** of an account can connect it. If the user who connected the app later loses edit rights to one account (becomes a viewer or leaves it), calls addressed to that account answer `403 grant_inactive` while the other accounts keep working; the account becomes usable again when someone with edit rights connects the app again.\n\n## 3. Exchange the code for tokens\n\nThe code is valid for **10 minutes** and works **once** — a failed exchange burns it too. Send the same `redirect_uri` and the `code_verifier`:\n\n```bash\ncurl -X POST https://api.ai-budget.pl/oauth/token \\\n  -u \"$CLIENT_ID:$CLIENT_SECRET\" \\\n  -d grant_type=authorization_code \\\n  -d code=\"$CODE\" \\\n  -d redirect_uri=https://example.com/oauth/callback \\\n  -d code_verifier=\"$CODE_VERIFIER\"\n```\n\nA public client sends no secret, only `client_id`:\n\n```js\nconst { codeVerifier } = JSON.parse(sessionStorage.getItem('aba_pkce'));\nconst res = await fetch('https://api.ai-budget.pl/oauth/token', {\n  method: 'POST',\n  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n  body: new URLSearchParams({\n    grant_type: 'authorization_code',\n    code: new URLSearchParams(location.search).get('code'),\n    redirect_uri: 'https://example.com/oauth/callback',\n    code_verifier: codeVerifier,\n    client_id: 'aba_ci_q7Lx2Vb9TmR4kWc8Zp1sYg',\n  }),\n});\nlet tokens = await res.json();\n```\n\nResponse:\n\n```json\n{\n  \"access_token\": \"aba_at_Hq7Wm2Ks9Lp4Nx1Vb6Cz3Rt8Yu5Ie0Oa2Sd7Fg4Jk9L\",\n  \"token_type\": \"Bearer\",\n  \"expires_in\": 3600,\n  \"refresh_token\": \"aba_rt_Pz4Xc8Vb2Nm6Lk0Jh3Gf7Ds1Aq5We9Rt2Yu6Io0Pa\",\n  \"scope\": \"expenses:read expenses:write receipts:read categories:read\",\n  \"account_ids\": [\"7c9e1a3b-5d7f-4b2c-8e4a-6f8b0d2c4e6a\"],\n  \"account_id\": \"7c9e1a3b-5d7f-4b2c-8e4a-6f8b0d2c4e6a\"\n}\n```\n\n`account_ids` lists every account of the connection, in connection order. `account_id` appears **only** when the connection has exactly one account (kept for older integrations); read `account_ids`.\n\nThe **access token** lives **1 hour**. The **refresh token** is valid for **90 days**, and every refresh gives you a new one with a fresh 90 days. Store both securely, server-side where you can.\n\n## 4. Call the API\n\nSend the access token in the `Authorization` header:\n\n```bash\ncurl \"https://api.ai-budget.pl/public/v1/expenses?limit=50\" \\\n  -H \"Authorization: Bearer $ACCESS_TOKEN\"\n```\n\n```js\nconst res = await fetch('https://api.ai-budget.pl/public/v1/expenses?limit=50', {\n  headers: { Authorization: `Bearer ${tokens.access_token}` },\n});\nconst page = await res.json();\n```\n\n`GET /public/v1/account` tells you which account a call is about. With several accounts, say which one with the `X-Account-Id` header (see section 5). Lists are paginated with an opaque cursor: pass `next_cursor` back as `cursor`, with the same filters, while `has_more` is `true`.\n\n## 5. Several accounts in one connection\n\nThe token response always carries `account_ids`. Ask for the details with `GET /public/v1/accounts`; it needs only a valid token, no `X-Account-Id` and no scope, and it always answers:\n\n```json\n{ \"data\": [{ \"id\": \"...\", \"name\": \"Family\", \"type\": \"shared\", \"base_currency\": \"PLN\", \"access\": \"read_write\", \"active\": true, \"reason\": null }] }\n```\n\n- `type` tells a company account (`business`) from a private one (`personal`, `shared`, `trip`, `investment`).\n- `access` is `read_write` when the connection may write to the account, otherwise `read` (no `expenses:write` scope, or an end-to-end encrypted account with text encryption).\n- `active: false` means requests addressed to this account are refused; `reason` says why: `role` (the user is no longer an owner or editor), `encrypted` (the account is now fully end-to-end encrypted) or `archived` (an archived trip: still active, read-only). With `role` because the user is no longer a member at all, `name`, `type` and `base_currency` are `null`.\n\n**Choosing the account.** Send the id in the `X-Account-Id` header on every other call. With exactly **one** granted account the header is optional. With **more than one** it is required, and a call without it answers `400 account_required`, `GET /public/v1/account` included. The rule counts the accounts the user granted, not the usable ones: a connection of two accounts of which one is inactive still needs the header. An id that is not part of the connection answers `403 account_not_granted`; an account whose owner or editor right is gone answers `403 grant_inactive`; a newly encrypted one `403 account_encrypted`. The other accounts of the connection are not affected.\n\n**Always send `X-Account-Id`, even with one account, and treat `account_ids` in every token response as authoritative.** Re-consent can **replace** the set: a connection that had one account can move to a different one, and a header-less request would then silently land in the new account. After every code exchange and refresh, re-read `account_ids` and keep your own account-to-data mapping in step with it.\n\n**One call, one account.** There is no merged endpoint across accounts: to read several accounts, call once per account. Scopes and rate limits belong to the whole connection (one bucket for all its accounts), but `Idempotency-Key` values and the `/expenses/changes` cursors are scoped per account: the same key or cursor means something different on each one, so keep them per account.\n\n**Changing the set.** When the user connects your app again, the consent screen pre-ticks the accounts already connected, and what they approve **replaces** the set. An account they untick is cut off on the next request, and the tokens keep working for the rest. A refresh answers `invalid_grant` only when **no** account of the connection is usable any more.\n\n## 6. Refresh the access token\n\n```bash\ncurl -X POST https://api.ai-budget.pl/oauth/token \\\n  -u \"$CLIENT_ID:$CLIENT_SECRET\" \\\n  -d grant_type=refresh_token \\\n  -d refresh_token=\"$REFRESH_TOKEN\"\n```\n\n**Refresh tokens rotate.** Every refresh returns a **new** refresh token, and the one you sent stops working. Always store the new one, replacing the old one, before you do anything else.\n\nPresenting a refresh token that was already used is treated as theft: the whole connection is revoked and the user has to connect your app again. This also happens when **two workers refresh at the same time** with the same token — one wins, the other reuses a spent token. Refresh from **one place only** (one process, a lock, or a single in-flight request):\n\n```js\nlet refreshing = null;\n\nfunction refreshTokens() {\n  refreshing ??= (async () => {\n    const res = await fetch('https://api.ai-budget.pl/oauth/token', {\n      method: 'POST',\n      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n      body: new URLSearchParams({\n        grant_type: 'refresh_token',\n        refresh_token: tokens.refresh_token,\n        client_id: 'aba_ci_q7Lx2Vb9TmR4kWc8Zp1sYg',\n      }),\n    });\n    if (!res.ok) throw new Error('reconnect');\n    tokens = await res.json();\n    await saveTokens(tokens);\n  })().finally(() => {\n    refreshing = null;\n  });\n  return refreshing;\n}\n\nasync function api(path, init = {}) {\n  const call = () =>\n    fetch(`https://api.ai-budget.pl${path}`, {\n      ...init,\n      headers: { ...init.headers, Authorization: `Bearer ${tokens.access_token}` },\n    });\n  let res = await call();\n  if (res.status === 401) {\n    await refreshTokens();\n    res = await call();\n  }\n  return res;\n}\n```\n\nTo disconnect, send either token to `POST https://api.ai-budget.pl/oauth/revoke`; the whole connection is revoked. The user can also disconnect your app at any time in **Settings → Connected apps**.\n\n## 7. Incremental sync\n\nTo keep your own copy of the expenses, read the changes feed `GET https://api.ai-budget.pl/public/v1/expenses/changes` instead of re-listing:\n\n- Without `cursor` the feed starts from the beginning: every expense, plus every deletion ever recorded in the account. A client starting without a cursor therefore receives each historical deletion as a `delete`, mostly for ids it never saw.\n- Each entry is either `{\"type\": \"upsert\", \"expense\": {...}}` — insert or replace your copy — or `{\"type\": \"delete\", \"id\": \"...\"}` — remove it. A delete can name an id you never received: deleting an unknown id is a no-op.\n- A deleted expense, and an expense turned into a planned one, both arrive as `delete`.\n- An expense edited again arrives again, with its latest state, at the end of the feed.\n- A change appears in the feed about **10 seconds** after it was made.\n- Store `next_cursor` after processing each page — it is present even on an empty page — and continue from it. When `has_more` is `false`, poll again later.\n\n```js\nasync function sync(db) {\n  let cursor = await db.loadCursor();\n  for (;;) {\n    const url = new URL('https://api.ai-budget.pl/public/v1/expenses/changes');\n    if (cursor) url.searchParams.set('cursor', cursor);\n    const res = await api(url.pathname + url.search);\n    if (!res.ok) throw new Error(`HTTP ${res.status}`);\n    const page = await res.json();\n    for (const change of page.changes) {\n      if (change.type === 'upsert') await db.put(change.expense);\n      else await db.remove(change.id);\n    }\n    cursor = page.next_cursor;\n    await db.saveCursor(cursor);\n    if (!page.has_more) break;\n  }\n}\n```\n\nKnown limitations:\n\n- An expense moved to another account is not reported as deleted in the account it left.\n- Renaming a tag or a project does not re-emit the expenses that carry it. If names matter to you, do a full resync now and then (sync again without a cursor).\n\n## 8. Create, edit and delete expenses\n\nCreating an expense requires an `Idempotency-Key` header: a UUID you generate for this expense.\n\n```bash\ncurl -X POST https://api.ai-budget.pl/public/v1/expenses \\\n  -H \"Authorization: Bearer $ACCESS_TOKEN\" \\\n  -H \"Idempotency-Key: b7e3c1a9-4f2d-4e8b-9a6c-0d5f7e1b3a24\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"amount\":\"42.50\",\"currency\":\"PLN\",\"date\":\"2026-10-31\",\"merchant\":\"Biedronka\",\"category_id\":\"8a6e0f52-1c3b-4d7e-a9f8-2b4c6d8e0f13\"}'\n```\n\n- `201` — created. If the request times out, **retry with the same key**: a repeat returns the expense already created, with `200`, and the repeated body is ignored. Send retries one after another, not in parallel.\n- `409 idempotency_key_conflict` — the key is already used by another record (one your app did not create, or an expense deleted since). Generate a new UUID.\n- Amounts are decimal strings with at most two decimal places. A JSON number is refused.\n- `400 validation_failed` means the request itself is malformed: an unknown field, a wrong type or format, a missing required field. `details.fields` lists the problems.\n- `422 validation_failed` means the request is well-formed but refers to something that cannot be used: a `category_id`, `tag_ids` or `project_id` that is not in the connected account, or — on edit — `amount`, `currency` or `date` of an expense that is a share of a shared group expense (`source: \"group\"`), which the group owns.\n\n`PATCH /public/v1/expenses/{id}` changes only the fields you send; `null` clears a field where allowed. `DELETE /public/v1/expenses/{id}` deletes the expense; it then arrives as `delete` in the changes feed. Take valid `category_id` values from `GET /public/v1/categories`.\n\n## 9. Incomes\n\nIncomes work like expenses (section 8), under `/public/v1/incomes`: `GET` (list), `GET /{id}`, `POST`, `PATCH /{id}`, `DELETE /{id}`, and the changes feed `GET /incomes/changes` (the same rules as section 7). Scopes: `incomes:read`, `incomes:write`.\n\n- **Validation codes.** A malformed request — a bad query parameter, or a body field that fails validation (unknown field, wrong type, bad enum, `date_from` after `date_to`, an unknown currency) — is `400 validation_failed`. `422 validation_failed` is for reference and state errors: a category of the wrong type or of another account, a `tag_ids` or `project_id` that is not in the account, or an attempt to edit a transfer-linked income.\n- **List filters** have the expense list's names: `date_from`, `date_to`, `category_id`, `project_id`, `search`, `limit`, `cursor`. There is no merchant filter and no amount filter. `GET /public/v1/categories?type=income` lists the categories an income write accepts. Unknown query parameters are refused with `400 validation_failed`, as on every list route, `/categories` included.\n- **Idempotency.** `POST /incomes` requires `Idempotency-Key`, with the same rules as for expenses: the key is scoped to your app and the account; a repeat answers `200` with the existing record; `409 idempotency_key_conflict` means the key belongs to a record of another origin, to another record's id, or to a deleted record.\n- **Read-only fields.** `is_debt`, `is_debt_repayment`, `is_split_receivable` and `is_transfer` are returned but cannot be written.\n- **Debt repayments.** The amount and date of a debt repayment can be edited and the repayment can be deleted — either changes the debt's outstanding balance, so do it deliberately.\n- **Transfers.** An income that is one side of a transfer between accounts (`is_transfer: true`) cannot be edited or deleted; both answer `422 validation_failed`.\n\n```bash\ncurl -X POST https://api.ai-budget.pl/public/v1/incomes \\\n  -H \"Authorization: Bearer $ACCESS_TOKEN\" \\\n  -H \"Idempotency-Key: 0c9d4e1a-7b2f-4a63-8e15-3f6a9b2c4d70\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"amount\":\"4200.00\",\"currency\":\"PLN\",\"date\":\"2026-10-31\",\"description\":\"Salary\"}'\n```\n\n## 10. Budgets\n\nBudgets live under `/public/v1/budgets`: `GET` (list), `GET /{id}`, `POST`, `PATCH /{id}`, `DELETE /{id}`. Scopes: `budgets:read`, `budgets:write`.\n\n- `POST /budgets` requires `Idempotency-Key`, with the same rules as for incomes and expenses.\n- A budget has no category of its own: category scope exists only through `allocations`, a list of `{category_id, amount}`.\n- On `PATCH`, `allocations` **replaces the whole set**: `[]` clears it, omitting the field leaves it unchanged. A repeated `category_id` is `422 validation_failed`.\n- `is_active` is read-only.\n- `progress` describes the **current period**, in the budget's own currency, and uses the account's financial-month anchor.\n- There is no changes feed for budgets: poll the list.\n\n## 11. Analytics\n\nThree read-only reports under `/public/v1/analytics`, scope `analytics:read`: `GET /summary` (income, expenses, net, `transaction_count`), `GET /by-category` (`type=expense|income`) and `GET /monthly`. All take `from` and `to` (`YYYY-MM-DD`, both inclusive) and an optional `currency`.\n\nHow it is counted:\n\n- **Currency.** The default is the connecting user's display currency. Every response states its `currency`.\n- **Conversion.** Every row is converted with our server's exchange rates. A row whose rate is unknown is excluded from every figure and the response carries `fx_approximate: true` — it is never added unconverted.\n- **What is counted.** Planned expenses and the rows that record what friends owe the user back from a split bill are excluded. Debts and transfers are counted, as in the app's Analytics tab. `transaction_count` counts expense and income rows together.\n- **Categories.** A receipt split across categories is attributed by its split, so a category total can differ from a whole-row conversion by a cent.\n- **Ranges.** `from` and `to` are inclusive. At most 366 days for `summary` and `by-category`, at most 24 months for `monthly`; months are calendar months. A longer range, `from` after `to`, or an unknown currency is `400 validation_failed`.\n- **Caching.** Results are cached for 60 seconds.\n- **Why totals may differ slightly from the app.** The app converts with its own cached exchange rates and adds an amount without a known rate unconverted; the API excludes it and flags it.\n\n```bash\ncurl \"https://api.ai-budget.pl/public/v1/analytics/summary?from=2026-10-01&to=2026-10-31&currency=PLN\" \\\n  -H \"Authorization: Bearer $ACCESS_TOKEN\"\n```\n\n## 12. Errors\n\nEvery `/public/v1` error has the same body:\n\n```json\n{\n  \"error\": {\n    \"code\": \"validation_failed\",\n    \"message\": \"The request is invalid.\",\n    \"details\": { \"fields\": { \"amount\": [\"amount must be a decimal string with at most 2 decimal places\"] } }\n  }\n}\n```\n\nBranch on `code`; `message` is for people and may change. New codes may be added.\n\n| Code | HTTP | Meaning and what to do |\n| --- | --- | --- |\n| `invalid_token` | 401 | The access token is missing, invalid, expired or revoked. Refresh once and retry; if the refresh fails with `invalid_grant`, ask the user to connect your app again. |\n| `grant_inactive` | 403 | The user who connected the app can no longer edit this account. Someone with edit rights must connect it again. |\n| `account_encrypted` | 403 | The account is end-to-end encrypted: writes, `merchant`/`search` filters and receipts are not available (see below). |\n| `insufficient_scope` | 403 | The connection lacks a scope this request needs; `details.required` lists it. Ask the user to connect again with that scope. |\n| `viewer_read_only` | 403 | The connected user can only view this account. |\n| `trip_archived` | 403 | The account is an archived trip; it can no longer be changed. |\n| `forbidden` | 403 | Any other refusal. |\n| `validation_failed` | 400 / 422 | 400: the request is malformed. 422: it refers to records that are not in the account, or to fields owned by a shared group. See `details.fields`. |\n| `invalid_cursor` | 400 | Send `next_cursor` back exactly as you received it. |\n| `account_required` | 400 | The connection covers several accounts: send the `X-Account-Id` header with one of the ids from `GET /public/v1/accounts`. |\n| `account_not_granted` | 403 | The `X-Account-Id` account is not part of this connection. |\n| `bad_request` | 400 | Any other invalid request. |\n| `not_found` | 404 | No such record in the connected account, or it was deleted. |\n| `method_not_allowed` | 405 | The HTTP method is not supported on this path. |\n| `conflict` | 409 | The request conflicts with the current state. |\n| `idempotency_key_conflict` | 409 | The `Idempotency-Key` is already used by another record. Use a new UUID. |\n| `payload_too_large` | 413 | The request body is too large. |\n| `rate_limited` | 429 | Too many requests. Wait `Retry-After` seconds. |\n| `internal_error` | 500 | Our fault. Retry later. |\n| `unavailable` | 503 | A temporary failure. Retry with backoff. |\n\n`/oauth/token` and `/oauth/revoke` answer in the OAuth format `{\"error\": \"...\", \"error_description\": \"...\"}` with these codes: `invalid_request`, `invalid_client`, `invalid_grant`, `unsupported_grant_type`, `invalid_scope`, `slow_down`, `temporarily_unavailable`. `invalid_grant` on a refresh means the connection is gone (revoked by the user, a reused refresh token, or no account of the connection is usable any more because the user lost edit rights to all of them) — ask the user to connect again.\n\n## 13. Rate limits\n\n- **120 requests per minute per connection** (one user in your app, all the accounts of the connection together).\n- **1,200 requests per minute per app**, across all its connections.\n- `/oauth/token`: 30 requests per minute per app. A public client is counted only on requests that carry a valid authorization code or refresh token of that client.\n\nEvery authenticated and authorised `/public/v1` response carries `X-RateLimit-Limit` and `X-RateLimit-Remaining` (for whichever limit is closer to running out); a `401` or `403` does not. Your app's actual limits are the ones in these headers. Over the limit you get `429 rate_limited` with a `Retry-After` header in seconds — wait that long before the next request.\n\nIn front of the API an edge proxy also caps floods. Its `429` is not the JSON above and, for a browser app on a third-party origin, carries no CORS headers, so the browser reports it as a network error. Treat such an unreadable failure like a `429`: back off and retry.\n\n## 14. End-to-end encrypted accounts\n\nUsers can encrypt their data end to end in the app, and then even our servers cannot read it:\n\n- An account with **full** encryption cannot be connected at all.\n- An account with **text** encryption can be connected **read-only**. `GET /public/v1/account` shows `\"encryption\": \"text\"` and `\"read_only\": true`. Writes answer `403 account_encrypted`.\n- On such an account seven fields come back as `null` and are listed in each expense's `encrypted_fields`: `description`, `notes`, `merchant`, `location.name`, `items.description`, `tags.name`, `project.name`.\n- Category names on `/categories` are `null` as well, with `\"encrypted_fields\": [\"name\"]` on each category.\n- The `merchant` and `search` filters answer `403 account_encrypted`, and receipt files are not available.\n\n## 15. Try it in this page\n\nThe **Authorize** button above runs the real OAuth flow with PKCE against the user's real data. To use it, add `https://api.ai-budget.pl/public/docs/oauth2-redirect.html` to your app's redirect URIs, then enter your `client_id` (and the secret, for a confidential client).\n","version":"1","contact":{}},"tags":[{"name":"OAuth","description":"Token exchange, refresh and revocation (RFC 6749 / RFC 7009). These endpoints answer errors as `{\"error\", \"error_description\"}`, not in the `/public/v1` error shape."},{"name":"Account","description":"The account a request addresses (`X-Account-Id`), one of the accounts of the connection."},{"name":"Categories","description":"The account's expense categories, for `category_id`."},{"name":"Expenses","description":"Expenses: read, incremental sync, receipts and writes."}],"servers":[{"url":"https://api.ai-budget.pl","description":"Production"}],"components":{"securitySchemes":{"oauth2":{"type":"oauth2","flows":{"authorizationCode":{"authorizationUrl":"https://app.ai-budget.pl/authorize","tokenUrl":"https://api.ai-budget.pl/oauth/token","refreshUrl":"https://api.ai-budget.pl/oauth/token","scopes":{"expenses:read":"Read expenses: list, details with line items, and the changes feed.","expenses:write":"Create, edit and delete expenses.","receipts:read":"Download the original receipt file attached to an expense.","categories:read":"Read the account's expense and income categories.","incomes:read":"Read income records of the account.","incomes:write":"Create, edit and delete income records.","budgets:read":"Read budgets and how much of each is spent.","budgets:write":"Create, edit and delete budgets.","analytics:read":"Read analytics: reveals totals of income and spending."}}},"description":"OAuth 2.0 authorization code flow with PKCE (`S256` only). The user signs in to AI Budget, picks ONE account and approves the scopes; your app exchanges the code for an access token (1 hour) and a refresh token (90 days, rotated on every use). Send the access token as `Authorization: Bearer <access_token>`."}},"schemas":{"OAuthErrorResponse":{"type":"object","properties":{"error":{"type":"string","enum":["invalid_request","invalid_client","invalid_grant","unsupported_grant_type","invalid_scope","slow_down","temporarily_unavailable"],"example":"invalid_grant","description":"The OAuth error code."},"error_description":{"type":"string","example":"authorization code is invalid, expired or already used","description":"Human-readable explanation (English)."}},"required":["error","error_description"],"description":"RFC 6749 error body of `/oauth/token` and `/oauth/revoke`."},"OAuthTokenRequest":{"type":"object","properties":{"grant_type":{"type":"string","enum":["authorization_code","refresh_token"],"example":"authorization_code","description":"`authorization_code` to exchange a code, `refresh_token` to refresh."},"code":{"type":"string","example":"aba_ac_Xv3kP9qLm2Rt7Yw1Zb5Nc8Hd4Fg6Js0Ke2Ua9Wi3Oo","description":"The `code` your redirect URI received. Required for `authorization_code`."},"redirect_uri":{"type":"string","example":"https://example.com/oauth/callback","description":"Exactly the `redirect_uri` of the authorize request. Required for `authorization_code`."},"code_verifier":{"type":"string","minLength":43,"maxLength":128,"example":"dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk","description":"The PKCE verifier whose SHA-256 you sent as `code_challenge`. Required for `authorization_code`."},"refresh_token":{"type":"string","example":"aba_rt_8fJ2kL5mN9pQ3rS7tU1vW4xY6zA0bC2dE5fG8hI1jK","description":"Your current refresh token. Required for `refresh_token`."},"scope":{"type":"string","example":"expenses:read expenses:write receipts:read categories:read incomes:read incomes:write budgets:read budgets:write analytics:read","description":"Optional on refresh: a subset of the granted scopes. The response still reports all granted scopes."},"client_id":{"type":"string","example":"aba_ci_q7Lx2Vb9TmR4kWc8Zp1sYg","description":"Your client id, unless sent with HTTP Basic."},"client_secret":{"type":"string","example":"aba_cs_W3nR8tY2uI6oP0aS4dF7gH1jK5lZ9xC3vB6nM2qE8rT","description":"Confidential clients only, unless sent with HTTP Basic. Never send it from a browser or a mobile app."}},"required":["grant_type"],"description":"Parameters of `POST /oauth/token`. Which ones are required depends on `grant_type`."},"OAuthTokenResponse":{"type":"object","properties":{"access_token":{"type":"string","example":"aba_at_Hq7Wm2Ks9Lp4Nx1Vb6Cz3Rt8Yu5Ie0Oa2Sd7Fg4Jk9L","description":"Send as `Authorization: Bearer <access_token>`."},"token_type":{"type":"string","enum":["Bearer"],"example":"Bearer","description":"Always `Bearer`."},"expires_in":{"type":"number","example":3600,"description":"Access token lifetime in seconds (3600)."},"refresh_token":{"type":"string","example":"aba_rt_Pz4Xc8Vb2Nm6Lk0Jh3Gf7Ds1Aq5We9Rt2Yu6Io0Pa","description":"Valid for 90 days and single-use: each refresh returns a new one. Reusing an old one revokes the connection."},"scope":{"type":"string","example":"expenses:read expenses:write categories:read","description":"The scopes of the connection, space-separated."},"account_ids":{"example":["7c9e1a3b-5d7f-4b2c-8e4a-6f8b0d2c4e6a","2f4a6c8e-0b1d-4f3a-9c5e-7a9b1d3f5e7c"],"type":"array","items":{"type":"string","format":"uuid"},"description":"The accounts the user connected, in connection order."},"account_id":{"type":"string","format":"uuid","example":"7c9e1a3b-5d7f-4b2c-8e4a-6f8b0d2c4e6a","description":"Present only when the connection has exactly one account (phase 1 compatibility). Read `account_ids` instead."}},"required":["access_token","token_type","expires_in","refresh_token","scope","account_ids"],"description":"A token pair. Store the new `refresh_token` every time — the previous one no longer works."},"OAuthRevokeRequest":{"type":"object","properties":{"token":{"type":"string","example":"aba_rt_8fJ2kL5mN9pQ3rS7tU1vW4xY6zA0bC2dE5fG8hI1jK","description":"An access or refresh token of the connection."},"token_type_hint":{"type":"string","enum":["access_token","refresh_token"],"example":"refresh_token","description":"Accepted and ignored: both kinds are found anyway."},"client_id":{"type":"string","example":"aba_ci_q7Lx2Vb9TmR4kWc8Zp1sYg","description":"Your client id, unless sent with HTTP Basic."},"client_secret":{"type":"string","example":"aba_cs_W3nR8tY2uI6oP0aS4dF7gH1jK5lZ9xC3vB6nM2qE8rT","description":"Confidential clients only, unless sent with HTTP Basic."}},"required":["token"],"description":"Parameters of `POST /oauth/revoke`."},"PublicApiErrorObject":{"type":"object","properties":{"code":{"type":"string","enum":["invalid_token","grant_inactive","account_encrypted","insufficient_scope","rate_limited","unavailable","validation_failed","viewer_read_only","trip_archived","not_found","conflict","forbidden","method_not_allowed","payload_too_large","bad_request","internal_error","invalid_cursor","idempotency_key_conflict","account_required","account_not_granted"],"example":"validation_failed","description":"Stable machine-readable code. New codes may be added; existing ones never change."},"message":{"type":"string","example":"The request is invalid.","description":"Human-readable explanation (English)."},"details":{"type":"object","additionalProperties":true,"example":{"fields":{"amount":["amount must be a decimal string with at most 2 decimal places"]}},"description":"Extra data for some codes, e.g. `fields` (per-field messages) for `validation_failed`, `required` (the missing scopes) for `insufficient_scope`."}},"required":["code","message"],"description":"Error details. Branch on `code`; `message` is for humans and may change."},"PublicApiErrorResponse":{"type":"object","properties":{"error":{"allOf":[{"$ref":"#/components/schemas/PublicApiErrorObject"}],"description":"What went wrong."}},"required":["error"],"description":"The body of every `/public/v1` error."},"PublicAccountUser":{"type":"object","properties":{"id":{"type":"string","format":"uuid","example":"2d4f6a8c-0e1b-4c3d-9e5f-7a9b1c3d5e7f","description":"User id."},"name":{"type":"string","example":"Anna","description":"Display name."},"base_currency":{"type":"string","example":"PLN","description":"The user's own display currency (may differ from the account's)."}},"required":["id","name","base_currency"],"description":"The user who connected the app."},"PublicAccount":{"type":"object","properties":{"id":{"type":"string","format":"uuid","example":"7c9e1a3b-5d7f-4b2c-8e4a-6f8b0d2c4e6a","description":"Account id."},"name":{"type":"string","example":"Family budget","description":"Account name."},"type":{"type":"string","enum":["personal","business","shared","investment","trip"],"example":"shared","description":"Account type."},"base_currency":{"type":"string","example":"PLN","description":"The account's base currency (ISO 4217); the default `currency` of a new expense."},"role":{"type":"string","enum":["owner","editor"],"example":"owner","description":"Role of the connecting user in this account (only owners and editors can connect)."},"encryption":{"type":"string","enum":["none","text"],"example":"none","description":"`none`, or `text` for an end-to-end encrypted account (read-only through the API, some fields withheld). Fully encrypted accounts cannot be connected."},"read_only":{"type":"boolean","example":false,"description":"`true` when writes are refused (the account is end-to-end encrypted)."},"user":{"allOf":[{"$ref":"#/components/schemas/PublicAccountUser"}],"description":"The user who connected the app."}},"required":["id","name","type","base_currency","role","encryption","read_only","user"],"description":"The connected account."},"PublicConnectedAccount":{"type":"object","properties":{"id":{"type":"string","format":"uuid","example":"7c9e1a3b-5d7f-4b2c-8e4a-6f8b0d2c4e6a","description":"Account id; send it as `X-Account-Id`."},"name":{"type":"string","nullable":true,"example":"Family budget","description":"Account name; `null` when the user is no longer a member of it (`reason: role`)."},"type":{"type":"string","nullable":true,"enum":["personal","business","shared","trip","investment"],"example":"shared","description":"Account type; `null` when the user is no longer a member of it."},"base_currency":{"type":"string","nullable":true,"example":"PLN","description":"The account's base currency (ISO 4217); `null` when the user is no longer a member of it."},"access":{"type":"string","enum":["read","read_write"],"example":"read_write","description":"`read_write` when this connection may write to the account, otherwise `read`."},"active":{"type":"boolean","example":true,"description":"`false` when requests addressed to this account are refused."},"reason":{"type":"string","enum":["role","encrypted","archived"],"nullable":true,"example":null,"description":"Why the account is limited: `role` (the user lost owner/editor rights), `encrypted` (end-to-end encrypted), `archived` (archived trip, read-only); `null` otherwise."}},"required":["id","name","type","base_currency","access","active","reason"],"description":"One account of the connection."},"PublicConnectedAccountList":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/PublicConnectedAccount"},"description":"Accounts, in the order they were connected."}},"required":["data"],"description":"The accounts of the connection."},"PublicBudgetAllocation":{"type":"object","properties":{"category_id":{"type":"string","format":"uuid","example":"8a6e0f52-1c3b-4d7e-a9f8-2b4c6d8e0f13","description":"Expense category id."},"category_name":{"type":"string","nullable":true,"example":"Food","description":"Category name; `null` when withheld (see `encrypted_fields`)."},"amount":{"type":"string","pattern":"^\\d+(\\.\\d+)?$","example":"300.00","description":"Allocated amount as a decimal string in the budget currency."}},"required":["category_id","category_name","amount"],"description":"A category allocation of a budget."},"PublicBudgetProgress":{"type":"object","properties":{"period_start":{"type":"string","format":"date","example":"2026-11-01","description":"First day of the current period, `YYYY-MM-DD`. A monthly budget follows the account's financial-month start day."},"period_end":{"type":"string","format":"date","example":"2026-11-30","description":"Last day of the current period, `YYYY-MM-DD` (inclusive)."},"spent":{"type":"string","pattern":"^\\d+(\\.\\d+)?$","example":"412.30","description":"Spent so far in the period, decimal string. Planned expenses and receivables from split bills are not counted."},"remaining":{"type":"string","pattern":"^\\d+(\\.\\d+)?$","example":"1087.70","description":"What is left of the amount, decimal string; `\"0.00\"` when overspent."},"percent_used":{"type":"number","example":27.5,"description":"Percent of the amount spent, one decimal place; may exceed 100."},"projected_total":{"type":"string","pattern":"^\\d+(\\.\\d+)?$","example":"1200.00","description":"Estimated spending at the end of the period, decimal string; equals `spent` while too little of the period has elapsed to project."}},"required":["period_start","period_end","spent","remaining","percent_used","projected_total"],"description":"Spending against the budget in the CURRENT period, in the budget currency (never converted)."},"PublicBudget":{"type":"object","properties":{"id":{"type":"string","format":"uuid","example":"4c8e2a6f-1b3d-4f5a-9c7e-0a2b4d6f8e10","description":"Budget id."},"name":{"type":"string","nullable":true,"example":"Groceries","description":"Budget name; `null` when withheld (see `encrypted_fields`)."},"amount":{"type":"string","pattern":"^\\d+(\\.\\d+)?$","example":"1500.00","description":"Budget amount as a decimal string in the budget currency."},"currency":{"type":"string","example":"PLN","description":"ISO 4217 currency code of the budget."},"period":{"type":"string","enum":["daily","weekly","monthly","yearly","custom"],"example":"monthly","description":"Budget period: `daily`, `weekly`, `monthly`, `yearly` or `custom`."},"start_date":{"type":"string","format":"date","example":"2026-11-01","description":"Start date, `YYYY-MM-DD`."},"end_date":{"type":"string","format":"date","nullable":true,"example":null,"description":"End date, `YYYY-MM-DD`, or `null`."},"alert_threshold":{"type":"integer","example":80,"description":"Percent of the amount at which the app warns."},"is_active":{"type":"boolean","example":true,"description":"Whether the budget is active."},"allocations":{"type":"array","items":{"$ref":"#/components/schemas/PublicBudgetAllocation"},"description":"Category allocations. Empty means the budget covers all spending in its currency."},"progress":{"allOf":[{"$ref":"#/components/schemas/PublicBudgetProgress"}],"description":"Spending in the current period."},"encrypted_fields":{"example":[],"type":"array","items":{"type":"string"},"description":"On an end-to-end encrypted account: the fields returned as `null` because they are encrypted — `name`, `allocations.category_name`. Otherwise `[]`."},"created_at":{"type":"string","format":"date-time","example":"2026-11-01T09:00:00.000Z","description":"When the budget was created (ISO-8601, UTC)."},"updated_at":{"type":"string","format":"date-time","example":"2026-11-01T09:00:00.000Z","description":"When the budget last changed (ISO-8601, UTC)."}},"required":["id","name","amount","currency","period","start_date","end_date","alert_threshold","is_active","allocations","progress","encrypted_fields","created_at","updated_at"],"description":"A budget."},"PublicBudgetList":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/PublicBudget"},"description":"The budgets."}},"required":["data"],"description":"The budgets of the account."},"PublicBudgetAllocationInput":{"type":"object","properties":{"category_id":{"type":"string","format":"uuid","example":"8a6e0f52-1c3b-4d7e-a9f8-2b4c6d8e0f13","description":"One of the expense categories from `GET /public/v1/categories?type=expense`."},"amount":{"type":"string","pattern":"^(0|[1-9]\\d{0,9})(\\.\\d{1,2})?$","example":"300.00","description":"Decimal string, at most 2 decimal places, e.g. `\"300.00\"`."}},"required":["category_id","amount"],"description":"A category allocation to write."},"PublicBudgetCreateDto":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":200,"example":"Groceries","description":"Budget name, 1–200 characters."},"amount":{"type":"string","pattern":"^(0|[1-9]\\d{0,9})(\\.\\d{1,2})?$","example":"1500.00","description":"Decimal string, at most 2 decimal places, e.g. `\"1500.00\"`."},"currency":{"type":"string","pattern":"^[A-Z]{3}$","example":"PLN","description":"ISO 4217 code in upper case. Default: the account's base currency."},"period":{"type":"string","enum":["daily","weekly","monthly","yearly","custom"],"example":"monthly","description":"One of `daily`, `weekly`, `monthly`, `yearly`, `custom`."},"start_date":{"type":"string","format":"date","example":"2026-11-01","description":"Start date, `YYYY-MM-DD`."},"end_date":{"type":"string","format":"date","example":"2026-11-30","description":"End date, `YYYY-MM-DD`; meaningful for `custom` budgets."},"alert_threshold":{"type":"integer","minimum":1,"maximum":100,"example":80,"description":"Percent of the amount at which the app warns, 1–100. Default 80."},"allocations":{"maxItems":100,"type":"array","items":{"$ref":"#/components/schemas/PublicBudgetAllocationInput"},"description":"Category allocations (up to 100). Omit to cover all spending in the budget currency."}},"required":["name","amount","period","start_date"],"description":"A new budget. Fields not listed here are refused."},"PublicBudgetUpdateDto":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":200,"example":"Food","description":"Budget name, 1–200 characters."},"amount":{"type":"string","pattern":"^(0|[1-9]\\d{0,9})(\\.\\d{1,2})?$","example":"1600.00","description":"Decimal string, at most 2 decimal places."},"currency":{"type":"string","pattern":"^[A-Z]{3}$","example":"EUR","description":"ISO 4217 code in upper case."},"period":{"type":"string","enum":["daily","weekly","monthly","yearly","custom"],"example":"weekly","description":"One of `daily`, `weekly`, `monthly`, `yearly`, `custom`."},"start_date":{"type":"string","format":"date","example":"2026-11-01","description":"Start date, `YYYY-MM-DD`."},"end_date":{"type":"string","format":"date","example":"2026-12-31","description":"End date, `YYYY-MM-DD`."},"alert_threshold":{"type":"integer","minimum":1,"maximum":100,"example":90,"description":"Percent of the amount at which the app warns, 1–100."},"allocations":{"maxItems":100,"type":"array","items":{"$ref":"#/components/schemas/PublicBudgetAllocationInput"},"description":"Replaces ALL category allocations; `[]` removes them. Omit to keep the current ones."}},"description":"The fields to change. Omitted fields stay as they are; no field can be `null`."},"PublicCategory":{"type":"object","properties":{"id":{"type":"string","format":"uuid","example":"8a6e0f52-1c3b-4d7e-a9f8-2b4c6d8e0f13","description":"Category id — the value for `category_id`."},"name":{"type":"string","nullable":true,"example":"Groceries","description":"Name; `null` on an end-to-end encrypted account."},"parent_id":{"type":"string","format":"uuid","nullable":true,"example":null,"description":"Parent category id for a subcategory, otherwise `null`."},"icon":{"type":"string","nullable":true,"example":"cart","description":"Icon name used by the app."},"color":{"type":"string","nullable":true,"example":"#4CAF50","description":"Colour used by the app, e.g. `#4CAF50`."},"type":{"type":"string","example":"expense","description":"Always `expense` in this version."},"encrypted_fields":{"example":[],"type":"array","items":{"type":"string"},"description":"Fields withheld (`null`) because the account is end-to-end encrypted: `[\"name\"]` or `[]`."}},"required":["id","name","parent_id","icon","color","type","encrypted_fields"],"description":"An expense category."},"PublicCategoryList":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/PublicCategory"},"description":"The categories."}},"required":["data"],"description":"Expense categories."},"PublicLocation":{"type":"object","properties":{"lat":{"type":"number","example":52.2297,"description":"Latitude."},"lng":{"type":"number","example":21.0122,"description":"Longitude."},"name":{"type":"string","nullable":true,"example":"Marszałkowska 10, Warszawa","description":"Place name; `null` when unset or withheld (see `encrypted_fields`)."}},"required":["lat","lng","name"],"description":"A location."},"PublicRef":{"type":"object","properties":{"id":{"type":"string","format":"uuid","example":"c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f","description":"Id."},"name":{"type":"string","nullable":true,"example":"household","description":"Name; `null` when withheld (see `encrypted_fields`)."}},"required":["id","name"],"description":"A tag or project."},"PublicCategorySplit":{"type":"object","properties":{"category_id":{"type":"string","format":"uuid","example":"8a6e0f52-1c3b-4d7e-a9f8-2b4c6d8e0f13","description":"Category id."},"amount":{"type":"string","pattern":"^\\d+(\\.\\d+)?$","example":"30.00","description":"The share, as a decimal string in the expense currency."}},"required":["category_id","amount"],"description":"One category's share of a split expense."},"PublicExpenseItem":{"type":"object","properties":{"id":{"type":"string","format":"uuid","example":"9b8a7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d","description":"Line item id."},"description":{"type":"string","nullable":true,"example":"Milk 2% 1 l","description":"Product name; `null` when withheld (see `encrypted_fields`)."},"quantity":{"type":"string","pattern":"^\\d+(\\.\\d+)?$","example":"2.000","description":"Quantity, decimal string with three places."},"unit_price":{"type":"string","pattern":"^\\d+(\\.\\d+)?$","example":"3.49","description":"Unit price, decimal string."},"total_price":{"type":"string","pattern":"^\\d+(\\.\\d+)?$","example":"6.98","description":"Line total, decimal string."},"line_discount":{"type":"string","pattern":"^\\d+(\\.\\d+)?$","example":"0.00","description":"Discount on this line, decimal string (`\"0.00\"` when none)."},"category_id":{"type":"string","format":"uuid","nullable":true,"example":"8a6e0f52-1c3b-4d7e-a9f8-2b4c6d8e0f13","description":"Category of this line, or `null`."}},"required":["id","description","quantity","unit_price","total_price","line_discount","category_id"],"description":"A receipt line item."},"PublicCreatedByApp":{"type":"object","properties":{"id":{"type":"string","example":"aba_ci_q7Lx2Vb9TmR4kWc8Zp1sYg","description":"The app's public `client_id` — compare it with yours to recognise your own writes."},"name":{"type":"string","example":"AI Accounting","description":"App name."}},"required":["id","name"],"description":"An app connected through this API."},"PublicCreatedByUser":{"type":"object","properties":{"id":{"type":"string","format":"uuid","example":"2d4f6a8c-0e1b-4c3d-9e5f-7a9b1c3d5e7f","description":"User id."},"name":{"type":"string","example":"Anna","description":"Display name."}},"required":["id","name"],"description":"A member of the account."},"PublicExpense":{"type":"object","properties":{"id":{"type":"string","format":"uuid","example":"3f2b8c1e-7d4a-4f6b-9c2e-1a5d8e9f0b37","description":"Expense id."},"amount":{"type":"string","pattern":"^\\d+(\\.\\d+)?$","example":"42.50","description":"Decimal string in the expense's own `currency` (never converted), e.g. `\"42.50\"`."},"currency":{"type":"string","example":"PLN","description":"ISO 4217 code."},"date":{"type":"string","format":"date","example":"2026-10-31","description":"Date of the expense, `YYYY-MM-DD`."},"time":{"type":"string","nullable":true,"example":"18:45","description":"Time as recorded, `HH:mm`, or `null`."},"category_id":{"type":"string","format":"uuid","nullable":true,"example":"8a6e0f52-1c3b-4d7e-a9f8-2b4c6d8e0f13","description":"Category id, or `null` when uncategorized."},"description":{"type":"string","nullable":true,"example":"Weekly groceries","description":"Description. `null` when empty or withheld (see `encrypted_fields`)."},"merchant":{"type":"string","nullable":true,"example":"Biedronka","description":"Merchant. `null` when empty or withheld (see `encrypted_fields`)."},"notes":{"type":"string","nullable":true,"example":null,"description":"Notes. `null` when empty or withheld (see `encrypted_fields`)."},"location":{"nullable":true,"allOf":[{"$ref":"#/components/schemas/PublicLocation"}],"description":"Where the expense was made, or `null`."},"tags":{"type":"array","items":{"$ref":"#/components/schemas/PublicRef"},"description":"Tags of the expense."},"project":{"nullable":true,"allOf":[{"$ref":"#/components/schemas/PublicRef"}],"description":"The project the expense belongs to, or `null`."},"category_splits":{"type":"array","items":{"$ref":"#/components/schemas/PublicCategorySplit"},"description":"When the expense is split across several categories: the share of each. Empty when it is not split."},"items":{"type":"array","items":{"$ref":"#/components/schemas/PublicExpenseItem"},"description":"Receipt line items. Present on a single expense and in the changes feed; absent from the list."},"discount_amount":{"type":"string","pattern":"^\\d+(\\.\\d+)?$","nullable":true,"example":null,"description":"Total discount on the receipt, as a decimal string, or `null`."},"deposit_amount":{"type":"string","pattern":"^\\d+(\\.\\d+)?$","nullable":true,"example":null,"description":"Bottle/packaging deposit already paid within the amount, as a decimal string, or `null`."},"is_debt":{"type":"boolean","example":false,"description":"Money lent to someone (a debt), not ordinary spending."},"is_debt_repayment":{"type":"boolean","example":false,"description":"A repayment of money the user borrowed."},"is_split_receivable":{"type":"boolean","example":false,"description":"The part of a shared bill that others owe the user back, not the user's own spending."},"is_recurring":{"type":"boolean","example":false,"description":"Part of a recurring series."},"source":{"type":"string","example":"api","description":"How the expense was recorded, for example `manual`, `voice`, `ocr`, `import`, `telegram`, `whatsapp`, `slack`, `notification`, `group` (a share of a shared group expense) or `api`. Treat it as open: new values may appear."},"created_by_app":{"nullable":true,"allOf":[{"$ref":"#/components/schemas/PublicCreatedByApp"}],"description":"The app that created the expense through this API, or `null`."},"created_by_user":{"allOf":[{"$ref":"#/components/schemas/PublicCreatedByUser"}],"description":"The user who recorded the expense."},"has_receipt":{"type":"boolean","example":false,"description":"A receipt file is attached (download it with `GET /public/v1/expenses/{id}/receipt`)."},"encrypted_fields":{"example":[],"type":"array","items":{"type":"string"},"description":"On an end-to-end encrypted account: the fields returned as `null` because they are encrypted — `description`, `notes`, `merchant`, `location.name`, `items.description`, `tags.name`, `project.name`. Otherwise `[]`."},"created_at":{"type":"string","format":"date-time","example":"2026-10-31T17:46:02.114Z","description":"When the expense was created (ISO-8601, UTC)."},"updated_at":{"type":"string","format":"date-time","example":"2026-10-31T17:46:02.114Z","description":"When the expense last changed (ISO-8601, UTC)."}},"required":["id","amount","currency","date","time","category_id","description","merchant","notes","location","tags","project","category_splits","discount_amount","deposit_amount","is_debt","is_debt_repayment","is_split_receivable","is_recurring","source","created_by_app","created_by_user","has_receipt","encrypted_fields","created_at","updated_at"],"description":"An expense."},"PublicExpenseList":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/PublicExpense"},"description":"The expenses of this page (without `items`)."},"next_cursor":{"type":"string","nullable":true,"example":"eyJ1IjoiMjAyNi0xMC0zMVQxMjozNDo1Ni43ODlaIiwiaSI6IjNmMmI4YzFlIn0","description":"Pass as `cursor` to get the next page; `null` on the last page."},"has_more":{"type":"boolean","example":true,"description":"Whether another page follows."}},"required":["data","next_cursor","has_more"],"description":"One page of expenses."},"PublicExpenseUpsertChange":{"type":"object","properties":{"type":{"type":"string","enum":["upsert"],"example":"upsert","description":"Always `upsert`."},"expense":{"allOf":[{"$ref":"#/components/schemas/PublicExpense"}],"description":"The expense in its current state, with line items. Insert or replace your copy."}},"required":["type","expense"],"description":"An expense was created or changed."},"PublicExpenseDeleteChange":{"type":"object","properties":{"type":{"type":"string","enum":["delete"],"example":"delete","description":"Always `delete`."},"id":{"type":"string","format":"uuid","example":"3f2b8c1e-7d4a-4f6b-9c2e-1a5d8e9f0b37","description":"Id of the expense to remove from your copy. It may be an id you never received."},"deleted_at":{"type":"string","format":"date-time","example":"2026-10-31T18:02:40.517Z","description":"When it was deleted or stopped being visible (ISO-8601, UTC)."}},"required":["type","id","deleted_at"],"description":"An expense was deleted or is no longer visible (it became a planned expense)."},"PublicExpenseChanges":{"type":"object","properties":{"changes":{"type":"array","items":{"oneOf":[{"$ref":"#/components/schemas/PublicExpenseUpsertChange"},{"$ref":"#/components/schemas/PublicExpenseDeleteChange"}],"discriminator":{"propertyName":"type","mapping":{"upsert":"#/components/schemas/PublicExpenseUpsertChange","delete":"#/components/schemas/PublicExpenseDeleteChange"}}},"description":"Changes, oldest first. Apply them in order."},"next_cursor":{"type":"string","example":"eyJ1IjoiMjAyNi0xMC0zMVQxMjozNDo1Ni43ODlaIiwiaSI6IjNmMmI4YzFlIn0","description":"Always present (on an empty page it equals the cursor you sent). Store it after processing the page and send it next time."},"has_more":{"type":"boolean","example":false,"description":"Whether more changes are available right now. When `false`, poll again later with `next_cursor`."}},"required":["changes","next_cursor","has_more"],"description":"One page of the changes feed."},"PublicLocationInput":{"type":"object","properties":{"lat":{"type":"number","minimum":-90,"maximum":90,"example":52.2297,"description":"Latitude, −90 to 90."},"lng":{"type":"number","minimum":-180,"maximum":180,"example":21.0122,"description":"Longitude, −180 to 180."},"name":{"type":"string","nullable":true,"maxLength":200,"example":"Marszałkowska 10, Warszawa","description":"Place name, up to 200 characters."}},"required":["lat","lng"],"description":"A location."},"PublicExpenseItemInput":{"type":"object","properties":{"description":{"type":"string","minLength":1,"maxLength":500,"example":"Milk 2% 1 l","description":"Product name, 1–500 characters."},"quantity":{"type":"string","pattern":"^(0|[1-9]\\d{0,6})(\\.\\d{1,3})?$","default":"1","example":"2","description":"Decimal string greater than 0, up to 3 decimal places. Default `\"1\"`."},"unit_price":{"type":"string","pattern":"^(0|[1-9]\\d{0,9})(\\.\\d{1,2})?$","default":"0","example":"3.49","description":"Decimal string, up to 2 decimal places. Default `\"0\"`."},"total_price":{"type":"string","pattern":"^(0|[1-9]\\d{0,9})(\\.\\d{1,2})?$","example":"6.98","description":"Line total, decimal string, up to 2 decimal places."}},"required":["description","total_price"],"description":"A receipt line item."},"PublicExpenseCreateDto":{"type":"object","properties":{"amount":{"type":"string","pattern":"^(0|[1-9]\\d{0,9})(\\.\\d{1,2})?$","example":"42.50","description":"Decimal string greater than 0, at most 2 decimal places, e.g. `\"42.50\"`."},"currency":{"type":"string","pattern":"^[A-Z]{3}$","example":"PLN","description":"ISO 4217 code in upper case. Default: the account's base currency."},"date":{"type":"string","format":"date","example":"2026-10-31","description":"Date of the expense, `YYYY-MM-DD`."},"time":{"type":"string","pattern":"^([01]\\d|2[0-3]):[0-5]\\d$","example":"18:45","description":"Time, `HH:mm` (24-hour)."},"category_id":{"type":"string","format":"uuid","example":"8a6e0f52-1c3b-4d7e-a9f8-2b4c6d8e0f13","description":"One of the categories from `GET /public/v1/categories`."},"description":{"type":"string","maxLength":500,"example":"Weekly groceries","description":"Description, up to 500 characters."},"merchant":{"type":"string","maxLength":120,"example":"Biedronka","description":"Merchant, up to 120 characters."},"notes":{"type":"string","maxLength":2000,"example":"Paid with the family card","description":"Notes, up to 2,000 characters."},"location":{"allOf":[{"$ref":"#/components/schemas/PublicLocationInput"}],"description":"Where the expense was made."},"tag_ids":{"maxItems":50,"uniqueItems":true,"example":["c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f"],"type":"array","items":{"type":"string","format":"uuid"},"description":"Ids of tags of this account (up to 50, no duplicates)."},"project_id":{"type":"string","format":"uuid","example":"5e4d3c2b-1a09-4f8e-b7d6-c5b4a3928170","description":"Id of a project of this account."},"items":{"minItems":1,"maxItems":500,"type":"array","items":{"$ref":"#/components/schemas/PublicExpenseItemInput"},"description":"Receipt line items (1–500). Only on create."}},"required":["amount","date"],"description":"A new expense. Fields not listed here are refused."},"PublicExpenseUpdateDto":{"type":"object","properties":{"amount":{"type":"string","pattern":"^(0|[1-9]\\d{0,9})(\\.\\d{1,2})?$","example":"45.00","description":"Decimal string greater than 0. Cannot be `null`."},"currency":{"type":"string","pattern":"^[A-Z]{3}$","example":"EUR","description":"ISO 4217 code in upper case. Cannot be `null`."},"date":{"type":"string","format":"date","example":"2026-10-30","description":"`YYYY-MM-DD`. Cannot be `null`."},"time":{"type":"string","nullable":true,"pattern":"^([01]\\d|2[0-3]):[0-5]\\d$","example":"09:15","description":"`HH:mm`, or `null` to clear."},"category_id":{"type":"string","nullable":true,"format":"uuid","example":"8a6e0f52-1c3b-4d7e-a9f8-2b4c6d8e0f13","description":"A category from `GET /public/v1/categories`, or `null` to clear."},"description":{"type":"string","nullable":true,"maxLength":500,"example":"Groceries and household","description":"Description, or `null` to clear."},"merchant":{"type":"string","nullable":true,"maxLength":120,"example":"Lidl","description":"Merchant, or `null` to clear."},"notes":{"type":"string","nullable":true,"maxLength":2000,"example":null,"description":"Notes, or `null` to clear."},"location":{"nullable":true,"allOf":[{"$ref":"#/components/schemas/PublicLocationInput"}],"description":"Location, or `null` to clear."},"tag_ids":{"maxItems":50,"uniqueItems":true,"example":[],"type":"array","items":{"type":"string","format":"uuid"},"description":"Replaces all tags; `[]` removes every tag. Cannot be `null`."},"project_id":{"type":"string","nullable":true,"format":"uuid","example":null,"description":"A project of this account, or `null` to remove the expense from its project."}},"description":"The fields to change. Omitted fields stay as they are; `null` clears a field where allowed."},"PublicIncome":{"type":"object","properties":{"id":{"type":"string","format":"uuid","example":"6b1f4a7c-2e9d-4c3a-8f5b-0d7e9a1c3b25","description":"Income id."},"amount":{"type":"string","pattern":"^\\d+(\\.\\d+)?$","example":"4200.00","description":"Amount as a decimal string in the income currency, never converted."},"currency":{"type":"string","example":"PLN","description":"ISO 4217 currency code."},"date":{"type":"string","format":"date","example":"2026-10-31","description":"Date of the income, `YYYY-MM-DD`."},"description":{"type":"string","nullable":true,"example":"October salary","description":"Description; `null` when empty or withheld (see `encrypted_fields`)."},"notes":{"type":"string","nullable":true,"example":null,"description":"Notes; `null` when empty or withheld (see `encrypted_fields`)."},"category":{"nullable":true,"allOf":[{"$ref":"#/components/schemas/PublicRef"}],"description":"Category of the income (`id` and `name`), or `null`. `name` is `null` when withheld."},"tags":{"type":"array","items":{"$ref":"#/components/schemas/PublicRef"},"description":"Tags of the income."},"project":{"nullable":true,"allOf":[{"$ref":"#/components/schemas/PublicRef"}],"description":"Project of the income, or `null`."},"source":{"type":"string","example":"api","description":"How the income was recorded, for example `manual`, `voice`, `ocr`, `import`, `telegram`, `whatsapp`, `slack` or `api`. Treat it as open: new values may appear."},"is_debt":{"type":"boolean","example":false,"description":"The income is the borrowed side of a debt."},"is_debt_repayment":{"type":"boolean","example":false,"description":"The income is a repayment of a debt."},"is_split_receivable":{"type":"boolean","example":false,"description":"The money is accounted for by another row, so the income is kept out of income totals."},"is_transfer":{"type":"boolean","example":false,"description":"The income is the receiving leg of a transfer between two accounts of the user."},"created_by_app":{"nullable":true,"allOf":[{"$ref":"#/components/schemas/PublicCreatedByApp"}],"description":"The app that created the income through this API, or `null`."},"encrypted_fields":{"example":[],"type":"array","items":{"type":"string"},"description":"On an end-to-end encrypted account: the fields returned as `null` because they are encrypted — `description`, `notes`, `category.name`, `tags.name`, `project.name`. Otherwise `[]`."},"created_at":{"type":"string","format":"date-time","example":"2026-10-31T17:46:02.114Z","description":"When the income was created (ISO-8601, UTC)."},"updated_at":{"type":"string","format":"date-time","example":"2026-10-31T17:46:02.114Z","description":"When the income last changed (ISO-8601, UTC)."}},"required":["id","amount","currency","date","description","notes","category","tags","project","source","is_debt","is_debt_repayment","is_split_receivable","is_transfer","created_by_app","encrypted_fields","created_at","updated_at"],"description":"An income record."},"PublicIncomeList":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/PublicIncome"},"description":"The incomes of this page."},"next_cursor":{"type":"string","nullable":true,"example":"eyJ1IjoiMjAyNi0xMC0zMVQxMjozNDo1Ni43ODlaIiwiaSI6IjNmMmI4YzFlIn0","description":"Pass as `cursor` to get the next page; `null` on the last page."},"has_more":{"type":"boolean","example":true,"description":"Whether another page follows."}},"required":["data","next_cursor","has_more"],"description":"One page of incomes."},"PublicIncomeUpsertChange":{"type":"object","properties":{"type":{"type":"string","enum":["upsert"],"example":"upsert","description":"Always `upsert`."},"income":{"allOf":[{"$ref":"#/components/schemas/PublicIncome"}],"description":"The income in its current state. Insert or replace your copy."}},"required":["type","income"],"description":"An income was created or changed."},"PublicIncomeDeleteChange":{"type":"object","properties":{"type":{"type":"string","enum":["delete"],"example":"delete","description":"Always `delete`."},"id":{"type":"string","format":"uuid","example":"6b1f4a7c-2e9d-4c3a-8f5b-0d7e9a1c3b25","description":"Id of the income to remove from your copy. It may be an id you never received."},"deleted_at":{"type":"string","format":"date-time","example":"2026-10-31T18:02:40.517Z","description":"When it was deleted (ISO-8601, UTC)."}},"required":["type","id","deleted_at"],"description":"An income was deleted."},"PublicIncomeChanges":{"type":"object","properties":{"changes":{"type":"array","items":{"oneOf":[{"$ref":"#/components/schemas/PublicIncomeUpsertChange"},{"$ref":"#/components/schemas/PublicIncomeDeleteChange"}],"discriminator":{"propertyName":"type","mapping":{"upsert":"#/components/schemas/PublicIncomeUpsertChange","delete":"#/components/schemas/PublicIncomeDeleteChange"}}},"description":"Changes, oldest first. Apply them in order."},"next_cursor":{"type":"string","example":"eyJ1IjoiMjAyNi0xMC0zMVQxMjozNDo1Ni43ODlaIiwiaSI6IjNmMmI4YzFlIn0","description":"Always present (on an empty page it equals the cursor you sent). Store it after processing the page and send it next time."},"has_more":{"type":"boolean","example":false,"description":"Whether more changes are available right now. When `false`, poll again later with `next_cursor`."}},"required":["changes","next_cursor","has_more"],"description":"One page of the incomes changes feed."},"PublicIncomeCreateDto":{"type":"object","properties":{"amount":{"type":"string","pattern":"^(0|[1-9]\\d{0,9})(\\.\\d{1,2})?$","example":"4200.00","description":"Decimal string greater than 0, at most 2 decimal places, e.g. `\"4200.00\"`."},"currency":{"type":"string","pattern":"^[A-Z]{3}$","example":"PLN","description":"ISO 4217 code in upper case. Default: the account's base currency."},"date":{"type":"string","format":"date","example":"2026-10-31","description":"Date of the income, `YYYY-MM-DD`."},"category_id":{"type":"string","format":"uuid","example":"8a6e0f52-1c3b-4d7e-a9f8-2b4c6d8e0f13","description":"One of the income categories from `GET /public/v1/categories?type=income`."},"description":{"type":"string","maxLength":500,"example":"October salary","description":"Description, up to 500 characters."},"notes":{"type":"string","maxLength":2000,"example":"Including the quarterly bonus","description":"Notes, up to 2,000 characters."},"tag_ids":{"maxItems":50,"uniqueItems":true,"example":["c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f"],"type":"array","items":{"type":"string","format":"uuid"},"description":"Ids of tags of this account (up to 50, no duplicates)."},"project_id":{"type":"string","format":"uuid","example":"5e4d3c2b-1a09-4f8e-b7d6-c5b4a3928170","description":"Id of a project of this account."}},"required":["amount","date"],"description":"A new income. Fields not listed here are refused."},"PublicIncomeUpdateDto":{"type":"object","properties":{"amount":{"type":"string","pattern":"^(0|[1-9]\\d{0,9})(\\.\\d{1,2})?$","example":"4300.00","description":"Decimal string greater than 0. Cannot be `null`."},"currency":{"type":"string","pattern":"^[A-Z]{3}$","example":"EUR","description":"ISO 4217 code in upper case. Cannot be `null`."},"date":{"type":"string","format":"date","example":"2026-10-30","description":"`YYYY-MM-DD`. Cannot be `null`."},"category_id":{"type":"string","nullable":true,"format":"uuid","example":"8a6e0f52-1c3b-4d7e-a9f8-2b4c6d8e0f13","description":"An income category from `GET /public/v1/categories?type=income`, or `null` to clear."},"description":{"type":"string","nullable":true,"maxLength":500,"example":"Salary","description":"Description, or `null` to clear."},"notes":{"type":"string","nullable":true,"maxLength":2000,"example":null,"description":"Notes, or `null` to clear."},"tag_ids":{"maxItems":50,"uniqueItems":true,"example":[],"type":"array","items":{"type":"string","format":"uuid"},"description":"Replaces all tags; `[]` removes every tag. Cannot be `null`."},"project_id":{"type":"string","nullable":true,"format":"uuid","example":null,"description":"A project of this account, or `null` to remove the income from its project."}},"description":"The fields to change. Omitted fields stay as they are; `null` clears a field where allowed."},"PublicAnalyticsSummary":{"type":"object","properties":{"currency":{"type":"string","example":"PLN","description":"ISO 4217 code of every amount in the response."},"from":{"type":"string","format":"date","example":"2026-10-01","description":"First day of the period, as requested."},"to":{"type":"string","format":"date","example":"2026-10-31","description":"Last day of the period, as requested."},"fx_converted":{"type":"boolean","example":true,"description":"`true` when at least one counted amount was converted from another currency."},"fx_approximate":{"type":"boolean","example":false,"description":"`true` when at least one record was left out because its currency had no known exchange rate."},"encrypted_fields":{"example":[],"type":"array","items":{"type":"string"},"description":"Always `[]`: amounts are not end-to-end encrypted."},"total_income":{"type":"string","pattern":"^\\d+\\.\\d{2}$","example":"8200.00","description":"Income counted, decimal string in `currency`."},"total_expenses":{"type":"string","pattern":"^\\d+\\.\\d{2}$","example":"5310.45","description":"Spending counted, decimal string in `currency`."},"net":{"type":"string","pattern":"^-?\\d+\\.\\d{2}$","example":"2889.55","description":"`total_income` minus `total_expenses`; negative when more was spent."},"transaction_count":{"type":"integer","example":87,"description":"Number of expense and income records counted (a record left out for lack of an exchange rate is not)."}},"required":["currency","from","to","fx_converted","fx_approximate","encrypted_fields","total_income","total_expenses","net","transaction_count"],"description":"Totals of a period, in one currency."},"PublicAnalyticsCategoryAmount":{"type":"object","properties":{"category_id":{"type":"string","format":"uuid","nullable":true,"example":"8a6e0f52-1c3b-4d7e-a9f8-2b4c6d8e0f13","description":"The category id; `null` for records without a category."},"category_name":{"type":"string","nullable":true,"example":"Food","description":"The category name; `null` without a category, or when hidden (see `encrypted_fields`)."},"amount":{"type":"string","pattern":"^\\d+\\.\\d{2}$","example":"1240.30","description":"Total of the category, decimal string in `currency`."},"share":{"type":"number","example":0.2336,"description":"The category's part of the sum of all categories, from 0 to 1, rounded to 4 decimal places."}},"required":["category_id","category_name","amount","share"],"description":"One category's total."},"PublicAnalyticsByCategory":{"type":"object","properties":{"currency":{"type":"string","example":"PLN","description":"ISO 4217 code of every amount in the response."},"from":{"type":"string","format":"date","example":"2026-10-01","description":"First day of the period, as requested."},"to":{"type":"string","format":"date","example":"2026-10-31","description":"Last day of the period, as requested."},"fx_converted":{"type":"boolean","example":true,"description":"`true` when at least one counted amount was converted from another currency."},"fx_approximate":{"type":"boolean","example":false,"description":"`true` when at least one record was left out because its currency had no known exchange rate."},"encrypted_fields":{"example":[],"type":"array","items":{"type":"string"},"description":"On an end-to-end encrypted account: `category_name`, returned as `null` because category names are encrypted. Otherwise `[]`."},"data":{"type":"array","items":{"$ref":"#/components/schemas/PublicAnalyticsCategoryAmount"},"description":"The categories, largest amount first."}},"required":["currency","from","to","fx_converted","fx_approximate","encrypted_fields","data"],"description":"Totals per category, in one currency."},"PublicAnalyticsMonth":{"type":"object","properties":{"month":{"type":"string","pattern":"^\\d{4}-\\d{2}$","example":"2026-10","description":"The month, `YYYY-MM`."},"income":{"type":"string","pattern":"^\\d+\\.\\d{2}$","example":"8200.00","description":"Income counted in the month, decimal string."},"expenses":{"type":"string","pattern":"^\\d+\\.\\d{2}$","example":"5310.45","description":"Spending counted in the month, decimal string."},"net":{"type":"string","pattern":"^-?\\d+\\.\\d{2}$","example":"2889.55","description":"Income minus spending; may be negative."}},"required":["month","income","expenses","net"],"description":"Totals of one calendar month."},"PublicAnalyticsMonthly":{"type":"object","properties":{"currency":{"type":"string","example":"PLN","description":"ISO 4217 code of every amount in the response."},"from":{"type":"string","format":"date","example":"2026-10-01","description":"First day of the period, as requested."},"to":{"type":"string","format":"date","example":"2026-10-31","description":"Last day of the period, as requested."},"fx_converted":{"type":"boolean","example":true,"description":"`true` when at least one counted amount was converted from another currency."},"fx_approximate":{"type":"boolean","example":false,"description":"`true` when at least one record was left out because its currency had no known exchange rate."},"encrypted_fields":{"example":[],"type":"array","items":{"type":"string"},"description":"Always `[]`: amounts are not end-to-end encrypted."},"data":{"type":"array","items":{"$ref":"#/components/schemas/PublicAnalyticsMonth"},"description":"One entry per calendar month of the range, oldest first."}},"required":["currency","from","to","fx_converted","fx_approximate","encrypted_fields","data"],"description":"Totals per calendar month, in one currency."}}}}