# 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: '`""`' 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` (`/i/`). 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'