Files
kvitt/contracts/openapi.yaml
T
jacobh2 c6df2ab8f5 M1: contracts and vectors (SPEC v0.1, schema 1)
contracts/openapi.yaml (OpenAPI 3.1, E-1..E-33, error shape and the
exhaustive error code enum, device bearer scheme), contracts/schema/
server-v1.sql (SPEC §14.1 verbatim), seven test-vector files per §14.5.5,
80 examples (every JSON body of §14.3.4, every error code, sync pull/push,
every FCM push type), and tools/contracts-check.py (stdlib-only reference
of §9.1, §9.3, §9.4 and §14.5.4 plus shape checks; the M1 gate).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 20:57:59 +02:00

2483 lines
82 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Kvitt HTTP API, written by hand from SPEC v0.1 §7 and §14.3 (milestone M1). Schema version 1, sync protocol 1.
# SPEC §14.3 is binding; a change to this file follows the contract-change rule of SPEC §14.8.
# Value limits (lengths, ranges) are not encoded as JSON Schema constraints: a value outside SPEC §9.6 is
# 422 validation_failed, not 400 invalid_request (SPEC §14.3.1), so the limits stay in SPEC §9.6.
openapi: 3.1.0
info:
title: Kvitt
version: '1'
description: |
Shared-expense server for a two-person household (SPEC.md). Money is integer öre, timestamps are
RFC 3339 UTC with millisecond precision (`2026-10-17T06:00:00.000Z`), ids are lowercase UUIDv7 strings.
Common rules (SPEC §14.3.1):
* Every JSON body is `application/json; charset=utf-8`.
* Unknown JSON fields in requests are ignored. Missing required fields or wrong types: 400 `invalid_request`.
Values outside SPEC §9.6: 422 `validation_failed` with `field`.
* Optional fields in responses are present with `null`, never omitted.
* `PATCH` bodies: every field optional; absent = unchanged; at least one field required (`invalid_request`).
* Path ids that are not a UUID: 404 `not_found`.
* Authenticated endpoints without `X-Kvitt-Protocol: 1`: 426 `protocol_unsupported`.
* Every response carries `X-Kvitt-Server-Version`.
* Besides the codes listed per operation, authenticated operations can return `unauthorized`, `device_revoked`,
`protocol_unsupported`, `rate_limited`, `internal`, `unavailable`; unauthenticated ones `rate_limited`,
`internal`, `unavailable`. `not_implemented` (501) exists only during milestones M2a to M4.
servers:
- url: https://icehack.asuscomm.com:8443
security:
- device: []
paths:
/health:
get:
operationId: getHealth
summary: E-1 Liveness and DB check
security: []
parameters:
- $ref: '#/components/parameters/ProtocolOptional'
- $ref: '#/components/parameters/AppVersion'
- $ref: '#/components/parameters/AcceptLanguage'
responses:
'200':
description: Server and database are up.
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/Health'
'429':
$ref: '#/components/responses/RateLimited'
'500':
$ref: '#/components/responses/Internal'
'503':
description: |
The database check (`SELECT 1`) failed: the `Health` shape with `status` `degraded` and `db_ok` false.
An `Error` with code `unavailable` when the server is shutting down.
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
# TODO(spec): §14.3.4 E-1 gives 503 the Health shape; §14.3.2 gives every endpoint 503 `unavailable` (Error shape).
oneOf:
- $ref: '#/components/schemas/Health'
- $ref: '#/components/schemas/Error'
/setup/status:
get:
operationId: getSetupStatus
summary: E-2 Whether the household has been bootstrapped
security: []
parameters:
- $ref: '#/components/parameters/ProtocolOptional'
- $ref: '#/components/parameters/AppVersion'
- $ref: '#/components/parameters/AcceptLanguage'
responses:
'200':
description: '`initialized` is true once the admin_setup invite has been accepted (AC-1.5).'
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/SetupStatus'
'429':
$ref: '#/components/responses/RateLimited'
'500':
$ref: '#/components/responses/Internal'
'503':
$ref: '#/components/responses/Unavailable'
/invites/accept:
post:
operationId: acceptInvite
summary: E-3 Pair a device with an invite code (all three invite kinds)
security: []
parameters:
- $ref: '#/components/parameters/ProtocolOptional'
- $ref: '#/components/parameters/AppVersion'
- $ref: '#/components/parameters/AcceptLanguage'
requestBody:
required: true
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/AcceptInviteRequest'
responses:
'200':
description: Paired. The token is shown only here.
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/AcceptInviteResponse'
'400':
$ref: '#/components/responses/BadRequest'
description: '`invalid_request` (also a missing `display_name` for admin_setup and partner), `invite_invalid`.'
'409':
$ref: '#/components/responses/Conflict'
description: '`household_full`.'
'422':
$ref: '#/components/responses/Unprocessable'
description: '`validation_failed`.'
'429':
$ref: '#/components/responses/RateLimited'
description: '`rate_limited`, also after more than 10 failed accepts from one IP within 60 minutes (AC-2.12).'
'500':
$ref: '#/components/responses/Internal'
'503':
$ref: '#/components/responses/Unavailable'
/i/{code}:
get:
operationId: getInviteLanding
summary: E-4 Invite landing page (HTML)
description: |
HTML per AC-2.5 for any `code` of 1 to 32 characters; validity is not checked. Swedish when
`Accept-Language` prefers `sv`, English otherwise.
security: []
parameters:
- name: code
in: path
required: true
schema:
type: string
- $ref: '#/components/parameters/ProtocolOptional'
- $ref: '#/components/parameters/AppVersion'
- $ref: '#/components/parameters/AcceptLanguage'
responses:
'200':
description: Landing page.
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
text/html; charset=utf-8:
schema:
type: string
'404':
description: '`code` not 1 to 32 characters: an HTML 404 page.'
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
text/html; charset=utf-8:
schema:
type: string
'429':
$ref: '#/components/responses/RateLimited'
'500':
$ref: '#/components/responses/Internal'
'503':
$ref: '#/components/responses/Unavailable'
/apk/latest:
get:
operationId: getApkLatest
summary: E-5 Latest APK metadata
security: []
parameters:
- $ref: '#/components/parameters/ProtocolOptional'
- $ref: '#/components/parameters/AppVersion'
- $ref: '#/components/parameters/AcceptLanguage'
responses:
'200':
description: The content of `latest.json` (SPEC §10.6), read on every request.
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/ApkLatest'
'404':
$ref: '#/components/responses/NotFound'
description: '`not_found` (nothing published).'
'429':
$ref: '#/components/responses/RateLimited'
'500':
$ref: '#/components/responses/Internal'
'503':
$ref: '#/components/responses/Unavailable'
/apk/files/{file_name}:
get:
operationId: getApkFile
summary: E-6 APK download
security: []
parameters:
- name: file_name
in: path
required: true
description: Served only when it matches `^kvitt-[0-9]+\.apk$` and exists (SPEC §10.6).
schema:
type: string
- $ref: '#/components/parameters/ProtocolOptional'
- $ref: '#/components/parameters/AppVersion'
- $ref: '#/components/parameters/AcceptLanguage'
responses:
'200':
description: The APK bytes.
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
Content-Length:
required: true
schema:
type: integer
content:
application/vnd.android.package-archive:
schema:
type: string
contentMediaType: application/vnd.android.package-archive
'404':
$ref: '#/components/responses/NotFound'
description: '`not_found` (unknown APK file).'
'429':
$ref: '#/components/responses/RateLimited'
'500':
$ref: '#/components/responses/Internal'
'503':
$ref: '#/components/responses/Unavailable'
/me:
get:
operationId: getMe
summary: E-7 Current user, device and household users
parameters:
- $ref: '#/components/parameters/Protocol'
- $ref: '#/components/parameters/AppVersion'
- $ref: '#/components/parameters/AcceptLanguage'
responses:
'200':
description: The caller's user and device.
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/MeResponse'
'401':
$ref: '#/components/responses/Unauthorized'
'426':
$ref: '#/components/responses/ProtocolUnsupported'
'429':
$ref: '#/components/responses/RateLimited'
'500':
$ref: '#/components/responses/Internal'
'503':
$ref: '#/components/responses/Unavailable'
patch:
operationId: patchMe
summary: E-8 Change own display name
parameters:
- $ref: '#/components/parameters/Protocol'
- $ref: '#/components/parameters/AppVersion'
- $ref: '#/components/parameters/AcceptLanguage'
requestBody:
required: true
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/PatchMeRequest'
responses:
'200':
description: The updated user.
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/User'
'400':
$ref: '#/components/responses/BadRequest'
description: '`invalid_request`.'
'401':
$ref: '#/components/responses/Unauthorized'
'422':
$ref: '#/components/responses/Unprocessable'
description: '`validation_failed`.'
'426':
$ref: '#/components/responses/ProtocolUnsupported'
'429':
$ref: '#/components/responses/RateLimited'
'500':
$ref: '#/components/responses/Internal'
'503':
$ref: '#/components/responses/Unavailable'
/me/picture:
put:
operationId: putMePicture
summary: E-9 Upload own picture
description: JPEG, PNG or WebP up to 5 MiB and 8000 × 8000 px; processed per AC-4.4.
parameters:
- $ref: '#/components/parameters/Protocol'
- $ref: '#/components/parameters/AppVersion'
- $ref: '#/components/parameters/AcceptLanguage'
requestBody:
required: true
content:
image/jpeg:
schema:
type: string
contentMediaType: image/jpeg
image/png:
schema:
type: string
contentMediaType: image/png
image/webp:
schema:
type: string
contentMediaType: image/webp
responses:
'200':
description: The user with the new `picture_version`.
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/User'
'401':
$ref: '#/components/responses/Unauthorized'
'413':
$ref: '#/components/responses/PayloadTooLarge'
description: '`payload_too_large` (over 5 MiB).'
'415':
$ref: '#/components/responses/UnsupportedMediaType'
description: '`unsupported_media_type`.'
'422':
$ref: '#/components/responses/Unprocessable'
description: '`image_invalid`.'
'426':
$ref: '#/components/responses/ProtocolUnsupported'
'429':
$ref: '#/components/responses/RateLimited'
'500':
$ref: '#/components/responses/Internal'
'503':
$ref: '#/components/responses/Unavailable'
delete:
operationId: deleteMePicture
summary: E-10 Remove own picture
description: Idempotent.
parameters:
- $ref: '#/components/parameters/Protocol'
- $ref: '#/components/parameters/AppVersion'
- $ref: '#/components/parameters/AcceptLanguage'
responses:
'200':
description: The user with `picture_version` null.
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/User'
'401':
$ref: '#/components/responses/Unauthorized'
'426':
$ref: '#/components/responses/ProtocolUnsupported'
'429':
$ref: '#/components/responses/RateLimited'
'500':
$ref: '#/components/responses/Internal'
'503':
$ref: '#/components/responses/Unavailable'
/users/{user_id}/picture:
get:
operationId: getUserPicture
summary: E-11 Download a user's picture
parameters:
- $ref: '#/components/parameters/UserId'
- name: size
in: query
required: false
description: Other values are `invalid_request`.
schema:
type: integer
enum: [128, 512]
default: 512
- name: v
in: query
required: false
description: The `picture_version`; a cache key only, ignored by the server.
schema:
type: integer
- $ref: '#/components/parameters/Protocol'
- $ref: '#/components/parameters/AppVersion'
- $ref: '#/components/parameters/AcceptLanguage'
responses:
'200':
description: The square JPEG.
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
ETag:
description: '`"<picture_version>"`'
required: true
schema:
type: string
Cache-Control:
description: '`private, max-age=31536000, immutable`'
required: true
schema:
type: string
content:
image/jpeg:
schema:
type: string
contentMediaType: image/jpeg
'400':
$ref: '#/components/responses/BadRequest'
description: '`invalid_request` (bad `size`).'
'401':
$ref: '#/components/responses/Unauthorized'
'404':
$ref: '#/components/responses/NotFound'
description: '`not_found` (unknown user or no picture).'
'426':
$ref: '#/components/responses/ProtocolUnsupported'
'429':
$ref: '#/components/responses/RateLimited'
'500':
$ref: '#/components/responses/Internal'
'503':
$ref: '#/components/responses/Unavailable'
/invites:
post:
operationId: createInvite
summary: E-12 Create a partner or device invite (admin)
description: Creating a `partner` invite cancels any pending one (AC-2.1).
parameters:
- $ref: '#/components/parameters/Protocol'
- $ref: '#/components/parameters/AppVersion'
- $ref: '#/components/parameters/AcceptLanguage'
requestBody:
required: true
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/CreateInviteRequest'
responses:
'201':
description: The invite with `code` (12 characters, no hyphens) and `link` (`<public_url>/i/<code>`).
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/Invite'
'400':
$ref: '#/components/responses/BadRequest'
description: '`invalid_request`.'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
description: '`forbidden` (caller is not the admin).'
'404':
$ref: '#/components/responses/NotFound'
description: '`not_found` (target user).'
'409':
$ref: '#/components/responses/Conflict'
description: '`household_full`.'
'426':
$ref: '#/components/responses/ProtocolUnsupported'
'429':
$ref: '#/components/responses/RateLimited'
'500':
$ref: '#/components/responses/Internal'
'503':
$ref: '#/components/responses/Unavailable'
get:
operationId: listInvites
summary: E-13 List pending invites (admin)
parameters:
- $ref: '#/components/parameters/Protocol'
- $ref: '#/components/parameters/AppVersion'
- $ref: '#/components/parameters/AcceptLanguage'
responses:
'200':
description: Pending invites only (not accepted, not cancelled, not expired), `code` and `link` null.
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/InviteList'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
description: '`forbidden`.'
'426':
$ref: '#/components/responses/ProtocolUnsupported'
'429':
$ref: '#/components/responses/RateLimited'
'500':
$ref: '#/components/responses/Internal'
'503':
$ref: '#/components/responses/Unavailable'
/invites/{invite_id}:
delete:
operationId: cancelInvite
summary: E-14 Cancel an invite (admin)
description: Idempotent for an already cancelled invite.
parameters:
- $ref: '#/components/parameters/InviteId'
- $ref: '#/components/parameters/Protocol'
- $ref: '#/components/parameters/AppVersion'
- $ref: '#/components/parameters/AcceptLanguage'
responses:
'204':
description: Cancelled.
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
description: '`forbidden`.'
'404':
$ref: '#/components/responses/NotFound'
description: '`not_found`.'
'426':
$ref: '#/components/responses/ProtocolUnsupported'
'429':
$ref: '#/components/responses/RateLimited'
'500':
$ref: '#/components/responses/Internal'
'503':
$ref: '#/components/responses/Unavailable'
/devices:
get:
operationId: listDevices
summary: E-15 List devices (admin all, member own)
parameters:
- $ref: '#/components/parameters/Protocol'
- $ref: '#/components/parameters/AppVersion'
- $ref: '#/components/parameters/AcceptLanguage'
responses:
'200':
description: Ordered by `created_at`, revoked devices included (with `revoked_at`).
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/DeviceList'
'401':
$ref: '#/components/responses/Unauthorized'
'426':
$ref: '#/components/responses/ProtocolUnsupported'
'429':
$ref: '#/components/responses/RateLimited'
'500':
$ref: '#/components/responses/Internal'
'503':
$ref: '#/components/responses/Unavailable'
/devices/{device_id}:
delete:
operationId: revokeDevice
summary: E-16 Revoke a device
description: Revoking an already revoked device is 204. Admin any device except the current one; member own devices except the current one (SPEC §8.4).
parameters:
- $ref: '#/components/parameters/DeviceId'
- $ref: '#/components/parameters/Protocol'
- $ref: '#/components/parameters/AppVersion'
- $ref: '#/components/parameters/AcceptLanguage'
responses:
'204':
description: Revoked.
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
description: '`forbidden`.'
'404':
$ref: '#/components/responses/NotFound'
description: '`not_found`.'
'409':
$ref: '#/components/responses/Conflict'
description: '`cannot_revoke_current_device`.'
'426':
$ref: '#/components/responses/ProtocolUnsupported'
'429':
$ref: '#/components/responses/RateLimited'
'500':
$ref: '#/components/responses/Internal'
'503':
$ref: '#/components/responses/Unavailable'
/devices/current/push-token:
put:
operationId: putPushToken
summary: E-17 Set or clear this device's FCM token
parameters:
- $ref: '#/components/parameters/Protocol'
- $ref: '#/components/parameters/AppVersion'
- $ref: '#/components/parameters/AcceptLanguage'
requestBody:
required: true
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/PushTokenRequest'
responses:
'204':
description: Stored (or cleared).
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
'400':
$ref: '#/components/responses/BadRequest'
description: '`invalid_request`.'
'401':
$ref: '#/components/responses/Unauthorized'
'426':
$ref: '#/components/responses/ProtocolUnsupported'
'429':
$ref: '#/components/responses/RateLimited'
'500':
$ref: '#/components/responses/Internal'
'503':
$ref: '#/components/responses/Unavailable'
/accounts:
post:
operationId: createAccount
summary: E-18 Create account
parameters:
- $ref: '#/components/parameters/Protocol'
- $ref: '#/components/parameters/AppVersion'
- $ref: '#/components/parameters/AcceptLanguage'
requestBody:
required: true
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/CreateAccountRequest'
responses:
'201':
description: The new account.
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/Account'
'400':
$ref: '#/components/responses/BadRequest'
description: '`invalid_request`.'
'401':
$ref: '#/components/responses/Unauthorized'
'409':
$ref: '#/components/responses/Conflict'
description: '`name_taken`, `household_incomplete`.'
'422':
$ref: '#/components/responses/Unprocessable'
description: '`validation_failed`.'
'426':
$ref: '#/components/responses/ProtocolUnsupported'
'429':
$ref: '#/components/responses/RateLimited'
'500':
$ref: '#/components/responses/Internal'
'503':
$ref: '#/components/responses/Unavailable'
/accounts/{account_id}:
patch:
operationId: patchAccount
summary: E-19 Rename, change default ratio
parameters:
- $ref: '#/components/parameters/AccountId'
- $ref: '#/components/parameters/Protocol'
- $ref: '#/components/parameters/AppVersion'
- $ref: '#/components/parameters/AcceptLanguage'
requestBody:
required: true
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/PatchAccountRequest'
responses:
'200':
description: The updated account.
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/Account'
'400':
$ref: '#/components/responses/BadRequest'
description: '`invalid_request`.'
'401':
$ref: '#/components/responses/Unauthorized'
'404':
$ref: '#/components/responses/NotFound'
description: '`not_found`.'
'409':
$ref: '#/components/responses/Conflict'
description: '`name_taken`.'
'422':
$ref: '#/components/responses/Unprocessable'
description: '`validation_failed`.'
'426':
$ref: '#/components/responses/ProtocolUnsupported'
'429':
$ref: '#/components/responses/RateLimited'
'500':
$ref: '#/components/responses/Internal'
'503':
$ref: '#/components/responses/Unavailable'
delete:
operationId: deleteAccount
summary: E-22 Soft delete when empty
description: Refused while a non-deleted expense, an allocation of a non-deleted settlement or a non-deleted recurring template references the account (AC-6.5).
parameters:
- $ref: '#/components/parameters/AccountId'
- $ref: '#/components/parameters/Protocol'
- $ref: '#/components/parameters/AppVersion'
- $ref: '#/components/parameters/AcceptLanguage'
responses:
'200':
description: The tombstone.
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/Account'
'401':
$ref: '#/components/responses/Unauthorized'
'404':
$ref: '#/components/responses/NotFound'
description: '`not_found`.'
'409':
$ref: '#/components/responses/Conflict'
description: '`account_not_empty`.'
'426':
$ref: '#/components/responses/ProtocolUnsupported'
'429':
$ref: '#/components/responses/RateLimited'
'500':
$ref: '#/components/responses/Internal'
'503':
$ref: '#/components/responses/Unavailable'
/accounts/{account_id}/archive:
post:
operationId: archiveAccount
summary: E-20 Archive account
description: No body. Idempotent.
parameters:
- $ref: '#/components/parameters/AccountId'
- $ref: '#/components/parameters/Protocol'
- $ref: '#/components/parameters/AppVersion'
- $ref: '#/components/parameters/AcceptLanguage'
responses:
'200':
description: The account.
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/Account'
'401':
$ref: '#/components/responses/Unauthorized'
'404':
$ref: '#/components/responses/NotFound'
description: '`not_found`.'
'426':
$ref: '#/components/responses/ProtocolUnsupported'
'429':
$ref: '#/components/responses/RateLimited'
'500':
$ref: '#/components/responses/Internal'
'503':
$ref: '#/components/responses/Unavailable'
/accounts/{account_id}/unarchive:
post:
operationId: unarchiveAccount
summary: E-21 Unarchive account
description: No body. Idempotent.
parameters:
- $ref: '#/components/parameters/AccountId'
- $ref: '#/components/parameters/Protocol'
- $ref: '#/components/parameters/AppVersion'
- $ref: '#/components/parameters/AcceptLanguage'
responses:
'200':
description: The account.
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/Account'
'401':
$ref: '#/components/responses/Unauthorized'
'404':
$ref: '#/components/responses/NotFound'
description: '`not_found`.'
'426':
$ref: '#/components/responses/ProtocolUnsupported'
'429':
$ref: '#/components/responses/RateLimited'
'500':
$ref: '#/components/responses/Internal'
'503':
$ref: '#/components/responses/Unavailable'
/categories:
post:
operationId: createCategory
summary: E-23 Create category
description: New categories get `sort_order` = current maximum + 10 (AC-7.3). `icon` and `color` are tokens of SPEC §9.7.
parameters:
- $ref: '#/components/parameters/Protocol'
- $ref: '#/components/parameters/AppVersion'
- $ref: '#/components/parameters/AcceptLanguage'
requestBody:
required: true
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/CreateCategoryRequest'
responses:
'201':
description: The new category.
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/Category'
'400':
$ref: '#/components/responses/BadRequest'
description: '`invalid_request`.'
'401':
$ref: '#/components/responses/Unauthorized'
'422':
$ref: '#/components/responses/Unprocessable'
description: '`validation_failed`.'
'426':
$ref: '#/components/responses/ProtocolUnsupported'
'429':
$ref: '#/components/responses/RateLimited'
'500':
$ref: '#/components/responses/Internal'
'503':
$ref: '#/components/responses/Unavailable'
/categories/{category_id}:
patch:
operationId: patchCategory
summary: E-24 Rename, icon, colour, order, reset name
description: |
`name: null` resets a default category's name. Reordering several categories is one PATCH per category whose
`sort_order` changes.
parameters:
- $ref: '#/components/parameters/CategoryId'
- $ref: '#/components/parameters/Protocol'
- $ref: '#/components/parameters/AppVersion'
- $ref: '#/components/parameters/AcceptLanguage'
requestBody:
required: true
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/PatchCategoryRequest'
responses:
'200':
description: The updated category.
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/Category'
'400':
$ref: '#/components/responses/BadRequest'
description: '`invalid_request`.'
'401':
$ref: '#/components/responses/Unauthorized'
'404':
$ref: '#/components/responses/NotFound'
description: '`not_found`.'
'409':
$ref: '#/components/responses/Conflict'
description: '`category_is_default` (`name: null` on a non-default category).'
'422':
$ref: '#/components/responses/Unprocessable'
description: '`validation_failed`.'
'426':
$ref: '#/components/responses/ProtocolUnsupported'
'429':
$ref: '#/components/responses/RateLimited'
'500':
$ref: '#/components/responses/Internal'
'503':
$ref: '#/components/responses/Unavailable'
delete:
operationId: deleteCategory
summary: E-27 Soft delete when unused and not default
parameters:
- $ref: '#/components/parameters/CategoryId'
- $ref: '#/components/parameters/Protocol'
- $ref: '#/components/parameters/AppVersion'
- $ref: '#/components/parameters/AcceptLanguage'
responses:
'200':
description: The tombstone.
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/Category'
'401':
$ref: '#/components/responses/Unauthorized'
'404':
$ref: '#/components/responses/NotFound'
description: '`not_found`.'
'409':
$ref: '#/components/responses/Conflict'
description: '`category_in_use`, `category_is_default`.'
'426':
$ref: '#/components/responses/ProtocolUnsupported'
'429':
$ref: '#/components/responses/RateLimited'
'500':
$ref: '#/components/responses/Internal'
'503':
$ref: '#/components/responses/Unavailable'
/categories/{category_id}/archive:
post:
operationId: archiveCategory
summary: E-25 Archive category
description: No body.
parameters:
- $ref: '#/components/parameters/CategoryId'
- $ref: '#/components/parameters/Protocol'
- $ref: '#/components/parameters/AppVersion'
- $ref: '#/components/parameters/AcceptLanguage'
responses:
'200':
description: The category.
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/Category'
'401':
$ref: '#/components/responses/Unauthorized'
'404':
$ref: '#/components/responses/NotFound'
description: '`not_found`.'
'426':
$ref: '#/components/responses/ProtocolUnsupported'
'429':
$ref: '#/components/responses/RateLimited'
'500':
$ref: '#/components/responses/Internal'
'503':
$ref: '#/components/responses/Unavailable'
/categories/{category_id}/unarchive:
post:
operationId: unarchiveCategory
summary: E-26 Unarchive category
description: No body.
parameters:
- $ref: '#/components/parameters/CategoryId'
- $ref: '#/components/parameters/Protocol'
- $ref: '#/components/parameters/AppVersion'
- $ref: '#/components/parameters/AcceptLanguage'
responses:
'200':
description: The category.
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/Category'
'401':
$ref: '#/components/responses/Unauthorized'
'404':
$ref: '#/components/responses/NotFound'
description: '`not_found`.'
'426':
$ref: '#/components/responses/ProtocolUnsupported'
'429':
$ref: '#/components/responses/RateLimited'
'500':
$ref: '#/components/responses/Internal'
'503':
$ref: '#/components/responses/Unavailable'
/recurring-templates:
post:
operationId: createRecurringTemplate
summary: E-28 Create recurring template
description: If a period is already due (SPEC §9.5), the server generates it before responding.
parameters:
- $ref: '#/components/parameters/Protocol'
- $ref: '#/components/parameters/AppVersion'
- $ref: '#/components/parameters/AcceptLanguage'
requestBody:
required: true
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/CreateRecurringTemplateRequest'
responses:
'201':
description: The new template.
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/RecurringTemplate'
'400':
$ref: '#/components/responses/BadRequest'
description: '`invalid_request`.'
'401':
$ref: '#/components/responses/Unauthorized'
'409':
$ref: '#/components/responses/Conflict'
description: '`household_incomplete`.'
'422':
$ref: '#/components/responses/Unprocessable'
description: '`validation_failed` (also for an unknown or deleted `account_id` or `category_id`, with `field`).'
'426':
$ref: '#/components/responses/ProtocolUnsupported'
'429':
$ref: '#/components/responses/RateLimited'
'500':
$ref: '#/components/responses/Internal'
'503':
$ref: '#/components/responses/Unavailable'
/recurring-templates/{template_id}:
patch:
operationId: patchRecurringTemplate
summary: E-29 Edit, pause, resume
description: Any subset of the E-28 fields; `split_mode` and `splits` must be sent together.
parameters:
- $ref: '#/components/parameters/TemplateId'
- $ref: '#/components/parameters/Protocol'
- $ref: '#/components/parameters/AppVersion'
- $ref: '#/components/parameters/AcceptLanguage'
requestBody:
required: true
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/PatchRecurringTemplateRequest'
responses:
'200':
description: The updated template.
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/RecurringTemplate'
'400':
$ref: '#/components/responses/BadRequest'
description: '`invalid_request`.'
'401':
$ref: '#/components/responses/Unauthorized'
'404':
$ref: '#/components/responses/NotFound'
description: '`not_found`.'
'422':
$ref: '#/components/responses/Unprocessable'
description: '`validation_failed`.'
'426':
$ref: '#/components/responses/ProtocolUnsupported'
'429':
$ref: '#/components/responses/RateLimited'
'500':
$ref: '#/components/responses/Internal'
'503':
$ref: '#/components/responses/Unavailable'
delete:
operationId: deleteRecurringTemplate
summary: E-30 Soft delete recurring template
parameters:
- $ref: '#/components/parameters/TemplateId'
- $ref: '#/components/parameters/Protocol'
- $ref: '#/components/parameters/AppVersion'
- $ref: '#/components/parameters/AcceptLanguage'
responses:
'200':
description: The tombstone.
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/RecurringTemplate'
'401':
$ref: '#/components/responses/Unauthorized'
'404':
$ref: '#/components/responses/NotFound'
description: '`not_found`.'
'426':
$ref: '#/components/responses/ProtocolUnsupported'
'429':
$ref: '#/components/responses/RateLimited'
'500':
$ref: '#/components/responses/Internal'
'503':
$ref: '#/components/responses/Unavailable'
/sync:
get:
operationId: syncPull
summary: E-31 Pull changes since a cursor
description: SPEC §14.4.2.
parameters:
- name: since
in: query
# TODO(spec): §14.4.2 does not say whether `since` may be omitted; required here (the phone's cursor starts at 0).
required: true
description: The highest `server_version` the phone has applied (0 initially).
schema:
type: integer
format: int64
- name: limit
in: query
required: false
description: Rows per page, 1 to 1000 (`invalid_request` otherwise).
schema:
type: integer
default: 500
- $ref: '#/components/parameters/Protocol'
- $ref: '#/components/parameters/AppVersion'
- $ref: '#/components/parameters/AcceptLanguage'
responses:
'200':
description: One page. Every array is present (possibly empty) and sorted by `server_version`.
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/SyncPullResponse'
'400':
$ref: '#/components/responses/BadRequest'
description: '`invalid_request`.'
'401':
$ref: '#/components/responses/Unauthorized'
'409':
$ref: '#/components/responses/Conflict'
description: '`cursor_ahead`.'
'426':
$ref: '#/components/responses/ProtocolUnsupported'
'429':
$ref: '#/components/responses/RateLimited'
'500':
$ref: '#/components/responses/Internal'
'503':
$ref: '#/components/responses/Unavailable'
post:
operationId: syncPush
summary: E-32 Push local expense and settlement changes
description: SPEC §14.4.3 and §14.4.4. 1 to 100 mutations, body at most 1 MiB. One result per mutation, in request order.
parameters:
- $ref: '#/components/parameters/Protocol'
- $ref: '#/components/parameters/AppVersion'
- $ref: '#/components/parameters/AcceptLanguage'
requestBody:
required: true
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/SyncPushRequest'
responses:
'200':
description: Per-mutation results.
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/SyncPushResponse'
'400':
$ref: '#/components/responses/BadRequest'
description: '`invalid_request`.'
'401':
$ref: '#/components/responses/Unauthorized'
'409':
$ref: '#/components/responses/Conflict'
description: '`household_incomplete` (whole request, before any mutation).'
'413':
$ref: '#/components/responses/PayloadTooLarge'
description: '`payload_too_large`.'
'422':
$ref: '#/components/responses/Unprocessable'
description: '`batch_too_large` (more than 100 mutations).'
'426':
$ref: '#/components/responses/ProtocolUnsupported'
'429':
$ref: '#/components/responses/RateLimited'
'500':
$ref: '#/components/responses/Internal'
'503':
$ref: '#/components/responses/Unavailable'
/settlements/proposal:
post:
operationId: proposeSettlement
summary: E-33 Server-side allocation proposal
description: Computed with `kvitt-domain::propose_allocation` (SPEC §9.4) over the server's non-deleted rows.
parameters:
- $ref: '#/components/parameters/Protocol'
- $ref: '#/components/parameters/AppVersion'
- $ref: '#/components/parameters/AcceptLanguage'
requestBody:
required: true
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/ProposalRequest'
responses:
'200':
description: The proposal.
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/ProposalResponse'
'400':
$ref: '#/components/responses/BadRequest'
description: '`invalid_request`.'
'401':
$ref: '#/components/responses/Unauthorized'
'409':
$ref: '#/components/responses/Conflict'
description: '`no_accounts`.'
'422':
$ref: '#/components/responses/Unprocessable'
description: '`validation_failed`.'
'426':
$ref: '#/components/responses/ProtocolUnsupported'
'429':
$ref: '#/components/responses/RateLimited'
'500':
$ref: '#/components/responses/Internal'
'503':
$ref: '#/components/responses/Unavailable'
components:
securitySchemes:
device:
type: http
scheme: bearer
description: |
`Authorization: Bearer kvt_<43 base64url characters>` (SPEC §8.2). Missing or unknown token: 401
`unauthorized`; revoked device: 401 `device_revoked`. Admin-only operations (E-12, E-13, E-14) return 403
`forbidden` for a member (SPEC §8.4).
parameters:
Protocol:
name: X-Kvitt-Protocol
in: header
required: true
description: Sync protocol version. Missing or other than `1` on an authenticated endpoint is 426 `protocol_unsupported`.
schema:
type: string
enum: ['1']
ProtocolOptional:
name: X-Kvitt-Protocol
in: header
required: false
description: Sent by the app on every call; not enforced on unauthenticated endpoints.
schema:
type: string
enum: ['1']
AppVersion:
name: X-Kvitt-App-Version
in: header
required: false
description: The app's `versionCode`, sent by the app on every call.
schema:
type: integer
AcceptLanguage:
name: Accept-Language
in: header
required: false
description: The device locale.
schema:
type: string
UserId:
name: user_id
in: path
required: true
description: Not a UUID is 404 `not_found`.
schema:
$ref: '#/components/schemas/Uuid'
InviteId:
name: invite_id
in: path
required: true
description: Not a UUID is 404 `not_found`.
schema:
$ref: '#/components/schemas/Uuid'
DeviceId:
name: device_id
in: path
required: true
description: Not a UUID is 404 `not_found`.
schema:
$ref: '#/components/schemas/Uuid'
AccountId:
name: account_id
in: path
required: true
description: Not a UUID is 404 `not_found`.
schema:
$ref: '#/components/schemas/Uuid'
CategoryId:
name: category_id
in: path
required: true
description: Not a UUID is 404 `not_found`.
schema:
$ref: '#/components/schemas/Uuid'
TemplateId:
name: template_id
in: path
required: true
description: Not a UUID is 404 `not_found`.
schema:
$ref: '#/components/schemas/Uuid'
headers:
ServerVersion:
description: The server crate version.
required: true
schema:
type: string
RetryAfter:
description: Seconds until the client may retry.
required: true
schema:
type: integer
responses:
BadRequest:
description: '`invalid_request` or `invite_invalid`.'
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/Error'
Unauthorized:
description: '`unauthorized` or `device_revoked`.'
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/Error'
Forbidden:
description: '`forbidden`.'
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/Error'
NotFound:
description: '`not_found`.'
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/Error'
Conflict:
description: A 409 error code of SPEC §14.3.2.
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/Error'
PayloadTooLarge:
description: '`payload_too_large`.'
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/Error'
UnsupportedMediaType:
description: '`unsupported_media_type`.'
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/Error'
Unprocessable:
description: A 422 error code of SPEC §14.3.2.
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/Error'
ProtocolUnsupported:
description: '`protocol_unsupported`.'
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/Error'
RateLimited:
description: '`rate_limited` (SPEC §8.6).'
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
Retry-After:
$ref: '#/components/headers/RetryAfter'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/Error'
Internal:
description: '`internal` (logged with a request id, returned in `message`).'
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/Error'
NotImplemented:
description: '`not_implemented` (route registered but not yet implemented; only during milestones M2a to M4).'
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/Error'
Unavailable:
description: '`unavailable` (database busy beyond `busy_timeout`, or server shutting down).'
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/Error'
schemas:
# ---- primitives (SPEC conventions, §14.3.3) ----
Uuid:
type: string
format: uuid
description: Lowercase, hyphenated, 36 characters (UUIDv7).
pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
Timestamp:
type: string
format: date-time
description: RFC 3339 UTC, millisecond precision, literal Z.
pattern: '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$'
Date:
type: string
format: date
pattern: '^\d{4}-\d{2}-\d{2}$'
Period:
type: string
description: A month, YYYY-MM.
pattern: '^\d{4}-(0[1-9]|1[0-2])$'
Ore:
type: integer
format: int64
description: Money in öre (1/100 SEK).
ServerVersionNumber:
type: integer
format: int64
description: From the one global, strictly increasing sequence (SPEC §5.2).
# ---- enums ----
Role:
type: string
enum: [admin, member]
InviteKind:
type: string
enum: [admin_setup, partner, device]
DefaultCategoryKey:
type: string
enum: [groceries, restaurants, home, utilities, transport, travel, entertainment, health, shopping, other]
SplitMode:
type: string
enum: [default, ratio, exact, full]
EntityType:
type: string
enum: [expense, settlement]
AuditAction:
type: string
enum: [create, update, delete]
AuditSource:
type: string
enum: [app, recurring, import]
ErrorCode:
type: string
description: Exhaustive (SPEC §14.3.2). The app maps `code` (and `field`) to localised strings.
enum:
- invalid_request
- invite_invalid
- unauthorized
- device_revoked
- forbidden
- not_found
- household_full
- household_incomplete
- name_taken
- account_not_empty
- category_in_use
- category_is_default
- cannot_revoke_current_device
- no_accounts
- cursor_ahead
- payload_too_large
- unsupported_media_type
- validation_failed
- image_invalid
- batch_too_large
- protocol_unsupported
- rate_limited
- internal
- not_implemented
- unavailable
RejectionReason:
type: string
description: Exhaustive (SPEC §14.4.6).
enum:
- stale
- deleted
- validation_failed
- reference_not_found
- clock_skew
# ---- wire objects (SPEC §14.3.3) ----
User:
type: object
required: [id, role, display_name, picture_version, created_at, updated_at, server_version]
properties:
id:
$ref: '#/components/schemas/Uuid'
role:
$ref: '#/components/schemas/Role'
display_name:
type: string
picture_version:
type: [integer, 'null']
created_at:
$ref: '#/components/schemas/Timestamp'
updated_at:
$ref: '#/components/schemas/Timestamp'
server_version:
$ref: '#/components/schemas/ServerVersionNumber'
Device:
type: object
description: Only in /me, /devices and /invites/accept.
required: [id, user_id, name, app_version_code, created_at, last_seen_at, revoked_at, is_current]
properties:
id:
$ref: '#/components/schemas/Uuid'
user_id:
$ref: '#/components/schemas/Uuid'
name:
type: string
app_version_code:
type: [integer, 'null']
created_at:
$ref: '#/components/schemas/Timestamp'
last_seen_at:
$ref: '#/components/schemas/Timestamp'
revoked_at:
oneOf:
- $ref: '#/components/schemas/Timestamp'
- type: 'null'
is_current:
type: boolean
Invite:
type: object
description: '`code` and `link` only in the POST /invites response; null in GET /invites.'
required: [id, kind, target_user_id, code, link, created_at, expires_at]
properties:
id:
$ref: '#/components/schemas/Uuid'
kind:
$ref: '#/components/schemas/InviteKind'
target_user_id:
oneOf:
- $ref: '#/components/schemas/Uuid'
- type: 'null'
code:
type: [string, 'null']
link:
type: [string, 'null']
created_at:
$ref: '#/components/schemas/Timestamp'
expires_at:
$ref: '#/components/schemas/Timestamp'
Share:
type: object
description: A share of an account default.
required: [user_id, share_bp]
properties:
user_id:
$ref: '#/components/schemas/Uuid'
share_bp:
type: integer
Account:
type: object
required: [id, name, default_shares, created_by_user_id, created_at, updated_at, archived_at, deleted_at, server_version]
properties:
id:
$ref: '#/components/schemas/Uuid'
name:
type: string
default_shares:
type: array
description: Sorted by `user_id`.
minItems: 2
maxItems: 2
items:
$ref: '#/components/schemas/Share'
created_by_user_id:
$ref: '#/components/schemas/Uuid'
created_at:
$ref: '#/components/schemas/Timestamp'
updated_at:
$ref: '#/components/schemas/Timestamp'
archived_at:
oneOf:
- $ref: '#/components/schemas/Timestamp'
- type: 'null'
deleted_at:
oneOf:
- $ref: '#/components/schemas/Timestamp'
- type: 'null'
server_version:
$ref: '#/components/schemas/ServerVersionNumber'
Category:
type: object
required: [id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, server_version]
properties:
id:
$ref: '#/components/schemas/Uuid'
default_key:
oneOf:
- $ref: '#/components/schemas/DefaultCategoryKey'
- type: 'null'
name:
type: [string, 'null']
icon:
type: string
description: An icon token of SPEC §9.7.
color:
type: string
description: A colour token of SPEC §9.7.
sort_order:
type: integer
created_at:
$ref: '#/components/schemas/Timestamp'
updated_at:
$ref: '#/components/schemas/Timestamp'
archived_at:
oneOf:
- $ref: '#/components/schemas/Timestamp'
- type: 'null'
deleted_at:
oneOf:
- $ref: '#/components/schemas/Timestamp'
- type: 'null'
server_version:
$ref: '#/components/schemas/ServerVersionNumber'
Split:
type: object
description: A split of an expense; `share_bp` null for mode exact.
required: [user_id, share_ore, share_bp]
properties:
user_id:
$ref: '#/components/schemas/Uuid'
share_ore:
$ref: '#/components/schemas/Ore'
share_bp:
type: [integer, 'null']
TemplateSplit:
type: object
description: Exactly one of `share_bp` / `share_ore` non-null.
required: [user_id, share_bp, share_ore]
properties:
user_id:
$ref: '#/components/schemas/Uuid'
share_bp:
type: [integer, 'null']
share_ore:
type: [integer, 'null']
format: int64
Expense:
type: object
required: [id, account_id, category_id, amount_ore, description, notes, expense_date, payer_user_id, split_mode, splits,
recurring_template_id, recurring_period, created_by_user_id, updated_by_user_id, created_at, updated_at,
deleted_at, server_version]
properties:
id:
$ref: '#/components/schemas/Uuid'
account_id:
$ref: '#/components/schemas/Uuid'
category_id:
$ref: '#/components/schemas/Uuid'
amount_ore:
$ref: '#/components/schemas/Ore'
description:
type: string
notes:
type: string
expense_date:
$ref: '#/components/schemas/Date'
payer_user_id:
$ref: '#/components/schemas/Uuid'
split_mode:
$ref: '#/components/schemas/SplitMode'
splits:
type: array
description: Sorted by `user_id`.
minItems: 2
maxItems: 2
items:
$ref: '#/components/schemas/Split'
recurring_template_id:
oneOf:
- $ref: '#/components/schemas/Uuid'
- type: 'null'
recurring_period:
oneOf:
- $ref: '#/components/schemas/Period'
- type: 'null'
created_by_user_id:
$ref: '#/components/schemas/Uuid'
updated_by_user_id:
$ref: '#/components/schemas/Uuid'
created_at:
$ref: '#/components/schemas/Timestamp'
updated_at:
$ref: '#/components/schemas/Timestamp'
deleted_at:
oneOf:
- $ref: '#/components/schemas/Timestamp'
- type: 'null'
server_version:
$ref: '#/components/schemas/ServerVersionNumber'
Allocation:
type: object
required: [account_id, amount_ore]
properties:
account_id:
$ref: '#/components/schemas/Uuid'
amount_ore:
$ref: '#/components/schemas/Ore'
Settlement:
type: object
required: [id, from_user_id, to_user_id, amount_ore, settlement_date, note, allocations, created_by_user_id,
updated_by_user_id, created_at, updated_at, deleted_at, server_version]
properties:
id:
$ref: '#/components/schemas/Uuid'
from_user_id:
$ref: '#/components/schemas/Uuid'
to_user_id:
$ref: '#/components/schemas/Uuid'
amount_ore:
$ref: '#/components/schemas/Ore'
settlement_date:
$ref: '#/components/schemas/Date'
note:
type: string
allocations:
type: array
description: Sorted by `account_id`.
items:
$ref: '#/components/schemas/Allocation'
created_by_user_id:
$ref: '#/components/schemas/Uuid'
updated_by_user_id:
$ref: '#/components/schemas/Uuid'
created_at:
$ref: '#/components/schemas/Timestamp'
updated_at:
$ref: '#/components/schemas/Timestamp'
deleted_at:
oneOf:
- $ref: '#/components/schemas/Timestamp'
- type: 'null'
server_version:
$ref: '#/components/schemas/ServerVersionNumber'
RecurringTemplate:
type: object
required: [id, account_id, category_id, amount_ore, description, notes, payer_user_id, split_mode, splits, day_of_month,
start_period, end_period, paused, last_generated_period, next_occurrence_date, created_by_user_id,
updated_by_user_id, created_at, updated_at, deleted_at, server_version]
properties:
id:
$ref: '#/components/schemas/Uuid'
account_id:
$ref: '#/components/schemas/Uuid'
category_id:
$ref: '#/components/schemas/Uuid'
amount_ore:
$ref: '#/components/schemas/Ore'
description:
type: string
notes:
type: string
payer_user_id:
$ref: '#/components/schemas/Uuid'
split_mode:
$ref: '#/components/schemas/SplitMode'
splits:
type: array
description: Sorted by `user_id`; empty for mode default.
items:
$ref: '#/components/schemas/TemplateSplit'
day_of_month:
type: integer
start_period:
$ref: '#/components/schemas/Period'
end_period:
oneOf:
- $ref: '#/components/schemas/Period'
- type: 'null'
paused:
type: boolean
last_generated_period:
oneOf:
- $ref: '#/components/schemas/Period'
- type: 'null'
next_occurrence_date:
description: Computed by the server (SPEC §9.5); null when paused, deleted or past `end_period`.
oneOf:
- $ref: '#/components/schemas/Date'
- type: 'null'
created_by_user_id:
$ref: '#/components/schemas/Uuid'
updated_by_user_id:
$ref: '#/components/schemas/Uuid'
created_at:
$ref: '#/components/schemas/Timestamp'
updated_at:
$ref: '#/components/schemas/Timestamp'
deleted_at:
oneOf:
- $ref: '#/components/schemas/Timestamp'
- type: 'null'
server_version:
$ref: '#/components/schemas/ServerVersionNumber'
AuditChange:
type: object
description: One field of the audit diff (SPEC §14.4.7). `old` and `new` are JSON values of the field's type, or null.
required: [field, old, new]
properties:
field:
type: string
old: {}
new: {}
AuditEntry:
type: object
required: [id, entity_type, entity_id, action, source, actor_user_id, client_at, server_at, changes, server_version]
properties:
id:
$ref: '#/components/schemas/Uuid'
entity_type:
$ref: '#/components/schemas/EntityType'
entity_id:
$ref: '#/components/schemas/Uuid'
action:
$ref: '#/components/schemas/AuditAction'
source:
$ref: '#/components/schemas/AuditSource'
actor_user_id:
$ref: '#/components/schemas/Uuid'
client_at:
$ref: '#/components/schemas/Timestamp'
server_at:
$ref: '#/components/schemas/Timestamp'
changes:
type: array
items:
$ref: '#/components/schemas/AuditChange'
server_version:
$ref: '#/components/schemas/ServerVersionNumber'
Error:
type: object
description: '`message` is English developer text, never shown to users. `field` is the JSON key concerned or null.'
required: [code, message, field]
properties:
code:
$ref: '#/components/schemas/ErrorCode'
message:
type: string
field:
type: [string, 'null']
# ---- request and response bodies (SPEC §14.3.4) ----
Health:
type: object
required: [status, server_version, schema_version, db_ok]
properties:
status:
type: string
enum: [ok, degraded]
server_version:
type: string
schema_version:
type: integer
db_ok:
type: boolean
SetupStatus:
type: object
required: [initialized]
properties:
initialized:
type: boolean
AcceptInviteRequest:
type: object
required: [code, device_name]
properties:
code:
type: string
description: Normalised per SPEC §8.3 by the app before sending.
device_name:
type: string
display_name:
type: string
description: Required for `admin_setup` and `partner` invites (`invalid_request` if missing), ignored for `device`.
AcceptInviteResponse:
type: object
required: [token, user, device, household]
properties:
token:
type: string
description: '`kvt_` + base64url without padding of 32 random bytes (47 characters, SPEC §8.2).'
user:
$ref: '#/components/schemas/User'
device:
$ref: '#/components/schemas/Device'
household:
type: array
items:
$ref: '#/components/schemas/User'
ApkLatest:
type: object
description: '`latest.json` (SPEC §10.6).'
required: [version_code, version_name, file_name, size_bytes, sha256, released_at]
properties:
version_code:
type: integer
version_name:
type: string
file_name:
type: string
size_bytes:
type: integer
format: int64
sha256:
type: string
released_at:
$ref: '#/components/schemas/Timestamp'
MeResponse:
type: object
required: [user, device, household, public_url]
properties:
user:
$ref: '#/components/schemas/User'
device:
$ref: '#/components/schemas/Device'
household:
type: array
items:
$ref: '#/components/schemas/User'
public_url:
type: string
PatchMeRequest:
type: object
minProperties: 1
properties:
display_name:
type: string
CreateInviteRequest:
type: object
required: [kind]
properties:
kind:
type: string
enum: [partner, device]
target_user_id:
# TODO(spec): §14.3.4 E-12 writes `uuid?` in a request; read here as nullable and optional (absent = null).
description: Required for `device`, must be null for `partner`.
oneOf:
- $ref: '#/components/schemas/Uuid'
- type: 'null'
InviteList:
type: object
required: [invites]
properties:
invites:
type: array
items:
$ref: '#/components/schemas/Invite'
DeviceList:
type: object
required: [devices]
properties:
devices:
type: array
items:
$ref: '#/components/schemas/Device'
PushTokenRequest:
type: object
# TODO(spec): §14.3.4 E-17 writes `string?`; read here as required and nullable (null clears), so an empty body is invalid_request.
required: [fcm_token]
properties:
fcm_token:
type: [string, 'null']
CreateAccountRequest:
type: object
required: [name, default_shares]
properties:
name:
type: string
default_shares:
type: array
minItems: 2
maxItems: 2
items:
$ref: '#/components/schemas/Share'
PatchAccountRequest:
type: object
minProperties: 1
properties:
name:
type: string
default_shares:
type: array
minItems: 2
maxItems: 2
items:
$ref: '#/components/schemas/Share'
CreateCategoryRequest:
type: object
required: [name, icon, color]
properties:
name:
type: string
icon:
type: string
color:
type: string
PatchCategoryRequest:
type: object
minProperties: 1
properties:
name:
type: [string, 'null']
description: null resets a default category's name (`category_is_default` on a non-default category).
icon:
type: string
color:
type: string
sort_order:
type: integer
CreateRecurringTemplateRequest:
type: object
description: |
`splits` per SPEC §9.1: none for `default`; two bp entries for `ratio` and `full`; two öre entries summing to the
amount for `exact`.
required: [account_id, category_id, amount_ore, description, notes, payer_user_id, split_mode, splits, day_of_month,
start_period, end_period, paused]
properties:
account_id:
$ref: '#/components/schemas/Uuid'
category_id:
$ref: '#/components/schemas/Uuid'
amount_ore:
$ref: '#/components/schemas/Ore'
description:
type: string
notes:
type: string
payer_user_id:
$ref: '#/components/schemas/Uuid'
split_mode:
$ref: '#/components/schemas/SplitMode'
splits:
type: array
items:
$ref: '#/components/schemas/TemplateSplit'
day_of_month:
type: integer
start_period:
$ref: '#/components/schemas/Period'
end_period:
oneOf:
- $ref: '#/components/schemas/Period'
- type: 'null'
paused:
type: boolean
PatchRecurringTemplateRequest:
type: object
description: Any subset of the E-28 fields; `split_mode` and `splits` must be sent together.
minProperties: 1
dependentRequired:
split_mode: [splits]
splits: [split_mode]
properties:
account_id:
$ref: '#/components/schemas/Uuid'
category_id:
$ref: '#/components/schemas/Uuid'
amount_ore:
$ref: '#/components/schemas/Ore'
description:
type: string
notes:
type: string
payer_user_id:
$ref: '#/components/schemas/Uuid'
split_mode:
$ref: '#/components/schemas/SplitMode'
splits:
type: array
items:
$ref: '#/components/schemas/TemplateSplit'
day_of_month:
type: integer
start_period:
$ref: '#/components/schemas/Period'
end_period:
oneOf:
- $ref: '#/components/schemas/Period'
- type: 'null'
paused:
type: boolean
ProposalRequest:
type: object
required: [payer_user_id, amount_ore]
properties:
payer_user_id:
$ref: '#/components/schemas/Uuid'
amount_ore:
$ref: '#/components/schemas/Ore'
exclude_settlement_id:
# TODO(spec): §14.3.4 E-33 writes `uuid?` in a request; read here as nullable and optional (absent = null).
description: The settlement being edited, excluded from the balances.
oneOf:
- $ref: '#/components/schemas/Uuid'
- type: 'null'
ProposalResponse:
type: object
required: [allocations, excess_ore, over_settled_account_ids]
properties:
allocations:
type: array
description: In the order produced by SPEC §9.4.
items:
$ref: '#/components/schemas/Allocation'
excess_ore:
$ref: '#/components/schemas/Ore'
over_settled_account_ids:
type: array
items:
$ref: '#/components/schemas/Uuid'
# ---- sync (SPEC §14.4) ----
SyncPullResponse:
type: object
required: [epoch, since, next_since, has_more, server_time, users, categories, accounts, recurring_templates, expenses,
settlements, audit_entries]
properties:
epoch:
type: string
description: '`meta.sync_epoch`, 32 lowercase hex characters.'
pattern: '^[0-9a-f]{32}$'
since:
type: integer
format: int64
next_since:
type: integer
format: int64
description: The highest `server_version` in the page, or `since` when the page is empty.
has_more:
type: boolean
server_time:
$ref: '#/components/schemas/Timestamp'
users:
type: array
items:
$ref: '#/components/schemas/User'
categories:
type: array
items:
$ref: '#/components/schemas/Category'
accounts:
type: array
items:
$ref: '#/components/schemas/Account'
recurring_templates:
type: array
items:
$ref: '#/components/schemas/RecurringTemplate'
expenses:
type: array
items:
$ref: '#/components/schemas/Expense'
settlements:
type: array
items:
$ref: '#/components/schemas/Settlement'
audit_entries:
type: array
items:
$ref: '#/components/schemas/AuditEntry'
PushedExpense:
type: object
description: |
Exactly the client-writable fields (SPEC §14.4.3). Server-owned fields are ignored if sent. A delete is the full
entity with `deleted_at` set.
required: [id, account_id, category_id, amount_ore, description, notes, expense_date, payer_user_id, split_mode, splits,
created_at, updated_at, deleted_at]
properties:
id:
$ref: '#/components/schemas/Uuid'
account_id:
$ref: '#/components/schemas/Uuid'
category_id:
$ref: '#/components/schemas/Uuid'
amount_ore:
$ref: '#/components/schemas/Ore'
description:
type: string
notes:
type: string
expense_date:
$ref: '#/components/schemas/Date'
payer_user_id:
$ref: '#/components/schemas/Uuid'
split_mode:
$ref: '#/components/schemas/SplitMode'
splits:
type: array
items:
$ref: '#/components/schemas/Split'
created_at:
$ref: '#/components/schemas/Timestamp'
updated_at:
$ref: '#/components/schemas/Timestamp'
deleted_at:
oneOf:
- $ref: '#/components/schemas/Timestamp'
- type: 'null'
PushedSettlement:
type: object
description: Exactly the client-writable fields (SPEC §14.4.3).
required: [id, from_user_id, to_user_id, amount_ore, settlement_date, note, allocations, created_at, updated_at, deleted_at]
properties:
id:
$ref: '#/components/schemas/Uuid'
from_user_id:
$ref: '#/components/schemas/Uuid'
to_user_id:
$ref: '#/components/schemas/Uuid'
amount_ore:
$ref: '#/components/schemas/Ore'
settlement_date:
$ref: '#/components/schemas/Date'
note:
type: string
allocations:
type: array
items:
$ref: '#/components/schemas/Allocation'
created_at:
$ref: '#/components/schemas/Timestamp'
updated_at:
$ref: '#/components/schemas/Timestamp'
deleted_at:
oneOf:
- $ref: '#/components/schemas/Timestamp'
- type: 'null'
ExpenseMutation:
type: object
required: [mutation_id, entity_type, entity]
properties:
mutation_id:
$ref: '#/components/schemas/Uuid'
entity_type:
const: expense
entity:
$ref: '#/components/schemas/PushedExpense'
SettlementMutation:
type: object
required: [mutation_id, entity_type, entity]
properties:
mutation_id:
$ref: '#/components/schemas/Uuid'
entity_type:
const: settlement
entity:
$ref: '#/components/schemas/PushedSettlement'
Mutation:
oneOf:
- $ref: '#/components/schemas/ExpenseMutation'
- $ref: '#/components/schemas/SettlementMutation'
discriminator:
propertyName: entity_type
mapping:
expense: '#/components/schemas/ExpenseMutation'
settlement: '#/components/schemas/SettlementMutation'
SyncPushRequest:
type: object
required: [mutations]
properties:
mutations:
type: array
description: 1 to 100 mutations (more is 422 `batch_too_large`).
items:
$ref: '#/components/schemas/Mutation'
AcceptedResult:
type: object
# TODO(spec): §14.3.1 says optional response fields are present with null, but the §14.4.3 example gives accepted and
# rejected results disjoint key sets; the example's key sets are used here.
required: [mutation_id, entity_type, entity_id, status, server_version, entity]
properties:
mutation_id:
$ref: '#/components/schemas/Uuid'
entity_type:
$ref: '#/components/schemas/EntityType'
entity_id:
$ref: '#/components/schemas/Uuid'
status:
const: accepted
server_version:
$ref: '#/components/schemas/ServerVersionNumber'
entity:
description: The full wire object after processing, of the type named by `entity_type`.
oneOf:
- $ref: '#/components/schemas/Expense'
- $ref: '#/components/schemas/Settlement'
RejectedResult:
type: object
required: [mutation_id, entity_type, entity_id, status, reason, message, current]
properties:
mutation_id:
$ref: '#/components/schemas/Uuid'
entity_type:
$ref: '#/components/schemas/EntityType'
entity_id:
$ref: '#/components/schemas/Uuid'
status:
const: rejected
reason:
$ref: '#/components/schemas/RejectionReason'
message:
type: string
description: For validation failures, the domain error token of SPEC §14.5.3.
current:
description: The stored row as a full wire object of the type named by `entity_type`, or null when the server has no row with that id.
oneOf:
- $ref: '#/components/schemas/Expense'
- $ref: '#/components/schemas/Settlement'
- type: 'null'
MutationResult:
oneOf:
- $ref: '#/components/schemas/AcceptedResult'
- $ref: '#/components/schemas/RejectedResult'
discriminator:
propertyName: status
mapping:
accepted: '#/components/schemas/AcceptedResult'
rejected: '#/components/schemas/RejectedResult'
SyncPushResponse:
type: object
required: [server_time, results]
properties:
server_time:
$ref: '#/components/schemas/Timestamp'
results:
type: array
description: One result per mutation, in request order.
items:
$ref: '#/components/schemas/MutationResult'