# /headings

7 routes.

Named groups of to-dos INSIDE a project or subproject (“before the move”, “moving day”, “after”). A heading only orders and groups its project’s list: it has no page, no dates, no notes and no trash, and its to-dos still belong to the project (`parentId`), with `headingId` naming the group. Areas have none. If a group needs its own deadline or notes, make it a subproject instead (`/headings/{id}/convert`).

- [GET /v1/headings](#get-v1-headings)
- [POST /v1/headings](#post-v1-headings)
- [GET /v1/headings/{id}](#get-v1-headings-id)
- [PATCH /v1/headings/{id}](#patch-v1-headings-id)
- [DELETE /v1/headings/{id}](#delete-v1-headings-id)
- [POST /v1/headings/{id}/convert](#post-v1-headings-id-convert)
- [POST /v1/headings/{id}/complete](#post-v1-headings-id-complete)

## GET /v1/headings

List headings

A project’s headings in their order with `?project_id=` (`projectId` also works); without it, every heading of the account, grouped by project. A heading of a project in the trash is left out (it is in the trash with its project). `openCount` is what the apps show after the name.

**Scopes**: `containers: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 |
| `project_id` | string | optional | max 64 chars |
| `projectId` | string | optional | max 64 chars |

**Response**

- **200** [HeadingList](/objects#headinglist). One page of headings.

**Errors**: [grant_expired](/errors/grant_expired) * [grant_revoked](/errors/grant_revoked) * [unauthorized](/errors/unauthorized) * [forbidden_scope](/errors/forbidden_scope) * [account_too_large](/errors/account_too_large) * [validation_failed](/errors/validation_failed) * [rate_limited](/errors/rate_limited) * [internal](/errors/internal)

## POST /v1/headings

Create a heading

`projectId` must be a live project or subproject (an area is refused). `after`: omitted puts it LAST, `null` puts it FIRST, a heading id puts it right after that heading.

**Scopes**: `containers:write`  
**Idempotent.** Send `Idempotency-Key` and a retry replays the first answer instead of writing twice.

**Request body**

| field | type | | notes |
|---|---|---|---|
| `projectId` | string | required | max 64 chars, min 1 |
| `name` | string | required | max 512 chars |
| `after` | string | optional | or null, max 64 chars |
| `id` | string (UPPERCASE UUID) | optional | max 64 chars |

**Response**

- **201** [Heading](/objects#heading). The heading as stored.

**Errors**: [grant_expired](/errors/grant_expired) * [grant_revoked](/errors/grant_revoked) * [unauthorized](/errors/unauthorized) * [forbidden_scope](/errors/forbidden_scope) * [conflict](/errors/conflict) * [duplicate_id](/errors/duplicate_id) * [payload_too_large](/errors/payload_too_large) * [unsupported_media_type](/errors/unsupported_media_type) * [account_too_large](/errors/account_too_large) * [validation_failed](/errors/validation_failed) * [rate_limited](/errors/rate_limited) * [internal](/errors/internal)

## GET /v1/headings/{id}

One heading

Readable by id even while its project is in the trash (the list leaves those out). Writes to such a heading (PATCH, DELETE, `/convert`, `/complete`) are `validation_failed` until the project is restored.

**Scopes**: `containers:read`

**Response**

- **200** [Heading](/objects#heading). The heading.

**Errors**: [grant_expired](/errors/grant_expired) * [grant_revoked](/errors/grant_revoked) * [unauthorized](/errors/unauthorized) * [forbidden_scope](/errors/forbidden_scope) * [not_found](/errors/not_found) * [account_too_large](/errors/account_too_large) * [rate_limited](/errors/rate_limited) * [internal](/errors/internal)

## PATCH /v1/headings/{id}

Rename or reorder a heading

`name` renames it. `after` reorders it among its project’s headings (`null` = first, a heading id = right after it). A heading never changes project. A heading of a project in the trash takes no writes (`validation_failed`): restore the project first.

**Scopes**: `containers:write`

**Request body**

| field | type | | notes |
|---|---|---|---|
| `name` | string | optional | max 512 chars |
| `after` | string | optional | or null, which clears it, max 64 chars |

**Response**

- **200** [Heading](/objects#heading). The heading after the change.

**Errors**: [grant_expired](/errors/grant_expired) * [grant_revoked](/errors/grant_revoked) * [unauthorized](/errors/unauthorized) * [forbidden_scope](/errors/forbidden_scope) * [not_found](/errors/not_found) * [payload_too_large](/errors/payload_too_large) * [unsupported_media_type](/errors/unsupported_media_type) * [account_too_large](/errors/account_too_large) * [validation_failed](/errors/validation_failed) * [rate_limited](/errors/rate_limited) * [internal](/errors/internal)

## DELETE /v1/headings/{id}

Delete a heading, keeping its tasks

There is one delete and the tasks always stay: every to-do under the heading gets `headingId: null` and stays in the project, in its order; then the heading row is deleted for good (a heading has no trash). Returns the cleared to-do ids. Fires `heading.deleted`, and `todo.updated` for each cleared to-do. A heading of a project in the trash is `validation_failed`.

**Scopes**: `containers:write`, `todos:write`

**Response**

- **200** [HeadingDeleted](/objects#headingdeleted). The heading is gone; these to-dos lost it.

**Errors**: [grant_expired](/errors/grant_expired) * [grant_revoked](/errors/grant_revoked) * [unauthorized](/errors/unauthorized) * [forbidden_scope](/errors/forbidden_scope) * [not_found](/errors/not_found) * [account_too_large](/errors/account_too_large) * [rate_limited](/errors/rate_limited) * [internal](/errors/internal)

## POST /v1/headings/{id}/convert

Make a heading a subproject

One batch: creates a subproject of the project with the heading’s name, moves every to-do under the heading into it (keeping their order, `headingId` cleared), then deletes the heading. ⚠ Only for a heading in a PROJECT: a subproject holds to-dos only, so a heading inside a subproject is `validation_failed`. Safe to retry when you pass no `id`: the subproject’s id is derived from the heading, so a retry after a failure part-way finds the subproject this heading’s convert made and finishes the move. It never files into any other subproject: an `id` that already exists is `duplicate_id`, and a derived subproject that was since trashed (or moved) is `conflict` (restore it, then convert again). With your own `id`, a retry after a part-way failure is `duplicate_id`, so omit `id` if you may retry. A heading of a project in the trash is `validation_failed`.

**Scopes**: `containers:write`, `todos:write`  
**Idempotent.** Send `Idempotency-Key` and a retry replays the first answer instead of writing twice.

**Request body**

| field | type | | notes |
|---|---|---|---|
| `id` | string (UPPERCASE UUID) | optional | max 64 chars |

**Response**

- **201** [HeadingConverted](/objects#headingconverted). The new subproject and the to-dos moved into it.

**Errors**: [grant_expired](/errors/grant_expired) * [grant_revoked](/errors/grant_revoked) * [unauthorized](/errors/unauthorized) * [forbidden_scope](/errors/forbidden_scope) * [not_found](/errors/not_found) * [conflict](/errors/conflict) * [duplicate_id](/errors/duplicate_id) * [payload_too_large](/errors/payload_too_large) * [unsupported_media_type](/errors/unsupported_media_type) * [account_too_large](/errors/account_too_large) * [validation_failed](/errors/validation_failed) * [rate_limited](/errors/rate_limited) * [internal](/errors/internal)

## POST /v1/headings/{id}/complete

Complete every open to-do under a heading

Completes each open, live to-do under the heading exactly as `/todos/{id}/complete` does (a recurring one rolls to its next occurrence and stays open). The heading stays. Send an `Idempotency-Key` if you may retry: a second run completes whatever is open by then, including a recurring to-do’s next occurrence. Pass `?today=` as for `/complete`. A heading of a project in the trash is `validation_failed`.

**Scopes**: `todos:write`  
**Idempotent.** Send `Idempotency-Key` and a retry replays the first answer instead of writing twice.

**Response**

- **200** [HeadingCompleted](/objects#headingcompleted). The heading and the to-dos completed.

**Errors**: [grant_expired](/errors/grant_expired) * [grant_revoked](/errors/grant_revoked) * [unauthorized](/errors/unauthorized) * [forbidden_scope](/errors/forbidden_scope) * [not_found](/errors/not_found) * [conflict](/errors/conflict) * [payload_too_large](/errors/payload_too_large) * [unsupported_media_type](/errors/unsupported_media_type) * [account_too_large](/errors/account_too_large) * [rate_limited](/errors/rate_limited) * [internal](/errors/internal)
