Donetick¶
Donetick is the self-hosted chores and tasks app on the personal VM, port 2021. It runs on Docker Hub (donetick/donetick, not GHCR — GHCR 403s), config in config/selfhosted.yaml, SQLite database in the container. Public at donetick.thomasjwilde.com through the SWAG holder file.
The agent account¶
There is a dedicated member account for the agent, ted-lasso, admin of the family circle and of its own personal circle. It carries a long-lived token, which is what every API call below uses. Keeping a separate account rather than reusing mine means attribution stays clean — the ledger can tell the difference between the agent completing something and a person completing something.
The auth gotcha¶
The auth header is secretkey: <token>, not Authorization: Bearer. The multi-auth middleware tries secretkey first, then JWT. This is the number one thing that trips people up.
A long-lived token works on both /eapi/v1/* and regular /api/v1/* routes.
Trailing slashes matter
POST /api/v1/auth/ (register) and /api/v1/circles/ need the trailing slash. Without it you get a 301/307 redirect that looks like a failure.
API quirks worth knowing¶
- List endpoints return
items, notresults—{items:[...], total, total_pages}. - Circle join via API creates a PENDING request that an admin must approve. Not instant, don't mistake it for a bug. (In v0.1.79 the join endpoint actually panics — nil deref in
GetCircleByInviteCode— so the standard join→approve flow is unusable. The reliable path is a direct SQLite write of theuser_circlesrow.) /api/v1/users/profileis JWT-only, 401 withsecretkey. Avoid it.- The labels endpoint is locked in the self-hosted build — it rejects every token. Seed labels by writing the SQLite DB directly.
- Chore
pointshas no API route.PUT /api/v1/chores/:idreturns 200 but silently dropspoints. Direct DB update only. - Delete is owner-only. Both delete routes 403 unless the caller is the chore's
created_by. TheX-Impersonate-User-IDheader is not honored on the delete route.
The two-id problem¶
The circle members endpoint has two ids. id is the member-row id, userId is the actual user. Chore assignedTo, assignees[].userId, and label created_by all take the userId. Passing the member-row id gives a 400 "Assignee not found in circle".
Points attribution¶
Points credit whoever performs the completion, the completed_by field on chore_histories, not the chore's assigned_to. On a shared tablet logged in as one person, checking the kids off credits that person. This is the root cause of the "I got points for tasks assigned to the kids" complaint.
The fix is completedBy, two working paths, both admin-gated, both require the credited user to be an assignee of the chore:
POST /eapi/v1/chore/:id/complete?completedBy=<uid>— what the official HA integration uses.POST /api/v1/chores/:id/dowithcompletedByin the body — admin-gated.
Because chores are individually assigned per kid, completing the right chore with completedBy=<that kid> credits the kid correctly regardless of whose session tapped it. You don't need per-kid logins on the tablet.
The leaderboard displays points - pointsRedeemed, and redemption is the only point-mutation with a built-in audit trail, which makes it the mechanism for penalties. The backend check runs on the raw points, so a kid can't be docked more than their raw total. There's no free-text reason field on the ledger.
The points board in Home Assistant¶
The family-points dashboard reads balances through the eAPI members endpoint, no database access needed. A sync script runs on a five-minute cron and writes sensor.donetick_* entities: a summary, per-person open counts, and per-person points sensors.
The sensors state is points - pointsRedeemed, matching what the app renders. That matters: the backend redeem does not touch the raw points column, it only increments points_redeemed. If the sensor mirrored the raw column instead of the net, a penalty would not show on the board. It was writing raw points until I caught that.
Live balances as of October 2026: thomas 35, parker 31 (2 redeemed), emaline 8 (2 redeemed), rachel 0.
The intended replacement for the iframe approach is the official donetick-hass-integration, which exposes chores as native todo.* entities and takes completed_by as a service parameter, so attribution is settable per completion regardless of whose session tapped it. It needs a path into custom_components/ that this HA box does not currently offer — no HACS, no HAOS SSH — so the board still runs on the sync-script sensors.
Rolling overdue chores¶
A missed daily rolls to today, not last week's backlog. Weeklies and intervals advance to their next scheduled slot. One-offs are never auto-rolled.
POST /api/v1/chores/:id/skipadvances one period, logs a history row, awards no points.PUT /api/v1/chores/:id/dueDatesets the next due date directly, one call.
Backward moves are silently ignored
A dueDate PUT that moves a date earlier returns 200 with the old date unchanged, no error. Only forward moves persist. An updatedAt in the future returns 403 with an empty body — that's not an auth failure, the token is accepted.
The server stores dates in UTC but interprets a naive dueDate in the configured timezone, so keep explicit offsets if the timezone ever changes.
See also¶
- Home Assistant voice stack — the Donetick voice tools
- Repo: donetick/donetick-hass-integration — the intended replacement for the iframe approach, exposes chores as native
todo.*entities withcompleted_byattribution