10 routes
/attachments
10 routes.
Links, text and files. One attachment can be in SEVERAL places at once, any mix of to-dos, containers (an area, a project or a subproject) and notes: one stored file, never a copy. Its first place is its PRIMARY (parentType / parentId); places lists them all. Attachments carry tags. The API creates url and text attachments, uploads files and pictures (POST /attachments/upload: the file as the raw body up to 100 MB, or base64 in JSON up to about 3 MB), and reads the decrypted bytes of any file at GET /attachments/{id}/file. An image or file added in an app before its upload finished reads pending: true until that device uploads it. Deleting an attachment deletes its stored file.
- GET /v1/attachments
- POST /v1/attachments
- GET /v1/attachments/{id}
- GET /v1/attachments/{id}/file
- PATCH /v1/attachments/{id}
- DELETE /v1/attachments/{id}
- POST /v1/attachments/{id}/places
- DELETE /v1/attachments/{id}/places/{parentId}
- POST /v1/attachments/{id}/reorder
- POST /v1/attachments/upload
GET /v1/attachments
List attachments
Every attachment once, in (sortKey, id) order. ?parent_id= lists the attachments IN one place (a to-do, a container or a note), whether it is their primary place or not, in that place’s own order. ?tag= keeps the ones carrying a tag (repeat it for several).
Scopes: attachments:read
Query
| parameter | type | notes | |
|---|---|---|---|
limit |
integer | optional | min 1, max 200 |
cursor |
string | optional | max 4096 chars |
updated_since |
string (RFC 3339) | optional | max 24 chars. An RFC 3339 instant, e.g. 2026-09-16T08:30:00.000Z. |
parent_id |
string | optional | max 64 chars. Only the attachments IN this place (a to-do, container or note), primary or not, in THAT place’s order (each place keeps its own). |
tag |
string or array of string | optional | max 64 chars, min 1. Only attachments carrying this tag (by name). Repeat it to require several. |
Response
- 200 AttachmentList. One page of attachments.
Errors: grant_expired * grant_revoked * unauthorized * forbidden_scope * account_too_large * validation_failed * rate_limited * internal
POST /v1/attachments
Create a url or text attachment
url and text here. A file or a picture is uploaded with POST /attachments/upload. note was an attachment kind once and is not one any more: a note is a row of its own, created with POST /notes. The new attachment goes at the end of its parent’s list. tags are names; a new name creates the tag.
Scopes: attachments:write Idempotent. Send Idempotency-Key and a retry replays the first answer instead of writing twice.
Request body
| field | type | notes | |
|---|---|---|---|
parentType |
todo, container, note |
required | What the attachment hangs off: a to-do, a container (an area, a project or a subproject), or a note (the note’s own attachments). |
parentId |
string | required | max 64 chars, min 1. The to-do, container or note. It must exist and not be trashed, or the create is validation_failed at /parentId. |
kind |
url, text |
required | |
url |
string (uri) | optional | or null, max 2048 chars, min 1. An absolute http:// or https:// URL. Other schemes (javascript:, file:, data:) are refused. |
text |
string | optional | or null, max 2048 chars |
title |
string | optional | or null, max 512 chars |
id |
string (UPPERCASE UUID) | optional | max 64 chars. An uppercase UUID, all 36 characters, hex digits in capitals: 8E4F2A1B-7C3D-4E5F-9A0B-1C2D3E4F5A6B. Supplying one is how a create is made idempotent; a repeat is 409 duplicate_id. Lower case is refused, and so is a short id. |
tags |
array of string | optional | max 64 items |
Response
- 201 Attachment. The attachment as stored.
Errors: grant_expired * grant_revoked * unauthorized * forbidden_scope * conflict * duplicate_id * payload_too_large * unsupported_media_type * account_too_large * validation_failed * rate_limited * internal
GET /v1/attachments/{id}
One attachment
Scopes: attachments:read
Response
- 200 Attachment. The attachment.
Errors: grant_expired * grant_revoked * unauthorized * forbidden_scope * not_found * account_too_large * rate_limited * internal
GET /v1/attachments/{id}/file
The decrypted bytes of a file attachment
The one place the server hands out plaintext bytes. The blob is decrypted server-side on your behalf, because a REST caller holds no key. A revoked grant therefore stops being able to read it, which is exactly why the wrapped file key is never returned alongside. 404 when the attachment has no stored file (a url or text row; a link’s stored site icon is not served here), when an image or file has not been uploaded yet (pending: true: its bytes are still on the device that added it), when the object is gone, or when it will not authenticate. To store a new file, use POST /attachments/upload.
Scopes: attachments:read
Response
- 200 application/octet-stream. The decrypted bytes. ⚠ The
Content-Typeis the attachment’s ownmime, whatever that is (image/png,application/pdf, …), andapplication/octet-streamwhen the row does not carry one. It is served as a download, never inline. CarriesContent-Type,Content-Disposition,Content-Length,X-Content-Type-Options.
Errors: grant_expired * grant_revoked * unauthorized * forbidden_scope * not_found * account_too_large * rate_limited * internal
PATCH /v1/attachments/{id}
Change an attachment
The content fields: title, url, text, and tags (the whole set, by name). kind is fixed at creation. Places change through /attachments/{id}/places; the stored order in one place through /attachments/{id}/reorder.
Scopes: attachments:write
Request body
| field | type | notes | |
|---|---|---|---|
title |
string | optional | or null, which clears it, max 512 chars |
url |
string (uri) | optional | max 2048 chars, min 1. An absolute http:// or https:// URL. Other schemes (javascript:, file:, data:) are refused. |
text |
string | optional | or null, which clears it, max 2048 chars |
tags |
array of string | optional | or null, which clears it, max 64 items |
Response
- 200 Attachment. The attachment after the change.
Errors: grant_expired * grant_revoked * unauthorized * forbidden_scope * not_found * payload_too_large * unsupported_media_type * account_too_large * validation_failed * rate_limited * internal
DELETE /v1/attachments/{id}
Delete an attachment
Irreversible, and from EVERY place it is in. The stored file goes with the row: an image or file attachment’s encrypted bytes and thumbnail, or a link’s stored site icon, are deleted from storage too. To take it out of one place only, use DELETE /attachments/{id}/places/{parentId}.
Scopes: attachments:write
Response
- 204 No body. Deleted, with anything it stored.
Errors: grant_expired * grant_revoked * unauthorized * forbidden_scope * not_found * account_too_large * rate_limited * internal
POST /v1/attachments/{id}/places
Attach it to another place too
The apps’ attach to…: the SAME attachment (one stored file, never a copy) also shows in that to-do, container or note, at the end of its list. A place it already has changes nothing and answers 200.
Scopes: attachments:write Idempotent. Send Idempotency-Key and a retry replays the first answer instead of writing twice.
Request body
| field | type | notes | |
|---|---|---|---|
parentType |
todo, container, note |
required | What the new place is: a to-do, a container (an area, a project or a subproject) or a note. |
parentId |
string | required | max 64 chars, min 1. It must exist and not be trashed. |
id |
string (UPPERCASE UUID) | optional | max 64 chars. An id for the new place row (an uppercase UUID); omit it and the server issues one. |
Response
- 201 Attachment. The attachment with its new place.
- 200 Attachment. It was already there; nothing changed.
Errors: grant_expired * grant_revoked * unauthorized * forbidden_scope * not_found * conflict * duplicate_id * payload_too_large * unsupported_media_type * account_too_large * validation_failed * rate_limited * internal
DELETE /v1/attachments/{id}/places/{parentId}
Take it out of one place
The apps’ detach. Another place: it leaves that place only. Its PRIMARY place while it has others: the oldest other place becomes the primary. Its LAST place: it becomes an INBOX item holding the same file, link or text (its tags stay behind), and the attachment row goes; that case needs inbox:write too. An image or file that has not been uploaded yet cannot leave its last place from here (conflict): its bytes are on the device that added it. parentId is the to-do, container or note.
Scopes: attachments:write
Response
- 200 AttachmentPlaceRemoved. What happened: still attached elsewhere, or now in the inbox.
Errors: grant_expired * grant_revoked * unauthorized * forbidden_scope * not_found * conflict * account_too_large * rate_limited * internal
POST /v1/attachments/{id}/reorder
Reorder it within one place
Each place keeps its own order, so this moves it in parentId’s list only (the apps’ move up / move down). Name a neighbour with after / before, or send step.
Scopes: attachments:write
Request body
| field | type | notes | |
|---|---|---|---|
parentId |
string | required | max 64 chars, min 1. The place whose list to reorder (one of the attachment’s places). Each place keeps its own order. |
after |
string | optional | or null, max 64 chars. Right after this attachment of the same place, or null for first. |
before |
string | optional | max 64 chars. Right before this attachment of the same place. |
step |
up, down |
optional | One step up or down the list (the apps’ move up / move down). Not with after or before. |
Response
- 200 Attachment. The attachment, with that place’s new
sortKey.
Errors: grant_expired * grant_revoked * unauthorized * forbidden_scope * not_found * payload_too_large * unsupported_media_type * account_too_large * validation_failed * rate_limited * internal
POST /v1/attachments/upload
Upload a file or a picture
The apps’ attach… > file and picture, in two forms. Raw: the file is the body, and parent_type, parent_id and name go in the query; up to 100 MB, the apps’ own limit. JSON: the bytes base64 in content, with the filename in name; a JSON body is capped at 4 MB, so about 3 MB of file. Either way the server seals the bytes under a fresh key for this file, stores them as the user, and creates the attachment with its file in one write. An image/* type makes a picture, anything else a file. The server makes no thumbnail: the JSON form takes thumbnail (a JPEG of at most 256 px and 64 KB) for the preview the apps show in lists. A file over 100 MB is refused before it is read, and so is one that would take the account past its 10 GB (payload_too_large).
Scopes: attachments:write Idempotent. Send Idempotency-Key and a retry replays the first answer instead of writing twice.
Request body
| field | type | notes | |
|---|---|---|---|
parentType |
todo, container, note |
required | Where it goes first: a to-do, a container (an area, a project or a subproject) or a note. |
parentId |
string | required | max 64 chars, min 1. It must exist and not be trashed. |
name |
string | required | max 512 chars, min 1. The filename, WITH its extension (invoice.pdf): it is the attachment’s title, and the extension is how the apps name its type. |
mime |
string | optional | max 128 chars. The media type (application/pdf, image/png). Omitted: taken from the extension, else application/octet-stream. An image/* type makes a picture (kind: "image"), anything else a file. |
content |
string | required | pattern ^[A-Za-z0-9+/]*={0,2}$. The file’s bytes, base64. A JSON request body is capped at 4 MB, so about 3 MB is the largest file this form takes. Send a bigger one (up to 100 MB) as the raw body instead. |
thumbnail |
string | optional | pattern ^[A-Za-z0-9+/]*={0,2}$. Optional: a preview the apps show in lists, a JPEG of at most 256 px on its longest side and 64 KB, base64. Without one a picture shows its name until it is opened. |
tags |
array of string | optional | max 64 items |
id |
string (UPPERCASE UUID) | optional | max 64 chars. An uppercase UUID, all 36 characters, hex digits in capitals: 8E4F2A1B-7C3D-4E5F-9A0B-1C2D3E4F5A6B. Supplying one is how a create is made idempotent; a repeat is 409 duplicate_id. Lower case is refused, and so is a short id. |
Or a raw body: The file itself, as the whole body, with its Content-Length. Any Content-Type but application/json: the file’s own type, or application/octet-stream. Up to 100 MB. The query below says what the file is.
Query
| parameter | type | notes | |
|---|---|---|---|
parent_type |
todo, container, note |
optional | Raw body only (required there): todo, container (an area, project or subproject) or note. |
parent_id |
string | optional | max 64 chars, min 1. Raw body only (required there): the place. It must exist and not be trashed. |
name |
string | optional | max 512 chars, min 1. Raw body only (required there): the filename, WITH its extension. |
mime |
string | optional | max 128 chars. Raw body only: the media type. Omitted: the request’s Content-Type unless that is application/octet-stream, else the extension. |
tag |
string or array of string | optional | max 64 chars, min 1, max 64 items. Raw body only: a tag to put on it, by name. Repeat it for several. |
id |
string (UPPERCASE UUID) | optional | max 64 chars. An uppercase UUID, all 36 characters, hex digits in capitals: 8E4F2A1B-7C3D-4E5F-9A0B-1C2D3E4F5A6B. Supplying one is how a create is made idempotent; a repeat is 409 duplicate_id. Lower case is refused, and so is a short id. |
Response
- 201 Attachment. The attachment, with
fileIdset: its bytes read back at/attachments/{id}/file.
Errors: grant_expired * grant_revoked * unauthorized * forbidden_scope * conflict * duplicate_id * payload_too_large * unsupported_media_type * account_too_large * validation_failed * rate_limited * internal