← back

kvit API

Everything the frontend does, it does through this API, so anything the page can do a script can do too. There are no accounts and no tokens: the slug in the URL is the only credential, and whoever has it can read and change the group.

Requests and responses are JSON. Bodies must be UTF-8, at most 64 KiB, and may not carry fields that are not listed here; an unknown field is rejected rather than ignored, so a typo fails loudly instead of silently doing nothing.

Types

A snapshot is the whole state of one group. Every call below except group creation answers with one:

{
  "slug": "kJ8fQ2xWq0A",
  "name": "Trip to Bled",
  "people": [
    { "no": 1, "name": "Ana" },
    { "no": 2, "name": "Bo" }
  ],
  "entries": [
    {
      "no": 1,
      "kind": "expense",
      "name": "dinner",
      "date": "2026-07-29",
      "amount_cents": 4200,
      "payer_no": 1,
      "shares": { "1": 1, "2": 1 }
    }
  ]
}

Notes on the fields:

The split itself is not stored. Each person owes floor(amount_cents * weight / total_weight), and the cents left over by that rounding go one each to the largest remainders, lowest person number first. That way the parts always add up to the amount exactly and the balances of a group always sum to zero.

Groups

POST /api/groups

Creates an empty, unnamed group. No body. This is the one call that does not answer with a snapshot:

{ "slug": "kJ8fQ2xWq0A" }

The slug is 64 bits from the system random source, written as 11 base64url characters. It is the group: there is no listing, no search, and no recovery, so the reply is the only chance to keep it.

GET /api/groups/{slug}

Returns the snapshot.

PATCH /api/groups/{slug}

Renames the group. An empty name is allowed and means unnamed.

{ "name": "Trip to Bled" }

People

POST /api/groups/{slug}/people

{ "name": "Ana" }

Names must be non-empty, at most 100 characters, and unique within the group. A group holds at most 200 people.

PATCH /api/groups/{slug}/people/{no}

{ "name": "Ana B" }

DELETE /api/groups/{slug}/people/{no}

Only works while the person carries no history: not the payer of any entry and holding no share in one. Otherwise the call fails with 409 and says how many entries are in the way. Deleting people out from under an expense would change what everyone else owes, so kvit refuses instead.

Entries

POST /api/groups/{slug}/entries

{
  "kind": "expense",
  "name": "dinner",
  "date": "2026-07-29",
  "amount_cents": 4200,
  "payer_no": 1,
  "shares": { "1": 1, "2": 1 }
}

All fields are required except name, which may be empty. The payer and everyone in shares must belong to this group, at least one weight must be non-zero, and each weight is between 0 and 10000. The amount may not be negative.

A settlement is an ordinary entry with kind set to settlement: the person who handed over the money is the payer, and the person who received it holds the only share. The frontend writes these with an empty name and labels them from the kind.

PATCH /api/groups/{slug}/entries/{no}

Same body as above, and it replaces the entry outright rather than merging, shares included. There is no version check: two clients editing the same entry end with whichever write landed last.

DELETE /api/groups/{slug}/entries/{no}

Removes the entry and its shares.

Watching a group

GET /api/groups/{slug}/ws

A websocket. It sends the current snapshot immediately on connect, then the new one after every change anyone makes to that group, in the same JSON as GET /api/groups/{slug}. Nothing sent by the client is read, and there is nothing to poll: the server pushes a whole snapshot rather than a diff, so a client can simply replace its state each time.

The server pings every 30 seconds to keep idle connections alive through proxies. If the socket drops, refetching the group over HTTP gets you back to the same state.

Errors

Failures answer with a status code and a body naming what went wrong:

{ "error": "payer_not_member" }
{ "error": "person_name_taken", "args": { "name": "Ana" } }

error is a stable machine-readable code, not a sentence: kvit has no fixed language for people, only for programs. args, when present, carries the values a message built from that code would need to fill in, such as a name or a count.

codestatusargsmeaning
group_full400maxthe group already holds the maximum number of people
person_name_empty400a person's name cannot be empty
person_name_too_long400maxa person's name is too long
person_name_invalid400a person's name has invalid bytes or control characters
person_name_taken409nameanother person already has this name in the group
person_is_payer409countthe person paid for entries and cannot be removed
person_has_shares409countthe person has a share in entries and cannot be removed
group_name_too_long400maxthe group name is too long
group_name_invalid400the group name has invalid bytes or control characters
entry_name_too_long400maxthe entry description is too long
entry_name_invalid400the entry description has invalid bytes or control characters
invalid_kind400kind is neither expense nor settlement
invalid_date400date is not YYYY-MM-DD
amount_negative400amount_cents is negative
amount_too_large400amount_cents is implausibly large
weight_negative400a share weight is negative
weight_too_large400a share weight is over 10000
shares_all_zero400no share weight is above zero
payer_not_member400payer_no is not a member of this group
person_not_member400noa person named in shares is not a member of this group
not_found404no such group, person or entry
malformed_body400the request body is not valid JSON, or carries an unknown field
bad_path_param400fielda number in the URL path is missing or not positive
rate_limited429too many requests for groups that do not exist
internal_error500a fault on the server; the detail is in its log, not in the reply

Only 404s are rate limited, 20 per minute per client. Normal use never reaches that, but guessing at slugs does.

Worked example

base=http://localhost:8080

slug=$(curl -sX POST $base/api/groups | jq -r .slug)

curl -sX POST $base/api/groups/$slug/people -d '{"name":"Ana"}'
curl -sX POST $base/api/groups/$slug/people -d '{"name":"Bo"}'

curl -sX POST $base/api/groups/$slug/entries -d '{
  "kind":"expense","name":"dinner","date":"2026-07-29",
  "amount_cents":4200,"payer_no":1,"shares":{"1":1,"2":1}}'

curl -s $base/api/groups/$slug

Ana paid 42.00 for a dinner split evenly, so Bo owes her 21.00. Open /g/$slug in a browser to see the same group.