EinVault is a private, self-hosted companion health and care tracker built for homelabs. Track health records, daily activities, and care schedules for your animal companions. All data stays on your hardware. No cloud, no telemetry, no external accounts.
Want a look before you self-host? There's a read-only demo at demo.einvault.app. Pick a role and explore; nothing you do there sticks.
- Features
- Screenshots
- Production (Docker)
- Docker (local build)
- Local development
- User management
- OIDC / SSO (optional)
- Two-factor authentication (2FA)
- Bearer-token API (optional)
- Adding a new locale
- Stack
- License
- Companion profiles: breed, bio, vet info, emergency contacts, and avatar photo
- Daily journal: per-companion entries with mood tracking, photo and video uploads, and a configurable daily media limit (default 5)
- Health tracking: vet visits, vaccinations, medications, procedures, and weight history
- Activity logging: walks, meals, bathroom trips, treats, play sessions, and grooming
- Quick logs: personal one-tap buttons for repetitive events, reorderable and shareable with other users
- Bearer-token API: log activities and journal entries, record health and weight, manage reminders, and read companion, shift, and roster data headlessly from smart buttons, scripts, and devices
- Reminders: recurring and one-time reminders for medications, vaccinations, grooming, and more
- Search: full-text search across journals, health, activity, reminders, documents, and media, with
@companion,#type, and date-range filters (members and admins) - Calendar feed: subscribe to health events, reminders (with recurrence), and shifts from any calendar app or Home Assistant via a personal, revocable ICS URL
- Role-based access: admins manage the app, members track health, caretakers log activities
- Self-contained: single Docker container, SQLite database, no external dependencies
- Localization: English, German, Spanish, French, Italian, and Portuguese (non-English translations are AI-generated and may contain errors)
- Responsive UI: works on desktop and mobile, with dark and light mode support
| Member dashboard (mobile) | Caretaker dashboard (mobile) |
|---|---|
![]() |
![]() |
Requires Docker Engine 24+, Docker Compose v2, and a reverse proxy for TLS (Caddy, Nginx, Traefik, or similar).
Download docker-compose.prod.yml and set your domain before starting:
ORIGIN is your public domain:
ORIGIN: https://einvault.yourdomain.comThen:
mkdir -p ./data
docker compose -f docker-compose.prod.yml up -dOpen your domain and follow the /setup prompt to create your admin account.
Everything else in the compose file can be edited directly:
| Default | Description | |
|---|---|---|
TZ |
UTC |
Container timezone. Set to your local timezone (e.g. America/New_York, Europe/London) so dates and times display correctly. |
UPLOAD_MAX_MB |
10 |
Maximum size in MB for image (photo and avatar) uploads. BODY_SIZE_LIMIT is derived from the larger of this and VIDEO_MAX_MB at container start. |
VIDEO_MAX_MB |
100 |
Maximum size in MB for journal video uploads. Videos are stored as-is unless transcoding is enabled (see below). |
MAX_DAILY_MEDIA |
5 |
Maximum number of journal photos and videos (combined) per companion per day. (Renamed from MAX_DAILY_PHOTOS, still honored with a deprecation warning.) |
MAX_DOCUMENTS_PER_COMPANION |
200 |
Maximum number of documents (uploads and Paperless references combined) stored per companion. |
REMINDER_UNDO_SECONDS |
7 |
Default undo window (seconds) when dismissing a Reminder. 0 disables the undo window. Each user can override in their settings. |
CALENDAR_FEED_HISTORY_DAYS |
90 |
Days of past events the calendar feed includes. 0 includes all history (larger feeds). |
CALENDAR_FEED_ENABLED |
true |
Set false to disable the calendar feed endpoint entirely. |
API_TOKENS_ENABLED |
true |
Set false to disable the Bearer-token API (token creation and the /api/logs, /api/journal, /api/quick-logs endpoints) entirely. |
DEMO_MODE |
false |
Enable read-only public demo mode. See Running a demo instance. |
user |
1000:1000 |
UID:GID the container runs as. Change if your ./data directory has different ownership. |
./data volume |
./data |
Where the database and uploads are stored on the host. |
DATABASE_URL |
/data/einvault.db |
Database path inside the container. Unlikely to need changing. |
TWOFA_ENC_KEY |
- | 32-byte base64 key (openssl rand -base64 32) that encrypts stored TOTP secrets. Required to enable two-factor authentication. |
The calendar feed URL contains a secret token that authenticates the subscriber. Avoid logging full /api/calendar/ request URLs at the reverse proxy, as the token would appear in plain text in your access logs.
By default, uploaded videos are stored exactly as received. A browser can only play codecs it supports, so a clip in a format like H.265/HEVC (common from Apple devices) may fail to play and fall back to a download link.
Set VIDEO_TRANSCODE=true to convert each uploaded video to a universal web profile (H.264 + AAC in an MP4 with the moov atom at the front) and generate a poster thumbnail. Conversion runs in the background after upload: the video shows a "converting" state and switches to the playable version when it finishes. The feature is off by default because it is CPU-intensive and depends on ffmpeg.
ffmpeg and ffprobe ship in the official Docker image, including the HEVC decoder needed to read Apple-recorded source and libx264 for H.264 output. If you run outside the image and the binaries are not found, the feature disables itself and videos are stored as-is.
| Default | Description | |
|---|---|---|
VIDEO_TRANSCODE |
false |
Enable background transcoding of uploaded videos to a web-playable MP4 plus a poster. |
VIDEO_KEEP_ORIGINAL |
true |
Keep the original source file alongside the transcoded copy. The original is never served; it is retained for re-encoding or backup. false discards it. |
VIDEO_TRANSCODE_MAX_MB |
VIDEO_MAX_MB |
Skip transcoding (store as-is) for inputs larger than this. |
VIDEO_TRANSCODE_MAX_SECONDS |
600 |
Skip transcoding for inputs longer than this. |
VIDEO_TRANSCODE_MAX_WIDTH |
4096 |
Skip transcoding for inputs wider than this. |
VIDEO_TRANSCODE_MAX_HEIGHT |
4096 |
Skip transcoding for inputs taller than this. |
VIDEO_FFMPEG_PATH |
/usr/bin/ffmpeg |
Absolute path to the ffmpeg binary. Override if it lives elsewhere (e.g. /opt/homebrew/bin/ffmpeg on macOS). |
VIDEO_FFPROBE_PATH |
/usr/bin/ffprobe |
Absolute path to the ffprobe binary. |
VIDEO_TMP_DIR |
<data>/transcode-tmp |
Scratch directory for in-progress transcodes. Must be on disk with room for the source plus output. Do not point it at the in-memory /tmp tmpfs. |
Transcoding decodes attacker-supplied media with ffmpeg. The shipped compose files already run the container with a read-only root filesystem, all capabilities dropped, and no-new-privileges, which contains a decoder exploit. For extra isolation you can run the container without network access.
Transcoding is the only CPU-heavy thing EinVault does. It runs one job at a time with ffmpeg capped at two threads, so it never grabs the whole host, but the default cpus: 0.5 / memory: 256M limits in docker-compose.prod.yml are sized for the app alone and are too low once it is enabled. With transcoding on, raise them to roughly cpus: 1.5 (use 1.0 on a small host, 2.0 for the fastest conversions) and memory: 512M (1G if you allow large or 4K clips). The memory bump matters most: the worker buffers the whole clip while converting, and too low a limit will OOM-kill the job rather than slow it down.
By default, avatars and journal photos are written to ./data/uploads. You can instead route new uploads to an S3-compatible bucket (AWS S3, Garage, MinIO, Backblaze B2, Cloudflare R2). Existing photos remain on disk; only new writes go to S3.
| Default | Description | |
|---|---|---|
STORAGE_BACKEND |
local |
local writes to ./data/uploads. s3 writes new uploads to the configured bucket. Reads always honor the per-row provider, so switching does not invalidate old rows. |
S3_ENDPOINT |
- | Full endpoint URL, e.g. https://s3.garage.example.com or https://s3.us-east-1.amazonaws.com. |
S3_BUCKET |
- | Bucket name. Must already exist and should be private. |
S3_REGION |
auto |
Region. For non-AWS providers, auto usually works. |
S3_ACCESS_KEY_ID |
- | Access key. Scope it to this bucket only. |
S3_SECRET_ACCESS_KEY |
- | Secret key. |
S3_FORCE_PATH_STYLE |
false |
Set to true for Garage, MinIO, and older S3 deployments. Leave false for AWS and R2. |
S3_PRESIGN_TTL_SECONDS |
300 |
Lifetime of presigned download URLs. Shorter is stricter, longer improves browser cache reuse on reload. |
Downloads use short-lived presigned URLs (the app issues a 302 redirect). Access control is checked before the URL is issued; once issued, the URL stays valid for the full TTL even if permissions later change. Keep the TTL short.
When IMMICH_URL and IMMICH_API_KEY are set, members and admins get a "Pick from Immich" option on the journal photo and companion avatar flows. The chosen asset stays in Immich; EinVault stores only a reference and proxies reads through the server using the API key. EinVault never uploads to Immich and never deletes assets from it. Caretakers cannot use the picker. Immich v2 and v3 servers are supported. Only assets with timeline visibility appear in the picker; archived and locked assets are excluded.
| Default | Description | |
|---|---|---|
IMMICH_URL |
- | Base URL of your Immich server, e.g. http://immich.local:2283. No trailing slash. Required if IMMICH_API_KEY is set. |
IMMICH_API_KEY |
- | API key. Required permissions: asset.read, asset.view. Generate in Immich → Account Settings → API Keys. Required if IMMICH_URL is set. |
IMMICH_ALBUM_ID |
- | If set, the picker only shows assets in this album. If unset, the picker shows the user's most recent assets across the whole library. |
Each companion has a Documents tab for receipts, invoices, vaccination records, and other paperwork. Members and admins can upload PDFs and images (JPEG, PNG, WebP, HEIC) up to UPLOAD_MAX_MB, sort them into categories, set a document date, and link a document to a health event. Uploaded images are re-encoded server-side to strip metadata; PDFs are stored as received. Documents are private to members and admins; caretakers have no access. PDFs preview in the browser without leaving the page.
When PAPERLESS_URL and PAPERLESS_API_TOKEN are set, members and admins get an "Add from Paperless" option on the Documents tab that searches a Paperless-ngx instance and attaches a document by reference. The document stays in Paperless; EinVault stores only a reference and proxies reads through the server using the token. EinVault never uploads to or deletes from Paperless. Caretakers cannot use the picker. EinVault serves the archived (OCR'd PDF) version of each referenced document.
The token can read every document its Paperless user can see, and any member can then search and attach them. Set PAPERLESS_TAG_ID to limit the picker and import to one tag, and use a dedicated Paperless user whose object permissions are restricted to that tag.
| Default | Description | |
|---|---|---|
PAPERLESS_URL |
- | Base URL of your Paperless-ngx instance, e.g. http://paperless.local:8000. No trailing slash. Required if PAPERLESS_API_TOKEN is set. |
PAPERLESS_API_TOKEN |
- | API token. Generate in Paperless under Settings → "My Profile". Use a dedicated user limited to the documents you want visible. Required if PAPERLESS_URL is set. |
PAPERLESS_TAG_ID |
- | If set, only documents carrying this Paperless tag ID can be searched and imported. Strongly recommended; without it every member can search the whole library. |
When SMTP_HOST and SMTP_FROM are both set, EinVault enables outbound email and adds a self-service "Forgot password?" link on the login page. When SMTP is configured, users can also opt in (Settings -> Notifications) to an email when a reminder comes due, and to an email 24 hours before a caretaker shift starts or ends. Caretakers only receive reminder emails for companions assigned to them, and shift emails for their own shifts. Both variables must be set together; setting only one disables email and logs a warning at startup.
ORIGIN must be set correctly: password reset links are built from it. Behind a reverse proxy, make sure ORIGIN matches the public URL users see.
| Default | Description | |
|---|---|---|
SMTP_HOST |
- | SMTP server hostname. Required (with SMTP_FROM) to enable email. |
SMTP_PORT |
587 |
SMTP port. Use 465 with SMTP_SECURE=true for implicit TLS, or 587 (default) for STARTTLS. |
SMTP_SECURE |
false |
true = implicit TLS (port 465). false = STARTTLS upgrade on connect. |
SMTP_USER |
- | SMTP username. Leave unset for unauthenticated relays. |
SMTP_PASS |
- | SMTP password. Leave unset for unauthenticated relays. |
SMTP_FROM |
- | RFC 5322 From address shown to recipients, e.g. EinVault <einvault@example.com>. Required (with SMTP_HOST) to enable email. |
When NTFY_URL is set, EinVault can publish push notifications via ntfy. This configures the server only (base URL and optional access token). Each user sets their own topic name under Settings -> Notifications; a non-empty topic is that user's opt-in for pushes scoped to what they can see in the app (reminders due, shift alerts). The notification scheduler runs when either SMTP or ntfy is configured. The forgot-password flow remains email-only.
On public servers like ntfy.sh, the topic name is the only access control. Users should pick long, random topic names that are hard to guess.
| Default | Description | |
|---|---|---|
NTFY_URL |
- | ntfy server base URL, e.g. https://ntfy.sh or a self-hosted instance. No topic in the URL. |
NTFY_TOKEN |
- | Bearer token for self-hosted ntfy servers with auth enabled. Used for every publish regardless of topic. |
NOTIFY_SCAN_INTERVAL_MS |
60000 |
How often the notification scheduler scans for due reminders and shift alerts, in milliseconds. Values below 250 fall back to the default. Mainly useful for automated tests. |
Data lives in ./data next to the compose file. Stop the container first so SQLite isn't mid-write, then copy the directory:
docker compose -f docker-compose.prod.yml stop einvault
cp -r ./data ./data.bak
docker compose -f docker-compose.prod.yml start einvaultSet DEMO_MODE=true and start with a fresh data volume. On boot the app self-provisions a sample dataset and re-anchors all dates to "now" daily, so the demo always looks recent. The login page becomes a role picker (admin / member / caretaker) that bypasses password auth; all write endpoints are blocked. The Bearer-token API is forced off (API_TOKENS_ENABLED is ignored), since a read-only public demo has no reason to mint write-capable tokens.
Leave all integration and mail variables unset (OIDC_*, S3_*, IMMICH_*, PAPERLESS_*, SMTP_*, NTFY_*). DEMO_MODE disables OIDC at the source, but the others are independent outbound-fetch or SSRF-adjacent surface that the read-only guard does not cover.
Behind Cloudflare, set the adapter-node client-IP header so the /auth/demo rate limiter sees the real visitor IP rather than the Cloudflare edge IP:
ADDRESS_HEADER=CF-Connecting-IP
If your setup uses X-Forwarded-For with multiple hops, also set XFF_DEPTH to the correct depth. Without the right header, the rate limit collapses all visitors into one bucket.
Cloudflare caching: the app emits Cache-Control: private, no-store on authenticated HTML in demo mode, so do not add page rules that cache HTML. Hashed /_app/immutable/* assets are safe to cache aggressively; that is where the bandwidth saving comes from.
| Runs as root | No (runs as node, UID 1000) |
no-new-privileges |
Enabled |
| Linux capabilities | All dropped |
| Root filesystem | Read-only |
Writable /tmp |
tmpfs, 64 MB |
| CPU limit | 0.5 cores (raise for video transcoding) |
| Memory limit | 256 MB (raise for video transcoding) |
| Tag | Description |
|---|---|
latest |
Latest stable release (published on version tags) |
x.y.z |
Pinned release |
x.y |
Latest patch of a minor release |
main |
Latest commit on main, unstable, for testing only |
sha-<commit> |
Specific commit build, useful for rollback |
Each release image carries a Sigstore-signed SLSA provenance attestation and a SPDX SBOM, and only ships after Trivy clears the OS and library packages on both linux/amd64 and linux/arm64. See SECURITY.md for the verification commands.
For tag selection:
:latestis the lowest-friction default and whatdocker-compose.prod.ymluses. Adocker compose pullwill follow new releases automatically, so a hypothetical poisoned release would auto-deploy. Acceptable for casual homelab use, especially when paired with manualgh attestation verifybefore the pull.:vX.Y.Zpins to a specific release. Auto-updaters like Watchtower will not move past it. Recommended for production deployments.@sha256:<digest>pins to the exact bytes you verified. Immune to registry retags or future republishing. Recommended for hardened deployments.
Builds the image locally instead of pulling from GHCR. Useful for testing Dockerfile changes or working on EinVault itself:
docker compose -f docker-compose.dev.yml up -d --buildAll the same env vars work here. ORIGIN defaults to http://localhost:3000 so no .env is needed for a basic smoke test.
Requires Node.js 20+, npm 10+, and the native build tools for better-sqlite3 and sharp:
- Debian/Ubuntu:
sudo apt install python3 g++ make - macOS:
brew install python3(Xcode Command Line Tools provides g++ and make)
npm install
npm run db:generate # generate migration files from the schema
npm run db:migrate # apply migrations
npm run dev # http://localhost:5173No .env needed. The database defaults to ./data/einvault.db and migrations run on startup. Open http://localhost:5173 and you'll land on /setup to create your admin account.
npm run dev # dev server at http://localhost:5173
npm run build # production build
npm run check # SvelteKit type checking
npm run lint # ESLint + Prettier check
npm run format # auto-format with Prettier
npm run test:unit # unit tests (Vitest)
npm run test:e2e # end-to-end tests (Playwright, builds the app first)
npm test # both
npm run db:generate # generate a migration file after schema changes
npm run db:migrate # apply pending migrations
npm run db:studio # Drizzle Studio (visual database browser)When you change src/lib/server/db/schema.ts, run db:generate then db:migrate and commit both files together.
Unit tests cover server helpers against a fresh in-memory database per test file. End-to-end tests build the app for production and drive a real browser against real server processes, each with its own throwaway SQLite database under .test-data/. Integrations run against local fakes (SMTP, OIDC, S3, Immich, Paperless, ntfy), so no external services are needed.
The e2e suite needs Chromium once:
npx playwright install chromium
sudo npx playwright install-deps chromium # system libraries, Debian/UbuntuPW_SKIP_BUILD=1 npm run test:e2e reuses the existing build/ output instead of rebuilding. The build goes stale silently, so rebuild after changing server code.
CI runs lint, type checks, unit tests, and the e2e suite (sharded four ways) on every PR.
- First run redirects to
/setupto create the initial admin account (one-time only) - Manage users at
/admin/users: create accounts, reset passwords, deactivate users - No open registration
EinVault supports OpenID Connect for SSO with providers like Authelia, Authentik, Keycloak, and PocketID. OIDC runs alongside password login; existing users keep their passwords. A "Sign in with {provider}" button appears on the login page when OIDC is configured.
OIDC is disabled unless all required variables are set. Add them to your .env (local) or compose file (production), then restart.
Register https://<your-domain>/auth/oidc/callback as an allowed redirect URI with your IdP first.
| Variable | Description |
|---|---|
OIDC_ISSUER_URL |
IdP base URL. Discovery happens at <issuer>/.well-known/openid-configuration. e.g. https://auth.example.com |
OIDC_CLIENT_ID |
Client ID registered with your IdP. |
OIDC_CLIENT_SECRET |
Client secret. Omit for public clients (PKCE-only). |
OIDC_REDIRECT_URI |
Must match what's registered with the IdP. e.g. https://einvault.yourdomain.com/auth/oidc/callback |
| Variable | Default | Description |
|---|---|---|
OIDC_SCOPES |
openid profile email |
Space-separated scopes requested from the IdP. |
OIDC_PROVIDER_NAME |
SSO |
Display label on the login button. |
OIDC_ALLOW_SIGNUP |
false |
If true, auto-creates accounts for users who authenticate but have no matching record. See below. |
OIDC_DEFAULT_ROLE |
member |
Role assigned to auto-created users. Allowed: member, caretaker. Admin can only be granted via groups. |
OIDC_ADMIN_GROUPS |
(unset) | Comma-separated. Users whose groups claim contains any value here are promoted to admin on every login. |
OIDC_POST_LOGOUT_REDIRECT_URI |
(unset) | If set and the IdP advertises end_session_endpoint, logout triggers RP-initiated logout at the IdP. |
OIDC_STATE_SECRET |
(random) | Pin a long random string. Without it, in-flight logins break across server restarts. |
OIDC_ALLOW_INSECURE_HTTP |
false |
Allows plain-HTTP issuer URLs. Exists for automated tests against a local mock IdP. Never set this in production. |
The callback decides which account to use in this order:
- Match by OIDC subject. If the user has logged in via OIDC before, link by stored
(issuer, subject). - Match by verified email. If
email_verifiedistrueand the email matches an existing user, link the OIDC subject to that account. - Auto-create. Only if
OIDC_ALLOW_SIGNUP=true. Username taken frompreferred_usernameclaim (sanitised, with numeric suffix on collision), or email local-part as fallback. - Reject. Otherwise, the user is returned to the login page with an "account not provisioned" message. An admin must create the account first.
By default (OIDC_ALLOW_SIGNUP=false), users must already exist in EinVault, or share an email with an existing account, to log in.
If OIDC_ADMIN_GROUPS is set, the user's groups claim is checked on every login. Membership in any listed group sets the user's role to admin; absence demotes them to the default role. Revoking admin at the IdP takes effect on next login.
If OIDC_ADMIN_GROUPS is unset, OIDC does not touch user roles. Roles set in EinVault's admin UI are preserved across SSO logins.
OIDC_DEFAULT_ROLE cannot grant admin; allowed values are member and caretaker.
Group membership is read from the top-level groups claim in the ID token, as an array of strings (a single string also works). Other claim names (roles, Keycloak's realm_access.roles) and nested claims aren't read. Configure your IdP to emit groups directly. See provider notes below.
By default, logout destroys the local EinVault session and returns the user to /auth/login. Set OIDC_POST_LOGOUT_REDIRECT_URI (and register it with your IdP) to also end the IdP session via RP-initiated logout.
| Provider | Notes |
|---|---|
| Authelia | Register the redirect URI under the OIDC client config. Set client_secret_basic (default) or none for public clients. |
| Authentik | Create an OAuth2/OIDC provider; scope mapping for groups is built-in. OIDC_ADMIN_GROUPS matches the groups claim directly. |
| Keycloak | Add a "Group Membership" mapper to the client with token claim name groups and "Full group path" off. EinVault does not read realm_access.roles. Add the redirect URI to "Valid Redirect URIs". |
| PocketID | Public-client first; omit OIDC_CLIENT_SECRET. Register the redirect URI in the client settings. |
EinVault supports per-user TOTP-based two-factor authentication. Users can enable it under Settings → Security: scan the QR code with any TOTP app (Aegis, Bitwarden Authenticator, Google Authenticator, etc.), enter the confirmation code, and download the 10 one-time backup codes. Each backup code works once and is not shown again.
Requirements. 2FA requires TWOFA_ENC_KEY to be set. This is a 32-byte base64 key used to encrypt stored TOTP secrets at rest. Without it, 2FA is unavailable and the enforcement setting in the admin panel is locked.
Generate a key:
openssl rand -base64 32Add it to your compose file or .env:
TWOFA_ENC_KEY=<the generated value>
Admin enforcement. Admins can require 2FA at Admin → Users → Security. Three levels are available:
- Off: 2FA is optional. Users can enable it but are not required to.
- Admins only: Admin accounts must enroll before they can use the app.
- Everyone: All non-OIDC accounts must enroll.
When enforcement is active, un-enrolled users are redirected to /2fa-setup after login and cannot access the rest of the app until they complete enrollment.
OIDC users are exempt. Accounts that sign in via OIDC are never required to enroll. MFA for those accounts is handled by the identity provider.
Recovery. If a user loses their authenticator app, they can sign in using one of their backup codes instead. If backup codes are also lost, an admin can reset the user's two-factor enrollment from the Manage drawer (Admin → Users → Manage → Reset two-factor). The user will be prompted to re-enroll on next login.
EinVault exposes an HTTP API so smart buttons, scripts, and other devices can log events, record health and weight, complete reminders, and read companion, shift, and roster data without a browser session. It is enabled by default; set API_TOKENS_ENABLED=false to turn off token creation and every endpoint below.
Creating a token. Go to Settings → API tokens, create a token, and copy it. The raw token is shown only once. A token acts as the user who created it and inherits their permissions, so a caretaker's token still requires an active shift and only reaches assigned companions, and can only write the current day's journal. Each token has an access level, either full (read and write) or write-only, which hides companion profile details (GET /api/companions returns just id, name, species, and active state), plus an optional expiry. Use Rotate to re-key a device: it mints a fresh token with the same name and access and revokes the old one.
Per-user access. While the API is enabled, every user has API access by default. Admins grant or revoke access for individual members and caretakers from Admin → Users → Manage; revoking disables all of that user's tokens at once without deleting them, and granting re-enables them. Admins always retain their own access.
Authenticating. Send the token in an Authorization: Bearer <token> header on every request.
curl -X POST https://einvault.example.com/api/logs \
-H "Authorization: Bearer $EINVAULT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"companionId": "abc123", "type": "walk", "durationMinutes": 30}'Endpoints.
| Method & path | Body | Purpose |
|---|---|---|
GET /api/companions |
- | List the companions the token user may target (members/admins: all active; caretakers: assigned). Returns { companions: [...] } with each companion's full profile. Use this to discover companionIds. |
GET /api/companions/{companionId} |
- | Get one companion. Full-scope tokens get the full profile; write-scope tokens get the minimal projection (no PII). An id the token can't reach reads as 404, same as unknown, so it can't be used to probe which ids exist; an archived companion reads as 404 too, not as a retrievable "deleted" record. Returns { companion }. |
POST /api/logs |
{ companionId or companionIds, type, subtypes?, notes?, durationMinutes?, loggedAt? } |
Log a daily event. type is one of walk, meal, bathroom, treat, play, grooming, other. subtypes is an optional array of kinds valid for that type (e.g. ["pee"] for bathroom, ["leash"] for walk; the full list per type is in /api/docs); a value that isn't valid for the type is rejected with 400. Returns 201 { ids, eventGroupId }. |
GET /api/logs |
query ?companionId=&date= (date optional YYYY-MM-DD) |
Read back logged events for a companion, newest first. Each event includes its subtypes (null when none were logged). Returns { events: [...] }. |
GET /api/journal |
query ?companionId=&date= (date defaults to today) |
Read back the journal entry for a companion/day. Returns { entry } (or { entry: null }). |
POST /api/journal |
{ companionId, date?, body?, mood? } |
Upsert the journal entry for a day (date defaults to today). Sending both body and mood replaces them; omitting a key leaves that field untouched, so a mood-only or body-only post won't wipe the other. Caretaker tokens may only write today. Returns 201 { id, companionId, date }. |
GET /api/quick-logs |
- | List the token user's enabled quick logs so a device can discover their ids. Returns { quickLogs: [...] }. |
POST /api/quick-logs/{id}/execute |
{ companionIds?, loggedAt? } (optional) |
Run a configured quick log. With no body it uses the quick log's remembered/assigned companions, so a physical button only needs the URL and token. Returns 201 { ids }. |
POST /api/health-events |
{ companionId, type, title, notes?, occurredAt?, vetName?, vetClinic? } |
Log a health event. type is one of vet_visit, vaccination, medication, procedure, other. occurredAt defaults to now and may be historical. Returns 201 { id, companionId }. |
GET /api/health-events |
query ?companionId=&date= (date optional YYYY-MM-DD) |
Read back health events for a companion, newest first by occurredAt. Returns { events: [...] }. |
POST /api/weight |
{ companionId, weight, unit, notes?, recordedAt? } |
Log a weight entry. unit is kg or lbs. recordedAt defaults to now and may be historical. Returns 201 { id, companionId }. |
GET /api/weight |
query ?companionId= |
Read back weight entries for a companion, newest first by recordedAt. Returns { entries: [...] }. |
GET /api/reminders |
query ?companionId=&status=due|all |
List reminders. Default status=due lists not-yet-completed reminders oldest-due first; status=all also includes completed reminders, ordered by due date (newest first). Omit companionId to list across every companion the token user may access. Returns { reminders: [...] }. |
POST /api/reminders/{id}/complete |
- | Mark a reminder done. A recurring reminder spawns its next occurrence. Returns 200 { id, completedAt, nextReminderId } (nextReminderId is null for a one-off). |
GET /api/shifts |
query ?from=&to= (both optional YYYY-MM-DD) |
List the caretaker shift schedule, most recent shifts first (up to 200; use from/to to window further back). Admins and members see every shift; a caretaker sees only their own. Returns { shifts: [...] }. |
GET /api/users |
- | List the user roster, scoped to what the token user may see: an admin sees everyone, a member sees everyone except admins, a caretaker sees only themselves. Returns { users: [...] } with id, displayName, role, isActive per user, plus username (omitted for a member-role token, since a member has no need to see other users' login identifiers). |
Quick logs are the customizable one-tap buttons you set up under Settings → Quick logs; pairing one with POST /api/quick-logs/{id}/execute keeps all configuration (event type, note, companions) in the app so the device stays a dumb trigger.
Pagination. The list endpoints (GET /api/logs, /api/health-events, /api/weight, /api/reminders, /api/shifts, /api/users) accept ?limit=&offset= (limit 1-200, default 50; offset 0-100000, default 0) and return a hasMore boolean alongside the array.
Reliability. Write endpoints accept an optional Idempotency-Key header. A retried request that carries the same key and body replays the original response instead of writing a duplicate (a device on a flaky network can safely retry); reusing a key with a different body returns 409. Error responses are JSON { code, message }; branch on the stable code (e.g. noActiveShift, notAssigned, noTargets, noteTooLong, journalTooLong, invalidPagination), not the localized message. Request bodies are strict: an unknown field is rejected with 400 invalidBody. Free text is bounded: notes cap at 5000 characters and journal bodies at 20000.
Docs. /api/docs is a self-hosted, zero-dependency reference for every endpoint above: request/response shapes, status codes, and a per-endpoint Try-It panel that fires real requests with a token you paste in. It's generated from the same schemas the server validates against, so it can't drift from what an endpoint actually accepts. The raw document is /api/openapi.json (OpenAPI 3.1), for anyone who wants to feed it into their own client or codegen; that endpoint is gated by API_TOKENS_ENABLED like the rest of the API, and /api/docs shows a load error when it's off.
- Copy
src/lib/i18n/en.tstosrc/lib/i18n/{code}.ts(e.g.ja.ts) and translate every value. The file mustexport default { ... } satisfies Record<keyof Messages, string>; the compiler will catch any missing keys. - In
src/lib/i18n/index.ts: import the new file, add the code to theLocaletype,SUPPORTED_LOCALES,LOCALE_LABELS, andcatalogs. - In
src/lib/server/db/schema.ts: add the code to thelocaleenum on theuserstable.
No migration is needed; SQLite text columns don't enforce enums at the database level.
Note: Non-English translations were generated by Claude (Anthropic) and may contain errors. Corrections via pull request are welcome.
- SvelteKit: full-stack TypeScript framework with file-based routing
- SQLite + Drizzle ORM: local-first, portable database
- Tailwind CSS: utility-first styling with custom components
- Session-based auth: custom sessions with bcryptjs password hashing; optional OIDC SSO via
openid-client - Docker: multi-stage, hardened single-container deployment
MIT. See LICENSE.




