Skip to content

Dvarim HTTP API (v1)

Base path /api. JSON in and out. Dates are YYYY-MM-DD strings in the server's local zone (TZ env, default Asia/Jerusalem); times are HH:MM 24-hour. Ids are 8-char base36 strings. Every endpoint but the three under Sign in needs a credential once a password is set.

Objects

// Todo
{
  "id": "a1b2c3d4",
  "title": "Send the draft to the board with questions",
  "notes": "Markdown text, may be empty",
  "status": "open",                 // open | done | canceled
  "inbox": false,                   // lives in Inbox.md
  "someday": false,                 // ๐Ÿ’ค
  "when": "2026-09-19",             // โณ or null
  "evening": false,                 // ๐ŸŒ™
  "reminder": "15:00",              // โฐ or null
  "deadline": "2026-09-24",         // ๐Ÿ“… or null
  "repeat": "every week when done", // ๐Ÿ” or null
  "tags": ["queries"],
  "checklist": [{"text": "Rice", "done": false}],
  "created": "2026-09-19",          // โž•
  "completed": null,                // โœ… or โŒ date
  "projectId": "p7k3j9a2", "projectName": "Annual report",
  "areaId": "a5m6n7o8",  "areaName": "Work",
  "headingId": "p7k3j9a2:Hand-in", "headingName": "Hand-in",
  "index": 3,                       // position within its container (file order)
  "todayIndex": 1,                  // position in Today when applicable
  "file": "Areas/Work/Annual report.md",
  "line": 27
}
// Project
{ "id", "name", "notes", "status", "when", "evening", "someday", "deadline", "repeat",
  "tags", "created", "completed", "areaId", "areaName", "progress": 0.45,
  "openCount": 6, "doneCount": 5, "headings": [{"id","name","archived"}], "file" }   // heading id = "<projectId>:<heading name>"; URL-encode it in paths
// Area
{ "id", "name", "notes", "tags", "file", "projects": [Project], "openCount" }
// CalendarEvent (phase 2; empty until Radicale is configured)
{ "date": "2026-09-19", "allDay": true, "time": null, "end": null, "title": "Street festival",
  "calendar": "holidays", "color": "#4CB35B" }

Sign in

A password (node server/src/cli.js set-password) turns authentication on; without one the server is open, which is right on a tailnet and wrong on the internet. Two ways in: a browser signs in and gets a session cookie, a program carries a bearer token. These three are the only endpoints that work signed out.

Method & path Body Returns
GET /api/health { ok:true } โ€” reachability, no credential needed
GET /api/auth { required, authenticated, person?, requests? } โ€” whether a login is needed, and whether this request has one; with a password set, person: { name, owner } says whose (docs/PEOPLE.md), and for the owner requests how many people are waiting for an answer
POST /api/login { name?, password } { authenticated:true, required, person } and sets dvarim_session: HttpOnly, SameSite=Strict, Secure over HTTPS, 30 days. A name someone has signs in as them; no name, the owner's, or one nobody has, as the owner. 401 {error} with Retry-After when wrong, 429 once three failures from one address have earned a lockout
GET /api/join/{code} { ok, note, expires } if the invitation is still good, else 404
GET /api/requests { open }: whether this server takes requests for an account (a password is set, and there is a folder for other people)
POST /api/requests { name, email, message?, website? } { ok:true }. Kept in requests.json beside auth.json for the owner. website is a field people never see: filled in, the answer is the same and nothing is kept. 400 for a missing name or an odd email, 429 after 3 from one address in a day or with 30 waiting, 404 where requests are not taken. The same email again updates its request
POST /api/join { code, name, password } as /api/login, for the new person: their account, a folder of their own with the tutorial in it, and a session. 400 for a name or password that will not do, 409 for a name taken, 404 for a used or lapsed invitation
POST /api/logout { authenticated:false }, clears the cookie

Programs use Authorization: Bearer dvt_โ€ฆ instead of the cookie; tokens are named, minted with node server/src/cli.js add-token <name> or in Settings โ†’ Sync, stored only as hashes, and revoked individually (docs/INTEGRATIONS.md). The server reads auth.json again when the command line changes it, so a token made or revoked there counts at once. A native client around the web app can also reuse the cookie the web view already holds, which is what the web-view Mac app did. The native Mac app signs in with a session of its own and makes its copy a token from it.

