API guide

On this page

Overview

Three APIs share the hostname api.sasek.org. Each has its own path, credential, rate limits and errors. A token for one API is rejected by the others, and the ledger does not proxy the rest.

The APIs

  • Ledger, https://api.sasek.org/ledger/v1. Accounts, transactions, categories, rules, imports, reports and notifications. Every route needs a token in the X-Ledger-Token header. Reference: /docs/api/ledger/.
  • Telemetry, https://api.sasek.org/telemetry/v1. Read-only health snapshot of the home server. No token. Reference: /docs/api/telemetry/.

Versions

The major version is in the path. Each API is at v1. A change that breaks a client gets a new path version. Adding a field, an endpoint or an optional parameter does not. Clients should ignore response fields they do not recognise.

Environment

There is one environment, production, with no sandbox. The ledger holds real data. Test writes with a named token of the smallest scope that works, and revoke it afterwards.

Quick start

Telemetry needs no token. The response is cached for five seconds:

curl https://api.sasek.org/telemetry/v1/summary

The ledger needs a named token. Keep it in a shell variable, and never paste it into a document, commit or chat. Without a valid token the ledger returns 404 Not found:

export KEY=your-named-token
curl -H X-Ledger-Token:$KEY "https://api.sasek.org/ledger/v1/accounts"

To create a named token, see /docs/api-guide/#access-and-keys.

Find an endpoint

Each API has a reference page listing every operation, its parameters, request and response schemas, and status codes. The pages are generated from each API's OpenAPI description, so they match the server. The index is /docs/api/. The reference sends no requests.

Errors, limits and paging

  • Ledger: JSON errors with an error message and a code. Rate limits, paging, idempotent retries and body limits: /docs/api-guide/#conventions.
  • Telemetry: 60 requests a minute per client address. A limited request gets 429 with a Retry-After header. Errors are on the reference page.

Access and keys

Each API has its own credential. There are no sessions, cookies or accounts. Examples use $KEY for a token, and say so when they need the admin token. Set it in your shell from a secret store, and never paste a real token into a document, commit or chat.

Summary

  • Ledger: a named token or the admin token, in the X-Ledger-Token header.
  • Telemetry: no credential.

Ledger: the token header

curl -H X-Ledger-Token:$KEY "https://api.sasek.org/ledger/v1/accounts"

A request gets 404 Not found, with the body {"error":"Not found","code":"not_found"}, when the header is missing, the token is wrong or revoked, or the token's scope does not allow the method or route. The same response is returned for a route that does not exist, so a caller cannot tell which tokens exist.

Ledger: two kinds of token

  • The admin token. One secret for the whole ledger, set on the server as LEDGER_TOKEN. It must be at least 32 characters, or the server refuses to start. It can do everything, including creating and revoking named tokens. The API never displays it. To change it, update the stored secret and redeploy the ledger.
  • Named tokens. Created with the admin token, one per client. Each is revoked on its own. Clients other than the owner should use a named token.

Ledger: scopes

  • read: GET requests only. Other methods get 404.
  • write: any method on the data routes. Token management and source settings get 404.
  • admin: everything. Only the admin token has it, and it is the only scope that manages tokens and source settings.

Ledger: create a named token

POST /v1/tokens takes a name and a scope of read or write, and needs the admin token. It returns 201 Created with the token's id, name, scope and created_at, and the new token in the token field. The token is shown only in this response. The server stores only its SHA-256 hash, so a lost token cannot be recovered. Create another and revoke the lost one.

curl -X POST -H X-Ledger-Token:$KEY -H "Content-Type: application/json" \
  -d '{"name":"Phone shortcut","scope":"read"}' \
  https://api.sasek.org/ledger/v1/tokens

# 201 Created
{"id":4,"name":"Phone shortcut","scope":"read","created_at":"2026-10-12T09:14:02.118Z","token":"lt_..."}

A name is required and is trimmed. Any other field, or a scope other than read or write, gets 400 invalid_request.

Ledger: list and revoke

  • GET /v1/tokens returns every named token, oldest first, with id, name, scope, created_at, last_used_at and revoked_at. Token values are never listed. Check last_used_at before revoking, to see whether a client still calls the API.
  • DELETE /v1/tokens/{id} revokes a token and returns 204 No Content. The token stops working on the next request and cannot be restored. An unknown or already revoked id returns 404.
