# CEO Table API Version 1 · Base URL `https://ceotable.dev/api/v1` The CEO Table API lets another app, or your own script, read and change a CEO Table account: tasks and their checklists, projects and sub-projects, people, weekly commitments, repeating meetings, and the Finance module's books, accounts, categories, transactions and budgets. It is a JSON API over HTTPS, meant to be called from a server. Every request acts as the person who created the API key, within the access they gave that key. - [Quick start](#quick-start) - [Authentication](#authentication) - [Requests and responses](#requests-and-responses) - [Conventions](#conventions) - [Lists, pagination and sync](#lists-pagination-and-sync) - [Rate limits](#rate-limits) - [Idempotent retries](#idempotent-retries) - [Errors](#errors) - [Reference: account](#account) - [Reference: tasks](#tasks) - [Reference: projects and sub-projects](#projects) - [Reference: people and weekly commitments](#people) - [Reference: repeating meetings](#repeating-meetings) - [Reference: finance](#finance) - [Behaviour worth knowing](#behaviour-worth-knowing) - [Changelog](#changelog) ## Quick start 1. In CEO Table, open **Settings → Developer API → Manage keys** and choose **Create key**. Give it a name and the access it needs, then copy the key. It is shown only once. 2. Check the key works: ```bash curl https://ceotable.dev/api/v1/me \ -H "Authorization: Bearer $CEOTABLE_KEY" ``` 3. Create a task: ```bash curl https://ceotable.dev/api/v1/tasks \ -H "Authorization: Bearer $CEOTABLE_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 6c1f0e3a-board-deck" \ -d '{"title": "Send the board deck", "project_id": "3f6d…", "deadline": "2026-10-24", "impact": 5}' ``` 4. Record some spending: ```bash curl https://ceotable.dev/api/v1/finance/transactions \ -H "Authorization: Bearer $CEOTABLE_KEY" \ -H "Content-Type: application/json" \ -d '{"kind": "expense", "account_id": "9a1c…", "amount": "-75000", "payee": "Kopi Kenangan"}' ``` This document is also served, as plain text, at `GET https://ceotable.dev/api/v1/docs`. ## Authentication Send the key in the `Authorization` header of every request: ```http Authorization: Bearer ct_live_Ab12cDEfGh3iJkLmNoPqRsTuVwXyZ0123456789_-aB ``` A key is `ct_live_` followed by 43 characters. CEO Table stores only a hash of it, so a lost key cannot be shown again: revoke it and create another. A person can have up to 10 active keys. **Keep keys on servers.** A key can do whatever its scopes allow, in its owner's name. Never put one in a web page, a mobile app, a public repository or a shared document. The API sends no CORS headers, so browsers can't call it directly. ### Scopes Each key has access to two areas, each set to none, read, or read and write. Write includes read. | Scope | Allows | | --- | --- | | `planner:read` | `GET` on tasks, checklists, projects, sub-projects, stakeholders, commitments and repeating meetings | | `planner:write` | Everything above, plus creating, changing and deleting them | | `finance:read` | `GET` on everything under `/finance` | | `finance:write` | Everything above, plus creating, changing and deleting under `/finance` | `GET /me` works with any key. A request outside the key's scopes gets `403 insufficient_scope`. ### Subscription The API works while the key owner's CEO Table subscription is active. While it is not, every request with that owner's keys, reads included, gets `402 subscription_inactive`. Keys start working again as soon as the subscription is renewed; nothing needs to be recreated. ### Expiry and revocation A key can be made to expire after 30 days, 90 days or a year, or never. An expired key gets `401 api_key_expired`; a revoked one `401 api_key_revoked`. Both are final. ## Requests and responses - Bodies are JSON, sent with `Content-Type: application/json`, up to 256 KB. - Field names are `snake_case`. **Unknown fields are refused** with `422`, so a typo is never silently ignored. - A single object comes back as `{"data": {…}}`; a list as `{"data": […], "next_cursor": …}`. - Creating something answers `201 Created` with a `Location` header pointing at it. - Deleting answers `200` with `{"data": {"id": "…", "deleted": true}}`. Deletes are permanent. - Every response carries an `X-Request-Id`. Quote it when asking for help. - Responses are never cached (`Cache-Control: no-store`). - `HEAD` works wherever `GET` does, and `OPTIONS` lists a path's methods in `Allow`. - `PATCH` changes only the fields sent. To clear a field, send `null`. ## Conventions **Ids** are UUIDs. An id that isn't yours answers `404`, exactly as one that doesn't exist. **Days** are `yyyy-MM-dd`; **times of day** are 24-hour `HH:mm`; **timestamps** are RFC 3339 in UTC, such as `2026-10-19T03:15:00.123+00:00`. **Timezone.** Days and times mean the account owner's own timezone, set in CEO Table's Settings and returned by `GET /me`. "Today" is today where they are. A task's `deadline` is a day; CEO Table keeps it as noon of that day in the owner's timezone, as the app does. **Money** is sent and returned as a decimal **string** in the currency's main unit, such as `"75000"` or `"12.50"`, never as a JSON number, because numbers lose cents in many languages. Up to the currency's own number of decimals is accepted (IDR and USD have 2, JPY none, KWD 3); more is refused rather than rounded. Each amount also has a `*_minor` twin, a whole number of the currency's smallest unit (`"75000"` IDR is `7500000`). You may send either form, not both. **Signs** are from the account's point of view: income is positive, spending is negative, and a refund is spending sent as a **positive** amount with `"refund": true`. For a transfer, `amount` is what left the sending account (negative) and `to_amount` what arrived (positive, in the receiving account's currency). What a credit card or a loan you owe stands at is negative. **Allocation.** A project's share of your time is `allocation_percent`, from 1 to 100. Active projects add up to at most 100%, and so do the active sub-projects of one project. **Weekdays** count from `0` for Sunday to `6` for Saturday. ## Lists, pagination and sync Lists that can grow long (`/tasks`, `/finance/transactions`) are paged: | Parameter | Meaning | | --- | --- | | `limit` | Items per page, 1–200. Default 50. | | `cursor` | The `next_cursor` of the previous page. | `next_cursor` is `null` on the last page. A cursor marks a position in a fixed order, so rows added while you page don't make you skip or repeat any. Treat it as opaque; it does not expire. The other lists are short and come back whole, with `next_cursor` always `null`. ### Keeping a copy in sync Pass `updated_since` with the newest `updated_at` you have seen. The list is then ordered by `updated_at`, oldest first, and includes finished and archived items, so a completion shows up as a change. The bound is inclusive, so the last item you saw comes back once; match on `id`. ```bash curl "https://ceotable.dev/api/v1/tasks?updated_since=2026-10-19T03:15:00Z&limit=200" \ -H "Authorization: Bearer $CEOTABLE_KEY" ``` Deleted items do not appear in this feed. If you mirror data, re-list now and then. ## Rate limits Each key may make **120 requests a minute**. Every authenticated response carries: | Header | Meaning | | --- | --- | | `X-RateLimit-Limit` | 120 | | `X-RateLimit-Remaining` | Requests left in this minute | | `X-RateLimit-Reset` | When the minute ends, as a Unix timestamp in seconds | Past the limit the API answers `429 rate_limited` with a `Retry-After` header in seconds. Use the batch endpoints to create many tasks or transactions at once. ## Idempotent retries A `POST` may carry an `Idempotency-Key` header: any 1–255 visible ASCII characters, unique per operation, such as a UUID. If the same key sends the same request again within 24 hours, the API returns the first response again, with `Idempotent-Replayed: true`, instead of doing the work twice. Use it whenever a retry could create a duplicate. - Sending the key again with a **different** body or path answers `422 idempotency_key_reused`. - While the first request is still running, a repeat answers `409 request_in_progress`. - Only successful responses are kept. A request that failed can be retried with the same key. Keys are kept per API key, so two of your integrations can't collide. ## Errors Every error has the same shape: ```json { "error": { "code": "validation_failed", "message": "Enter a title.", "details": [{ "field": "title", "message": "Enter a title." }], "request_id": "0d6f4e0e-6f1c-4f1e-9a51-6b1f5d5c2a77" } } ``` `code` is stable and meant for programs; `message` is a sentence for people and may change. `details` is present when there is more to say: for `validation_failed`, one entry per field, named as in the request, with batch items as `tasks[2].title`. | Status | `code` | Meaning | | --- | --- | --- | | 400 | `invalid_json` | The body is not valid JSON. | | 401 | `missing_api_key` | No `Authorization: Bearer` header. | | 401 | `invalid_api_key` | Not a key CEO Table knows. | | 401 | `api_key_revoked` | The key was revoked. | | 401 | `api_key_expired` | The key has expired. | | 402 | `subscription_inactive` | The owner's subscription is not active. | | 403 | `insufficient_scope` | The key lacks the scope; `details.required` names it. | | 404 | `not_found` | No such endpoint, or no such item of yours. | | 405 | `method_not_allowed` | The path exists; `Allow` lists its methods. | | 409 | `conflict` | The change clashes with the data, such as deleting a project that still has tasks. | | 409 | `meeting_clash` | A meeting would overlap another; `details` lists what holds the time. | | 409 | `finance_not_set_up` | Finance hasn't been set up in the app yet. | | 409 | `request_in_progress` | A request with this `Idempotency-Key` is still running. | | 413 | `payload_too_large` | The body is over 256 KB. | | 415 | `unsupported_media_type` | The body isn't sent as `application/json`. | | 422 | `validation_failed` | A field is missing, unknown or invalid; see `details`. | | 422 | `rule_violation` | Valid fields that break a rule, such as allocation over 100%. | | 422 | `idempotency_key_reused` | The `Idempotency-Key` was used for a different request. | | 422 | `invalid_cursor` | The cursor didn't come from this list. | | 429 | `rate_limited` | Over 120 requests this minute; see `Retry-After`. | | 500 | `internal_error` | Something failed on our side. Retrying later is safe with an `Idempotency-Key`. | | 503 | `api_unavailable` | The API is not switched on for this CEO Table yet. | | 503 | `feature_unavailable` | That part of CEO Table is not switched on yet. | --- ## Account ### `GET /` The API's name and version, and where these docs are. Needs no key. ### `GET /docs` This document, as plain text. Needs no key. ### `GET /me` Who the key belongs to, what it may do, and what is left of this minute's requests. ```json { "data": { "user": { "id": "0b8e8b8e-…", "name": "Amar" }, "timezone": "Asia/Jakarta", "today": "2026-10-19", "key": { "id": "…", "name": "Zapier", "prefix": "ct_live_Ab12", "scopes": ["planner:write", "finance:read"], "expires_at": null }, "subscription": { "active": true, "plan": "monthly", "status": "active", "current_period_end": "2026-11-12T08:00:00.000Z" }, "finance": { "set_up": true, "base_currency": "IDR" }, "rate_limit": { "limit": 120, "remaining": 117, "reset_at": "2026-10-19T03:16:00.000Z" } } } ``` --- ## Tasks A task is a unit of work in a project. A task with `fixed_date`, `fixed_start` and `fixed_end` is a **meeting**: it happens at that time, and its effort is its length. Occurrences of a [repeating meeting](#repeating-meetings) are tasks too, with a `series_id`. ### The task object ```json { "id": "5b0e…", "title": "Send the board deck", "notes": "Numbers from finance first", "status": "active", "project_id": "3f6d…", "sub_project_id": null, "impact": 5, "effort_minutes": 90, "deadline": "2026-10-24", "deadline_type": "hard", "cognitive_load": "deep", "commitment": "promised_external", "delegability": 0, "blocked_by": [], "stakeholder_id": null, "delegated_to": null, "delegate_by": null, "fixed_date": null, "fixed_start": null, "fixed_end": null, "series_id": null, "series_date": null, "series_detached": false, "deferred_count": 0, "actual_minutes": 0, "completed_at": null, "archived_at": null, "created_at": "2026-10-19T03:15:00.123+00:00", "updated_at": "2026-10-19T03:15:00.123+00:00", "checklist": [{ "id": "…", "title": "Pull Q3 numbers", "done": false, "position": 0, "completed_at": null }] } ``` | Field | Values | | --- | --- | | `status` | `inbox`, `active`, `scheduled`, `delegated`, `waiting`, `review`, `done`, `deferred`, `dropped` | | `impact` | `1`, `2`, `3`, `5` or `8` | | `deadline_type` | `hard`, `soft`, or `none` when there is no deadline | | `cognitive_load` | `deep`, `medium`, `shallow` | | `commitment` | `internal`, `promised_internal` (promised to a colleague), `promised_external` (to a client or partner) | | `delegability` | 0–100: how much of it someone else could do | | `blocked_by` | Ids of tasks this one waits on | | `actual_minutes` | Time tracked on the task in the app | | `checklist` | Only on `GET /tasks/{id}` and on create | ### `GET /tasks` Open, unarchived tasks, newest first. Filters: | Parameter | Meaning | | --- | --- | | `status` | One or more statuses, comma-separated: `status=active,scheduled`. Overrides `include_done`. | | `project_id`, `sub_project_id`, `stakeholder_id`, `series_id` | Only tasks with that id | | `due_by` | Deadline on or before this day | | `fixed_date_from`, `fixed_date_to` | Meetings on or between these days | | `include_done` | `true` to include `done` and `dropped` tasks | | `include_archived` | `true` to include archived tasks | | `updated_since` | Changed at or after this timestamp; see [sync](#keeping-a-copy-in-sync) | | `order` | `created` (newest first, the default) or `updated` (oldest change first, the default with `updated_since`) | | `limit`, `cursor` | [Pagination](#lists-pagination-and-sync) | ### `POST /tasks` Creates a task. Only `title` and `project_id` are required. | Field | Default | Notes | | --- | --- | --- | | `title` | — | 1–240 characters | | `project_id` | — | An active project of yours | | `sub_project_id` | `null` | Must belong to `project_id` | | `notes` | `null` | Up to 4,000 characters | | `status` | `inbox` | One of `inbox`, `active`, `scheduled`, `delegated`, `waiting`, `review` | | `impact` | `3` | | | `effort_minutes` | `null` | Ignored for a meeting, whose effort is its length | | `deadline` | `null` | A day | | `deadline_type` | `soft` with a deadline, else `none` | `hard` or `soft` need a `deadline` | | `cognitive_load` | `medium` | | | `commitment` | `internal` | | | `delegability` | `0` | | | `blocked_by` | `[]` | Up to 20 task ids; a loop is refused | | `fixed_date`, `fixed_start`, `fixed_end` | `null` | All three or none. A meeting that overlaps another is refused with `409 meeting_clash`. | | `checklist` | — | Up to 50 step titles, in order | ```json { "title": "Investor call", "project_id": "3f6d…", "fixed_date": "2026-10-27", "fixed_start": "09:30", "fixed_end": "10:15", "commitment": "promised_external", "checklist": ["Send the agenda", "Share the data room"] } ``` Answers `201` with the task, its checklist included. ### `POST /tasks/batch` Creates up to 30 tasks in one go, all or none. Same fields as `POST /tasks` except `checklist`, plus `blocked_by_indexes`: positions of tasks earlier **in this batch** that this one waits on. ```json { "tasks": [ { "title": "Draft the offer letter", "project_id": "3f6d…" }, { "title": "Get legal to review it", "project_id": "3f6d…", "blocked_by_indexes": [0] } ] } ``` Answers `201` with `{"data": [task, task]}` in the order sent. Errors name the item: `tasks[1].title`. ### `GET /tasks/{id}` One task, with its `checklist`. ### `PATCH /tasks/{id}` Changes the fields sent; takes the same fields as `POST /tasks` except `checklist`. Moving a task to another project without a `sub_project_id` clears its sub-project. - `status` here moves an open task between open statuses. A `done`, `deferred` or `dropped` task answers `409`; move it with [`reopen`](#post-tasksidreopen) or [`status`](#post-tasksidstatus) first. - On an **occurrence of a repeating meeting**, only `title`, `notes`, `project_id`, `sub_project_id`, `impact`, `cognitive_load`, `commitment`, `status` and the three `fixed_*` fields can change, and the change applies to that occurrence alone, as "this event" does in a calendar. Other fields answer `422 rule_violation`. ### `DELETE /tasks/{id}` Deletes the task. Deleting an occurrence of a repeating meeting skips that date, so it is not written again. ### `POST /tasks/{id}/complete` Marks the task done and records when. No body. ### `POST /tasks/{id}/reopen` Moves a `done`, `deferred` or `dropped` task back to `inbox`. An open task is left as it is. ### `POST /tasks/{id}/status` `{"status": "deferred"}`: sets any status. `done` records the completion time; anything else clears it; `deferred` counts one more deferral. ### `POST /tasks/{id}/archive` and `POST /tasks/{id}/unarchive` Archiving takes a task out of your working set without changing its status. A task with a timer running in the app can't be archived (`409`). ### `POST /tasks/{id}/delegate` Records who the work was handed to and by when. | Field | Default | Notes | | --- | --- | --- | | `stakeholder_id` | `null` | One of your [stakeholders](#people) | | `delegated_to` | `null` | A name, for someone who isn't a stakeholder. Not with `stakeholder_id`. | | `delegate_by` | `null` | The day it is due back | | `status` | `delegated` | `inbox`, `active`, `scheduled`, `delegated`, `review` or `done` | Sending neither `stakeholder_id` nor `delegated_to` clears the recipient. ### Checklists | Endpoint | Does | | --- | --- | | `GET /tasks/{id}/checklist` | The steps, in order | | `PUT /tasks/{id}/checklist` | Replaces the whole list: `{"items": [{"id": "…", "title": "Kept, with its tick"}, {"title": "New step"}]}`. Steps left out are removed. Up to 50. | | `PATCH /tasks/{id}/checklist/{item_id}` | Ticks a step: `{"done": true}` | --- ## Projects ### The project object ```json { "id": "3f6d…", "name": "Acme Corp", "role_label": "CEO", "entity": "PT Acme Indonesia", "allocation_percent": 60, "color": "#4F8CFF", "mode": "active", "archived_at": null, "created_at": "…", "updated_at": "…" } ``` `mode` is `active` or `maintenance` (kept running with little attention). | Endpoint | Does | | --- | --- | | `GET /projects` | Active projects. `include_archived=true` adds archived ones. | | `POST /projects` | Creates one. Required: `name`, `role_label`, `allocation_percent`. Optional: `entity`, `color` (`#RRGGBB`), `mode`. | | `GET /projects/{id}` | One project | | `PATCH /projects/{id}` | Changes the fields sent | | `DELETE /projects/{id}` | Deletes it; `409` while it still has tasks that aren't dropped | ### Sub-projects The same shape with a `project_id` and without `role_label` and `entity`. | Endpoint | Does | | --- | --- | | `GET /sub-projects` | Active sub-projects; filter with `project_id`, add archived with `include_archived=true` | | `POST /sub-projects` | Required: `project_id`, `name`, `allocation_percent`. Optional: `color`, `mode`. | | `GET /sub-projects/{id}` | One sub-project | | `PATCH /sub-projects/{id}` | Changes the fields sent | | `DELETE /sub-projects/{id}` | `409` while it still has tasks | --- ## People ### Stakeholders People you work with and hand work to. ```json { "id": "…", "name": "Rina", "tier": 1, "sla_hours": 24, "created_at": "…", "updated_at": "…" } ``` `tier` is 1 (most important) to 3; `sla_hours` is how quickly they expect an answer, 1–720. | Endpoint | Does | | --- | --- | | `GET /stakeholders` | All, by name | | `POST /stakeholders` | Required: `name`. Defaults: `tier` 2, `sla_hours` 48. | | `GET /stakeholders/{id}` | One | | `PATCH /stakeholders/{id}` | Changes the fields sent | | `DELETE /stakeholders/{id}` | Deletes | ### Weekly commitments Standing time every week that comes out of your capacity: a staff meeting, a class, a ritual. ```json { "id": "…", "title": "Staff meeting", "weekday": 1, "start_time": "09:00", "end_time": "10:00", "kind": "meeting", "start_date": "2026-01-05", "end_date": null, "active": true, "created_at": "…", "updated_at": "…" } ``` | Endpoint | Does | | --- | --- | | `GET /commitments` | All, by weekday and time | | `POST /commitments` | Required: `title`, `weekday`, `start_time`, `end_time`. Defaults: `kind` `meeting` (or `class`, `personal`, `ritual`), `start_date` today, `end_date` `null` (no end). | | `GET /commitments/{id}` | One | | `PATCH /commitments/{id}` | Changes the fields sent | | `DELETE /commitments/{id}` | Deletes | --- ## Repeating meetings A repeating meeting (a **series**) writes its occurrences out as [tasks](#tasks) for the next 60 days. Read, complete, edit or delete each occurrence through `/tasks`; filter them with `GET /tasks?series_id=…`. ```json { "id": "…", "title": "1:1 with Rina", "notes": null, "project_id": "3f6d…", "sub_project_id": null, "impact": 3, "cognitive_load": "medium", "commitment": "internal", "freq": "weekly", "weekdays": [2], "month_day": null, "start_date": "2026-10-20", "end_date": null, "start_time": "14:00", "end_time": "14:30", "generated_through": "2026-12-18", "created_at": "…", "updated_at": "…", "exceptions": [{ "occurrence_date": "2026-11-03", "action": "skip", "new_date": null, "new_start": null, "new_end": null }] } ``` | Endpoint | Does | | --- | --- | | `GET /series` | All series | | `GET /series/{id}` | One, with its `exceptions` | | `POST /series` | Creates one and writes its occurrences (see below) | | `POST /series/{id}/stop` | Ends it before a day: `{"from": "2026-12-01"}` (default today). Open occurrences from then on are removed; finished ones stay. Stopping from its first day deletes the series. | | `DELETE /series/{id}` | Deletes it and its open occurrences; finished ones stay as history | | `POST /series/sync` | Writes any missing occurrences up to 60 days ahead, for every series. Body `{}`. Answers `{"data": {"created": 3, "through": "2026-12-18"}}`. | `POST /series` fields: | Field | Default | Notes | | --- | --- | --- | | `title`, `project_id` | — | Required | | `freq` | — | Required: `daily`, `weekly` or `monthly` | | `start_time`, `end_time` | — | Required | | `weekdays` | `[]` | For `weekly`: at least one, `0`–`6` | | `month_day` | `null` | For `monthly`: `1`–`31` | | `start_date` | today | | | `end_date` | `null` | No end | | `notes`, `sub_project_id`, `impact`, `cognitive_load`, `commitment` | as for tasks | | | `exceptions` | `[]` | Dates to `skip`, or to `move` with `new_date`, `new_start`, `new_end` | A month without the series' `month_day` (31 November) is left out, unless a `move` exception whose `occurrence_date` is that month's last day says where it goes. If any occurrence in the first 60 days would overlap another meeting, the series is refused with `409 meeting_clash`. --- ## Finance Everything under `/finance` needs a `finance:` scope, and Finance set up once in the app, which picks the base currency. Until then, requests answer `409 finance_not_set_up`. ### `GET /finance/settings` ```json { "data": { "set_up": true, "base_currency": "IDR", "number_locale": "id-ID" } } ``` This one answers even before Finance is set up, with `set_up: false`. ### Books A book is a set of accounts with a currency of its own, looked at as if it were a separate system: "New Zealand" in NZD with the NZ bank accounts, "Indonesia" in IDR with the Indonesian ones. Every account and every budget is in exactly one book; categories, exchange rates and settings are shared. Everyone has at least one book, and most people only that one. ```json { "id": "b7f2…", "name": "New Zealand", "currency": "NZD", "color": 2, "sort": 1, "created_at": "…", "updated_at": "…" } ``` `currency` is what the book's totals are shown in; its accounts may hold other currencies too. In the app each book has its own dashboard in that currency, and every book together is shown in the base currency, or in any book's currency. | Endpoint | Does | | --- | --- | | `GET /finance/books` | Every book, in order. The first is where an account sent without `book_id` goes. | | `POST /finance/books` | Required: `name`, unique among your books whatever the case. Defaults: `currency` the base currency, `color` the next palette slot. | | `GET /finance/books/{id}` | One | | `PATCH /finance/books/{id}` | `name`, `currency`, `color` | | `DELETE /finance/books/{id}` | Only an empty book, and never the last one; otherwise `409`. Move its accounts first with `PATCH /finance/accounts/{id}`. Its budgets go with it. | Books arrive with a database update. Until it is applied, `/finance/books` answers `503 feature_unavailable`, `book_id` reads `null`, and everything else works as before. ### Accounts ```json { "id": "9a1c…", "book_id": "b7f1…", "name": "BCA Tahapan", "kind": "bank", "currency": "IDR", "institution": "BCA", "opening_balance": "2500000.00", "opening_balance_minor": 250000000, "opening_date": "2026-01-01", "credit_limit": null, "credit_limit_minor": null, "color": 1, "include_in_net_worth": true, "archived_at": null, "created_at": "…", "updated_at": "…", "balance": { "on": "2026-10-19", "amount": "12750000.00", "amount_minor": 1275000000, "transaction_count": 84, "last_transaction_on": "2026-10-18" } } ``` `kind` is `bank`, `cash`, `ewallet`, `credit_card`, `investment`, `loan_receivable` (money lent) or `loan_payable` (money owed). `color` is a palette slot, 1–8. `balance` is the balance at the end of the day `on`, opening balance included. | Endpoint | Does | | --- | --- | | `GET /finance/accounts` | Active accounts with balances. `on=2026-09-30` for another day; `book_id` for one book's; `include_archived=true` adds archived ones. | | `POST /finance/accounts` | Required: `name`, `kind`. Defaults: `book_id` your first book, `currency` the base currency, `opening_balance` 0, `opening_date` today, `include_in_net_worth` true. Also `institution`, `credit_limit` (credit cards), `color`. | | `GET /finance/accounts/{id}` | One, with its balance; `on` as above | | `PATCH /finance/accounts/{id}` | Changes the fields sent. The currency is fixed once anything is recorded on the account. A new `book_id` moves it, with its history, to another book. | | `POST /finance/accounts/{id}/archive`, `…/unarchive` | Hides or restores it, keeping its history | | `DELETE /finance/accounts/{id}` | Only an account nothing was recorded on; otherwise `409`, archive it instead | A credit card's opening balance is what it stood at, so a card owing Rp 2,000,000 opens at `"-2000000"`. ### Categories ```json { "id": "…", "kind": "expense", "name": "Food & drink", "parent_id": null, "color": 3, "sort": 2, "archived_at": null, "created_at": "…", "updated_at": "…" } ``` `kind` is `income` or `expense`. A category may have one level of sub-categories (`parent_id`). | Endpoint | Does | | --- | --- | | `GET /finance/categories` | Active categories; filter with `kind`, add archived with `include_archived=true` | | `POST /finance/categories` | Required: `kind`, `name`. Optional: `parent_id`, `color`. | | `GET /finance/categories/{id}` | One | | `PATCH /finance/categories/{id}` | `name`, `parent_id`, `color`. The kind can't change. | | `POST /finance/categories/{id}/archive`, `…/unarchive` | Hides or restores it | | `DELETE /finance/categories/{id}` | Deletes it. `?replacement_id=…` moves its transactions and repeating transactions to another category of the same kind first; without it they become uncategorised. A category with sub-categories can't be deleted until they are moved or deleted. | ### Transactions ```json { "id": "c41e…", "kind": "expense", "account_id": "9a1c…", "currency": "IDR", "amount": "-75000.00", "amount_minor": -7500000, "refund": false, "to_account_id": null, "to_currency": null, "to_amount": null, "to_amount_minor": null, "category_id": "…", "occurred_on": "2026-10-19", "payee": "Kopi Kenangan", "note": null, "project_id": null, "recurring_id": null, "recurring_date": null, "has_receipt": false, "created_at": "…", "updated_at": "…" } ``` | `kind` | `amount` | Also | | --- | --- | --- | | `income` | Positive | `category_id` (an income category) | | `expense` | Negative; positive with `"refund": true` | `category_id` (a spending category) | | `transfer` | Negative: what left `account_id` | `to_account_id`, and `to_amount`: what arrived, positive, in that account's currency. Between two accounts in one currency what arrives equals what left, so leave `to_amount` out; record a fee with `fee`. | | `adjustment` | Either sign | Corrects a balance to match the bank | **Positive spending without `"refund": true` is refused.** It is almost always a sign mistake, and it would record money coming in. An archived account takes no new transactions. `project_id` links a transaction to one of your [projects](#projects). `recurring_id` is set on transactions the app posted from a [repeating transaction](#repeating-transactions). Receipts can be attached in the app; `has_receipt` says whether there is one. #### `GET /finance/transactions` Newest first, by `occurred_on`. Filters: | Parameter | Meaning | | --- | --- | | `account_id` | On this account, either side of a transfer | | `book_id` | On one of this book's accounts, either side of a transfer. Not with `account_id`. | | `category_id` | In this category | | `uncategorized` | `true` for income and spending without a category | | `project_id` | Linked to this project | | `kind` | One or more kinds, comma-separated | | `from`, `to` | On or between these days | | `q` | Text in the payee or note | | `updated_since`, `order` (`occurred` or `updated`) | See [sync](#keeping-a-copy-in-sync) | | `limit`, `cursor` | [Pagination](#lists-pagination-and-sync) | #### `POST /finance/transactions` | Field | Default | Notes | | --- | --- | --- | | `kind`, `account_id` | — | Required | | `amount` or `amount_minor` | — | Required; see the table above | | `to_account_id`, `to_amount` / `to_amount_minor` | `null` | Transfers only | | `category_id` | `null` | Income and spending only | | `occurred_on` | today | | | `payee` | `null` | Up to 120 characters | | `note` | `null` | Up to 1,000 characters | | `project_id` | `null` | | | `refund` | `false` | See above | | `fee` or `fee_minor` | — | Transfers only: a positive fee, recorded as separate spending on the sending account, in the same write | ```json { "kind": "transfer", "account_id": "9a1c…", "amount": "-1621500", "to_account_id": "77ab…", "to_amount": "100", "fee": "6500" } ``` Answers `201` with the transaction; a fee comes back inside it as `fee_transaction`. #### `POST /finance/transactions/batch` Up to 20 transactions, all or none: `{"transactions": [ … ]}`, each as for `POST`, without `fee`. Answers `201` with the list, in order. Errors name the item: `transactions[3].amount`. #### `GET`, `PATCH`, `DELETE /finance/transactions/{id}` `PATCH` changes the fields sent. Changing `account_id` to an account in another currency needs the `amount` again. Deleting a transaction deletes its receipt too. ### Budgets A spending category's budget in a book holds from a month on, until a later month changes it. Each book has its own, so one category can have a budget in NZD in one book and in IDR in another; the app adds them up when every book is shown together. A budget is for each `day`, `week` (Monday to Sunday), `month` or `year`: `amount` is what may be spent in one such period. The app spreads it over the days of its period to show it for any other, so Rp 100,000 a day is Rp 3,100,000 in October. ```json { "book_id": "b7f1…", "category_id": "…", "month": "2026-10", "amount": "2000000.00", "amount_minor": 200000000, "currency": "IDR", "period": "month", "effective_from": "2026-09-01" } ``` | Endpoint | Does | | --- | --- | | `GET /finance/budgets` | The budgets in force in a month, one per book and category: `?month=2026-10`, default this month; `book_id` for one book's | | `PUT /finance/budgets` | Sets one from a month on: `{"book_id": "…", "category_id": "…", "month": "2026-11", "amount": "2500000"}`. `book_id` may be left out while you have one book, and is required once you have more (`422` otherwise). `currency` defaults to the book's, `period` to `month`: `{"…", "amount": "100000", "period": "day"}` is Rp 100,000 a day. `"amount": "0"` ends it. | ### Repeating transactions Salary, rent, subscriptions: rules the app uses to post transactions, or to remind you to. Read-only in this version. ```json { "id": "…", "name": "Rent", "kind": "expense", "account_id": "9a1c…", "currency": "IDR", "amount": "-8500000.00", "amount_minor": -850000000, "to_account_id": null, "to_currency": null, "to_amount": null, "to_amount_minor": null, "category_id": "…", "project_id": null, "payee": "Landlord", "note": null, "freq": "monthly", "every": 1, "day_of_month": 1, "weekend_rule": "previous", "start_date": "2026-01-01", "end_date": null, "mode": "auto", "next_due_on": "2026-11-01", "paused": false, "created_at": "…", "updated_at": "…" } ``` `freq` is `weekly`, `monthly` or `yearly`, every `every` periods. `weekend_rule` moves a due date on a weekend to the `previous` or `next` weekday, or not at all (`none`). `mode` is `auto` (posted for you) or `remind`. | Endpoint | Does | | --- | --- | | `GET /finance/recurring` | All rules | | `GET /finance/recurring/{id}` | One | --- ## Behaviour worth knowing - **Repeating meetings are written out 60 days ahead**, and the window moves forward when the app is opened. If nobody opens the app for a while, call `POST /series/sync` before reading future meetings. - **Repeating transactions are posted by the app**, when it is opened, not by the API. Reading transactions through the API does not post anything due. - **Deletes are permanent** and don't appear in `updated_since` feeds. - **Rate limits are per key.** Two keys of one person each have their own 120 a minute. - **The API acts as the key's owner** and sees exactly what they see in the app, no more. - **Books group accounts; they don't copy anything.** A transfer from an account in one book to an account in another is one transaction, listed in both books, and counted neither as income nor as spending. ## Changelog ### v1 · budget periods Added, nothing changed for existing calls: `period` on budgets (`day`, `week`, `month` or `year`), and on `PUT /finance/budgets`, where it defaults to `month`. ### v1 · books Added, nothing changed for existing calls: [books](#books) at `/finance/books`; `book_id` on accounts and budgets; a `book_id` filter on accounts, transactions and budgets; `book_id` on `PUT /finance/budgets`, required once there is more than one book. ### v1 · 2026-10-19 First release: account, tasks, checklists, projects, sub-projects, stakeholders, weekly commitments, repeating meetings, and Finance settings, accounts, categories, transactions, budgets and repeating transactions.