Anything else without a credential answers 401 {"error":"not signed in"}. A state-changing request whose Origin header names another host answers 403 {"error":"cross-site request refused"}; requests with no Origin at all, which is every non-browser client, are let through.

Devices

The tokens of the devices (and bots) that sync with this server, for Settings โ†’ Sync. Only a browser signed in with the password may use these; a token may not, so no token can make another.

Method & path Body Returns
GET /api/tokens { tokens: [{ name, created }] }, no hashes
POST /api/tokens { name } { name, token }, the token shown this once. 409 if the name is taken, 400 if it is empty or odd
DELETE /api/tokens/{name} { removed }; the token stops working at once

People

Other people on the owner's server (docs/PEOPLE.md). Every request goes to the folder of whoever made it, the lists, the event stream, the tokens above and /dav/ alike; these are the owner's alone, signed in with the password.

Method & path Body Returns
GET /api/people { enabled, reason?, owner: { name, owner }, people: [{ name, display, created }], invites: [{ id, note, created, expires }], requests: [{ id, name, email, message, created }] }
POST /api/people/requests/{id}/invite as an invitation below, in the asker's name, with their name and email; the request goes
DELETE /api/people/requests/{id} { removed }: dismissed, and nobody is told
PUT /api/people/owner { name } the owner's sign-in name, which nobody else may then take; empty clears it
POST /api/people/invites { note? } { id, code, expires, link }, the link shown this once: it works once, for 7 days
DELETE /api/people/invites/{id} { removed }
POST /api/people/{name}/password { password } { ok }: a new password to pass on, which signs them out everywhere
DELETE /api/people/{name} { removed, keptAt }: signed out at once, their folder moved to keptAt under the people folder, never deleted
GET /api/export everyone: their own folder as Dvarim <date>.zip

The folder, over WebDAV: /dav/

The vault for the built-in sync (docs/SYNC.md, "Built-in sync"): class 1 WebDAV, answered from the store (server/src/dav.js).

  • Who may: a token, as Authorization: Bearer dvt_โ€ฆ or as Basic auth's password under any user name; or this site's own session cookie, from this site's pages only. Never the account password. Without one: 401 with WWW-Authenticate: Basic.
  • Other origins: every answer carries Access-Control-Allow-Origin: *, and preflights are answered without a credential. No cookie is taken from another site's page, so a page elsewhere gets nothing without a token.
  • What is there: every note the app reads, plus .dvarim/state.json and .dvarim/stamps.json. Nothing else is listed, served or accepted.
Method What
OPTIONS DAV: 1
PROPFIND Depth: 0, 1 or infinity: resourcetype, getetag, getcontentlength, getcontenttype, displayname
GET, HEAD the file, with ETag (its SHA-1)
PUT If-Match: <etag> or If-None-Match: *; 412 if the file is not that version any more. 201 new, 204 replaced. The answer carries an ETag only if the file is kept exactly as sent
DELETE a note, with If-Match; 403 for a folder or a state file
MKCOL a folder notes can go in; 405 if it is there

LOCK, MOVE and COPY are not offered (405): a sync needs none of them.

Read