curl -X DELETE -H X-Ledger-Token:$KEY https://api.sasek.org/ledger/v1/tokens/4

Ledger: source settings

Some sources are off until you enable them. Only the admin token can read or change their settings. GET /v1/sources/{id}/settings returns source, enabled, config and updated_at. A source with no saved settings reports enabled false, an empty config and a null updated_at. PUT /v1/sources/{id}/settings accepts enabled, config, or both. Omitted fields keep their values. config replaces the whole object, so send every key you want to keep.

A source cannot be enabled until its config has every required key. The collectr source requires profile_url, an https address of at most 500 characters. If it is missing, the PUT returns 400 invalid_request, names the missing key, and saves nothing.

curl -X PUT -H X-Ledger-Token:$KEY -H "Content-Type: application/json" \
  -d '{"enabled":true,"config":{"profile_url":"https://example.com/your-page"}}' \
  https://api.sasek.org/ledger/v1/sources/collectr/settings

Ledger: handling tokens

  • Give each client its own named token, with the smallest scope it needs. Use read unless the client must write.
  • Revoke a token when the client no longer needs it, or if anyone else may have seen it.
  • Send tokens only in the header. A token in a URL can end up in server logs, browser history and proxy logs.
  • Do not commit a token, print it in a log, or paste it into a chat or ticket.
  • Wrong or missing tokens are counted per client address. After 30 in a 15-minute window, that address gets 429 until the window ends, even with a valid token. See /docs/api-guide/#conventions.

Telemetry

Telemetry takes no token. Each client address may make 60 requests a minute. Browsers may read the responses only from https://sasek.org, the one origin in Access-Control-Allow-Origin. Other clients, such as curl, are not restricted by origin.

Keep the reference in step

Every endpoint, with its parameters, schemas, status codes and examples, is in the API reference at /docs/api/, generated from each API's OpenAPI description.

Conventions

These rules apply to every ledger route under /v1. A change that breaks a client gets a new path version. Adding a field, an endpoint or an optional parameter does not.

Formats

  • Money is an integer number of cents, in fields with a _cents suffix. Negative means money out: -1250 is $12.50 spent, and 300000 is $3,000.00 received.
  • Dates are YYYY-MM-DD. Months are YYYY-MM.
  • Timestamps are ISO 8601 in UTC, such as 2026-10-08T00:00:00Z.
  • Lists are JSON arrays with no wrapper object.
  • JSON, except the CSV import (text/csv) and the CSV exports.

Errors

Every error is a JSON object with an error message and a code. Branch on code. Messages may change; codes do not change within a version.

{ "error": "ids must be 1 to 500 transaction ids", "code": "invalid_request" }
  • 400 invalid_request: the request is malformed or breaks a rule. The message says which. Also returned for a malformed cursor, and for an export of more than 10,000 rows.
  • 400 invalid_split: the parts of a split do not add up to the transaction, or have the wrong sign.
  • 404 not_found: a missing or wrong token, a route the token's scope does not allow, or an unknown id. See /docs/api-guide/#access-and-keys.
  • 409 conflict: the change clashes with current state, such as splitting a transaction that is already split.
  • 413 payload_too_large: the body is over the route's limit. See Request bodies.
  • 415 unsupported_media_type: a write sent without the required Content-Type.
  • 429 rate_limited: this client address has sent too many requests. See Rate limits.
  • 500 internal_error: the server failed. Nothing partial was saved.

Request ids

Every response has an X-Request-Id header. To trace a request, send your own X-Request-Id of 8 to 64 letters, digits or hyphens. The server logs that id. Any other value is replaced with a new id, which is returned in the response.

Paging

Long lists return one page at a time. A page with more rows after it carries an X-Next-Cursor header. Pass its value as the cursor query parameter for the next page. The last page has no such header, so stop when it is absent. Cursors stay stable when new rows arrive; offsets do not. A malformed cursor gets 400.

  • Routes that page with cursor: GET /v1/transactions, GET /v1/accounts/{id}/balances, GET /v1/holdings/history and GET /v1/notifications.
  • GET /v1/transactions also takes offset, from 0 to 1,000,000. Send either cursor or a non-zero offset, not both. Sending both gets 400.
  • Other lists take only limit and return one page.
  • Defaults and maximums: most lists default to 500 and allow up to 1000. Notifications default to 50 and allow up to 500. Merchants default to 200. The monthly summary defaults to 120. Each list's ceiling is on its reference page.

