7 routes
/tags
7 routes.
Free-form labels on to-dos, attachments and notes. A tag is identified by its NAME, and the row id is derived from it, so creating one twice is not an error, and a RENAME moves the tag to the new name’s id: every to-do, filter, attachment and note that used it is rewritten and the old id forwards (GET /tags/{oldId} answers 308).
- GET /v1/tags
- POST /v1/tags
- GET /v1/tags/{id}
- PATCH /v1/tags/{id}
- POST /v1/tags/{id}/trash
- POST /v1/tags/{id}/restore
- DELETE /v1/tags/{id}
GET /v1/tags
List tags
The live tags. A tag in the trash is left out unless include_trashed=true (then it carries its trashedAt). A forwarding id a rename left behind is never listed.
Scopes: tags: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. |
include_trashed |
true, false |
optional |
Response
- 200 TagList. One page of tags.
Errors: grant_expired * grant_revoked * unauthorized * forbidden_scope * account_too_large * validation_failed * rate_limited * internal
POST /v1/tags
Create a tag, or return the existing one
A tag is identified by its NAME, and its row id is a hash of it, so creating one twice is not a conflict. The name is normalised to lower case with runs of whitespace collapsed before the comparison. A tag of that name in the TRASH is that row: it comes back out of the trash (its colour and place kept, a colorHex sent now wins), and every to-do still carrying the name shows it again.
Scopes: tags:write Idempotent. Send Idempotency-Key and a retry replays the first answer instead of writing twice.
Request body
| field | type | notes | |
|---|---|---|---|
name |
string | required | max 64 chars, min 1 |
colorHex |
string | optional | or null, 6-digit hex, with or without the #, max 16 chars. A 6-digit hex colour, with or without the leading #: #3A7BD5 or 3A7BD5. Three-digit shorthand is refused. |
Response
- 201 Tag. A new tag was created.
- 200 Tag. A tag with that name already existed (or was in the trash and is restored); this is that row.
Errors: grant_expired * grant_revoked * unauthorized * forbidden_scope * conflict * payload_too_large * unsupported_media_type * account_too_large * validation_failed * rate_limited * internal
GET /v1/tags/{id}
Get a tag (a renamed tag’s old id redirects to it)
A live tag answers 200. A tag id that a RENAME left behind answers 308 Permanent Redirect with Location: /v1/tags/{newId} and the tag it now resolves to as the body, so a client that does not follow redirects still gets the current tag. An old id whose chain cannot be resolved answers 404.
Scopes: tags:read
Response
- 200 Tag. The tag.
- 308 Tag. This id belonged to a tag that was renamed (or merged).
Locationis the tag it resolves to, and the body is that tag.
Errors: grant_expired * grant_revoked * unauthorized * forbidden_scope * not_found * account_too_large * rate_limited * internal
PATCH /v1/tags/{id}
Rename, merge or recolour a tag
name RENAMES the tag. A tag’s id is derived from its name, so a rename MOVES the tag to the new name’s id: every to-do, filter, attachment and note that used it is rewritten, the response is the tag at its NEW id, and the old id keeps working as a forwarding pointer (GET /tags/{oldId} answers 308). A change of case only (Bug → bug) is the same tag and changes nothing. Renaming onto a name another tag already has is a MERGE: it answers 409 tag_exists unless the body says "merge": true; a merge keeps the existing tag’s colour, and to-dos that carried both keep one. colorHex alone recolours. after (a tag id, or null for first) or before reorders the tag list. An id that a rename left behind answers 404 here; only GET follows it.
Scopes: tags:write
Request body
| field | type | notes | |
|---|---|---|---|
name |
string | optional | max 64 chars, min 1 |
merge |
boolean | optional | |
colorHex |
string | optional | or null, which clears it, 6-digit hex, with or without the #, max 16 chars. A 6-digit hex colour, with or without the leading #: #3A7BD5 or 3A7BD5. Three-digit shorthand is refused. |
after |
string | optional | or null, which clears it, max 64 chars. Reorder the tag list: right after this tag, or null for first. |
before |
string | optional | max 64 chars. Reorder the tag list: right before this tag. |
Response
- 200 Tag. The tag after the change: after a rename, at its new id.
Errors: grant_expired * grant_revoked * unauthorized * forbidden_scope * not_found * tag_exists * payload_too_large * unsupported_media_type * account_too_large * validation_failed * rate_limited * internal
POST /v1/tags/{id}/trash
Move a tag to the trash
One write: the tag is stamped trashedAt. Nothing else changes: its to-dos, notes and attachments KEEP its name and filters keep their criteria, so /restore brings everything back. While it is in the trash the apps hide it from every list, picker and pill, GET /tags leaves it out, and a filter criterion naming it matches nothing. The trash deletes it for good after 30 days (or DELETE /trash). Already in the trash: no change.
Scopes: tags:write
Response
- 200 Tag. The tag, now stamped
trashedAt.
Errors: grant_expired * grant_revoked * unauthorized * forbidden_scope * not_found * payload_too_large * unsupported_media_type * account_too_large * rate_limited * internal
POST /v1/tags/{id}/restore
Restore a tag from the trash
Scopes: tags:write
Response
- 200 Tag. The tag, with
trashedAtcleared.
Errors: grant_expired * grant_revoked * unauthorized * forbidden_scope * not_found * payload_too_large * unsupported_media_type * account_too_large * rate_limited * internal
DELETE /v1/tags/{id}
Delete a tag and remove it from every to-do, attachment and note
Irreversible, live or in the trash (the apps’ delete moves a tag to the trash: POST /tags/{id}/trash). Removes the tag from every to-do, attachment and note, and retires every old id that forwarded to it. A filter never changes what it matches because of a delete: a criterion that names the tag is removed only where that is exactly equivalent, and otherwise stays, reading back as the deleted tag’s raw tg_ id (a filter that required the tag now matches nothing). An id that a rename left behind answers 404.
Scopes: tags:write
Response
- 204 No body. Deleted, and removed from every to-do that carried it.
Errors: grant_expired * grant_revoked * unauthorized * forbidden_scope * not_found * account_too_large * rate_limited * internal