Method & path Returns
GET /api/state { today, counts:{inbox, today, todayDeadlines}, areas:[Area], looseProjects:[Project], tags:[{name,count}] } โ€” everything the sidebar needs
GET /api/lists/inbox { items:[Todo] }
GET /api/lists/today { events:[CalendarEvent], items:[Todo], headings? } โ€” items ordered by todayIndex; evening items carry evening:true. headings: [{ id, name, todos:[ids] }], Today's own in order, with which of today's items are under each, is there only when there are any
GET /api/lists/upcoming { days:[{ date, events:[CalendarEvent], items:[Todo] }] } โ€” one entry per date that has anything, ascending, next 12 months. An item appears on its When, or on its Deadline when it has no When, or on its next repeat date
GET /api/lists/anytime { items:[Todo], groups:[{ project:Project | null, area:Area | null, items:[Todo] }] } โ€” items are loose to-dos; groups follow sidebar order
GET /api/lists/someday { items:[Todo], projects:[Project] }
GET /api/lists/logbook?limit=200&before=YYYY-MM-DD { groups:[{ date, items:[Todo], projects:[Project] }] } newest first
GET /api/lists/trash { items:[Todo], projects:[Project] }
GET /api/lists/tomorrow { date, events, items, projects } โ€” a list reached only through Quick Find
GET /api/lists/deadlines { items:[Todo], projects:[Project] } sorted by deadline, past ones included
GET /api/lists/repeating { items:[Todo], projects:[Project] } sorted by next occurrence
GET /api/lists/projects / logged-projects { projects:[Project] } all open / all completed-or-canceled
GET /api/calendars { configured, calendars:[{name,color,components}] } from the CalDAV server
GET /api/reminders/log { configured, log:[...] } last 50 reminder deliveries
GET /api/projects/{id} { project:Project, items:[Todo] } โ€” items include done ones (status), client decides to show
GET /api/areas/{id} { area:Area, items:[Todo] } โ€” loose to-dos in the area
GET /api/tags [{ name, count }] including nested names like work/client
GET /api/search?q=text { lists:[{id,name}], projects:[Project], areas:[Area], tags:[{name,count}], todos:[Todo] } for Quick Find; case-insensitive substring; #tag narrows to tags
GET /api/calendar?from=&to= [CalendarEvent]
GET /api/stream Server-Sent Events. event: changed with data: {"files":[...]} after any index change. Clients refetch what they show
POST /api/parse-repeat { repeat, when? } { ok, repeat, describes, next:[4 dates], whenDone } or { ok:false, error }. The repeat editor builds a sentence and asks here rather than working the dates out itself
POST /api/parse-date { text } [{ label, when, evening, someday, reminder }] the When popover's suggestions for natural-language input (e.g. "tom", "sat 8pm", "in 3 days", "oct 2", "someday")

Write

Every write returns the affected object(s) and emits changed on the stream.

