Menu

Integrations and internals IntegrationsVersion 0.3.0

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/register or auth/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 send Authorization: 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:

  1. POST /api/v1/auth/login with {email, password} → the opentodo_session cookie.
  2. POST /api/v1/tokens with that cookie and {"name":"cli@<hostname>"} → {token, secret}. Send no Origin header: cookie-authenticated writes are refused when Origin is present and does not match the server, and accepted without it (browsers always send one, non-browser clients normally do not).
  3. POST /api/v1/auth/logout with the cookie, then keep only the secret and 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_at set) and the header Idempotent-Replayed: true. Nothing new is written.
  • A repeat with different content (another title, a field added or left out) answers 409 idempotency_conflict and changes nothing. Use a new key.
  • A malformed key answers 400 invalid_input naming Idempotency-Key. Unauthenticated and read-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_id after 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_id set, recurrence: null, other fields copied from the task after the rest of the PATCH is applied),
  • the task stays open and its due moves to the next occurrence (time and tz kept); the response is the task with the new due,
  • when the end condition (until or for 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.

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 — the cursor of the previous page (before is 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, openai or gemini (never compatible, and no base URL). Anthropic models must be one of anthropic_models; OpenAI and Gemini model ids are 1–100 characters of A-Z a-z 0-9 . _ : -. A key is required unless you already stored one for that provider. own_model alone 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=true or 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/mfa and auth/reauth each have their own window of 10 per minute; auth/oidc/start and auth/oidc/callback share a window of 20 per minute; failed /mcp authentications count toward the login window. Per account: invitation creation 20 per hour, project invite link creation 20 per hour, mutating /users requests 60 per hour, /me/password 10 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).

Edit this page on GitHub