To fetch every transaction page, read the cursor from the first response's headers and send it with the second request:

curl -s -D headers.txt -o page1.json -H X-Ledger-Token:$KEY \
  "https://api.sasek.org/ledger/v1/transactions?limit=500"
next=$(tr -d '\r' < headers.txt | awk -F': ' 'tolower($1)=="x-next-cursor" {print $2}')
curl -s -o page2.json -H X-Ledger-Token:$KEY \
  "https://api.sasek.org/ledger/v1/transactions?limit=500&cursor=$next"

Idempotent retries

POST /v1/transactions and POST /v1/imports accept an Idempotency-Key header of 1 to 200 characters. Use one unique key per logical create. A retry with the same key returns the stored response with Idempotent-Replayed: true, and creates nothing new.

  • Only 2xx responses are stored. A failed request can be retried with the same key.
  • Keys are scoped to the token that sent them. Two tokens using the same key each get their own response.
  • The nightly cleanup deletes a stored key one day after it was written. A retry after that creates a new record.
  • A manual transaction can also use a unique external_id in the body. Sending it again returns 200 with created: false and no second transaction.

Rate limits

  • Every request counts toward 120 per minute for its client address, including requests with missing or wrong tokens. The limit is checked before the token, so a limited request gets 429 whatever token it sends.
  • Wrong or missing tokens are counted separately. After 30 in a 15-minute window, the address gets 429 until the window ends. The window starts with the first failure. A correct token is refused during the block too.
  • A limited request gets 429 with a Retry-After header in seconds, and the body {"error":"Too many requests","code":"rate_limited"}.
  • Normal use stays well below both limits.

The client address is the one the proxy records. Behind the gateway (LEDGER_TRUST_PROXY=1), it is the X-Real-IP header that Caddy sets. If that is missing, it is the last entry of X-Forwarded-For, which the proxy appends. The first entry comes from the client and is never used. Without a trusted proxy, the connection's address is used and both headers are ignored. A client cannot choose its address by sending either header.

Caching

Every ledger response carries Cache-Control: no-store. The telemetry summary is the only cached API response, reusable for five seconds.

Request bodies

  • JSON bodies are limited to 16 KB. Larger bodies get 413.
  • A CSV import is limited to 1 MB and 5,000 rows. Larger files get 413, or 400 for too many rows.
  • A JSON write must send Content-Type: application/json, and an import must send Content-Type: text/csv. Otherwise the request gets 415.

Concepts the reference does not explain

Categories

Every transaction has one of three category kinds: a category from the category list, set by you or by a rule; Transfers, for a matched transfer between your accounts; or Uncategorized, when no rule matches yet. A rule never overwrites a category you set.

Transfers

Two transactions are a transfer when their amounts are equal and opposite, their accounts differ, they post within three days of each other, and each side has exactly one candidate. Transfers are left out of spending and income totals. Ambiguous pairs stay in the review list until you link them.

Rules

A rule matches normalised merchant text as a substring: the pattern COSTCO matches COSTCO WHOLESALE #7. Patterns are normalised the same way as bank text. Rules are tried from the lowest priority number, and the first match wins. Rules run on each sync. POST /v1/rules/apply re-runs them now and returns how many transactions changed.

Notification events

  • income: a new incoming transaction (money in) at or above the threshold, in cents. Optionally limited to one category.
  • large_spend: a new outgoing transaction (money out) at or above the threshold, in cents. Optionally limited to one category.
  • spend_digest: one notification per day, updated as spending arrives, with the day's total and the three largest merchants.
  • transaction: every new transaction in either direction. Transfers and split parents are left out.
  • balance_below: an account's latest balance is below the threshold.
  • sync_failed: a sync or source run failed.
  • uncategorized_over: more transactions are Uncategorized than the threshold. Sent at most once a day.

A notification rule sees only transactions that arrive after it is created. Each alert is kept in the inbox at GET /v1/notifications and pushed to the ntfy server at https://ntfy.sasek.org.

CSV import format

An import file is Ledger CSV v1: UTF-8, comma-separated, with the header date,description,amount,payee,memo,category,note. Amounts are decimals with at most two places, negative for money out, with no currency symbols or thousands separators. The whole file is checked before anything is written. A dry run reports the same counts without writing. A row is a duplicate when the account already has a transaction with the same date and amount, so re-uploading a file adds nothing. The full format is in ledger/docs/import-format.md in the repository.