init.Tasks openapi.json inittasks.com

what the server sees, and what a grant hands over

Encryption

The server stores ciphertext. Below: which fields, what a grant hands to your app, and which of your instincts about a REST API will be wrong here.

What the server stores

Each encrypted value is an envelope:

text
v1.<base64url nonce>.<base64url ciphertext+tag>

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: every column of a user's task data, that is the to-dos, containers, headings, notes, note folders, inbox, tags, attachments, filters, settings and the rows that link them. Titles, subtitles, descriptions, note bodies and their version history, tag names, dates and times, reminders, subtasks, status, priority, colours, sort keys, inbox and attachment text, filter criteria, recurrence rules, file names, file sizes and file bytes, and the links between rows: which project a to-do is in, which places a note has, which to-do a note is linked to.

Not encrypted in those tables, because the database needs them to function: row ids, the owning user, created and updated timestamps, and two counters the sync uses (a revision and the key generation). A to-do's tags are stored as blind ids, described below.

Outside the task data, the server keeps in the clear what it has to act on without a key: each key's and connected app's scopes, expiry, last use and the app's callback host (a key's name is encrypted), webhook subscriptions (the URL and the events), when each device was enrolled and last seen (its name and platform are encrypted), and, for the ten minutes an "approve this device" request lives, the new device's name and platform.

The shape of a user's data is therefore visible to the server: how many rows of each kind they keep, and how often they write. What the rows say, and how they connect, 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 renamed tag gets a new id. The id is derived from the name, so a rename moves the tag to the id the new name derives to, rewrites every to-do, filter, attachment and note that used it, and leaves the old id forwarding to the new one. The forwarding pointer is encrypted like every other column, so the server holds no readable record of a rename. GET /v1/tags/{oldId} answers 308 to the tag's current id. See tags.

A link is named by its page's title. The native apps read the page themselves. A browser cannot read another site's page, so the web app asks POST /v1/link-title with the link and gets back { "title", "host" }. The server fetches the page once and keeps nothing: no row, no cache, and no log line with the link or the title. The app then stores the title, encrypted, like any other field. The server never writes a title into anyone's data.

The fetch goes through the same guard as webhook delivery: public addresses only, every redirect checked again (at most 5), 8 seconds, 256 KB, text/html only. A page that cannot be read answers title: null, and the app names the link by its address. It answers only an app session. An access key or a connected app is refused; to capture a link with its title, use POST /v1/inbox.

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 to serve you and keeps nothing on disk: a REST request holds the key for that request, a hosted MCP connection holds it while the connection is open and is closed after 30 minutes unused. So that a burst of requests decrypts the account once rather than once per request, the decrypted rows stay in the server's memory for a few seconds after your last request (never more than 30), and are dropped at once when the user's data changes.

So, plainly: an app the user approves 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 chose to connect, and nothing could, since the app has to see plaintext to be useful.

What it does mean:

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. A write attempt returns forbidden_scope. It does not silently no-op, and it does not half-apply.

When the user changes their key

A user can rotate their encryption key, after losing a device for example. Everything re-encrypts under a new generation, and every existing credential stops working. Yours included. A token cannot be migrated, because it carried the old key.

You will see grant_revoked. Send the user through approval again. Do not retry and do not treat it as transient, because it will never recover on its own.

What this means for your design