You are reading documentation for the init.Tasks public API. Base URL: https://api.inittasks.com/v1 · OpenAPI: https://api.inittasks.com/openapi.json · Docs: https://devs.inittasks.com Key facts that change how code should be written against it: - Every task field is end-to-end encrypted. The server stores ciphertext and cannot read titles, notes, tags or dates. Do not expect server-side search over content, and do not design around filters the server cannot evaluate. - Errors are RFC 9457 problem+json. Branch on `type`, never on `detail`. - Writes take an `Idempotency-Key`. Retries without one can duplicate a row. - Webhook events are thin: an id and a type, never the object. Fetch it. --- page content follows --- # ============================================================ # / # ============================================================ # init.Tasks API init.Tasks is a to-do app that looks like a terminal. This is its public API: a REST surface at `https://api.inittasks.com/v1`, and an MCP server so an AI client can use the same data with the same permissions. // One thing shapes every design decision below, so it comes first. ## The server cannot read your tasks Every content field is encrypted on the user's device before it is stored. Titles, notes, tags, dates, file names, attachment text — the database holds ciphertext, and the key never reaches the server as anything the server can use on its own. When you call this API, the credential you hold carries the key, and the server decrypts *for that request only*. So: - [✓] You get plaintext. `GET /v1/todos` returns real titles. - [ ] There is no server-side search over content. `/v1/search` runs after decryption, inside your request. - [ ] There is no `todo.completed` webhook. `status` is encrypted; the server cannot tell a completion from a rename. - [ ] An administrator cannot read a user's tasks, and neither can a database backup. If a feature would require the server to understand content while nobody is asking for it, that feature does not exist here. That is the trade, and it is deliberate. ## Two kinds of credential | | for | how | |---|---|---| | **Personal access token** | your own scripts, a cron job, one-off automation | create one in the app, paste it into your code | | **OAuth 2.1** | an app other people will use | register, send the user to approve, get a token | Both carry scopes, both are revocable, and both stop working the moment the user changes their password or their encryption key. See [authentication](/authentication). ## Start here - [Quickstart](/quickstart) — a key, a curl, your first to-do, in five minutes. - [Authentication](/authentication) — tokens, the approval flow, scopes. - [Encryption](/encryption) — what the server sees, and what a grant actually hands over. - [Reference](/reference) — every route, generated from the OpenAPI document. - [Webhooks](/webhooks) — thin events, signature verification, retries. - [MCP](/mcp) — connect Claude, ChatGPT, Cursor and friends. ## Ground rules - The base URL is `https://api.inittasks.com/v1`. JSON only. - `/oauth/*`, `/.well-known/*`, `/healthz` and `/openapi.json` sit at the host root — they are not versioned data. - `/v1` is stable once announced; breaking changes go to `/v2` and `/v1` keeps serving for at least six months. See [versioning](/versioning). - Errors are [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem+json. Branch on `type`, never on `detail`. - `GET https://api.inittasks.com/openapi.json` is the machine contract. Every response links to it. - It is free, and there is no approval process for your app. # ============================================================ # /quickstart # ============================================================ # Quickstart Five minutes, no OAuth, no registration. ## 1. Make a personal access token In init.Tasks: **Settings → account → encryption & devices → api keys → new key**. Pick the scopes you need and an expiry. You will see the key exactly once. It looks like this: ```text itk_7mQ2xLp9Va4Nk1Zr8Ts6Ye3Wu0Bd5Mg2Hj7Cq4Ln1Xv8 ``` // It carries your encryption key. Treat it like a password manager entry, not like an API key you paste into a chat. ## 2. Check it works ```bash curl -s https://api.inittasks.com/v1/me \ -H "Authorization: Bearer $INITTASKS_TOKEN" ``` ```json { "sub": "8Kd2mQ...", "scopes": ["todos:read", "todos:write"], "grant": { "id": "gr_4f2a", "kind": "pat", "clientId": null }, "webhooks": { "disabled": 0 } } ``` ## 3. Create a to-do ```bash curl -s https://api.inittasks.com/v1/todos \ -X POST \ -H "Authorization: Bearer $INITTASKS_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{"title":"buy milk","whenDate":"2026-09-16","tags":["errands"]}' ``` The title is encrypted on the way in and decrypted on the way out. `errands` is created if it does not exist — you do not have to pre-register a vocabulary. ## 4. Read it back ```bash curl -s "https://api.inittasks.com/v1/todos?limit=5" \ -H "Authorization: Bearer $INITTASKS_TOKEN" ``` ```json { "data": [{ "id": "8E4F2A1B", "title": "buy milk", "whenDate": "2026-09-16", "tags": ["errands"] }], "next_cursor": null } ``` ## Retrying safely This is the part most integrations get wrong, so it is worth doing properly from the start. A write that times out may still have succeeded. Retrying it without an `Idempotency-Key` creates a second to-do. With one, the server replays the original response instead. ```bash #!/usr/bin/env bash # One key per logical operation — generated ONCE, reused by every retry. KEY=$(uuidgen) for attempt in 1 2 3 4 5; do response=$(curl -s -w '\n%{http_code}' https://api.inittasks.com/v1/todos \ -X POST \ -H "Authorization: Bearer $INITTASKS_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $KEY" \ -d '{"title":"buy milk"}') status=$(printf '%s' "$response" | tail -n1) body=$(printf '%s' "$response" | sed '$d') case "$status" in 2*) printf '%s\n' "$body"; exit 0 ;; 429|5*) # Back off. `Retry-After` is on 429s; this doubles from one second. sleep $(( 2 ** (attempt - 1) )) ;; *) printf 'giving up: %s\n' "$body" >&2; exit 1 ;; esac done ``` Three rules behind it: 1. **Generate the key once, outside the loop.** A key generated per attempt defeats the whole mechanism — that is the actual bug this example exists to prevent. 2. **Retry only on 429 and 5xx.** A `validation_failed` will fail identically forever. 3. **Reuse the key with the *same body*.** A different body under the same key is a [conflict](/errors/conflict), on purpose: it means two different operations were given the same name. ## Next - Scopes and OAuth: [authentication](/authentication) - What the server can and cannot see: [encryption](/encryption) - Every route: [reference](/reference) # ============================================================ # /authentication # ============================================================ # Authentication Every request carries a bearer token: ```http Authorization: Bearer itk_7mQ2xLp9Va4Nk1Zr8Ts6Ye3Wu0Bd5Mg2Hj7Cq4Ln1Xv8 ``` There is no API key, no client-credentials flow, and no way to act without a user. That is a consequence of the encryption design: a token that no user authorised would have no key, and a request with no key can read nothing. See [encryption](/encryption). ## Personal access tokens For your own scripts. Created in the app — **Settings → account → encryption & devices → api keys** — because only a device that already holds the encryption key can hand it to a new credential. - Shown once. The server keeps no copy it can show you again. - Expire after 30, 90, 180 or 365 days. Expiry is mandatory. - At most 25 active at a time, at most 10 created per day. - A token cannot create another token. Listing and revoking need `sessions:read` / `sessions:write`; creating is first-party only. ## OAuth 2.1 For an app other people will use. Registration is open — [RFC 7591](https://www.rfc-editor.org/rfc/rfc7591) dynamic client registration, no review process, no waiting. ```bash curl -s https://api.inittasks.com/oauth/register \ -X POST -H "Content-Type: application/json" \ -d '{ "client_name": "Standup Bot", "redirect_uris": ["https://standup.example/callback"], "token_endpoint_auth_method": "none" }' ``` Then the standard flow, with PKCE (S256) required and `resource` **mandatory**: ```text GET https://api.inittasks.com/oauth/authorize ?response_type=code &client_id= &redirect_uri=https://standup.example/callback &scope=todos:read todos:write &resource=https://api.inittasks.com &code_challenge= &code_challenge_method=S256 &state= ``` // `resource` ([RFC 8707](https://www.rfc-editor.org/rfc/rfc8707)) is not optional. A token is minted for one audience and refused at the other, so a token for the REST API cannot be replayed at the MCP server. ### Approval happens on the user's device There is no password form in your browser window. The user sees a nine-digit code, opens init.Tasks on a device they already trust, and approves there. What they are shown, in this order: **the callback address first**, then your app's name as a subtitle. Names are self-declared and unverified — the host is the fact. There is no directory, no badge, and no review; identity is the address you registered. They can also grant **less** than you asked for. If you request `todos:read todos:write` and they grant read, your token comes back with `scope=todos:read`. Read the granted scope from the token response; do not assume you got what you asked for. ### Refresh tokens rotate Every refresh returns a new refresh token and invalidates the old one, with a 30-second grace window for a response you did not receive. > ⚠ Reuse of an already-rotated refresh token **revokes the whole grant**. That is the point: replay means the token leaked. Store the newest one atomically, and never run two refreshes concurrently. A grant needs re-approval after 365 days. ## Scopes | scope | grants | |---|---| | `todos:read` `todos:write` | to-dos | | `containers:read` `containers:write` | areas, projects, subprojects | | `inbox:read` `inbox:write` | capture | | `tags:read` `tags:write` | tags | | `attachments:read` `attachments:write` | attachments, including file bytes | | `filters:read` `filters:write` | saved filters | | `settings:read` `settings:write` | week start | | `sessions:read` `sessions:write` | keys, grants and sessions | | `webhooks:write` | webhooks — including reading them | | `tasks:read` `tasks:write` | umbrellas over the content scopes | Two rules that surprise people, both deliberate: - **The umbrellas do not imply `sessions:*` or `webhooks:write`.** `tasks:write` is broad access to content. Enumerating a user's credentials, or wiring a channel that sends their activity to a URL, is a different kind of power and is asked for by name. - **`webhooks:write` covers its own reads.** Listing subscriptions reveals every endpoint the user has connected, so there is no read-only half. A write scope implies its read scope: `todos:write` gives you `todos:read`. ## Everything ends at once Any of these disconnects **every** token, key, session and grant: - [✓] the user changes their password - [✓] the user changes their encryption key - [✓] the user revokes your app in Settings There is no partial state to recover from and no way to detect it in advance. Handle [unauthorized](/errors/unauthorized) and [grant_revoked](/errors/grant_revoked) by sending the user through approval again. # ============================================================ # /encryption # ============================================================ # Encryption This page is the one to read if you are deciding whether to build on this API. It explains what is genuinely private, what is not, and which of your instincts about a REST API will be wrong here. ## What the server stores Ciphertext, per field. Each encrypted value is an envelope: ```text v1.. ``` AES-256-GCM, with the row's identity bound into the authenticated data — so a ciphertext cannot be lifted from one row and pasted into another. Encrypted: titles, subtitles, notes, tag names, dates, status, priority, colours, sort keys, inbox and attachment text, filter criteria, recurrence rules, file names and file bytes. **Not** encrypted, because the database needs them to function: row ids, the owning user, created/updated timestamps, and the parent-child links between rows. // So the shape of a user's data — how many projects, how often they write — is visible to the server. The content is not. ## Tags are blind ids A tag's row id is `HMAC(key, normalised name)`. The server can tell that two to-dos share a tag; it cannot tell you what the tag is called. Names are resolved client-side from the tag table. One consequence reaches the API: **a tag cannot be renamed.** The id IS the name, so renaming would change the identity and silently detach every to-do. `PATCH /tags/{id}` accepts a colour and refuses a name. The honest operation is create-new, delete-old, and you can see it happen. ## What a grant hands over When a user approves your app, the grant carries the encryption key, wrapped for that grant alone. The server unwraps it only while serving your request, and holds it for no longer. This is the honest description: **an app you approve can read everything in the scopes it was granted.** End-to-end encryption protects the user from the server and from anyone who steals the database. It does not protect them from an app they deliberately connected — nothing could, since the app has to see plaintext to be useful. What it does mean: - [✓] revoking your app ends your access immediately, with no server-side cleanup to wait for - [✓] a read-only grant is enforced by the server, not by your good behaviour - [✓] a database backup, an administrator, and a subpoena all get ciphertext - [ ] the user cannot un-share history with an app that already read it ## Read-only really is read-only A grant scoped to reads gets a key that opens data and a server that refuses writes. Both halves. Attempting a write returns [forbidden_scope](/errors/forbidden_scope) — it does not silently no-op, and it does not half-apply. ## When the user changes their key The user can rotate their encryption key (after losing a device, say). Everything re-encrypts under a new generation, and **every existing credential stops working** — yours included. There is no migration path for a token: it carried the old key. You will see [grant_revoked](/errors/grant_revoked). Send the user through approval again. Do not retry, and do not treat it as a transient failure: it will never recover on its own. ## What this means for your design - **Do not plan server-side search or filtering over content.** `/search` and `/filters/{id}/todos` decrypt inside your request and filter there. They work; they are just not a database index, and they cost more than you would expect from a normal API. - **Do not expect content in webhooks.** Events carry an id and a type. Fetch the object. - **Do not cache plaintext anywhere the user did not agree to.** You are holding decrypted personal data under a grant that can be revoked; keeping a copy after revocation is exactly what the user thought they had prevented. - **Expect `account_too_large` on very large accounts.** Decryption happens per request, so there is a ceiling (20,000 rows per table). See [account_too_large](/errors/account_too_large). # ============================================================ # /webhooks # ============================================================ # Webhooks Subscribe to changes in a user's tasks. Events are **thin**: an id and a type, never the object. ```json { "id": "evt_aCyx2ukBPQnIfeQ7", "type": "todo.updated", "occurredAt": "2026-09-15T18:02:11.000Z", "data": { "object": "todo", "id": "8E4F2A1B", "sub": "8Kd2mQ..." } } ``` // Thin for two reasons, and either alone would be enough. The server cannot read the row — it is ciphertext — so a fat event could only ever carry an envelope no subscriber can open. And a webhook endpoint is a URL: sending content there makes the content's blast radius your log retention. ## Subscribe ```bash curl -s https://api.inittasks.com/v1/webhooks \ -X POST \ -H "Authorization: Bearer $INITTASKS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "url": "https://standup.example/hooks/inittasks", "events": ["todo.created", "todo.updated", "todo.deleted"], "description": "standup bot" }' ``` ```json { "id": "wh_9Kd2mQx4", "url": "https://standup.example/hooks/inittasks", "events": ["todo.created", "todo.updated", "todo.deleted"], "active": true, "secret": "whsec_R2x9...", "createdAt": "2026-09-15T18:00:00.000Z" } ``` The `secret` is shown **once**. Store it now; the server keeps no copy it can show you again. Lost it? [Rotate](/reference/webhooks). All of `/webhooks` needs `webhooks:write`, the reads included — listing subscriptions enumerates every channel out of the account. ## Events `todo.*`, `container.*`, `inbox.*`, `tag.*`, `filter.*`, `attachment.*` — each with `.created`, `.updated`, `.deleted` — plus `webhook.ping` and `webhook.disabled`. ### There is no `todo.completed` `status` is encrypted. The server genuinely cannot tell a completion from a rename — both are `todo.updated`. You do the comparison: ```js // ⛔ This event does not exist. Do not wait for it. // if (event.type === 'todo.completed') { ... } // ✅ Compare against what you last saw. const seen = new Map(); // id -> status async function onEvent(event) { if (event.type !== 'todo.updated') return; const res = await fetch(`https://api.inittasks.com/v1/todos/${event.data.id}`, { headers: { Authorization: `Bearer ${token}` }, }); if (res.status === 404) { seen.delete(event.data.id); return; } const todo = await res.json(); const before = seen.get(todo.id); seen.set(todo.id, todo.status); if (before !== 'completed' && todo.status === 'completed') { await celebrate(todo); } } ``` Keep the last-seen status keyed by id. That is the whole trick, and it is the same shape you would need anyway for a server that could send completions, because events can arrive out of order. ## Verify the signature Every delivery carries: ```http X-InitTasks-Event-Id: evt_aCyx2ukBPQnIfeQ7 X-InitTasks-Event: todo.updated X-InitTasks-Signature: t=1789459331,v1=8f3c...,v1=1b90... ``` The signed string is `"."`, HMAC-SHA256, hex. Verify against the **raw bytes** — parsing and re-serialising the JSON changes them. ```js import { createHmac, timingSafeEqual } from 'node:crypto'; const TOLERANCE_SECONDS = 300; export function verify(rawBody, header, secret) { const t = Number(/t=(\d+)/.exec(header)?.[1]); if (!Number.isFinite(t)) return false; // ⛔ Reject an old timestamp. Without this, a captured delivery replays // forever — the signature stays valid because the body never changes. if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false; const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex'); const expectedBuf = Buffer.from(expected, 'utf8'); // There may be TWO v1 values during a secret rotation. Accept either. return [...header.matchAll(/v1=([0-9a-f]+)/g)].some((m) => { const got = Buffer.from(m[1], 'utf8'); return got.length === expectedBuf.length && timingSafeEqual(got, expectedBuf); }); } ``` With Express, take the raw body before the JSON parser: ```js app.post('/hooks/inittasks', express.raw({ type: 'application/json' }), (req, res) => { if (!verify(req.body, req.get('X-InitTasks-Signature') ?? '', SECRET)) { return res.sendStatus(404); } res.sendStatus(200); // ACK fast, work afterwards queue.push(JSON.parse(req.body.toString('utf8'))); }); ``` ### Rotating a secret `POST /webhooks/{id}/rotate-secret` returns a new secret and keeps the old one valid for **24 hours**. During the overlap, deliveries are signed with both and the header carries two `v1=` values — so you can deploy the new secret without dropping the events that land mid-deploy. That is why the verifier loops over every `v1=` instead of reading the first. ## Delivery - HTTPS only. The hostname must resolve to a public address, checked at send time; no redirects are followed; 10-second timeout. - `2xx` is delivered. Anything else is retried at **+1 min, +5 min, +30 min, +2 h, +8 h**. - After **three days** of continuous failure the subscription is set `active: false`, and a `webhook.disabled` event goes to your *other* subscriptions. - If you have no other subscription, `GET /me` reports `webhooks.disabled` — that is the only channel left, so check it. - The last 100 deliveries per subscription are kept for 30 days: `GET /webhooks/{id}/deliveries`, and `POST .../redeliver` to try one again. ## Guarantees, stated honestly > **Inbound is best-effort and at-most-once.** The change feed this API listens to makes one attempt per event and never retries, and its queue is not crash-safe. A dropped event is not detectable from either side. So: **webhooks are a latency optimisation, not a source of truth.** Reconcile on a timer: ```bash curl -s "https://api.inittasks.com/v1/todos?updated_since=2026-09-15T17:00:00Z" \ -H "Authorization: Bearer $INITTASKS_TOKEN" ``` Keep a high-water mark, poll every few minutes, and treat webhooks as the thing that usually makes the poll find nothing. Any integration that assumes every event arrives will eventually be quietly wrong. # ============================================================ # /mcp # ============================================================ # MCP init.Tasks speaks [MCP](https://modelcontextprotocol.io) at `https://mcp.inittasks.com/mcp`, so an AI client can work with a user's tasks directly — the same data, the same scopes, the same revocation. // It is the same server and the same encryption as the REST API. An MCP client is just another OAuth app. ## Connect Most clients take a URL. In Claude Code: ```bash claude mcp add --transport http init-tasks https://mcp.inittasks.com/mcp ``` In a client that reads a JSON config: ```json { "mcpServers": { "init-tasks": { "type": "http", "url": "https://mcp.inittasks.com/mcp" } } } ``` Then the client opens the approval flow. **You approve on your phone**, not in the client's browser window: init.Tasks shows a nine-digit code, you open the app on a device you already trust, and confirm there. No password is typed into anything but the app itself. ## Read-only by default A connection asks for read access unless you grant more. 12 of the 39 tools work read-only; the rest need write access, and the server refuses them without it — the refusal is enforced server-side, not left to the client to respect. Revoke any time in **Settings → account → encryption & devices → connected apps**. A password change or an encryption-key change disconnects everything at once. ## The tools | tool | read-only | what it does | |---|---|---| | `archive_inbox_item` | [ ] | Archive an inbox item (hides it from the inbox). | | `capture_inbox` | [ ] | Quick-add capture into the inbox (v1.2). Kind (text/url) is auto-detected. For a text capture the trailing date phrase is parsed via the canonical quick-add grammar ("call maria tomorrow", "renew passport 7 oct") — the remainder becomes the item text and the parsed date is PERSISTED on the item, ready to ride onto the to-do when it is later moved into a project. A url fires the title fetcher best-effort. | | `complete_todo` | [ ] | Mark a to-do done (idempotent). A recurring active occurrence runs the spec/60 transition — a done copy is created and the active row advances to its next occurrence. Resolve by id or exact title. | | `create_area` | [ ] | Create a top-level area. | | `create_filter` | [ ] | Create a saved filter (v1.4). `criteria` is { v:1, all:[ …criterion ] }, AND-composed. A criterion is one of: {kind:"tag",name} · {kind:"priority",min:"low"\|"medium"\|"high"} (matches priority ≥ min) · {kind:"deadline",withinDays:0..365} (deadline ≤ today+N, overdue included) · {kind:"deadline",overdue:true} · {kind:"scheduled",state:"dated"\|"anytime"\|"someday"} · {kind:"where",containerId} (the to-do’s ancestor chain contains that area/project/subproject id). An unknown/newer-version criterion is rejected here (only stored filters may be inert). Returns the filter and its match count. | | `create_note` | [ ] | v1.6: create a standalone NOTE in the inbox — content the user means to KEEP, as opposed to a scrap awaiting triage. Stored as tasks_inbox.kind='note' with the body in `text` and an optional heading in `title` (spec/10 "Notes"), so it lives alongside attachments and inherits the whole inbox pipeline. ⛔ A note is NOT a to-do: it has no done state, never appears in a date view, and counts only toward `inbox`. Kind is never auto-detected for notes — use capture_inbox for ordinary text/url captures. | | `create_project` | [ ] | Create a project. area is resolved by name or id; omit for an area-less project. color = #RRGGBB. priority = low\|medium\|high (display-only ! mark, v1.2). | | `create_subproject` | [ ] | Create a subproject inside a project (resolved by name or id). priority = low\|medium\|high (display-only ! mark, v1.2). | | `create_todo` | [ ] | Create a to-do. Parent (project or subproject) is resolved by exact name (case-insensitive) or id; omit for a standalone to-do. when/deadline accept natural language ("tomorrow", "fri", "7 oct") or yyyy-MM-dd. Pass `repeat` to make it recurring (spec/60 — clears deadline, stamps whenDate/anchor). A repeat rule takes an optional `mode` ("schedule", the default fixed grid, or "afterCompletion", where the next date counts from when you complete it — afterCompletion carries ONLY freq+interval), an optional `until` (strict yyyy-MM-dd, the last date an occurrence may fall on, inclusive) and/or an optional `count` (occurrences remaining incl. the active one, 1…999, decremented on each completion). Setting a repeat with no when in schedule mode seeds the first occurrence date; in afterCompletion mode it seeds when=today. `reminder` is a local `yyyy-MM-ddTHH:mm`. | | `delete_container` | [ ] | Alias of trash_container — moves a container subtree to trash (v1.2; recoverable). For an irreversible hard delete use permanent_delete. | | `delete_filter` | [ ] | Delete a saved filter (v1.4) — HARD delete, no trash, no confirm (a config row like a tag; cheap to recreate, and the to-dos it matched are untouched). Resolve by id or exact name. | | `delete_todo` | [ ] | Alias of trash_todo — moves a to-do to trash (v1.2; recoverable). For an irreversible hard delete use permanent_delete. | | `empty_trash` | [ ] | Permanently delete EVERY trashed root and its subtree (v1.2) — the irreversible hard-delete cascade for the whole trash. Destructive — requires confirm: true. | | `get_anytime` | [✓] | Anytime (v1.2, narrowed in v1.6): open to-dos with no scheduled date (whenDate null) that ARE filed under a project/subproject. A deadline may exist and the row still lists here (deadline alone never schedules a to-do). someday to-dos are excluded. v1.6: an UNFILED to-do (no parent AND no date) is NOT here — it belongs to the inbox; see get_inbox. | | `get_filter` | [✓] | A saved filter’s VIEW (v1.4): the matched to-dos with their count, in the anytime view’s order (tree-position-then-sortKey, standalone last). Universe = open + someday visible to-dos matching ALL criteria; done never matches (the logbook owns history). An INERT filter (criteria from a newer version) returns count 0, no matches, and a muted note. Resolve by id or exact name. | | `get_inbox` | [✓] | The inbox = the triage queue (v1.6): UNFILED to-dos first (open, no parent AND no date), then open capture rows in capture order. `openCount` is the nav count over both. A to-do leaves the inbox the moment it gets a parent or a date — there is no inbox flag to set. | | `get_logbook` | [✓] | The completed-history logbook (v1.4): every done to-do (non-trashed, with a completedAt), grouped by the LOCAL calendar day it was completed, newest day first, and completedAt-descending within a day. Each day carries its date, a label, a count and the to-dos. Recurring done copies appear like any done to-do; inbox items never appear. Unbounded — no nav count. | | `get_next_7_days` | [✓] | The next 7 days (a rolling window: today through today+6): overdue plus one bucket per day, each with its to-dos. Always 7 buckets — it is not bounded by the calendar week and does not read the weekStart setting. | | `get_overview` | [✓] | The full task tree: areas → projects → subprojects with recursive open-todo counts and progress, their open to-dos (with dates/tags), standalone to-dos, and an inbox summary. The one call to understand everything. | | `get_someday` | [✓] | Someday (v1.2 phase 2 — GROUPED): parked to-dos and parked inbox items. Parking is timeline-free (it strips dates), so no date meta renders here. Top to bottom: `groups` — every project/subproject that has DIRECT parked to-dos, in overview-tree order, each `{ container, todos }`; `standalone` — standalone parked to-dos (no project) then parked TEXT inbox captures; `attachments` — parked url/image/file inbox items, last. `containerSomeday` is the raw `{containerId: [todoIds]}` map that also feeds each container’s own collapsible someday section. | | `get_today` | [✓] | Today view: overdue to-dos (missed deadlines), today’s to-dos, and what was completed today. | | `get_upcoming` | [✓] | Upcoming: open to-dos dated after today, grouped by their effective date ascending. | | `inbox_to_someday` | [ ] | Park an inbox item in someday (stays an inbox item; v1.2 parking strips its captured date — contrast archive, which keeps it). | | `list_filters` | [✓] | The saved filters (v1.4), in nav order (sortKey). Each row reports its parsed criteria, a live match COUNT (open + someday visible to-dos matching all criteria; done never matches), a derived priorityMin when the filter leads with a priority criterion, and inert=true for a filter whose criteria this client can’t parse (an unknown/newer-version shape — it matches nothing and renders a muted note). The three defaults low/medium/high are ordinary rows. | | `list_trash` | [✓] | The trash view (v1.2): trashed ROOTS only (a row trashed individually inside a container that was later trashed is hidden here but still purges by its own stamp), newest first. Each entry reports its kind, label, when it was trashed, and — for containers — the subtree it will take with it on permanent delete. | | `move_container` | [ ] | Move an area/project/subproject to a new parent, and/or reorder it among its new siblings. parent = the destination container (name or id), or the literal "top" for the top level. ⭐ A move can RE-KIND the row, exactly as dragging does: move a project onto a project and it becomes a SUBPROJECT of it; move a subproject to "top" and it is PROMOTED to a project. The hierarchy is closed (area → project → subproject), so illegal moves are refused with the reason — nothing may nest under a subproject, an area is always top level, and nothing may move into its own contents. position = "start" \| "end" (default) \| the id of a sibling to place it after. | | `move_inbox_item` | [ ] | Sort an inbox item onto a target (resolved by name or id). A text item MOVES into a container (becomes a to-do, carrying its captured date); a url item ATTACHES to a container or to-do. Pass target "anytime" to turn a text item into a standalone, unscheduled to-do (v1.2). | | `move_to_anytime` | [ ] | Move an existing to-do to anytime (v1.2): detach it from its project and clear its scheduled date, keeping its deadline and tags. It becomes a standalone, unscheduled to-do that lists in the anytime view. Resolve by id or exact title. | | `move_todo` | [ ] | Reparent and/or reorder a to-do. position = "start" \| "end" \| a sibling to-do id to place it after. Omit `parent` (or pass "none") to reorder within the STANDALONE band — the project-less to-dos that list after the projects on the overview. | | `permanent_delete` | [ ] | Permanently delete a trashed row by id (v1.2) — the irreversible hard-delete cascade (a container takes its whole subtree: child containers, to-dos and attachments). Destructive — requires confirm: true. Pass an id from list_trash. | | `restore_trashed` | [ ] | Restore a trashed root by id (v1.2): clears its trash stamp; the row — and, for a container, its whole subtree — returns to its views untouched. | | `search` | [✓] | Search everything (v1.2): case-insensitive substring match across to-do titles/subtitles/notes/tags, container names/subtitles/notes, inbox text/urls/titles, and attachment titles/urls/text. Excludes trashed rows and archived/trashed subtrees; someday rows are included. Each hit reports which field matched and its direct-parent crumb. | | `set_week_start` | [ ] | Set the first day of the week (shared setting): 1 = Monday (the default), 7 = Sunday, null = Monday. v1.5: this is presentation-only — it orders the weekday columns of the date-picker calendar in the apps. It changes NO view, no bucket and no count (next-7-days is always today…today+6), and weekly recurrence stays Monday-anchored. | | `trash_container` | [ ] | Move an area/project/subproject (and, by exclusion, its whole subtree) to trash (v1.2). Only this root row is stamped; restore_trashed brings the subtree back untouched. Auto-purged after 30 days. No confirm — trash is the safety net. | | `trash_todo` | [ ] | Move a to-do to trash (v1.2). Recoverable via restore_trashed; auto-purged after 30 days. No confirm — trash is the safety net. Resolve by id or exact title. | | `uncomplete_todo` | [ ] | Reopen a done to-do (clears completedAt). Resolve by id or exact title. | | `update_container` | [ ] | Edit an area/project/subproject. Pass only fields to change. notes = the detail-pane note (v1.2), or null to clear. deadline accepts a date phrase / yyyy-MM-dd, or null to clear (areas have no deadline). priority = low\|medium\|high (display-only ! mark, v1.2; projects/subprojects only — areas never carry it), or null to clear. subtitle/name/color (#RRGGBB) editable too. | | `update_filter` | [ ] | Edit a saved filter (v1.4): pass `name` and/or `criteria` (the same { v:1, all:[…] } shape as create_filter — replaces the whole criteria set). An unknown/newer-version criterion is rejected. Resolve by id or exact name. Returns the filter and its new match count. | | `update_todo` | [ ] | Edit a to-do. Pass only fields to change. status = open\|someday\|done. Parking (status→someday) is timeline-free for a PLAIN to-do: it STRIPS whenDate + deadline to null but KEEPS the parent (a project idea parks with its project); a RECURRING to-do instead keeps its whenDate + rule DORMANT while parked (spec/60). Un-park a plain parked to-do by setting any when or deadline (assigning only a parent leaves it parked); un-park a recurring parked to-do with status=open, which re-aligns its dormant whenDate to today-or-later (schedule mode) or leaves it (afterCompletion). parent = project/subproject name or id, or null to make standalone. when/deadline = a date phrase / yyyy-MM-dd, or null to clear. priority = low\|medium\|high (display-only ! mark), or null to clear. tags replaces the tag set. repeat = a rule to make it recurring (clears deadline, re-anchors to its whenDate; takes optional mode "schedule"\|"afterCompletion" + optional until yyyy-MM-dd + optional count 1…999), or null to clear the rule. reminder = a local `yyyy-MM-ddTHH:mm`, or null to clear. | ## What an AI client cannot do - **Read anything outside the granted scopes.** Scopes are enforced per request. - **Write on a read-only grant.** The server refuses. - **See your data after you revoke.** The grant carried the key; revoking ends it. - **Search your notes on the server.** Everything is decrypted per request, for that request only — see [encryption](/encryption). ## Building an MCP client The server implements the standard discovery documents — [RFC 9728](https://www.rfc-editor.org/rfc/rfc9728) protected-resource metadata and [RFC 8414](https://www.rfc-editor.org/rfc/rfc8414) authorization-server metadata — so a compliant client needs no special-casing. One thing is stricter than the baseline: `resource` ([RFC 8707](https://www.rfc-editor.org/rfc/rfc8707)) is **mandatory** on `/authorize` and `/token`. A token minted for the MCP server is refused at the REST API and vice versa. Send `https://mcp.inittasks.com/mcp` as the resource when connecting here. See [authentication](/authentication) for the rest of the flow — it is the same one. # ============================================================ # /reference # ============================================================ # API reference Base URL **`https://api.inittasks.com/v1`**. Every route below is generated from [openapi.json](https://api.inittasks.com/openapi.json) at build time — the same document the server serves, rendered from the schemas that validate the requests. If a page here disagrees with the server, the page is a build artefact of an older deploy; the document wins. ## Resources - [/attachments](/reference/attachments) — 6 routes - [/containers](/reference/containers) — 8 routes - [/filters](/reference/filters) — 6 routes - [/grants](/reference/grants) — 2 routes - [/inbox](/reference/inbox) — 6 routes - [/keys](/reference/keys) — 3 routes - [/me](/reference/me) — 1 routes - [/search](/reference/search) — 1 routes - [/sessions](/reference/sessions) — 2 routes - [/settings](/reference/settings) — 2 routes - [/tags](/reference/tags) — 4 routes - [/todos](/reference/todos) — 12 routes - [/trash](/reference/trash) — 2 routes - [/views](/reference/views) — 6 routes - [/webhooks](/reference/webhooks) — 9 routes ## Conventions - Every response carries `X-Request-Id`, and `Link: <…/openapi.json>; rel="service-desc"`. - `/oauth/*`, `/.well-known/*`, `/healthz` and `/openapi.json` sit at the HOST root (https://api.inittasks.com), not under `/v1` — they are not versioned data. - Lists are cursor-paged: `?limit=` (default 50, max 200) and `?cursor=`. Follow `next_cursor` until it is absent; do not construct cursors. - `?updated_since=` takes an RFC 3339 instant and filters on the server-side update time. - Writes accept `Idempotency-Key`. The same key with the same body replays the original response for 24 hours; with a different body it is a `conflict`. # ============================================================ # /reference/attachments # ============================================================ # /attachments 6 routes. ## GET /v1/attachments List attachments **Scopes** — `attachments:read` **Query** | parameter | type | | limit | |---|---|---|---| | `limit` | integer | optional | max 200 | | `cursor` | string | optional | max 4096 chars | | `updated_since` | string | optional | max 24 chars | | `parent_id` | string | optional | max 64 chars | **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## POST /v1/attachments Create a url/text/note attachment **Scopes** — `attachments:write` **Request body** | field | type | | limit | |---|---|---|---| | `parentType` | `todo` · `container` · `inbox` | required | — | | `parentId` | string | required | max 64 chars, min 1 | | `kind` | `url` · `text` · `note` | required | — | | `url` | string | optional | max 2048 chars, — | | `text` | string | optional | max 2048 chars, — | | `title` | string | optional | max 512 chars, — | | `id` | string | optional | max 64 chars | **Errors** — [validation_failed](/errors/validation_failed) · [duplicate_id](/errors/duplicate_id) · [conflict](/errors/conflict) · [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## GET /v1/attachments/{id} One attachment **Scopes** — `attachments:read` **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## GET /v1/attachments/{id}/file The decrypted bytes of a file attachment **Scopes** — `attachments:read` **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## PATCH /v1/attachments/{id} Change an attachment **Scopes** — `attachments:write` **Request body** | field | type | | limit | |---|---|---|---| | `title` | string | optional | max 512 chars, — | | `url` | string | optional | max 2048 chars, — | | `text` | string | optional | max 2048 chars, — | **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## DELETE /v1/attachments/{id} Delete an attachment **Scopes** — `attachments:write` **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) # ============================================================ # /reference/containers # ============================================================ # /containers 8 routes. ## GET /v1/containers List containers **Scopes** — `containers:read` **Query** | parameter | type | | limit | |---|---|---|---| | `limit` | integer | optional | max 200 | | `cursor` | string | optional | max 4096 chars | | `updated_since` | string | optional | max 24 chars | | `kind` | `area` · `project` · `subproject` | optional | — | | `parent_id` | string | optional | max 64 chars | | `status` | `active` · `archived` | optional | — | **Errors** — [account_too_large](/errors/account_too_large) · [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## POST /v1/containers Create a container **Scopes** — `containers:write` **Request body** | field | type | | limit | |---|---|---|---| | `kind` | `area` · `project` · `subproject` | required | — | | `name` | string | required | max 256 chars, min 1 | | `parentId` | string | optional | max 64 chars, — | | `subtitle` | string | optional | max 512 chars, — | | `notes` | string | optional | max 100000 chars, — | | `symbol` | string | optional | max 64 chars, — | | `colorHex` | string | optional | max 16 chars, — | | `deadline` | string | optional | max 10 chars, — | | `priority` | `low` · `medium` · `high` | optional | —, — | | `id` | string | optional | max 64 chars | **Errors** — [validation_failed](/errors/validation_failed) · [duplicate_id](/errors/duplicate_id) · [conflict](/errors/conflict) · [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## GET /v1/containers/{id} One container **Scopes** — `containers:read` **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## PATCH /v1/containers/{id} Change a container (not parentId/kind — use /move) **Scopes** — `containers:write` **Request body** | field | type | | limit | |---|---|---|---| | `name` | string | optional | max 256 chars, min 1 | | `subtitle` | string | optional | max 512 chars, — | | `notes` | string | optional | max 100000 chars, — | | `symbol` | string | optional | max 64 chars, — | | `colorHex` | string | optional | max 16 chars, — | | `deadline` | string | optional | max 10 chars, — | | `priority` | `low` · `medium` · `high` | optional | —, — | | `status` | `active` · `archived` | optional | — | **Errors** — [validation_failed](/errors/validation_failed) · [duplicate_id](/errors/duplicate_id) · [conflict](/errors/conflict) · [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## POST /v1/containers/{id}/move Re-parent and reorder **Scopes** — `containers:write` **Request body** | field | type | | limit | |---|---|---|---| | `parentId` | string | required | max 64 chars, — | | `kind` | `area` · `project` · `subproject` | optional | — | | `after` | string | optional | max 64 chars | | `before` | string | optional | max 64 chars | **Errors** — [validation_failed](/errors/validation_failed) · [duplicate_id](/errors/duplicate_id) · [conflict](/errors/conflict) · [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## POST /v1/containers/{id}/trash Trash a container **Scopes** — `containers:write` **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## POST /v1/containers/{id}/restore Restore a container **Scopes** — `containers:write` **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## DELETE /v1/containers/{id} Delete permanently (cascades) **Scopes** — `containers:write` **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) # ============================================================ # /reference/filters # ============================================================ # /filters 6 routes. ## GET /v1/filters List filters **Scopes** — `filters:read` **Query** | parameter | type | | limit | |---|---|---|---| | `limit` | integer | optional | max 200 | | `cursor` | string | optional | max 4096 chars | | `updated_since` | string | optional | max 24 chars | **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## POST /v1/filters Create a filter (criteria uses tag names) **Scopes** — `filters:write` **Request body** | field | type | | limit | |---|---|---|---| | `name` | string | required | max 256 chars, min 1 | | `criteria` | string | required | max 10000 chars, min 1 | | `symbol` | string | optional | max 64 chars, — | | `id` | string | optional | max 64 chars | **Errors** — [validation_failed](/errors/validation_failed) · [duplicate_id](/errors/duplicate_id) · [conflict](/errors/conflict) · [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## GET /v1/filters/{id} One filter **Scopes** — `filters:read` **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## GET /v1/filters/{id}/todos Evaluate a filter with the engine **Scopes** — `filters:read`, `todos:read` **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## PATCH /v1/filters/{id} Change a filter **Scopes** — `filters:write` **Request body** | field | type | | limit | |---|---|---|---| | `name` | string | optional | max 256 chars, min 1 | | `criteria` | string | optional | max 10000 chars, min 1 | | `symbol` | string | optional | max 64 chars, — | **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## DELETE /v1/filters/{id} Delete a filter **Scopes** — `filters:write` **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) # ============================================================ # /reference/grants # ============================================================ # /grants 2 routes. ## GET /v1/grants List connected apps **Scopes** — `sessions:read` **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## DELETE /v1/grants/{id} Disconnect an app **Scopes** — `sessions:write` **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) # ============================================================ # /reference/inbox # ============================================================ # /inbox 6 routes. ## GET /v1/inbox List inbox items **Scopes** — `inbox:read` **Query** | parameter | type | | limit | |---|---|---|---| | `limit` | integer | optional | max 200 | | `cursor` | string | optional | max 4096 chars | | `updated_since` | string | optional | max 24 chars | | `status` | `open` · `archived` · `someday` · `moved` | optional | — | | `kind` | `text` · `url` · `file` · `image` | optional | — | **Errors** — [account_too_large](/errors/account_too_large) · [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## POST /v1/inbox Capture text or a URL (the API fetches the title) **Scopes** — `inbox:write` **Request body** | field | type | | limit | |---|---|---|---| | `kind` | `text` · `url` | required | — | | `text` | string | optional | max 2048 chars | | `url` | string | optional | max 2048 chars | | `whenDate` | string | optional | max 10 chars, — | | `id` | string | optional | max 64 chars | **Errors** — [validation_failed](/errors/validation_failed) · [duplicate_id](/errors/duplicate_id) · [conflict](/errors/conflict) · [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## GET /v1/inbox/{id} One inbox item **Scopes** — `inbox:read` **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## POST /v1/inbox/{id}/move Sort an item onto a container **Scopes** — `inbox:write`, `todos:write` **Request body** | field | type | | limit | |---|---|---|---| | `parentId` | string | optional | max 64 chars, — | **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## POST /v1/inbox/{id}/archive Archive an item **Scopes** — `inbox:write` **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## POST /v1/inbox/{id}/someday Park an item **Scopes** — `inbox:write` **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) # ============================================================ # /reference/keys # ============================================================ # /keys 3 routes. ## GET /v1/keys List personal access tokens **Scopes** — `sessions:read` **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## POST /v1/keys Create a personal access token (first-party only) **Scopes** — any valid credential **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## DELETE /v1/keys/{id} Revoke a personal access token **Scopes** — `sessions:write` **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) # ============================================================ # /reference/me # ============================================================ # /me 1 route. ## GET /v1/me The calling credential and its subject id **Scopes** — any valid credential **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) # ============================================================ # /reference/search # ============================================================ # /search 1 route. ## GET /v1/search Search everything **Scopes** — `todos:read`, `containers:read` **Query** | parameter | type | | limit | |---|---|---|---| | `limit` | integer | optional | max 200 | | `cursor` | string | optional | max 4096 chars | | `updated_since` | string | optional | max 24 chars | | `q` | string | required | max 512 chars, min 1 | **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) # ============================================================ # /reference/sessions # ============================================================ # /sessions 2 routes. ## GET /v1/sessions List sessions **Scopes** — `sessions:read` **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## DELETE /v1/sessions/{id} End a session **Scopes** — `sessions:write` **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) # ============================================================ # /reference/settings # ============================================================ # /settings 2 routes. ## GET /v1/settings Read settings (platform blobs are never exposed) **Scopes** — `settings:read` **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## PATCH /v1/settings Change settings **Scopes** — `settings:write` **Request body** | field | type | | limit | |---|---|---|---| | `weekStart` | string or string | required | —, —, — | **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) # ============================================================ # /reference/tags # ============================================================ # /tags 4 routes. ## GET /v1/tags List tags **Scopes** — `tags:read` **Query** | parameter | type | | limit | |---|---|---|---| | `limit` | integer | optional | max 200 | | `cursor` | string | optional | max 4096 chars | | `updated_since` | string | optional | max 24 chars | **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## POST /v1/tags Create a tag, or return the existing one **Scopes** — `tags:write` **Request body** | field | type | | limit | |---|---|---|---| | `name` | string | required | max 64 chars, min 1 | | `colorHex` | string | optional | max 16 chars, — | **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## PATCH /v1/tags/{id} Change a tag colour (renaming is refused) **Scopes** — `tags:write` **Request body** | field | type | | limit | |---|---|---|---| | `colorHex` | string | required | max 16 chars, — | **Errors** — [validation_failed](/errors/validation_failed) · [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## DELETE /v1/tags/{id} Delete a tag and remove it from every to-do **Scopes** — `tags:write` **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) # ============================================================ # /reference/todos # ============================================================ # /todos 12 routes. ## GET /v1/todos List to-dos **Scopes** — `todos:read` **Query** | parameter | type | | limit | |---|---|---|---| | `limit` | integer | optional | max 200 | | `cursor` | string | optional | max 4096 chars | | `updated_since` | string | optional | max 24 chars | | `parent_id` | string | optional | max 64 chars | | `status` | `open` · `someday` · `done` | optional | — | | `tag` | string or array of string | optional | max 64 chars, min 1, — | | `when_from` | string | optional | max 10 chars | | `when_to` | string | optional | max 10 chars | | `deadline_from` | string | optional | max 10 chars | | `deadline_to` | string | optional | max 10 chars | | `include_trashed` | `true` · `false` | optional | — | **Errors** — [account_too_large](/errors/account_too_large) · [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## POST /v1/todos Create a to-do (unknown tags are created) **Scopes** — `todos:write` **Request body** | field | type | | limit | |---|---|---|---| | `title` | string | required | max 512 chars, min 1 | | `parentId` | string | optional | max 64 chars, — | | `subtitle` | string | optional | max 512 chars, — | | `notes` | string | optional | max 100000 chars, — | | `tags` | array of string | optional | max 64 items | | `whenDate` | string | optional | max 10 chars, — | | `deadline` | string | optional | max 10 chars, — | | `status` | `open` · `someday` · `done` | optional | — | | `priority` | `low` · `medium` · `high` | optional | —, — | | `colorHex` | string | optional | max 16 chars, — | | `colorStyle` | string | optional | max 8 chars, — | | `reminderTime` | string | optional | max 16 chars, — | | `recurrenceRule` | string | optional | max 512 chars, — | | `id` | string | optional | max 64 chars | **Errors** — [validation_failed](/errors/validation_failed) · [duplicate_id](/errors/duplicate_id) · [conflict](/errors/conflict) · [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## GET /v1/todos/{id} One to-do **Scopes** — `todos:read` **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## PATCH /v1/todos/{id} Change a to-do (not parentId/status — use the actions) **Scopes** — `todos:write` **Request body** | field | type | | limit | |---|---|---|---| | `title` | string | optional | max 512 chars, min 1 | | `subtitle` | string | optional | max 512 chars, — | | `notes` | string | optional | max 100000 chars, — | | `tags` | array of string | optional | max 64 items, — | | `whenDate` | string | optional | max 10 chars, — | | `deadline` | string | optional | max 10 chars, — | | `priority` | `low` · `medium` · `high` | optional | —, — | | `colorHex` | string | optional | max 16 chars, — | | `colorStyle` | string | optional | max 8 chars, — | | `reminderTime` | string | optional | max 16 chars, — | | `recurrenceRule` | string | optional | max 512 chars, — | **Errors** — [validation_failed](/errors/validation_failed) · [duplicate_id](/errors/duplicate_id) · [conflict](/errors/conflict) · [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## POST /v1/todos/{id}/complete Complete (spawns the next occurrence when recurring) **Scopes** — `todos:write` **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## POST /v1/todos/{id}/uncomplete Un-complete **Scopes** — `todos:write` **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## POST /v1/todos/{id}/someday Park **Scopes** — `todos:write` **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## POST /v1/todos/{id}/anytime Detach and unschedule **Scopes** — `todos:write` **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## POST /v1/todos/{id}/move Re-parent and reorder **Scopes** — `todos:write` **Request body** | field | type | | limit | |---|---|---|---| | `parentId` | string | required | max 64 chars, — | | `after` | string | optional | max 64 chars | | `before` | string | optional | max 64 chars | **Errors** — [validation_failed](/errors/validation_failed) · [duplicate_id](/errors/duplicate_id) · [conflict](/errors/conflict) · [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## POST /v1/todos/{id}/trash Trash a to-do **Scopes** — `todos:write` **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## POST /v1/todos/{id}/restore Restore a to-do **Scopes** — `todos:write` **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## DELETE /v1/todos/{id} Delete permanently **Scopes** — `todos:write` **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) # ============================================================ # /reference/trash # ============================================================ # /trash 2 routes. ## GET /v1/trash List trashed roots **Scopes** — `todos:read` **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## DELETE /v1/trash Empty the trash **Scopes** — `todos:write` **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) # ============================================================ # /reference/views # ============================================================ # /views 6 routes. ## GET /v1/views/overview The overview view **Scopes** — `todos:read`, `containers:read` **Query** | parameter | type | | limit | |---|---|---|---| | `today` | string | optional | max 10 chars | **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## GET /v1/views/today The today view **Scopes** — `todos:read`, `containers:read` **Query** | parameter | type | | limit | |---|---|---|---| | `today` | string | optional | max 10 chars | **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## GET /v1/views/upcoming The upcoming view **Scopes** — `todos:read`, `containers:read` **Query** | parameter | type | | limit | |---|---|---|---| | `today` | string | optional | max 10 chars | **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## GET /v1/views/anytime The anytime view **Scopes** — `todos:read`, `containers:read` **Query** | parameter | type | | limit | |---|---|---|---| | `today` | string | optional | max 10 chars | **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## GET /v1/views/someday The someday view **Scopes** — `todos:read`, `containers:read` **Query** | parameter | type | | limit | |---|---|---|---| | `today` | string | optional | max 10 chars | **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## GET /v1/views/logbook The logbook view **Scopes** — `todos:read`, `containers:read` **Query** | parameter | type | | limit | |---|---|---|---| | `today` | string | optional | max 10 chars | | `from` | string | optional | max 10 chars | | `to` | string | optional | max 10 chars | **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) # ============================================================ # /reference/webhooks # ============================================================ # /webhooks 9 routes. ## POST /v1/webhooks Subscribe to events (the secret is shown once) **Scopes** — `webhooks:write` **Request body** | field | type | | limit | |---|---|---|---| | `url` | string | required | max 2048 chars, min 1 | | `events` | array of `todo.created` · `todo.updated` · `todo.deleted` · `container.created` · `container.updated` · `container.deleted` · `inbox.created` · `inbox.updated` · `inbox.deleted` · `tag.created` · `tag.updated` · `tag.deleted` · `filter.created` · `filter.updated` · `filter.deleted` · `attachment.created` · `attachment.updated` · `attachment.deleted` · `webhook.ping` · `webhook.disabled` | required | max 20 items | | `description` | string | optional | max 256 chars, — | **Errors** — [validation_failed](/errors/validation_failed) · [rate_limited](/errors/rate_limited) · [unauthorized](/errors/unauthorized) ## GET /v1/webhooks List subscriptions **Scopes** — `webhooks:write` **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## GET /v1/webhooks/{id} One subscription **Scopes** — `webhooks:write` **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## PATCH /v1/webhooks/{id} Change url, events or active **Scopes** — `webhooks:write` **Request body** | field | type | | limit | |---|---|---|---| | `url` | string | optional | max 2048 chars, min 1 | | `events` | array of `todo.created` · `todo.updated` · `todo.deleted` · `container.created` · `container.updated` · `container.deleted` · `inbox.created` · `inbox.updated` · `inbox.deleted` · `tag.created` · `tag.updated` · `tag.deleted` · `filter.created` · `filter.updated` · `filter.deleted` · `attachment.created` · `attachment.updated` · `attachment.deleted` · `webhook.ping` · `webhook.disabled` | optional | max 20 items | | `active` | boolean | optional | — | | `description` | string | optional | max 256 chars, — | **Errors** — [validation_failed](/errors/validation_failed) · [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## DELETE /v1/webhooks/{id} Delete a subscription **Scopes** — `webhooks:write` **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## POST /v1/webhooks/{id}/rotate-secret New secret; the old one verifies for 24 h **Scopes** — `webhooks:write` **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## POST /v1/webhooks/{id}/ping Deliver a webhook.ping event **Scopes** — `webhooks:write` **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## GET /v1/webhooks/{id}/deliveries The last 100 delivery attempts **Scopes** — `webhooks:write` **Query** | parameter | type | | limit | |---|---|---|---| | `limit` | integer | optional | max 200 | **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) ## POST /v1/webhooks/{id}/deliveries/{did}/redeliver Queue the same event again **Scopes** — `webhooks:write` **Errors** — [unauthorized](/errors/unauthorized) · [rate_limited](/errors/rate_limited) # ============================================================ # /errors/unauthorized # ============================================================ # unauthorized **HTTP 401 — Unauthorized** ## When you see it The `Authorization` header is missing, malformed, expired, or names a credential this server does not know. ## What to do Check the header is `Authorization: Bearer `. If the token is an OAuth access token, refresh it. The `WWW-Authenticate` header on the response points at the authorization server. ## The response Every error is [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem+json: ```json { "type": "https://devs.inittasks.com/errors/unauthorized", "title": "Unauthorized", "status": 401, "detail": "a sentence for a developer; never branch on it", "instance": "/todos/8E4F2A1B", "request_id": "3f9a1c7e-2b44-4d1e-9c30-5a7e8b2d1f60" } ``` // Branch on `type`, never on `detail` or on the HTTP status alone — several codes share a status. # ============================================================ # /errors/grant_revoked # ============================================================ # grant_revoked **HTTP 401 — Grant revoked** ## When you see it The grant behind this credential was disconnected — by the user in Settings, by a password change, or by an encryption-key change. ## What to do Ask the user to connect the app again. Refreshing will not help: the grant is gone, not expired. A key change also revokes, because the grant carried a key that no longer opens anything. ## The response Every error is [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem+json: ```json { "type": "https://devs.inittasks.com/errors/grant_revoked", "title": "Grant revoked", "status": 401, "detail": "a sentence for a developer; never branch on it", "instance": "/todos/8E4F2A1B", "request_id": "3f9a1c7e-2b44-4d1e-9c30-5a7e8b2d1f60" } ``` // Branch on `type`, never on `detail` or on the HTTP status alone — several codes share a status. # ============================================================ # /errors/grant_expired # ============================================================ # grant_expired **HTTP 401 — Grant expired** ## When you see it The grant reached its expiry. Personal access tokens expire after at most a year; OAuth grants need re-approval after 365 days. ## What to do Mint a new key, or send the user through the approval flow again. ## The response Every error is [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem+json: ```json { "type": "https://devs.inittasks.com/errors/grant_expired", "title": "Grant expired", "status": 401, "detail": "a sentence for a developer; never branch on it", "instance": "/todos/8E4F2A1B", "request_id": "3f9a1c7e-2b44-4d1e-9c30-5a7e8b2d1f60" } ``` // Branch on `type`, never on `detail` or on the HTTP status alone — several codes share a status. # ============================================================ # /errors/forbidden_scope # ============================================================ # forbidden_scope **HTTP 403 — Insufficient scope** ## When you see it The credential is valid but was not granted the scope this route needs. ## What to do Request the scope at authorization time. Note that `tasks:read`/`tasks:write` do NOT imply `sessions:*` or `webhooks:write` — those are granted explicitly. ## The response Every error is [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem+json: ```json { "type": "https://devs.inittasks.com/errors/forbidden_scope", "title": "Insufficient scope", "status": 403, "detail": "a sentence for a developer; never branch on it", "instance": "/todos/8E4F2A1B", "request_id": "3f9a1c7e-2b44-4d1e-9c30-5a7e8b2d1f60" } ``` // Branch on `type`, never on `detail` or on the HTTP status alone — several codes share a status. # ============================================================ # /errors/not_found # ============================================================ # not_found **HTTP 404 — Not found** ## When you see it No row with that id belongs to this user, or it was permanently deleted. ## What to do Treat it as gone. A trashed row is still readable under `/trash`; a deleted one is not recoverable. ## The response Every error is [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem+json: ```json { "type": "https://devs.inittasks.com/errors/not_found", "title": "Not found", "status": 404, "detail": "a sentence for a developer; never branch on it", "instance": "/todos/8E4F2A1B", "request_id": "3f9a1c7e-2b44-4d1e-9c30-5a7e8b2d1f60" } ``` // Branch on `type`, never on `detail` or on the HTTP status alone — several codes share a status. # ============================================================ # /errors/duplicate_id # ============================================================ # duplicate_id **HTTP 409 — Duplicate id** ## When you see it You supplied an `id` on create and a row with it already exists. ## What to do Supplying your own id is how you make a create idempotent — so this usually means the write already succeeded. Fetch the row and carry on. ## The response Every error is [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem+json: ```json { "type": "https://devs.inittasks.com/errors/duplicate_id", "title": "Duplicate id", "status": 409, "detail": "a sentence for a developer; never branch on it", "instance": "/todos/8E4F2A1B", "request_id": "3f9a1c7e-2b44-4d1e-9c30-5a7e8b2d1f60" } ``` // Branch on `type`, never on `detail` or on the HTTP status alone — several codes share a status. # ============================================================ # /errors/conflict # ============================================================ # conflict **HTTP 409 — Conflict** ## When you see it An `Idempotency-Key` was reused with a different body, or a request with that key is still in flight. ## What to do Use a fresh key for a genuinely new request, and the SAME key with the SAME body when retrying. ## The response Every error is [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem+json: ```json { "type": "https://devs.inittasks.com/errors/conflict", "title": "Conflict", "status": 409, "detail": "a sentence for a developer; never branch on it", "instance": "/todos/8E4F2A1B", "request_id": "3f9a1c7e-2b44-4d1e-9c30-5a7e8b2d1f60" } ``` // Branch on `type`, never on `detail` or on the HTTP status alone — several codes share a status. # ============================================================ # /errors/payload_too_large # ============================================================ # payload_too_large **HTTP 413 — Payload too large** ## When you see it The request body is over 1 MB. ## What to do Split the write. Note `notes` alone may be up to 100,000 characters, so this is usually a batch that grew. ## The response Every error is [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem+json: ```json { "type": "https://devs.inittasks.com/errors/payload_too_large", "title": "Payload too large", "status": 413, "detail": "a sentence for a developer; never branch on it", "instance": "/todos/8E4F2A1B", "request_id": "3f9a1c7e-2b44-4d1e-9c30-5a7e8b2d1f60" } ``` // Branch on `type`, never on `detail` or on the HTTP status alone — several codes share a status. # ============================================================ # /errors/unsupported_media_type # ============================================================ # unsupported_media_type **HTTP 415 — Unsupported media type** ## When you see it The request has no `Content-Type: application/json`. ## What to do Set the header. The API speaks JSON only; there is no form or multipart endpoint in v1. ## The response Every error is [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem+json: ```json { "type": "https://devs.inittasks.com/errors/unsupported_media_type", "title": "Unsupported media type", "status": 415, "detail": "a sentence for a developer; never branch on it", "instance": "/todos/8E4F2A1B", "request_id": "3f9a1c7e-2b44-4d1e-9c30-5a7e8b2d1f60" } ``` // Branch on `type`, never on `detail` or on the HTTP status alone — several codes share a status. # ============================================================ # /errors/validation_failed # ============================================================ # validation_failed **HTTP 422 — Validation failed** ## When you see it A field is missing, the wrong type, too long, or not recognised. Unknown fields are rejected rather than ignored. ## What to do Read the `errors` array: each entry has a JSON Pointer into your request body and a message. A rejected unknown field is usually a typo — `deadlne` for `deadline`. ## The response Every error is [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem+json: ```json { "type": "https://devs.inittasks.com/errors/validation_failed", "title": "Validation failed", "status": 422, "detail": "a sentence for a developer; never branch on it", "instance": "/todos/8E4F2A1B", "request_id": "3f9a1c7e-2b44-4d1e-9c30-5a7e8b2d1f60", "errors": [ { "pointer": "/title", "message": "must be at most 512 characters" } ] } ``` // Branch on `type`, never on `detail` or on the HTTP status alone — several codes share a status. # ============================================================ # /errors/account_too_large # ============================================================ # account_too_large **HTTP 422 — Account too large** ## When you see it The account holds more rows than the API will walk in one request (20,000 per table). ## What to do This is a ceiling on the account, not on your request. The user needs to delete or archive data; the apps keep working. ## The response Every error is [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem+json: ```json { "type": "https://devs.inittasks.com/errors/account_too_large", "title": "Account too large", "status": 422, "detail": "a sentence for a developer; never branch on it", "instance": "/todos/8E4F2A1B", "request_id": "3f9a1c7e-2b44-4d1e-9c30-5a7e8b2d1f60" } ``` // Branch on `type`, never on `detail` or on the HTTP status alone — several codes share a status. # ============================================================ # /errors/rate_limited # ============================================================ # rate_limited **HTTP 429 — Rate limited** ## When you see it A rate bucket is exhausted. ## What to do Wait `Retry-After` seconds. Watch `RateLimit-Remaining` on every response and slow down before you are cut off — the limits are documented at /rate-limits. ## The response Every error is [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem+json: ```json { "type": "https://devs.inittasks.com/errors/rate_limited", "title": "Rate limited", "status": 429, "detail": "a sentence for a developer; never branch on it", "instance": "/todos/8E4F2A1B", "request_id": "3f9a1c7e-2b44-4d1e-9c30-5a7e8b2d1f60", "retry_after": 42 } ``` // Branch on `type`, never on `detail` or on the HTTP status alone — several codes share a status. # ============================================================ # /errors/internal # ============================================================ # internal **HTTP 500 — Internal error** ## When you see it Something failed on the server that was not your request’s fault. ## What to do Retry with backoff. Quote the `request_id` from the response body if you report it — it is the only handle that ties your call to the server log, which carries no request bodies. ## The response Every error is [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem+json: ```json { "type": "https://devs.inittasks.com/errors/internal", "title": "Internal error", "status": 500, "detail": "a sentence for a developer; never branch on it", "instance": "/todos/8E4F2A1B", "request_id": "3f9a1c7e-2b44-4d1e-9c30-5a7e8b2d1f60" } ``` // Branch on `type`, never on `detail` or on the HTTP status alone — several codes share a status. # ============================================================ # /rate-limits # ============================================================ # Rate limits Every response carries the current state of the bucket it charged: ```http RateLimit-Limit: 600 RateLimit-Remaining: 587 RateLimit-Reset: 1789459620 ``` On a refusal you also get `Retry-After`, in seconds, and a [rate_limited](/errors/rate_limited) problem body. // The headers are on EVERY response, not just refusals — a client that can see its remaining budget can slow down before it is cut off. One that only learns at the 429 has no way to behave well. ## The buckets | bucket | limit | |---|---| | per credential, all requests | 600 / 5 min | | per credential, writes | 120 / 5 min | | `POST /oauth/token` | 60 / min / IP | | `POST /oauth/register` | 10 / hour / IP | | approval code lookup | 10 / min / user, 30 / min / IP | | `POST /keys` | 10 / day / user | | `POST /webhooks` | 20 / hour / user | | per IP, at the edge | 20 req/s, burst 100 | Windows are fixed, not sliding. A burst that straddles a boundary can spend two windows' worth; that is a deliberate trade for counters that survive a restart. ## Behaving well 1. **Read `RateLimit-Remaining` and slow down.** Do not wait for the 429. 2. **On 429, sleep `Retry-After`.** Not a guess, not immediately. 3. **Back off exponentially on 5xx**, with jitter, so a restart does not gather every client into one thundering retry. 4. **Use `updated_since` rather than re-reading everything.** A full re-read on a timer is the usual reason an integration hits the limit at all. 5. **Prefer a webhook to a tight poll** — but keep a slow reconciliation poll, because [inbound delivery is best-effort](/webhooks). ## If you need more The limits are tunable and currently generous relative to what real integrations use. If you are hitting one legitimately, say what you are building. # ============================================================ # /versioning # ============================================================ # Versioning ## What is stable `/v1` is stable once announced. Within it: - **Additive changes ship without a bump** — a new field on a response, a new optional request field, a new route, a new error code. They appear in the [changelog](/changelog). - **New enum values are announced 30 days ahead.** Your client must tolerate an unknown value rather than crashing on it. This is the single most common way an integration breaks on an API that never made a breaking change. - **Breaking changes go to `/v2`**, and `/v1` keeps serving for at least six months. ## How notice is delivered Three channels, because each one reaches a different reader: | channel | reaches | |---|---| | `Deprecation` and `Sunset` response headers, plus `Link: rel="deprecation"` | code that logs them | | the [Atom feed](/changelog.xml) | the person who has to act | | email to a registered client's `contacts` | best-effort, if you gave one | // Subscribe to the feed. The headers reach your logs; the feed reaches you. [RFC 9745](https://www.rfc-editor.org/rfc/rfc9745) for `Deprecation`, [RFC 8594](https://www.rfc-editor.org/rfc/rfc8594) for `Sunset`. ## The machine contract ```bash curl -s https://api.inittasks.com/openapi.json ``` OpenAPI 3.1, generated from the same schemas that validate the requests — not a hand-maintained document beside them. Every response carries `Link: <…/openapi.json>; rel="service-desc"`. If the reference pages here and the document disagree, **the document wins**: these pages are a build artefact, and a deploy can be newer than the docs you are reading. ## Writing a client that survives - [✓] Ignore unknown response fields; never validate a response with a closed schema. - [✓] Treat an unknown enum value as "something new", not as an error. - [✓] Branch on the problem `type` URI, never on `detail` or on the status alone — several codes share a status. - [✓] Follow `next_cursor`; never construct or parse a cursor. - [✓] Send `Idempotency-Key` on every write, so a retry after a timeout is safe. # ============================================================ # /changelog # ============================================================ # Changelog Subscribe to [changelog.xml](/changelog.xml). Breaking changes go to `/v2` and `/v1` keeps serving for at least six months with `Deprecation` and `Sunset` headers — see [versioning](/versioning). ## 2026-09-15 — Webhooks Subscribe to thin events over `/webhooks`: create, rotate a secret with a 24-hour overlap, ping, read the last 100 deliveries, redeliver one. Events carry an id and a type, never the object — the server cannot read your content. There is deliberately no `todo.completed`: `status` is encrypted, so the server cannot tell a completion from a rename. Fetch the object and decide. Inbound is best-effort and at-most-once. Reconcile with `updated_since` polling. ## 2026-09-15 — v1 The first public version: containers, to-dos, inbox, tags, attachments, filters, views, search, trash and settings, plus `/keys`, `/grants` and `/sessions`. Personal access tokens for your own scripts; OAuth 2.1 with PKCE and native approval for apps. Fine-grained scopes. RFC 9457 problem+json on every error. Cursor pagination in a stable order. `Idempotency-Key` on writes.