The API¶
Interactive documentation at /docs on your own deployment; the reference
one is at kasbbook.nyxon.tech/docs. This page covers the
conventions the OpenAPI schema cannot express.
Base path: /api/v1. Health checks sit outside it, at /healthz and
/readyz.
Conventions¶
Money is a string¶
Never a JSON number. JSON has one numeric type and it is a float;
12500000.15 does not survive that round trip. Parse it with a decimal type on
your side — Decimal in Python, BigDecimal in Java, a decimal library in
JavaScript. Do not parse it into a Number.
Dates are ISO, periods are Jalali¶
occurred_on is 2026-08-25. But a report period is 1403-05 — Mordad of
1403 — converted to a Gregorian range before the query runs.
| Period | Means |
|---|---|
1403-05 |
one Jalali month |
1403 |
one Jalali year |
week |
the current week |
| (omitted) | everything |
Errors¶
Domain errors carry their own status: 404 not found, 403 denied, 422 invalid,
409 conflict, 401 unauthenticated, 429 rate limited (with a Retry-After
header in seconds). An amount of 10^24 or more is a 422. Validation failures come
from Pydantic in its own shape, with the offending field.
Unhandled exceptions are always {"detail": "something went wrong on our
side"} with a 500. The real error is in the log, never in the response.
Not-found means not-yours¶
Asking for a book you are not a member of returns 404, not 403. Revoking someone else's API key returns 404. This is deliberate: a 403 would confirm the id exists.
Getting a key without leaving Telegram¶
Account → 🔌 API issues one. It is shown once, concealed behind a tap, and never again: only a SHA-256 digest is stored, which is what makes a leaked database worth nothing. Issuing a new one revokes the old, so there is never a second key nobody remembers creating.
Authenticating¶
Either a bearer token or an API key. Both resolve to the same account, but only a person's bearer token may manage the account itself — keys, profile, contact, password, sessions, closing it, and messenger links answer an API key with 403.
Register and sign in¶
curl -X POST /api/v1/auth/register \
-H 'Content-Type: application/json' \
-d '{"display_name":"عماد","email":"you@example.com","password":"at-least-8"}'
An email or a phone is required — an account with no way to be reached cannot be recovered.
Staying signed in¶
Store the new refresh token and discard the old one. Every refresh rotates. Presenting a spent token revokes the entire family and signs the session out — that is theft detection working, not a bug. See security.md.
The account itself¶
PATCH /auth/me |
display name, timezone, locale |
PUT /auth/me/contact |
the email or phone this account is reached and recovered by |
PUT /auth/me/password |
set or change it — ends every session, including yours |
Changing a password signs everything out — refresh tokens and already-issued access tokens alike, so the bearer you used to make the request stops working too. Somebody doing it because they fear a leak expects exactly that. Sign in again afterwards.
An API key is unaffected: it belongs to a program, and a nightly job should not stop at 3am because a person changed their password.
An address another account already holds is refused with a 422 that does not confirm the address is registered.
GET /auth/me/deletion-preview |
what closing would destroy, and what would block it |
DELETE /auth/me |
closes it — irreversible, no grace period |
DELETE /auth/me needs the account's password when it has one; an account with
none is proven by the bearer token alone. It returns 422 when a shared book
blocks it. There is no id to pass: a token can only close its own account.
POST /auth/logout |
ends this session |
POST /auth/logout-everywhere |
ends all of them |
GET /auth/sessions |
where this account is signed in |
Keys for programs¶
curl -X POST /api/v1/auth/api-keys -H "Authorization: Bearer $T" \
-d '{"name":"nightly export","expires_in_days":365}'
The response is the only time the key appears. Listing keys returns the prefix, never the key.
Books and transactions¶
GET /books |
books this account can see |
POST /books |
{"name":"مغازه","type":"business","currency":"IRR"} |
GET /books/{id}/members · POST · DELETE /{user_id} |
membership |
GET /books/{id}/transactions |
?since=&until=&page=&per_page=, newest first |
POST /books/{id}/transactions |
see below |
GET/DELETE /books/{id}/transactions/{tx} |
one transaction |
GET /books/{id}/currencies · PUT /{code} |
which currencies this book may hold |
GET /books/{id}/wallet |
what it holds, per currency |
GET/POST /books/{id}/conversions |
move value between two of them |
curl -X POST /api/v1/books/$BOOK/transactions \
-H "Authorization: Bearer $T" -H 'Content-Type: application/json' \
-d '{"flow":"income","category":"فروش","amount":"250000","occurred_on":"2026-08-25"}'
A transaction carries has_receipt, receipt_kind and receipt_file_name.
The file id itself is not returned: it is opaque and scoped to the messenger
holding the file, so it would mean nothing here and handing it out only widens
what a leak exposes. Attaching a receipt is a bot flow — there is no upload
endpoint yet.
scope is optional and defaults from the book type. currency defaults to the
book's own, and a different one must be a currency the book has enabled and
must carry conversion_rate — that rate is frozen on the transaction and never
re-applied. An expense in a currency the book does not hold enough of is
refused; the base currency is exempt.
POST /books also takes currency, and omitting it now means the same default
the bot uses. It used to mean IRR, which is why a book created in the bot
refused every transaction posted to it here.
Adding a member requires that person to already have an account; they are found by email or phone.
Reports¶
GET /books/{id}/reports/summary |
?period=1403-05 → income, expense, net, count |
GET /books/{id}/reports/by-category |
?flow=expense&period= → totals and shares |
GET /books/{id}/reports/export.csv |
the period as a file, Jalali dates included; text cells starting = + - @ get a leading ' |
GET /books/{id}/reports/day |
?date=2026-03-19 → the daily list's split: business, personal, installments, savings |
Budgets, debts, loans¶
GET/PUT /books/{id}/budgets |
a ceiling per category; PUT because a category has one |
DELETE /books/{id}/budgets/{budget} |
|
GET/POST /books/{id}/debts |
?include_settled=true to see closed ones |
POST /books/{id}/debts/{debt}/settle |
closes it; {"record_transaction": true} also records the money (income if owed to you, expense if you owed). Off by default: if the original sale or purchase is already recorded, recording the payment too counts it twice. A second settle is 422. |
GET/POST /books/{id}/loans |
instalment amount and count, not a total |
POST /books/{id}/loans/{loan}/pay |
records one instalment and its expense |
A loan is described by what one instalment costs and how many there are, because that is how a loan is described to a person: "twelve of 4,000,000".
Payroll and treasury¶
Team and organization books only. Every route calls the same service the bot calls, so a share set here is what Telegram reports and a payslip calculated in Telegram is what this returns.
GET/POST /books/{id}/periods |
list, or open one |
PATCH /books/{id}/periods/{p} |
rename, or move the window; payslips are recalculated, payments kept |
DELETE /books/{id}/periods/{p} |
only while it has issued no payslip |
GET /books/{id}/periods/{p}/distribution |
income − costs − treasury = distributable (never below zero; shortfall is a losing month's loss) |
POST /books/{id}/periods/{p}/status/{status} |
only the documented transitions |
POST /books/{id}/periods/{p}/close |
to locked in one step; 422, with nothing moved, if there are no payslips, they are stale, or anyone is owed |
GET/PUT /books/{id}/shares |
who takes what; PUT end-dates the previous rule |
DELETE /books/{id}/shares/{user} |
stop paying this member from today; past periods keep the rule |
GET/PUT /books/{id}/periods/{p}/performance |
hours, days, points — for measured bases |
GET/POST /books/{id}/periods/{p}/adjustments |
bonuses and deductions, signed |
POST …/adjustments/{a}/approve |
recording and approving are separate |
POST /books/{id}/periods/{p}/calculate |
payslips, every input frozen onto them; again, updates them in place and keeps their payments |
DELETE /books/{id}/periods/{p}/payslips |
discard the calculation — refused once anyone has been paid |
GET /books/{id}/periods/{p}/payslips |
everyone's, or only yours, by permission |
POST /books/{id}/payslips/{slip}/payments |
staged; instalments are the norm; any currency the book holds |
DELETE /books/{id}/payslips/{slip}/payments/{pay} |
take a payment back while the period is open |
GET/POST/DELETE /books/{id}/funds |
treasury funds |
GET/POST/DELETE /books/{id}/funds/{f}/rules |
what feeds them |
calculate returns 422, not an empty list, when no member has a share. A
run that produces nothing because the question was never answered is not an
empty result.
A payment defaults to the payslip's own currency. Another must be one the book
has enabled and holds enough of, and must carry conversion_rate into the
payslip's currency; the payslip counts it at that rate, and each payment in the
response carries its rate so the list reconciles with paid. A rate sent with
the payslip's own currency is ignored, as it is on a transaction. reference
is at most 80 characters.
curl -X POST /api/v1/books/$BOOK/payslips/$SLIP/payments \
-H "Authorization: Bearer $T" -H 'Content-Type: application/json' \
-d '{"amount":"50","currency":"USDT","conversion_rate":"229867"}'
Deleting a fund that has already taken money is refused — a paid period would be left pointing at nothing. Deactivate it instead. Deleting a rule is always safe, because what past periods took is snapshotted rather than recomputed.
Webhooks¶
POST /api/v1/webhooks/{provider}/{secret}
Always answers 200, even for an update that fails — a provider that receives an error retries, and retrying a poisoned update forever is worse than dropping it and logging why.
Only reachable when this process is configured to serve webhooks. Otherwise every path returns 404. See security.md for what the secret in the path is doing there.
Health¶
GET /healthz |
answers without touching anything; safe to poll hard |
GET /readyz |
runs a query; 503 when the database is unreachable |
A process that cannot reach its database is up but not useful, and those are the deploys that look fine and are not.
Categories, transaction editing, invitations and account switching¶
Transaction responses include activity with kind (created or edited),
user_id, display_name and the UTC at timestamp. Detail and list endpoints
use the same authorized, batched ledger-service attribution as the bot. The bot
shows either original creation or the latest edit, with the date/time converted
to the book's local Jalali calendar. Category/name, amount, description and
receipt edits update the displayed editor. Original creator/date remain stored.
Historical edited rows lacking reliable editor attribution show an unknown
editor rather than assigning the edit to the creator or guessing from timestamps.
Book categories are shared by all transaction flows. GET/POST
/api/v1/books/{book_id}/categories, PATCH/DELETE
/api/v1/books/{book_id}/categories/{category_id} use CategoryService.
Reading requires transaction visibility. Creation requires create_category,
which is granted to owners, admins, accountants and members; renaming and
deletion still require transaction editing. Viewers cannot create categories.
A category with any transaction cannot be deleted. Renaming also updates the
existing category budget, recurring and treasury filters without merging them.
Category creation requires flow: "income" or flow: "expense". Category PATCH
accepts name, flow, or both. GET .../categories?flow=income (or expense) uses
the same service filter as the bot's transaction picker. A classified category
cannot be used for the opposite transaction flow, including free-text/API
entries. Category type changes affect future recording only: historical
transaction flows, frozen amounts and journal lines remain unchanged.
Legacy categories have flow: null and appear as unclassified in management;
choose their type explicitly. They are excluded from the filtered picker until
classified. Their existing transactions remain usable without rewriting history.
PATCH /api/v1/books/{book_id}/transactions/{transaction_id} accepts category,
amount and description. Money is a string. Amount means the original
currency amount and retains the stored conversion rate. description: null
clears it; null category/amount and empty patches are refused. Financial dates
with calculated payslips or a non-open payroll period are protected. Loan
payment amounts must be changed through their own workflow.
POST /api/v1/books/{book_id}/invitations accepts a supported messenger provider,
username or numeric identifier, and a non-owner role. The recipient must
already have started that provider's bot. Delivery is queued durably for that
provider's poller, webhook activity or reminder loop. GET /api/v1/invitations
lists the current user's pending invitations; POST
/api/v1/invitations/{invitation_id}/respond accepts {"accept": true} or false.
Invitations expire after seven days. Membership is granted only on acceptance,
while the inviter still has member-management permission.
POST /api/v1/identities/account-login/request accepts an owned identity_id
and destination email/phone identifier. It returns only challenge_id and
expires_in. Proof goes to an existing identity on the same messenger; no
email or SMS delivery is implied. POST /api/v1/identities/account-login/complete
accepts the identity, challenge and code. Invalid proof returns success: false
so failed attempts are committed. Successful switching invalidates both
accounts' existing access/refresh sessions; authenticate again afterward.