Menu

Integrations and internals IntegrationsVersion 0.1.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 → API tokens or via POST /tokens, then send Authorization: Bearer ot_….

A token has a scope: full (the default) or read. 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.

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?} → 201 {user} + cookie. The first account becomes owner; afterwards an invitation secret admits one member while sign-up is closed
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/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}
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/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, or read) → 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
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
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 read-only Model Context Protocol endpoint, 404 while disabled; 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, endpoint}: whether the MCP endpoint is on and its URL
PUT /api/v1/settings/mcp owner {enabled} → {enabled, endpoint}
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 and the invitation preview 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 → Linked accounts) and returns to /settings?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?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?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

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 or read)
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
settings.changed success actor = owner (the user for encryption*) setting (audit.retention_days, audit.log_ip, webhooks_allow_private, mfa.required, mcp.enabled, caldav.enabled, 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.

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); 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.

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 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, 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); 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
insufficient_scope 403 a write route or /api/v1/sync called with a read API token
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, /mcp. 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; 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
invitation_spent 409 revoking an accepted invitation
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
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

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