HTTP API
Base path: /api/v1. All requests and responses are JSON. Errors look like:
On this page
{ "error": { "code": "invalid_input", "message": "task.priority must be an integer from 1 (highest) to 4 (lowest)" } }
Authentication#
Two options:
- Session cookie (the web app). Obtained from
auth/registerorauth/login. State-changing requests must come from the app's own origin. - Bearer token (scripts, CLI, AI assistants). Create one in Settings → Integrations → API tokens or via
POST /tokens, then sendAuthorization: Bearer ot_….
A token has a scope: full (the default), read or agent. A read token reads everything its account can read but is refused with 403 insufficient_scope on every request that can change something: every method other than GET, HEAD and OPTIONS, and /api/v1/sync whatever the method. That includes creating another token, so a read token cannot escalate itself. Tokens created before scopes existed are full. Give read tokens to anything that only needs to look, such as an AI assistant connected to the MCP endpoint.
An agent token is for an AI assistant that may make changes. It is the only scope that may call the MCP write tools, and only while the owner's writes_enabled and the user's allow_writes are both on (mcp.md). On the REST API it may write only on the agent content routes (create and edit tasks, projects, labels and comments) under the same switches and limits; every other write is refused like a read token's, including /api/v1/sync, /api/v1/tokens, every settings route and /api/v1/me/agent-access (even GET). It is stored as a read scope plus a marker, so a server downgraded to a version without agent treats it as read-only.
GET /api/v1/me tells a client which credential it is using: see Credential introspection.
export OT=https://todo.example.com
export TOKEN=ot_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
If you would rather not hand-write requests, the ot command-line client wraps this API and prints the same entity shapes with --json.
How a non-browser client signs in#
Bearer tokens are minted by a signed-in user, so a client that only has an email and a password goes through a short session, which is what ot login does:
POST /api/v1/auth/loginwith{email, password}→ theopentodo_sessioncookie.POST /api/v1/tokenswith that cookie and{"name":"cli@<hostname>"}→{token, secret}. Send noOriginheader: cookie-authenticated writes are refused whenOriginis present and does not match the server, and accepted without it (browsers always send one, non-browser clients normally do not).POST /api/v1/auth/logoutwith the cookie, then keep only thesecretand use it as a bearer token. Bearer requests are not subject to the Origin check.
Login is rate limited (10 attempts per minute per address); a token created in Settings and passed to ot auth set-token skips the session entirely.
If the account has two-factor authentication, step 1 answers {"mfaRequired":true,"challenge":"…"} without a cookie. Post the challenge and a code to POST /api/v1/auth/mfa to get the cookie, then continue with step 2 (see Two-factor authentication). The fresh session can create the token without re-authenticating. Bearer tokens are never asked for a code.
Endpoints#
Auth column: none = public; yes = any session or token (a read token only on GET); owner, admin = that account role; member = a member of the project; fresh = a step-up route (see Two-factor authentication); cookie = a browser session. Every /api/v1 route not listed as public requires authentication.
Health and authentication#
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /healthz |
none | {"status":"ok","version":"…"} |
| GET | /api/v1/auth/status |
none | {setupRequired, signupAllowed, authenticated, user, version, passkeysAvailable, oidc, mfa?, encryption?, minClientVersion?, demo?}; mfa, encryption and minClientVersion only when signed in; demo ({token, sandboxHours: {idle, max}, resetsAt}: a fresh single-use sandbox-creation token valid for 10 minutes, the sandbox lifetimes in hours and the next nightly reset; no credentials) only on a demo instance (see Two-factor authentication, Single sign-on, End-to-end encryption) |
| POST | /api/v1/auth/register |
none | {email, name?, password, invitation?, projectInvite?} → 201 {user} + cookie. The first account becomes owner; afterwards an invitation secret admits one member while sign-up is closed, and a projectInvite secret creates a member who joins that project (201 {user, projectId, role}, see Project invite links) |
| POST | /api/v1/auth/login |
none | {email, password} → {user} + cookie, or {mfaRequired:true, challenge, expiresAt} and no cookie when the account has a second factor |
| POST | /api/v1/auth/mfa |
none | {challenge, code} or {challenge, recoveryCode}, optional remember → {user} + cookie (+ ot_trust cookie when remember) |
| POST | /api/v1/auth/reauth |
cookie | {password} or {code} → {reauthAt, validForMs}; unlocks fresh routes for 10 minutes |
| POST | /api/v1/auth/passkey/options |
none | → {publicKey} sign-in options, see Passkeys |
| POST | /api/v1/auth/passkey |
none | {credential} → {user} + cookie |
| POST | /api/v1/auth/logout |
cookie | 204, or 200 {redirect} (provider sign-out, see Single sign-on) |
| POST | /api/v1/demo/sandbox |
none | demo instance only (absent otherwise): {token} from auth/status → 201 {user} + cookie for a new private sandbox. Requires a same-origin Origin header; 403 demo_token (missing, forged, expired or used token), 403 bad_origin, 429 rate_limited (5 per address per hour), 503 demo_busy |
| GET | /api/v1/auth/oidc/start |
none | browser navigation: 302 to the provider (links the identity instead when signed in) |
| GET | /api/v1/auth/oidc/callback |
none | provider return: 303 to / + cookie, or to /?error=… |
| GET | /api/v1/invitations/{token} |
none | preview {inviterName, email, expiresAt} or 404 invitation_invalid, see Invitations |
| GET | /api/v1/project-invites/{token} |
none | preview {inviterName, projectName, role, email, expiresAt, canCreateAccount, alreadyMember?} or 404 invitation_invalid, see Project invite links |
| GET | /api/v1/password-resets/{token} |
none | preview {email, expiresAt} or 404 reset_invalid |
| POST | /api/v1/password-resets/{token} |
none | {password} → 204 or 404 reset_invalid |
Your account and credentials#
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/v1/me |
yes | {user, mfa, encryption: {enabled, keyVersion}, minClientVersion, auth}; auth describes the credential, see Credential introspection. The owner's session also gets config_warnings: [{variable, message, ignored?}], configuration problems the server started despite (an invalid OPENTODO_TRUSTED_PROXIES entry), empty when there are none; admins, members and tokens never get the field |
| POST | /api/v1/me/password |
yes, fresh | {currentPassword, newPassword} → 204; signs out your other sessions |
| GET | /api/v1/me/activity |
yes | {events:[…]}: your own access events, last 90 days, at most 100 |
| GET | /api/v1/me/agent-access |
session or full token |
{allow_writes, owner_allows_writes, mcp_enabled}: your "Allow AI assistants to make changes" switch; read and agent tokens → 403 insufficient_scope |
| PUT | /api/v1/me/agent-access |
session or full token |
{allow_writes: boolean} → same shape; recorded as settings.changed (agent.writes); anything else → 400 invalid_input; demo instances → 403 demo_restricted |
| GET | /api/v1/me/identities |
yes | {identities:[…], provider} |
| DELETE | /api/v1/me/identities/{id} |
yes | 204, or 409 last_credential |
| GET | /api/v1/tokens |
yes | {tokens:[{id,name,scope,createdAt,lastUsedAt}]} |
| POST | /api/v1/tokens |
yes, fresh | {name, scope?} (full default, read or agent) → 201 {token, secret} (secret shown once); another scope → 400 invalid_input |
| DELETE | /api/v1/tokens/{id} |
yes | 204 |
| POST | /api/v1/passkeys/register/options |
yes | → {publicKey} creation options |
| POST | /api/v1/passkeys/register |
yes | {name, credential} → 201 {passkey} |
| GET | /api/v1/passkeys |
yes | {passkeys:[{id,name,createdAt,lastUsedAt,suspiciousAt,backedUp}]} |
| PATCH | /api/v1/passkeys/{id} |
yes | {name} → {passkey} |
| DELETE | /api/v1/passkeys/{id} |
yes | 204 |
| GET | /api/v1/mfa |
yes | {mfa}, see Two-factor authentication |
| POST | /api/v1/mfa/totp/enroll |
yes | → {secret, uri, expiresAt} (pending for 10 minutes; the only time the secret is returned) |
| POST | /api/v1/mfa/totp/confirm |
yes | {code} → {recoveryCodes:[10], mfa} (codes shown once) |
| DELETE | /api/v1/mfa/totp |
yes, fresh | turn the second factor off → 204 |
| POST | /api/v1/mfa/recovery-codes/regenerate |
yes, fresh | → {recoveryCodes:[10]}; every earlier code stops working |
| GET | /api/v1/mfa/trusted-devices |
yes | {devices:[{id,userAgent,createdAt,expiresAt,lastUsedAt}]} |
| DELETE | /api/v1/mfa/trusted-devices |
yes | forget every remembered browser → 204 |
| DELETE | /api/v1/mfa/trusted-devices/{id} |
yes | forget one → 204 |
| GET | /api/v1/encryption/keys |
yes | {keys}: the wrapped key record, or 404 encryption_disabled; see End-to-end encryption |
| PUT | /api/v1/encryption/keys |
yes (fresh when replacing) | create (version: 1) or replace (version = stored + 1) the key record → 201/200 {keys}; 409 encryption_conflict |
| DELETE | /api/v1/encryption/keys |
yes, fresh | 204; turns encryption off for the account (404 encryption_disabled without a record) |
| POST | /api/v1/encryption/purge-history |
yes | → {purged, notice}; deletes superseded content operations; 409 encryption_disabled |
| GET | /api/v1/notifications |
yes | your in-app notifications and the unread count, see Assignment and notifications |
| POST | /api/v1/notifications/{id}/read |
yes | mark one read → 204 |
| GET | /api/v1/push/vapid-key |
yes | {publicKey}; with ?device={id} also subscription (that device's status or null), see Reminders and push |
| POST | /api/v1/push/subscriptions |
yes | {deviceId, endpoint, keys:{p256dh, auth}, includeContent?} → 201 {subscription} (200 when the device's subscription was replaced) |
| DELETE | /api/v1/push/subscriptions/{id} |
yes | 204, or 404 not_found |
Tasks, projects and other synced data#
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/v1/tasks |
yes | {tasks:[…]} (yours and those of projects shared with you); ?include_deleted=true to include tombstones |
| POST | /api/v1/tasks |
yes | create → 201 task, see Task fields; optional Idempotency-Key header, see Idempotency keys (every collection below accepts it on create) |
| GET | /api/v1/tasks/{id} |
yes | task or 404 |
| PATCH | /api/v1/tasks/{id} |
yes | partial update → task |
| DELETE | /api/v1/tasks/{id} |
yes | soft delete (to the Trash) → 204 |
| GET/POST/GET/PATCH/DELETE | /api/v1/projects… |
yes | same shape for projects, see Project fields |
| GET/POST/GET/PATCH/DELETE | /api/v1/sections… |
yes | same shape for sections, see Sections |
| GET/POST/GET/PATCH/DELETE | /api/v1/labels… |
yes | same shape for labels, see Label fields |
| GET/POST/GET/PATCH/DELETE | /api/v1/views… |
yes | same shape for saved views, see View fields |
| GET/POST/GET/PATCH/DELETE | /api/v1/reminders… |
yes | same shape for reminders, see Reminders and push |
| GET | /api/v1/tasks/{id}/history, /api/v1/projects/{id}/history |
yes | change sets from the operation log, oldest first; ?before=<ts>&limit=<1-200> pages backwards; see Trash and history |
| DELETE | /api/v1/tasks/{id}/purge, /api/v1/projects/{id}/purge |
yes | permanently delete an item that is in the Trash → {purged:{tasks,projects,reminders,sections,comments}}; 409 not_deleted otherwise |
| POST | /api/v1/trash/purge |
yes | empty the Trash (every deleted task and project you may delete) → {purged:{…}} |
| GET | /api/v1/tasks/{id}/comments |
yes | {comments}: the task's live comments in thread order; see Comments and activity |
| POST | /api/v1/tasks/{id}/comments |
yes | {body, parent_id?, mentions?} → 201 comment; 429 rate_limited past 60 new comments a minute |
| PATCH | /api/v1/comments/{id} |
yes | {body?, mentions?} → 200 comment with edited_at set; 403 forbidden unless you wrote it |
| DELETE | /api/v1/comments/{id} |
yes | 204; allowed for the author, project admins and the owner |
| GET | /api/v1/tasks/{id}/activity, /api/v1/projects/{id}/activity |
yes | {events, more, cursor?} newest first, derived from the operation log; ?before=<cursor>&limit=<1-200> (default 50) |
| GET | /api/v1/activity |
yes | the activity feed across every shared project you are a member of; ?cursor=&limit=&project=&actor=&kind=; see Activity feed |
| GET | /api/v1/projects/{id}/members |
member | {projectId, role, ownerId, members:[{userId,name,email,role}]}, see Sharing |
| POST | /api/v1/projects/{id}/members |
owner, admin of the project | {email, role} → 201 membership (shares the project on first use) |
| PATCH | /api/v1/projects/{id}/members/{userId} |
owner, admin of the project | {role} → membership |
| DELETE | /api/v1/projects/{id}/members/{userId} |
member | remove a member, or leave with your own id or me → 204 |
| POST | /api/v1/projects/{id}/transfer |
owner of the project | {userId} → membership; the previous owner becomes an admin |
| GET | /api/v1/projects/{id}/invites |
owner, admin of the project | pending invite links {invites:[…]}, see Project invite links |
| POST | /api/v1/projects/{id}/invites |
owner, admin of the project, fresh | {role, email?, expiresInHours?} → 201 {invite, link, newAccounts} (link shown once) |
| DELETE | /api/v1/projects/{id}/invites/{inviteId} |
owner, admin of the project | revoke a pending link → 204 |
| POST | /api/v1/project-invites/{token}/accept |
yes | join the project as the signed-in account → {projectId, role} |
| GET | /api/v1/settings/project-invites |
yes | {membersCanInviteNewPeople} (the owner switch) |
| PUT | /api/v1/settings/project-invites |
owner | {membersCanInviteNewPeople: true|false} → the same shape |
| POST | /api/v1/sync |
yes (not read) |
device sync, see sync.md; the response carries the log generation |
| GET | /api/v1/sync/events?device={id} |
yes | server-sent event stream; wakes the device when other devices or the API record operations, see sync.md |
| GET | /api/v1/export |
yes | full JSON export (projects, tasks, labels, views, reminders, sections, comments, encryption), including deleted items and the projects shared with you (with role and owner_id); never contains audit events or push subscriptions; reminders are personal, so only your own |
| POST | /api/v1/import/preview |
yes | multipart file + options → dry run {format, totals, rows, …}, see Import |
| POST | /api/v1/import |
yes | multipart file + options → 202 {job}; the import runs in the background |
| GET | /api/v1/import/jobs |
yes | {jobs:[…]}, newest first (last 50) |
| GET | /api/v1/import/jobs/{id} |
yes | {job} with status, counts and row errors |
| POST | /api/v1/import/jobs/{id}/undo |
yes | tombstone everything the job created → {job} (status undone); idempotent |
Integrations#
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/v1/webhooks |
yes | {webhooks:[…], events:[…]}, see Webhooks |
| POST | /api/v1/webhooks |
yes | {url, events, projectId?, enabled?} → 201 {webhook} (includes secret, shown once) |
| GET | /api/v1/webhooks/{id} |
yes | {webhook} (never the secret) |
| PATCH | /api/v1/webhooks/{id} |
yes | partial {url?, events?, projectId?, enabled?} → {webhook} |
| DELETE | /api/v1/webhooks/{id} |
yes | delete with its delivery log → 204 |
| POST | /api/v1/webhooks/{id}/test |
yes | send a ping now → {ok, delivery} |
| GET | /api/v1/webhooks/{id}/deliveries |
yes | {deliveries:[…]}, newest 100 |
| POST | /api/v1/webhooks/{id}/deliveries/{did}/redeliver |
yes | queue the same body with a new id → 202 {delivery} |
| GET | /api/v1/app-passwords |
yes | {appPasswords:[…]}, see CalDAV and app passwords |
| POST | /api/v1/app-passwords |
yes, fresh | {name} → 201 {appPassword, secret} (secret shown once) |
| DELETE | /api/v1/app-passwords/{id} |
yes | revoke → 204 |
| GET | /api/v1/email-inbox |
yes | your email-to-task address and settings, see Email to task |
| PATCH | /api/v1/email-inbox |
yes | {enabled?, allowlist?} → the same shape |
| POST | /api/v1/email-inbox/rotate |
yes | new address; the old one stops working |
| * | /dav/, /.well-known/caldav |
HTTP Basic with an app password | CalDAV (WebDAV methods), 404 while disabled; see caldav.md |
| POST | /mcp |
bearer token only | Model Context Protocol endpoint, 404 while disabled; write tools only for agent tokens with both switches on; see mcp.md |
Administration (owner and admins)#
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/v1/invitations |
owner, admin | {invitations:[…]}, see Invitations |
| POST | /api/v1/invitations |
owner, admin, fresh | {email?, expiresInHours?} → 201 {invitation, link} (link shown once) |
| DELETE | /api/v1/invitations/{id} |
owner, admin | revoke a pending invitation → 204 |
| GET | /api/v1/users |
owner, admin | {users:[…]}, see User administration |
| PUT | /api/v1/users/{id}/role |
owner, fresh | {role: "admin"|"member"} → {user} |
| POST | /api/v1/users/{id}/deactivate |
owner, admin, fresh | → {user}; revokes sessions, tokens, pending invitations and reset links |
| POST | /api/v1/users/{id}/reactivate |
owner, admin, fresh | → {user} |
| DELETE | /api/v1/users/{id} |
owner, admin, fresh | {confirmEmail} → 204; deletes the account and its data |
| POST | /api/v1/users/{id}/password-reset |
owner, admin, fresh | {expiresInHours?} → 201 {expiresAt, link} (link shown once) |
| POST | /api/v1/users/{id}/transfer-ownership |
owner, fresh | {password} → {user} |
| POST | /api/v1/members/{id}/mfa/reset |
owner, fresh | remove a member's second factor, recovery codes and remembered browsers → 204 |
| GET | /api/v1/settings/mfa |
owner | {required: "none"|"owner"|"all"} |
| PUT | /api/v1/settings/mfa |
owner, fresh | {required} → {required} |
| GET | /api/v1/settings/oidc |
owner | provider configuration, secret masked, see Single sign-on |
| PUT | /api/v1/settings/oidc |
owner, fresh | change the provider configuration |
| POST | /api/v1/settings/oidc/test |
owner | {issuer?} → {ok, issuer, authorizationHost, tokenHost, endSessionSupported} |
| GET | /api/v1/settings/mcp |
yes | {enabled, writes_enabled, endpoint}: whether the MCP endpoint is on, whether assistants may make changes, and its URL |
| PUT | /api/v1/settings/mcp |
owner | {enabled?, writes_enabled?} (at least one) → {enabled, writes_enabled, endpoint}; each change is recorded as settings.changed (mcp.enabled, mcp.writes) |
| GET | /api/v1/settings/caldav |
yes | {enabled, url, secure, encryption} |
| PUT | /api/v1/settings/caldav |
owner | {enabled} → the same shape |
| GET | /api/v1/settings/webhooks |
owner | {allowPrivate} |
| PUT | /api/v1/settings/webhooks |
owner | {allowPrivate} → {allowPrivate} |
| GET | /api/v1/audit |
owner | {events:[…], cursor} newest first; filters name, actor, target, outcome, from, to, cursor, limit≤200, see Audit log |
| GET | /api/v1/audit/export |
owner | the filtered log as application/x-ndjson download; records audit.exported |
| GET | /api/v1/settings/audit |
owner | {retentionDays, logIp} |
| PUT | /api/v1/settings/audit |
owner | {retentionDays?: 7–3650, logIp?} → settings; disabling logIp erases stored addresses |
| GET | /api/v1/backups |
owner | backup status, see Backups |
| GET | /api/v1/backups/settings |
owner | {settings, secrets} (secret presence flags only) |
| PUT | /api/v1/backups/settings |
owner | {settings, secrets?} → {settings, secrets} |
| POST | /api/v1/backups/run |
owner | start a backup now → 202, or 409 conflict while one runs |
| POST | /api/v1/backups/test |
owner | write and delete a probe object at the saved destination → 200 {ok:true} or 502 destination_error |
Passkeys#
Passkeys (WebAuthn) are an additional way to sign in; the password keeps working. They are only offered when OPENTODO_BASE_URL is https://… (or http://localhost / 127.0.0.1 for development). The relying-party id is the host of that URL and the expected origin is its scheme://host[:port]. auth/status reports passkeysAvailable. When it is false, every passkey route answers 409 passkeys_unavailable.
Both ceremonies take two steps. The options route returns {"publicKey": …}, the JSON form of PublicKeyCredentialCreationOptions / PublicKeyCredentialRequestOptions, with a single-use challenge valid for five minutes. The browser runs navigator.credentials.create() or get() with those options, and the client posts the result back. All binary fields are base64url strings (padding optional), in the same shape that PublicKeyCredential.toJSON() produces:
{
"name": "Work laptop",
"credential": {
"id": "q2Xx…", "rawId": "q2Xx…", "type": "public-key",
"response": {
"clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIi…",
"attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVj…",
"transports": ["internal", "hybrid"]
}
}
}
For sign-in, response carries clientDataJSON, authenticatorData and signature, and the body has no name. Sign-in uses discoverable credentials only (allowCredentials is empty), so the server finds the account from rawId. Registration asks for residentKey: "required", userVerification: "preferred" and attestation: "none". ES256, EdDSA (Ed25519) and RS256 keys are accepted.
| Code | Status | When |
|---|---|---|
passkeys_unavailable |
409 | the base URL is unset or not https/localhost |
passkey_challenge_invalid |
400 | the challenge is expired, already used, issued to another account, or was a sign-in challenge used for registration (or the reverse) |
invalid_input |
400 | origin, relying-party id, type or signature format does not match; credential already registered; empty name on rename |
bad_request |
400 | a field is missing or is not base64url |
invalid_credentials |
401 | sign-in with an unknown or deleted passkey, a bad signature, or a signature counter that went backwards (the passkey is then marked suspiciousAt) |
not_found |
404 | rename or delete of a passkey id that is not yours |
rate_limited |
429 | more than 10 requests per minute per address to auth/login, auth/register, auth/passkey, the invitation preview and the project invite link preview and accept routes combined (one shared window), or more than 10 per minute to auth/passkey/options (its own window, so the sign-in screen's autofill request never uses up password attempts) |
The anonymous auth/passkey routes apply the same Origin check as auth/login. A successful passkey sign-in sets the same opentodo_session cookie as a password sign-in. Bearer tokens can list, rename and delete passkeys, but a CLI cannot run a ceremony.
Two-factor authentication#
Accounts can add a time-based one-time password (TOTP, RFC 6238: HMAC-SHA1, 6 digits, 30-second steps) from any authenticator app. The server generates and checks the codes with its own clock and contacts nothing, so keep the host's clock synchronised (NTP). A code is accepted one step (30 s) either side of the server's time. Each code completes at most one authentication.
Profile. GET /api/v1/me, GET /api/v1/mfa and auth/status (when signed in) carry:
{ "mfa": { "enrolled": true, "recoveryCodesRemaining": 9, "policy": "owner", "required": true, "restricted": false } }
required means the owner's policy covers this account. restricted means the session may only enrol, because the account is required to have a second factor and has none yet. Bearer-token requests are never restricted.
Enrolment. POST /mfa/totp/enroll returns a fresh secret (base32) and its provisioning uri (otpauth://totp/OpenTodo:<email>?secret=…&issuer=OpenTodo&algorithm=SHA1&digits=6&period=30). The web app renders the QR code locally. Confirm within ten minutes with POST /mfa/totp/confirm {"code":"123456"}. The answer contains ten recovery codes such as ABCDE-FGH23, shown only this once and stored as SHA-256 hashes. A recovery code can stand in for an app code at sign-in (dash and case optional) and is used up by that sign-in.
Two-step sign-in. For an enrolled account, a correct password answers 200 with no cookie:
curl -sS -c jar -X POST "$OT/api/v1/auth/login" -H 'Content-Type: application/json' \
-d '{"email":"[email protected]","password":"…"}'
# {"challenge":"Zk3…","expiresAt":1760000300000,"mfaRequired":true}
curl -sS -c jar -X POST "$OT/api/v1/auth/mfa" -H 'Content-Type: application/json' \
-d '{"challenge":"Zk3…","code":"492039","remember":true}'
# {"user":{…}} + opentodo_session cookie (+ ot_trust cookie)
A challenge lasts five minutes and allows five wrong codes. Each wrong code gets 401 mfa_code_invalid; after the fifth, the challenge is gone and the next attempt gets 401 mfa_challenge_invalid, which means "enter the password again". remember: true sets ot_trust, an HttpOnly, SameSite=Lax cookie (Secure on https) limited to /api/v1/auth and valid for a fixed 30 days. While that cookie is present, the password step starts the session directly for that account on that browser. A passkey sign-in whose authenticator reports user verification (biometric or PIN) counts as two factors and is never asked for a code. A passkey without user verification answers with the same challenge as a password.
Step-up. For cookie sessions, the routes marked fresh in the table require a proof of identity within the last ten minutes. A sign-in counts, and so does POST /auth/reauth with {"password":"…"} or {"code":"123456"}. Otherwise they answer 403 reauth_required and change nothing. The web app then shows a re-authentication dialog and repeats the request once. Bearer-token requests are never challenged.
Owner policy. PUT /settings/mfa {"required":"none"|"owner"|"all"}. When the policy covers an account that has no second factor, sign-in still works. Every route except GET /me, /mfa/*, /auth/reauth and /auth/logout then answers 403 mfa_enrolment_required until the account enrols. That includes /sync, so a device keeps its changes queued and uploads them after enrolment. An account the policy covers cannot turn its second factor off (403 mfa_required_by_policy).
Recovery. The owner resets a member's second factor with POST /members/{id}/mfa/reset. If the owner loses both their authenticator and their recovery codes, stop the server and run opentodo mfa-reset [--data-dir DIR] <email> on the host (see SECURITY.md). Both are recorded as mfa.reset.
| Code | Status | When |
|---|---|---|
mfa_code_invalid |
400 | confirming enrolment with a wrong code or after the ten-minute window |
mfa_code_invalid |
401 | wrong, reused or replayed code or recovery code at auth/mfa or auth/reauth |
mfa_challenge_invalid |
401 | unknown, expired or exhausted sign-in challenge |
mfa_already_enrolled |
409 | enrol or confirm when a second factor is already active |
mfa_not_enrolled |
409 | disable or regenerate without a second factor |
mfa_required_by_policy |
403 | disabling while the owner's policy covers the account |
mfa_enrolment_required |
403 | any other route while the session is restricted |
reauth_required |
403 | a fresh route on a cookie session older than ten minutes since its last proof |
invalid_credentials |
401 | wrong password at auth/reauth |
invalid_input |
400 | missing challenge or code, empty re-authentication body, unknown policy value, auth/reauth with a bearer token |
forbidden |
403 | settings/mfa or a member reset by a non-owner |
not_found |
404 | unknown member id or trusted device |
rate_limited |
429 | more than 10 requests per minute per address to auth/mfa, or to auth/reauth (separate windows) |
Wrong codes are recorded as mfa.challenge_failed with a reason (wrong_code, wrong_recovery_code) and never the code. Enrolment, disabling and resets are recorded as mfa.enrolled, mfa.disabled and mfa.reset, and policy changes as settings.changed (setting: mfa.required). A completed sign-in is login.succeeded with method password+totp, password+recovery_code, password+trusted_device or passkey+totp.
Single sign-on#
The owner can connect one OpenID Connect provider (Authentik, Keycloak, Authelia, Zitadel, a hosted login service). It is off until configured; setup steps are in self-hosting/sso.md. When, and only when, a provider is configured, the server contacts that issuer's discovery document and the token and key endpoints it names (10-second timeout, no redirects followed).
Status. auth/status always carries {"oidc":{"enabled":true,"displayName":"Home SSO","ssoOnly":false}} (enabled:false when no provider is configured).
Sign-in is a browser navigation, not an API call: GET /api/v1/auth/oidc/start redirects to the provider with the authorization-code flow, PKCE (S256), a single-use state and a nonce, and sets a 10-minute HttpOnly, SameSite=Lax cookie ot_oidc (path /api/v1/auth/oidc) holding the signed flow state. The provider returns to GET /api/v1/auth/oidc/callback, which exchanges the code, verifies the identity token (RS256, PS256, ES256 or EdDSA signature from the provider's JWKS; issuer, audience, expiry, issued-at and nonce, with 60 seconds of clock leeway) and answers 303 to / with the usual opentodo_session cookie. The session counts as having passed the second factor: the provider applies its own policy, so an account with TOTP is not asked for a code. The owner's two-factor enrolment policy still applies to the session like any other.
The identity is matched by issuer and subject first; a first-time identity whose verified email equals an account's email is linked to that account; otherwise a member account is created only if open sign-up is on or the owner enabled allowProvision. Failures redirect to the sign-in screen with ?error=:
error |
When |
|---|---|
oidc_failed |
the provider refused, the code exchange failed or the identity token failed a check (details only in the server log) |
oidc_email_unverified |
a first-time identity without an email marked verified |
signup_closed |
no matching account and provisioning is not allowed |
start while signed in links the provider identity to the current account instead (Settings → Account → Linked accounts) and returns to /settings/account?linked=1. If that identity is already linked to the same account, the flow is a fresh proof of identity for the session (the fresh routes work for ten minutes) and returns to /settings/account?reauthenticated=1.
The callback answers 400 oidc_state_invalid (missing, forged, expired or already used state) and 409 identity_taken (linking an identity that belongs to another account) as JSON; a browser navigation (Accept: text/html) is redirected to /?error=<code> or /settings/account?error=<code> instead, so people never see raw JSON.
Owner settings. GET /api/v1/settings/oidc:
{
"enabled": true, "issuer": "https://sso.example.com/application/o/opentodo", "clientId": "opentodo",
"clientSecret": "••••a1b2", "clientSecretSet": true, "displayName": "Home SSO", "scopes": "openid email profile",
"allowProvision": false, "ssoOnly": false, "providerLogout": false,
"redirectUri": "https://todo.example.com/api/v1/auth/oidc/callback", "baseUrlConfigured": true,
"managedBy": { "issuer": "settings", "clientId": "settings", "clientSecret": "env", "displayName": "settings",
"scopes": "settings", "allowProvision": "settings", "ssoOnly": "settings", "providerLogout": "settings" }
}
PUT takes any subset of issuer, clientId, clientSecret, displayName, scopes, allowProvision, ssoOnly, providerLogout and returns the same shape. A new issuer must be an https URL whose discovery document is reachable and names the same issuer, or the answer is 400 invalid_input and the previous configuration stays. clientSecret is write-only: omit it (or send the masked value back) to keep it, send "" to remove it. A field set by an OPENTODO_OIDC_* variable is reported as managedBy: "env" and cannot be changed (400 invalid_input). An empty issuer turns single sign-on off (links are kept). PUT needs a fresh session. POST /api/v1/settings/oidc/test with optional {"issuer":"…"} runs discovery and answers {ok, issuer, authorizationHost, tokenHost, endSessionSupported} or 400 invalid_input. Changes are recorded as settings.changed (the secret as (changed)).
SSO-only mode (ssoOnly: true, effective only while a provider is configured): password and passkey sign-in, and registration through the password form, answer 403 sso_required for every account except the owner (recorded as login.failed with reason sso_required). The owner keeps local sign-in as break-glass (the sign-in screen shows the password form at /?owner=1). Bearer tokens keep working.
Linked identities. GET /api/v1/me/identities → {identities:[{id, issuer, email, createdAt, lastLoginAt}], provider:{enabled, displayName, ssoOnly}}. DELETE /api/v1/me/identities/{id} → 204, or 409 last_credential when the account would have no way left to sign in under the current mode (another identity counts while a provider is configured; a password or passkey counts unless SSO-only mode refuses it for this account). Linking and unlinking are recorded as identity.linked / identity.unlinked; a provider sign-in as login.succeeded with method oidc.
Sign-out. POST /auth/logout always deletes the local session. When providerLogout is on and the session started through the provider, it answers 200 {"redirect":"https://sso…/end-session?client_id=…&id_token_hint=…&post_logout_redirect_uri=https://todo.example.com/"} instead of 204, and the web app follows it after clearing local data.
| Code | Status | When |
|---|---|---|
oidc_disabled |
404 | auth/oidc/start or callback while no provider is configured |
oidc_state_invalid |
400 | callback with a missing, forged, expired or reused state |
identity_taken |
409 | linking an identity that is linked to another account |
last_credential |
409 | unlinking the account's last way to sign in |
sso_required |
403 | password or passkey sign-in, or password registration, by a non-owner in SSO-only mode |
invalid_input |
400 | issuer not https, discovery failed or names another issuer, client id missing, scopes without openid, changing an environment-managed field |
forbidden |
403 | settings/oidc by a non-owner |
rate_limited |
429 | more than 20 requests per minute per address to auth/oidc/start and callback together |
Idempotency keys#
POST /api/v1/{collection} (tasks, projects, labels, sections, views, reminders) accepts an optional Idempotency-Key header: 1 to 128 characters of letters, digits and . _ : -. The server derives the new entity's id and the ids of its operations from the key, your account and the collection, so a retry after a timeout cannot create a second entity:
- The first request applies and answers 201 with the entity.
- A repeat with the same content answers 200 with the stored entity (later edits included, a tombstone with
deleted_atset) and the headerIdempotent-Replayed: true. Nothing new is written. - A repeat with different content (another title, a field added or left out) answers 409
idempotency_conflictand changes nothing. Use a new key. - A malformed key answers 400
invalid_inputnamingIdempotency-Key. Unauthenticated andread-token requests are refused as usual (401, 403).
Keys are scoped to your account: the same key used by someone else names a different entity, and one person's key can never return, overwrite or collide with another person's entity (an instance-wide random salt also keeps the derived ids unguessable). There is no key table to expire: the operation log is the record, so a key stays usable for as long as the entity exists. After the entity is permanently deleted from the Trash, or its history purged by end-to-end encryption, the key answers 409. Devices never send the header and sync converges with keyed creates like with any other write. With an agent token the derivation also includes the token, so two tokens using the same key create two entities.
curl -s -X POST "$OT/api/v1/tasks" -H "Authorization: Bearer $TOKEN" -H 'Idempotency-Key: run42-milk' \
-H 'Content-Type: application/json' -d '{"title":"Buy milk"}' # 201
curl -si -X POST "$OT/api/v1/tasks" -H "Authorization: Bearer $TOKEN" -H 'Idempotency-Key: run42-milk' \
-H 'Content-Type: application/json' -d '{"title":"Buy milk"}' # 200, Idempotent-Replayed: true, same id
curl -s -X POST "$OT/api/v1/tasks" -H "Authorization: Bearer $TOKEN" -H 'Idempotency-Key: run42-milk' \
-H 'Content-Type: application/json' -d '{"title":"Buy bread"}'
# 409 {"error":{"code":"idempotency_conflict","message":"this Idempotency-Key was already used for a create with different content; …"}}
Credential introspection#
GET /api/v1/me carries an auth object describing the credential of the request. It never contains the secret.
| Field | When | Value |
|---|---|---|
method |
always | session or token |
scope |
always | full, read or agent (a session is full) |
token_id, token_name |
tokens | the token in use, as listed by GET /api/v1/tokens |
writes_allowed |
agent tokens |
whether both assistant switches are on right now |
writes_denied |
agent tokens with writes off |
writes_disabled_by_owner or writes_disabled_by_user |
{"user":{…},"auth":{"method":"token","scope":"agent","token_id":"9f1c…","token_name":"desk-assistant","writes_allowed":true}}
ot auth status shows it. A revoked token gets 401 unauthorized and no auth object.
Agent tokens#
An agent token may write through REST on this fixed list of routes only, and only while the owner's AI assistants may make changes and the user's Allow AI assistants to make changes are both on:
| Route | What an agent token may send |
|---|---|
POST /api/v1/tasks |
title, description, project_id, section_id, parent_id, priority, due, labels, estimate_minutes, recurrence |
PATCH /api/v1/tasks/{id} |
the same edit fields (not parent_id), due, project_id, section_id, completed_at (complete or reopen) and deleted_at (to the Trash, only tasks the same token created) |
DELETE /api/v1/tasks/{id} |
only tasks the same token created (to the Trash) |
POST /api/v1/projects |
name, color, parent_id (archived: false is accepted) |
PATCH /api/v1/projects/{id} |
name, color |
POST /api/v1/labels |
name, color (an existing label of that name is returned with 200) |
POST /api/v1/tasks/{id}/comments |
body, parent_id (mentions is accepted and dropped: assistant comments notify nobody) |
These requests never reach the regular handlers: they are translated into calls of the same tool registry as the MCP write tools (one request is one write call) and applied in one batch through the operation log with device agent:<token-id>, so they show in history as "AI assistant (token name)", are recorded as agent.write in the audit log, share the token's limits with MCP (30 write calls a minute, 1,000 changed items a day; beyond that 429 rate_limited with Retry-After), and follow the same rules for end-to-end encrypted accounts. Responses have the usual REST shapes.
Refusals:
| Status | Code | When |
|---|---|---|
| 403 | writes_disabled_by_owner, writes_disabled_by_user |
a switch is off (recorded as a failed agent.write) |
| 403 | insufficient_scope |
any other write route, or a field outside the list above (assignee_id, position, archived, restoring with deleted_at: null, …) |
| 403 | forbidden, not_permitted |
the user's role in a shared project does not allow it; trashing a task the token did not create |
| 404 | not_found |
unknown, deleted or invisible ids (indistinguishable) |
| 409 | encryption_enabled |
readable text (title, description, name, comment) for an end-to-end encrypted account |
| 400 | invalid_input |
a value the tool refuses (with the tool's message) |
Every other write route (tokens, settings, sync, sharing and members, invitations, project invite links, encryption keys, imports, backups, webhooks, purging, archiving or deleting projects, user administration) answers 403 insufficient_scope, and the list is enforced by the matched route, so a route added later stays refused until it is added on purpose.
Task fields#
| Field | Type | Notes |
|---|---|---|
title |
string | |
description |
string | the note as Markdown text (headings, **bold**, _italic_, ~~strike~~, `code`, links, -/1. lists, - [ ]/- [x] check items). Readable and writable as plain text: a write here is folded into the rich-text document by the next app that opens the note. At most 64 KB |
notes_doc |
string | the note as a mergeable rich-text document: "" or "ydoc:v1:<base64>" (at most 256 KB). Opaque; scripts normally ignore it and use description. 400 invalid_input when it is not a valid envelope. See sync.md |
project_id |
string or null | null = Inbox |
parent_id |
string or null | parent task for subtasks, any depth; see Subtasks |
priority |
1–4 | 1 highest, 4 default |
due |
{date:"YYYY-MM-DD", time?:"HH:MM", tz?:"Area/City"} or null |
floating date by default; with tz (an IANA zone, only together with time) a zoned time, the instant at that wall time in that zone. 400 invalid_input for tz without time or an unknown zone. See sync.md |
labels |
string[] | ids of existing, non-deleted labels; replaced as a whole on PATCH |
estimate_minutes |
integer 1–10080 or null | duration estimate in minutes; 0, negatives, fractions and strings are rejected. The Day and Week calendar draws a task with a due time as a block of this length |
recurrence |
string or null | recurrence rule, e.g. every 3 days after completion (see recurrence.md); stored in canonical form, 400 invalid_input when it does not parse or is over 200 characters |
series_id |
string or null | on a completed occurrence record or a detached copy: the id of its recurring series |
assignee_id |
string or null | account id of the assignee; see Assignment and notifications. 400 not_a_member when it is not a member of the task's shared project (or not you, for a personal task) |
section_id |
string or null | section of the task's project; see Sections. 400 invalid_input for an unknown or deleted section or one of another project. Changing project_id without naming a section clears it |
ical_extra |
string or null | opaque iCalendar lines a CalDAV client sent that the app does not model (alarms, X- properties…); kept so calendar apps do not lose them. See caldav.md |
position |
number | sort order inside a list: ascending, ties by id; clients move a task by writing the midpoint between its new neighbours (see docs/sync.md) |
completed_at, deleted_at |
unix ms or null | |
created_at |
unix ms | set by the server on create if missing |
Subtasks#
A task becomes a subtask by setting parent_id to another task's id, to any depth. On POST /tasks and PATCH /tasks/{id} the server checks the value and answers 400 invalid_input when:
- the parent does not exist or is deleted (
parent task not found), - the parent is in a different project than the task — the task's
project_idafter the write, so you can set both in one request (parent must be in the same project), - the parent is the task itself or one of its descendants, which would form a cycle (
parent is a descendant of the task).
parent_id: null detaches a task and is always accepted. Nothing changes on a rejected request. The sync endpoint never rejects an operation for tree reasons; see docs/sync.md for how clients converge.
The REST API writes exactly the fields you send. The cascades the web app performs (completing a parent completes its open descendants, deleting a parent tombstones the subtree, moving a parent moves the subtree) are ordinary per-task operations issued by the client; a script that wants the same result sends one request per task. A subtask whose parent is completed, deleted or missing is shown at the top level of its project by every client, so nothing becomes unreachable.
# Add a subtask under an existing task in the same project
curl -sS "$OT/api/v1/tasks" -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"title":"Book hotel","project_id":"<project-id>","parent_id":"<parent-task-id>"}'
Recurring tasks#
POST and PATCH store recurrence in canonical form ("Every 2nd Tuesday" is stored as "every 2nd tuesday"). A PATCH that sets a non-null completed_at on an open recurring task behaves like completing it in the app:
- a completed occurrence record is created (id derived from the series id and the occurrence date,
series_idset,recurrence: null, other fields copied from the task after the rest of the PATCH is applied), - the task stays open and its
duemoves to the next occurrence (time andtzkept); the response is the task with the newdue, - when the end condition (
untilorfor N times) is reached, the task is completed normally instead.
"Today" for catch-up and completion-relative rules is the server's local date (the container's time zone, UTC by default). Reopening a record (completed_at: null) turns it into a standalone task; the series is not moved back. Skip and "this occurrence only" are client actions: a script skips by PATCHing due.
# Make a task repeat, then complete the current occurrence
curl -sS -X PATCH "$OT/api/v1/tasks/<id>" -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"recurrence":"every weekday","due":{"date":"2026-10-12"}}'
curl -sS -X PATCH "$OT/api/v1/tasks/<id>" -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"completed_at":1760264400000}'
Project fields#
name (string), color (string, CSS colour), position (number, sort order among siblings), archived (boolean), parent_id (string or null, see Sub-projects), created_at, deleted_at.
Sub-projects#
A project becomes a sub-project by setting parent_id to another project's id, to any depth. On POST /projects and PATCH /projects/{id} the server checks the value and answers 400 invalid_input when:
- the parent does not exist or is deleted (
parent project not found), - the parent is the project itself or one of its descendants, which would form a cycle (
parent is a descendant of the project).
parent_id: null moves a project to the top level and is always accepted. Nothing changes on a rejected request. The sync endpoint never rejects an operation for tree reasons; see docs/sync.md for how clients converge when two devices move projects into each other concurrently.
The REST API writes exactly the fields you send. The cascades the web app performs (archiving a project archives its descendants, deleting one moves its direct sub-projects to its own parent and its open tasks to the Inbox) are ordinary per-project operations issued by the client; a script that wants the same result sends one request per project. A sub-project whose parent is archived, deleted or missing is shown at the top level by every client, so nothing becomes unreachable.
# Create a sub-project, then move it to the top level
curl -sS "$OT/api/v1/projects" -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"name":"Q4 launch","parent_id":"<parent-project-id>"}'
curl -sS -X PATCH "$OT/api/v1/projects/<id>" -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"parent_id":null}'
Sections#
A section is a named, ordered container inside one project (OpenSpec change board-view): a heading in the list view and a column on the board. Sections are soft-deleted like everything else, live in the sharing scope of their project (editors and up may write them, viewers read them) and appear in the export, deleted ones included.
| Field | Type | Notes |
|---|---|---|
name |
string | required, not blank |
project_id |
string | required on create: an existing, non-deleted project; cannot change afterwards (400 invalid_input) |
position |
number | ascending order within the project; defaults to now (last) |
created_at |
number | set by the server when omitted |
deleted_at |
number or null | set by DELETE |
DELETE /api/v1/sections/{id} tombstones the section and, in the same batch, sets section_id to null on each of its open tasks, so they move to "No section". Unknown fields are refused with 400 invalid_input; requests without credentials get 401 unauthorized.
# Create a section, put a task in it, then move the task back to "No section"
curl -X POST -H "Authorization: Bearer $T" -d '{"name":"Doing","project_id":"<project>"}' $BASE/api/v1/sections
# 201 {"id":"…","name":"Doing","project_id":"<project>","position":1760000000000,"created_at":1760000000000}
curl -X PATCH -H "Authorization: Bearer $T" -d '{"section_id":"<section>"}' $BASE/api/v1/tasks/$TASK
curl -X PATCH -H "Authorization: Bearer $T" -d '{"section_id":null}' $BASE/api/v1/tasks/$TASK
curl -X POST -H "Authorization: Bearer $T" -d '{"name":"Doing","project_id":"missing"}' $BASE/api/v1/sections
# 400 {"error":{"code":"invalid_input","message":"section.project_id references unknown or deleted project \"missing\""}}
Label fields#
| Field | Type | Notes |
|---|---|---|
name |
string | required on create, must not be blank; uniqueness is advisory (the app refuses duplicates per device, the API accepts them so merges stay possible) |
color |
string | CSS colour; picked from the palette when omitted on create |
position |
number | sidebar order; defaults to the creation time |
created_at, deleted_at |
unix ms (or null) |
Tasks reference labels by id, so renaming a label is one PATCH and never rewrites tasks. Deleting a label through the API only sets its tombstone; tasks that still carry the id keep it (the app hides ids of deleted labels), and the app's own delete additionally removes the id from every carrying task.
View fields#
A saved view is a named filter query with presentation options (OpenSpec custom-views). The app pins views to the sidebar and composes dashboards from them; queries are evaluated on each device, the server only checks that they parse.
| Field | Type | Notes |
|---|---|---|
name |
string | required on create, must not be blank |
query |
string | required on create for list views; must parse under the grammar in docs/filters.md, otherwise 400 with the parser's message (view.query: Unknown term "urgent" at position 6); may be empty for dashboards |
kind |
"list" or "dashboard" |
default list; cannot be changed after creation |
group_by |
"project", "due", "priority", "label" or "none" |
default project |
sort_by |
"due", "priority", "position", "created" or "title" |
default due (undated tasks last) |
sort_dir |
"asc" or "desc" |
default asc; for priority, desc lists priority 1 first |
pinned |
boolean | shown in the sidebar's Views group; default false |
position |
number | sidebar order; defaults to the creation time |
color |
string | CSS colour; default #64748b |
panels |
string[] | dashboards only: up to 6 ids of existing, non-deleted list views, no duplicates, never the dashboard itself; replaced as a whole on PATCH |
created_at, deleted_at |
unix ms (or null) |
Values outside the listed sets are rejected with 400 invalid_input on POST and PATCH and nothing is written. The sync endpoint, by contrast, accepts any string for kind, group_by, sort_by and sort_dir so that a future option stays additive; clients show the default for a value they do not know (see docs/sync.md, "Saved views"). Deleting a view sets its tombstone only; dashboards that list it keep the id and the app shows a "View removed" placeholder in that panel. The start view each device opens on launch is a device setting and is not part of the API.
Reminders and push#
A reminder belongs to one task and fires at an instant (OpenSpec change reminders; data model in docs/sync.md, behaviour in docs/reminders.md).
| Field | Type | Notes |
|---|---|---|
task_id |
string | required on create; must name an existing, non-deleted task |
kind |
"relative" or "absolute" |
default absolute |
offset_minutes |
integer or null | relative only: minutes from the task's due time, negative = before (−525600…525600) |
fire_at |
unix ms or null | absolute: required. Relative: computed by the server from the task's due date when omitted (09:00 for a date-only due, in the zone offset the user's devices last reported, UTC until then; a zoned due in its own zone); null while the task has no due date |
delivered_at |
unix ms or null | written by the server when it pushed the reminder |
dismissed_at, created_at, deleted_at |
unix ms (or null) |
PATCH /api/v1/tasks/{id} that changes due (including completing a recurring task, which moves the series) recomputes fire_at of the task's relative reminders in the same batch, with the same timestamp as the due op.
Push subscriptions are per device and outside the op log and the export. POST /api/v1/push/subscriptions takes the browser's PushSubscription.toJSON() plus the device id:
{"deviceId": "8c1f…", "endpoint": "https://fcm.googleapis.com/fcm/send/…",
"keys": {"p256dh": "BCVx…", "auth": "BTBZ…"}, "includeContent": false}
It answers 201 {"subscription": {"id", "deviceId", "includeContent", "createdAt", "lastOkAt", "lastError", "failures", "needsRenewal"}}; the endpoint and keys are never returned. Posting again for the same device replaces its subscription (200). A body without an https endpoint URL, an uncompressed P-256 p256dh key or a 16-byte auth secret is rejected with 400 invalid_input. A subscription created with a browser session is deleted when that session signs out or is revoked; the push service answering 404/410 deletes it as well, and needsRenewal turns true after 5 consecutive failed sends. With OPENTODO_PUSH=off every /api/v1/push/ route answers 404 not_found.
POST /api/v1/sync accepts an optional tzOffset (minutes east of UTC, −840…840): the device's zone, used for the REST recomputation above.
Invitations#
Invitations let the owner admit one person with one single-use, expiring link while sign-up stays closed. Accounts with the owner or admin role may create, list or revoke them; a member gets 403 forbidden. Invitations always admit a member. Invitations are server-side account data: they are never written to the op log and never sync to devices.
Create — POST /api/v1/invitations with an optional body:
| Field | Type | Notes |
|---|---|---|
email |
string | Bind the invitation to one address (matched case-insensitively at acceptance) |
expiresInHours |
integer 1–720 | Default 168 (seven days) |
Response (201):
{
"invitation": {
"id": "…", "status": "pending", "email": "[email protected]", "role": "member",
"createdBy": "<owner id>", "createdByName": "Owner",
"createdAt": 1760000000000, "expiresAt": 1760604800000,
"acceptedAt": null, "acceptedBy": null, "revokedAt": null
},
"link": "https://todo.example.com/invite/oti_…"
}
The link contains the secret and is returned exactly once; the server stores only a hash. The link uses OPENTODO_BASE_URL when set, otherwise the request's origin (honouring X-Forwarded-Host / X-Forwarded-Proto). Bad input (expiresInHours outside 1–720, a malformed email) is a 400 invalid_input. Creation is limited to 20 per hour per account (429 rate_limited).
List — GET /api/v1/invitations → {invitations: [...]} with the same shape as above, newest first. status is one of pending, accepted, expired, revoked; an accepted entry carries acceptedBy: {id, email, name}. Secrets and links never appear in the list.
Revoke — DELETE /api/v1/invitations/{id} → 204. An already accepted invitation answers 409 invitation_spent; an unknown id 404 not_found. Revoking an invitation that is already revoked or expired is a no-op 204.
Preview — GET /api/v1/invitations/{token} needs no authentication and returns {inviterName, email, expiresAt} (email is null for an open invitation). Unknown, expired, revoked and accepted tokens all return the same 404 invitation_invalid. Rate limited per client IP like sign-in.
Accept — POST /api/v1/auth/register with invitation set to the secret:
curl -sS "$OT/api/v1/auth/register" -H 'Content-Type: application/json' \
-d '{"email":"[email protected]","name":"Friend","password":"a long passphrase","invitation":"oti_…"}'
Creates a member account and starts a session even when OPENTODO_ALLOW_SIGNUP is false. The invitation is spent in the same transaction, so it can create at most one account; a second use returns 404 invitation_invalid. Errors: 403 invitation_email_mismatch when the invitation is bound to another address, 409 email_taken, 400 invalid_input for the password policy. In every error case the invitation stays pending.
User administration#
The owner, and admins within narrower limits, administer the accounts on the server. User administration is server-side account data: never written to the op log, never synced, and every action needs a connection. Roles:
| Action | owner | admin | member |
|---|---|---|---|
| invite, list and revoke invitations; list users | yes | yes | no |
deactivate, reactivate, reset or delete a member |
yes | yes | no |
the same on an admin |
yes | no | no |
| anything on the owner | no | no | no |
change role (admin ↔ member), transfer ownership |
yes | no | no |
| change own password | yes | yes | yes |
Checks run in this order: acting on your own account gives 409 self_action; a role that does not allow the action gives 403 forbidden (a member gets 403 on every mutating /users route); a target in the wrong state gives 409 account_deactivated or account_active; an unknown id gives 404 not_found. Every mutating /users route and /me/password is fresh: a cookie session must have proved its identity in the last ten minutes (403 reauth_required, see Two-factor authentication); bearer tokens are never challenged.
List — GET /api/v1/users → oldest first, never a credential:
{"users": [
{"id": "…", "email": "[email protected]", "name": "Owner", "role": "owner",
"createdAt": 1760000000000, "lastLoginAt": 1760090000000, "status": "active"},
{"id": "…", "email": "[email protected]", "name": "m", "role": "member",
"createdAt": 1760001000000, "lastLoginAt": null, "status": "deactivated"}
]}
lastLoginAt is the last session start (password, passkey, second step or registration); it is null for accounts that have not signed in since the upgrade. status is active or deactivated.
Role — PUT /api/v1/users/{id}/role with {"role":"admin"} or {"role":"member"} → {user}. owner or an unknown value is 400 invalid_input; a deactivated target is 409 account_deactivated. The new role applies to the target's next request without signing them out.
Deactivate / reactivate — POST /api/v1/users/{id}/deactivate → {user}. In one transaction the account is marked deactivated, all its sessions and API tokens are deleted, and its pending invitations and unused reset links are revoked; its open /sync/events streams are closed at once. Its data stays on the server. A correct password for it then gets 403 account_deactivated at sign-in (a wrong one still gets 401 invalid_credentials). POST …/reactivate lifts the block; revoked credentials are not restored. Deactivating a deactivated account is 409 account_deactivated; reactivating an active one is 409 account_active.
Delete — DELETE /api/v1/users/{id} with {"confirmEmail":"[email protected]"} (compared case-insensitively; missing or different is 400 confirmation_mismatch) → 204. In one transaction the account, its personal ops and fields, sessions, tokens, passkeys, reset links and the invitations it created are deleted. Shared projects it owned are deleted for every member (their devices purge them on the next sync); its memberships in other people's projects end, while the edits it made there stay part of those projects. Audit events about the account are kept with an email snapshot. Irreversible: backups are the recovery path.
Reset links — POST /api/v1/users/{id}/password-reset with optional {"expiresInHours": 1–72} (default 24) → 201:
{"expiresAt": 1760086400000, "link": "https://todo.example.com/reset/otr_…"}
The link is returned once (only its hash is stored) and uses the same origin rules as invitation links. Nothing is emailed; the administrator copies it. A new link revokes earlier unused ones. A deactivated target is 409 account_deactivated; a bad expiry 400 invalid_input. The owner is never a target: on the server, opentodo reset-owner-password prints an owner reset link (see docs/self-hosting/docker.md).
GET /api/v1/password-resets/{token} (no authentication) → {email, expiresAt}. POST /api/v1/password-resets/{token} with {"password":"…"} → 204: the password is replaced, the link is spent and every session of the account is deleted in one transaction; API tokens are kept and no session is started. Used, expired, revoked and unknown tokens all give the same 404 reset_invalid; a password under 10 characters is 400 invalid_input and the link stays pending. Both routes share the per-address sign-in limit (10 per minute).
Own password — POST /api/v1/me/password with {"currentPassword":"…","newPassword":"…"} → 204. Your other sessions are signed out, the current one stays (a bearer-token request signs out every session), API tokens are kept. A wrong current password is 403 invalid_credentials; a short new one 400 invalid_input. Limited to 10 per hour per account.
Transfer — POST /api/v1/users/{id}/transfer-ownership with {"password":"…"} (the owner's) → {user} with role: "owner". In one transaction the target becomes owner and the previous owner becomes admin; the database allows only one owner. A deactivated target is 409 account_deactivated, your own id 409 self_action, a wrong password 403 invalid_credentials, a sender who is not (or no longer) owner 403 forbidden.
Mutating /users requests are limited to 60 per hour per acting account (429 rate_limited).
# Issue a reset link for a member (token-authenticated script: never challenged)
curl -sS -X POST "$OT/api/v1/users/$ID/password-reset" -H "Authorization: Bearer $TOKEN"
# 201 {"expiresAt":1760086400000,"link":"https://todo.example.com/reset/otr_…"}
curl -sS -X POST "$OT/api/v1/users/$OWNER_ID/deactivate" -H "Authorization: Bearer $TOKEN"
# 409 {"error":{"code":"self_action","message":"you cannot do this to your own account"}}
Backups#
Scheduled backups of the whole server (see backups.md). Every route needs authentication (401 unauthorized) and the owner role (403 forbidden for members). Backup settings, runs and secrets are server-level data: they never sync to devices and are not part of the export.
Settings — GET /api/v1/backups/settings:
{
"settings": {
"destination": "s3",
"local": { "dir": "" },
"s3": { "endpoint": "https://s3.eu-central-1.amazonaws.com", "region": "eu-central-1", "bucket": "my-backups",
"prefix": "opentodo", "accessKey": "AKIA…", "pathStyle": false },
"webdav": { "url": "", "username": "", "allowHttp": false },
"schedule": { "frequency": "daily", "time": "03:00", "weekday": 0 },
"retention": { "keepLast": 7, "maxAgeDays": 0 },
"compress": true,
"scheduleSince": 1760000000000
},
"secrets": { "hasS3SecretKey": true, "hasWebdavPassword": false, "hasPassphrase": true }
}
| Field | Notes |
|---|---|
destination |
local, s3 or webdav (default local) |
local.dir |
absolute path; empty means <data>/backups |
s3.* |
endpoint (http(s) URL), bucket and accessKey are required for s3; region defaults to us-east-1; pathStyle for MinIO, Garage and most self-hosted stores |
webdav.* |
url is required for webdav; plain http:// is refused unless allowHttp is true (the password would travel unencrypted) |
schedule.frequency |
off (default), hourly, daily, weekly, in the server's time zone |
schedule.time |
HH:MM; hourly uses the minutes only |
schedule.weekday |
0 (Sunday) … 6 (Saturday), for weekly |
retention.keepLast |
at least 1 (default 7) |
retention.maxAgeDays |
0 = no age limit |
compress |
gzip the archive (default true) |
scheduleSince |
read-only: when the current schedule was set |
Update — PUT /api/v1/backups/settings replaces the settings. Secrets go in an optional secrets object: a key that is absent keeps the stored value, an empty string removes it, any other value replaces it.
curl -sS -X PUT "$OT/api/v1/backups/settings" -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{
"settings": {"destination":"local","local":{"dir":""},"schedule":{"frequency":"daily","time":"03:00","weekday":0},
"retention":{"keepLast":7,"maxAgeDays":0},"compress":true},
"secrets": {"passphrase":"a long passphrase you will not forget"}
}'
Secrets are stored in <data>/secrets/backup.json (mode 0600) and are never returned: responses carry only the has… flags. Invalid input is a 400 invalid_input whose error.field names the setting (for example s3.bucket or retention.keepLast); the previous settings stay active.
Status — GET /api/v1/backups:
{
"enabled": true, "destination": "local", "encrypted": true,
"schedule": { "frequency": "daily", "time": "03:00", "weekday": 0 },
"running": null,
"nextRunAt": 1760151600000,
"lastRun": { "id": "…", "kind": "scheduled", "status": "succeeded", "startedAt": 1760065200000, "finishedAt": 1760065200412,
"durationMs": 412, "bytes": 182344, "destination": "local: /data/backups",
"archiveName": "opentodo-20261010-030000-4f1c0a9e.tar.gz.enc", "error": "" },
"history": [ { "…": "the last 20 runs, newest first" } ]
}
running is the run in progress (or null); status is running, succeeded or failed; a failed run carries the destination's error in error. nextRunAt is null while the schedule is off. Runs are kept for 90 days.
Run now — POST /api/v1/backups/run → 202 {"status":"started"}; poll the status for the outcome. A request while a backup runs is a 409 conflict and starts nothing.
curl -sS -X POST "$OT/api/v1/backups/run" -H "Authorization: Bearer $TOKEN"
curl -sS "$OT/api/v1/backups" -H "Authorization: Bearer $TOKEN" | jq '.lastRun'
Test destination — POST /api/v1/backups/test writes a small opentodo-probe-<ms>.txt object to the saved destination and deletes it again → 200 {"ok":true}. A destination failure is a 502 destination_error whose message is the destination's own (for example s3 put opentodo/opentodo-probe-…: 403 Forbidden AccessDenied: …). The server contacts a remote destination only for a test or a run.
Audit log#
The server records security-relevant events in its own database (never synced to devices, never part of /api/v1/export). The owner reads, filters and exports the log; every user reads their own access events. There is no route to modify or delete an event: PATCH/DELETE /api/v1/audit/{id} answer 404 not_found. Events are removed only by retention pruning (and addresses only by disabling address logging).
Event shape (list, export and activity):
{
"id": 412, "ts": 1760000000000, "name": "token.created", "outcome": "success",
"actorId": "<user id>", "actorEmail": "[email protected]",
"targetId": null, "targetEmail": null,
"ip": null, "userAgent": "Mozilla/5.0 …",
"details": { "token_id": "…", "token_name": "laptop script" }
}
actorId is the account that acted (null for a failed sign-in); targetId is the account acted upon when different. actorEmail / targetEmail come from the live account, or from the email snapshot in details.email when the account no longer exists. ip is null unless the owner enabled address logging. userAgent is truncated to 200 bytes. details only ever carries the keys below: never task or project content, passwords, token or invitation secrets, one-time or recovery codes.
| Event | Outcome | Actor / target | details keys |
|---|---|---|---|
login.succeeded |
success | actor = user | email, method (password, register, passkey, oidc, or a two-step method such as password+totp) |
login.failed |
failure | target = account if it exists | email (existing accounts only), reason (wrong_password, unknown_account, rate_limited, unknown_passkey, passkey_rejected, passkey_counter_regression, deactivated) |
logout |
success | actor = user | |
token.created, token.revoked |
success | actor = user | token_id, token_name; token.created also token_scope (full, read or agent) |
agent.write |
success or failure | actor = user | tool, token_id, token_name, count (items changed; 0 on failure), reason on failure (writes_disabled_by_owner, writes_disabled_by_user, rate_limited, encryption_enabled, not_found, forbidden, scope_change, not_permitted, invalid_arguments, …). One per MCP write tool call of an agent token, and one per tool a REST write of an agent token runs (update_project for a project rename); never content or entity ids |
app_password.created, app_password.revoked |
success | actor = user | app_password_id, app_password_name |
email_inbox.created, email_inbox.rotated |
success | actor = user | (never the secret) |
email_inbox.updated |
success | actor = user | setting (enabled or allowlist), from, to (true/false, or the number of allowlist entries; never the addresses) |
invitation.created |
success | actor = owner | invitation_id, invited_email (bound email, if any) |
invitation.accepted |
success | actor = new member | invitation_id, email |
invitation.revoked |
success | actor = owner | invitation_id |
project_invite.created |
success | actor = creator | project_invite_id, shared_project_id, project_role, invited_email (bound email, if any) |
project_invite.revoked |
success | actor = project owner or admin | project_invite_id, shared_project_id |
project_invite.accepted |
success | actor = the account that joined (new or existing), target = the link's creator | project_invite_id, shared_project_id, project_role |
settings.changed |
success | actor = owner (the user for encryption*) |
setting (audit.retention_days, audit.log_ip, webhooks_allow_private, mfa.required, mcp.enabled, mcp.writes, agent.writes (actor = the user), caldav.enabled, project_invites.members_create_accounts, oidc.*, encryption, encryption.keys), from, to |
export.requested |
success | actor = user | format (json) |
audit.exported |
success | actor = owner | format (ndjson), count |
mfa.enrolled, mfa.disabled |
success | actor = user (or the owner, target = account) | method |
mfa.reset |
success | actor = owner (none from the command line), target = account | |
mfa.challenge_failed |
failure | target = account | reason (wrong_code, wrong_recovery_code; never the code) |
passkey.registered, passkey.deleted |
success | actor = user | passkey_id, passkey_name |
identity.linked, identity.unlinked |
success | actor = user | provider |
role.changed |
success | actor = owner, target = account | from, to (account roles, or project.<role> for project membership) |
user.deactivated, user.reactivated |
success | actor = owner or admin, target = account | |
user.deleted |
success | actor = owner or admin, target = account | email (snapshot) |
password.reset_issued |
success | actor = owner or admin (none from the server shell), target = account | reset_id |
password.reset_completed |
success | actor = account | reset_id |
password.changed |
success | actor = user | |
owner.transferred |
success | actor = previous owner, target = new owner | from (owner), to (admin): the previous owner's role change |
backup.created |
success or failure | actor = owner for "Back up now", none for scheduled runs | backup_id (the run id), reason (run_failed) on failure |
backup.restored |
success | none (recorded by the server on its first start after opentodo restore) |
backup_id (the archive file name) |
While sign-in is rate limited for an address, one login.failed event with reason rate_limited is recorded per window instead of one per refused attempt. Task and project edits are never audit events.
List — GET /api/v1/audit (owner) → {events: [...], cursor}, newest first. cursor is the id to pass as ?cursor= for the next page, or null when there are no more. Query parameters, all optional:
| Parameter | Meaning |
|---|---|
name |
one event name from the table above |
actor, target |
an account id, or an email (matched case-insensitively) |
outcome |
success or failure |
from, to |
inclusive time bounds, Unix milliseconds or RFC 3339 |
cursor |
the cursor of the previous page |
limit |
page size 1–200, default 50 |
A malformed value is a 400 invalid_input whose message starts with the parameter name (limit: must be between 1 and 200).
# Failed sign-ins of the last 24 hours
curl -sS -H "Authorization: Bearer $TOKEN" \
"$OT/api/v1/audit?name=login.failed&from=$(( ($(date +%s) - 86400) * 1000 ))"
Export — GET /api/v1/audit/export (owner) with the same filters except cursor and limit → every matching event as newline-delimited JSON (application/x-ndjson, Content-Disposition: attachment; filename="opentodo-audit-YYYYMMDD.ndjson"), one event object per line, newest first. The export records an audit.exported event.
Settings — GET /api/v1/settings/audit (owner) → {"retentionDays": 90, "logIp": false}. PUT the same object, both fields optional: retentionDays is an integer 7–3650 (otherwise 400 invalid_input), logIp turns client address logging on or off. Turning it off erases every stored address in the same transaction. Each changed value is recorded as settings.changed; lowering retention prunes expired events immediately, and the server also prunes at startup and hourly.
Personal activity — GET /api/v1/me/activity (any signed-in user) → {events: [...]}: the caller's own sign-ins, failed sign-ins against their account, sign-outs and credential and token events from the last 90 days, at most 100, newest first. Events about other accounts never appear.
Sharing#
A project owner shares a project with other accounts on the same server by exact email (OpenSpec project-sharing). Roles:
| Role | May |
|---|---|
viewer |
read the project and its tasks |
editor |
also create, edit, complete and delete tasks in the project |
admin |
also rename, recolour and archive the project; add, re-role and remove viewers and editors |
owner |
everything, including deleting the project, transferring ownership and managing admins; exactly one per project |
Every route requires a session or token. A caller who is not a member gets 404 not_found, the same as for a project that does not exist; the member's email is looked up only after the caller's role was checked, so the routes cannot be used to probe for accounts. Labels and saved views stay personal.
# Share with an existing account as editor
curl -X POST -H "Authorization: Bearer $T" -H 'Content-Type: application/json' \
-d '{"email":"[email protected]","role":"editor"}' $BASE/api/v1/projects/$P/members
# 201 {"projectId":"…","role":"owner","ownerId":"…","members":[{"userId":"…","name":"Alice","email":"[email protected]","role":"owner"},
# {"userId":"…","name":"Bob","email":"[email protected]","role":"editor"}]}
curl -X POST … -d '{"email":"[email protected]","role":"editor"}' $BASE/api/v1/projects/$P/members
# 404 {"error":{"code":"user_not_found","message":"no account with this email exists on this server"}}
curl -X DELETE -H "Authorization: Bearer $T" $BASE/api/v1/projects/$P/members/me # as the owner
# 409 {"error":{"code":"owner_must_transfer","message":"the owner cannot leave the project; transfer ownership to another member first"}}
curl -X POST … -d '{"userId":"<bob>"}' $BASE/api/v1/projects/$P/transfer
# 200 membership; Bob is the owner, the previous owner an admin
Collections, single reads and the export include the shared projects and their tasks; a shared project carries the read-only keys role (yours) and owner_id. They are ignored when sent back in a write. REST writes are checked with the same role table as sync: a refused write answers 403 forbidden and stores nothing. A task cannot change sharing scope: PATCH of project_id to a project shared differently (or to the Inbox from a shared project) answers 400 scope_change; create a copy in the destination and delete the original instead, which is what the web app does.
Membership changes are recorded in the audit log as role.changed with from/to values such as none → project.editor.
Project invite links#
A project's owner or admin can invite someone with a single-use link instead of an email (OpenSpec project-invite-links). Opening the link adds the person to the project with the link's role; it can also create their account. Links are server-side account data: never written to the op log, never synced, and every route needs a connection.
Create — POST /api/v1/projects/{id}/invites (owner or admin of the project; a fresh session, like invitations; read and agent tokens get 403 insufficient_scope):
| Field | Type | Notes |
|---|---|---|
role |
viewer, editor or admin |
required; admin only for the project's owner (an admin gets 403 forbidden) |
email |
string | Bind the link to one address (matched case-insensitively when it is used) |
expiresInHours |
integer 1–720 | Default 168 (seven days) |
{
"invite": {
"id": "…", "projectId": "…", "role": "editor", "email": null,
"createdBy": "<your id>", "createdByName": "Alice",
"createdAt": 1760000000000, "expiresAt": 1760604800000
},
"link": "https://todo.example.com/invite/p/otp_…",
"newAccounts": true
}
The link holds the secret and is returned exactly once; the server stores a hash. newAccounts says whether the link can create an account right now (see below). A viewer or editor gets 403 forbidden, a non-member 404 not_found; bad input is 400 invalid_input naming the field; 20 links per account per hour (429 rate_limited). An end-to-end encrypted account cannot create a link for a project it has not shared yet (409 encryption_enabled).
List — GET /api/v1/projects/{id}/invites → {invites: [...]}: the pending links of the project, newest first, same shape as above, never a secret.
Revoke — DELETE /api/v1/projects/{id}/invites/{inviteId} → 204 (any owner or admin of the project; repeating it is a no-op). An accepted link answers 409 invitation_spent, an unknown id 404 not_found.
Preview — GET /api/v1/project-invites/{token} needs no authentication:
{"inviterName": "Alice", "projectName": "Home", "role": "editor", "email": null,
"expiresAt": 1760604800000, "canCreateAccount": true}
alreadyMember (true or false) is added when the request carries a session. No ids are returned. Unknown, expired, revoked and used links, links to a deleted project, links whose creator was deactivated or can no longer grant the role (removed from the project, or no longer its owner or admin) all answer the same 404 invitation_invalid. Rate limited per client address together with sign-in.
Join while signed in — POST /api/v1/project-invites/{token}/accept (session or full token) → 200 {projectId, role}. The account becomes a member, the link is spent, and the devices of every member are woken to sync. Errors: 409 already_member (the link stays pending), 403 invitation_email_mismatch, 404 invitation_invalid (also when the creator lost the right since the preview, or another acceptance won).
Join with a new account — POST /api/v1/auth/register with projectInvite:
curl -sS "$OT/api/v1/auth/register" -H 'Content-Type: application/json' \
-d '{"email":"[email protected]","name":"Dora","password":"a long passphrase","projectInvite":"otp_…"}'
# 201 {"user":{…,"role":"member"},"projectId":"…","role":"editor"} + session cookie
The account, the membership and the spend commit in one transaction, so a link creates at most one account. A link may create an account when its creator is the server owner or an admin, when open sign-up is on, or when the owner turned on Members can invite new people with project links (PUT /api/v1/settings/project-invites {"membersCanInviteNewPeople": true}; off by default; any signed-in account can read it with GET). Otherwise the answer is 403 project_invite_not_allowed and the person signs in with an existing account first. In SSO-only mode registration answers 403 sso_required; the person signs in through the provider and then joins. Other errors as for invitations (invitation_email_mismatch, email_taken, invalid_input); in every error case the link stays pending.
Assignment and notifications#
A task's assignee_id (OpenSpec task-assignment) is an ordinary task field: set it with POST /api/v1/tasks or PATCH /api/v1/tasks/{id}, clear it with null. In a shared project the assignee must be a current member; for a task in your Inbox or an unshared project it must be you. Viewers cannot assign (403 forbidden, like any write). A refused assignment answers 400 not_a_member and stores nothing; the sync endpoint refuses the same operation individually with rejected: [{"id":"…","reason":"not_a_member"}].
When a member leaves or is removed, the server clears assignee_id on every task of that project assigned to them with operations of its own (device server), so every member's devices see the tasks unassigned after their next sync.
Assigning a task to someone other than yourself creates an in-app notification for the assignee. Notifications are server-side account data: they are not part of the op log or the export. Read notifications older than 90 days are removed when the server starts. Both routes require a session or token (401 unauthorized).
| Method & path | Purpose |
|---|---|
GET /api/v1/notifications |
your notifications, newest first (at most 200), and the unread count |
POST /api/v1/notifications/{id}/read |
mark one of yours read (idempotent); 204, or 404 not_found for an unknown id or another account's notification |
curl -X PATCH -H "Authorization: Bearer $T" -d '{"assignee_id":"<bob>"}' $BASE/api/v1/tasks/$TASK
# 200 task with "assignee_id":"<bob>"
curl -X PATCH … -d '{"assignee_id":"<carol, not a member>"}' $BASE/api/v1/tasks/$TASK
# 400 {"error":{"code":"not_a_member","message":"task.assignee_id: the assignee must be a member of the task's project (or yourself for a personal task)"}}
curl -H "Authorization: Bearer $BOB" $BASE/api/v1/notifications
# 200 {"notifications":[{"id":"…","kind":"assigned","payload":{"assigneeId":"<bob>","actorId":"<alice>",
# "actorName":"Alice","taskId":"<task>","taskTitle":"Milk","projectId":"<p>","projectName":"Home",
# "at":1760000000000},"createdAt":1760000000000,"readAt":null}],
# "unread":1}
curl -X POST -H "Authorization: Bearer $BOB" $BASE/api/v1/notifications/$N/read
# 204
projectId and projectName are omitted for a task in the Inbox.
Trash and history#
Deleting (DELETE /api/v1/{tasks|projects}/{id}) is a tombstone: the item goes to the Trash and can be restored by writing deleted_at: null (PATCH accepts deleted items). Deleting a project leaves its tasks' project_id alone; clients hide those tasks until the project is restored or purged.
History is read from the operation log. Each change set groups the field writes made at one timestamp by one device (server for REST writes, caldav:<id> for calendar apps, agent:<token-id> for AI assistants); a change set written by an AI assistant also carries device_label: "AI assistant (<token name>)" when you own the token and it still exists, "AI assistant" otherwise. from is the previous value in timestamp order, and lost: true marks a write that arrived after a newer one and never became the stored value. Members of a shared project see the history of its tasks; nobody sees another account's personal items (404 not_found, as for unknown ids and purged items). Restoring an old value is an ordinary write, so it appears in the history too.
curl -sS "$OT/api/v1/tasks/$ID/history" -H "Authorization: Bearer $TOKEN"
# 200 {"entity":"task","id":"…","more":false,"changeSets":[
# {"ts":1760090000000,"device":"3c54…","userId":"…","changes":[{"opId":"…","field":"title","from":null,"to":"Draft"}]},
# {"ts":1760090500000,"device":"server","userId":"…","changes":[{"opId":"…","field":"title","from":"Draft","to":"Final"}]}]}
# Permanently delete a trashed task (its operations, values and reminders)
curl -sS -X DELETE "$OT/api/v1/tasks/$ID/purge" -H "Authorization: Bearer $TOKEN"
# 200 {"purged":{"tasks":1,"projects":0,"reminders":0,"sections":0,"comments":0}}
# 409 {"error":{"code":"not_deleted","message":"only items in the Trash can be permanently deleted; delete it first"}}
Purging a project also purges every task that references it; purging a task also purges its deleted subtasks. In a shared project the role table applies: editors may purge tasks, only the owner may purge the project (403 forbidden); emptying the Trash skips items your role cannot delete. Purge is final and cannot be undone. The server also purges items that have been in the Trash longer than OPENTODO_TRASH_RETENTION_DAYS (default 30, 0 disables); see sync.
Comments and activity#
Comments (OpenSpec comments-and-activity) are threaded markdown notes on a task. They are stored in the operation log like tasks, in the task's sharing scope, so every route answers 404 not_found for a task or comment you cannot read, exactly as for an unknown id. Anyone who can read the task may comment, viewers included. Only the author may edit a comment; the author, project admins and the owner may delete it (403 forbidden otherwise). author_id, task_id and the timestamps are set by the server and cannot be sent. mentions lists account ids; the server keeps only current members of the task's shared project (always none on a personal task) and notifies each newly mentioned member (kind: "mentioned" in GET /api/v1/notifications, payload {recipientId, actorId, actorName, commentId, taskId, taskTitle, projectId?, projectName?, excerpt, at}). Each account may create 60 comments a minute (429 rate_limited). Bodies are markdown; clients render a safe subset and the server never renders them.
curl -sS -X POST "$OT/api/v1/tasks/$TASK/comments" -H "Authorization: Bearer $TOKEN" \
-d '{"body":"Whole or **skimmed**? @bob","mentions":["<bob>","<not a member>"]}'
# 201 {"author_id":"<you>","body":"Whole or **skimmed**? @bob","created_at":1760090000000,"deleted_at":null,
# "edited_at":null,"id":"…","mentions":["<bob>"],"parent_id":null,"task_id":"<task>"}
curl -sS -X POST "$OT/api/v1/tasks/$TASK/comments" -H "Authorization: Bearer $BOB" -d '{"body":"Whole","parent_id":"<comment>"}'
# 201 reply; parent_id must be a comment on the same task (400 invalid_input otherwise)
curl -sS "$OT/api/v1/tasks/$TASK/comments" -H "Authorization: Bearer $TOKEN"
# 200 {"comments":[ …top-level comments by creation time, each followed by its replies… ]}
curl -sS -X PATCH "$OT/api/v1/comments/$C" -H "Authorization: Bearer $BOB" -d '{"body":"hijack"}'
# 403 {"error":{"code":"forbidden","message":"only the author can edit a comment; the author, project admins and the owner can delete it"}}
curl -sS -X DELETE "$OT/api/v1/comments/$C" -H "Authorization: Bearer $TOKEN"
# 204; the comment is no longer listed (devices show a placeholder in the thread)
Activity is derived from the operation log on request, never stored (see sync for the rule shared with the client). A task's activity covers the task and its comments; a project's covers its tasks, their comments and the project. Events are newest first; cursor is present when more is true and is passed back as before for the next page (pages never overlap). actor is the author's account id, actorName and taskTitle are resolved for display, changes lists each touched field with its old and new value (empty for comment events), opIds the operations the event folds.
curl -sS "$OT/api/v1/projects/$P/activity?limit=2" -H "Authorization: Bearer $TOKEN"
# 200 {"events":[
# {"id":"…","kind":"completed","actor":"<bob>","actorName":"Bob","entity":"task","entityId":"<task>","taskId":"<task>",
# "taskTitle":"Milk","ts":1760090500000,"opIds":["…"],"changes":[{"field":"completed_at","from":null,"to":1760090500000}]},
# {"id":"…","kind":"commented","actor":"<you>","actorName":"Alice","entity":"comment","entityId":"<comment>",
# "taskId":"<task>","taskTitle":"Milk","ts":1760090000000,"opIds":["…","…"],"changes":[]}],
# "more":true,"cursor":"1760090000000:…"}
Kinds: created, completed, reopened, deleted, restored, rescheduled, assigned, unassigned, moved, archived, unarchived, edited, commented, comment_edited, comment_deleted. A malformed before or a limit outside 1–200 answers 400 invalid_input.
Activity feed#
GET /api/v1/activity (OpenSpec activity-feed) returns events from every shared project the caller is a member of, newest first, with the same shape and the same cursor as the per-project route plus projectId and projectName. Each project's events are derived exactly as GET /api/v1/projects/{id}/activity derives them, so an event has the same id in both. Personal projects and the Inbox never appear (their activity is in the project view), nor do projects you are not a member of, even when named in project.
Query parameters, all optional:
cursor— thecursorof the previous page (beforeis accepted too); pages never overlap.limit— 1–200, default 50.project— a project id; a project you are not a member of yields no events.actor— an account id.kind— a feed category:completed(completed, reopened),created,commented(commented, comment_edited, comment_deleted),assigned(assigned, unassigned),rescheduled,other(every other kind).
curl -sS "$OT/api/v1/activity?kind=completed&limit=1" -H "Authorization: Bearer $TOKEN"
# 200 {"events":[
# {"id":"…","kind":"completed","actor":"<bob>","actorName":"Bob","entity":"task","entityId":"<task>","taskId":"<task>",
# "taskTitle":"Milk","projectId":"<project>","projectName":"Home","ts":1760090500000,"opIds":["…"],
# "changes":[{"field":"completed_at","from":null,"to":1760090500000}]}],
# "more":true,"cursor":"1760090500000:…"}
curl -sS "$OT/api/v1/activity?cursor=junk" -H "Authorization: Bearer $TOKEN"
# 400 {"error":{"code":"invalid_input","message":"cursor must be a cursor returned by this route"}}
Without credentials the route answers 401 unauthorized; an unknown kind, a malformed cursor or a limit outside 1–200 answers 400 invalid_input. What a user has already seen is not stored by this route: it is the project_prefs.feed_seen_at field in the user's personal scope, written through sync like any other op (see sync). REST clients may write it with POST /api/v1/sync but have no collection route for it.
Import#
Import tasks and projects from the app's own export, CSV, or JSON exports of other task managers. Formats, the CSV columns, mapping rules and limits are documented in import.md. Every imported field is written to the operation log with server time and the device id import, so devices receive imports like any other write.
Uploads are multipart/form-data with a file part. Options go either in one options part holding JSON or in individual fields of the same names: format, targetProject, timeZone, sections (section|label|ignore, default section), duplicates (skip|import), errors (skip_rows|abort), existing (skip|overwrite). The file may be at most OPENTODO_IMPORT_MAX_MB (default 20 MB) and 50,000 rows; these routes do not use the 4 MB JSON body limit.
Preview — POST /api/v1/import/preview writes nothing and returns:
{
"format": "csv",
"columns": { "recognised": ["title", "project", "priority"], "ignored": ["color"] },
"options": { "timeZone": "Europe/Rome", "sections": "section", "duplicates": "skip", "errors": "skip_rows", "existing": "skip" },
"totals": { "rows": 100, "create": 97, "update": 0, "skip": 0, "error": 3, "possibleDuplicates": 0,
"warnings": 1, "newProjects": 1, "newLabels": 2, "newSections": 2, "recurring": 4, "reminders": 3,
"entities": 104, "written": 0, "ops": 0 },
"rows": [
{ "row": 2, "sourceId": "…", "kind": "task", "title": "Write report", "project": "Work", "outcome": "create" },
{ "row": 35, "kind": "task", "title": "Task 34", "outcome": "error", "reason": "invalid priority \"urgent!\" (expected 1-4 or p1-p4)" },
{ "row": 40, "kind": "task", "title": "Call mom", "outcome": "skip", "reason": "possible_duplicate" },
{ "row": 41, "kind": "task", "title": "Standup", "outcome": "create", "warnings": ["recurrence \"every 3 hours\" not supported; imported without a rule"] }
],
"truncated": false,
"notes": []
}
kind is project, task, label, section, reminder or view. outcome is create, update, skip (reason exists, unchanged, deleted, already_imported, possible_duplicate or no_project) or error (with reason). newSections, recurring (tasks with a recurrence rule) and reminders count what the import adds; notes holds file-level remarks, such as the number of comments in an own export that are not imported. row is the line in a CSV file or the position in a JSON array; version is set for an own export. At most 5,000 rows are listed, those needing attention first (truncated: true); totals always counts every row.
Start — POST /api/v1/import takes the same upload, re-checks it and returns 202:
{ "job": { "id": "…", "format": "csv", "status": "running", "options": { … }, "totals": { … },
"errors": [{ "row": 35, "reason": "invalid priority \"urgent!\" (expected 1-4 or p1-p4)" }],
"createdAt": 1760000000000, "finishedAt": null, "undoneAt": null } }
Status — GET /api/v1/import/jobs/{id} returns {job}; status is running, done, failed (with message, e.g. the first bad row under abort, or a restart) or undone. totals.written and totals.ops grow while the job runs. GET /api/v1/import/jobs lists {jobs} with at most 20 errors each.
Undo — POST /api/v1/import/jobs/{id}/undo soft-deletes every project, task, label, section, reminder and saved view the job created and returns the job with status undone. Calling it again is a no-op.
Errors: 400 invalid_input (unknown option value, unknown time zone, a targetProject that is not yours; no job is created), 400 unsupported_format (names the supported formats), 400 invalid_file (recognised but unreadable), 400 bad_request (not multipart or no file part), 413 too_large (file or row limit), 409 conflict (another import or undo of yours is running), 404 not_found (unknown job or another account's job).
curl -sS "$OT/api/v1/import/preview" -H "Authorization: Bearer $TOKEN" -F [email protected] -F timeZone=Europe/Rome
JOB=$(curl -sS "$OT/api/v1/import" -H "Authorization: Bearer $TOKEN" -F [email protected] -F timeZone=Europe/Rome | jq -r .job.id)
curl -sS "$OT/api/v1/import/jobs/$JOB" -H "Authorization: Bearer $TOKEN" | jq .job.status
curl -sS -X POST "$OT/api/v1/import/jobs/$JOB/undo" -H "Authorization: Bearer $TOKEN"
CalDAV and app passwords#
The CalDAV endpoint itself (/dav/, /.well-known/caldav) speaks WebDAV/CalDAV, not this JSON API; see caldav.md. It authenticates only with HTTP Basic (account email plus an app password). These JSON routes manage it; all require a session or token.
| Method | Path | Who | Body / result |
|---|---|---|---|
GET |
/api/v1/app-passwords |
any user | {"appPasswords":[{"id","name","createdAt","lastUsedAt","revokedAt"}]}, newest first, revoked ones included. Never the secret. |
POST |
/api/v1/app-passwords |
any user, recent sign-in (403 reauth_required otherwise, like token creation) |
{"name":"iPhone"} → 201 {"appPassword":{…},"secret":"otap_…"}. The secret is shown only here. |
DELETE |
/api/v1/app-passwords/{id} |
owner of the app password | 204; 404 not_found for an unknown, foreign or already revoked id. |
GET |
/api/v1/settings/caldav |
any user | {"enabled":false,"url":"https://todo.example.com/dav/","secure":true}; url is what calendar apps are configured with, secure is false for plain http. |
PUT |
/api/v1/settings/caldav |
owner | {"enabled":true} → the same shape. 400 invalid_input without a boolean enabled, 403 forbidden for members. Recorded as settings.changed (caldav.enabled). |
App passwords are refused by every /api/v1 route, and API tokens, passwords and session cookies are refused by /dav/. Creating and revoking one records app_password.created / app_password.revoked in the audit log.
Email to task#
Each account can have a secret inbox address; mail sent to it becomes an Inbox task (see email.md). The feature exists only when the operator configured a mailbox with OPENTODO_EMAIL_*. All routes require a session or API token and act on the caller's account. No response ever contains the operator's mail server, mailbox user or password.
| Method | Path | Who | Body / result |
|---|---|---|---|
GET |
/api/v1/email-inbox |
any user | {"enabledByOperator":true,"address":"todo+k3f9…@example.com","enabled":true,"allowlist":[],"hourlyLimit":60}. The first call creates the address (enabled). With no operator configuration: {"enabledByOperator":false,"address":null,"enabled":false,"allowlist":[],"hourlyLimit":60} and nothing is created. |
PATCH |
/api/v1/email-inbox |
any user | {"enabled":false} and/or {"allowlist":["[email protected]"]} → the same shape. allowlist must be an array of at most 50 bare email addresses (stored lowercased, duplicates dropped; [] clears it), otherwise 400 invalid_input and nothing changes. 409 email_disabled when the operator has not configured email capture. |
POST |
/api/v1/email-inbox/rotate |
any user | → the same shape with a new address; the old one stops working at once. 409 email_disabled as above. |
Changes and rotations are limited to 30 per account per hour (429 rate_limited). They are recorded as email_inbox.created, email_inbox.rotated and email_inbox.updated in the audit log.
curl -s -H "Authorization: Bearer $TOKEN" https://todo.example.com/api/v1/email-inbox
# 200 {"enabledByOperator":true,"address":"todo+k3f9…@example.com","enabled":true,"allowlist":[],"hourlyLimit":60}
curl -s -X PATCH -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"allowlist":["[email protected]"]}' https://todo.example.com/api/v1/email-inbox
# 200 {..., "allowlist":["[email protected]"], ...}
curl -s -X PATCH -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"allowlist":"[email protected]"}' https://todo.example.com/api/v1/email-inbox
# 400 {"error":{"code":"invalid_input","message":"allowlist: allowlist must be a list of at most 50 email addresses"}}
Tasks created from mail are ordinary operations with device server (title, description, priority 4, created_at, position; no project, no due date), so they arrive through POST /api/v1/sync like any other change.
Webhooks#
Outbound webhooks notify a URL you choose when your tasks and projects change. Event list, payload format, signature verification, retries and receiver recipes are in docs/webhooks.md; this section covers the management routes. Every route requires authentication and only ever sees the caller's own webhooks: another account's webhook id answers 404 not_found. Webhooks are server configuration: they never go through the op log, never sync and are not in the export.
Create — POST /api/v1/webhooks:
| Field | Type | Notes |
|---|---|---|
url |
string | http:// or https://, at most 2,048 characters |
events |
string[] | non-empty subset of task.created, task.updated, task.completed, task.reopened, task.deleted, project.created, project.updated, project.archived, project.deleted, import.completed, import.undone |
projectId |
string or null | only events for this project (tasks in it, or the project itself) |
enabled |
boolean | default true |
curl -sS "$OT/api/v1/webhooks" -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"url":"https://n8n.example.com/webhook/opentodo","events":["task.completed","task.created"]}'
Response (201):
{
"webhook": {
"id": "…", "url": "https://n8n.example.com/webhook/opentodo", "events": ["task.created", "task.completed"],
"projectId": null, "enabled": true, "disabledReason": null, "consecutiveFailures": 0,
"createdAt": 1760000000000, "updatedAt": 1760000000000,
"secret": "whsec_…"
}
}
secret appears only in this response; store it in the receiver to verify signatures. Errors: 400 invalid_input naming the field (url: url scheme must be http or https, events: unknown event "task.exploded", events: choose at least one event), 400 private_target when the host resolves only to loopback, private, link-local or other internal addresses and the owner has not allowed private targets, 409 limit_reached beyond 20 webhooks per account.
Update — PATCH /api/v1/webhooks/{id} with any of the fields above (projectId: null removes the filter). Changing url re-runs the private-target check. Setting enabled: true on a webhook the server disabled clears disabledReason and the failure counter.
Read and delete — GET /api/v1/webhooks, GET /api/v1/webhooks/{id}, DELETE /api/v1/webhooks/{id} (204, also deletes its delivery log and queue). disabledReason is null, or too_many_failures after 20 consecutive failed deliveries.
Test — POST /api/v1/webhooks/{id}/test sends a ping immediately (one attempt) and answers 200 {ok, delivery} whether or not the receiver accepted it; ok is true for a 2xx answer.
Delivery log — GET /api/v1/webhooks/{id}/deliveries → {deliveries: [...]}, newest first, at most 100, entries older than 30 days pruned:
{
"id": "…", "webhookId": "…", "event": "task.completed", "status": "retry", "attempts": 1,
"nextAttemptAt": 1760000030000, "statusCode": 503, "error": "HTTP 503", "responseExcerpt": "Service Unavailable",
"durationMs": 41, "createdAt": 1760000000000, "deliveredAt": null
}
status is pending, retry, delivered or failed; error is HTTP <code>, timeout, connection failed: … or private_target; responseExcerpt holds the first kilobyte of the response body.
Redeliver — POST /api/v1/webhooks/{id}/deliveries/{did}/redeliver queues the same body under a new delivery id → 202 {delivery}. A delivery id that does not belong to that webhook answers 404 not_found.
Owner policy — GET/PUT /api/v1/settings/webhooks with {"allowPrivate": true|false} allows targets on private networks (Home Assistant or n8n on the LAN) for every account. Owner only: members get 403 forbidden. The optional OPENTODO_WEBHOOKS_ALLOW_PRIVATE environment variable presets it on the first start.
End-to-end encryption#
Optional per-account encryption of task content (e2ee.md has the threat model). The server stores only the key record the browser produced and never decrypts. For an encrypted account, the content fields of personal data — task title, description, ical_extra, notes_doc; project, label and section name; view name and query; comment body — hold ciphertext strings in every REST read, the export and /api/v1/sync:
"enc:v1:" + base64url(nonce(12) || AES-256-GCM(utf8(plaintext)) || tag(16))
additional authenticated data = utf8(entity + "\0" + entityId + "\0" + field)
All other fields (due, priority, completed_at, project_id, labels, …) stay plaintext. Plaintext REST writes are accepted for an encrypted account; the next unlocked device re-writes them as ciphertext.
# Create the key record (version 1). Only wrapped keys and parameters: salt 16 bytes,
# wrapped keys 40 bytes each (AES-KW of a 256-bit key), base64url without padding.
curl -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"version":1,"kdf":1,"salt":"AQEBAQEBAQEBAQEBAQEBAQ","iterations":600000,"wrappedByPassphrase":"…","wrappedByRecovery":"…"}' \
https://todo.example.com/api/v1/encryption/keys
# 201 {"keys":{"version":1,"kdf":1,"salt":"…","iterations":600000,"wrappedByPassphrase":"…","wrappedByRecovery":"…","createdAt":1760000000000,"updatedAt":1760000000000}}
# again with version 1 (another device was first):
# 409 {"error":{"code":"encryption_conflict","message":"the encryption key record was created or changed by another device; unlock with the existing passphrase"}}
# a malformed record (for example 1000 iterations):
# 400 {"error":{"code":"invalid_input","message":"iterations must be between 600000 and 10000000"}}
# A REST read of an encrypted task: ciphertext title, plaintext due date
curl -H "Authorization: Bearer $TOKEN" https://todo.example.com/api/v1/tasks/<id>
# 200 {"id":"…","title":"enc:v1:oKGio6Slpqeoqaqron0SWSy4dh8xioWkMvJuPN5GjNypnM8","due":{"date":"2026-10-11"},"priority":1,…}
# Delete superseded content operations (plaintext history) for your account
curl -X POST -H "Authorization: Bearer $TOKEN" https://todo.example.com/api/v1/encryption/purge-history
# 200 {"purged":3,"notice":"Superseded content operations were deleted from the server. Backups made before this point, …"}
# without a key record: 409 {"error":{"code":"encryption_disabled",…}}
Replacing the record (passphrase change, recovery reset) and DELETE need a fresh re-authentication on cookie sessions (403 reauth_required). GET /api/v1/me and GET /api/v1/auth/status carry "encryption":{"enabled":true,"keyVersion":2} and "minClientVersion":1. The export adds "encryption": the key record, or null. Refused for encrypted accounts with 409 encryption_enabled: the first share of a project (POST /api/v1/projects/{id}/members on an unshared project) and POST /api/v1/app-passwords; /dav/ answers 403. GET /api/v1/settings/caldav adds "encryption": true|false for the caller.
In-app assistant#
The in-app assistant (OpenSpec in-app-assistant). Every route requires authentication. Settings, consent and usage are server state, never ops; conversations exist only on the device. Keys are write-only: no route ever returns one.
Owner settings — GET /api/v1/settings/assistant returns, for the owner:
{
"enabled": true, "active": true, "default_provider": "anthropic",
"providers": {
"anthropic": {"enabled": true, "model": "claude-opus-5-5", "key_set": true, "label": "Anthropic (Claude)"},
"openai": {"enabled": false, "model": "", "key_set": false, "label": "OpenAI"},
"gemini": {"enabled": false, "model": "", "key_set": false, "label": "Google Gemini"},
"compatible": {"enabled": false, "model": "", "base_url": "", "key_set": false, "label": "…"}
},
"allow_member_keys": false, "monthly_token_cap_per_user": 500000, "allow_private_endpoint": false,
"cap_member_keys": false, "member_providers": ["anthropic", "openai", "gemini"], "own_key_members": 0,
"anthropic_models": ["claude-opus-5-5", "claude-sonnet-5-5", "claude-haiku-5-5"], "default_model": "claude-opus-5-5"
}
cap_member_keys (default false): whether turns on a member's own key count toward monthly_token_cap_per_user; off, they are recorded but neither counted nor blocked. member_providers lists what members may choose for their own key (never compatible); own_key_members counts accounts whose turns use their own provider and key right now.
Members get a status without keys or base URL: {enabled, provider, provider_label, model, allow_member_keys, monthly_token_cap_per_user, cap_member_keys}. PUT (owner only, 403 forbidden otherwise) takes the same fields (a provider object replaces that provider's settings) plus keys: {"anthropic": "sk-…"} sets a key, "" removes it, an absent provider keeps it. Unknown fields are refused. Errors: 400 invalid_input naming the field (an unknown provider, a provider enabled without a model, a hosted provider without a key while member keys are off, the compatible provider without a valid http(s) base URL or with credentials in it, a disabled default provider, a negative cap); 400 private_target when the base URL resolves only to private, loopback, link-local or metadata addresses and allow_private_endpoint is off. A refused request changes nothing. Every changed value is audited as settings.changed (assistant.<field>; keys as assistant.<provider>.key from unset to set or back, never the value; a base URL by scheme and host only).
curl -sS -X PUT "$OT/api/v1/settings/assistant" -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"enabled":true,"default_provider":"compatible","allow_private_endpoint":true,
"providers":{"compatible":{"enabled":true,"model":"llama3.1","base_url":"http://192.168.1.20:11434/v1"}}}'
Test connection — POST /api/v1/settings/assistant/test with {"provider":"compatible"} (owner only) sends one minimal request with one tool, using the saved settings and key, and answers 200 {ok, code?, message, status?}. code is no_tool_support when the endpoint refuses tool definitions but answers without them, no_key, private_target, provider_auth, provider_rate_limited or provider_error.
Your consent, provider and key — GET /api/v1/me/assistant:
{
"available": true, "enabled": true, "reason": "",
"provider": "openai", "provider_label": "OpenAI", "model": "gpt-4.1-mini",
"source": "own", "own_key_in_use": true,
"server_provider": "anthropic", "server_provider_label": "Anthropic (Claude)", "server_model": "claude-opus-5-5",
"consented": true, "consent": {"accepted_at": 1760000000000, "disclosure_version": 1, "provider": "openai"},
"disclosure_version": 1, "fields": ["task titles and descriptions", "…"],
"allow_member_keys": true, "own_key_set": true, "server_own_key_set": false, "operator_key_set": true,
"own_provider": {"provider": "openai", "provider_label": "OpenAI", "model": "gpt-4.1-mini", "key_set": true},
"member_providers": ["anthropic", "openai", "gemini"], "anthropic_models": ["claude-opus-5-5", "claude-sonnet-5-5", "claude-haiku-5-5"],
"cap_member_keys": false
}
provider, provider_label and model name what your turns use now: your own provider (source: "own") while the owner allows member keys and your key for it is set, otherwise the owner's (source: "server", the same as server_provider). own_key_in_use is true when your turns run on your own key, which includes a key you stored for the owner's provider. own_provider is your choice (null for the server's assistant); it is kept while the owner does not allow member keys, but not used. own_key_set and server_own_key_set say whether you stored a key for provider and for the owner's provider. reason is disabled, no_provider or encryption_enabled when available is false. consented is true only for the current disclosure version and provider, so switching providers asks again.
PUT /api/v1/me/assistant (sessions and full tokens) takes:
{"consent": true, "provider": "<the provider the disclosure named>"}, or{"consent": false}to withdraw;{"own_provider": "openai", "own_model": "gpt-4.1-mini", "own_key": "sk-…"}to use your own provider:anthropic,openaiorgemini(nevercompatible, and no base URL). Anthropic models must be one ofanthropic_models; OpenAI and Gemini model ids are 1–100 characters ofA-Z a-z 0-9 . _ : -. A key is required unless you already stored one for that provider.own_modelalone changes the model of your provider;{"own_provider": ""}to go back to the server's assistant (the key you stored for the provider you had chosen is deleted);{"own_key": "…"}: sets the key of your own provider, or of the owner's provider when you have none (as before);""removes it (removing your own provider's key also goes back to the server's assistant).
Choosing a provider or storing a key needs the owner's "Let members bring their own key" (403 forbidden otherwise); removing either never does. Leaving a provider you chose deletes its key. Audited as settings.changed: assistant.own_provider (server or the provider name), assistant.own_model, assistant.own_key.<provider> (unset/set, never the key). Errors: 400 invalid_input naming own_provider, own_model or own_key; 409 provider_changed when provider is not the one in effect after the request, 409 encryption_enabled, 409 assistant_disabled. A refused request stores nothing. Deleting an account deletes its keys and choice.
Test your own provider — POST /api/v1/me/assistant/test (no body; signed-in sessions only) sends one minimal request with one tool to your own provider with your model and key and answers 200 {ok, code?, message, status?} like the owner's test (provider_auth says your key was rejected). Errors: 403 session_required for API tokens, 409 assistant_disabled, 403 forbidden while member keys are not allowed, 409 no_own_provider when your turns do not use your own provider, 429 rate_limited with Retry-After after 5 tests in 10 minutes.
Usage — GET /api/v1/me/assistant/usage → {month, input_tokens, output_tokens, total_tokens, turns, cap, resets_at, counted_tokens, cap_member_keys, own_key: {input_tokens, output_tokens, total_tokens, turns}} for the current UTC month, counted from the provider's usage fields. total_tokens counts every turn, own_key the turns on your own key, and counted_tokens what is measured against cap (everything but own_key unless cap_member_keys is on).
A turn — POST /api/v1/assistant/turn, signed-in sessions only:
{"messages": [{"role": "user", "text": "move everything overdue to Friday"}], "timezone": "Europe/Rome", "view": "today", "selected": []}
messages is the device's conversation (at most 40, the last from the user; the whole body at most 256 KB); timezone is an IANA zone; view and selected are optional context. Checks, in order, before any provider request: 404 not_found while the assistant is off; 403 session_required for API tokens; 409 encryption_enabled; 403 consent_required; 413 too_large; 400 invalid_input; 429 assistant_quota (with resets_at; not for a turn on your own key unless the owner turned on cap_member_keys); 409 turn_in_progress (one turn per person); 503 assistant_busy with Retry-After (8 turns server-wide); 409 no_key. Cookie requests from another origin get 403 bad_origin.
The answer is a text/event-stream:
event: status
data: {"text":"Looking at Today…"}
event: text
data: {"text":"I've proposed moving 12 tasks to Friday. "}
event: usage
data: {"input":2700,"output":360,"month_total":18240,"cap":500000,"own_key":false}
event: changeset
data: {"items":[{"entity":"task","entityId":"…","patch":{"due":{"date":"2026-10-16"}},"base":{"due":{"date":"2026-10-09"}},"shared":false}],"limit":50}
event: done
data: {"stop_reason":"end_turn","server_time":"2026-10-12T10:00:00Z"}
In usage, month_total is what counts toward the cap and cap is 0 for a turn the cap does not apply to (your own key while cap_member_keys is off); own_key marks a turn on your own key.
changeset comes at most once, only after end_turn, with at most 50 entities: patch is what to write, base the values it was planned on (for "changed since proposed"), create marks new entities, shared marks shared projects. The server never applies it: the app applies the confirmed part as ordinary ops with device assistant:<device-id>, which /api/v1/sync accepts and history labels "Assistant". done.stop_reason is end_turn, max_tokens (with a message; no change set), refusal (message: "The model declined this request."; no change set) or round_limit (more than 8 model calls). A failure ends the stream with event: error and {code, message}: provider_auth, provider_rate_limited, provider_error or timeout; never a key. Usage is recorded whatever the outcome.
Examples#
Create a task due tomorrow at 9:00 in a project:
curl -sS "$OT/api/v1/tasks" -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"title":"Call the plumber","priority":2,"project_id":"<project-id>","due":{"date":"2026-10-11","time":"09:00"}}'
A stand-up fixed to Berlin time (devices elsewhere show it converted to their zone, with 09:00 Berlin as a hint):
curl -sS "$OT/api/v1/tasks" -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"title":"Stand-up","due":{"date":"2026-10-11","time":"09:00","tz":"Europe/Berlin"}}'
# 201 {..., "due":{"date":"2026-10-11","time":"09:00","tz":"Europe/Berlin"}, ...}
curl -sS -X PATCH "$OT/api/v1/tasks/<id>" -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"due":{"date":"2026-10-11","tz":"Europe/Berlin"}}'
# 400 {"error":{"code":"invalid_input","message":"invalid op: task.due tz requires time"}}
Complete it:
curl -sS -X PATCH "$OT/api/v1/tasks/<id>" -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d "{\"completed_at\": $(date +%s000)}"
List open tasks in the Inbox with jq:
curl -sS "$OT/api/v1/tasks" -H "Authorization: Bearer $TOKEN" | jq '.tasks[] | select(.project_id == null and .completed_at == null) | .title'
Create a label, then tag a task with it:
LABEL=$(curl -sS "$OT/api/v1/labels" -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"name":"errand"}' | jq -r .id)
curl -sS -X PATCH "$OT/api/v1/tasks/<id>" -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d "{\"labels\":[\"$LABEL\"]}"
A labels value containing an id that is not an existing, non-deleted label is rejected with 400 invalid_input and the task is unchanged.
Rename a label (one request, no task is touched):
curl -sS -X PATCH "$OT/api/v1/labels/$LABEL" -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"name":"errands"}'
Save a view and pin it (the response carries id, position and the default grouping and sort; devices show it after their next sync):
curl -sS "$OT/api/v1/views" -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"name":"Waiting","query":"@waiting","pinned":true}'
# 201 {"color":"#64748b","created_at":1760090000000,"group_by":"project","id":"…","kind":"list","name":"Waiting",
# "panels":[],"pinned":true,"position":1760090000000,"query":"@waiting","sort_by":"due","sort_dir":"asc"}
Group it by due date with priority 1 first, then build a dashboard from it:
curl -sS -X PATCH "$OT/api/v1/views/<id>" -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"group_by":"due","sort_by":"priority","sort_dir":"desc"}'
curl -sS "$OT/api/v1/views" -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"name":"Morning","kind":"dashboard","panels":["<id>"],"pinned":true}'
An option outside the allowed set, or a query that does not parse, is refused and nothing is created:
curl -sS "$OT/api/v1/views" -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"name":"x","query":"p1","group_by":"colour"}'
# 400 {"error":{"code":"invalid_input","message":"view.group_by must be one of project, due, priority, label, none"}}
curl -sS "$OT/api/v1/views" -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"name":"x","query":"@work p1"}'
# 400 {"error":{"code":"invalid_input","message":"view.query: Expected \"\u0026\" or \"|\" before \"p1\" at position 7"}}
Export everything:
curl -sS "$OT/api/v1/export" -H "Authorization: Bearer $TOKEN" -o opentodo-export.json
The export includes saved views (views, deleted ones too) next to projects, tasks and labels. It resolves label ids: tasks[].labels holds ids and the top-level labels array (including deleted labels) carries their names and colours.
Follow live updates (the response is a text/event-stream that stays open; -N disables curl's buffering):
curl -sSN "$OT/api/v1/sync/events?device=my-script" -H "Authorization: Bearer $TOKEN"
# : connected
# event: ops
# data: {"cursor":158}
# : ping
device is required (400 bad_request without it) and names the subscriber; operations pushed by that same device id through POST /sync do not wake it. Each ops event carries only the user's latest cursor; fetch the changes with a sync exchange or the REST reads.
Behaviour notes#
- Every write through this API is recorded in the operation log with server time. Connected devices are woken over the event stream and sync within a second or two; devices without a live stream pick the change up on their next poll (within 30 s), or immediately when they next edit or regain focus.
- Writes to unknown fields or with wrong types are rejected as a whole; nothing is partially applied.
- Deletes are tombstones. Use
include_deleted=trueor the export to see them. - Request bodies are limited to 4 MB, except import uploads (
OPENTODO_IMPORT_MAX_MB, default 20 MB). - Rate limits (per IP):
auth/login,auth/register,auth/passkey, the invitation preview, the project invite link preview and accept routes and the password-reset routes share one window of 10 per minute;auth/passkey/options,auth/mfaandauth/reautheach have their own window of 10 per minute;auth/oidc/startandauth/oidc/callbackshare a window of 20 per minute; failed/mcpauthentications count toward the login window. Per account: invitation creation 20 per hour, project invite link creation 20 per hour, mutating/usersrequests 60 per hour,/me/password10 per hour, new comments 60 per minute, email-inbox changes 30 per hour.
Error codes#
Every error answers {"error":{"code","message"}}; some validation errors add field. Codes by area (the sections above give the details):
General
| Code | Status | Where |
|---|---|---|
bad_request |
400 | malformed JSON, a JSON body over 4 MB, or a missing required parameter (for example device on /sync or /sync/events); an import upload that is not multipart or has no file part |
invalid_input |
400 | validation failure naming the field or parameter |
invalid_op |
400 | /api/v1/sync batch with an op that fails schema validation (unknown entity or field, wrong type), or a device id starting with the reserved agent: or caldav: (assistant: is a device prefix and is accepted); nothing in the batch is applied |
unauthorized |
401 | any protected route without a valid session or token |
demo_expired |
401 | demo instance: a session cookie that no longer resolves, because its sandbox was deleted (inactivity, maximum age or the nightly reset). Clients drop their local data and offer a new sandbox |
forbidden |
403 | an action your account role or project role does not allow: invitation management or user administration by a member, an admin acting on an admin or the owner, a role change or transfer by a non-owner; owner settings (backups, audit, webhook policy, two-factor policy, single sign-on, MCP, CalDAV) or a member reset by a non-owner; a write, purge or membership change your project role does not allow; editing someone else's comment |
bad_origin |
403 | cookie-authenticated write from another origin; any /mcp request whose Origin is not this server |
session_required |
403 | POST /api/v1/assistant/turn with an API token: the assistant is used from the app while signed in |
insufficient_scope |
403 | a write route or /api/v1/sync called with a read or agent API token; /api/v1/me/agent-access with either |
demo_token |
403 | POST /api/v1/demo/sandbox without a valid, unused creation token |
demo_restricted |
403 | a feature a demo instance (OPENTODO_DEMO=true) refuses, before authentication: sign-up, invitations, user administration, password change and resets, tokens, app passwords, CalDAV, passkeys, two-factor, settings writes, backups, webhooks, email inbox, import, encryption, push, membership changes, project invite links (every method), /mcp, the assistant (/api/v1/assistant/*, /api/v1/me/assistant*). See demo instance |
not_found |
404 | unknown id, including another account's items, webhooks and deliveries, and items you cannot read; /api/v1/push/* with OPENTODO_PUSH=off |
conflict |
409 | starting or undoing an import while another import of yours runs; POST /backups/run while a backup is running |
too_large |
413 | /mcp body over 1 MB; an assistant turn over 256 KB or 40 messages; import file over OPENTODO_IMPORT_MAX_MB or 50,000 rows |
rate_limited |
429 | too many attempts in the window, see Behaviour notes |
demo_limit |
429 | demo instance: the sandbox used its write budget (600 ops per hour, 5,000 ops and 2 MB of values in total), or the database is above its size limit, on /sync or a REST write; nothing of the batch is applied. Demo instances also cap values at 4 KB (32 KB for task notes) with invalid_input / invalid_op |
internal |
500 | unexpected server error (details only in the server log) |
unavailable |
503 | backups or webhooks are not available on this server |
demo_busy |
503 | demo instance: no new sandbox now (live cap reached with none idle for 15 minutes, or the database is above its size limit) |
Sign-in, accounts and credentials
| Code | Status | Where |
|---|---|---|
invalid_credentials |
401/403 | 401 for a wrong sign-in or passkey; 403 for a wrong current password at /me/password or transfer-ownership |
signup_closed |
403 | registration without an invitation while sign-up is closed; first sign-in through the provider when provisioning is not allowed |
email_taken |
409 | registration with an existing email |
invitation_email_mismatch |
403 | registration email differs from the invitation's bound email |
invitation_invalid |
404 | unknown, expired, revoked or already accepted invitation or project invite link; a project link whose project was deleted or whose creator can no longer grant its role |
project_invite_not_allowed |
403 | registering through a project invite link that may not create accounts (see Project invite links) |
invitation_spent |
409 | revoking an accepted invitation or project invite link |
reset_invalid |
404 | unknown, used, expired or revoked password reset link |
self_action |
409 | a /users action on your own account |
account_deactivated |
403/409 | 403 at sign-in with the correct password of a deactivated account; 409 for an administrative action on a deactivated account |
account_active |
409 | reactivating an active account |
confirmation_mismatch |
400 | deleting an account without its email in confirmEmail |
reauth_required |
403 | a fresh route on a cookie session that has not proved its identity in the last ten minutes |
mfa_code_invalid |
400/401 | 400 confirming enrolment with a wrong or late code; 401 for a wrong, reused or replayed code or recovery code |
mfa_challenge_invalid |
401 | unknown, expired or exhausted sign-in challenge: enter the password again |
mfa_already_enrolled / mfa_not_enrolled |
409 | enrolling twice, or disabling/regenerating without a second factor |
mfa_required_by_policy |
403 | turning off a second factor that the owner's policy requires |
mfa_enrolment_required |
403 | any other route while the owner's policy requires a second factor this account has not set up |
passkeys_unavailable |
409 | any passkey route when OPENTODO_BASE_URL is not https or localhost |
passkey_challenge_invalid |
400 | passkey ceremony with an expired, reused or foreign challenge |
sso_required |
403 | password or passkey sign-in (or password registration) by a non-owner while SSO-only mode is on |
oidc_disabled |
404 | single sign-on routes while no provider is configured |
oidc_state_invalid |
400 | provider callback with a missing, forged, expired or reused state |
identity_taken |
409 | linking a provider identity that belongs to another account |
last_credential |
409 | unlinking the last way to sign in to the account |
Data, sharing and features
| Code | Status | Where |
|---|---|---|
scope_change |
400 | PATCH of a task's project_id into another sharing scope |
not_a_member |
400 | a task's assignee_id that is not a member of its project (or not you, for a personal task) |
user_not_found |
404 | adding a project member by an email that has no account |
already_member |
409 | adding an account that is already a member of the project, or accepting a project invite link as one |
owner_must_transfer |
409 | the project owner leaving the project |
not_deleted |
409 | purging a task or project that is not in the Trash |
unsupported_format |
400 | import file in no supported format |
invalid_file |
400 | import file recognised but unreadable |
private_target |
400 | webhook URL on a private, loopback or link-local network while the owner has not allowed private targets |
limit_reached |
409 | more than 20 webhooks on one account |
destination_error |
502 | POST /backups/test when the destination refuses or cannot be reached |
email_disabled |
409 | changing or rotating the email-to-task address when the operator has not configured email capture |
encryption_disabled |
404/409 | 404 for GET/DELETE /api/v1/encryption/keys without a key record; 409 for the history purge without one |
encryption_conflict |
409 | PUT /api/v1/encryption/keys with a version that is not the stored version + 1 (another device created or changed the record) |
encryption_enabled |
409 | sharing a project for the first time, or creating a CalDAV app password, while the account uses end-to-end encryption |
method_not_allowed |
405 | /mcp with a method other than POST |
not_acceptable |
406 | /mcp with an Accept header that admits only text/event-stream |
unsupported_protocol_version |
400 | /mcp with an MCP-Protocol-Version header naming an unsupported version |
In-app assistant
| Code | Status | Where |
|---|---|---|
consent_required |
403 | an assistant turn before you accepted the disclosure for the current provider |
encryption_enabled |
409 | an assistant turn or consent for an end-to-end encrypted account |
turn_in_progress |
409 | a second assistant turn while your first is still answering |
no_key |
409 | an assistant turn when the provider needs a key and none is stored |
no_own_provider |
409 | testing your own provider while your turns do not use one (choose it and save your key first) |
provider_changed |
409 | accepting a disclosure that named a provider no longer in effect |
assistant_disabled |
409 | accepting the disclosure while the assistant is off |
assistant_quota |
429 | an assistant turn at your monthly token allowance; resets_at names the reset date |
assistant_busy |
503 | 8 assistant turns already running on the server; retry after Retry-After |
private_target |
400 | the compatible endpoint's base URL resolves only to private addresses and private endpoints are not allowed |
CalDAV (/dav/) answers with WebDAV status codes and precondition elements instead of this envelope; MCP tool errors are JSON-RPC errors (see mcp.md).