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.
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:
no numbers people and entries within one group, starting at 1.
They are not reused after a deletion, and they mean nothing outside the group.amount_cents is an integer number of cents. There are no floats
anywhere in kvit; sending one is an error.date is the day the money was spent, as
YYYY-MM-DD. It carries no time.kind is "expense" or "settlement". It
only decides how a row is labelled; the balance arithmetic treats both the
same.shares maps person number to an integer weight. Weights are a
ratio, not an amount: {"1": 2, "2": 1} means the first person
carries twice the second. A person left out of the map carries nothing, and a
weight of zero is stored as absence. JSON object keys are strings, so the person
numbers appear quoted.entries comes back ordered by date, then by no.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.
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.
Returns the snapshot.
Renames the group. An empty name is allowed and means unnamed.
{ "name": "Trip to Bled" }
{ "name": "Ana" }
Names must be non-empty, at most 100 characters, and unique within the group. A group holds at most 200 people.
{ "name": "Ana B" }
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.
{
"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.
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.
Removes the entry and its shares.
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.
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.
| code | status | args | meaning |
|---|---|---|---|
group_full | 400 | max | the group already holds the maximum number of people |
person_name_empty | 400 | a person's name cannot be empty | |
person_name_too_long | 400 | max | a person's name is too long |
person_name_invalid | 400 | a person's name has invalid bytes or control characters | |
person_name_taken | 409 | name | another person already has this name in the group |
person_is_payer | 409 | count | the person paid for entries and cannot be removed |
person_has_shares | 409 | count | the person has a share in entries and cannot be removed |
group_name_too_long | 400 | max | the group name is too long |
group_name_invalid | 400 | the group name has invalid bytes or control characters | |
entry_name_too_long | 400 | max | the entry description is too long |
entry_name_invalid | 400 | the entry description has invalid bytes or control characters | |
invalid_kind | 400 | kind is neither expense nor settlement | |
invalid_date | 400 | date is not YYYY-MM-DD | |
amount_negative | 400 | amount_cents is negative | |
amount_too_large | 400 | amount_cents is implausibly large | |
weight_negative | 400 | a share weight is negative | |
weight_too_large | 400 | a share weight is over 10000 | |
shares_all_zero | 400 | no share weight is above zero | |
payer_not_member | 400 | payer_no is not a member of this group | |
person_not_member | 400 | no | a person named in shares is not a member of this group |
not_found | 404 | no such group, person or entry | |
malformed_body | 400 | the request body is not valid JSON, or carries an unknown field | |
bad_path_param | 400 | field | a number in the URL path is missing or not positive |
rate_limited | 429 | too many requests for groups that do not exist | |
internal_error | 500 | a 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.
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.