Method & path Body Returns
POST /api/todos { id?, title, notes?, inbox?, someday?, projectId?, areaId?, headingId?, when?, evening?, reminder?, deadline?, tags?, checklist?, repeat?, position?: "top" \| "bottom" \| {"after": id} } Todo โ€” with none of inbox/projectId/areaId, lands in Standalone.md. id (8 of 0-9a-z) lets a client name the to-do itself โ€” the web app mints one for a to-do made offline so it can keep editing it before the server has heard of it; if that id already exists the to-do is returned as it is, so a replay after a half-finished sync makes no second copy
GET /api/todos/{id} โ€” Todo, wherever it lives; 404 if there is none. What a reminder opens
PATCH /api/todos/{id} any subset of title, notes, when (null clears), evening, someday, reminder, deadline, repeat, tags, checklist Todo
POST /api/todos/{id}/complete { on? } { todo:Todo, next:Todo \| null } โ€” next is the new instance of a repeating to-do. on (YYYY-MM-DD, never in the future) is the day it was ticked, for a client sending one it kept while offline; without it, today
POST /api/todos/{id}/cancel { on? } Todo โ€” on as for complete
POST /api/todos/{id}/reopen {} Todo
POST /api/todos/{id}/move { inbox?:true } \| { standalone?:true } \| { projectId, headingId? } \| { areaId } plus optional position Todo
POST /api/todos/{id}/convert { id?, checklist?:[ids] } Project โ€” to-do becomes a project; checklist items become to-dos. id names the project and checklist names those to-dos in order, so a conversion made offline keeps its ids; if the project already exists it is returned as it is, the source to-do having gone
POST /api/todos/{id}/duplicate { id? } Todo โ€” id names the copy, the same rule
DELETE /api/todos/{id} Todo (now in Trash.md)
POST /api/todos/{id}/restore Todo
POST /api/reorder { context: "today" \| "anytime" \| "someday" \| "inbox" \| "standalone" \| "project:<id>" \| "heading:<id>" \| "area:<id>" \| "areas" \| "projects:<areaId>", ids:[...] } { ok:true } โ€” ids in new order. today, anytime, someday and upcoming:<YYYY-MM-DD> are view state; the rest rewrite the file. Send every id of the section, completed ones included. A day in Upcoming gathers to-dos from every file, so its order cannot be the file's: ids it does not name keep file order behind those it does, and orders for days now past are dropped when any of them is written. Logbook and Trash are date-ordered and cannot be reordered
POST /api/quick-add { text, notes?, id?, list?: "inbox" \| "today" } Todo โ€” the quick-entry grammar: trailing date words, #tags, ! for deadline marker (!fri), @time; default list is Inbox. notes is kept as written, for a capture that brought more than a line. id names the to-do, so a line typed offline keeps its id when the server parses it
POST /api/projects { id?, name, areaId?, notes?, when?, deadline?, someday?, tags? } Project โ€” id (p + 7 of 0-9a-z) as for to-dos: a client may name it, and an id that exists is answered with the project as it is
PATCH /api/projects/{id} { name?, notes?, when?, evening?, someday?, deadline?, repeat?, tags?, areaId? (null = no area) } Project
POST /api/projects/{id}/complete { completeOpenTodos?: bool } Project โ€” the app asks first whether the open to-dos should be completed too
POST /api/projects/{id}/cancel / reopen Project
DELETE /api/projects/{id} Project (file moved to Trash/)
POST /api/projects/{id}/restore Project (moved back to its original folder)
POST /api/projects/{id}/headings { name, position?: "top" \| {"after": todoId} \| {"before": todoId} \| {"beforeHeading": headingId} } { id, name, archived } โ€” id is <projectId>:<name>, URL-encode it in later paths. Without a position it goes last. At a to-do, the to-dos below that point in the same section come under the new heading, as typing ## Name there in the file would have it
PATCH /api/headings/{id} { name?, archived? } heading
DELETE /api/headings/{id} to-dos under it lose their heading
POST /api/tutorial Project: "Meet Dvarim" (engine/src/tutorial.js), the one there is while it is open and out of the Trash, else a new one with ids of its own. A vault with no notes at all starts with it
POST /api/today/headings { id?, name, position?: "top" \| {"after": headingId} } { id, name, todos:[] } โ€” a heading of Today's own, kept in .dvarim/state.json. id is h + 7 of 0-9a-z; sent again, the same heading comes back. Without a position it goes last
PATCH /api/today/headings/{id} { name } heading
DELETE /api/today/headings/{id} { ok:true } โ€” its to-dos stay in Today, above the headings
POST /api/today/file { ids:[...], headingId: id \| null } [heading] โ€” those to-dos under that heading, or (null) under none. Reorder Today's headings with POST /api/reorder and context today-headings
POST /api/areas { id?, name, notes?, tags? } Area โ€” id (a + 7 of 0-9a-z), the same rule
PATCH /api/areas/{id} { name?, notes?, tags? } Area
DELETE /api/areas/{id} projects move to Projects/, loose to-dos to Standalone.md, the area's note is deleted. Anything else in its folder (images, other notes) is never deleted: the folder moves to Trash/<name> and {ok, kept} says where; kept is null when the folder was empty
GET /api/logbook/pending { pending, mode } โ€” completions still sitting in their lists
POST /api/logbook/log-now { cutoff? } files everything completed on or before cutoff (sets logCutoff), today by default; a client sends the day it was asked for
POST /api/trash/empty { ok:true }
GET /api/file?path=<vault-relative.md> { path, text, sha, kind, name, obsidian, bytes } โ€” the raw Markdown behind a project or area
PUT /api/file?path=โ€ฆ { text, sha? } { path, sha, kind, todos, warning }. sha is what the editor loaded: if the file moved on since, nothing is written and 409 carries the current text back. Guarded to .md files inside the data root, never .dvarim/
GET/PUT /api/settings { logCompleted: "immediately" \| "daily" \| "manually", weekStartsOn: 0..6 (0 = Sunday), calendars:[names], eveningStartsAt: "18:00", groupTodayByList: bool, badgeCount: "today" \| "inbox" \| "none", showCalendarEvents: bool } settings
PUT /api/view-state { collapsed:[areaIds] } {ok:true}

Errors are { "error": "message" } with 400/404, or 401/403/429 as above. Concurrency: a write to a file that changed on disk since the index last read it returns 409 with the fresh object; the client refetches and retries.

Dev

Server: node server/src/main.js (port 8420; DVARIM_ROOT for the data). Frontend dev server proxies /api to http://localhost:8420. Production: the API serves frontend/dist at /, with index.html as the SPA fallback.